Skip to content

fix(injection): a message the render gives null goes in as stored - #720

Merged
nissa-seru merged 8 commits into
anima-research:mainfrom
nissa-seru:fix/inject-render-null-as-stored
Oct 24, 2026
Merged

nissa-seru merged 8 commits into
anima-research:mainfrom
nissa-seru:fix/inject-render-null-as-stored

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

A message that arrives during a turn's tool call, and that the strategy's render gives null, goes into the live stream as stored again, so the agent hears it and the next request matches the live round.

Problem

At a tool boundary, #691 asks context-manager's renderAsNewest how the next compile sends the messages that arrived during the call, and injects that. Where the render gives null, #691 left the message out of the live round and stored a [not shown] system line in its place.

The memory strategy gives null for a message that starts before its recent window, which takes a batch bigger than that window. It also gives null for every message while a head window that a topic change reset hasn't filled yet, since its recent window then starts at the newest message. But the next compile sends such a message raw, in order, right after the tool result, as @Anarchid's review of anima-research/context-manager#275 (R1-1) found. Leaving it out spared nothing. The agent didn't hear it mid-turn, the line told it something false, and the next request still differed from the live round at that message.

Our probe on main e78bd274, with context-manager 0.18.0, used the memory strategy with adaptive resolution and recentWindowTokens: 4000, and four screenshots arriving during one tool call:

  • The live round carried: screenshot3, screenshot4, then "[not shown] For assistant: 2 messages arrived during your turn that aren't shown here, since your context doesn't keep them among your newest messages: …".
  • The next request carried: screenshot1 to screenshot4, raw, each with its image, then that line.

As stored, the four screenshots are exactly what the next request sends.

As stored isn't always that, though. The next compile still cuts such a message at its per-message cap and counts its images against the image limits, and the render does neither for a message it gives null. In our probe of eight screenshots, the render gave six of them null. The live round carried all eight images, and the next request stripped the two oldest under maxLiveImages 6. That's context-manager's to close (Out of scope).

Changes

  • A message the render gives null goes in as stored, as it does for a strategy without the hook, in its place in the batch. A due clock row the render gives null does the same.
  • What that rests on, in the comment at the branch:
    • The only view filter agent-framework gives a store is the tune-out stamp, and a stamped message is skipped before the render. So no null here means a message the view hides.
    • The boundary stores and renders with nothing between the two. So no summary can stand in for what it just stored, and no select can move windowed passthrough's anchor past it.
    • The exception is a write whose landing was unknown, found at this boundary after it landed at an earlier flush. By then a summary may stand in for it, or the anchor may be past it, and it goes in where the next compile doesn't send it. That takes a store that throws after landing, then one of those two.
  • The [not shown] line goes, since nothing is left out for it to name. With it go its method (recordLeftOutNotice), contentSize, DrainedWrite.parts, the log line naming what was left out, and the comment at the consumed mark about left-out messages.
  • Changelog: a fixed fragment.

What each part is for

  • Injecting as stored on null: it's the fix. A message the next compile sends after the tool result has to be in the live round for the agent to hear it and for the prefix to match. As stored is the form the next compile sends it in, unless its cap or an image limit reaches it.
  • The clock row's same rule: a due clock is stored ahead of the batch, so in the same case it's before the window too.
  • The comment's two conditions: they're what makes "as stored" safe, and a later change that adds a view filter, or puts an await between the store and the render, would need to read them.
  • Removing the notice, contentSize, DrainedWrite.parts, the log line and the mark paragraph: with nothing left out, the line would have nothing true to say, and the rest existed only for it.
  • The tests: each piece above has one that fails without it (Runs). The memory strategy's own render is in three of them, so the fix is checked against what the next request really sends, not only against a stand-in.
  • The fragment: 0.24.1's CHANGELOG promised the line, and hosts read the CHANGELOG.

Tests

  • test/mid-turn-injection-as-compiled.test.ts:
    • under the memory strategy's own render, with adaptive resolution, a recentWindowTokens: 4000 window and four screenshots, the render gives some of them null. All four go into the live round in the order they arrived, and the next request carries them one after another, right after the tool result, in the same form;
    • with a stand-in render that marks each id's answer, each message goes in as its own render gives it;
    • with a stand-in render giving the batch's first message null, it goes in as stored in its place, and the consumed mark covers it;
    • with a stand-in render giving the whole batch null, the message with an image goes in as stored, and the one a tune-out stamped doesn't;
    • a single message bigger than the recent window, queued as text, image and text and stored in parts, goes in whole, as the store reads it back (its image, then its text as one block);
    • after a head window reset, a short message the render gives null goes in as stored, as the next request sends it.
  • test/present-while-acting.test.ts:
    • an addressed DM the render gives null goes in as stored and re-pins the turn, as an injected one does;
    • a budget restart still takes it as an arrival.
  • test/clock-rows.test.ts:
    • a mid-turn clock the render gives null goes in as stored;
    • a clock and a message after it, rendered together by a stand-in that marks each, each go in as their own render gives them.
  • Runs at 649b3b9e, from a clean tree, on Linux with Node 24.22.1 and context-manager 0.20.1, which main's ^0.20.0 installs:
    • Checks: the typecheck is clean. The four files, the three above and deferred-window-only, pass 225 of 225. The whole suite runs 7,066 tests: 7,022 pass, 44 are skipped, and none fail.
    • Red-first: with main's src/ and this branch's tests, 7 of the 225 fail. Those are the R1-1 case, the head reset, the two stand-in cases, the body bigger than the window, the DM and the clock. In the head reset on main, the render gives the message null and the live round doesn't carry it.
    • Removals: each of the change's 4 pieces taken out one at a time fails a test. Those are leaving a null message out, leaving a null clock out, passing the null through as content, and giving each message the render at its own index without the clock's offset.
  • CI at 649b3b9e: green, 12 of 12 checks. Run 40057549587 checked out 8b5ddc8, GitHub's merge of 649b3b9e into main a4a74b48. On Ubuntu with Node 24 and the latest (26.12.1), the suite ran 7,064 tests: 7,019 pass, 45 are skipped, and none fail. The Node 22.12.0 legs, on Ubuntu and macOS, fail none.

Out of scope

  • context-manager's side: its renderAsNewest gives null for messages its next compile sends, which its doc says it shouldn't. The fix there is fix(render): renderAsNewest gives null only for a message the next compile doesn't send context-manager#314, open and waiting for its maintainers' review. It renders such a message cut and counted as the next compile does, which closes the cap and image case above, and with it null means only that the next compile doesn't send the message. By the two conditions above, none of those reach this boundary, apart from a write found at a later flush. In our run of agent-framework 0.26.0 with fix(render): renderAsNewest gives null only for a message the next compile doesn't send context-manager#314 at e094ac59, the four and the eight screenshots each went into the live round as the next request carries them. So either change alone ends the left-out messages in those cases, and this one does it without that fix, as the runs above with context-manager 0.20.1 show.
  • A single message bigger than the recent window, which the memory strategy stores in parts: the next compile sends it differently from any one live form, by Anarchid's reading. As stored, the agent at least hears it.

For hosts

  • Nothing to configure. The agent hears every mid-turn message again, as before 0.24.1 (0.24.1 through 0.26.1 carry the left-out path), and the [not shown] line no longer appears. That affects two cases: one tool call that collects more than the memory strategy's recent window holds (30,000 tokens by context-manager's default, and 40% of the compile budget under connectome-host, 120,000 of its default 300,000), and any mid-turn message after a topic change resets the head window, until the head fills (connectome-host's /newtopic, with a 4,000-token head).

Merge notes


  • Changelog fragment added: changelog.d/fix-inject-render-null-as-stored.fixed.md.

Found from Anarchid's review of anima-research/context-manager#275. Written by Milo-2653 (Claude Opus 5.5), on calls by Ada-1017 (Claude Opus 5.5). Ezra-2423 (Claude Opus 5.5) read it, and Phoebe-2419 (Claude Opus 5.5) read it from a resident's seat.

Description checked and posted by Daniel-2612 (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.

Milo-2653 and others added 6 commits October 22, 2026 18:26
At a tool boundary, the strategy's render (context-manager's renderAsNewest)
gives null for a message it places before its window: under the memory
strategy, one that starts before the recent window, which needs a batch
bigger than that window. The boundary left such a message out of the live
round and stored a `[not shown]` system line in its place. But the next
compile sends it raw, in order, among its newest, after the tool result
(context-manager#275's review, R1-1): a probe with four screenshots under a
4,000-token recent window had the live round carry the last two and the line,
and the next request all four, then the line. So the agent didn't hear the
first two mid-turn, the line was false, and the prefix diverged anyway. As
stored, that batch matches the next request exactly.

The boundary now injects a null-rendered message as stored, as it does for a
strategy without the hook, and a due clock row the same way. The comment at
the null branch says what that rests on: the only view filter this framework
gives a store is the tune-out stamp, whose messages are skipped before the
render, and the store and the render run with nothing between them, so no
summary or moved anchor can stand behind a null for what this flush stored.
A write found here that landed at an earlier flush is named as the
exception.

With nothing left out, the `[not shown]` notice (recordLeftOutNotice),
contentSize, DrainedWrite.parts, the left-out log line and the mark
comment's paragraph about left-out messages go.

Tests: the memory strategy's own render through the boundary, four
screenshots under a 4,000-token recent window, the render giving null for
some, and the next request carrying all four where and as the live round
did; the stand-in null cases now inject as stored; a body bigger than the
recent window goes in as stored; an addressed DM rendered null re-pins the
turn as an injected one does; a mid-turn clock rendered null goes in as
stored.

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

The memory strategy's render gives null not only for a message before its
recent window but for every message while a head window a topic change
reset hasn't filled yet: its tail then starts at the newest message. The
next compile sends the head's messages raw, after the tool result, so
injecting as stored is right there too. The comment at the null branch and
the changelog fragment now name both, and a test resets the head window and
checks that a short mid-turn message goes in as stored and as the next
request sends it.

The fragment said the next compile sends such a message "among its newest",
the phrase the render's null denies; it now says "after the tool result".
The deferral doc still said a tool boundary leaves out "one the next compile
won't send among its newest"; the boundary leaves out only what a tune-out
holds now.

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

From the review of the null fix:
- As stored differs from the next request where the per-message cap or an
  image limit reaches a null message, since the render neither cuts it nor
  counts its images: a probe with eight screenshots, six of them null, had
  the live round carry all eight images and the next request strip the two
  oldest under maxLiveImages 6. The comment at the null branch and the
  fragment say so. A context-manager change in which the render cuts and
  counts such a message would close it; it isn't built yet.
- The comment on the render's image count says it runs from the oldest
  message the render places. The overview points at the null branch rather
  than renderForInjection, and the null branch says nothing can put a
  summary in place of what this flush stored, or move the anchor, before
  the render, rather than naming one mover.
- Tests: the R1-1 case now checks the live round's order and that the next
  request carries the screenshots right after the tool result; a stand-in
  render marking each id's answer pins that each message takes its own; a
  clock and a message rendered together pin the clock's offset; the body
  bigger than the window carries an image and two text blocks, so its
  read-back join differs from the queued blocks; the budget restart's name
  says the render gave null. The clock harness's `during` may now be async.

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

One conflict, in test/mid-turn-injection-as-compiled.test.ts, taken from
this branch: anima-research#705's test "names a left-out message's sender in its notice as
the store keeps the name, a lone surrogate as U+FFFD" checks the [not shown]
notice this change removes, and sat where this branch renames the whole-batch
test and adds the per-id render test. With no notice, a message the render
gives null goes in as stored through the same provideToolResults mapping as
every injected message, which anima-research#705's own "goes in with its sender's name as
the store keeps it" pins. src/framework.ts merged cleanly: anima-research#705's
nameAsStored and contentAsStored apply at provideToolResults, after the null
branch. main now asks context-manager ^0.19.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0.25.0 carries anima-research#691 too, and this fix goes onto v0.24.1 and the 0.25 line, so
the fragment says "since 0.24.1" rather than naming one release.

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

handleMcplPushEvent queues the mid-turn message synchronously, so the clock
and message test needs no 30 ms wait for the run loop, and the harness's
`during` goes back to a plain synchronous callback. Three runs of the file
pass 36 of 36 each.

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: merge and fast-follow. 2 medium severity findings can be fixed after the merge.

Reviewed commit ae627bf with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. This bug fix fits. It corrects the existing hear-while-acting injection at tool boundaries so the live round carries what the next compile sends, and it stays inside the code that already does this work, with tests and a changelog fragment.

5 reasons with sources
  • It fixes behavior the project already has rather than adding scope. The boundary already injects mid-turn arrivals as the next compile renders them (#691). This change only alters what happens when the render gives null: such a message now goes in as stored, which is what the boundary already does for strategies without the hook. (src/framework.ts (tool-boundary injection, renderForInjection))
  • It removes code that only existed for the notice (recordLeftOutNotice, contentSize, DrainedWrite.parts) and adds no new component or second way of doing things. No dependency, public interface or concept is added. The change narrows the framework's surface. (src/framework.ts)
  • It undoes behavior a released changelog announced, and it records that in a fragment as CONTRIBUTING requires. CHANGELOG.md describes the [not shown] line. The new changelog.d/fix-inject-render-null-as-stored.fixed.md fragment says the line is gone, so hosts reading the changelog are told. (CHANGELOG.md (0.24.1 entry); CONTRIBUTING.md, Changelog)
  • Tests accompany the behavior change, as CONTRIBUTING asks, including tests against the memory strategy's real render. The suites for injection, present-while-acting and clock rows are updated in place. Two new tests drive AutobiographicalStrategy's own renderAsNewest. (CONTRIBUTING.md, What a PR should contain)
  • It cites the related context-manager discussion and leaves the remaining cap and image-limit gap to that repo. CONTRIBUTING expects cross-repo relations to be declared. The PR names context-manager#275 and lists the gap as out of scope. The review's findings about the cap and image limits and about the 'unknown' landing are code defects for the author, not a reason against the change belonging here. (CONTRIBUTING.md, How changes land)

Findings

2 findings of medium or high severity, see the inline comments:

1 more finding, less severe
  • Low severity, documentation, src/framework.ts:11901-11905. The boundary comment leaves out that a sharded null message differs from the next request. The comment says that 'as stored' is the form the next compile sends, except where the per-message cap or an image limit reaches the message. The changelog fragment and the new test at the sharded-body case say otherwise for a single message too big for the recent window. That message is stored in shards and injected joined by asCompiled: image first, then the text as one block. The next compile sends it as shards. A maintainer reading only the code comment would think sharded bodies match.

Comment thread src/framework.ts
Comment on lines +11917 to 11925
const asSent = (i: number, content: ContentBlock[]): ContentBlock[] => rendered?.[i] ?? content;
if (clock) midTurnInjections.push({ participant: 'user', content: asSent(0, clock.content), metadata: clock.metadata });
heard.forEach((h, i) => {
const sent = rendered ? rendered[i + first]! : h.content;
// The next compile won't send it among its newest: injected, it
// would put in the live turn what that compile sends elsewhere,
// or not at all. It's under any consumed mark the boundary sets
// (below), as anything stored here is.
if (sent === null) { leftOut.push(h); return; }
midTurnInjections.push({
participant: h.msg.participant,
content: sent,
content: asSent(i + first, h.content),
...(h.msg.metadata ? { metadata: h.msg.metadata } : {}),
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. A message the render gives null now goes into the live round without its per-message cap or image limits.

asSent falls back to the stored content whenever renderForInjection returns null for an id. Per the boundary's own comment (11902-11905), the render does not cut a null message to maxMessageTokens and does not count its images. Before this change such messages were left out. Now a batch bigger than the recent window, for example eight screenshots, sends every image and the full uncapped text into the live request, past the configured maxLiveImages and per-message cap that exist to bound what a request carries. The next request then strips or cuts them, so the prompt-cache prefix differs anyway. The PR description's own eight-screenshot probe shows this. This is the case the change sets out to handle, and it still sends a request past the operator's limits; a fallback that applies the cap and image limits (or asks context-manager for them) is needed before relying on 'as stored'.

Comment thread src/framework.ts
Comment on lines +11913 to +11917
// write found here that landed at an earlier flush
// (writeDrainedInOrder) is the exception: by now a summary may
// stand in for it, or the anchor be past it, and then it goes in
// where the next compile doesn't send it.
const asSent = (i: number, content: ContentBlock[]): ContentBlock[] => rendered?.[i] ?? content;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. A write found later (landing 'unknown') is injected even when the next compile doesn't send it.

The comment admits that a write found at this boundary after it landed at an earlier flush may already be stood in for by a summary, or sit behind windowed passthrough's anchor. A null from the render then means the next compile does not send it, yet asSent injects it as stored anyway. writeDrainedInOrder already knows this case: it logs it with foundLater: true at line 22237. The boundary could therefore skip injecting it, or handle it some other way, when the render gives null. Instead the live round carries a message the next request won't, which breaks the prefix match, and the agent hears something its later context drops.

The comment said as stored is what the next compile sends unless the cap or
an image limit reaches the message. The fragment and the sharded-body test
also name a body stored in parts, which goes in whole, joined as the store
reads it back, while the next compile sends it in more than one piece. The
comment now names both exceptions. Comment only.

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

Copy link
Copy Markdown
Contributor Author

Replying to this review.

One comment fix is pushed, and two findings stand as they are, for these reasons.

The cap and image limits (medium). As stored does differ from the next request there, and the description says so (Problem and Out of scope), as do the comment at the null branch and the fragment. Our probe is the eight-screenshot one you cite. The live round carrying such a message uncut, with every image, is also what agent-framework did for every mid-turn message before 0.24.1, which injected without a render (v0.24.0 has no renderAsNewest call). What closes it is context-manager rendering those messages cut and counted as its next compile does, so that the render stops giving null for a message the compile sends (R1-1 on anima-research/context-manager#275). A fallback here would be a second copy of context-manager's cap and image accounting, and once the render does that, nothing would reach the fallback.

A write found later (medium). By our reading of writeDrainedInOrder, such a write is stored where it landed, at an earlier flush. The live round puts it after this boundary's tool result. So the next request can differ there whichever way the branch goes, and the branch decides only whether the agent hears that message during the turn. We'd rather it heard a message it was sent. The case needs a store write that threw after the message landed, and the comment names it as the exception. Leaving it to that comment, rather than branching on it, was our call.

The comment and the sharded body (low). Fixed in fd4b22ad, comment only. The comment at the null branch now names both exceptions, the cap and image limits and a body stored in parts, as the fragment does.

Written by Milo-2653 (Claude Opus 5.5).

— Daniel-2612

main gained anima-research#726 (F427, deferred writes
found later), anima-research#723 (a write heard mid-turn starts no turn of its own) and
more since this branch's merge of 5143b30. Two conflicts, both in the
tool boundary's comments in src/framework.ts:

- The flush's account of what it injects: this change's "One the render
  gives null goes in as stored", in place of main's left-out notice, with
  anima-research#726's account of a write that threw after it was stored: looked up by
  its id and carried where it landed, and the known limit, one neither
  read back nor looked up, carried at the boundary that finds it.
- After the consumed mark: main's paragraph from anima-research#723 on a message the
  boundary stored and left out of the injection, which the notice told the
  agent of. This change leaves no message out of the injection (a null
  goes in as stored, and a tune-out message is skipped before the render,
  as before), so the paragraph has nothing to describe and goes.

The null branch's comment still names a write found at an earlier flush
as the exception, which is anima-research#726's known limit. No code conflicted.
@nissa-seru
nissa-seru merged commit 66c8079 into anima-research:main Oct 24, 2026
12 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 24, 2026
main brought anima-research#720, which injects a message the render gives null as
stored rather than leaving it out of the live round, and so removes the
left-out notice, and anima-research#786's changelog for 0.26.2.

src/framework.ts conflicted in three places, resolved as main has it,
with this branch's additions kept:
- the tool boundary's injection: main's asSent, with each injection's
  stored id (clock and heard messages), which the stream's notes name;
- the comment on the consumed mark: this branch's wording for the
  thinking-binding record, and main's removal of the left-out paragraph;
- recordLeftOutNotice: removed, as main removed it.

test/clock-rows.test.ts merged without conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 24, 2026
…-research#720

One conflict, in the tool boundary's drain loop (src/framework.ts): anima-research#720
dropped `parts` from what the drain hands back and from `heard`, with the
left-out notice that used it; the branch's side keeps its two lines there,
releaseLandedMail for each stored message and the skip of a message held
from the residents (heldFromResidents), which anima-research#720's side has as the older
tuneOut check. The resolution is the branch's lines without `parts`.
Everything else merged without conflict. anima-research#720's new present-while-acting
test of a re-pin to an addressed message the render gives null runs under
the legacy speech rule (speech.locus 'latest-inbound'), as the file's other
re-pin tests do on this branch, since the branch's default locus doesn't
re-pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant