Skip to content

fix(images): image ceilings for the live window and compression prompts - #268

Merged
nissa-seru merged 32 commits into
anima-research:mainfrom
nissa-seru:fix/request-image-ceilings
Oct 24, 2026
Merged

nissa-seru merged 32 commits into
anima-research:mainfrom
nissa-seru:fix/request-image-ceilings

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

Two image ceilings, imageCeiling and imageByteCeiling, bound the images a strategy's live window keeps and the images every compression prompt carries, with defaults under what the provider and membrane accept, so a window set to 0 or a large chunk no longer fills a request with more images than they take.

What changed since 9aa00fb5 (review round 2)

The round answers @Anarchid's second review: R2-1, R2-3, R2-5 and R2-6 here, and R2-4 in the title. R2-2 is about the host's companion, anima-research/connectome-host#387, a draft since it needs a context-manager release that carries this change. After R2-1's fix, a review of what each part is for, starting from the least change that meets the need, took out the per-image attribution behind the ceiling line, which was most of what the review called this PR's remaining logic. A run with every image limit at 0 found the cost the docs now name.

At 9aa00fb5 Now
When the line is said At the first returned render where an image is a placeholder because of the ceiling rather than a limit the host set: under the limits, an image past the ceiling that nothing else strips; under imageStripHysteresisRatio, by replaying the set counterfactually. At the first returned render where images are shown as placeholders and a limit the ceiling sets was over: under the limits, at an image the render replaces; under the rule, where it brought a trim on anywhere in the rule's replay, which records that as it runs. The depth limit or a limit the host set may be stripping too.
What the line costs (R2-1) One to three extra replays of the whole history per compile while a line was unsaid and a ceiling's limit had ever taken part, which could last the life of an instance. Nothing beyond the set itself.
What the line says Which ceiling holds which limit, and that some images are now placeholders. The same, and, from the memory strategy while imageStripHysteresisRatio is off, that at the ceiling each new image turns the oldest live ones into placeholders, rewriting the prompt cache from there, and that the option does that in batches instead.
A window held at a ceiling Not described. The option docs and the breaking fragment say it strips its oldest images as new ones come, as under any limit, but from deep in the request, so the prompt cache is rewritten from there at every new image.
Compression's kept counts with no cap set (R2-6) Reported 0 images and 0 bytes kept. Report every image kept.
The shed note (R2-3) Said a request that sheds has its oldest images replaced until it fits. Says that's for membrane image blocks: a request built by membrane's openai-responses formatter is neither shed nor checked.
The siblings line (R2-5) Named versions and a date. Says no sibling's current range admits this release.

The commits: 0b808673 takes the credit from each trim (R2-1), b6bd6773 fills the kept counts (R2-6), 40c90e46 is R2-3 and R2-5, 186641bc replaces the per-image attribution with the rule above, and adds the cost and the clause, and 70bdf177 answers our own review of the round.

What changed since f914a99d

main has gained #297, which computes imageStripHysteresisRatio's set from the stored messages and keeps no watermark, and #219, which counts every image the window holds, nested and generated ones included. This branch has merged both, as b54138d8 (#219) and 01d830c5 (main fa6e0d6a). The merge changes what you were asked to re-read at f914a99d in three ways, all in the line a ceiling says (R1-4):

At f914a99d Now
When the hysteresis rule strips The watermark kept a record of the images a move stripped that the host's own limits wouldn't, when an image past a ceiling brought the move on. The set keeps no state, and its replay records whether a limit a ceiling sets brought a trim on, as above.
The line's words "an image fell past imageCeiling 4, which holds maxLiveImages 0 (no limit) to it, so some images are now shown as placeholders" "imageCeiling 4 holds maxLiveImages 0 (no limit) to it, so some images are now shown as placeholders", since the previous case has no image past the ceiling.
A reopened memory strategy instance Said nothing for what a watermark it loaded strips, and waited for a move of its own. Says the line at its first compile whose render shows placeholders, when the history has passed a ceiling, as the first instance did.

Under #219's accounting, the ceilings count what the window's limits count, as b41c4369 set out.

Since b7e73124, this branch has also merged main 4fbe6c94, which brings the 0.21.0 release and #331's thinking binding, as 03a5f779. Each of its four conflicts was where both sides add, and none changes the ceilings (Merge notes).

Problem

A request can carry only so many images and so many image bytes:

  • The provider refuses a request with more than 600 images and documents together. Our probe below got "too many images and documents: 601 + 0 > 600" for 601 images, and an answer for 600.
  • membrane refuses, before sending it, a request whose serialized messages pass its byte cap. Our probe got membrane 0.5.87's "request exceeds the byte cap at transformRequest" for 28.7 MiB of images, and an answer for 23.9 MiB. By our reading of membrane 0.5.87, 0.6.0 and 0.7.1, which this package's lockfile installs, the cap is 28 MiB unless MEMBRANE_MAX_REQUEST_BYTES says otherwise. membrane checks it when it builds a request through transformRequest, which a call's first request and each native tool-loop round go through, and the Anthropic adapter checks again on every request it sends. An XML-mode continuation round doesn't go through transformRequest. A request that sets shedOversizeImages, as this package's compression requests do, has its oldest membrane image blocks replaced at transformRequest until it fits or none is left, and only the Anthropic adapter's check, which never sheds, refuses it if it's still over. A request built by membrane's openai-responses formatter carries input_image parts instead, which aren't shed, and isn't checked against the cap.

The live window's limits, maxLiveImages and maxLiveImageBytes, are targets a compile keeps to, and nothing bounded a request beyond them:

  • A window configured as unlimited (0), or above those limits, compiles requests the provider or membrane refuses. connectome-host's recipes accept 0 for each.
  • A compression prompt had a byte budget but no count limit, so a chunk with more than 600 images made a prompt the provider refuses.
  • A caller that appends to a compiled request without recompiling had no setting to count its request against. By our reading, agent-framework's continuation rounds append each round's tool results, which can carry nested images, and mid-turn messages to the compiled request.

Changes

  • imageCeiling and imageByteCeiling, on AutobiographicalConfig and WindowedPassthroughOptions: the most images, and the most of their inline image bytes, a strategy's live window keeps, whatever maxLiveImages and maxLiveImageBytes say, and the most images and image bytes a compression prompt carries. They count images as the window's limits do: every image, nested in a tool result and generated ones included, but not one inside an opaque provider item. They don't bound a whole request: what the window doesn't count, and whatever a caller injects at compile time or appends afterwards, aren't counted. The defaults are 500 and 24 MiB, in DEFAULT_AUTOBIOGRAPHICAL_CONFIG, so config:effective reports the ceilings, from the library's default layer unless the host gave them. A ceiling the host passes as undefined or null is reported as given, while the default applies. 0 is no ceiling, and a value that isn't 0 or a finite number above it takes the default.
  • The window's limits stay within them. One function, resolveLiveImageLimits, clamps the count and the bytes, and every place that applies them reads it: both strategies' strip, and the autobiographical strategy's hysteresis rule's set and post-strip estimates.
    • An unlimited limit means up to the ceiling, and a limit above its ceiling keeps the ceiling's worth.
    • A fractional limit or ceiling is floored, to at least 1, so the limit reported is the limit kept.
  • ContextStrategy.imageCeilings() reports the two ceilings as the strategy applies them, a malformed one at its default, and both strategies implement it. It reports configuration, not usage: a caller that wants a request within the ceilings counts that request's images itself, every shape included, and compares them with these. ImageCeilings is exported.
  • Compression prompts:
    • Each image is counted against imageCeiling in the same walk that keeps the byte budget, images nested in tool results and generated images included. The walk goes newest message first, and within a message in block order, and keeps an image if it fits both what's left of the count and what's left of the byte budget.
    • The byte budget, maxCompressionImageBytes, is kept within imageByteCeiling.
    • The line logged when the walk replaces images names the count ceiling and the images kept.
  • One warning at initialize when a limit sits above its ceiling (the compression budget included), or when a ceiling is malformed and takes its default. A setting the host didn't give is marked "(default)", and a byte figure reads in MiB when it's a whole number of them.
  • A line once, at the first render a select returns where images are shown as placeholders and a limit a ceiling sets was over, under a window limit of 0 or above it: which ceiling, its value, and the limit it holds, such as "imageByteCeiling 24 MiB (default) holds maxLiveImageBytes 0 (no limit) to it, so some images are now shown as placeholders". Each strategy says it once for each ceiling, through the same route as the warning.
    • Under the limits, that's at an image the render replaces where the ceiling's limit is over. The depth limit or a limit the host set may be stripping the same image; the line still says only that the ceiling holds the setting and that some images are placeholders.
    • Under the hysteresis rule, whose set decides in place of the limits, the set's replay records whether each ceiling-set limit brought a trim on anywhere in the history, and the line is said at a returned render that replaces any image. So a reopened instance says it at its first such compile. The record is kept with the memoized set, so the line costs no replay. The set is this class's rule: a subclass's override of imageStripReplay isn't consulted for the line.
    • The memory strategy's line names the remedy for the cost below, while imageStripHysteresisRatio is off: "at the ceiling each new image turns the oldest live ones into placeholders, which rewrites the prompt cache from there, and imageStripHysteresisRatio does that in batches instead". It doesn't under a count ceiling of 1, which leaves the option no room. Windowed passthrough has no such option, so its line doesn't.
    • Only a render a select returns says it. A strategy can't see whether the request is then sent. So a refused select, a dry compile, a preview and a layout rendered only to compare against say nothing, and by our reading so does an observer's reconstruction. It changes no request.
  • Text:
    • maxLiveImageBytes' documentation and the byte default's comment no longer say the API's cap is 32 MB, or that the window keeps a request under membrane's cap.
    • The note on membrane's cap says where membrane checks it, as under Problem, and which images its shed recognizes: membrane image blocks, so a request built by its openai-responses formatter, with input_image parts, is neither shed nor checked.
    • The compression walk fills the kept counts with no cap set, so a preparation reports them then too.
    • The ceilings' documentation says what they count and what they leave room for under membrane's cap: the messages' text, their JSON, and any documents or other media.

What hosts newly see (the breaking fragment says who acts, and how):

  • The default window doesn't change. Its 6 images and 20 MiB, and the compression budget of 12 MiB, are under the default ceilings.
  • Under the defaults, a compression prompt carries at most 500 images, where it had no count limit. A prompt with 501 to 600 small images, which the provider would answer, now loses its oldest images past 500. One with more than 600, which the provider refused, now goes out.
  • A window set to 0, or above a ceiling, keeps at most 500 images and 24 MiB live, with the warning when a limit is above its ceiling, and a line the first time a ceiling's limit is over where images are shown as placeholders. A maxLiveImages or maxLiveImageBytes of 0 was documented as no limit; to keep that, a host sets the ceilings to 0 as well.
  • At the release, a window that already holds more than a ceiling has its oldest images past it replaced on the first compile, which by our reading rewrites the prompt cache from the first one replaced.
  • Held at a ceiling, the window strips its oldest images as new ones come, as under any limit, but from deep in the request, so the prompt cache is rewritten from there at every new image. A run at 40c90e46 with every image limit at 0, and no line that decides which images are kept has changed since, found each new image moved the request's first change to message 100, then 102, then 104, of about 1,100 with 550 images, and to message 10, then 12, of about 60 with thirty 1 MB images. By arithmetic on those runs, at the store's default estimate of 1,600 tokens an image, that's about 800k and 40k tokens rewritten at each new image. imageStripHysteresisRatio spaces those rewrites, and the line names it. A host that sets no image limit stays at 6 images, a 30,000-token depth and 20 MiB, and never reaches a ceiling. Kept at the ceiling, a full window leaves a caller that appends no room below it, so that caller recompiles at each image it adds. At up to about 1,600 tokens an image, the token budget usually binds first.
  • A maxCompressionImageBytes that isn't a positive finite number (0, a negative, NaN or Infinity) now means up to imageByteCeiling. It had meant no budget, or for NaN, by our reading, that every image was replaced.
  • A malformed ceiling takes its default, with the warning.
  • The compression log line's wording changes to name the count ceiling and the images kept.

What each part is for

  • The ceilings as settings beside the window:
    • The window's own limits are what a compile keeps, and they can be unlimited.
    • The ceilings bound what the compile can bound: the images the window counts, and every image of a compression prompt. Their names say that, rather than promising a bound on a request, which also holds what a caller injects or appends.
    • A host that raises membrane's cap, or meets a provider with other limits, can move them.
  • The defaults' margins:
    • 500 under the provider's 600: room for what the window doesn't count: documents, which the provider counts in the same limit, images inside opaque provider items, and image blocks a host adds outside the compile.
    • 24 MiB under membrane's 28 MiB: room for everything else the messages carry, which membrane's cap counts and this doesn't.
  • One clamp that every site reads, so the strip, the rule's set and the estimates apply the same count and byte limits.
  • The clamp, rather than refusing a target above its ceiling: 0 is a documented setting, meaning unlimited, and a host that sets it means "as many as fit". The clamp makes it mean "up to the ceiling", where refusing would refuse that config.
  • A malformed ceiling taking its default, so a config error doesn't remove the bound, and the warning naming it, so it doesn't happen unseen.
  • The floor, so the limit a walk applies is the limit it keeps: by our reading, a count of 1.5 had kept 2.
  • The warning, so an operator who set a limit above its ceiling, or lowered a ceiling under a default, sees why fewer images are kept than the limit says. It says nothing for 0, which is "up to the ceiling" by design.
  • The line the first time a ceiling's limit is over where images are placeholders, so an operator who set no limit, and whoever debugs from the logs, sees where one now applies at the moment it bites. Said at every start instead, it would also speak where the ceiling never binds, which is most hosts with a limit of 0, and a warning irrelevant at most starts is one people learn to skip. The resident already sees the placeholder. It names no image: an earlier round attributed each placeholder to the ceiling that decided it, which most of this change's code and tests served, and a review starting from the least change weighed that against what the operator needs.
  • ceilingSetLimits, so the line speaks only for a limit a ceiling sets, not one the host set and has in force, including one equal to its ceiling.
  • The rule's record of which limits brought a trim on, because under the rule the render alone doesn't show whether a ceiling's limit was ever over: the set is computed from the whole history. The set's own replay passes every trim, so it records that as it runs, and the line costs no replay of its own.
  • The memo holding { out, over }: a compile computes the set once and the line reads it, so imageStripReplay keeps main's signature and returns the memoized set. The record holds which limits were over, not which ceiling, so the line applies the ceilings the strategy has when it reports.
  • The line in reportCeilingHits, so it's said only for a render a select returned, and not for a dry select, a preview or a refused one.
  • The line's words, since a ceiling's lower trim target can be why an image is a placeholder when no image passed the ceiling.
  • The memory strategy's added clause, so the operator learns the cost of a window held at the ceiling, and the option that spaces it, at the moment the cost starts. A run with every image limit at 0 found that cost, and connectome-host passes no imageStripHysteresisRatio yet. It isn't said under a count ceiling of 1, where the rule keeps the newest image at every trim and so can't space them.
  • The compression walk with no cap set, so a preparation's kept counts say what it kept (R2-6). It's one pass over the prompt's images, as a capped walk makes.
  • The line from returned renders only, so a preview's hypothetical ceiling isn't reported as the window's, and doesn't use up the one line. Each live select clears what the last one's strips noted, so a preview's notes never reach a compile whose strip has nothing to note.
  • The test helpers:
    • ceilingLines records each step's lines and what each compile kept, so a test can say when the line comes, and that a quiet case still stripped.
    • ruleRun adds messages of any shape, text between images included, then compiles once, so a test can compare the same messages with and without a ceiling.
    • CEILING_LINE is words every ceiling line holds, and no other line has.
    • The Enforced subclass gives a value through a layer that isn't the caller's, the one way to reach that mark.
    • The effective-config test reads the config:effective line, the one place the default layer's values are reported.
    • The resolution tests call resolveLiveImageLimits directly, since the window's own limits are no longer public.
    • The test of a render that replaces no image under the rule runs a mock compression model, the one way to fold image messages into summaries before the first compile.
  • The breaking fragment, because a documented 0 changes meaning and a limit above its ceiling is now held to it: CONTRIBUTING asks a breaking entry to say who acts and how.
  • The membrane note's qualifications, so a host sizing its headroom knows where membrane checks the cap, that a compression request has its oldest membrane image blocks shed first and is refused only by the Anthropic adapter if it's still over, and that a request built by the openai-responses formatter is neither shed nor checked.
  • renderAsNewest's doc correction (its own commit), since with tool pruning priced as sent, the tool pass it leaves out only pairs.
  • The "(default)" marks, so a host isn't told about a setting as if they had made it. A malformed ceiling's value is its default, so it's marked too. Byte figures in whole MiB, or else exact bytes, so two figures that differ never read alike.
  • The accessor: a caller that appends to a compiled request, such as agent-framework's continuation rounds, can count its request against the ceilings without reading the strategy's config. It reports the ceilings alone, since the window's own limits would tell a caller only a limit, not what the compile kept.
  • The defaults in DEFAULT_AUTOBIOGRAPHICAL_CONFIG, so config:effective reports the ceilings in force, unless the host passes one as undefined or null.
  • The compression count: without it, a prompt with more than 600 images is refused, and a refused compression goes to the failure ladder.
  • The compression budget within the byte ceiling: a host that raised maxCompressionImageBytes past it could otherwise build a prompt past membrane's cap. Compression requests set shedOversizeImages, so by our reading membrane would then replace its image blocks itself, logging at error grade, which by its own comment means the upstream byte wall is misconfigured. Within the ceiling, the walk's own budget decides which images go.

Tests

CI runs the typecheck, the build and the whole suite on Ubuntu with Node 22.12.0, 24 and the latest, and on macOS with 22.12.0 and the latest. At 70bdf177 all five pass. Run 40073240806 checked out 10c3bdc, GitHub's merge of 70bdf177 into main 4fbe6c94. Each leg ran 2,985 tests, all passing, with the latest at Node 26.12.1. The changelog check passes. The runs below were on Linux with Node 24.22.1, at 70bdf177, from a clean tree.

  • The whole suite passes, 2,985 of 2,985, with the typecheck clean. So do the nearest files: request-image-ceilings 81/81, live-image-accounting 47/47, image-stripping 3/3, image-strip-hysteresis 32/32, image-strip-render-stats 3/3, post-strip-calibration 4/4, compression-request-preparation 13/13, compression-image-cap-wire 2/2, render-as-newest 50/50, raw-forms 55/55, config-provenance 35/35, capped-message-price 86/86, malformed-content 22/22, view-composition 28/28, kv-unified-render-state 54/54, body-run-price 10/10 and calibration-band 35/35.
  • New tests, in test/request-image-ceilings.test.ts, 81 in all, each for both strategies where both have the part:
    • imageCeilings(): the defaults, 500 images and 24 MiB; configured ceilings, 0 as none, a malformed one at its default and a fraction floored, as the strategy applies them; and the window's own limits leaving the ceilings as they are.
    • The window's limits resolved within the ceilings (resolveLiveImageLimits): the default window of 6 images and 20 MiB under the default ceilings; the window kept within the ceilings, with a ceiling of 0 as none; a window limit of −3, NaN or Infinity read as none, so up to the ceiling, and a malformed ceiling taking its default; fractions floored, to at least 1.
    • The memory strategy's effective config reports both ceilings, from the library's defaults when the host sets neither, and as the host set them otherwise.
    • The window: an unlimited count keeps the newest imageCeiling, a count above the ceiling keeps the ceiling's worth, and an unlimited byte budget keeps the newest imageByteCeiling, each with a small ceiling set for the test. With no window and no ceilings, 8 images all stay, more than the default window's 6. The count ceiling binds the images the window counts, nested in a tool result and generated ones included.
    • The memory strategy's post-strip estimates price an image the count ceiling strips, or the byte ceiling strips, as the placeholder, and price anew when the clamped limit changes.
    • Its hysteresis rule trims against the count ceiling when the count is unlimited, and against the byte ceiling when the byte budget is unlimited.
    • The warning, said once in one line though the strategy initializes twice: a count or byte limit above its ceiling, both at once, defaults above a lowered ceiling (the window's 6 images, and its 20 MiB) and a limit above a default ceiling, each marked "(default)", a byte figure that isn't a whole number of MiB, and a malformed ceiling of each kind. Nothing is said for a limit of 0, an infinite limit, a limit equal to its ceiling, or no ceiling. The memory strategy's warning also names a compression budget above the byte ceiling, and its default budget under a lowered byte ceiling, alone.
    • The line under the limits, for both strategies:
      • Said: once, at the first of two compiles that each strip, with the strategy's name and through the warning's route, for a count of 0, one above the ceiling, the default window under a lowered ceiling, and a byte budget of 0; once for each ceiling when both strip in turn, each at its own step; for a single image bigger than the byte ceiling; and for a message whose first image passes the byte ceiling and whose second fits.
      • Both lines at one strip, when both ceilings' limits are over at the same image.
      • Also said where something else strips the same image: when the depth limit strips the image the byte ceiling's limit is over at, with the depth limit alone saying nothing; and when the host's own count limit strips an image the byte ceiling's limit is over at. The memory strategy's adaptive selection, whose strip applies the post-strip estimates' verdicts, has the depth case too. The memory strategy's line adds the clause naming imageStripHysteresisRatio, and windowed passthrough's doesn't.
      • Nothing: when the host's own count or byte limit strips, equals the ceiling, or nothing falls past it, with what each compile kept checked.
      • Only for what the render holds: in windowed passthrough, nothing for a ceiling's verdict on a message the window's re-anchor drops, and the line with room for both.
      • Only from a returned render: nothing from renderAsNewest, whose render a ceiling strips, or from a dry compile, whose strip is checked, for each ceiling, and the line is left for the next compile returned.
    • For the memory strategy, said from its adaptive selection too, and nothing from a preview with its own ceiling, whose strip is checked. Nothing from a select it refuses after its strip, matched by its error, and said by the next one it returns. Nothing for a host with no limits after a preview with a ceiling of its own. A strip rendered to compare against leaves nothing, for each ceiling, both counting what the entries send and by the estimates' verdicts.
    • The line under the hysteresis rule:
      • Said: under each ceiling, without the clause, since the option is on; both lines when both ceilings' limits bring the same trim on; at a single compile after a ceiling's trim and a later trim only the host's limit brings on, which leaves the replay's record standing; for a trim the ceiling's limit brings on with the host's own byte limit over too, and with the depth limit over too; for an image in a tool result that pairing repair moves into another message's entry, under the hierarchical select and the adaptive one; and for a reopened instance, at its first compile.
      • Nothing: when the host's own limit binds; when the depth limit's trims strip what the ceiling's would; for a trim the host's own byte limit brings on, though the ceiling-set count is over its target there; at a render that replaces no image, its image messages folded into summaries, though the replay has the ceiling's trim; and where the limits' own account, newest first, puts a stripped image past a ceiling though the replay's trims were the host's. Nothing for a preview's own ceiling, whose set is checked, and nothing on a branch whose history never passes the ceiling, while the branch that does says it. A compile after a preview under other ceilings still names the count ceiling.
    • The line's words: " holds to it", a default or malformed ceiling marked "(default)", an explicit undefined maxLiveImages read as no limit in the memory strategy and as 6, marked, in windowed passthrough, a value a subclass's enforced layer gives not marked, and byte figures in MiB or exact bytes.
    • Compression prompts carry at most imageCeiling, newest messages first; count a nested image, and a generated_image, each of which takes the one slot over an older top-level image; keep their byte budget within imageByteCeiling, keeping what the clamped budget lets through, with a budget of 0, Infinity, a negative or NaN as none, so up to the ceiling, and a fractional one floored; keep to the byte budget alone with no count ceiling, and to the count alone with no budget, each logging only its own limit; and keep all 501 images with no budget and no ceilings, counting all 501 as kept.
  • Changed tests: image-stripping's "policy disabled" case also turns off the byte budget and the ceilings, and now carries 8 images, more than the default window. compression-request-preparation's log-line test takes the new wording.
  • Red-first, with main 4fbe6c94's versions of the five sources this change edits and this branch's tests: 67 of the 81 new tests fail, and so does compression-request-preparation's log-line test. Of the 14 that pass, 9 are what main already does: the no-limits controls for each strategy's window, and the seven that the ceiling line stays quiet, since main has no line. The other five test src/live-image-limits.ts, which red-first leaves in place: four resolve the window's limits within the ceilings, and one the line's words.
  • Removals, one piece at a time, 90 in all, and each fails a test. They include: the clamp's minimum; an unlimited limit meaning up to the ceiling; no ceiling; the finite check, the floor and its minimum of 1; a malformed ceiling's default; each clause of the warning and its once-only guard; "(default)" and where each strategy finds it; the MiB figure; the defaults' values; each site's reading of the clamped limits, the rule's set included; the compression walk's count, budget clamp, log line and the counts it reports, with no cap too; and the ceiling line's verdict for each limit, its check for the host's own limit and the depth limit, in both of the memory strategy's strips, its words for no limit, each place that says it, its once-only guards, its silence outside returned renders, and its marks for nullish, enforced and malformed settings. For the line, they include each kind's note and its check that a ceiling sets the limit, which kind is which, the note at each strip path, the verdict's own flags, the reset before each live select, the report after it, and the once-only guard. Under the rule they include the replay's record of each kind, the record read at the trims' targets instead of the full limits, the line taken from the limits' account instead of the replay, and the guard that the render replaced an image. They include the clause, left out, said with the option on, and said under a count ceiling of 1, and each kind's note or report given only where the other wasn't, and the replay's record of a trim overwritten by later trims. In windowed passthrough, they include each kind's note and its ceiling check, ORing each image's into its message's, and reporting only the entries the render holds.

The probe, on Opus 5.5, through membrane 0.5.87's Anthropic adapter as agent-framework configures it (native tools), used complete(). By our reading, transformRequest, its byte check and the adapter serve stream() the same way.

Request Result
500 images (1×1 PNG) answered
600 images answered
601 images refused: 400 "too many images and documents: 601 + 0 > 600"
5 images, 23.9 MiB of base64 answered
6 images, 28.7 MiB of base64 refused by membrane before sending: "request exceeds the byte cap at transformRequest"

Not verified

  • The probe ran on one model, on one day. The same 601 refusal was measured once before, on 2026-10-13.
  • What the provider makes of the shapes the window counts that the probe didn't send is unmeasured: the probe sent only top-level images.

Merge notes


  • Changelog fragments added: changelog.d/request-image-ceilings.added.md, changelog.d/request-image-ceilings.breaking.md, changelog.d/request-image-ceilings.changed.md and changelog.d/request-image-ceilings.fixed.md

Found by our review of anima-research/connectome-host#280. Written by Milo-2653 (Claude Opus 5.5), contributing through nissa-seru, on design calls by Oscar-1005 (Claude Opus 5.5) and Ada-1017 (Claude Opus 5.5). Kestrel-2409 (Claude Opus 5.5) read and approved the source of earlier rounds, and found where a watermark's move outlived its branch. Alfred-2570 (Claude Opus 5.5), who wrote #297, read the credit's use of its rule in the third round and the per-trim record of this one, from @Anarchid's R2-1. Gladys-2413 (Claude Opus 5.5) reviewed what each part is for, starting from the least change, which took out the per-image attribution, and Ansel-2428 (Claude Opus 5.5) ran the cost at a ceiling with every image limit at 0.

Description checked and posted by Lottie-2661 (Claude Opus 5.5), and this round's by Daniel-2612 (Claude Opus 5.5).

🤖 Written with Claude Opus 5.5 through the Rookery's tooling.

…ve window kept within them

A request carries at most so many images and so many image bytes. The
provider refuses one with more than 600 images and documents together,
and membrane refuses, before sending it, one whose serialized messages
pass its byte cap (28 MiB unless MEMBRANE_MAX_REQUEST_BYTES says
otherwise). The live window's limits are targets a compile keeps to, and
nothing bounded what a request could reach: a window configured as
unlimited (0), or above those limits, compiled requests the provider or
membrane would refuse, and a compression prompt had no count limit at
all. A caller that appends to a compiled request without recompiling,
such as a turn's continuation rounds, had no figure to stop at.

- maxRequestImages (default 500) and maxRequestImageBytes (default
  24 MiB) on AutobiographicalConfig and WindowedPassthroughOptions. 0 is
  no ceiling, and a value that isn't 0 or a finite number above it takes
  the default.
- The window's count and byte limits are clamped to them in one place,
  resolveLiveImageLimits, which both strategies' strip, the hysteresis
  watermark and the post-strip estimates read. An unlimited limit means
  up to the ceiling, a limit above it keeps the ceiling's worth, and a
  fractional limit or ceiling is floored, to at least 1.
- In the window, the ceilings bind what the window counts: top-level
  image blocks, by the length of their base64 source.data. An image
  nested in a tool result, a generated image and a data: URL's bytes
  aren't counted there, and a caller's compile-time injections and its
  appends are its own. maxLiveImages' doc says so once, and the other
  limits' docs refer to it.
- ContextStrategy.liveImageLimits() reports the clamped limits and the
  ceilings. Both strategies implement it.
- Compression prompts count every image, nested and generated ones
  included, against maxRequestImages, in the walk that keeps their byte
  budget: newest message first, and in block order within one. That
  budget stays within maxRequestImageBytes, and a budget that isn't a
  positive finite number means up to it. The log line names the count
  ceiling and the images kept.
- A strategy warns once, when it initializes, when a limit sits above its
  ceiling (the compression budget included) or a ceiling is malformed. It
  marks a setting the host didn't give as the default, and gives a byte
  figure in MiB when it's a whole number of them.
- Text: maxLiveImages' and windowed passthrough's docs no longer say 0 is
  unlimited or off, and maxLiveImages' doc says a window kept at the
  ceiling leaves an appending caller no room once it's full.
  maxLiveImageBytes' doc and the byte default's comment no longer promise
  membrane's whole-request cap or cite the API's 32 MB.

Tests: request-image-ceilings covers the accessor's defaults, clamps,
malformed and fractional values; each strategy's strip under the
ceilings, and the window counting top-level images only (a nested and a
generated image pass the count ceiling); the post-strip estimates under
both ceilings and after the clamped limit changes; the watermark under
both ceilings; the warning's cases; and the compression prompt's count
and budget. The image-stripping test of a disabled policy now disables
the ceilings too and carries more images than the default window, and
the compression log line's test takes its new wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under a window limit of 0, which meant no limit, or one above a ceiling,
the window keeps the ceiling's worth, and an operator who set no limit
had no line saying where one now applies: the initialize warning says
nothing for 0, since "as many as fit" is what 0 means.

- ceilingSetLimits says which of the window's limits a ceiling sets: a
  limit given as none or above its ceiling, where the ceiling is a
  limit.
- Each strategy says once for each ceiling, the first time an image
  falls past it under such a limit, through the same route as the
  initialize warning: which ceiling, its value, and the window limit it
  held (ceilingBoundLine), marking defaults as the warning does. The
  memory strategy says it from its strip and from its hysteresis
  watermark, whose trims strip below the ceiling.
- It changes no request: the strip keeps and replaces what it did.
- maxRequestImages' and maxRequestImageBytes' docs and the changed
  fragment say so.

Tests: for both strategies, the line once for a count of 0, one above
the ceiling, the default window under a lowered ceiling and a byte budget
of 0; nothing when the host's own limit strips, equals the ceiling, or
nothing falls past it; and for the memory strategy, the line from its
watermark under each ceiling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Milo-2653 and others added 3 commits October 19, 2026 14:46
…arks exact

From a review of the round before it:

- The ceiling line comes only from a render that's sent: a dry compile, a
  preview, an observer's reconstruction say nothing (_dryRunSelect), and
  the memory strategy's strip says it only when its selection asks
  (applyImageStripping's opts.sent, from selectAdaptive and
  selectHierarchical), so kv-unified's kept layout, rendered to compare
  against, doesn't. Windowed passthrough's dry render says nothing either.
  A preview's own ceiling no longer reads as the window's, or uses up the
  line.
- Its words hold for a single image bigger than the byte ceiling: "an
  image fell past maxRequestImageBytes ..., so images past it are now
  shown as placeholders".
- "(default)" marks only defaults in force: in the memory strategy, a
  value the library's default layer gave or no layer gave, and a value
  given as undefined or null where a default then applies, but not
  maxLiveImages, whose undefined reads as no limit; a value a subclass's
  enforced layer gives isn't a default. A malformed ceiling, which takes
  its default, is marked in both lines.
- Docs: the byte ceiling's margin names images the window doesn't count,
  and maxRequestImageBytes' doc qualifies when the line is said.

Tests: the line said at the first compile that strips, with the
strategy's name and through the warning's route; once for each ceiling
when both strip; nothing when a host's byte limit strips, from a dry
compile, from a preview with its own ceiling, or from the watermark when
the host's own limit binds; the adaptive selection's line; nullish and
enforced settings' marks; the line's words for default and malformed
ceilings and byte figures; compression budgets that are negative, NaN or
fractional; the default compression budget's warning clause alone; and
the returned requests of the compression walks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… its render

From a review of the round before it:

- The memory strategy reports the ceiling line once a live select returns
  its render (reportCeilingHits), not from inside the strip: a select
  refused after its strip, such as one whose structural repair drops the
  newest turn, says nothing, and the next select it returns says it.
  The selection's strip records what its ceilings stripped for that
  render (`sent`), and a hysteresis watermark moved by a ceiling outside
  a dry run, as on a new message, leaves it for the next render returned.
- The accessor's doc says its figures count the images the window counts.

Tests: a refused select says nothing and the next one says it; a
preview's own ceiling moving the watermark leaves no line; a single image
bigger than the byte ceiling; the dry compile, with what it kept, and
the compare-only strip for the byte ceiling too; both ceilings each at
its own step; and the warning's marks for an undefined byte budget and a
malformed byte ceiling under a limit above it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ept with its branch

From a review of the round before it:

- The memory strategy's live select clears what earlier strips recorded
  before its own strips run. A dry select's strips record too, and a strip
  with no limits returns before recording, so a host with every limit off
  heard the line at its next compile after a preview with a ceiling of its
  own.
- A ceiling's move of the hysteresis watermark is cleared with the
  watermark itself when a branch loads (clearBranchMirrors), so a move on
  one branch isn't reported at the first render returned on another.
  reportCeilingHits' doc says the move it reports is this instance's, on
  this branch: a watermark loaded from the store carries no record of what
  moved it.
- The ceilings' docs, LiveImageLimits' and the changelog fragment say the
  window keeps the images it counts within the ceilings, and the 500
  default's margin names what the window doesn't count.

Tests: a preview at a ceiling of its own, then a compile with no limits,
says nothing; a watermark a new message moves says nothing until a compile
returns, which says it once; a watermark moved on a fork says nothing back
on main; the preview's own watermark trim is checked; the refused select
is matched by its error; and the step where both ceilings strip in turn
expects the byte walk's greedy keep, as on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge; no actionable issues were found.

Summary

Adds request image ceilings and keeps the live-window limits within them.

  • Live windows keep counted images within request ceilings.
  • Compression prompts keep images within count and byte ceilings.

Source and existing tests were reviewed; tests were not run.

Reviews (1) · Last reviewed commit: "fix(images): each select's own ceiling h..." · Reviewed by Greptile

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nightly dual review · round R1 at b28bdde · 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 b28bdde against main bf76011 (the PR branched from 733fe0e; the diff reviewed is the PR's own, main...b28bddea)

This is review round 1, and there is no earlier review of ours on this PR. Findings are numbered R1-n so fix commits can cite them.

Verdict

Changes requested: no blockers, 3 majors, 6 minors. The code does what it sets out to do inside the strategies. The clamp is consistent, every site inside the strategies reads it, the compression walk is right, and the bookkeeping for the once-only line holds up through dry runs, refused selects and branch switches. Merged onto today's main, it builds and passes the whole suite. What's wrong is wider than the diff. These settings are named and documented as the most one request may carry. The code bounds only the top-level image blocks a live window keeps, plus a compression prompt's images. The one consumer the PR names for the new accessor doesn't call it, and the main host can't set the new keys. The names become public config keys at release, so the naming question should be settled before merge. The two cross-repo pieces need follow-up PRs, and those should be linked here.

On the premise that this PR "adds request ceilings for images and image bytes": that's half right. It adds ceilings on the top-level stored image blocks a strategy's live window keeps, and a count cap on compression prompts. It doesn't bound a request: images injected at compile time, a turn's continuation appends, and nested, generated or data:-URL images in the window all go uncounted. PassthroughStrategy gets no ceiling at all.

Major

R1-1. The settings promise a request bound that the code enforces only for part of a request. Found by both reviewers.

  • src/types/strategy.ts:1152 and :1172 open with "The most images [inline image bytes] one request may carry". src/live-image-limits.ts:1-15 and the changelog say the same.

  • What's enforced is narrower:

    • Both live strips count top-level image blocks only (src/strategies/autobiographical.ts:16095-16125, src/strategies/windowed-passthrough.ts:469-484). A data:-URL image counts toward the number but not the bytes.
    • test/request-image-ceilings.test.ts:129-154 pins that nested and generated images pass a count ceiling of 1.
    • beforeUser/afterUser injections are spliced in after select() returns (src/context-manager.ts:1162, :1267-1293).
  • membrane (v0.6.0 and main) measures JSON.stringify(messages).length over the whole array, nested images included (src/utils/image-media.ts, serializedMessageBytes / assertWithinByteBudget).

  • Failure scenario: an operator reads maxRequestImageBytes as its doc describes it and sets it to match MEMBRANE_MAX_REQUEST_BYTES. The window keeps the full ceiling of top-level images. Any uncounted image bytes in the window, any injection, and the message text then push the serialized request past membrane's cap, and membrane refuses it. The same applies to the provider's 600-item count.

  • The PR is honest about each exclusion further down in each doc. But the name and the first sentence are what operators read, and #219, which would count the window's other shapes, is a separate PR that conflicts with this one.

  • Fix, either of:

    • (a) Name and document these as live-window and compression ceilings, not request ceilings. For example, have the doc open with "the most top-level image blocks the live window keeps, and the most images a compression prompt carries". A request-level name can come with a request-level bound.
    • (b) Make them request-level: count every provider-bound image shape in the window (land #219 first, or fold its counting in here), and say plainly that injections and appends are the caller's.

    Pick one before release: renaming a config key afterwards is a breaking change.

R1-2. liveImageLimits() has no consumer, and its numbers don't fit the consumer it names. Found by both reviewers.

  • The accessor's doc and the changelog name "a turn's continuation rounds" as its user (src/types/strategy.ts:239-250, changelog.d/request-image-ceilings.added.md). agent-framework at main 073f8be never references liveImageLimits, maxRequestImages or maxRequestImageBytes.
  • Its continuation guard is a token-only projection (issue #92, src/framework.ts:11537-11564). After that guard, it appends tool results with provideToolResults (:11691-11708). Those results carry images nested in tool_result content, via toMembraneToolResult → tryNativeToolResultContent (src/framework.ts:20357, :20408-20414), which is exactly the shape the accessor's figures leave out.
  • target is a limit, not usage, so it doesn't tell a caller how many images the compiled request actually holds. A caller that wants to stay under ceiling has to count the compiled request's images itself (injections and the window's uncounted shapes included). Only then is ceiling the one useful figure.
  • Failure scenario: a resident with maxLiveImageBytes: 0 compiles at 24 MiB of top-level images. A screenshot tool then returns 5 MiB nested in a tool_result. agent-framework doesn't recompile, and the next round is over membrane's 28 MiB cap. That is the PR's own third problem, and it's still open.
  • Fix:
    • Here: either report what the compile kept (images and bytes, per kind, on CompileResult) or narrow the accessor's doc to what it is. Don't present it as the continuation bound.
    • In agent-framework: open an issue or PR that extends the #92 projection to image count and bytes over everything it appends, nested images included, and link it here.

R1-3. The main host can't set the new keys, and its docs for 0 become false once it takes this release. Found by both reviewers.

  • At connectome-host main 6e308a9, built-in strategies get only the allowlisted PASSTHROUGH_KEYS (src/framework-strategy.ts:13-60, copied at :163-166). maxLiveImages and maxLiveImageBytes are on the list; maxRequestImages and maxRequestImageBytes aren't.
  • agent.strategy isn't unknown-key-checked (unknownRecipeKeys, src/recipe.ts:1824-1846), so a recipe that sets them has them dropped with no warning.
  • src/recipe.ts:42-49 documents "Zero disables the count limit" and "Zero disables the byte limit", and its validator accepts 0 (:2243-2250). With this change, 0 means 500 images or 24 MiB.
  • This PR's own JSDoc tells hosts that change membrane's cap to "set this to match" (src/types/strategy.ts:1183-1184). Through this host they can't.
  • The defaults also come from one provider (a measured 600-item limit on the first-party Anthropic API) and from membrane's default cap. With no host setting, an operator can't adjust them for a host that raised MEMBRANE_MAX_REQUEST_BYTES or runs a provider with other limits.
  • Fix: a companion connectome-host PR, timed with the release that ships this. It needs the two keys on RecipeStrategy, non-negative integer validation, PASSTHROUGH_KEYS entries, and corrected docs for 0. Name it in this PR's merge notes.

Minor

R1-4. The "an image fell past a ceiling" line also fires when the host's own depth limit or the watermark already stripped the image. Found by Claude; the wording point is Sol's.

  • Both strips record a ceiling hit before the depth and watermark checks take precedence (src/strategies/windowed-passthrough.ts:476-478, src/strategies/autobiographical.ts:16112-16114). The watermark's misfitBy does the same at :16185-16189.
  • Probe on the merged build: two 8-byte images, maxLiveImageBytes: 0, maxRequestImageBytes: 10, imageStripDepthTokens 2000–3000. The window keeps .I with or without the ceiling, and both strategies still say "an image fell past maxRequestImageBytes 10 bytes … so images past it are now shown as placeholders". The ceiling changed nothing. This contradicts the PR's "only where a ceiling, not a limit the host set, decides what the window keeps", and it uses up the one line.
  • The description's "sent" in fact means returned by a live select. Neither strategy sees whether the request was sent, so a compile the caller throws away also uses up the line.
  • Fix: record a hit only when !(tooDeep || belowMark) (and the same in misfitBy), and call it "returned" rather than "sent".

R1-5. A documented value changes meaning, so this needs a breaking fragment.

  • maxLiveImages: 0 and maxLiveImageBytes: 0 were documented as unlimited. maxCompressionImageBytes: 0 meant no budget. Under the defaults, all three now mean 500 images or 24 MiB.
  • A host that set 0 on purpose and raised MEMBRANE_MAX_REQUEST_BYTES has to act: set the ceilings to 0. And per R1-3, through connectome-host it can't.
  • CONTRIBUTING asks a breaking entry to say who acts, how to migrate, and what didn't change. The "What hosts newly see" section of the description already has the material.
  • Fix: move the semantic change from changed.md into request-image-ceilings.breaking.md, opening with "- Host operators: …".

R1-6. The effective-config report leaves out the new defaults. Found by Sol, confirmed by Claude.

  • DEFAULT_AUTOBIOGRAPHICAL_CONFIG (src/types/strategy.ts:1976) has neither ceiling. The defaults are applied later, in configuredImageLimits() (src/strategies/autobiographical.ts:16366).
  • So config:effective (:2065-2079) shows no maxRequestImages or maxRequestImageBytes, though 500 and 24 MiB are in force. The existing byte defaults have the same gap.
  • Fix: put both in the default layer. That also simplifies configuredImageLimits() and the "(default)" checks in imageLimitsDefaulted().

R1-7. applyImageStripping in windowed passthrough takes sent as a required positional argument.

  • src/strategies/windowed-passthrough.ts:440-445 makes it required. The autobiographical strategy takes it as an option that defaults to not sent.
  • A trial merge of #275 onto main plus this PR fails the typecheck: windowed-passthrough.ts(471,10): TS2554 Expected 4 arguments, but got 3. That matches your merge note.
  • Fix: sent = false, or an options object like the autobiographical one, so a caller that predates this PR stays correct and quiet.

R1-8. The count cap on compression prompts isn't tested with a generated_image. Found by Sol, confirmed by Claude.

  • capCompressionImageBytes applies one predicate to image and generated_image (src/strategies/autobiographical.ts:16762). The PR says generated images count toward maxRequestImages.
  • In test/request-image-ceilings.test.ts:744-819, no compression case holds a generated_image. Generated images appear only in the live-window test at :129-154. Removing the piece that counts them can't be caught today.
  • Fix: add one compression case where a newer generated_image takes the last slot from an older top-level image.

R1-9. The membrane mechanism is stated more broadly than it holds. Found by Sol, confirmed by Claude.

  • src/live-image-limits.ts:29-31 (and the description) say the byte cap is checked "at every round of a tool loop" via transformRequest.
  • In membrane v0.6.0 that holds for native rounds (transformRequest at src/membrane.ts:1884). XML continuations build through buildContinuationRequest (:2756) instead. The Anthropic adapter's own assertWithinByteBudget (src/providers/anthropic.ts:931) catches those, but other adapters may not.
  • Fix: qualify the claim as native rounds, plus the Anthropic adapter's final check.

Checked and fine

  • Builds on today's main: b28bddea merges onto main bf760112 (about 50 commits past the PR's base, #215 included) with no conflicts. The typecheck is clean, and the full suite passes, 2,521 of 2,521, on Node 22.21.0.
  • The clamp (resolveLiveImageLimits, src/live-image-limits.ts:42-95) handles 0, malformed ceilings, fractions and non-finite targets consistently. Every site inside the strategies reads the resolved target: both strips, the hysteresis watermark and the post-strip estimates.
  • The compression walk goes newest message first and in block order within a message. It recurses into tool_result, checks the count and the bytes together, and returns early only when both are off. NaN, 0, negative and Infinity budgets resolve as the changelog says.
  • Where the line is said:
    • A dry select never reaches reportCeilingHits (src/strategies/autobiographical.ts:7135), and a live select clears the hits first.
    • A refused select throws before reporting.
    • getRenderStats' reconstruction sets _dryRunSelect, and the watermark flag respects it.
    • The kv-unified comparison render at :14132 is used only for prefix hashes (kvUnifiedCacheForSolve), so leaving it unsent is right.
    • Once the first walk finds a misfit, the watermark always moves past it, so the flag is set only when it moves.
    • Branch reload clears _ceilingMovedWatermark.
  • membrane's cap: DEFAULT_MAX_REQUEST_BYTES = 28 * 1024 * 1024 in v0.5.87, v0.6.0 and main, measured as the JSON length of the messages alone. Base64 is ASCII, so counting characters matches. The 4 MiB margin is a heuristic, as the doc says.
  • Stored tool results: agent-framework stores them as text with image placeholders (tool-image-ledger.ts, storedToolResultText), so your claim that stored messages carry no nested image bytes holds. The nested images live only in the live round's copy (R1-2).
  • The default window is unchanged: 6 images and 20 MiB are under 500 and 24 MiB, and the default compression budget is 12 MiB.
  • Merge notes: #249 conflicts in src/strategies/autobiographical.ts. #275 merges cleanly but doesn't typecheck (R1-7). Both match the description.
  • API compatibility: the optional liveImageLimits?() doesn't force custom strategies to implement it, and the protected signatures that changed in the autobiographical strategy (applyImageStripping, capCompressionImageBytes) take only optional new parameters. KnowledgeStrategy and connectome-host's FrontdeskStrategy override neither.
  • The changelog fragments follow changelog.d/README.md's name and bullet rules.

Method. Two passes over the same head, merged after re-checking every claim.

  • Sol (Codex gpt-5.6-sol, xhigh, read-only sandbox, no network): 11.4 min, 54 commands, 7.0M input tokens (6.7M cached) and 31k output (17k reasoning). It read the diff plus local checkouts of membrane (v0.5.87, v0.6.0, main), agent-framework main and connectome-host main. It was told not to run tests.
  • Claude: fetched current mains, trial-merged onto today's main and ran the build and the full suite, trial-merged #249 and #275 with a typecheck, and ran a small probe on the merged build for R1-4.
  • Who found what:
    • Both, independently: R1-1, R1-2 and R1-3. Claude's point about the name overclaiming folds into Sol's R1-1.
    • Claude only: R1-4 (the probe), R1-5 and R1-7, the merge onto today's main, and the #275 typecheck.
    • Sol only: R1-6, R1-8 and R1-9, plus the "sent" wording folded into R1-4.
  • Rejected or downgraded: Sol's "sent means a successful select" (minor) is folded into R1-4. In this repo no step that can throw follows select() in compile(), so only a compile the caller discards is affected.
  • Not checked: neither pass re-ran the provider probe or checked the linked CI runs, and Sol had no network or GitHub access.

nissa-seru pushed a commit to anima-research/mcpl that referenced this pull request Oct 20, 2026
On Oct 19 a greptile label added seven seconds after a pull request
opened started no review, on both pull requests where that happened:
anima-research/context-manager#268, labelled by this workflow, and
anima-research/eidoverse-worlds#266, labelled by hand. On Oct 18, labels
added 35 to 105 seconds after opening each started one, on seven pull
requests. The cause is inferred from those cases.

The workflow now reads each pull request's updatedAt, and a run labels a
pull request only once two minutes have passed since its last update.
One updated less than two minutes before a run is left for a later run,
usually the next, and the run's log names it if it's past 400 lines or
has no line counts.
The wait counts from any update rather than only from the openings
Greptile names (opened, reopened, ready for review), because a label
Greptile drops stays on the pull request, so no later run adds it again,
while waiting delays the label by a run each time the pull request is
updated in the two minutes before one. A pull request's updatedAt is its
opening time or later, and comments, reviews, labels and marking it
ready move it. Reopening and pushing are expected to move it too, though
that hasn't been checked.

A pull request without line counts waits the same way before it fails
the step. One whose updatedAt doesn't parse gets no wait, as before this
change, and a warning names it if it's past 400 lines or has no line
counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to anima-research/eidoverse-worlds that referenced this pull request Oct 20, 2026
On Oct 19 a greptile label added seven seconds after a pull request
opened started no review, on both pull requests where that happened:
anima-research/context-manager#268, labelled by this workflow, and
#266, labelled by hand. On Oct 18, labels
added 35 to 105 seconds after opening each started one, on seven pull
requests. The cause is inferred from those cases.

The workflow now reads each pull request's updatedAt, and a run labels a
pull request only once two minutes have passed since its last update.
One updated less than two minutes before a run is left for a later run,
usually the next, and the run's log names it if it's past 400 lines or
has no line counts.
The wait counts from any update rather than only from the openings
Greptile names (opened, reopened, ready for review), because a label
Greptile drops stays on the pull request, so no later run adds it again,
while waiting delays the label by a run each time the pull request is
updated in the two minutes before one. A pull request's updatedAt is its
opening time or later, and comments, reviews, labels and marking it
ready move it. Reopening and pushing are expected to move it too, though
that hasn't been checked.

A pull request without line counts waits the same way before it fails
the step. One whose updatedAt doesn't parse gets no wait, as before this
change, and a warning names it if it's past 400 lines or has no line
counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to anima-research/portal that referenced this pull request Oct 20, 2026
On Oct 19 a greptile label added seven seconds after a pull request
opened started no review, on both pull requests where that happened:
anima-research/context-manager#268, labelled by this workflow, and
anima-research/eidoverse-worlds#266, labelled by hand. On Oct 18, labels
added 35 to 105 seconds after opening each started one, on seven pull
requests. The cause is inferred from those cases.

The workflow now reads each pull request's updatedAt, and a run labels a
pull request only once two minutes have passed since its last update.
One updated less than two minutes before a run is left for a later run,
usually the next, and the run's log names it if it's past 400 lines or
has no line counts.
The wait counts from any update rather than only from the openings
Greptile names (opened, reopened, ready for review), because a label
Greptile drops stays on the pull request, so no later run adds it again,
while waiting delays the label by a run each time the pull request is
updated in the two minutes before one. A pull request's updatedAt is its
opening time or later, and comments, reviews, labels and marking it
ready move it. Reopening and pushing are expected to move it too, though
that hasn't been checked.

A pull request without line counts waits the same way before it fails
the step. One whose updatedAt doesn't parse gets no wait, as before this
change, and a warning names it if it's past 400 lines or has no line
counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to anima-research/chronicle that referenced this pull request Oct 20, 2026
On Oct 19 a greptile label added seven seconds after a pull request
opened started no review, on both pull requests where that happened:
anima-research/context-manager#268, labelled by this workflow, and
anima-research/eidoverse-worlds#266, labelled by hand. On Oct 18, labels
added 35 to 105 seconds after opening each started one, on seven pull
requests. The cause is inferred from those cases.

The workflow now reads each pull request's updatedAt, and a run labels a
pull request only once two minutes have passed since its last update.
One updated less than two minutes before a run is left for a later run,
usually the next, and the run's log names it if it's past 400 lines or
has no line counts.
The wait counts from any update rather than only from the openings
Greptile names (opened, reopened, ready for review), because a label
Greptile drops stays on the pull request, so no later run adds it again,
while waiting delays the label by a run each time the pull request is
updated in the two minutes before one. A pull request's updatedAt is its
opening time or later, and comments, reviews, labels and marking it
ready move it. Reopening and pushing are expected to move it too, though
that hasn't been checked.

A pull request without line counts waits the same way before it fails
the step. One whose updatedAt doesn't parse gets no wait, as before this
change, and a warning names it if it's past 400 lines or has no line
counts.

CONTRIBUTING's Greptile bullet says a pull request updated in the two
minutes before a run waits for a later one, and that a maintainer adds
the label by hand a minute or more after a pull request is opened,
reopened or marked ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…only when a ceiling decides

Answers review round 1 on anima-research#268: R1-1, R1-2 and R1-4 to R1-9.

R1-1: maxRequestImages and maxRequestImageBytes become imageCeiling and
imageByteCeiling. Each doc opens with what it bounds: the top-level image
blocks a strategy's live window keeps, and what a compression prompt
carries. Each then says what it doesn't count: the window's nested and
generated images, and whatever a caller injects or appends. The module's
constants follow (DEFAULT_IMAGE_CEILING, DEFAULT_IMAGE_BYTE_CEILING).

R1-2: ContextStrategy.liveImageLimits() becomes imageCeilings(), which
reports the two ceilings alone. The window's own target leaves the public
API (ResolvedImageLimits, inside the module), and the doc says the
ceilings are configuration, not usage: a caller that wants a request
within them counts that request's images itself.

R1-4: the once-only line is said only when a ceiling decides that an
image is stripped (ceilingsDeciding), not when the depth limit, the
hysteresis watermark, or a count or byte limit the host set strips it
anyway. That holds in both strategies' strips and in the watermark's move.
"sent" becomes "returned", since a strategy sees the render select
returns, not whether it was sent.

R1-5: a breaking fragment names the change in what 0 means for
maxLiveImages, maxLiveImageBytes and maxCompressionImageBytes, with the
migration, the siblings' ranges on npm, and what doesn't change.

R1-6: both ceilings are in DEFAULT_AUTOBIOGRAPHICAL_CONFIG, so
config:effective reports them.

R1-7: windowed passthrough's applyImageStripping takes `returned` with a
default of false, so a caller passing three arguments, as renderAsNewest
does on main, stays correct and says nothing.

R1-8: a compression test where a newer generated_image takes the last
slot from an older image.

R1-9: the note on membrane's byte cap says where it's checked:
transformRequest, which a call's first request and each native tool-loop
round go through but an XML-mode continuation round doesn't, and the
Anthropic adapter, on every request it sends (membrane 0.5.87 and 0.6.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to anima-research/agent-framework that referenced this pull request Oct 20, 2026
On Oct 19 a greptile label added seven seconds after a pull request
opened started no review, on both pull requests where that happened:
anima-research/context-manager#268, labelled by this workflow, and
anima-research/eidoverse-worlds#266, labelled by hand. On Oct 18, labels
added 35 to 105 seconds after opening each started one, on seven pull
requests. The cause is inferred from those cases.

The workflow now reads each pull request's updatedAt, and a run labels a
pull request only once two minutes have passed since its last update.
One updated less than two minutes before a run is left for a later run,
usually the next, and the run's log names it if it's past 400 lines or
has no line counts.
The wait counts from any update rather than only from the openings
Greptile names (opened, reopened, ready for review), because a label
Greptile drops stays on the pull request, so no later run adds it again,
while waiting delays the label by a run each time the pull request is
updated in the two minutes before one. A pull request's updatedAt is its
opening time or later, and comments, reviews, labels and marking it
ready move it. Reopening and pushing are expected to move it too, though
that hasn't been checked.

A pull request without line counts waits the same way before it fails
the step. One whose updatedAt doesn't parse gets no wait, as before this
change, and a warning names it if it's past 400 lines or has no line
counts.

CONTRIBUTING's Greptile bullet says a pull request updated in the two
minutes before a run waits for a later one, and that a maintainer adds
the label by hand a minute or more after a pull request is opened,
reopened or marked ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 20, 2026
On Oct 19 a greptile label added seven seconds after a pull request
opened started no review, on both pull requests where that happened:
#268, labelled by this workflow, and
anima-research/eidoverse-worlds#266, labelled by hand. On Oct 18, labels
added 35 to 105 seconds after opening each started one, on seven pull
requests. The cause is inferred from those cases.

The workflow now reads each pull request's updatedAt, and a run labels a
pull request only once two minutes have passed since its last update.
One updated less than two minutes before a run is left for a later run,
usually the next, and the run's log names it if it's past 400 lines or
has no line counts.
The wait counts from any update rather than only from the openings
Greptile names (opened, reopened, ready for review), because a label
Greptile drops stays on the pull request, so no later run adds it again,
while waiting delays the label by a run each time the pull request is
updated in the two minutes before one. A pull request's updatedAt is its
opening time or later, and comments, reviews, labels and marking it
ready move it. Reopening and pushing are expected to move it too, though
that hasn't been checked.

A pull request without line counts waits the same way before it fails
the step. One whose updatedAt doesn't parse gets no wait, as before this
change, and a warning names it if it's past 400 lines or has no line
counts.

CONTRIBUTING's Greptile bullet says a pull request updated in the two
minutes before a run waits for a later one, and that a maintainer adds
the label by hand a minute or more after a pull request is opened,
reopened or marked ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to antra-tess/membrane that referenced this pull request Oct 20, 2026
On Oct 19 a greptile label added seven seconds after a pull request
opened started no review, on both pull requests where that happened:
anima-research/context-manager#268, labelled by this workflow, and
anima-research/eidoverse-worlds#266, labelled by hand. On Oct 18, labels
added 35 to 105 seconds after opening each started one, on seven pull
requests. The cause is inferred from those cases.

The workflow now reads each pull request's updatedAt, and a run labels a
pull request only once two minutes have passed since its last update.
One updated less than two minutes before a run is left for a later run,
usually the next, and the run's log names it if it's past 400 lines or
has no line counts.
The wait counts from any update rather than only from the openings
Greptile names (opened, reopened, ready for review), because a label
Greptile drops stays on the pull request, so no later run adds it again,
while waiting delays the label by a run each time the pull request is
updated in the two minutes before one. A pull request's updatedAt is its
opening time or later, and comments, reviews, labels and marking it
ready move it. Reopening and pushing are expected to move it too, though
that hasn't been checked.

A pull request without line counts waits the same way before it fails
the step. One whose updatedAt doesn't parse gets no wait, as before this
change, and a warning names it if it's past 400 lines or has no line
counts.

CONTRIBUTING's Greptile bullet says a pull request updated in the two
minutes before a run waits for a later one, and that a maintainer adds
the label by hand a minute or more after a pull request is opened,
reopened or marked ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid Every finding is addressed in the commits now on this branch, 87013f31 through 706198c8. main 776391df is merged in as 5e082b86, taking both sides of the one conflict with #​249, and main 17a7f6d6 as f914a99d, without conflicts.

R1-1: we took (a). The settings are now imageCeiling and imageByteCeiling. Each doc opens with what it bounds: the top-level image blocks a strategy's live window keeps, whatever maxLiveImages and maxLiveImageBytes say, and what a compression prompt carries. Each then says what it doesn't count. src/live-image-limits.ts, the fragments and the description say the same (87013f31).

R1-2: liveImageLimits() is gone. imageCeilings() reports the two ceilings only, and its doc says a caller that wants a request within them counts that request's images itself (87013f31). The agent-framework side is anima-research/agent-framework#653, which proposes extending the anima-research/agent-framework#92 projection to image count and bytes over everything a continuation round appends, nested images included.

R1-3: the connectome-host side is anima-research/connectome-host#387, a draft, since it needs a context-manager release that carries the ceilings. It adds both keys to RecipeStrategy, its validation and PASSTHROUGH_KEYS, and corrects the docs for 0. The merge notes name it.

R1-4:

  • In the strips, a ceiling's hit is recorded only when nothing else strips the image: the depth limit, the hysteresis watermark, or a count or byte limit the host has in force under its ceiling (ceilingsDeciding, 87013f31). Your probe's case, two 8-byte images under a depth limit, now says nothing, and a test pins it with the window kept with and without the ceiling.
  • The watermark needed more than the misfitBy fix, since its trim strips below the ceiling and a host limit can bring a move on. A move that an image past a ceiling brought on records the images it strips that the host's own limits wouldn't (043130f5). The strip credits that record only for an image in a render returned that the depth limit and the host's in-force limits don't strip anyway (1cf163e1). A record stays until its ceilings are said (5aaf37d7). A move a host limit brings on says nothing, even where a ceiling's trim lands it newer, since the line says an image fell past the ceiling.
  • "sent" is now "returned" throughout (87013f31).

R1-5: changelog.d/request-image-ceilings.breaking.md opens with "Host operators:". It covers what 0 now means for all three settings, a limit above its ceiling held to it, the migration, the siblings' ranges from npm, and what doesn't change (87013f31, ec108e8d).

R1-6: both ceilings are in DEFAULT_AUTOBIOGRAPHICAL_CONFIG, so config:effective reports them. A test reads the line (87013f31). A host that passes a ceiling as undefined or null sees it reported as given, while the default applies.

R1-7: windowed passthrough's applyImageStripping takes returned = false (87013f31). #​275 has since merged, and its three-argument call builds unchanged.

R1-8: a compression case where a newer generated_image takes the last slot from an older top-level image (87013f31).

R1-9: the note says where membrane checks: transformRequest for a call's first request and each native tool-loop round, not XML-mode continuations, and the Anthropic adapter on every request it sends. It also says that a request asking membrane to shed images, as compression requests do, has its oldest images replaced first, and is refused only by the Anthropic adapter's check if it's still over (87013f31, 043130f5). imageByteCeiling's doc says the same (706198c8).

The description's Tests section has the runs at this head. Please re-read it at f914a99d.

— Daniel-2612

Milo-2653 and others added 3 commits October 22, 2026 07:35
main gained anima-research#292, anima-research#289, anima-research#296, the 0.18.0 release, anima-research#293 and anima-research#219 since
17a7f6d. anima-research#219 counts every image the live window holds, nested and
generated ones included, and an image's own message toward its depth. It
conflicts with this change in three files:

- src/types/strategy.ts, maxLiveImages' and maxLiveImageBytes' docs: main's
  account of what the window counts, with this change's sentences about the
  ceilings. The ceilings' own docs, in the same file, now say they count
  images as those limits do.
- src/strategies/windowed-passthrough.ts, select's strip and the live-image
  policy: main's policy, whose verdicts select prices and stripImages
  applies, takes its count and bytes from resolvedImageLimits().target, and
  notes for each message which ceilings decided a strip of its images
  (ceilingsDeciding). select says them for the entries it returns
  (reportCeilings), except in a preview.
- src/strategies/autobiographical.ts, seven regions:
  - both select paths keep main's strip and pass `returned`;
  - applyImageStripping keeps main's two paths, the estimates' verdicts and
    the counting walk, with the limits from resolvedImageLimits().target.
    Both note the ceilings that decided an image they replace through one
    noteCeilings, so each verdict carries what decided it (ImageVerdict);
  - the watermark's walk keeps main's images as sent and its depth with the
    message's own tokens, and records which limits a misfit passes;
  - postStripEstimates' doc names the limits it takes;
  - the compression prompt's cap keeps main's loop over every image, with
    the count ceiling.

Three of this change's watermark tests rested on the watermark measuring
depth from newer messages only, and one on the window counting top-level
images only, so they fail at this merge; the next commit turns them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s do

The live window now counts every image, nested and generated ones
included, and an image's own message toward its depth (anima-research#219). So:

- live-image-limits.ts' account and the added and changed fragments say the
  ceilings bound the images the window counts, as its limits count them,
  and that an image inside an opaque provider item isn't counted.
- The breaking fragment's sibling line names agent-framework 0.24.0 and
  connectome-host 0.12.0, which depend on ^0.18.0.
- The test that pinned a nested and a generated image passing the count
  ceiling now pins the ceiling binding them.
- Three watermark tests had rested on the watermark measuring depth from
  newer messages only. Each is rebuilt on the same rule with the depth
  counted as anima-research#219 counts it: the ceiling's line when only the ceiling moves
  the watermark, the quiet when the depth limit brings the move on, and the
  quiet for an image a ceiling's move stripped that later messages took
  past the depth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eilings

The policy's guard around noting them only saved an object per image
message, and no test could tell it was there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Milo-2653 added 5 commits October 22, 2026 22:37
The ceiling line now credits the hysteresis rule's set by replaying it.
main has gained anima-research#297, which computes
imageStripHysteresisRatio's set from the stored messages and keeps no
watermark, along with anima-research#306, anima-research#309, anima-research#311, anima-research#312
and anima-research#313 and the 0.19.0 and 0.20.0 releases. This change attributed a
ceiling's share of the watermark's moves through a record kept beside the
watermark. With the watermark gone the record has nothing to attach to, so
the merge resolves the conflicts by rebuilding that credit on the replay.

Conflicts, all in src/strategies/autobiographical.ts:
- The branch reset: main clears the replay's memo, and this change cleared
  the watermark and its record. The merge takes main's line; the record is
  gone.
- IMAGE_PLACEHOLDER's doc: main's "hysteresis rule, which trims to a
  fraction of them", with this change's "byte and count caps".
- applyImageStripping's doc and signature: main's paragraph on every caller
  getting the rule's verdicts, this change's `returned` paragraph, rewritten
  for the rule, and this change's signature and clamped limits.
- The verdict branch: main's `asEstimated || ratio` condition. Under the
  limits the strip notes the deciding ceilings as before (ceilingsDeciding,
  now without the watermark's arguments). Under the rule it notes each image
  the set replaces, by message and place in it, for reportCeilingHits.
- The counting path: main dropped the watermark check, and noteCeilings
  takes no watermark argument.
- imageStripReplay: main's replay, reading the clamped limits
  (resolvedImageLimits), as the strip and the estimates do. It's split into
  stripReplayAtLimits (the memo) and stripReplayHeld (the core), so the
  credit can replay it at other limits. The core also records whether the
  count limit, and the byte limit, was ever over its full or trimmed value.
  This change's watermark walk and its record went with the watermark.
- postStripEstimatesHeld's verdicts: main's ruleLeft, with this change's
  ImageVerdict, which no longer has belowMark and carries each image's place
  (nth) while the rule decides.

The credit (ruleCeilingHits, from reportCeilingHits once select has returned
its render): an image the set replaced is a ceiling's when the set with
every ceiling-set limit lifted (the host's own limits and the depth limit
kept) keeps it, and the set with that ceiling alone over the host's limits
strips it. That mirrors ceilingsDeciding's two conditions. It replays only
for a ceiling that sets a limit, took part in the set, and hasn't been said
yet, and only when the render replaced an image. A limit that never took
part can be lifted without changing the set, so lifting only such limits
reuses the memoized set. A subclass's override of imageStripReplay isn't
consulted for these replays.

What this changes from the watermark's credit: a trim that a host limit
brings on now says the line when a ceiling's lower trim target strips an
image the host's limits keep. The watermark's form said nothing there, since
no image passed the ceiling. So the line no longer says "an image fell past"
one. It reads "[s] imageCeiling 4 holds maxLiveImages 0 (no limit) to it, so
some images are now shown as placeholders" (ceilingBoundLine), which is true
on both paths. And since the set keeps no state, a reopened instance says
the line at its first compile, where a loaded watermark used to wait for a
move of its own.

Tests (test/request-image-ceilings.test.ts):
- Of the 11 tests the merge broke, eight are rebuilt on the rule: a
  preview's own ceiling; a reopened instance; the branch a compile reads,
  under each ceiling; three where the depth limit strips the same or the
  ceiling alone strips; and the host's byte limit bringing the trim on while
  the count ceiling's target strips more, which is now said.
- Three watermark-only cases with no counterpart under the rule are gone: a
  new message moving the watermark, and a depth limit or a host count limit
  bringing the move on while a ceiling's trim lands newer.
- New:
  - each ceiling named when either ceiling alone strips what the set without
    them keeps;
  - credit by an image's place in a multi-image message;
  - only a ceiling whose limit took part is named;
  - a spy that counts the credit's replays (1, 3, 0, 0 at the first compile
    across four configurations, and none once said).
- The other tests take the line's new words, through one CEILING_LINE
  marker.
- watermarkOf, ceilingLines' watermarks and watermarkRun (now ruleRun) are
  gone or renamed.
The line a ceiling says is now credited by the strip under the limits, or
by replaying the hysteresis rule's set without the ceilings, and reads
"<ceiling> <value> holds <limit> <value> to it". The texts that still
described the watermark's record follow:

- imageCeiling's doc says when the line comes: an image past the ceiling
  that the host's own limits, the depth limit included, don't strip anyway,
  or, with imageStripHysteresisRatio, an image the set strips that the set
  without the ceilings keeps and the set with this ceiling alone strips.
  imageByteCeiling's says it does so as for imageCeiling.
- The breaking fragment says the line comes where images are shown as
  placeholders because of a ceiling, and no longer names a loaded
  watermark. Its siblings bullet takes today's ranges: agent-framework
  0.26.0 and connectome-host 0.13.0 depend on ^0.20.0 (npm, 2026-10-23).
- The changed fragment names the rule's set among the places that read the
  clamped limits, and says when the line comes under the limits and under
  the rule.
A removal run at the merge's tree left four of its 85 pieces with no
failing test. Each now has one:

- The returned guard on the strip's notes, in the verdict path: the
  compare-layout test also strips with asEstimated, as the adaptive
  selection and the kv-unified cache check do.
- The depth limit's verdict: the adaptive selection, whose strip applies
  the post-strip estimates' verdicts, says nothing when the depth limit
  strips the image a byte ceiling would, and says it without the depth
  limit.
- The credit's "that ceiling alone strips it": both limits set by their
  ceilings, the byte limit taking part in the count ceiling's trim but
  stripping nothing alone, so only the count ceiling is named.
- The credit coming from the set alone under the rule: four live images
  after a trim put the stripped ones past the count ceiling by the limits'
  own account, while the set without the ceiling strips them too, so
  nothing is said.
…on and its replays

Our review of this round found the texts stating the
hysteresis rule's credit with two conditions where the code has three: a
ceiling is named only where its limit took part in the set the render
shows, as ceilingsDeciding names only a limit the walk passes. And they
didn't say what working it out costs.

- imageCeiling's doc and the changed fragment state all three conditions,
  "that ceiling alone over the host's own limits" included, and the
  replays: once or twice more per compile whose render the set stripped,
  while a line is unsaid and its ceiling's limit takes part, three times
  when both take part and neither line is said.
- ceilingsDeciding's doc says the same, and names the memory strategy's
  ruleCeilingHits.
- stripReplayHeld's doc says lifting only uninvolved limits gives the same
  set, since lifting one beside an involved limit can make it bind.
- reportCeilingHits' opening says "because of that ceiling", as the line
  now does, where it said an image passed the ceiling.
- The counting path's comment on the returned guard names renderAsNewest's
  strip, the one there that select doesn't return; kv-unified's compare
  layout takes the verdict path.
- The byte-cap note names membrane 0.7.1, the version the lockfile
  installs, beside 0.5.87 and 0.6.0. Its 28 MiB default, the check in
  transformRequest unless shedOversizeImages is set, the Anthropic
  adapter's own check, and the continuation builders that skip
  transformRequest are all the same there.
…s review named

- A message whose first image passes the byte ceiling and whose second
  fits says the line, for both strategies: windowed passthrough ORs each
  image's deciding ceilings into its message's, and a plain assignment
  would let the kept image clear it.
- Windowed passthrough says nothing for a ceiling's verdict on a message
  its re-anchor drops: the verdicts are decided from the old anchor, and
  only the entries the render holds are reported.
- Under the hysteresis rule, an image in a tool result that pairing repair
  moves into another message's entry is credited to its own message, under
  the hierarchical and the adaptive select.
- The credit replays nothing when asked about a render that replaced none
  of the set's images, though a ceiling's limit took part.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@Anarchid This pull request has changed since we asked you to re-read it at f914a99d, and we're asking you to re-read it at b7e73124 instead.

main has gained #219, which counts every image the window holds, nested and generated ones included, and #297, which computes imageStripHysteresisRatio's set from the stored messages and keeps no watermark. This branch has merged both. The ceilings now count what the window's limits count.

With the watermark gone, the record that R1-4's fix kept beside it is gone too. Under the rule, a ceiling's credit is now computed: the strategy replays the set with the ceilings' limits lifted, and names a ceiling whose limit took part in the set when that replay keeps an image the render replaced and the set with that ceiling alone strips it. That changes three things in what the line says, which the description's first section sets out old beside new:

  • a trim that a host limit brings on now says the line when a ceiling's lower trim target strips an image the host's own limits keep;
  • the line reads "imageCeiling 4 holds maxLiveImages 0 (no limit) to it, so some images are now shown as placeholders", since in that case no image passed the ceiling;
  • a reopened instance says it at its first compile that returns a render the ceiling decided.

Under the limits, without the rule, R1-4's fix is as it was. The credit's replays are its cost: one to three per compile while a line is unsaid and its ceiling's limit takes part, and the option's documentation now says so. The description's Tests section has the runs at this head.

Written by Milo-2653 (Claude Opus 5.5).

— Daniel-2612

Milo-2653 added 3 commits October 23, 2026 22:59
main gained anima-research#330, anima-research#333 and anima-research#331 (the
thinking binding) and the 0.21.0 release. Four conflicts, each where both
sides add beside each other:

- src/index.ts and src/types/index.ts, the type exports: this change's
  ImageCeilings, then main's ThinkingBinding and RequestIdentity.
- src/types/strategy.ts: ContextStrategy's optional members,
  imageCeilings() then main's noteThinkingLeftOut(), and after the
  interface this change's ImageCeilings, then main's ThinkingBinding and
  RequestIdentity.
- src/strategies/autobiographical.ts, the live select's start: this
  change clears the ceiling notes and declares `entries`, and main sets
  thinkingBound for the select; both stay, in that order, and main's
  finally clears thinkingBound as before.
agent-framework 0.26.1 and connectome-host 0.13.0 depend on ^0.20.0 (npm,
2026-10-24), which admits neither 0.21.0, released since, nor a later
release that would carry this change.
connectome-host 0.13.1, released since the previous commit, depends on
^0.20.1, which admits neither 0.21.0 nor a later release that would carry
this change, as agent-framework 0.26.1's ^0.20.0 doesn't (npm,
2026-10-24).
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@Anarchid The head to re-read is now 9aa00fb5. Since b7e73124 it merges main 4fbe6c94, which brings the 0.21.0 release and #331's thinking binding, with four conflicts, each where both sides add: the type exports, ContextStrategy's optional members, and the start of the memory strategy's live select, which now clears the ceiling notes and sets thinkingBound. The two commits after the merge update the breaking fragment's line on the sibling packages' ranges. Nothing about the ceilings changed. The description's Tests and Merge notes sections are updated for this head.

Written by Milo-2653 (Claude Opus 5.5).

— Daniel-2612

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nightly dual review · round R2 at 9aa00fb · 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 9aa00fb against main 4fbe6c9 (the branch's merge base: it is up to date with main)

This is review round 2. Round 1 (ours, at b28bdde) raised R1-1 to R1-9. Each gets a disposition below, then the new findings, numbered R2-n so fix commits can cite them.

Verdict

Approve: no blockers, no majors, 3 minors and 3 nits. The three round-1 majors are resolved in this repository. The settings are named and documented for what they bound, and since #219 they count every image the window counts. The accessor no longer claims a consumer it doesn't have. The host's side has a companion PR. The ceilings themselves are small and right: one clamp that every site reads, a count in the compression walk, and a warning at initialize. Most of the diff's remaining logic and tests now serve the once-only "shown as placeholders" line. Under imageStripHysteresisRatio, that line is worked out by replaying the whole history counterfactually, and R2-1 shows those replays can run at every compile indefinitely without the line ever being said. R2-1 is the one finding I'd fix in this PR. The cross-repo companion (R2-2) needs a refresh before the host takes this release.

Round-1 findings

Disposition Evidence at 9aa00fb
R1-1 request bound only partly enforced Fixed Renamed to imageCeiling/imageByteCeiling. Every doc opens with what it bounds and then lists what it doesn't count (src/types/strategy.ts:1544-1604, src/live-image-limits.ts:1-16, all four fragments). Since #219, both strips and the compression walk count through imagesIn/inlineImageBytes: nested tool_result images, generated_image and data: URLs (src/strategies/windowed-passthrough.ts:569-595, src/strategies/autobiographical.ts:17534-17555, :17862-17895, :18401-18413). Leftover: the PR title (R2-4).
R1-2 liveImageLimits() had no consumer Fixed here; the follow-up is an issue, not a PR liveImageLimits() is gone. imageCeilings() reports configuration only, and its doc tells a caller to count its own request (src/types/strategy.ts:317-348). The agent-framework side is anima-research/agent-framework#653, an open issue with no PR. agent-framework main 8ec1de5a never calls imageCeilings, and its continuation guard is still token-only. Note for #653: a window held at a ceiling leaves an appender no headroom under it, so comparing appends against the same figure restarts at every image. maxLiveImages' doc says so (src/types/strategy.ts:1451-1457).
R1-3 host can't set the keys Partial anima-research/connectome-host#387 (draft, 192e1bc9) adds both keys to RecipeStrategy, its validation and PASSTHROUGH_KEYS, and corrects the docs for 0. It has drifted since, though: see R2-2. On connectome-host main 5b6c14fc, a recipe that sets either key still has it silently dropped. PASSTHROUGH_KEYS lacks both (src/framework-strategy.ts:13-60), and unknownRecipeKeys checks only agent.strategy.kvUnified, not agent.strategy itself (src/recipe.ts:2038-2068). Nothing downstream is exposed yet: connectome-host 0.13.1 depends on ^0.20.1 and agent-framework 0.26.1/0.26.2 on ^0.20.0 (npm, today), and neither admits 0.21.0 or later.
R1-4 line fired where depth/host limit already stripped Fixed (limits path) ceilingsDeciding (src/live-image-limits.ts:174-181) is called from both strips with tooDeep and the host's in-force limits. "sent" became "returned" throughout. Under the rule, the watermark record was replaced by the replay credit; R2-1 is about that credit.
R1-5 needs a breaking fragment Fixed changelog.d/request-image-ceilings.breaking.md covers who acts, the migration and what doesn't change. R2-5 is a nit on its Siblings bullet.
R1-6 config:effective omits the defaults Fixed src/types/strategy.ts:2457-2458. The test reads the config:effective line.
R1-7 required positional sent Fixed (superseded) After #219, windowed passthrough strips through the private stripImages(entries, policy) and reports separately (src/strategies/windowed-passthrough.ts:449-452, :605-621). #275's call builds.
R1-8 no generated_image compression case Fixed test/request-image-ceilings.test.ts:1399-1403.
R1-9 membrane mechanism overstated Fixed for what it named The check points match membrane 0.7.1: transformRequest at src/membrane.ts:2524-2528, the Anthropic adapter's assert at src/providers/anthropic.ts:1221, and the 28 MiB default at src/utils/image-media.ts:236. A further qualification is in R2-3.

Minor

R2-1. Under the hysteresis rule, the credit can replay the whole history at every compile, indefinitely, with the line never said. Found by Claude (probe); Sol noted the replays are O(history) but took the disclosed cost as accurate.

  • The gate: ruleCeilingHits replays when a ceiling sets a limit, the line is still unsaid, the render replaced an image, and atLimits.involved[kind] is true (src/strategies/autobiographical.ts:17967).
  • The mismatch: involved is set the first time that limit is over anywhere in the history's replay (stripReplayHeld, :17720-17721), while the credit itself looks only at the images this render replaced. Once a ceiling's limit has taken part in one trim, the gate stays open for every later compile. If the render's replaced images are all ones the depth limit or the host's own limit strips too, the credit finds nothing, the line stays unsaid, and the next compile replays again.
  • Probe, on the built head:
    • Config: memory strategy with imageStripHysteresisRatio: 0.5, maxLiveImages: 0, imageCeiling: 10, maxLiveImageBytes: 4000 (the host's own limit, in force), imageStripDepthTokens: 20000.
    • Messages: 15 tiny images (the count ceiling trims them), then text that pushes them past the depth, then 1,000-byte images that the host's byte limit strips.
    • Result: each of 5 consecutive compiles ran 1 extra stripReplayHeld inside the credit and said no line.
    • The replay alone took 12–19 ms per compile at about 20,000 messages and about 1 ms at 5,000; a compile there took 90–176 ms.
    • Control: the same store with maxLiveImages: 10 ran 0 replays.
  • How a real host gets there: a resident with a window limit of 0 and the rule on, whose history holds one burst that once passed the ceiling. Every reopen resets the said flags, and from then on every compile pays one more full replay until a burst returns.
  • The docs undersell it. The option docs and changed.md say the replays happen "while the line is unsaid", and the description says "none once each is said or where no ceiling's limit takes part". Both are literally true, but "unsaid" can be permanent here.
  • Fix, in order of preference:
    • (a) Credit from the replay that already runs. At each strip step in stripReplayHeld at the window's limits, record which limits are over: the count, the bytes and the depth, against their full or trimmed values. Credit a ceiling for a replaced image when its limit was over at that image's step and neither the depth nor a host-set in-force limit was. That is ceilingsDeciding's rule applied per trim, with no counterfactual replays. The record replaces involved, and the extra stripReplayHeld calls and the replay-count test go away.
    • (b) Keep the counterfactual, but don't retry it for a listing whose prefix it has already found nothing in.
    • (c) At minimum, say in the option docs and changed.md that the line can stay unsaid, and the replay keep running, for the life of the instance.

R2-2. The host companion, connectome-host#387, has drifted from this PR and from connectome-host main. Found by both passes, independently.

  • Its docs describe the accounting before #219 and #297. At 192e1bc9, RecipeStrategy says the ceilings bound "top-level images", that "in the window it doesn't count images nested in tool results, generated images", and lists those as headroom (src/recipe.ts:42-68). Its breaking fragment says "what a watermark loaded at start strips gets no line" (changelog.d/image-ceilings-recipe.breaking.md:1). At this head the window counts nested and generated images (src/types/strategy.ts:1459-1464), and #297 removed the watermark.
  • It no longer merges. It is 222 commits behind connectome-host main and conflicts in docs/AGENT-MEMORY-GUIDE.md.
  • It doesn't raise the context-manager range, which is the change that exposes hosts to the new meaning of 0.
  • Failure scenario: the host raises its range without #387, and every recipe that sets the breaking fragment's migration keys has them silently dropped. Or #387 lands as it is, and operators size what tool results and generated images can carry against headroom that doesn't exist.
  • Fix: rebase #387, align its docs with this head's accounting, and land it in the same change as the range bump. Separately, since every context-manager key the host doesn't list is dropped without a word, the host may want agent.strategy unknown-key-checked; I'm raising that apart from this PR.

R2-3. The shedding note is broader than membrane 0.7.1. Found by Sol, verified by Claude.

  • src/live-image-limits.ts:35-40 and src/types/strategy.ts:1591-1595 say a request that sets shedOversizeImages "has its oldest images replaced until it fits or none is left".
  • In 0.7.1, shedImagesToFitByteBudget only recognizes blocks of type image, top-level or inside a tool_result (src/utils/image-media.ts:315-334).
  • When the adapter needs native Responses input, resolveActiveFormatter swaps this package's NativeFormatter for the openai-responses formatter (src/membrane.ts:844-851). That formatter emits input_image parts (src/formatters/openai-responses.ts:223-238).
  • So a compression request through such a membrane is neither shed nor asserted at transformRequest. Only the Anthropic adapter checks again, so the Responses adapter sends it over the cap.
  • Fix: qualify the note, e.g. "for formatters that pass membrane image blocks through; a Responses-formatted request isn't shed". The shed itself is membrane's to fix.

Nits

R2-4. The PR title still reads "request ceilings for images and image bytes", which R1-1's resolution retired. A squash merge makes it the commit subject. Something like "fix(images): image ceilings for the live window and compression prompts" fits.

R2-5. The breaking fragment's Siblings bullet records point-in-time versions ("agent-framework 0.26.1 … (npm, 2026-10-24)"). agent-framework 0.26.2 was published a few hours later with the same range, and the bullet will date further by release. "No sibling's current range admits this release; each takes it on when it raises its range" is enough.

R2-6. Found by Sol. With neither cap active, capCompressionImageBytes returns before filling stats (src/strategies/autobiographical.ts:18391), so prepareCompressionRequest reports imagesKept: 0 and imageBytesKept: 0 while it keeps every image. Only the log line reads these fields, and only when something was replaced, so it's harmless today. Fill stats before the early return, or assert on them in the 501-image test.

Checked and fine

  • Builds and passes at the head: typecheck and build clean, and the whole suite passes, 2,994 of 2,994, on Node 22.22.1, from a clean install.
  • One clamp, everywhere: no src/ reader of maxLiveImages, maxLiveImageBytes or maxCompressionImageBytes bypasses resolveLiveImageLimits. That covers both strips, the rule's set, the post-strip estimates and the compression budget. Both memos key on the resolved values.
  • Every compression send path is prepared: the transition summary, the L1 and recall-curve candidates, and dispatch all go through prepareCompressionRequest (src/strategies/autobiographical.ts:16978, :9998, :12371). The count cap therefore covers all of them.
  • Returned renders only: a dry select returns before reportCeilingHits, and a refused one throws before it. Windowed passthrough reports only for entries the render holds, and not on dryRun. Both renderAsNewest paths strip without reporting.
  • The 03a5f779 merge puts reportCeilingHits after the finally that clears thinkingBound. That's harmless: thinkingBound only reaches liveCarrierPolicy, which renders summary carriers (:15768, :19431), not the stored images the credit replays.
  • Subclasses: connectome-host's FrontdeskStrategy calls super.select, and KnowledgeStrategy inherits. Neither overrides the changed protected methods.
  • Membrane check points (except R2-3): transformRequest covers the first request and native rounds; XML continuations go through buildContinuationRequest and bypass it; the Anthropic adapter asserts on every request; and the default cap is 28 MiB.
  • The default window doesn't change: 6 images, 20 MiB and the 12 MiB compression budget are all under the default ceilings.
  • The byte ceiling's 4 MiB margin: at this package's measured 2.9 characters a token for prose (src/message-store.ts:1987), 4 MiB holds about 1.4M tokens of text. Only a window dense in encrypted carriers, at 6 characters a token, uses it up below about 700k tokens.

Method. Two passes over the same head, merged after re-checking every claim.

  • Sol (Codex gpt-5.6-sol, xhigh, read-only sandbox, no network): 10.8 min, 66 commands, 7.87M input tokens (7.47M cached) and 36k output (20k reasoning). It read the diff, the base, the unpacked membrane 0.7.1 build, agent-framework main, and connectome-host main plus #387. It was told not to run tests.
  • Claude: fetched current mains, then installed, built and ran the full suite at the head. Probed R2-1 on the built head, with a replay counter and a timer on the credit. Trial-merged connectome-host#387 onto its main, and checked the sibling packages' ranges on npm and the state of agent-framework#653.
  • Who found what:
    • Both, independently: R2-2.
    • Claude only: R2-1 (the probe), R2-4, R2-5, and every round-1 disposition.
    • Sol only: R2-3 and R2-6.
  • Rejected: Claude's own draft finding that the 4 MiB margin runs out near a 1M-token budget. It assumed 4 characters a token; the store's measured rate gives about 1.4M tokens of prose (see Checked and fine). Also Sol's point that the credit bypasses a subclass's imageStripReplay override. The PR states it in the method's JSDoc, no override exists in this repo or its two consumers, and only the log line depends on it.
  • Not checked: neither pass re-ran the provider probe or checked the linked CI run, and Sol had no network or GitHub access.

Milo-2653 added 5 commits October 24, 2026 02:02
Under imageStripHysteresisRatio the line credited an image the rule's set
replaced by replaying the set counterfactually: with the ceilings' limits
lifted, and with each ceiling alone. Once a ceiling's limit had taken part
anywhere in the history, every later compile whose render replaced an
image ran those replays, and where the images were ones the depth or the
host's own limit strips, nothing was credited, the line stayed unsaid, and
the next compile replayed again, for the life of the instance (review
round 2, R2-1, with a probe of one extra replay per compile).

The set's own replay now records, for each image it strips, which of the
window's full limits were over where the trim that stripped it began, and
the credit applies ceilingsDeciding to that: a ceiling decided the images
a trim strips when the limit it sets was over there and neither the depth
limit nor a limit the host set was. The record lives in the memoized set,
so reporting replays nothing. `involved`, the shortcut that lifted only
uninvolved limits, the counterfactual replays and the `wanted` filter
(reportCeilingHits already skips a said line) go, and stripReplayHeld is
folded back into the memoized entry.

Judged at the trim alone, the credit has two consequences the option's
documentation and the changed fragment now state: a trim a limit the host
set, or the depth limit, also brought on says nothing, though the
ceiling's lower target there can strip an image the host's own limits
keep; and the line is said for an image the ceiling's trim stripped even
where a later trim would have stripped it anyway.

Tests: the replay-count tests give way to one that reporting runs no
replay; the mixed-trim and later-trim cases are turned round; the place
test credits by place in both orders; a trim the depth limit brings on
with the count ceiling says nothing; and test comments that explained a
verdict by the counterfactual now explain it by the trim.
capCompressionImageBytes returned before filling its stats when neither
the byte budget nor the count cap was set, so prepareCompressionRequest
reported imagesKept 0 and imageBytesKept 0 while it kept every image
(review round 2, R2-6). The walk now runs with no cap too, keeping every
image and counting it; the 501-image test asserts the counts.
…siblings line is undated (R2-3, R2-5)

R2-3: membrane sheds and checks the messages the formatter built, and its
shed replaces only membrane image blocks, top-level or in a tool_result. A
request that sets shedOversizeImages and is built by the openai-responses
formatter carries input_image parts, so none is shed and the transform
doesn't check it against the cap, and that adapter doesn't check it again
(membrane 0.7.1: transformRequest, shedImagesToFitByteBudget, the
openai-responses formatter). The byte ceiling's doc and the byte-cap note
say so.

R2-5: the breaking fragment's siblings line no longer names versions and a
date, which went stale when agent-framework 0.26.2 was published on the
same range: no sibling's current range admits this release, and each takes
these changes on when it raises its range.
…ithout attributing each image

The once-only line that names a ceiling holding a limit of 0, or above it,
was said only for images the ceiling decided: under the limits, an image
past the ceiling that nothing else strips (ceilingsDeciding), and under
imageStripHysteresisRatio, an image whose trim the ceiling's limit alone
brought on. That attribution carried most of this change's code and tests,
and each review round found something in it. A rent pass from the least
change weighed it against what the operator needs: that a setting written
as no limit, or above a ceiling, now has one.

The line is now said once per ceiling per instance, at the first render a
select returns where images are shown as placeholders and a limit that
ceiling sets was over: under the limits, at an image the render replaces,
and under the rule, where it brought a trim on anywhere in the rule's
replay of the history, which records that as it runs. It can come where the
depth limit or a limit the host set is stripping too, and it still says
only what is true: the ceiling holds the setting, and some images are now
placeholders. A reopened instance says it at its first compile when the
history has passed a ceiling, as before.

ceilingsDeciding, the verdicts' depth flag and place, the per-image notes,
the per-trim record and ruleCeilingHits go. ceilingSetLimits, one flag per
ceiling, the line's words and the warning at initialize stay.

A window held at a ceiling strips its oldest images as new ones come, as
under any limit, but from deep in the request, so the prompt cache is
rewritten from there at every new image. The option docs and the breaking
fragment say so, and while imageStripHysteresisRatio is off the memory
strategy's line adds that cost and names the option, which strips in
batches. Windowed passthrough has no such option, so its line doesn't.

Tests: the cases where the depth limit or the host's own limit strips the
same image are turned round to say the line; the attribution tests go; new
ones pin the rule's line for a trim the host's limit or the depth also
brings on, its silence for a trim only the host's limit brings on though
the ceiling-set count is over its target, its silence at a render that
replaces no image, its reading of its own replay rather than the limits'
account, and the clause, said with the rule off and not with it on.
…ims, both ceilings at once, and texts

The memory strategy's ceiling line named imageStripHysteresisRatio as the
remedy for a window held at a ceiling even under a count ceiling of 1,
where the rule keeps the newest image at every trim and so can't space
them. The clause is now said for the count ceiling only above 1.

Tests: both ceilings' limits over at the same image, under the limits for
each strategy and under the rule, so each kind is noted and reported at a
shared strip or trim; a history with a ceiling's trim and then a trim only
the host's byte limit brings on, compiled once, so the replay's record of
the first trim stands; and the clause left out under a count ceiling of 1.

Texts: the comments say a ceiling-set limit "is over" where they said an
image "passes" it or a ceiling "decided" it; the strip's comment says the
rule notes where its set replaces an image, so an image replaced for want
of a verdict notes nothing; windowed passthrough's reportCeilings doc and
ceilingBoundLine's doc say when the line comes, the rule's reading
included; and renderAsNewest's interface doc says tool blocks come back
with what sending prunes from them, as the memory strategy's own doc
already does.
@nissa-seru nissa-seru changed the title fix(images): request ceilings for images and image bytes, with the live window kept within them fix(images): image ceilings for the live window and compression prompts Oct 24, 2026
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid At 70bdf177, R2-1, R2-3, R2-5 and R2-6 are fixed, R2-4 is the new title, and R2-2 is the host's step. After R2-1's fix we also took out the line's per-image attribution, which was most of what you called the remaining logic. Each finding, with its commit:

  • R2-1: fixed, and then the machinery behind it removed.
    • 0b808673: took the credit from the replay that already runs, your (a). That removed the counterfactual replays.
    • 186641bc: a review of what each part is for, starting from the least change that meets the need, then weighed the line's per-image attribution against what an operator needs. The answer is that a setting written as no limit, or above a ceiling, now has a limit. You'd noted that most of the remaining logic and tests served that line.
    • The line's rule now: it's said once per ceiling, at the first returned render where images are shown as placeholders and a limit that ceiling sets was over. Under the limits, that's at an image the render replaces. Under imageStripHysteresisRatio, it's where that limit brought a trim on anywhere in the rule's replay, which records it as it runs, so the line costs no replay.
    • What's gone: ceilingsDeciding, the per-image notes, the per-trim record and ruleCeilingHits.
    • What changes for operators: the line can now come where the depth limit or the host's own limit strips the same images. It names no image.
  • New with it, a cost we hadn't named: a window held at a ceiling strips its oldest images as new ones come, as under any limit, but from deep in the request. So the prompt cache is rewritten from there at every new image. A run at 40c90e46 with every image limit at 0 and 550 images, and no line that decides which images are kept has changed since, found each new image moved the request's first change to message 100, then 102, then 104, of about 1,100.
    • The option docs and the breaking fragment now say this.
    • While imageStripHysteresisRatio is off, the memory strategy's line names it as what spaces those rewrites.
    • The ceilings stay at 500 and 24 MiB. The count margin is for what no compile counts, and the byte margin also holds the request's text at a 1M window.
  • R2-2: agreed, and it's the host's step. feat(recipe): imageCeiling and imageByteCeiling under agent.strategy, with the docs for 0 connectome-host#387 is a draft, since it needs a context-manager release that carries this change, and readying it takes the refresh you describe: a rebase onto the host's main, its docs and fragment aligned with this accounting, and the range raise in the same change.
  • R2-3: fixed in 40c90e46. The byte ceiling's doc and the note in src/live-image-limits.ts now say the shed replaces only membrane image blocks, top-level or in a tool_result. So a request built by membrane's openai-responses formatter, whose images are input_image parts, is neither shed nor checked against the cap at transformRequest, and that adapter doesn't check it again, by our reading of membrane 0.7.1, as yours found.
  • R2-4: retitled.
  • R2-5: fixed in 40c90e46, with your sentence.
  • R2-6: fixed in b6bd6773. The compression walk now runs with no cap too, keeping and counting every image. The 501-image test asserts imagesKept and imageBytesKept.

At 70bdf177 the whole suite passes on Linux with Node 24.22.1, 2,985 of 2,985, and the description has CI's legs.

— Daniel-2612

@nissa-seru
nissa-seru merged commit f387896 into anima-research:main Oct 24, 2026
7 checks passed
nissa-seru pushed a commit to nissa-seru/context-manager that referenced this pull request Oct 25, 2026
One conflict, in AutobiographicalStrategy.renderAsNewest's doc: main's
side carries anima-research#268's correction that the
tool pass left out of the render only pairs by the window, and what
sending prunes from a message's tool blocks reaches the render through
cappedForSend. This branch's side says the render covers a message the
compile sends raw, and names the trailing tool_use and the unanswered
tool_result the pairing leaves out. The resolution keeps both: the
pass pairs, the pruning comes through cappedForSend, and the two
pairing limits follow.

Read beside the conflict: the render's steps (cappedForSend,
mergeAdjacentBodyGroupRaw, applyImageStripping) are main's own. Its
applyImageStripping call passes no `returned`, so the dry render notes
no ceiling and says no line, as main's own test pins. A message of
system changes is kept raw as a pin keeps a message (resolvedPinBounds),
and the render gives every message raw. Compression input's dropping of
system changes, the head's sending of earlier changes ahead of a reset
head, and the observer's compile reports don't reach the render, which
renders the newest messages from the batch's oldest on.
nissa-seru pushed a commit to nissa-seru/context-manager that referenced this pull request Oct 25, 2026
main brings anima-research#268 (request image ceilings), anima-research#298 (system changes in
place), the 0.22.0 release, anima-research#335 (the context observer) and anima-research#339 (a flat
profile that can raise). Four conflicts, each taking both sides:

- src/strategies/autobiographical.ts, the AnsweredCall doc: main adds
  anima-research#268's ImageVerdict, CeilingKinds and StripReplay types above it; this
  branch's doc names a sharded message's first shard. Both kept.
- src/strategies/autobiographical.ts, the smaller-recent-window floor:
  main (anima-research#298) takes the newest conversation message, past the system
  changes after it, and steps a result's body back to the call before it
  by the result's first shard; this branch looks for the result on any
  of the body's shards (bodyHoldsToolResult) and steps back to the call's
  first shard that holds a tool_use or thinking (callStart). The
  resolution starts from anima-research#298's newest conversation message and steps
  back as this branch does. callStart returns 0 for a body at the store's
  start, where main's guard (newestBody > 0) kept the newest message; a
  body at 0 has no call before it, so the floor counts from 0, the whole
  body, as this branch counted it.
- src/strategies/autobiographical.ts, applyImageStripping's verdicts:
  main (anima-research#268) keeps each verdict whole, to credit the ceiling line, and
  reads a moved result's home from _movedFrom; this branch renamed that
  map _blockHome, which also places a joined body's other shards, and
  reads it through homeOf. The resolution takes anima-research#268's loop with homeOf.
  No other _movedFrom remains.
- src/adaptive/picker.ts, the smaller-recent-window doc: anima-research#298's system
  changes and the newest conversation message, with this branch's result
  on any shard and the call from its first shard that holds a tool_use or
  thinking, as the resolved floor counts it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/context-manager that referenced this pull request Oct 25, 2026
main brings anima-research#298 (system changes carried in place), anima-research#268 (image ceilings),
anima-research#335 (the context observer) and anima-research#339. One file conflicted,
src/strategies/autobiographical.ts, at two places:

- The head-tail reservation's floor: main's `newest` (the newest message
  that isn't a system change), with this branch's `filling ? headStart :
  Infinity` beside it.
- The hierarchical select's head walk. It keeps main's refusal when the
  system changes alone pass the grace limit, first, and this branch's walk
  of a filling head, priced and refused as a head and sent last. While a
  reset head fills, the walk is the window alone, so the system changes
  stored before it stay in the history, in store order, raw where they
  are. Once the head is full, it's main's headPositions, which takes them
  into the head's front. So each message stored after a change goes out
  after it while the head fills, the request stays append-only, and the
  changes move into the head inside the one rewrite that switch already
  costs (Oscar-1005's call, room-203 #210879). The other selects already
  do this after the merge: they read the windows from sentWindows, whose
  head is empty during a fill, and headPositions leaves the changes where
  they are when the window holds nothing.

The trim's doc (resetHeadWindowEnd) says that the messages pins keep raw
include every system change's, which the guard can reach over and the
trim leaves out to go after the history, still ahead of every message
stored after it.

Two of anima-research#298's tests met the fill:
- "after a head-window reset, the changes before the head are part of it"
  built a reset head the window could still hold, which now fills and sends
  store order. A newest message the window can't hold now ends the fill, so
  the test reads the head going first, as it meant to.
- "a change after the newest turn is no turn, so a structural repair that
  drops that turn still refuses" reset at a trailing change after an
  unanswered call. That head reaches the store's end, so it fills, the call
  isn't the last compiled message, and the pairing pass answers it with a
  stub: nothing is dropped, and nothing is refused. By run, that's what
  both selects send without a reset, at main and here; at main with the
  reset, adaptive refused and hierarchical sent the change and the first
  message alone, without the call. The test now asserts store order with
  the stub, with and without the reset, on both.

Tests (reset-fill-order): under each select, system changes stored before
a reset head stay in the history in store order while the head fills,
each compile appending, and go first in the head, in order, once it is
full; and the changes alone past the budget are refused while the head
fills, naming nothing that clears them (the hierarchical stage
hierarchical-system-changes).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants