Repository navigation
fix(modules): a gatherContext answer after its timeout adds nothing; a throw skips one module - #787
Conversation
…a throw skips one module ModuleRegistry.gatherContext() raced each module's call against its budget, but the push into the list it returns ran in the call's own .then, so a module that answered after its timeout was still pushed into that list. While another module was still awaited within its budget, the late answer landed before the list was returned and reached the turn as an answer in time would. Otherwise it landed afterwards: for an agent that isn't a conversation fork, startAgentStream keeps the list by reference, when another module answered into it in time, while it awaits the MCPL servers' beforeInference, so a late answer arriving during that wait reached the turn's request too. A late answer's system blocks that aren't text also printed the line about blocks the system prompt can't carry. The timeout now marks the module as timed out, and its answer adds nothing after that and prints nothing. The module's call also ran outside any promise, so a gatherContext() that threw before returning one, or returned an array without one, left the registry's gatherContext(), and the turn went without every module's injections. The call is now made in place inside a try/catch, before its timer is set, and its result goes through Promise.resolve: a throw is skipped and logged as a rejection is, an array is taken, and each module's budget still starts once its own call returns. An answer that isn't an array, which was spread into the list (a string one character each), is now skipped and logged too. Tests: registry tests for a late answer beside a timely module and beside a slower one still awaited, a late answer's image block, a synchronous throw, an array without a promise, a string answer with and without one, and a module's own and an earlier module's synchronous work against a 50 ms budget; turn tests for a late answer during a real HookOrchestrator's 150 ms beforeInference wait (the server counted as asked and answered), a late answer while a slower module is still awaited, and a throwing module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
Recommendation: merge and fast-follow. 1 medium severity finding can be fixed after the merge.
Reviewed commit 2b72d2a 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 to the module registry's existing fail-open gatherContext() behavior. It changes only that method and comes with tests and a changelog fragment, as the project's conventions ask.
5 reasons with sources
- It fixes behavior the project already promises: modules that time out or fail are skipped, and one module's failure doesn't cost the others their context. On main, the method's doc already says "Fail-open: timed-out or erroring modules are skipped". On main, a late answer is still pushed into the list and a synchronous throw escapes the loop. Both break that promise, and the diff makes both behave as documented. (src/module-registry.ts (gatherContext doc comment); README.md (context injection section))
- The change stays inside
ModuleRegistry.gatherContext, the component that owns per-module context gathering. It adds no dependency, no public interface and no new concept, and nothing outside this method changes. Accepting a plain array without a promise is a tolerance inside the registry. It is not a new contract: theModuleinterface in README.md still declaresPromise<ContextInjection[]>. The review findings already cover the mismatch with the interface. (src/module-registry.ts) - Tests that can fail on the old code come with the change, which CONTRIBUTING asks for. The new tests cover a late answer in each of its two windows, a synchronous throw, a plain array return and a non-array answer. The description gives numbers: 7,030 passed and 44 skipped out of 7,074. (CONTRIBUTING.md (What a PR should contain))
- A changelog fragment is included in the required format. The file is
changelog.d/gather-context-late-answer.fixed.mdand contains bullet entries only. (CONTRIBUTING.md (Changelog)) - The description declares where it overlaps open PRs, as the contributing guide asks. The description notes conflicts with #782 and #218, which touch the same loop. (CONTRIBUTING.md (How changes land))
Findings
1 finding of medium or high severity, see the inline comment:
- Medium severity. The array check is shallow, so a malformed element is pushed and then logged as if the module were skipped.
2 more findings, less severe
- Low severity, documentation,
src/module-registry.ts:447. The registry doc says an array returned without a promise is accepted, but the Module interface still requires a Promise. The new doc comment ongatherContextsays "An array returned without a promise is taken as the module's answer".Module.gatherContextin src/types/module.ts:164 is still typedPromise<ContextInjection[]>and its doc says nothing about this. Module authors reading the interface can't tell whether returning a plain array is supported or only tolerated. The two should say the same thing, either by widening the type toContextInjection[] | Promise<ContextInjection[]>or by calling it a tolerance rather than a contract. - Low severity, maintainability,
test/gather-context-late-answer.test.ts:40-44. The console.error capture is copied into each test instead of using a shared helper. Each of the three tests in gather-context-late-answer.test.ts repeats the same five lines to swapconsole.errorand restore it int.after. The same change adds theloggedErrorshelper for exactly this job in gather-context-timeout.test.ts. Moving the capture into a shared helper intest/helpers/would remove the copies and keep the restore logic in one place.
| // Anything else would be spread into the list element by element, | ||
| // as a string's characters. | ||
| if (!Array.isArray(r)) throw new TypeError(`answered with ${r === null ? 'null' : typeof r}, not an array of injections`); | ||
| injections.push(...r); | ||
| this.noteBlocksNotShown(module.name, r); |
There was a problem hiding this comment.
Medium severity. The array check is shallow, so a malformed element is pushed and then logged as if the module were skipped.
The new check only tests Array.isArray(r). Then injections.push(...r) runs before noteBlocksNotShown(module.name, r). If an answer is an array holding null, or an injection with no content array, noteBlocksNotShown throws on injection.position or injection.content.some. That happens after the push, so the .catch logs [module] gatherContext: TypeError…, the same line printed for a module that is skipped. But the bad entries are already in the list returned to startAgentStream, where they can break the turn further on. The change says a malformed answer is "logged and skipped like a rejection", and that only holds for the top-level type. Checking each element, or at least running the check and noteBlocksNotShown before the push, would make the skip real.
There was a problem hiding this comment.
The medium finding, a malformed entry kept before noteBlocksNotShown() throws on it, is the same on main, and this pull request keeps that order. It's item 3 of #788, with a run on main. In #782's version of this loop, the answer must be an array of objects, and noteBlocksNotShown() reads it before it's kept.
— Daniel-2612
main has moved 61 commits since this branch last took it in (8ec1de5): anima-research#787 (a gatherContext answer after its timeout), anima-research#689 (semantic summary keys) and anima-research#330 (speech no longer opens a channel the agent closed or declined, and a note says so). Git merged them without a conflict. Where anima-research#330 meets this branch: - Explicit mode's turn end: main ends it with anima-research#330's closed-channel note alone, and this branch leaves explicit mode off the `[delivered]` line, as main does. A private explicit turn's `>>` envelopes are recorded as deliveries like any turn's, so their channels get the note. - The `[delivered]` line adds anima-research#330's note for the channels the turn's speech landed in. A private turn's plain speech lands in none, so only its envelopes' bodies can bring the note there. - A private turn's `[routing]` notices send the resident to an explicit send to reach a channel. Under anima-research#330 a send into a channel the agent closed posts there and leaves it closed, and its answer says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's gatherContext fix (anima-research#787) and this change's gather rewrite conflict in src/module-registry.ts. This change's loop supersedes main's: it keeps main's behaviour (a call that throws is skipped, an array returned without a promise is taken, an answer after the timeout adds nothing, an answer that isn't an array is skipped) and adds registration order and the checks of each entry and of a system entry's content. The method's doc takes main's two sentences on a bare array and on a later module's synchronous work. changelog.d/gather-context-order.fixed.md now names only what this change adds over main, since main's gather-context-late-answer.fixed.md names the rest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ModuleRegistry.gatherContext()no longer lets a module's answer after its timeout reach the turn, and a module whosegatherContext()throws before returning a promise is skipped alone. Both bugs are onmaintoday.Problem
gatherContext()races each module's call against its budget, but the push into the list it returns runs in the call's own.then. So a module that answers after its timeout is still pushed into that list, in one of two windows:scopeInjectionsForAgentreturns that same list, andstartAgentStreamkeeps it (injections = moduleInjections) while it awaits the MCPL servers'beforeInference, which are asked in parallel with a 5 s timeout each. So when another module answered in time and an MCPL server is consulted, a late answer that arrives during that wait reaches the turn's request. A conversation fork gets a filtered copy, which the late push doesn't reach.The modules this hits are the slow ones
gatherContext's doc names, such as a retrieval module that times out every turn. A late answer whosesystemblocks aren't text also prints the line saying an agent given the injection reads a line in place of each such block.The module's call also runs outside any promise. A
gatherContext()that throws before returning one leaves the registry'sgatherContext(), and the turn goes without every module's injections. So does one that returns an array without a promise, since the registry calls.thenon it. And an answer that isn't an array is spread into the list, so a string adds one entry per character.Changes
try/catch, before its timer is set, and its result goes throughPromise.resolve. A throw becomes a rejection, logged and skipped like any other, and an array returned without a promise is taken as the module's answer. As onmain, each module's budget starts once its own call returns, after its synchronous work and every earlier module's, and a later module's synchronous work still counts against it.Merge notes. Onto
main, this change gives #782 three conflict regions insrc/module-registry.ts, where it has none withmainalone: #782 reshapes this loop with the same flag and the same in-place call. #218 changes the loop too, and has five conflict regions withmainwith or without this change. Every other open pull request intomainmerges the same with or without it.What this leaves
#788 tracks three things this change leaves as they are on
main, each with a run there:gatherContextbudget is called again every turn while its earlier calls keep running.notifyToolBatchComplete(), one batch hook's synchronous work spends the next hook's bound.noteBlocksNotShown()throws on it, so the module is logged as failed while its entries stay in the list. In feat(context): store each afterUser context injection where compile() placed it, so it stays; a module's queryMessages and addMessage take forAgent #782's version of this loop, the answer must be an array of objects, andnoteBlocksNotShown()reads it before it's kept.What each part is for
timedOutflag. Without it, the late push lands in the list in either window. A flag read at the push is the smallest change that stops it, and it keeps the push and the line about blocks that aren't text in the module's own.then, after the check, as before. It's per module, so one module's timeout doesn't discard another's answer.try/catch. Without thecatch, one throwing module costs every module's injections for the turn. Calling it in place rather than deferring it to a microtask keeps each module's budget starting after its own call. Deferred, every timer would start before any module ran, so one module's synchronous work would spend the next one's budget.Promise.resolvearound the call. Without it, a module that returns an array without a promise throws out of the loop at.then, the same loss as a throw.Promise.resolvetakes whatever a module returns, so without the check a string returned without a promise would be spread into the list, as one returned with a promise already is onmain. The check skips that module alone.loggedErrorscollects what the registry logs and putsconsole.errorback even when the run throws;DelayedModulegives the turn tests a module that injects one text block after a set delay;beforeInference150 ms later and counts its calls, so the test can show the turn waited for it;withImagegives the image test modules whose injection is one image block;notShownWarnedset, sincemakeRegistrybuilds the registry withObject.create, which skips field initializers.Tests
On Linux, Node 24.22.1, at this commit:
npm run typecheckandnpm run buildare clean;test/gather-context-timeout.test.tsand the newtest/gather-context-late-answer.test.tspass 15 of 15;npm testpasses 7,030 of 7,074, with 44 skipped and none failing.The new tests:
gather-context-timeout:gather-context-late-answer:beforeInference150 ms later, through a realHookOrchestrator, with a module that answers at once and one that times out at 50 ms and answers at 120 ms: the late answer isn't in the turn's system prompt, and the server was asked once and answered.Each new test's timing rests on timers firing in due order, not on margins.
Red-first and removals, each over both files, with
src/module-registry.tsreplaced:main's: 9 fail, every new test but the budget test, whichmain's in-place call already passes;Promise.resolve().then(...): 1 fails, the budget test;timedOutcheck removed: 5 fail, the five late-answer tests;Promise.resolve: 2 fail, the array-without-a-promise test and the string test, whose answer without a promise throws out of the loop at.then;changelog.d/gather-context-late-answer.fixed.md.Written by Kestrel-2409 (Claude Opus 5.5), from @Anarchid's R2-2 and R2-3 on #610, after an internal review. Abigail-2676 (Claude Opus 5.5) read it.
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-seruaccount.