Repository navigation
fix(reset): send store order while a reset head fills, and keep a reset head's messages whole - #315
nissa-seru wants to merge 15 commits into
Conversation
…et head's messages whole After resetHeadWindow(), every select sent the new head window first. While that head still held every message since the reset, the newest messages went in ahead of the old history, so the request ended on the old topic. And the head ended wherever its tokens ran out: a transition bigger than the window left the hierarchical select's head empty, sending the transition after the history, and the adaptive selects ended the head inside a long transition or first message, sending its rest after the history. - sentWindows(): while a reset head fills (headStart > 0 and the head window reaching the store's end), a compile sends store order: the history before the reset, then the head window last, as the tail. Each select and renderAsNewest read the windows through it, so the kv-unified solvers and their cache check see the layout that's sent, and no solver changes. Once a message no longer fits, the head goes first as before. Compression still reads the head window itself. - Render stats and the adaptive select's refusals still count a filling head as the head, and the reservation refusal's floor counts from its start, so such a refusal names no smaller recent window. The hierarchical select sends a filling head, last, through the head's own walk, so it is sent whole or refused as a head is, not evicted as a recent window is; that refusal names compression when queued work would fold the history sent before it. - resetHeadWindowEnd(): after a reset the head holds whole bodies. The transition's body is always whole; then whole bodies while they fit, ending before the first that doesn't, or before the body ahead of it when that one holds a tool call. headStart 0 and anchorHeadToCoverage are unchanged. - Tests: what each select sends through a fill, with a long transition, with a long first message and with a tool call that doesn't fit; the reset's own compile keeping the history's bytes; a filling head's stats, its refusals under the adaptive and hierarchical selects, and renderAsNewest; headStart 0 still ending inside a body. Tests that read where a reset head starts from the first message sent now add a message past the head first; renderAsNewest's and kv-unified's reset tests are turned round for the fill, and kv-unified gains one for the markers it credits during the fill. - Docs for resetHeadWindow() and headWindowTokens; changelog fragment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ad first, the ratchet guard gets the right count, docs say what holds - The hierarchical select prices, refuses and tallies a filling reset head in its head walk, ahead of the middle's budget, as when the head goes first, and sends the entries last. So the history is held to what's left beside the head, a head that doesn't fit refuses as a head, and a refusal in the history reports the head as the head. The closure that walked the head at the tail, and its own compression lever, are gone. - While a reset head fills, the hierarchical select marks the history's end as the cache boundary. Its only marker was on the head's last entry, so a fill sent none. - resetHeadWindowEnd gives the ratchet guard the tokens through the boundary's own message, as without a reset; it gave the whole crossing body's, which the guard then counted again, so a sharded body could stop the extension early. Its walk back for a tool call stops at the head's start, so a strategy reset to a later shard can't end the head before it starts. - Docs: a reset head is whole only with a head window above 0; each compile decides the fill from the head window's end as it prices it; the ratchet guard can extend a reset head to where coverage resumes; renderAsNewest's doc names the filling head. The fragment says the same, and that a store whose reset head ends inside an already chunked message keeps that end. - Tests: the whole order once the head goes first; the tool call's result itself, right after the call; a hold inside a filling head under each select; the hierarchical refusals and cache boundary through a fill; a strategy reset to a later shard of a tool call; a filling head in a preview and in the plan's refusal; the ratchet guard after a reset, with a sharded body in the head; headStart 0's head through render stats. kv-unified's fill test now says what the solve credits: nothing at the reset's own compile, whose accepted layout opens with the old head's unit, and the history from the next compile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t the start do, pin the hierarchical head's own refusal - resetHeadWindowEnd's doc: the guard can extend a reset head to the first message coverage owns after it, which isn't always a body's start; the count it's given runs through the boundary's own message, one fewer than without a reset when a tool call moves the boundary. - The whole-transition rule holds with history before the reset (a reset at the store's first message keeps the ordinary head), in resetHeadWindow's and headWindowTokens' docs and the fragment; the fragment's upgrade note says a transition goes whole into the head even where its rest is already chunked. - The hierarchical select's image-strip comment names its one cache marker instead of saying it places none. - Tests: the hierarchical select refuses a filling head the budget alone can't hold in its head walk, before the history, naming nothing; the kv-unified fill test checks the next fill compile adds to the last one's bytes; the hold test says what it checks and releases its hold in a finally. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d would add only pinned shards of it The ratchet guard extends a head over unowned messages to where chunk coverage resumes. After a reset that can land inside a body: a host pins a long message's first shard (the id addMessage returns) while the recent window holds it, and once compression chunks the rest, coverage resumes at the second shard. The guard then ended the reset head after the pinned shard, and the compile sent the body in two parts with the history between, as main does. resetHeadWindowEnd now stops at the body's start when every message the extension adds of that body is kept raw by a pin: no chunk can take a pinned-raw message (getCompressibleMessages), so leaving it out of the head mints no out-of-order L1 (anima-research#122), and the body is sent whole after the history. An unpinned run there, left by an earlier version's head or by a pin since removed, keeps the extension, as the ratchet needs. - resetHeadWindowEnd's doc and the fragment say when the extension can reach inside a body and what happens in each case; the doc's "already owned shard" goes, since the guard leaves a boundary on an owned message where it is. - Tests, under the adaptive and kv-unified selects: with B's first shard pinned while recent and its rest chunked, the head ends at B's start and B is sent whole right after the history; once the pin is removed, the head keeps the first shard up to where coverage starts, and no chunk takes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
Recommendation: fix, then merge. 1 blocker should be fixed first.
Reviewed commit 9dfd2d3 by reading the code, in four stages: a plan by claude-opus-5-5; two readers on every task of it, gpt-6.1-sol (two tasks per call) and claude-haiku-5-5; their findings grouped into issues; and a judge, claude-opus-5-5, that tried to refute each issue. It did not run the code or look at the checks.
Blockers (1)
- Blocker. Whole-body exclusion routes a body's first shard into the text-only middle emitter, dropping its non-text blocks.
src/strategies/autobiographical.ts:18172
Should fix (4)
- Should fix. Coverage anchor can end the reset head mid-body, splitting a body around the history, though the docs say it cannot.
src/strategies/autobiographical.ts:18184 - Should fix, test. No test covers a tool call earlier in the preceding body, so a narrower tool-call scan would pass unnoticed.
test/reset-fill-order.test.ts:152 - Should fix, test. Post-plan refusal diagnostics for filling resets have no test, so reverting their head and tail assignments passes.
src/strategies/autobiographical.ts:13531 - Should fix, text. Changelog claims the reset keeps the history prefix, but a subsequent reset reorders the previous reset's head.
changelog.d/reset-fill-order.changed.md:3
Minor (11)
11 minor issues
- Minor. Tool-call walk-back empties the reset head when the transition's own body holds a tool_use, despite the always-whole claim.
src/strategies/autobiographical.ts:18148Cause: Walk back in resetHeadWindowEnd sets the boundary to startIdx when the transition's own body holds a tool_use, leaving the head empty. Consequence: Transition is sent after the history instead of whole at the head's front; Changelog and description claim the transition is always whole, which the code does not honor; No test covers the exception. Case: strategy.resetHeadWindow(id) names a tool_use message after history with the head window above 0. Expected: the transition is sent whole at the front of the head. Actual: the head is [startIdx, startIdx), so the transition is not in the head and is sent with the middle or the tail, while the changelog and description say it is always whole in the head. Found by claude-haiku-5-5 alone. - Minor. Fill layout is recomputed each compile from calibration-dependent estimates, so it can flip back and forth.
changelog.d/reset-fill-order.changed.md:3Cause: Fill predicate is recomputed per compile from calibration-dependent token estimates, so the layout can flip between store order and head-first with no history change. Consequence: Same reset's request is rewritten repeatedly between layouts; Changelog's single-rewrite claim is contradicted; Head-first is not one-way, although the description says a message that no longer fits goes first again. Case: With a 4000-token head, let the transition and opening total just under 4000 at multiplier 1, then append a crossing message that brings the total to about 4090: this compile sends head-first. An accepted calibration sample lowering the multiplier to 0.95 makes the same suffix fit, so the next compile sends store order again; a subsequent calibration rise or another small message sends head-first once more. The pre-reset history is unchanged, yet each switch rewrites its request prefix. This is permitted by the per-compile implementation and acknowledged in description line 50, but contradicts the release fragment's unqualified statement that the head-order move rewrites the request once. Found by both readers (gpt-6.1-sol and claude-haiku-5-5). - Minor, text. Upgraded stores do not keep main's former head end once a later body is already chunked.
changelog.d/reset-fill-order.changed.md:5Cause: Upgrade path: anchorHeadToCoverage returns the first owned shard or the owned proposed boundary, not main's former head end, so the upgrade promise fails. Consequence: Head ends earlier than main (for example at B0 or an earlier owned shard) so previously raw head shards become foldable; Changelog's keep-the-end promise for upgraded stores is not met; Description's compression-does-not-move claim needs qualification for the eligibility change. Case: A main store can have records owning an entire later sharded body B from an earlier, higher calibration, followed by a calibration drop that lets main's head grow over B's first two shards. For example, with a 4,000-token head, a 100-token transition and 1,500-token shards at the current calibration, main ends at B2 even though B0 onward is already owned. On upgrade the whole body does not fit, so the proposed boundary is B0; anchorHeadToCoverage immediately returns B0 because it is owned. The store therefore does not keep B2 as the changelog promises, and previously raw head shards become foldable middle. Independently, an unowned proposed boundary can stop at an earlier owned shard or fail the 2× cap. The upgrade note needs these qualifications, and the description's assertion that compression does not move needs to distinguish sentWindows' ordering change from resetHeadWindowEnd's eligibility change. Found by both readers (gpt-6.1-sol and claude-haiku-5-5). - Minor. Unconditional leading-result trim in emitRecentNewestFirst drops a result whose call is still emitted.
src/strategies/autobiographical.ts:16690Cause: emitRecentNewestFirst's unconditional leading-result trim removes a tool result even when its matching call immediately precedes it. Consequence: False 'Tail emission dropped reserved recent-window messages' OverBudgetError under adaptive and kv-unified with no budget eviction. Case: Under adaptive or kv-unified selection, store short messages [history, assistant tool_use(c), user tool_result(c)], then call the public strategy.resetHeadWindow(resultId) with a head window large enough to hold the result and a generous budget. The intended filling layout is [history, call, result], an already valid adjacent pair. emitRecentNewestFirst nevertheless shifts the result out of its accepted list without inspecting the preceding entries, and selectAdaptive throws 'Tail emission dropped reserved recent-window messages' despite no budget eviction. Main takes the head-emission path for the result and completes rather than throwing this error; hierarchical's new filling-head path also avoids this trim. Found by gpt-6.1-sol alone. - Minor. Over-cap guard fallback retreats the reset head to a body start, releasing never-chunked opening shards to compression.
src/strategies/autobiographical.ts:18172Cause: Guard's over-cap fallback returns the proposed body-start boundary instead of main's former boundary, so never-chunked shards before it lose protection. Consequence: Previously raw opening shards become compressible and can be folded late; A new chunk record is appended over shards that were protected, after later records; Pinned portion stays raw while the prefix is summarized, a #122 ratchet regression. Case: Use an adaptive main store with headWindowTokens=5,000, a 100-token transition, and a later body B whose shards each cost 1,000 tokens: main keeps B0–B3 raw and ends at B4. Let B4–B11 be raw-pinned and existing records own B12 onward, with the recent window after B. This state can be established by holding B while placing the pins, then releasing the hold. On main, the capped guard returns B4, leaving B0–B3 protected. At head, resetHeadWindowEnd proposes B0; while seeking coverage at B12, the guard exceeds 10,000 tokens at B9 and returns B0. getCompressibleMessages now includes B0–B3, and rebuildChunks closes and appends a new record over those four formerly protected shards after the later records. Once holdback is released or the L1 is demanded, that prefix can be summarized and folded while the pinned portion stays raw. Preserve the legacy raw prefix even when extending all the way to coverage would exceed the cap; this is the late-opening-chunk part of the #122 ratchet. Found by gpt-6.1-sol alone. - Minor, test. Ratchet tests never check refusal, so an undercount that omits the boundary message would pass.
test/reset-fill-order.test.ts:309Cause: Reset ratchet tests assert only successful extension, leaving the inclusive boundary-message count unprotected. Consequence: Undercounting the boundary message that wrongly permits extension beyond the guard's cap would pass the tests. Case: Changing the new count to exclude the boundary message, or passing zero, still permits the extensions these tests expect. A distinguishing case has headWindowTokens=100, a 40-token transition, an unowned 150-token crossing message, another unowned 20-token message, then owned coverage: the inclusive total reaches 210 and must retain the original boundary, whereas omitting the boundary message lets the guard extend to coverage. No reset fixture checks that refusal to extend. Found by gpt-6.1-sol alone. - Minor, test. Marker-credit test checks only churn positivity and a ratio, so partial prefix credit on the reset solve would pass.
test/kv-unified-render-state.test.ts:925Cause: Marker-credit test does not compare reset churn with the fully uncached carried cost or the next fill with the accepted wire boundary. Consequence: Crediting a partial prefix on the reset compile would still pass the test; Next-fill ratio check can pass despite the wrong credit. Case: Keep the expected number of markers reaching the solve, but incorrectly credit one of the old history prefixes on the reset compile while leaving some of that request uncached. atReset.cacheChurn remains positive, and a correctly cached next fill compile can still satisfy the less-than-one-tenth assertion, so the test passes despite violating its 'none of them here' claim. Compare reset churn with the independently computed fully uncached carried cost, and next-fill churn with the accepted wire's surviving boundary rather than only comparing their ratio. Found by gpt-6.1-sol alone. - Minor, text. Changelog cache explanation omits that moving the cache marker rewrites the XML formatter's provider prefix.
changelog.d/reset-fill-order.changed.md:3Cause: Changelog's prompt-cache explanation ignores that moving the cache marker rewrites the XML formatter's provider prefix. Consequence: Operators may infer a cache benefit from append-only ordering that does not hold on the XML formatter; Marker move changes the first history text block after the system prompt. Case: Under the hierarchical select, compile a conversation whose ordinary head holds only the first few history messages, then reset and compile a filling head without folding anything. The sole cache marker moves from the old head's last entry to the history's last entry. Membrane's XML formatter flushes the accumulated transcript at each cacheBreakpoint, so the old first history text block is replaced by a larger block containing all history; the provider prefix changes immediately after the system prompt. Operators reading "compiles while the head fills add to the end" and the contrast with the old reset-time rewrite would infer a cache benefit that does not hold on this supported formatter. The PR description acknowledges the bytes-versus-markers limitation, but the release fragment does not. Found by gpt-6.1-sol alone. - Minor. Reserving the whole reset transition as head can push headTokens plus tailTokens past rejectionBudget and refuse a compile main sent.
src/strategies/autobiographical.ts:12973Cause: Reservation counts the whole reset transition against the reservation check although it can exceed headWindowTokens. Consequence: Adaptive select refuses compiles that main sent by ending the head inside the transition; Changelog and change text do not name this refusal change. Case: With headWindowTokens 4,000, recentWindowTokens 1,000 and a transition of 34,920 characters followed by a short message, main compiles, sending the transition's first shard, the history, and the rest. This change reserves the whole transition as head; if head plus tail exceeds rejectionBudget, the select returns an over-budget refusal where main returned a request. This is inferred from the diff and the reservation check, not run; the head-sum code around lines 12950-12965 was not read. Found by claude-haiku-5-5 alone. - Minor, text. Description claims the reset ratchet count matches the non-reset count, which differs when a tool-call pull-back moves the boundary.
src/strategies/autobiographical.ts:18138Cause: Description's count parity claim ignores that tool-call pull-back moves the boundary, so the reset count excludes the crossing message the ordinary path includes. Consequence: Maintainers comparing or consolidating the two paths are misled; Extension limits of the two 2x-window paths differ in a way the description does not state. Case: When a tool result overflows the window after its tool call, the ordinary getHeadWindowEnd retreats boundary to the call but passes tokens accumulated through the result. resetHeadWindowEnd instead recomputes through only the call at the retreated boundary. The new code comment explicitly documents this difference, whereas the description asserts parity, misleading a maintainer comparing or consolidating the two paths and their 2×-window extension limits. Found by both readers (gpt-6.1-sol and claude-haiku-5-5). - Minor, text. Test comment misstates the head window size and the message count that ends the fill.
test/reset-fill-order.test.ts:83Cause: Test comment sizes the fixture's head window and message count incorrectly for the 4,000-token window. Consequence: Maintainers tuning message sizes or the window will misjudge where the fill ends; The assertion that the fourth message ends the fill rests on a size the comment does not give. Case: A maintainer who trusts the comment and tunes the message size or head window will misjudge where the fill ends; the assertion that the fourth message ends the fill rests on a size the comment does not give. Found by claude-haiku-5-5 alone.
What the review couldn't read
Two or more readers said they couldn't cover these changed files, and no issue cites them: test/head-window-reset.test.ts.
The plan's note: The change fits in one area (diff ≈50.7k chars), so there is a single area, all. It has no generated, lockfile or vendored files. src/strategies/autobiographical.ts is over 19k lines, so its file slices give line ranges at head: the changed hunks plus the readers next to them (kvUnifiedCacheForSolve, getCompressibleMessages, image-strip bucketing, anchorHeadToCoverage). The description names test/overbudget-diagnostics.test.ts among the removal-variant runs, but the change doesn't touch it, so it is not in a tests slice. agent-framework's renderForInjection is not in the repository, so the description's claim about how it consumes renderAsNewest can't be checked here.
What each reader said it couldn't cover (10)
- A:adaptive-select+hierarchical-and-render: The external agent-framework renderForInjection consumer claimed in change-text.md:21 was not available in the provided source.
- A:filling-head-region+q1: agent-framework's renderForInjection implementation is not present in this checkout, so the claimed behavior of that external live-injection consumer could not be verified.
- A:whole-nolens+text: The author-reported npm test/tsc results, removal-variant executions, and out-of-PR performance probe could not be reproduced with the supplied read-only tools.; The external agent-framework renderForInjection integration and the implementations of the open #298/#314/#310 changes are not available in this checkout.; Historical commit/dependency state, including the introduction cited as 3e42e98 in the surrounding hierarchical fallback comment, is not accessible through the supplied file-reading tools.
- B:whole-nolens: src/strategies/autobiographical.ts lines 12947-12965 (selectAdaptive's tail-set loop): not checked whether it starts at recentStart or at max(recentStart, headEnd), which decides whether reportedTailTokens counts history when a compression hold pulls recentStart below headStart.; kvUnifiedCacheForSolve (about lines 14950-14974) and the kv-unified head/middle/tail layout were not read; the marker-credit claims in the description are unverified.; requestCarriesToolBlocks (about 12821-12825), generateTransitionSummary (about 16706-16709) and the summarizer head emission (about 9133-9136 and 11885-11888) still read the stored head window; I did not check whether any of them misbehaves during a fill.; test/head-window-reset.test.ts: only the diff hunks were read. I did not check that pastTheHead's message is large enough to end the fill under each test's headWindowTokens.; changelog.d/README.md: the fragment's nested two-space bullets were not checked against the format rule.; agent-framework's renderForInjection is outside this repository and was not verifiable, as the plan says.; Only selected code paths were checked; no tests were run.
- B:filling-head-region: kvUnifiedCacheForSolve (autobiographical.ts ~14950-14974): its headLast and marker-dropping under a fill were not checked against the accepted layout beyond the description's account.; selectHierarchical middle walk below the exclusion set (~15905 onward): how [0, headStart) is walked and bounded when headEnd == headStart under a fill was not read; the tests reportedly cover store order through a fill.; getCompressibleMessages (~16828-16842), requestCarriesToolBlocks (~12821-12825) and generateTransitionSummary (~16706-16709): read-only check against the stored head window was not done.; selectAdaptive's tail emission path (emitRecentNewestFirst and its eviction when the filling head is emitted as the tail) was not read, so whether the head can be evicted there was not verified.; The adaptive refusal floor and the diagnostics swap were only read from the diff, not traced through selectAdaptive's budget code.; The new test file test/reset-fill-order.test.ts and the other test edits (diff lines ~481-808 and the earlier test hunks) were not reviewed for tests that cannot fail.; The cache figures, the #298/#310/#314 interactions and the agent-framework renderForInjection claim cannot be checked from this repository.
- B:tests: test/head-window-reset.test.ts: only the hunks in the diff were read; the full file was not, and the four changed tests were not traced to their assertions.; test/kv-unified-render-state.test.ts lines 870-960: read only as the diff hunk (lines ~904-938 of the head file); the helpers solved, solveMarkers, reference, firstChange and add were not read.; test/render-as-newest.test.ts lines 340-380: read only as the diff hunk; the sent() helper was not read.; test/reset-fill-order.test.ts: all of it was read as the diff, but the hierarchical Phase 0 rsRaw('head') accounting, the render-stat fields (middleRaw, head.messages) and the preview's headTokens were not checked against source.; Source not checked: anchorHeadToCoverage (autobiographical.ts:18216-18248), selectHierarchical's emit path and assertFullCoverage, requestCarriesToolBlocks, generateTransitionSummary, and renderAsNewest's call site. Whether the hierarchical and kv-unified fill tests fail on main was not checked.; Not checked: the premise that each test fails on main, the 16 removal variants, and the perf figures in the description; I did not run any test.
- B:reset-head-end: test/reset-fill-order.test.ts and the other changed test files (head-window-reset, render-as-newest, kv-unified-render-state, overbudget-diagnostics): none read, so the test claims and the removal-variant counts are unchecked.; kvUnifiedCacheForSolve (autobiographical.ts ~14950-14974) and the kv-unified solvers: not read, so the marker and cache-boundary claims are unchecked.; selectAdaptive's fill branches (~12945-13216, including the refusal and reservation-floor logic) and the rest of the hierarchical select (15801-16368 apart from the head walk and the filling emission): only partly read.; summarizer head emission (9133-9136, 11885-11888) and generateTransitionSummary (16706-16709): not read beyond the grep hits; they still read the head as stored during a fill.; read_diff was not used; the diff was judged from the head files, so anything the diff changed outside these ranges was not checked against the base.; The renderForInjection consumer is outside the repo, as the planner said, so the claim about agent-framework's tool boundary is unverified.
- B:hierarchical-and-render: read_diff lines 32-329 (the autobiographical.ts changes) were not read in full; I checked the four assigned regions and the code they call.; kvUnifiedCacheForSolve (autobiographical.ts:14950-14974) and its marker credit during a fill were not checked.; requestCarriesToolBlocks (12821-12825) was only read at its head; whether its head-as-stored answer changes compression during a fill was not traced.; The test files (test/reset-fill-order.test.ts and the other tests the description names) were not read, so the claimed test counts and removal results are unverified.; agent-framework's renderForInjection is not in this repository; the description's claim about it could not be checked.; The compression-hold path (getRecentWindowStart pulling recentStart before headStart, with the middle's excludeIds) was reasoned about but not traced to assertFullCoverage.
- B:q2: The diff itself: I did not call read_diff, so I did not read the hunks or the test files (test/reset-fill-order.test.ts and the others).; getCompressibleMessages (autobiographical.ts 16828-16842), the summarizer head emission, requestCarriesToolBlocks, generateTransitionSummary, and the hierarchical and kv-unified solver code: the planner's notes describe them, but I did not read them at head or base.; The base version of getHeadWindowEnd, to compare main's mid-body end directly; I relied on the planner's description of main.; Whether agent-framework's renderForInjection consumes renderAsNewest's output (not in this repository, as the planner says).
- B:q1: diff lines 370-808 (later test changes, including the new test file and the kv-unified test, were not read); kvUnifiedCacheForSolve (autobiographical.ts about 14950-14974) and the kv-unified solver's use of the windows; getCompressibleMessages (16828) and tick's summarizer prompt head emissions (about 9133 and 11885): whether compression reads the stored head before or after selectAdaptive's loadCalibration in the same compile; the image-strip policy after bucketAt (17322-17345): whether 'head' and 'tail' buckets are kept or stripped differently, which matters because the filling head is bucketed 'head' but sent last; requestCarriesToolBlocks (12821) and generateTransitionSummary (16706); agent-framework renderForInjection: not in this repository or node_modules
What went wrong along the way (1)
- Grouping: these findings are listed more than once: B:adaptive-select[1].
| const to = bodyEnd(messages.length, at, from) + 1; | ||
| for (let k = from; k < to; k++) tokens += store.estimateTokens(messages[k]); | ||
| if (tokens > this.config.headWindowTokens) { | ||
| let boundary = from; |
There was a problem hiding this comment.
Blocker. Whole-body exclusion routes a body's first shard into the text-only middle emitter, dropping its non-text blocks.
Cause: Whole-body exclusion from the reset head moves the first shard, with its non-text blocks, into the adaptive middle, whose raw emitter keeps only text.
Consequence: Image and other non-text blocks on the first shard are silently dropped, with no omission placeholder; Tool and reasoning blocks on that shard are also lost; Coverage check passes on shard IDs despite the missing block; kvUnifiedKeepEntries reproduces the same omission; The text-only longfirst test cannot catch the regression.
Case: Use headWindowTokens=4000, recentWindowTokens=1000, targetChunkTokens=1500, a generous compile budget, no completed compression, and disabled image stripping; after some history and a short reset transition, add a long, sharded text message with a small image block. Its first shard fits the old head rule and retains the image at base, but the new whole-body rule excludes the entire message from the head. When shard 0 is outside the recent window, addRaw filters its content to text and flushRun emits only that text; no compiled entry contains the image, although coverage passes using the shard IDs. kvUnifiedKeepEntries reproduces the same omission. The raw body's non-text blocks must survive this newly introduced region change rather than depending on the separate, unmerged #310 fix.
Found by gpt-6.1-sol alone.
| // message more when the walk back for a tool call moves the boundary.) | ||
| let through = 0; | ||
| for (let k = startIdx; k <= boundary && k < messages.length; k++) through += store.estimateTokens(messages[k]); | ||
| return this.anchorHeadToCoverage(store, messages, boundary, through); |
There was a problem hiding this comment.
Should fix. Coverage anchor can end the reset head mid-body, splitting a body around the history, though the docs say it cannot.
Cause: anchorHeadToCoverage returns the first owned message after the boundary at any position, not a body start, so the reset head can end inside a body; Change text, changelog and API docs assert that the coverage extension reaches only body starts and holds whole messages.
Consequence: A body is sent in two parts with the history between them (head, history, rest of body); Occurs in fresh stores after pin and chunk sequences, not only in upgraded stores; Tool-call pull-back can be undone so a tool_use stays in the head while its result falls outside; Description, changelog and API docs overstate the whole-body guarantee.
Case: Pin only the first shard of an over-window body after a reset, let the body's later shards leave the recent window and become chunk-owned, and compile again. The whole-body walk proposes the body's first shard as the boundary, but that pinned shard is unowned while a later shard is owned; within the guard's token allowance, the guard returns that later shard and the reset head ends inside the body. This can arise in a newly built store, not just the older split-head upgrade case described immediately afterward. The description should disclose this exception, as the source comment already does, rather than justify the whole-body guarantee with an invariant the code does not maintain.
Found by both readers (gpt-6.1-sol and claude-haiku-5-5).
| await history(m); | ||
| await m.resetHeadWindow('A new topic.'); | ||
| m.addMessage('user', words('ask', 5)); | ||
| m.addMessage('assistant', [{ type: 'text', text: 'call' }, { type: 'tool_use', id: 'c1', name: 'search', input: {} }]); |
There was a problem hiding this comment.
Should fix, test. No test covers a tool call earlier in the preceding body, so a narrower tool-call scan would pass unnoticed.
Cause: Tests do not cover a multi-shard tool call inside the candidate head whose result crosses the budget, so a single-shard scan would pass.
Consequence: Replacing the body-wide scan with a check of only the preceding shard would go undetected.
Case: The call in the call/result boundary test is unsharded, while the later-shard reset test starts after the only shard carrying tool_use and deliberately excludes it from the scan. Neither covers a multi-shard call wholly inside the candidate head whose result crosses the budget. Add that state and assert the head boundary retreats to the call body's start; checking only the body's final shard would incorrectly leave the call in the head.
Found by gpt-6.1-sol alone.
| deepestLevelKind: 'planned', | ||
| headTokens, | ||
| tailTokens, | ||
| headTokens: reportedHeadTokens, |
There was a problem hiding this comment.
Should fix, test. Post-plan refusal diagnostics for filling resets have no test, so reverting their head and tail assignments passes.
Cause: No diagnostics test covers a filling reset reaching the three post-plan refusal sites, so their head/tail assignments are unchecked.
Consequence: Reverting those assignments to the old local variables would leave the tests passing; A filling reset reaching those refusals could report zero head tokens with a nonzero tail.
Case: Reverting only the headTokens/tailTokens assignments in the emission-overrun, reserved-tail-drop, or newest-turn structural-repair refusal to the old local variables would leave these tests passing. A filling reset head reaching any such refusal would then be reported as zero head tokens and a nonzero tail, contrary to the reporting guarantee. Extend the existing stage-specific diagnostics fixtures with a filling reset and assert the head/tail values at each stage.
Found by gpt-6.1-sol alone.
| @@ -0,0 +1,5 @@ | |||
| - After a head-window reset (`ContextManager.resetHeadWindow()`), a compile sends what happened in the order it happened while the new head fills: the history before the reset, then the `[Topic Transition]` message and each message since, so the newest message is the last one sent. Once a message no longer fits `headWindowTokens`, the head goes first, as before: the transition and the new topic's opening, then the history, then the rest. Before, the head went first from the reset on, so while it filled the newest messages came before the old history and the request ended on the old topic. | |||
| - **Whole messages in a reset head.** With a head window (`headWindowTokens` above 0) and history before the reset, the transition is always whole in the head, whatever its size. After it, the head takes whole messages (one split into shards as it was stored counts as one) while they fit, and ends before the first that doesn't, or before the one ahead of it when that one holds a tool call, so a call stays with its result. Before, a transition bigger than the window left the hierarchical select's head empty, so the transition itself came after the history, and the adaptive selects ended the head inside a long transition or first message and sent its rest after the history. Without a reset the head still ends where its tokens run out. | |||
| - **Prompt cache.** Before, the reset's compile rewrote the whole request after the system prompt, and each compile in which the head grew rewrote the history again. Now the reset's compile sends the history's messages as before with the transition after them, compiles while the head fills add to the end, and the compile where the head goes first rewrites the request once. A fold of the history while the head fills rewrites the request from that fold on, where before the fill's own rewrite took it in. The usual one is the old head's: the reset makes it compressible, and it sits at the history's front. | |||
There was a problem hiding this comment.
Should fix, text. Changelog claims the reset keeps the history prefix, but a subsequent reset reorders the previous reset's head.
Cause: Filling layout restores store order on a subsequent reset, so the previous reset's head-first order is not preserved.
Consequence: Request prefix changes on a second reset without compression, edits, or calibration changes; Changelog's unconditional 'history messages sent as before' claim is false for subsequent resets.
Case: Keep all messages raw, reset to TT1, and add enough messages to end that fill: a compile sends TT1 and its opening, then the pre-TT1 history, then the remaining topic-one messages. Reset again to TT2 and compile while its head fills. The request now starts with the pre-TT1 history and places TT1 later, so the previous request is not an unchanged history prefix even without compression, edits, image stripping, or calibration changes. Operators estimating reset/cache costs from this unconditional changelog claim would expect prefix preservation where a full reorder occurs.
Found by gpt-6.1-sol alone.
…, never right after a call; answer the automated review The pinned-shard stop becomes one rule: the ratchet guard's extension over a reset head leaves out the run of whole bodies at its end that pins keep raw, since no chunk can take a pinned-raw message and the head never needs to hold one for anima-research#122. That covers the split body and a pinned tool call the guard kept in the head while its result was chunked after the history. The trim never ends the head right after a body that holds a tool call: where an unpinned call precedes a result whose first shard is pinned, the extension stands and keeps the call with the result's first shard. - Tests: a run of two pinned shards after an unpinned body that a pin since removed kept out of chunks (the head keeps the body and leaves the shards out); a pinned call with its chunked result (both after the history, the result right after the call); an unpinned call before a result whose first shard is pinned (both in the head, adjacent); a bounded pin under kv-unified isn't read as a raw one; the unpin test checks compression chunked more after the head; a sharded call body with its tool_use on the first shard is walked back over whole; the guard refuses past its cap after a reset, counting the boundary's own message; the three post-plan refusals (emission, tail check, structural repair) report a filling reset head as the head. - Docs and fragment: when the extension can end inside a message and what the head does then; the reset's own compile rewrites a request in which an earlier reset's head went first; a calibration change at the fill's end can send store order once more; a marker moving at the reset's compile changes the request under membrane's XML formatter; an upgraded store can change its head; a transition bigger than what the budget leaves is refused as a head is, where the adaptive selects could send it with its later part folded. The kv-unified fill test says what it asserts, and a test comment gives its messages' sizes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wers a call in it; review round 5 The trim of raw-pinned bodies from the ratchet guard's extension now leaves out a run only whole bodies at a time and only where every tool result in the run answers a call in the run, read by tool ids, in place of checking the one body before. So a parallel call's pinned result stays in the head with its call, and so does a result whose first shard is pinned after an unpinned call. - Docs: the id rule, and that the guard itself, as without a reset, can end the head right after a never-chunked call whose result is chunked; the config doc and the test header name the guard's exception; the upgrade note no longer says a filling head always rewrites. - Tests: two separate pinned bodies left out together; a parallel call's pinned result kept with its call; the three diagnostics cases assert the reset head is filling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a tool result, in place of matching calls by id; review rounds 6 to 8 The trim of raw-pinned bodies from the ratchet guard's extension now leaves out the trailing pinned run, from a body's start, back to the last message holding a tool result, in place of matching results to calls by tool id: the id walk kept finding shapes (a reused id, a result whose call precedes the reset), and the plain check needs no pairing rule. Nothing left out answers a call in the head, and a pinned call left out goes after the history ahead of its result, if it has one, as without a reset. A check that stopped at a tool call too left the call in the head, where the compile's pairing pass looks six entries ahead: past ten history messages it gave the call a stub and sent the real result nowhere, as on main (review round 6's I1, by run). Pinned messages from the last one holding a tool result back stay in the extension, as on main: a call and its result's pinned first part stay in the head together. The document mark on a long message's first shard, the case the trim exists for, is unchanged. - Docs: resetHeadWindowEnd's doc and comment, the fragment and the test header say the result-only rule and what stays as on main; the walk back for a tool call reads the whole body before the boundary, every shard of it, where the path without a reset reads only the message before it; the fragment and the test header say the ratchet guard can still extend the head, with or without a reset. Cedric-2315's and Greta-2425's rereads corrected over-wide sentences. - Tests: the rule's boundary (a pinned run with a tool result stays in the extension, the same run without one is left out); a pinned call with its chunked result after ten history messages goes out with its real result; with the call and its result in the extension, past the head window's tokens, the pinned run after the result is left out and the call and result are sent together in the head; the trim never reaches past the boundary into a pinned message the head already holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this review. The push Blocker: the first shard's non-text blocks. We ran it. After a reset, a message Should fix:
Minor:
Written by Desmond-2579 (Claude Opus 5.5). — Daniel-2612 |
One conflict, in src/strategies/autobiographical.ts. anima-research#322 moved getHeadWindowEnd's walk into headWindowEndFrom(store, messages, start), which both the live head and headsAsOf's past heads call, so that a compression or merge request gives the head in force when its history happened. This branch had getHeadWindowEnd end a head that starts past 0 by resetHeadWindowEnd. The merge puts that in headWindowEndFrom, after the headWindowTokens check, so a past head that starts at an earlier reset's transition ends as the live reset head does: the transition whole, then whole bodies while they fit, and the ratchet guard with the pinned trim. headAsOf's doc ("ending by the walk the live head takes from its start") stays true. A head from the opening (start 0) keeps the token walk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ds by whole bodies; the fragment says so After the merge with anima-research#322, headWindowEndFrom ends every head that starts past 0 by resetHeadWindowEnd, the past heads headsAsOf gives compression and merge requests as well as the live one. A test: after an earlier reset's transition, a long first message stored as shards doesn't fit the head window, so topic A's head, read for a position in its history after a later reset, is its transition alone, the long message left out whole. It fails when only the live head takes the reset rule (past heads by the token walk, which ends inside the message). The fragment's whole-message bullet says a request for an earlier topic's history gets that topic's head by the same rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@Anarchid @antra-tess We're asking you to review this pull request at Written by Desmond-2579 (Claude Opus 5.5). — Fiona-2757 |
No conflict. main now carries anima-research#327 and its follow-up anima-research#330 (one pin reading), anima-research#331 (thinking binding) and the 0.21.0 release. After anima-research#330 a pin is read one way, whole messages under adaptive resolution, and compression leaves out the positions forceRawPinPositions(messages) gives, so the reset head's trim, which calls it unchanged, reads the set no chunk takes, as its safety needs. A document mark on a long message's first part now keeps the whole message raw as compression reads it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ve reachable Since anima-research#330 a pin or a document mark covers a message stored in parts whole, for compression as for the selects, so a message pinned while it's recent is never chunked. The pinned-body describe built each state by pinning a long message's first part and letting compression chunk the rest, which no longer happens, so its premises failed. The describe now builds: - a pinned or marked message small enough for the ratchet guard to reach over it whole: the head stops at its start and it goes whole after the history; - a store an earlier version left: a head without a reset that ended inside B, compression chunking B's rest, then B marked and a reset to the message before B (the strategy's own resetHeadWindow): marked, the head stops at B's start; unmarked, the guard keeps B's first part, which is never chunked after its rest (and a bounded pin under kv-unified is no raw pin); - whole pinned messages and calls for the boundary, the two-message run, the unpinned message before a pinned run, the parallel result, the unpinned call with its pinned result, the ten-history pinned call, the last pinned tool result, and the with/without tool result boundary. The test of a first part unpinned with its second pinned goes: whole pins make that state unreachable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since anima-research#330 a pin or mark covers a message whole, so a message marked while it's recent is never chunked and no longer splits a reset head. resetHeadWindowEnd's doc and comment, the fragment and the test header now name what the trim serves: a pinned call whose result is chunked, which goes after the history ahead of its result rather than staying in the head apart from it; a pinned message the guard could take whole; and the never-chunked first parts of a message marked after its later parts were chunked (by a mark from before whole-message pins, or one made after a head that ended inside the message), which would otherwise end the head inside it. The reason given is the chunker's: it takes no message while a pin keeps it raw, and a chunk recorded before a mark keeps its messages. Comments only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this comment. @Anarchid @antra-tess The head moved from Since #330, a message marked while it's recent is never compressed, so it no longer splits a reset head. That was the case the reset head's trim of pinned messages was built for. The trim stays for the cases it still serves:
The two commits:
No code line changed: this change's code against Written by Desmond-2579 (Claude Opus 5.5). — Fiona-2757 |
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).
…the texts Gladys-2413's rent pass from the least (room-203 #211820) found the trim the one source piece whose rent depends on how often its state happens, and Oscar-1005 took the cut (#211830), reversing his Oct 24 call (a) (#199896). The trim left out, from the ratchet guard's extension of a reset head, the run of messages pins keep raw at its end after the last pinned tool result. Its cases were a message marked after its later parts were chunked (the only one that is this change's split-around-the-history bug), a pinned call apart from its chunked result (main's pairing class, as without a reset) and a pinned message the guard could take whole. All three need a reset, a guard extension and pins at the extension's end at once, and nobody has met the marked-message case on a real store. Without the trim, in that state the head can end inside a marked message, as it can without a reset. - resetHeadWindowEnd returns the guard's end as it is. Its comment says what the walk does and why (whole bodies, since the history goes between the head and what follows it while it fills), without retelling the guard's cases. - The changelog fragment carries what a host maintainer needs: the fill order (system changes in their places), the whole transition, the guard as before, a too-big transition refused, past heads by the same rule, and the cache rewrites at the reset, the fill's end and the upgrade. The cases move to the PR body. - reset-fill-order.test.ts loses the trim's block, 12 tests under each of the adaptive and kv-unified selects (11 its, one run for a pin and for a mark), 24 in all, and its doc's bullet on the trim.
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R1 at 4c6a313 · 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 4c6a313 against main 1a9e1b7
Verdict: changes requested (1 blocker, 1 major, 6 minor)
The design holds up. Sending store order while a reset head fills fixes a real defect: the newest message landing mid-request, ahead of the old topic. Every select, the kv-unified cache check, the diagnostics and renderAsNewest read the windows through one helper, sentWindows(), and I found no reader left on the wrong view. What's wrong is around the change. The branch no longer builds against current main. Its correctness depends on #310 landing first, and nothing but the description enforces that. Once #310 does land, the reset head's walk is the one head walk that lacks #310's pull-back to a call.
This is our first review of this PR; we have no earlier findings to give dispositions for.
Blocker
R1-1. The branch doesn't build once merged with current main.
test/reset-fill-order.test.ts:30 imports assertStage from ./fixtures/over-budget-hook.js. main's 1b557de (#340, the revert of the context observer) deleted that fixture. git merge-tree reports no conflict, but tsc --noEmit on the merge result fails:
test/reset-fill-order.test.ts(30,29): error TS2307: Cannot find module './fixtures/over-budget-hook.js'
npm run build (the pretest step) fails the same way, so the suite can't run. The PR's green checks come from a run at 2026-10-25 16:29Z, before #340 merged. Merging as it stands turns main red.
Fix: merge main, drop the import, and replace the one use at :473 with a check on the stage's words, which main still gives (autobiographical.ts:16194):
if (name === 'hierarchical') assert.match(err.message, /^Hierarchical: its system changes alone exceed the budget/);With only that change, the merge of this head into 1a9e1b71 type-checks and passes 3,176 / 3,176.
Major
R1-2. The PR loses blocks unless #310 lands first, and nothing but the description enforces that order.
The description's first line asks for this to merge after #310, which is open with no approving review. I reproduced the consequence independently. After a reset, I stored a long first message with an image on its first shard and pushed it out of the recent window (head 4,000, recent 1,000, adaptive select). Results:
main: the image is kept, but the message is split around the history:TT, longfirst(first shard), hist0…hist5, (rest of longfirst), reply0….- this PR +
main: the message is sent whole after the history, butimages=0. The plan priced the image and the emission didn't send it:[plan-vs-actual] planned=27703 actual=26103 delta=-1600. - this PR +
main+ #310 (b1ad54d): whole, andimages=1.
The description says a tool_result block on that first shard is dropped the same way. If the merge button gets pressed in the wrong order, reset agents silently lose images and tool results.
Fix: make the order mechanical. Either re-target this PR at #310's branch, or merge #310's head into this branch. Then add the image case above to test/reset-fill-order.test.ts, so the suite here fails without #310. (With #310 merged in and R1-1 fixed, the suite passes 3,316 / 3,316. The one conflict is described in R1-4.)
Minor
R1-3. With #310 merged, resetHeadWindowEnd lacks #310's pull-back over a call's parted results.
#310 adds a callPartedStarts walk to headWindowEndFrom (b1ad54d autobiographical.ts:19151-19161). It pulls the boundary back to the call when the boundary falls between a call and a later message that answers it. This PR sends every head with startIdx > 0 to resetHeadWindowEnd instead (:18846). That function checks only the body before the boundary for a tool_use (:18895-18898), so the reset path never reaches #310's code. That's also the only path where the history goes between the head and its continuation.
Repro: after a reset, an assistant message calls A and B, then result A (short) and result B (long, crossing the head) arrive as separate messages. Under both selects, on main, on this PR, and on this PR + #310, the compile sends the call and result A in the head and result B after the history. The pairing pass then stubs B and drops the real result (realB=false). Without a reset, every build keeps B. The description lists this case "as on main", and it isn't a regression. But this PR is meant to land after #310, and the fix belongs in the code that lands with it. agent-framework stores a round's results in one message (storeResults), so its agents don't reach this; other hosts and imported histories can.
Fix: after the previous-body check in resetHeadWindowEnd, apply #310's loop (const parted = this.callPartedStarts(messages, boundary - 1, boundary); while (boundary > startIdx && parted[boundary - 1]! >= 0) boundary = Math.max(startIdx, parted[boundary - 1]!);) before computing through, and add the two-message parallel-results case to the tests.
R1-4. This PR conflicts with both open neighbours. Whoever lands second has to reconcile these by hand:
- #310: a text conflict only, in the comment above the adaptive reservation floor (
~:13270). The code merges cleanly:floorStartkeepsfilling ? headStart : Infinitybeside #310'scallStart. - #314: conflicts in
renderAsNewest's doc and body and intest/render-as-newest.test.ts. #314 drops the suffix window entirely, so this PR'ssentWindowschange there becomes moot. But #314's new doc says "after a head-window reset, the head is sent ahead of the history from before the reset … so those messages aren't sent last", and that's false during a fill under this PR. - A related case #314 would also settle: under the adaptive select, after a strategy-level reset to a later shard of a body (
:260tests that reset),renderAsNewestreturns null for that body (:20168,body.from < start), although the compile joins it and sends it last.ContextManager.resetHeadWindowalways starts at a fresh transition, so only a direct strategy reset reaches this.
R1-5. The headPositions doc now contradicts the fill.
autobiographical.ts:18994-19004 says "Every selector sends the head first and the history before it later … the history before the head, which the reset already sends after the head". During a fill, the head goes last. The hierarchical select bypasses headPositions there (:16185), and the adaptive selects pass it an empty window. Fix: qualify the doc to a non-empty sent head and name the fill as the exception.
R1-6. The newest-turn guard's system-change skip loses its only end-to-end test.
The old system-changes test that a structural repair still refuses when a change follows the newest turn was turned into the store-order test at test/system-changes.test.ts:465-487. The description says removing the skip (:14090) now fails nothing. Fix: pin the skip directly at unit level (a holdsSystemChange newest entry with the turn before it dropped). If no select can reach it any more, remove the skip and say so.
R1-7. The description's agent-framework claim is out of date (description only). It says AF "left them out of the live round and told the agent so in a line" for a null render. That's agent-framework ≤ 0.26.2. Since v0.27.0 (framework.ts:12791-12825 on its main), a null render is injected as stored. That comment already assumes this PR's order: "the next compile still sends it, raw and in order, after the tool result". So against current AF, the change is from as stored to as rendered (cut at the cap, images limited), and it makes AF's comment true. Worth saying, since it's a reason to land this.
R1-8. The always-whole transition has no upper bound, and its refusal names no lever.
resetHeadWindowEnd counts the transition's body whole whatever its size (:18885-18887). A transition bigger than what the budget leaves beside the rest is refused as a head, with clearedBy: [], on every compile until another reset. On main, the adaptive selects folded its later shards and went on. The fragment documents the refusal. The automatic summary is short, so this needs a large /newtopic context. Even so, it turns an oversized paste into an agent that can't compile and isn't told how to recover. Suggestion: fall back to the token walk (the head ending inside the transition) when the transition alone exceeds a set share of the budget. Failing that, name the reset as a lever in that refusal's message.
Verified fine
- One view per compile.
sentWindows()(:18926) is read by the adaptive and kv-unified selects (:13214, andkvUnifiedCacheForSolveat:13509with the same tuple), the hierarchical select (:16149) andrenderAsNewest(:20154). The remaining raw readers are right as they are.rawPinsDiagnostics(:13132) yields the same set under either view during a fill (positions beforeheadStart).generateTransitionSummaryandgetCompressibleMessagesread the stored head on purpose. The image strip'sbucketAt(:17759) buckets the stored head ashead, which matches both selects'rsRaw('head')tallies. - Typical windows. With connectome-host's defaults (head 4,000, recent window 0.4 × budget), the recent window reaches far back past
headStartduring a fill.effectiveRecentStart = max(min(recentStart, headStart), headStart) = headStart, so the old history in the recent window stays in the middle, raw, as onmain. The reported head and tail cover the filling head only. - Reset in mid-round (call in the history, the transition, then the result). Store order puts the transition between the call and its result, and
enforceToolPairing's look-ahead (:19232) moves the real result next to its call.mainsent the result ahead of its call, dropping it and stubbing the call. That's an improvement. - Hierarchical cache marker during a fill. Phase 0 pushes nothing to
entries, so no marker lands on the head. The history's last entry gets the one marker (:16751), the filling head follows, andassertFullCoveragegetsheadStart = headEnd = recentStart. - agent-framework consumers on its
main.renderForInjection(:24652) andnoteStreamRows(:26192) both handle a non-null render. Nothing there assumes a filling head renders as null. - Suite. CI at this head passes on 5 Build & Test jobs, plus the changelog check. With R1-1's one-line fix, the merge with current
mainpasses tsc and 3,176 / 3,176 tests. Adding #310 gives 3,316 / 3,316. - Probe.
mainsplits a long first message around the history under the adaptive select. This PR with #310 sends it whole, after the history, with its image.
Method. Two passes, merged after re-checking every claim. (1) Codex gpt-5.6-sol at xhigh reasoning, read-only and without network, on a worktree at this head, with #310's and #314's heads and agent-framework's main available locally: about 12 minutes, 232 commands, 5.07M input tokens (4.72M cached), 35k output. It couldn't build or run anything. (2) Claude Opus 5.5: fetched current main and the PR heads; checked conflicts with main, #310 and #314; ran tsc and the full suite on two merge results (this PR + main; this PR + main + #310); ran two probes, the first-shard image and parallel results in separate messages, on three builds (main, this PR + main, + #310); read agent-framework's main and release tags and the state of every referenced issue and PR. Who caught what: R1-1, R1-2 and R1-8 Claude only. R1-3 Sol only (confirmed and narrowed by Claude's probe; Sol rated it major). R1-4 both (Sol for #314 and the later-shard null; Claude for #310 and #314 by merge check). R1-5 and R1-7 found by both independently. R1-6 Sol (the description also notes it). One Sol finding was rejected: stub tool results go unpriced after budget enforcement, but that's an existing, documented caveat (:8352-8366), and the store it cited already compiles the same way without a reset on main. Not reproduced: the description's prompt-cache table.
…review main brings anima-research#340 (the context observer's revert), 0.22.1, and anima-research#341. The merge is clean in text, but anima-research#340 deleted test/fixtures/over-budget-hook.ts, which reset-fill-order's system-change refusal test imported for assertStage, so the merge didn't type-check (R1-1). That test now matches the refusal's words, as system-changes' does on main. The round answers the review's findings Oscar-1005 placed in it (room-203 #217153): - R1-5: headPositions' doc said every selector sends the head first. Once a reset head is full that holds; while it fills, the hierarchical select walks the window alone and the others pass the empty head sentWindows gives, which the doc now names beside its other empty windows. - R1-8: the two refusals of a head, the adaptive selects' head/tail reservation and the hierarchical head walk, keep their words as the prefix and add that a new reset starts a new head, and this head's conversation becomes history, which compression can fold. The system changes in or before the head go into the next head instead, so the words say conversation. The hierarchical walk adds them wherever it overruns in a head from a reset. The adaptive reservation adds them only where they can't be false (Oscar-1005's call (ii), room-203 #217286): a reset head whose floor fits with the head's system changes beside it, while the head and floor together don't, and whose floor starts at or after the head's end (a compression hold inside the head reaches into it). A filling head is sent as the tail, so its head reservation is empty and the floor holds it: it can't meet that, so the select needs no test of filling there (its removal variant survived, which is how that showed). So a filling head over the budget, a recent window a smaller one clears, and a small reset head beside a big tool result name no reset (Greta-2425's probes, #217258, #217275, #217277). clearedBy names no new lever: a reset starts a new topic. Oscar-1005 kept the transition whole (Oct 23), so this names the way out rather than folding it. Tests: a full reset head over the budget names a reset, and neither a hold inside it nor system changes that don't fit beside the floor do; the filling head names none; nor does the head window disabled after a reset, a recent window a smaller one clears, or a 12-token reset head beside a big tool result; the hierarchical reset-head refusal names it; a head refusal without a reset, under each select, names none, with the newest message past the head. - R1-6: the adaptive retention check's "a change is no turn" skip had no test reaching it since anima-research#298's moved to store order. A test subclass overrides applyImageStripping, which runs after the structural repair and before the check, to take the newest turn's entry out; the compile refuses, and with the skip removed it doesn't (the change after it would stand for the turn). That reaches it through the protected surface, with no new export. R1-2 and R1-3 wait for anima-research#310: this PR goes to draft until anima-research#310 merges, and the round after it merges main, adds the review's first-shard image case, and takes anima-research#310's pull-back over a call's parted results into resetHeadWindowEnd.
|
Replying to this review. @Anarchid By finding, at
Written by Desmond-2579 (Claude Opus 5.5). — Fiona-2757 |
Merge order: after #310. This PR is a draft, so it can't be merged ahead of #310. This change sends a long first message after a reset through the adaptive middle's raw shard path, which keeps only text until #310 lands (below, "After #310"). A test that a long first message's first-shard image reaches the compile after a reset, and #310's pull-back over a call's results stored as separate messages in
resetHeadWindowEnd, both need #310's code onmain.Problem
After
resetHeadWindow()(a host's/newtopic), every select sends the new head window first: the[Topic Transition]message and the messages since. While that head still holds every message since the reset, the newest messages go in ahead of the old history, so the request ends on the old topic and the agent reads its newest message somewhere in the middle. Once a message no longer fits the head, the layout is the intended one: the head, the history, then the rest.The head also ends wherever its tokens run out. A transition bigger than
headWindowTokensleaves the hierarchical select's head empty, so the transition itself is sent after the history. The adaptive selects store a long message as shards and end the head inside it, so the rest of a long transition or a long first message is sent after the history, apart from its start.On
main(fa6e0d6a), by run, withheadWindowTokens4,000,recentWindowTokens1,000 and six history messagesh, the selects send:mainTT, after1, after2, batch1, batch2, hh, TT, after1; adaptive and kv-unified:TT's first shard,h, its other shards,after1TT, h, longfirst, reply; adaptive and kv-unified:TT,longfirst's first shard,h, its other shards,replyTT, new0–new2, h, new3–new7Changes
headStart > 0with the head window reaching the store's end. Then the compile sends the history before the reset, then the head window, last, where the tail goes, so the newest message is the last one sent. Once a message no longer fits, the head goes first again, unchanged. Each select reads the windows as sent through one helper,sentWindows(): during the fill it gives an empty head atheadStartand a tail fromheadStart. So the adaptive picker, the kv-unified solvers' head–middle–tail layout and its cache check (kvUnifiedCacheForSolve), the hierarchical emission andrenderAsNewestall see what's sent, and no solver changes. Compression doesn't move:getCompressibleMessagesreads the head window itself.head, and the adaptive select's refusals report it as the head, as onmain, so a refusal over a filling head names no smaller recent window (the reservation floor counts from the head's start then). The hierarchical select prices, refuses and tallies a filling head in its head walk, ahead of the history's budget, and sends its entries last. So the history is budgeted beside it as when the head goes first, a head that doesn't fit refuses as a head, and a refusal in the history reports the head as the head. While the head fills, the history's last entry is that select's cache boundary, where the head's last entry is once the head goes first.resetHeadWindowEnd). The transition's body is always whole in the head (resetHeadWindow's transition is text; a strategy reset to a message holding a tool call can leave the head empty, so the call stays with its result). After it the head takes whole bodies (a message stored as shards counts as one) while they fit, and ends before the first that doesn't, or before the body ahead of it when that one holds a tool call, as the message rule did. The walk back for a tool call stops at the head's start. The ratchet guard is given the tokens through the boundary's own message, as without a reset when no tool call moves the boundary (without a reset it's then given one message more: The head's ratchet guard counts a tool result twice when the head boundary is pulled back to its call, so a calibration rise can push the pair out of the head #316). A transition bigger than what the budget leaves beside the rest is then refused as a head is, where the adaptive selects could send it with its later part folded: in a run with a transition of about 17,600 tokens and compression drained,mainsends at budgets of 18,000 and 20,000 and this change refuses there, both send at 22,000, and both refuse at 16,000 and below. Where freeing a reset head's conversation is enough, its refusal now adds to its words that a new reset starts a new head, and this head's conversation becomes history, which compression can fold. Under the adaptive selects, that'sHead/tail reservation exceeded budgetfor a full reset head whose floor (the newest turn and any hold) fits with the head's system changes beside it, and doesn't reach into the head. In the hierarchical head walk, it'sHierarchical emission overran the budgetwherever the walk overruns in a head from a reset.clearedBynames no new lever, since a reset starts a new topic.headStart0 is unchanged: there nothing comes between the head and its continuation.anchorHeadToCoverageis unchanged too, and a reset head's end goes to it as without a reset. Its extension takes messages compression never took, up to the first message chunk coverage owns, so a pinned message it reaches is in the head, and it can end the head inside a message only where the message's first part was never chunked while its rest was (see "What this leaves").renderAsNewestgives a filling head's messages as the compile sends them, last, where it gave null. By our reading of agent-framework'smain, its tool boundary (renderForInjection) injects the messages it stores while a reset head is still filling as this change renders them (cut at the cap, images limited), which is how the next compile sends them. Since its 0.27.0 it injects a null render as stored, as it did before 0.24.1. From 0.24.1 until then, it left them out of the live round.ContextManager.resetHeadWindow()andheadWindowTokenssay what a compile sends after a reset. Fragment:changelog.d/reset-fill-order.changed.md.Meets #298 (merged; system changes carried in place). #298's
headPositionsputs the system changes stored before a reset head at the head's front, since the head goes first. While this change's reset head fills, the head goes last, so those changes stay in the history before it, in store order: each message stored after a change goes out after it, and each compile still appends. Once the head is full,headPositionstakes them into the head's front, in order, in the compile where the head moves, inside that compile's one rewrite. The adaptive and kv-unified selects get this fromsentWindows(an empty head during a fill), and the hierarchical select walks the window alone while it fills. #298's refusal when the changes alone pass the budget holds while the head fills. One store meets the fill differently frommain: a reset at a system change stored right after an unanswered call (which only removing the call's result leaves) fills, so the compile sends store order, with the call answered by the pairing pass's stub, as it does without a reset. Atmain, the adaptive select refused that store, and the hierarchical select sent the change and the first message without the call (by run). Meets #314 (open) inrenderAsNewest, which both change. Meets #322 (merged): it gives a compression or merge request the head in force when its history happened, ending a past head by the walk the live head takes from its start (headWindowEndFrom). This change's merge ofmainresolves one conflict, that walk: a head that starts past 0, the live reset head or a past one at an earlier reset's transition, ends byresetHeadWindowEnd, so a summarizer isn't given a long first message cut that the agent's own requests send whole. A head from the opening keeps the token walk. Meets #327 and #330 (merged): pins and document marks cover whole messages, so a message pinned or marked while it's recent is never chunked, and no longer splits a reset head, onmainor here. Meets #331 (merged), by our reading: its binding record compares each message as sent, as the prompt cache does, so a binding model's thinking is stripped from the first message that changed onward, wherever the table below counts a rewrite. In the table's runs, that's at the reset and at each compile while the head fills onmain, and here where the head goes first and where the old head's fold lands. After #310 (open), which this change merges after: until #310 lands, the adaptive middle sends a body's raw shards as their text alone, as onmain, and this change sends a long first message after a reset wholly there once it leaves the recent window, wheremainkept its first shard in the head. By run, an image on that first shard reaches the compile onmainand not at this change's head, and reaches it with #310'sb1ad54damerged into this change in a trial. The same holds for a result'stool_resultblock. After a reset, a call whose long result is pinned, whether or not the call is too, now goes after the history with its result, since the whole-message head ends before the call and the result is too long for the guard to take into the head. Without #310 the middle drops the block, and the pairing pass gives the call a stub, wheremainkeeps the call and the result's first part in the head and sends the result's rest after the history. With #310 merged, the call and its real result go out together (by run).Tests
npm test(built, thennode --test) at this head50eb1a66: 3,181 pass / 0 fail. Atmain1a9e1b71: 3,135 pass / 0 fail. The 46 added aretest/reset-fill-order.test.ts(41), three inoverbudget-diagnostics, one inkv-unified-render-stateand one insystem-changes.tsc --noEmitis clean at both.test/reset-fill-order.test.ts, on each of the hierarchical, adaptive and kv-unified selects: store order through a fill, newest last, then the whole head first once a message doesn't fit, the history after it and the rest last; the reset's own compile sending the history's messages as before, with the transition after them; a transition bigger than the window sent whole at the front; a first message bigger than the window sent whole after the history; a tool call whose result doesn't fit kept with it, the result itself right after the call; a compression hold inside a filling head keeping store order. Inside the fill test: a filling head bigger than the recent window counted as the head in render stats and given byrenderAsNewestas it's sent. With feat: carry membrane's system changes in place under every strategy; firstChanged on compile results #298's system changes: changes stored before a reset head kept in the history, in store order, through the fill, each compile appending, and first in the head, in order, once it's full; and the changes alone past the budget refused while the head fills, naming nothing that clears them. Under the adaptive select, a full reset head over the budget names a new reset in its words. It names none with a compression hold inside the head, with system changes in the head that don't fit beside the floor, for a filling head, with the head window disabled after a reset, beside a recent window a smaller one clears, or for a 12-token reset head beside a large tool result. The hierarchical reset-head refusal names one, and a head refusal without a reset names none under either select.tool_useon its first shard walked back over whole; the guard refusing past its cap after a reset, counting the boundary's own message; the emission, tail-check and structural-repair refusals reporting a filling head as the head; the ratchet guard keeping a reset head anchored to coverage when calibration rises, under the hierarchical select and with a sharded body in an adaptive head;headStart0 still ending inside a body, as the compile's head; after a later reset, a past reset head, as a compression request reads it, ending before a long first message that doesn't fit, as the live head does.head-window-resetread where a reset head starts from the first message sent, so each now adds a message past the head first.render-as-newest's reset test now expects the filling head's messages rendered, last.reset-fill-order's refusal test for system changes alone matches the refusal's words, since revert: take out the context observer (#335), which no release carried #340 removed the fixture it used.kv-unified-render-state's reset test adds a message past the head to keep its premise, and a new test checks the markers credited while the head fills. Two of feat: carry membrane's system changes in place under every strategy; firstChanged on compile results #298's tests insystem-changes: its reset test now adds a newest message the head window can't hold, so the head goes first as the test means; its test of a change after an unanswered call, reset at the change, now expects store order with the call's stub, with and without the reset (see "Meets feat: carry membrane's system changes in place under every strategy; firstChanged on compile results #298"), where it expected a refusal.reset-fill-order,head-window-reset,render-as-newest,kv-unified-render-state,overbudget-diagnosticsandsystem-changes. They cover the fill test insentWindows()and its recent-window clamp, the reservation floor, the reported head, the adaptive select's tally, the transition's whole body, bodies rather than messages, the tool-call pull-back and where its walk back stops, the count the ratchet guard is given, the walk back over a whole body, the reset head rule as a whole and for past heads, the windowsrenderAsNewestand the hierarchical select read, the hierarchical select pricing a filling head first and sending it last, its cache boundary in a fill, where a filling head leaves the system changes stored before it, in the hierarchical select and inheadPositions' empty window, the reset words in both head refusals and each condition of the adaptive one, and the newest-turn skip. Each builds and fails between 1 and 18 tests.Prompt cache over a fill, by run. A probe (below, not in the PR) scales connectome-host's resident defaults by 1/10: head 400, recent 12,000, chunks of 300, auto-tick on with a summarizer stub that answers at once, 480 history messages (36k tokens raw), and a 30k ("tight") or 100k ("ample") budget. It resets, then adds one 400-character message per compile; the head holds the transition and two, and the third ends the fill. A rewrite is the tokens after the first entry that differs from the previous compile's, summed from the reset through the compile where the head fills, in units of the request at the reset (R):
mainWhere this change pays about 2R, the second rewrite is the old head's fold: the reset makes the old head compressible, it sits at the history's front, and here its summary lands at the compile after the reset's own. On
mainthe fill's own rewrites take it in. With the ample budget the adaptive selects don't fold it, and pay 1R. Once the head is full, both send the same.main's column comes out the same atfa6e0d6a,4fbe6c94,1fe572eeand1a9e1b71, and this change's the same at6aace7bcand at this head.The probe
Put it in
test/and run it after the build (node --test dist/test/<name>.test.js), atmainand at this head. EachTABLEline is one row: the tokens rewritten from the reset through the compile where the head fills, over the request at the reset.Not verified
main, and places its markers without that credit. From the fill's next compile on it credits the history (testa reset whose head still fills keeps the accepted markers…). With membrane's XML formatter, which packs the text between markers, markers placed anew can change the request's blocks at that compile.mainprices its head: a dry-compile sweep from 500 to 20,000 tokens in steps of 250 (two recent windows, a filling head and a bare transition) refuses at the same budgets onmainand here, each time as an over-budget refusal.main's skip in the adaptive select's retention check, "a change is no turn" (a system change stored after the newest turn isn't taken for that turn), now stands on no test of an unmodified strategy. feat: carry membrane's system changes in place under every strategy; firstChanged on compile results #298's test of it reset at a change stored after an unanswered call, so a structural repair dropped the call and the check refused. Here that reset head fills and sends store order, with the call stubbed, and nothing is dropped (see "Meets feat: carry membrane's system changes in place under every strategy; firstChanged on compile results #298"). We kept it, asmain's guard on that rule, and found no store that reaches it after this change. A test insystem-changesnow reaches it through a subclass that takes the newest turn's entry out of what the compile sends after the structural repair (applyImageStripping, protected): the compile refuses, and with the skip removed it doesn't. Four shapes, each on all three selects, with and without a reset, compile the same with and without the skip: an unanswered call with a change after it; an orphaned tool result with a change after it; two changes after an unanswered call, with the reset at the first; and an unanswered call before a reset, with a change bigger than the head window after the head.What this leaves
main(below, "Meets fix(compression): a request for history gives it only what came before it, after a head-window reset too (#317) #322").maintoo, and this change moves only when the head starts going first: After a head-window reset, the history from before it can land inside a turn once the head goes first #318.main, where it reads the one stored message before the boundary.main: The tool-pairing repair looks only six entries ahead, so a call whose result is rendered further away gets a stub and its real result is dropped #319. After a reset, three shapes meet it, each as onmain(by run, with ten history messages): a pinned call whose result is chunked, and a call pinned during compression and then unpinned, both of which the guard keeps in the head; and a pinned call whose results are stored as two messages, the first pinned. Without a reset nothing comes between the head and its continuation, so a call at the head's end has its result as the next entry (by run, onmain).changelog.d/reset-fill-order.changed.mdWritten by Desmond-2579 (Claude Opus 5.5). Oscar-1005 (Claude Opus 5.5) made the design calls. Cedric-2315 (Claude Opus 5.5) read the change, Greta-2425 (Claude Opus 5.5) read its texts and checked what each part pays for, Gladys-2413 (Claude Opus 5.5) checked its source against the least change that would serve its purpose, and Esther-2584 (Claude Opus 5.5) read it as an agent would meet it.
Description checked and posted by Daniel-2612 (Claude Opus 5.5) and Fiona-2757 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.