Skip to content

fix(reset): send store order while a reset head fills, and keep a reset head's messages whole - #315

Draft
nissa-seru wants to merge 15 commits into
anima-research:mainfrom
nissa-seru:fix/reset-fill-order
Draft

nissa-seru wants to merge 15 commits into
anima-research:mainfrom
nissa-seru:fix/reset-fill-order

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

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 on main.

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 headWindowTokens leaves 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, with headWindowTokens 4,000, recentWindowTokens 1,000 and six history messages h, the selects send:

case sent on main
reset, then four short messages all three: TT, after1, after2, batch1, batch2, h
a 34,920-character transition, then a short message hierarchical: h, TT, after1; adaptive and kv-unified: TT's first shard, h, its other shards, after1
a short transition, then a 34,899-character message and a reply hierarchical: TT, h, longfirst, reply; adaptive and kv-unified: TT, longfirst's first shard, h, its other shards, reply
the head full all three: TT, new0–new2, h, new3–new7

Changes

  • While a reset head fills, a compile sends store order. "Filling" is headStart > 0 with 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 at headStart and a tail from headStart. So the adaptive picker, the kv-unified solvers' head–middle–tail layout and its cache check (kvUnifiedCacheForSolve), the hierarchical emission and renderAsNewest all see what's sent, and no solver changes. Compression doesn't move: getCompressibleMessages reads the head window itself.
  • What's reported still counts the head as the head. Render stats tally a filling head under head, and the adaptive select's refusals report it as the head, as on main, 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.
  • A reset head holds whole bodies (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, main sends 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's Head/tail reservation exceeded budget for 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's Hierarchical emission overran the budget wherever the walk overruns in a head from a reset. clearedBy names no new lever, since a reset starts a new topic. headStart 0 is unchanged: there nothing comes between the head and its continuation. anchorHeadToCoverage is 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").
  • renderAsNewest gives a filling head's messages as the compile sends them, last, where it gave null. By our reading of agent-framework's main, 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.
  • Docs: ContextManager.resetHeadWindow() and headWindowTokens say 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 headPositions puts 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, headPositions takes 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 from sentWindows (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 from main: 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. At main, 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) in renderAsNewest, 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 of main resolves 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 by resetHeadWindowEnd, 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, on main or 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 on main, 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 on main, and this change sends a long first message after a reset wholly there once it leaves the recent window, where main kept its first shard in the head. By run, an image on that first shard reaches the compile on main and not at this change's head, and reaches it with #310's b1ad54da merged into this change in a trial. The same holds for a result's tool_result block. 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, where main keeps 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, then node --test) at this head 50eb1a66: 3,181 pass / 0 fail. At main 1a9e1b71: 3,135 pass / 0 fail. The 46 added are test/reset-fill-order.test.ts (41), three in overbudget-diagnostics, one in kv-unified-render-state and one in system-changes. tsc --noEmit is 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 by renderAsNewest as 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.
  • Beside them: under the adaptive select, a refusal over a filling head naming no smaller recent window and reporting the head as the head, and a preview and a plan refusal reporting it as the head; under the hierarchical select, a filling head the budget alone can't hold refused in the head walk, before the history, the history held to what's left beside a filling head, compression named when queued work would fold that history, and the cache boundary at the history's end through a fill and at the head's end after; a strategy reset to a later shard of a tool call sending nothing twice; a sharded call with its tool_use on 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; headStart 0 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.
  • Turned round: four tests in head-window-reset read 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 in system-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.
  • Removal variants: 30, each undoing one condition and run against reset-fill-order, head-window-reset, render-as-newest, kv-unified-render-state, overbudget-diagnostics and system-changes. They cover the fill test in sentWindows() 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 windows renderAsNewest and 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 in headPositions' 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):

select budget main this change
hierarchical tight or ample 2.90R 1.93R
adaptive tight 3.86R 1.99R
adaptive ample 3.01R 1.01R
kv-unified tight 2.95R 2.08R
kv-unified ample 3.01R 1.01R

Where 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 main the 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 at fa6e0d6a, 4fbe6c94, 1fe572ee and 1a9e1b71, and this change's the same at 6aace7bc and at this head.

The probe

Put it in test/ and run it after the build (node --test dist/test/<name>.test.js), at main and at this head. Each TABLE line is one row: the tokens rewritten from the reset through the compile where the head fills, over the request at the reset.

import { it } from 'node:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ContentBlock } from '@animalabs/membrane';
import { AutobiographicalStrategy, ContextManager } from '../src/index.js';

// connectome-host's resident defaults scaled by 1/10.
const base = {
  headWindowTokens: 400, recentWindowTokens: 12_000, targetChunkTokens: 300,
  imageStripDepthTokens: 0, compressionModel: 'mock', autoTickOnNewMessage: true,
};
const KVU = {
  policy: { alpha: 0.7, budgetLowRatio: 0.6, budgetHighRatio: 0.85, budgetUnderLambda: 1000, budgetOverLambda: 4000,
    cacheLambda: 1, cacheScale: 10_000, cacheReadPrice: 0.1, cacheWritePrice: 2, continuityLambda: 1, continuityScale: 10_000,
    continuityRecencyHalfLifeTokens: 10_000, continuityRecencyFloor: 0.2, continuityStableHalfLife: 16, continuityStableFloor: 0.25 },
  tokenBucketSize: 5_000, continuityBucketSize: 5_000, fidelityBucketSize: 10_000, labelCeiling: 10_000, adoptEpsilon: 300,
  treeifyNonContiguousSummaries: false, preserveGapBearingSummaries: false, hysteresisCertificate: true,
};
const SELECTS: Record<string, () => AutobiographicalStrategy> = {
  hierarchical: () => new AutobiographicalStrategy({ ...base, hierarchical: true } as never),
  adaptive: () => new AutobiographicalStrategy({ ...base, hierarchical: false, adaptiveResolution: true } as never),
  'kv-unified': () => new AutobiographicalStrategy({ ...base, hierarchical: false, adaptiveResolution: true,
    foldingStrategy: 'kv-unified', kvUnified: { ...KVU, policy: { ...KVU.policy } } } as never),
};
const BUDGETS: Record<string, number> = { tight: 30_000, ample: 100_000 };

const text = (label: string, chars: number): ContentBlock[] =>
  [{ type: 'text', text: `${label} ${'w'.repeat(Math.max(0, chars - label.length - 1))}` }];
const summarizer = () => {
  let n = 0;
  return { complete: async () => ({ stopReason: 'end_turn', content: [{ type: 'text', text: `[S${++n}] ` + 'x '.repeat(45) }],
    usage: { input_tokens: 100, output_tokens: 25 } }) } as never;
};

type Sent = { participant: string; content: ContentBlock[] };
const flat = (m: Sent): string => m.content.map((b) => (b as { text?: string }).text ?? `<${b.type}>`).join('');
const tokens = (ms: Sent[]): number => ms.reduce((t, m) => t + Math.ceil(flat(m).length / 4), 0);
const key = (m: Sent): string => `${m.participant}\u0000${flat(m)}`;
const rewritten = (prev: Sent[], cur: Sent[]): number => {
  let i = 0;
  while (i < prev.length && i < cur.length && key(prev[i]!) === key(cur[i]!)) i++;
  return tokens(cur.slice(i));
};
const settle = async (m: ContextManager): Promise<void> => {
  for (let i = 0; i < 400 && !m.isReady(); i++) await new Promise((r) => setTimeout(r, 5));
};

for (const [select, make] of Object.entries(SELECTS)) {
  for (const [budgetName, maxTokens] of Object.entries(BUDGETS)) {
    it(`${select}, ${budgetName}`, async () => {
      const dir = mkdtempSync(join(tmpdir(), 'cache-table-'));
      const m = await ContextManager.open({ path: join(dir, 'store'), strategy: make(), membrane: summarizer() });
      const budget = { maxTokens, reserveForResponse: 0 };
      try {
        for (let i = 0; i < 480; i++) m.addMessage(i % 2 ? 'assistant' : 'user', text(`hist${i}`, 300));
        await settle(m);
        await m.compile(budget);
        await settle(m);
        let prev = (await m.compile(budget)).messages as unknown as Sent[];
        let total = 0;
        let atReset = 0;
        for (let step = 1; step <= 4; step++) {
          if (step === 1) await m.resetHeadWindow('A new topic.');
          else m.addMessage(step % 2 ? 'assistant' : 'user', text(`post${step - 1}`, 400));
          await settle(m);
          const sent = (await m.compile(budget)).messages as unknown as Sent[];
          if (step === 1) atReset = tokens(sent);
          total += rewritten(prev, sent);
          prev = sent;
        }
        console.log(`TABLE ${select} | ${budgetName} | rewritten ${total} tok over the reset's request ${atReset} tok | ${(total / atReset).toFixed(2)}R`);
      } finally {
        await m.close();
        rmSync(dir, { recursive: true, force: true });
      }
    });
  }
}

Not verified

  • Not run against a real store. The cache figures are a 1/10-scale probe with a stub summarizer that answers before the next compile. With a real one, the old head's fold (the reset makes it compressible, and it sits at the history's front) lands when its summary does, during the fill or after it; after it, both orders pay it the same.
  • The figures measure the messages' bytes, not where the cache markers go. On a reset's own compile, when the accepted layout opens with the old head's unit (a head window above 0 that held anything), kv-unified's solve credits none of the accepted markers, as on main, and places its markers without that credit. From the fill's next compile on it credits the history (test a 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.
  • Each compile decides the fill from the head window's end as it prices it, so a calibration drop right after the fill ends, while only the crossing message follows the head, can send store order once more. Not measured.
  • Which budgets refuse doesn't change under the hierarchical select, which prices a filling head ahead of the history as main prices 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 on main and 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, as main's guard on that rule, and found no store that reaches it after this change. A test in system-changes now 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


  • Changelog fragment added: changelog.d/reset-fill-order.changed.md

Written 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-seru account.

Desmond-2579 and others added 3 commits October 22, 2026 23:31
…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>

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)

Should fix (4)

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:18148 Cause: 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:3 Cause: 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:5 Cause: 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:16690 Cause: 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:18172 Cause: 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:309 Cause: 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:925 Cause: 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:3 Cause: 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:12973 Cause: 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:18138 Cause: 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:83 Cause: 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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: {} }]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread changelog.d/reset-fill-order.changed.md Outdated
@@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Desmond-2579 and others added 3 commits October 23, 2026 01:43
…, 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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

The push 9dfd2d37..6aace7bc answers this review. Each finding below says what changed, or why nothing did.

Blocker: the first shard's non-text blocks. We ran it. After a reset, a message [image, 6,000 words] is stored as nine shards with the image on the first, and traffic then moves it out of the recent window. On main the compile carries the image, because the head holds that first shard. At this pull request's head it doesn't, because the whole message goes through the adaptive middle's raw shard path, which keeps text only. #310 keeps each block of a sharded body in place. In a trial merge of #​310's fcdf6b44 into this head, the compile carries the image, both for that case and with the first shard pinned, and the suite passes there. So this pull request now merges after #​310, as its description says at the top, rather than carrying a copy of #​310's fix.

Should fix:

  • The guard ending the reset head inside a body: fixed. The ratchet guard's extension now leaves out the run at its end, from a body's start, that pins keep raw and that holds no tool result, since no chunk can take a pinned message. So a document mark on a long message's first shard no longer ends the head inside that message, and a pinned call whose result is chunked goes after the history ahead of its result rather than staying in the head apart from it. Nothing left out answers a call in the head. Unpinned never-chunked messages, left by an earlier version's head or by a pin since removed, stay in the extension so they aren't compressed out of order (Head window ratchet: calibration rises peel the head into late, out-of-order L1s that merge across eras #122). So do pinned messages from the last one holding a tool result back, as on main: a call and its result's pinned first part stay in the head together, and the result's rest goes after the history. The docs, the fragment and the description name these, and each case has a test.
  • A sharded call with its tool_use on an earlier shard: test added. The walk back covers the whole body before the crossing one.
  • The post-plan refusals with a filling head: tests added for the emission, tail-check and structural-repair refusals. Each reports the filling head as the head and the tail as 0.
  • The fragment's "history sent as before": corrected. When an earlier reset's head went first in that request, the reset puts it back in store order and rewrites the request once.

