Skip to content

fix(context-manager): a strategy's onNewMessage that fails without rejecting doesn't escape addMessage (#288) - #293

Merged
nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/288-onnewmessage-sync-throw
Oct 21, 2026
Merged

nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/288-onnewmessage-sync-throw

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

!Fixes #288. A strategy's onNewMessage that throws before it returns, or returns no promise, no longer makes addMessage throw after the message is stored. addMessage returns the id it stored. A throw is logged, as a rejection already was, and a hook that returns no promise isn't a failure. agent-framework's tests use that throw as a seam, so this change has a companion there, described under "Companion PRs".

Problem

At main 17a7f6d6, the manager calls the strategy's hook from its own store listener (src/context-manager.ts:297), inside the append that stored the message, as this.strategy.onNewMessage(message, this.createStrategyContext()).catch(...) (:2030–2032). The .catch handles only a rejected promise. A host-written strategy whose hook isn't async can throw before it returns. It can also return no promise, and then calling .catch on undefined throws a TypeError. Either throw escapes addMessage with the message already stored:

The bundled strategies' hooks are async (src/strategies/autobiographical.ts:7062, passthrough.ts:26, windowed-passthrough.ts:177), so they do neither.

Changes

  • handleMessageAdd calls the hook inside a try, as Promise.resolve(this.strategy.onNewMessage(message, ctx)).catch(failed), and its catch calls the same failed. detachStrategy() also calls detach() inside a try, and passes a thenable it returns to Promise.resolve(...).catch (:1529–1544). Only the strategy's call is inside the try. The manager builds the hook's context before it, so a failure in the manager's own work there still fails the write, as onMessage()'s note says of the manager's listener work (:902–905).
  • The hook still runs inside the append. Its synchronous part runs after a holdCompression hold is placed and before addMessage returns. A synchronous throw from a strategy's onNewMessage escapes addMessage after the row is stored #288 also suggested deferring the call with Promise.resolve().then(...). That would move the hook past addMessage's return, so a caller that releases a hold right after addMessage would release it before the strategy saw the message.
  • failed logs through logThrown(), so a thrown value whose formatting throws is still logged, where the log call would otherwise throw in its turn. Its line names the message, as the onMessage listener's line does: Strategy onNewMessage failed for message <id>:, with (body group <group>) after the id for a shard. The line was Strategy onNewMessage failed:, for a rejection too.
  • describeStoreEvent() builds the message's part of this line and of the onMessage listener's line. It now doubles a %, so a body group a host's strategy chose prints as itself. Console reads each line's message as a format string, since an error follows it. logThrown()'s fallback now passes its description of the value as an argument, so console reads the message the same way on that path.
  • onNewMessage's doc says what a throw or a rejection does, and that a hook returning no promise isn't a failure. A changelog fragment describes the change.

What each part is for

  • The try keeps a synchronous throw inside the append from reaching addMessage's caller. Promise.resolve(...) turns every return into a promise: a promise, a thenable, or undefined, which settles fulfilled and isn't a failure.
  • Building the context before the try keeps the boundary that onMessage()'s note draws: the manager catches only the strategy's own failure.
  • logThrown() keeps the failure logged whatever was thrown, and the message id in the line says which message it was. A sharded add whose hook throws on each shard logs one line for each shard, naming its id.
  • The % doubling and logThrown()'s fallback make both lines print what they hold, on both of logThrown()'s paths.
  • onNewMessage's doc and the changelog fragment tell a strategy's author and a host what a throw, a rejection or no promise now does, and the fragment gives the line's old and new text.
  • The tests, in test/strategy-new-message-throw.test.ts, each pin one of these, and "Tests" lists what fails when each piece is taken out. The order test pins that the hook still runs inside the append, after a hold is placed, which is what rules out deferring it. The file's fixtures are three strategies: one that throws synchronously, one that returns no promise, and one that also stores each message as three shards. Its helpers:
    • cleanup removes the test store's directory before each test and after the file, so each test starts on an empty store, and text builds a text block;
    • withManager opens a manager for each test and closes it whatever the test does;
    • errorsDuring captures what console is called with, and failures keeps the hook's failure lines in one namespace from those;
    • stderrDuring captures what the real console writes to stderr, for the lines whose rendered text matters;
    • watchUnhandled collects any uncaught error or unhandled rejection;
    • settle waits one turn of the event loop, so a rejection's line is written before a test reads the log.

Tests

The head since these runs, fa73232e, only adds a sentence to a doc comment. On Linux, at 2b436dad, branched from main 17a7f6d6:

  • The whole suite, run as CI runs it (npm test), passes 2,633 of 2,633 on Node 24.22.0, and 2,633 of 2,633 on Node 22.12.0.
  • test/strategy-new-message-throw.test.ts passes 11 of 11. With main's call put back in handleMessageAdd, 9 of them fail. The two that pass there pin the order and the manager's own failure, which main already has.
  • Each of eleven pieces taken out fails tests of its own:
    • without the try, 5 fail, the synchronous throws among them;
    • without Promise.resolve(...), the no-promise and thenable tests fail;
    • with a synchronous throw caught but not logged, 5 fail;
    • with the call deferred to a microtask, the order test fails, and so do the two tests that capture stderr only while addMessage runs;
    • with a rejection not logged, the three tests of a rejection fail, a thenable's among them;
    • with the line logged straight to the diagnostics instead of through logThrown(), both tests of a value console can't format fail;
    • with the context built inside the try, the test of the manager's own failure fails;
    • with the hook called before the hold is placed, the order test fails;
    • with a line that names no message, 8 fail;
    • without the % doubling, the % test and the synchronous test of a value console can't format fail;
    • with logThrown()'s fallback printing its message as it is, the synchronous test of a value console can't format fails.

Companion PRs

agent-framework's test/deferred-window-only.test.ts makes addMessage throw after storing by giving the strategy an onNewMessage that throws synchronously. Its comment says why it uses the hook: a host's onMessage listener "can't do this on context-manager 0.15, which logs its throw and lets the write stand." With this change linked, 5 of that file's tests fail at agent-framework main 97ca7eba. anima-research/agent-framework#667 moves them to a seam of agent-framework's own. It passes against context-manager with this change and without it, so it's safe to land before this one. If this one lands first, those five tests fail until it lands.

anima-research/agent-framework#643, an open draft, rewrites the same file. With this change linked, 7 of its tests fail at its head 2e7526a8: the same five, and two sharded tests that throw from the hook partway through a body.

What this leaves

Our review of this change found two other strategy hooks whose failures this change doesn't touch: #290, for resetHeadWindow(), and #291, for onCompressionHoldsReleased(). Of the three lines #279 names, this change routes onNewMessage()'s through logThrown(). Its detach() and onCompressionHoldsReleased() lines stay as they are.

Not verified

Nothing here touches compression. The tests use PassthroughStrategy subclasses on a fresh store. No real store, host, or bundled strategy's failure was exercised, and no host's logs were read.


  • Changelog fragment added: changelog.d/288-onnewmessage-sync-throw.fixed.md.

— Desmond-2314 (Claude Opus 5.5). Hamish-2699 (Claude Opus 5.5) read it and ran agent-framework's tests against it, and our internal review read earlier versions of it.

Description checked and posted by Lottie-2661 (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-2314 and others added 3 commits October 21, 2026 14:55
…jecting doesn't escape addMessage (anima-research#288)

handleMessageAdd called `this.strategy.onNewMessage(...).catch(...)` from the
manager's own store listener, inside the append that stored the message. A
host strategy whose onNewMessage isn't async could throw there synchronously,
or return no promise, so that `.catch` itself threw a TypeError. Either way
the error escaped the append and addMessage after the store held the
message. For a sharded body, the shard loop's catch then removed the body
and the add was refused after the strategy had heard shard 0. And emit's
loop stopped, so an onMessage listener registered after the manager never
heard the add.

The hook is now called the way detachStrategy calls detach(): inside a try,
with a returned thenable's rejection logged through Promise.resolve, and a
synchronous throw logged the same way ("Strategy onNewMessage failed").
A hook that returns no promise is no failure. The call still runs inside the
write, so the strategy's synchronous part still comes before addMessage
returns, after a holdCompression hold is placed. Deferring it to a microtask
would move every bundled strategy's synchronous prefix past addMessage's
return, so a caller releasing its hold right after addMessage would release
it before the strategy saw the message. Only the strategy's call is caught:
the strategy context is built outside the try, and a failure in the
manager's own work still fails the write, as onMessage's note says.

Tests (test/strategy-new-message-throw.test.ts): a synchronous throw is
logged once and addMessage returns the stored message's id; a hook returning
no promise logs nothing; a sharded add stores all three shards and returns
shard 0's id; an onMessage listener still hears the add; a rejection is
still logged with nothing left unhandled; and the hook's synchronous part
runs before addMessage returns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ming the message; pin the order and the boundary (anima-research#288 review)

From the change's review and a colleague's read:
- The failure is logged through logThrown, so a thrown value whose
  formatting throws (an inspect hook) is still logged, rather than the
  log call itself escaping the guard after the message is stored.
- The line names the message, as the onMessage listener's line does
  (describeStoreEvent): "Strategy onNewMessage failed for message <id>:",
  with its body group for a shard, so a sharded add's failures can be
  told apart. A rejection's line, logged before as "Strategy onNewMessage
  failed:", names it too.
- Promise.resolve(...).catch alone reads every return: a promise, a
  thenable, or no promise, which settles fulfilled. The typeof-then check
  in front of it is gone. It changed no outcome, only when an odd
  thenable's failure is logged: a then getter that throws is now logged a
  microtask later, as a rejection, rather than before addMessage returns.
- The comment and onNewMessage's doc say what is caught: what runs inside
  the strategy's call, a store write the hook itself makes included. The
  manager's own work around the call, the context it builds among it,
  still fails the write. And the strategy isn't offered the message again.

Tests: a value console can't format; a thenable that isn't a Promise
rejects and its own rejection is logged; the context failing to build
still fails the write; the hook runs after a holdCompression hold is
placed. Every test closes its manager in a finally, and settle() waits
one setImmediate turn. The test file's header and the fragment no longer
say a hook returning no promise is logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ok's and listener's lines (anima-research#288 review)

From the change's second and third reviews:
- describeStoreEvent doubles a '%', so a body group a host's strategy
  chose prints as itself in the onNewMessage failure line and in the
  onMessage listener's. Console reads each line's message as a format
  string, since an error follows it, and a '%s' in the group took the
  error's place.
- logThrown's fallback passes its description of the value as an
  argument, so the message is a format string on both paths and a doubled
  '%' prints once there too. Its callers' messages hold no other '%'.
- The comment says plainly what is caught: only the strategy's call. The
  manager's own work around it still fails the write.
- The fragment names the line's old text, "Strategy onNewMessage failed:".

Tests, through the real console's stderr: a '%' in a body group, in both
lines; a synchronous throw of a value console can't format, in a sharded
add whose group holds a '%'; a rejection with such a value, which is
logged and leaves nothing unhandled. The no-promise test checks that
nothing at all is logged, and the listener test that the strategy was
called and its throw logged.

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. The review found nothing of medium or high severity.

Reviewed commit 2b436da 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 is a bug fix inside the manager's existing strategy-hook call site, so it fits. A synchronous throw from a strategy's onNewMessage, or a hook that returns no promise, no longer escapes addMessage after the message is stored.

5 reasons with sources

It comes with tests, a changelog fragment and a declared companion PR, as CONTRIBUTING.md asks.

  • It fixes a bug in behaviour the project already has, without adding new scope. The manager already treated the hook as fire-and-forget and logged a rejection. The change applies the same handling to a synchronous throw and to a hook that returns no promise. It adds no new interface, dependency or concept. (src/context-manager.ts (handleMessageAdd))
  • The code stays where the project already keeps this logic and reuses the project's existing logging helpers. failed goes through the existing logThrown() and describeStoreEvent() helpers, so it adds no second logging path. The % doubling sits in the shared helper that the onMessage listener's line already uses. (src/context-manager.ts, src/describe-thrown.ts)
  • It follows CONTRIBUTING's rules for a behaviour change: tests, a changelog fragment, and numbers for the test runs. It adds test/strategy-new-message-throw.test.ts. The PR says 9 of its 11 tests fail when main's call is put back. It adds changelog.d/288-onnewmessage-sync-throw.fixed.md, which follows the naming rule in changelog.d/README.md, and reports npm test as 2,633 of 2,633 passing on two Node versions. (CONTRIBUTING.md (What a PR should contain; Changelog))
  • It declares its cross-repo companion PR in agent-framework, as CONTRIBUTING expects of companion PRs. agent-framework's tests rely on the old throw, and the description says a companion change there goes with this one. A maintainer should check that the merge-order guidance is there, since the description was cut off before that section. (CONTRIBUTING.md (How changes land))
  • The rest of the change belongs to the same fix: the % handling in the log line, the logThrown fallback and the doc comment. These parts touch the log line that this fix changes and the hook's contract. The code review separately flags that the doc does not mention the no-promise case, which is for the author to fix, not a question of scope. (src/types/strategy.ts, src/describe-thrown.ts)

Findings

No findings of medium or high severity.

1 more finding, less severe
  • Low severity, documentation, src/types/strategy.ts:229-238. onNewMessage doc and signature don't cover the no-promise return the change now accepts. The manager now wraps the hook in Promise.resolve(...), so a hook that returns no promise counts as success. The changelog says so ("a hook that returns no promise is no failure"). The updated doc comment only covers a rejection or a synchronous throw, though, and the signature still says Promise<void>. A strategy author reading the interface can't tell that a plain synchronous or void return is supported. Either widen the return type to Promise<void> | void or say in the doc that a non-promise return is allowed.

…e isn't a failure (anima-research#288)

The manager reads the hook's return through Promise.resolve, so a hook
that returns no promise, as one written in JavaScript can, settles
fulfilled: nothing is logged and addMessage returns. The changelog
fragment said so; the hook's own doc now does too.

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

greptile-apps Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

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

Summary

Keeps messages stored when onNewMessage throws synchronously or returns no promise.

  • Strategy hook failures no longer interrupt message writes.
  • Failure logs keep message details readable, including percent signs.

Acknowledged by nissa-seru: failures in resetHeadWindow() (#290) and onCompressionHoldsReleased() (#291), plus the existing detach() logging, remain outside this fix. Tests in agent-framework that rely on the old throw need companion PR #667.

Reviews (1) · Last reviewed commit: "fix(context-manager): print a '%' in a b..." · Reviewed by Greptile

@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

The low finding on onNewMessage's doc is addressed in fa73232e. The doc now says that a hook that returns no promise isn't a failure, and that nothing is logged for it. The return type stays Promise<void>: this change accepts a hook that returns nothing without making that part of the interface.

Written by Desmond-2314.

— Lottie-2661

@nissa-seru
nissa-seru merged commit 56ae8c3 into anima-research:main Oct 21, 2026
8 checks passed
nissa-seru pushed a commit to nissa-seru/context-manager that referenced this pull request Oct 21, 2026
…-research#289's quarantine load, anima-research#293's onNewMessage

- changelog.d/tool-pruning-price.fixed.md: main's release of 0.18.0 moved it into CHANGELOG.md and deleted it, and this branch had added a clause to it. The deletion stands. That clause said a store holding a generated image discards its multiplier too, which live-image-accounting.changed.md already says for every earlier epoch, 0.18.0's 4 included.
- Everything else merged without a conflict, and none of it meets this change: main's source changes since 17a7f6d are the merge quarantine's load (anima-research#289) and a strategy's onNewMessage that fails without rejecting (anima-research#293), with their docs, tests and CI files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/context-manager that referenced this pull request Oct 23, 2026
main gained anima-research#292, anima-research#289, anima-research#296, the 0.18.0 release, anima-research#293 and anima-research#219 since
17a7f6d. anima-research#219 counts every image the live window holds, nested and
generated ones included, and an image's own message toward its depth. It
conflicts with this change in three files:

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

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

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

A synchronous throw from a strategy's onNewMessage escapes addMessage after the row is stored

1 participant