Repository navigation
fix(images): image ceilings for the live window and compression prompts - #268
Conversation
…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>
…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>
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryAdds request image ceilings and keeps the live-window limits within them.
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
left a comment
There was a problem hiding this comment.
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:1152and:1172open with "The most images [inline image bytes] one request may carry".src/live-image-limits.ts:1-15and the changelog say the same. -
What's enforced is narrower:
- Both live strips count top-level
imageblocks only (src/strategies/autobiographical.ts:16095-16125,src/strategies/windowed-passthrough.ts:469-484). Adata:-URL image counts toward the number but not the bytes. test/request-image-ceilings.test.ts:129-154pins that nested and generated images pass a count ceiling of 1.beforeUser/afterUserinjections are spliced in afterselect()returns (src/context-manager.ts:1162,:1267-1293).
- Both live strips count top-level
-
membrane (v0.6.0 and main) measures
JSON.stringify(messages).lengthover the whole array, nested images included (src/utils/image-media.ts,serializedMessageBytes/assertWithinByteBudget). -
Failure scenario: an operator reads
maxRequestImageBytesas its doc describes it and sets it to matchMEMBRANE_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 referencesliveImageLimits,maxRequestImagesormaxRequestImageBytes. - Its continuation guard is a token-only projection (issue #92,
src/framework.ts:11537-11564). After that guard, it appends tool results withprovideToolResults(:11691-11708). Those results carry images nested intool_resultcontent, viatoMembraneToolResult→tryNativeToolResultContent(src/framework.ts:20357,:20408-20414), which is exactly the shape the accessor's figures leave out. targetis 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 underceilinghas to count the compiled request's images itself (injections and the window's uncounted shapes included). Only then isceilingthe one useful figure.- Failure scenario: a resident with
maxLiveImageBytes: 0compiles at 24 MiB of top-level images. A screenshot tool then returns 5 MiB nested in atool_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.
- Here: either report what the compile kept (images and bytes, per kind, on
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).maxLiveImagesandmaxLiveImageBytesare on the list;maxRequestImagesandmaxRequestImageBytesaren't. agent.strategyisn'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-49documents "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_BYTESor 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_KEYSentries, 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'smisfitBydoes the same at:16185-16189. - Probe on the merged build: two 8-byte images,
maxLiveImageBytes: 0,maxRequestImageBytes: 10,imageStripDepthTokens2000–3000. The window keeps.Iwith 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 inmisfitBy), and call it "returned" rather than "sent".
R1-5. A documented value changes meaning, so this needs a breaking fragment.
maxLiveImages: 0andmaxLiveImageBytes: 0were documented as unlimited.maxCompressionImageBytes: 0meant 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_BYTEShas 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.mdintorequest-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, inconfiguredImageLimits()(src/strategies/autobiographical.ts:16366).- So
config:effective(:2065-2079) shows nomaxRequestImagesormaxRequestImageBytes, 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 inimageLimitsDefaulted().
R1-7. applyImageStripping in windowed passthrough takes sent as a required positional argument.
src/strategies/windowed-passthrough.ts:440-445makes it required. The autobiographical strategy takes it as an option that defaults to not sent.- A trial merge of #275 onto
mainplus 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.
capCompressionImageBytesapplies one predicate toimageandgenerated_image(src/strategies/autobiographical.ts:16762). The PR says generated images count towardmaxRequestImages.- In
test/request-image-ceilings.test.ts:744-819, no compression case holds agenerated_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_imagetakes 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" viatransformRequest.- In membrane v0.6.0 that holds for native rounds (
transformRequestatsrc/membrane.ts:1884). XML continuations build throughbuildContinuationRequest(:2756) instead. The Anthropic adapter's ownassertWithinByteBudget(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:b28bddeamerges ontomainbf760112(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 andInfinitybudgets 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
:14132is 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.
- A dry select never reaches
- membrane's cap:
DEFAULT_MAX_REQUEST_BYTES = 28 * 1024 * 1024in 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.KnowledgeStrategyand connectome-host'sFrontdeskStrategyoverride 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
mainand 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()incompile(), 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.
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>
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>
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>
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>
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>
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>
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>
|
Replying to this review. @Anarchid Every finding is addressed in the commits now on this branch, R1-1: we took (a). The settings are now R1-2: 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 R1-4:
R1-5: R1-6: both ceilings are in R1-7: windowed passthrough's R1-8: a compression case where a newer R1-9: the note says where membrane checks: The description's Tests section has the runs at this head. Please re-read it at — Daniel-2612 |
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>
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.
|
@Anarchid This pull request has changed since we asked you to re-read it at
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:
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 |
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).
|
@Anarchid The head to re-read is now Written by Milo-2653 (Claude Opus 5.5). — Daniel-2612 |
Anarchid
left a comment
There was a problem hiding this comment.
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:
ruleCeilingHitsreplays when a ceiling sets a limit, the line is still unsaid, the render replaced an image, andatLimits.involved[kind]is true (src/strategies/autobiographical.ts:17967). - The mismatch:
involvedis 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
stripReplayHeldinside 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: 10ran 0 replays.
- Config: memory strategy with
- 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.mdsay 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
stripReplayHeldat 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 isceilingsDeciding's rule applied per trim, with no counterfactual replays. The record replacesinvolved, and the extrastripReplayHeldcalls 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.mdthat the line can stay unsaid, and the replay keep running, for the life of the instance.
- (a) Credit from the replay that already runs. At each strip step in
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,RecipeStrategysays 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
mainand conflicts indocs/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.strategyunknown-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-40andsrc/types/strategy.ts:1591-1595say a request that setsshedOversizeImages"has its oldest images replaced until it fits or none is left".- In 0.7.1,
shedImagesToFitByteBudgetonly recognizes blocks of typeimage, top-level or inside atool_result(src/utils/image-media.ts:315-334). - When the adapter needs native Responses input,
resolveActiveFormatterswaps this package'sNativeFormatterfor theopenai-responsesformatter (src/membrane.ts:844-851). That formatter emitsinput_imageparts (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
imageblocks 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 ofmaxLiveImages,maxLiveImageBytesormaxCompressionImageBytesbypassesresolveLiveImageLimits. 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 ondryRun. BothrenderAsNewestpaths strip without reporting. - The
03a5f779merge putsreportCeilingHitsafter thefinallythat clearsthinkingBound. That's harmless:thinkingBoundonly reachesliveCarrierPolicy, which renders summary carriers (:15768,:19431), not the stored images the credit replays. - Subclasses: connectome-host's
FrontdeskStrategycallssuper.select, andKnowledgeStrategyinherits. Neither overrides the changed protected methods. - Membrane check points (except R2-3):
transformRequestcovers the first request and native rounds; XML continuations go throughbuildContinuationRequestand 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-frameworkmain, and connectome-hostmainplus #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
imageStripReplayoverride. 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.
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.
|
Replying to this review. @Anarchid At
At — Daniel-2612 |
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.
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>
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).
Two image ceilings,
imageCeilingandimageByteCeiling, 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.
9aa00fb5imageStripHysteresisRatio, by replaying the set counterfactually.imageStripHysteresisRatiois 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.imageblocks: a request built by membrane'sopenai-responsesformatter is neither shed nor checked.The commits:
0b808673takes the credit from each trim (R2-1),b6bd6773fills the kept counts (R2-6),40c90e46is R2-3 and R2-5,186641bcreplaces the per-image attribution with the rule above, and adds the cost and the clause, and70bdf177answers our own review of the round.What changed since
f914a99dmainhas gained #297, which computesimageStripHysteresisRatio'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, asb54138d8(#219) and01d830c5(mainfa6e0d6a). The merge changes what you were asked to re-read atf914a99din three ways, all in the line a ceiling says (R1-4):f914a99dUnder #219's accounting, the ceilings count what the window's limits count, as
b41c4369set out.Since
b7e73124, this branch has also mergedmain4fbe6c94, which brings the 0.21.0 release and #331's thinking binding, as03a5f779. 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:
MEMBRANE_MAX_REQUEST_BYTESsays otherwise. membrane checks it when it builds a request throughtransformRequest, 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 throughtransformRequest. A request that setsshedOversizeImages, as this package's compression requests do, has its oldest membraneimageblocks replaced attransformRequestuntil 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'sopenai-responsesformatter carriesinput_imageparts instead, which aren't shed, and isn't checked against the cap.The live window's limits,
maxLiveImagesandmaxLiveImageBytes, are targets a compile keeps to, and nothing bounded a request beyond them:Changes
imageCeilingandimageByteCeiling, onAutobiographicalConfigandWindowedPassthroughOptions: the most images, and the most of their inline image bytes, a strategy's live window keeps, whatevermaxLiveImagesandmaxLiveImageBytessay, 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, inDEFAULT_AUTOBIOGRAPHICAL_CONFIG, soconfig:effectivereports the ceilings, from the library's default layer unless the host gave them. A ceiling the host passes asundefinedornullis 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.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.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.ImageCeilingsis exported.imageCeilingin 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.maxCompressionImageBytes, is kept withinimageByteCeiling.imageStripReplayisn't consulted for the line.imageStripHysteresisRatiois 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.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.imageblocks, so a request built by itsopenai-responsesformatter, withinput_imageparts, is neither shed nor checked.What hosts newly see (the
breakingfragment says who acts, and how):maxLiveImagesormaxLiveImageBytesof 0 was documented as no limit; to keep that, a host sets the ceilings to 0 as well.40c90e46with 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.imageStripHysteresisRatiospaces 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.maxCompressionImageBytesthat isn't a positive finite number (0, a negative,NaNorInfinity) now means up toimageByteCeiling. It had meant no budget, or forNaN, by our reading, that every image was replaced.What each part is for
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.{ out, over }: a compile computes the set once and the line reads it, soimageStripReplaykeepsmain'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.reportCeilingHits, so it's said only for a render a select returned, and not for a dry select, a preview or a refused one.imageStripHysteresisRatioyet. 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.ceilingLinesrecords 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.ruleRunadds 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_LINEis words every ceiling line holds, and no other line has.Enforcedsubclass gives a value through a layer that isn't the caller's, the one way to reach that mark.config:effectiveline, the one place the default layer's values are reported.resolveLiveImageLimitsdirectly, since the window's own limits are no longer public.imageblocks shed first and is refused only by the Anthropic adapter if it's still over, and that a request built by theopenai-responsesformatter 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.DEFAULT_AUTOBIOGRAPHICAL_CONFIG, soconfig:effectivereports the ceilings in force, unless the host passes one asundefinedornull.maxCompressionImageBytespast it could otherwise build a prompt past membrane's cap. Compression requests setshedOversizeImages, so by our reading membrane would then replace itsimageblocks 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
70bdf177all five pass. Run 40073240806 checked out10c3bdc, GitHub's merge of70bdf177intomain4fbe6c94. 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, at70bdf177, from a clean tree.request-image-ceilings81/81,live-image-accounting47/47,image-stripping3/3,image-strip-hysteresis32/32,image-strip-render-stats3/3,post-strip-calibration4/4,compression-request-preparation13/13,compression-image-cap-wire2/2,render-as-newest50/50,raw-forms55/55,config-provenance35/35,capped-message-price86/86,malformed-content22/22,view-composition28/28,kv-unified-render-state54/54,body-run-price10/10 andcalibration-band35/35.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.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,NaNorInfinityread as none, so up to the ceiling, and a malformed ceiling taking its default; fractions floored, to at least 1.imageCeiling, a count above the ceiling keeps the ceiling's worth, and an unlimited byte budget keeps the newestimageByteCeiling, 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.imageStripHysteresisRatio, and windowed passthrough's doesn't.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.maxLiveImagesread 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.imageCeiling, newest messages first; count a nested image, and agenerated_image, each of which takes the one slot over an older top-level image; keep their byte budget withinimageByteCeiling, keeping what the clamped budget lets through, with a budget of 0,Infinity, a negative orNaNas 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.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.main4fbe6c94's versions of the five sources this change edits and this branch's tests: 67 of the 81 new tests fail, and so doescompression-request-preparation's log-line test. Of the 14 that pass, 9 are whatmainalready does: the no-limits controls for each strategy's window, and the seven that the ceiling line stays quiet, sincemainhas no line. The other five testsrc/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.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 servestream()the same way.Not verified
Merge notes
main4fbe6c94, four conflict in more places with this change than withmain. Each was merged onto this change's head and ontomainalone, and their conflict regions compared. Two others conflict withmainalready, with as many conflict regions in the same files either way, and six merge cleanly both ways. By our reading, each of the four resolves by taking both sides, whichever lands second:renderAsNewestgivesnullonly for a message the next compile doesn't send): one region, the method's doc, at its head merged withmain4fbe6c94. Its paragraph is fix(render): renderAsNewest gives null only for a message the next compile doesn't send #314's, with this change's correction that the tool pass the render leaves out only pairs, and that what sending prunes reaches it throughcappedForSend. fix(render): renderAsNewest gives null only for a message the next compile doesn't send #314's render calls the strip with noreturned, so a render says no ceiling line, as here.maincleanly. One is where both add types above the tool-call map. The other is the verdict loop, where fix(shards): a sharded body keeps each block in place and goes out as given, and window edges keep a tool call with its results #310 finds an image's message withhomeOfand this change notes which ceiling-set limits a replaced image is over; the loop takes both.reportCeilingsand feat(derive): a context manager derived from a live one — shared reads, separate writes, the rendering frontier reused (Dendrite) #238 adds its derivation and rendering-frontier methods. A context it derives runs the strategy its caller gives it, and a new instance starts with no line said, as after a reload.mainsince (b54138d8,01d830c5). The second merge's message names each conflict and its resolution.main4fbe6c94in03a5f779. Its four conflicts were each where both sides add: the type exports insrc/index.tsandsrc/types/index.ts,ContextStrategy's optional members and the types after it, and the start of the memory strategy's live select, which clears the ceiling notes and setsthinkingBound.capCompressionImageBytes, git merged fix(images): count every image in the live window's limits, price a generated image as an image, and estimate by the strip's depth rule #219's loop without a conflict and without this change's count ceiling, so the first merge adds the count to fix(images): count every image in the live window's limits, price a generated image as an image, and estimate by the strip's depth rule #219's predicate. The compression tests fail without it.main, its docs and fragment aligned with this accounting, and the range raise in the same change.f914a99dsaid. Under fix(images): count every image in the live window's limits, price a generated image as an image, and estimate by the strip's depth rule #219's accounting, windowed passthrough'srenderAsNeweststrips throughstripImagesand onlyselectreports a ceiling, and the memory strategy's strips withoutreturned, so neither render says the line, as a test shows for both.changelog.d/request-image-ceilings.added.md,changelog.d/request-image-ceilings.breaking.md,changelog.d/request-image-ceilings.changed.mdandchangelog.d/request-image-ceilings.fixed.mdFound 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.