Minor:

  • Transition with a tool call: the description now says the transition is whole when it's resetHeadWindow's text transition. A strategy reset to a message holding a tool call can leave the head empty, keeping the call with its result.
  • Calibration flip: the fragment now says each compile decides from the head window's end as it prices it, so a calibration change right at the fill's end can send store order once more.
  • Upgraded stores: the upgrade note no longer promises that the head keeps its end. It now says the head can change at the first compile, and when the guard keeps never-chunked messages in it.
  • The leading-result trim after a direct reset to a tool result: not changed. ContextManager.resetHeadWindow and the restore after a restart start the head at a transition, so only a strategy-level reset that names a tool result reaches it.
  • The capped guard in a legacy store with raw pins past twice the head window: not changed. It takes a store written by an earlier version, raw pins over the body's middle, and coverage more than twice the head window away.
  • Ratchet tests and the guard's count: a test now has the guard refuse past its cap after a reset. It fails if the count leaves out the boundary's own message.
  • kv-unified marker-credit test: its comment and assertion now say what it checks: the reset compile pays cache churn, and the next fill compile pays a tenth of that or less. The "credits none" reading is labelled as reading.
  • XML formatter: the fragment now says that a cache marker moving at the reset's compile changes the request under membrane's XML formatter.
  • Whole transition against the budget: the fragment and the description now name it. We ran it with a transition of about 17,600 tokens and compression drained. main sends at budgets of 18,000 and 20,000, where this head refuses, since the transition stays whole. Both send at 22,000.
  • The count parity claim: corrected. The description says the count differs from the path without a reset when a tool-call pull-back moves the boundary, which is 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.
  • The test comment's sizes: corrected.

Written by Desmond-2579 (Claude Opus 5.5).

— Daniel-2612

Desmond-2579 and others added 2 commits October 23, 2026 14:27
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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@Anarchid @antra-tess We're asking you to review this pull request at e7623fc2 before we merge it. After resetHeadWindow(), while the new topic's head window fills, it sends the history before the reset first and the newest message last, where main sends the new topic's messages first and ends the request on the old topic. A reset head holds whole messages, so a long transition or first message isn't split around the history. It builds on #310: without it, a long first message's image is lost after a reset, as the description says at the top. The description sets out what changed, what it leaves, and the runs behind it, including the prompt-cache cost over a fill.

Written by Desmond-2579 (Claude Opus 5.5).

— Fiona-2757

Desmond-2579 and others added 3 commits October 24, 2026 02:09
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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this comment.

@Anarchid @antra-tess The head moved from e7623fc2 to c6592c33 after we asked for your review. It merges main at 4fbe6c94, which brings #330 (pins and document marks cover whole messages) and #331, and two commits follow the merge.

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 main is what it was at e7623fc2. The description is updated to this head. We're asking you to review it at c6592c33 before we merge it.

Written by Desmond-2579 (Claude Opus 5.5).

— Fiona-2757

Desmond-2579 added 2 commits October 25, 2026 08:12
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 Anarchid left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nightly dual review · round R1 at 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, but images=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, and images=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: floorStart keeps filling ? headStart : Infinity beside #310's callStart.
  • #314: conflicts in renderAsNewest's doc and body and in test/render-as-newest.test.ts. #314 drops the suffix window entirely, so this PR's sentWindows change 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 (:260 tests that reset), renderAsNewest returns null for that body (:20168, body.from < start), although the compile joins it and sends it last. ContextManager.resetHeadWindow always 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, and kvUnifiedCacheForSolve at :13509 with the same tuple), the hierarchical select (:16149) and renderAsNewest (:20154). The remaining raw readers are right as they are. rawPinsDiagnostics (:13132) yields the same set under either view during a fill (positions before headStart). generateTransitionSummary and getCompressibleMessages read the stored head on purpose. The image strip's bucketAt (:17759) buckets the stored head as head, 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 headStart during a fill. effectiveRecentStart = max(min(recentStart, headStart), headStart) = headStart, so the old history in the recent window stays in the middle, raw, as on main. 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. main sent 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, and assertFullCoverage gets headStart = headEnd = recentStart.
  • agent-framework consumers on its main. renderForInjection (:24652) and noteStreamRows (: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 main passes tsc and 3,176 / 3,176 tests. Adding #310 gives 3,316 / 3,316.
  • Probe. main splits 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.
@nissa-seru
nissa-seru marked this pull request as draft October 26, 2026 05:31
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid By finding, at 50eb1a66, which merges main at 1a9e1b71:

  • R1-1: fixed. The merge brings in revert: take out the context observer (#335), which no release carried #340, and reset-fill-order's refusal test now matches the refusal's words, as system-changes does on main, instead of importing the fixture #​340 removed. On the merge, tsc --noEmit is clean and the suite passes 3,181 of 3,181.
  • R1-2: this PR is now a draft, so it can't be merged ahead of 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. We didn't merge #​310's head in or re-target, so that this PR's diff stays its own (#​310's branch is on our fork, so it couldn't be a base here anyway). Your first-shard image case needs #​310's code on main to pass.
  • R1-3: agreed. callPartedStarts arrives with #​310, so the pull-back and your two-message parallel-results test need #​310's code on main too.
  • R1-4: with #​310 the conflict is the comment above the reservation floor. With fix(render): renderAsNewest gives null only for a message the next compile doesn't send #314 it's the #​314 half, including its doc's line about the head after a reset.
  • R1-5: fixed. headPositions' doc now says the head goes first once a reset head is full, and names the empty head sentWindows gives while it fills among its empty windows.
  • R1-6: a test in system-changes now reaches the skip through a subclass that takes the newest turn's entry out of the compile's entries after the structural repair (applyImageStripping is protected). The compile refuses, and with the skip removed it doesn't. We kept the skip, since we found no store that reaches it after this change.
  • R1-7: the description now says what changes against agent-framework's current main: a filling head's messages go in as rendered where they went in as stored.
  • R1-8: the transition stays whole. Where freeing a reset head's conversation is enough, the refusal now names a new reset in its words: a new reset starts a new head, and this head's conversation becomes history, which compression can fold. Under the adaptive selects, that's a full reset head whose newest turn and holds fit with the head's system changes. In the hierarchical head walk, it's an overrun in a head from a reset. Other shapes, such as a filling head, a recent window a smaller one clears, or a small reset head beside a big tool result, name no lever. clearedBy names no new lever, since a reset starts a new topic.
  • The prompt-cache table: the description now carries the probe it came from, with how to read its output. It reproduces both columns at main 1a9e1b71 and at this head.

Written by Desmond-2579 (Claude Opus 5.5).

— Fiona-2757

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants