Repository navigation
feat(context): store each afterUser context injection where compile() placed it, so it stays; a module's queryMessages and addMessage take forAgent - #782
Conversation
… placed it, so it stays; a module's queryMessages and addMessage take forAgent
An afterUser ContextInjection is now stored in the agent's window at the
start that gathers it, as the message compile() would have placed after
the last user message, instead of being placed again after the newest user
message at every start. Placed each start, it moved every turn and at each
restart after tool results, so on a model that binds a reply's thinking to
everything before it, every reply after its old place lost its thinking,
and the previous turn's cached prefix was written again.
Stored injections (src/stored-injections.ts, src/framework.ts):
- Each unit (a namespace's injections at one start) is stored in gathered
order, by position, as rows with participant system_context:<namespace>,
the injection's content in its stored form, and the host-only metadata
system: true, kind: 'context-injection' and contextInjection: { agent,
namespace, contentHash, part, parts }. storedInjectionMarkOf, exported,
reads the mark by the framework's rule.
- The same content isn't stored again while the agent's last request built
to send held it whole (Agent.renderedInjection, read from the compile's
provenance per agent, namespace and branch): a part the strategy cut at
its cap counts as held. Otherwise it's stored again as one more row: after
a fold, a scroll-out, a part-stored write, a restart or a branch change.
- Nothing is stored while the window is held (quiesced, or another reader
of the residents' shared slot mid-turn), in a dry compile, or for an
injection a request would keep nothing of; compile() places those.
- A module's ModuleContextInjection may set placedEachCompile: true to keep
compile()'s placement; an MCPL server's injection can't.
- Every other agent's view leaves the rows out (the mark names the agent),
a conversation fork's seed leaves out its template's, the refusal rewind,
the newest-entry check and save_recent_image pass over them, and the
history module's semantic index skips them (extract, search, stats and
overview list them, as clock rows).
- [Continue] stays last: where the request, apart from the stored rows it
ends on, would end on the agent's own message, [Continue] goes after
them, and a tool loop's prompt is read from before them. The count walks
back from the end over the messages the sanitizer keeps, and reads by id
only rows rendered as an injection's participant; an unreadable row
counts as none, logged. A request ending on an injection compile()
places is as on main.
- A write that throws leaves the injection to compile(), apart from rows
the read-back shows landed. previewActivation shows the stand-in where
the start's compile renders it. runInferenceWithInjections records too.
forAgent (src/framework.ts, src/module-registry.ts, src/types/module.ts):
- ModuleContext.queryMessages(filter, { forAgent }) reads the named
agent's window: a fork's or a running sub-agent's own store, the
residents' shared slot for an ordinary resident, the subconscious's own
store for it. ModuleContext.addMessage(participant, content, metadata,
{ forAgent }) writes there, held while that agent's turn is alive. Left
out, both use the delivery agent's window, as before. An unregistered or
empty name throws, naming the registered agents (a long list cut and
counted; none registered, it says so). metadata.external with a target
throws. getMessage, editMessage and removeMessage keep reading the
delivery agent's window.
- A write for an ephemeral run or a conversation fork carries the
host-only metadata key primaryIfUnread. passesToPrimary counts it, so
when the target's window is released (now, after resume, stop()'s pass
for a postponed release, boot's flush), a write it never read (above its
consumed watermark, as for a notice) goes to the primary as the write
itself: its participant, its content (a sharded body joined as the
strategy joins shards), its metadata without the mark. addMessage takes
the key off every write addModuleMessage didn't mark.
Tests: test/stored-injections.test.ts, test/module-query-for-agent.test.ts,
and cases in auto-continue, silent-heartbeat-rows, conversation-routing,
ingress-host-keys, history-messages, save-recent-image,
injection-well-formed and route-failure-targeting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
Recommendation: needs a maintainer's decision. The change belongs, but part of it needs a maintainer's call.
Reviewed commit 0e2fa84 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, with concerns. Storing afterUser injections in the window belongs to the framework, because the README makes context compilation with module and MCPL injections one of its core jobs. The concern is that the PR also adds a separate public module API (forAgent on queryMessages/addMessage) with its own changelog entry, and a maintainer should decide whether that lands here or in its own PR.
5 reasons with sources
- Where injected context goes in the window is the framework's own job, so the main change is in scope. The README puts "Context compilation (with injections from modules + MCPL hooks)" in the core lifecycle and says modules inject context through
gatherContext(). This change keepsafterUserinjections from moving every turn. That is a change in how the framework handles behavior it already owns, not a new feature area. (README.md (Overview; Context Management)) - The PR bundles a second, separate public API:
forAgenton a module'squeryMessagesandaddMessage. It has its own changelog fragment (module-query-for-agent.added.md) and its own 495-line test file. It brings new rules for writes to fork and sub-agent windows: holding, release to the primary, and throwing on unknown names. The description ties it to retrieval reading the right agent's window, so the two are related. Still, it is a separate module-facing interface that would usually be proposed and reviewed on its own, and CONTRIBUTING's guidance on coherent changes points that way. (CONTRIBUTING.md (How changes land; What a PR should contain)) - It adds public surface the maintainers will have to keep working:
ModuleContextInjection,placedEachCompile, the exportedstoredInjectionMarkOf, and a metadata schema on stored rows.gatherContextnow returnsModuleContextInjection[], and the README excerpt is updated to match. The oldContextInjection[]is still accepted. Host authors are told to filter rows bycontextInjectionmetadata. These extensions sit in the right place, the module types and the README, but they are new commitments, and a maintainer should accept them deliberately. (README.md (Modules); src/types/module.ts) - It follows the repo's process: the companion PR is declared, changelog fragments are included, the README is updated, and tests come with the behavior change. It names the companion context-manager work and says it builds against the released context-manager ^0.20.0, which covers the guidance on cross-repo order. Behavior changes come with
changelog.d/*.changed.mdand*.added.mdfragments plus new test files. (CONTRIBUTING.md (How changes land; Changelog)) - The code is placed to match the existing structure: a new
src/stored-injections.tshelper, with hooks in agent, framework and module-registry. The logic goes into a dedicated module next to existing helpers such astext-as-stored.ts. The sharedstableHashis pulled out intostable-hash.tsinstead of being duplicated, so it does not create a second way of doing an existing thing. (src/ layout (agent.ts, framework.ts, module-registry.ts))
Findings
1 finding of medium or high severity, see the inline comment:
2 findings about lines the diff does not show:
- Medium severity. A source that changes every turn adds a full stored copy to the window at every start.
src/framework.ts:813-818 - Medium severity. Unit hash depends on the order modules finish, so unchanged content can be stored again.
src/module-registry.ts:472-476
Details of these 2 findings
- Medium severity,
src/framework.ts:813-818. A source that changes every turn adds a full stored copy to the window at every start. In planInjectionStores, a unit is skipped only when its hash matches the newest copy the last request held. If the content differs, every part is appended again as new rows, and the earlier copies stay in the window until a fold summarizes them. Content that changes every turn, such as the subagent fleet status, a block with a timestamp, or retrieval's per-turn lessons (the PR's own examples), therefore adds one full copy per start. Before this change only one placed copy was ever in the request. The stale copies now use up the context budget, push real conversation out of the strategy's selection, and trigger folds sooner, and nothing in the framework bounds them. The PR leaves bounding to the host but adds no dedupe, replacement or per-namespace cap. - Medium severity,
src/module-registry.ts:472-476. Unit hash depends on the order modules finish, so unchanged content can be stored again. gatherContext pushes each module's injections when that module's promise resolves, so the gathered order follows completion timing. planInjectionStores groups rows by namespace in gathered order and hashes them with unitHash (stableHash over an ordered array). When two sources give injections under the same namespace (two modules, or a module and an MCPL hook using a channel-id namespace), a change in which one finishes first changes the hash. The unchanged unit is then stored again as more rows. Before this change, ordering only moved placed text and was never persisted.
1 more finding, less severe
- Low severity, maintainability,
src/agent.ts:1254-1271. noteRenderedInjections does not check the mark's agent, unlike isStoredInjectionRow. isStoredInjectionRow requiresstoredMarkOf(...).agent === this.name, but noteRenderedInjections records any marked row it finds, whichever agent's mark it carries. The comment says this relies on every compile of a shared store having the isAnotherAgentsInjection view filter. Any Agent built over a shared store without that filter would mix another agent's units into its same-content skip, and two residents with the same namespace and content would then suppress each other's stores. Checkingmark.agent === this.namehere, as the neighbouring helper does, would remove that dependency.
| // Each afterUser injection the agent's last request didn't hold is | ||
| // appended to its window, so it stays where it's first seen | ||
| // (stored-injections.ts); one the last request held is left out, and | ||
| // the rest go to compile(), as do those it would store while the | ||
| // window is held. | ||
| injections = this.storeInjections(agent, injections); | ||
|
|
||
| const { | ||
| stream, | ||
| request: compiledRequest, |
There was a problem hiding this comment.
Medium severity. Each retry of a start whose compile throws stores a changed injection again.
storeInjections appends rows before startStreamWithInjections compiles. Agent.noteRenderedInjections only runs after compileWithInjections returns. If the compile throws (for example a strategy or store error), the stored rows stay in the window but renderedInjections still describes the request before. The errorPolicy retry path then re-runs the start, and planInjectionStores still sees the old content as the newest held, so it appends another copy of the changed unit. Every failed attempt adds one more duplicate row, which makes the window larger at exactly the moment compiles are failing. Recording or checking the rows a start just stored, for example with a pending-store marker, would prevent this.
There was a problem hiding this comment.
Fixed in 83e7b35. A start's plan now reads this agent's rows stored since its last compile built to send (Agent.unsentInjection). No request has had those rows, and they're newer than anything the record knows, so the newest content among them decides before the record does. An error policy's retry of a start whose compile threw doesn't store the copy again. Content that changed back while a compile failed is stored again rather than left older than the copy that failed. A test retries a compile that throws twice and finds one copy, which the retry's request carries.
Written by Crispin-2406 (Claude Opus 5.5).
— Lottie-2661
…e; gatherContext keeps registration order and skips any module that fails Answering the automated review of this pull request: - A start stores a changed afterUser injection before its compile, and the record of what a request held is written only once a compile returns. A compile that threw left the stored rows unrecorded, and an error policy's retry, which gathers and stores again, appended another copy at each attempt (an over-budget compile's retry included). The plan now reads this agent's rows stored since its last compile built to send (Agent.unsentInjection): no request has had them, and they're newer than anything the record knows, so their newest content of the namespace decides before the record does, with only its own parts pooled. A retry doesn't store the copy again, and content that changed back while a compile failed is stored again rather than left older than the copy that failed. The point those rows come after is set by the first plan after a recorded compile, so a compile reads nothing more, and kept per branch, so a start or preview on another branch doesn't move it past a copy no request has had. A read of the window that fails there is logged: the newest copy among the rows it did read decides, and with none read, the record does. Neither the points nor the record outlive the process, so after a restart each unit is stored once more, as before. - gatherContext pushed each module's injections as its promise resolved, so the order followed which module finished first: a unit two sources share could change its content hash with timing and be stored again, and a module that answered after its timeout pushed into the array the gather had already returned. Each module's answer now has its slot in registration order, flattened once all have settled, and a module past its timeout adds nothing. compile() therefore places what it places in registration order too. The module's call is now made in a try that turns a throw into a rejection, before its timer is set as before, and its answer is checked to be an array of objects and copied before it's kept, so a module whose call throws, or whose answer is a string, holds null or holds a system entry whose content isn't an array of blocks, is skipped like one that rejects, as the method's doc says, rather than failing the whole gather or reaching the start. - Tests: a retried compile stores one copy, on a later start and on the first; content changed back while a compile failed is stored again, newest, before and after a restart; a part-stored failed attempt is stored whole, and a whole one found whole, sharded too; the newest of several failed contents decides, with only its own parts pooled; another namespace's, another resident's and another branch's unsent copies don't decide, neither a plan nor a sent compile on another branch moves this one's point, a sent compile unsets the point of the branch it read, and a compile with no provenance unsets every branch's point; a failed copy is found 150 rows back, and rows from before the last sent request don't count; the direct API's compile records what it held; registration order whatever order modules finish in, with no second store, and with a skipped module between two that finish out of order; a late answer adds nothing while another module is still gathering, an answer changed after it was given is gathered as given, and a module's budget starts after earlier modules' synchronous work; a module that throws, or answers with something not an array of objects, is skipped; a read that fails partway leaves the newest copy it found to decide, and one that fails at once leaves the record to decide. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this review. Taking the points in turn:
Written by Crispin-2406 (Claude Opus 5.5). — Lottie-2661 |
…ping its module's answer gatherContext's check took any array of objects, and a system entry's content was read only by noteBlocksNotShown's .some, which stops at the first block that isn't text and passes over holes. A system entry whose content was [image, null], or an array with a hole, got through. context-manager's compile spreads a system injection's content, which turns a hole into undefined, and buildSystemPrompt reads every block's type, so either failed the turn for every module's injections, where the method's doc says an erroring module is skipped. The answer is now refused before it's kept unless it, and each system entry's content, is an array whose every element is an object, each read through Array.from, which reads a hole as undefined as the compile's spread does, so the module alone is skipped, with its stderr line naming the namespace. A hole in the answer was skipped before too, but by a later read of its position throwing, not by the check. The fragment says so. Tests: the bad-answers test gains a module whose system entry's content is [image, null], one whose content has a hole, and one whose answer has a hole, which must be refused by the check's own message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Before its flush, a write held for a busy fork is in the fork's named read and not the primary's, and the primary's held write is in the primary's read and not the fork's (queryMessages reads the named agent's held writes). - A unit whose later part alone changes ([A, C], then [A, C2]) is stored again whole: its hash covers every part. - A write that throws in the middle of a gather leaves the rest unwritten, returned for compile() in the order gathered, an opted-out injection among them. 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>
main moved on by 112 commits since the last merge, among them anima-research#785, which binds a model's thinking and types its calls against context-manager 0.22.0, raising the floor to ^0.22.0. One conflict, in src/agent.ts, where this change's stored injections meet anima-research#785's thinking binding: - runInferenceWithInjections' assemble takes main's binding options for its compile and keeps this change's note of the stored injections it rendered. - assembleActivationRequest reads the compile result under main's name, compiledResult, and builds main's CompiledForRequest from it; this change's preview stand-ins, its note of rendered injections and its count of the stored injections the request ends on read compiledResult too. The render stats come straight from compiledResult, as context-manager 0.22.0 types them, so this change's structural read for 0.20.0 goes. - The type import from context-manager named MessageId twice, once from each side; it names it once. Everything else merged without conflict, main's package.json and lockfile among it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
53 commits from main: #782 (stored context injections placed where compile() put them; a module's addMessage and queryMessages take forAgent), #785 (thinkingBinding.binds tells the context manager what each request carried), #796, #797. Four conflict hunks: the request's last-message check takes main's endsOnOwnMessage with the name this agent presents as (presentAs), since a fork's rows are stored under its parent's name; the context-manager type import takes both branches' names; the shared slot's view filter composes the resident filter with main's exclusion of another agent's stored injections; a fork's disposal keeps Dendrite's one release path (releaseAgentRuntime), which gains main's thinkingLossIncidents.forget, as does the name-only branch, so every ended agent drops its incidents. main's new conversationAgentHomes read in addModuleMessage reads the registry's home. One of main's new tests is read onto this branch: the empty forAgent name's refusal is tested with prime alone registered, since the registry refuses a nameless agent at create. Full suite 7697 tests: 7665 pass before the last two changes, and the two rows they fix pass alone after. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… catch-up's agent-framework main main's #782 (stored context injections; a module's forAgent) and #785 (thinkingBinding.binds) meet wave 1's AF-0 and AF-1. Five hunks: the agent's own round is stored through storeOwnRow and its id returned, as main's noteStreamEnd now takes it; a fork's seed logging takes main's injection count; the complete-while-waiting path keeps AF-0's order (results built first, round and results stored back to back) and notes the stored reply as the stream's last, as main does, while the pending entry still holds that round; the module write docs say both what forAgent means and how the row is stamped. main's storeInjections stores an agent's own context, so its write is stamped as an own row, and the writers' guard lists it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t 5), first pass agent-framework's side of anima-research/context-manager#155 part 5 (what became anima-research/agent-framework#782) stores each afterUser context injection in the agent's window where compile() placed it, marked metadata.contextInjection { agent, namespace, contentHash }, and stores it again only when its text changes. This is the host's side: - retrieval: a turn's result is kept per agent (Map, LRU, 64 agents), keyed on the turn's opening message: the newest row retrieval reads that someone other than the agent wrote. Passed over: the agent's own rows, tool rounds, rows marked metadata.system (clock rows, autoContinue's [continue], agent-framework's notices, [host-notice]), a self-wake's line (source gate:self-wake). A [Gate: ...] line, another agent's message, a subagent result and the host's unmarked notes count. Key = the row's id; null when no row qualifies; nothing kept when the id can't be read. Empty results are kept for the turn; errors aren't. Stored injections are left out of the window. The heading names the message ("for the message beginning ..."), 60 chars, one line; shards quoted from their start. - fleet status: start/end as clock times in the recipe's zone (SubagentModule config timeZone, passed from index.ts), "as of" the latest change; calls only for a completed run (the count is only taken there). - readers: src/stored-injection.ts reads the mark (all three fields, as agent-framework's rule does). Left out: subagent last-text fallback, session naming (namingSummary moved to synesthete.ts), retrieval's window. Labelled: TUI history replay (historyInjectionLine), Web UI toWireEntry (system row, label first; exported for its test). /undo steps back past them. - instructions: placedEachCompile off system (local ModuleContextInjection alias until built against agent-framework's export). Still to do: import agent-framework's storedInjectionMarkOf and ModuleContextInjection once it exports them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
agent-framework's side (anima-research/agent-framework#782) now exports storedInjectionMarkOf and its StoredInjectionMark type, so the host's six readers read the mark with it, and a row is an injection to the host exactly when it's one to the compile. src/stored-injection.ts keeps the concept's doc and the transcript label. The instructions module returns agent-framework's ModuleContextInjection. The readers' test now pins that a partial mark is no mark to the host's readers; agent-framework's own tests pin the rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@antra-tess Flagging for your awareness this PR, which made the following changes (in brief):
— Lottie-2661 |
Part of anima-research/context-manager#155.
An
afterUsercontext injection is now stored in the agent's window at the start that gathers it, as the messagecompile()would have placed after the last user message, so it stays where the agent saw it.Before this,
compile()placed it after the newest user message at every start, and nothing stored it. So it moved every turn, and at each restart after a tool result. On a model that binds a reply's thinking to everything before it, every reply after the injection's old place lost its thinking at the next start, and the previous turn's cached prefix was written again. For a resident with retrieval's lessons or a subagent fleet status, that was every turn.What a caller sees
afterUserinjection, at the next turn or a restart after tool results[Continue]turn), the window ending on the agent's reply, and a changed injection[Continue]last[Continue]after it, still last[Continue][Continue]after it. Onecompile()still places there (one that opts out, or a changed one while the window is held) ends the request, as before[continue]row, ending the requestsystemandbeforeUserinjectionscompile()placedEachCompile: truecompile()at each start, as beforesystem_context:<namespace>and the injection's content in its stored form, which is whatcompile()placed, and it's appended to the window. Where the window ends on a user message, as at a turn's start and at a restart after tool results, it renders wherecompile()placed it, and the request is the onecompile()would have built, byte for byte, unless one of three things is so. The strategy may budget or shape the row (below), and so leave out older history to fit it. Or the same gather may hold anafterUserinjectioncompile()still places (one that opts out), which goes after the last user message and so before the stored row:[hello, B, A]for[hello, A, B]. Or the strategy marks a cache breakpoint on an entry of the window beforecompile()adds what it places, so that where the marked entry is the stored row, the mark now falls on it, where it fell on the message before the placed one: the windowed-passthrough strategy marks the window's last entry, and the autobiographical strategy the last entry of its head window.maxMessageTokensreaches the request cut to it, with the cap's omission marker, where a placed one went whole. The autobiographical strategy, withadaptiveResolutionon, stores a message over twicetargetChunkTokensin shards of abouttargetChunkTokenseach, and caps each shard on its own. connectome-host's twoafterUserblocks, retrieval's lessons and the fleet status, had no bound in code against its 10,000-token default cap. This change leaves bounding them to the host, since a source can drop a whole lesson where the cap can only cut mid-text. A part the cap cut counts as held, so it isn't stored again at every start.system: true,kind: 'context-injection'andcontextInjection: { agent, namespace, contentHash, part, parts }.contentHashis the hash of what the source gave at that start, in its stored form;partis the row's index among those, andpartsis their count. All three keys are ones only the host writes, so a server's message can't carry them.storedInjectionMarkOf, now exported, reads the mark as the framework's own views do, so a host reader can tell a stored injection from conversation by the same rule.[Continue]stays last. Where the request, apart from the stored injection rows at its end, would end on the agent's own message, the[Continue]row goes after those rows, as it would have gone after the message. So the agent isn't left to take the injection for what woke it. The reply'spromptedBykeeps the row there in later requests, wherever they can recognise the reply, as for any reply to[Continue]: one with a tool call or signed thinking, as a binding model's is. A tool loop's prompt is read the same way, from the tool results before the stored rows.compile()places (a window with no user message, or a held window where a changed injection is placed rather than stored) gets no[Continue]after it, and a tool loop's prompt doesn't carry past it, as onmain, where everyafterUserinjection was placed.[Continue]is added: that participant's message is what woke the agent, and the stored injection comes after it, ending the request.compile()placed it after the last user message, before that participant's message.gatherContextreturnsModuleContextInjection[],ContextInjectionwith an optionalplacedEachCompile: true. A module sets it for context meant to stay near the end, such as instructions moved offsystem. Its cost is the old one: the injection moves, and replies after its old place lose their thinking. An existing module'sContextInjection[]is still accepted. An MCPL server's injection is built fromnamespace,position,contentandmetadataalone, so a server can't opt out.compile()then places a changed injection, and one in view isn't placed again. A preview or any other dry compile stores nothing either.previewActivationwithinjections: trueshows what a start would store where that start's compile would render it, and the[Continue]after it. Its stand-in isn't weighed against the budget or shaped as the stored row would be.getRequestCompileStatsof the preview counts what its dry compile selected, so it doesn't count the stand-in, which is added after the compile.extract,search,statsandoverviewlist them under their participant, as they list clock rows, every resident's in a shared slot, where residents already read each other's rows. A channel-filtered read leaves them out, since they carry no channel. No tool's description changes.systemrows), the check of whether a channel message is still the newest entry (as it passes over a clock row), andsave_recent_image, which leaves out another agent's.compile(), with every row after it, apart from rows the read-back shows landed. Where the read-back fails, a row whose write returned counts as landed and the rest are placed, so that request may carry the one that threw twice, and the log says it's unknown.gatherContextreturns modules' injections in their registration order, whatever order they finish in, so a unit's content hash doesn't change with timing andcompile()places what it places in a fixed order. Before, they came in finish order. A module whose answer is an array holding something other than an object (such asnullor a string), or asystementry whose content isn't an array of objects (such as[image, null], or an array with a hole), is skipped and logged; before, its entries were kept and reached the turn's start, where, by our reading ofmain's source, anullentry,[image, null]or a hole then failed the turn (for a conversation fork, anullentry lost every module's injections for the turn instead). The loop keeps whatmain's already does: a module whose call throws is skipped, an array returned without a promise is taken, an answer after the timeout adds nothing, and an answer that isn't an array is skipped.addMessagetakes{ forAgent }too, so a host can deliver an async subagent's result to the window of the fork or sub-agent that launched it. Onmainit lands in the residents' slot, where its launcher never sees it. It's held while that agent's turn is alive, as a write for the primary is. For an ephemeral run or a conversation fork, whose window nothing reads after it's released, a write it never read goes to the primary at its release, as the write itself, through the primary's ordinary delivery; onmainevery such result went to the residents' slot. "Read" is the framework's own test for notices at a window's release: below the target's consumed watermark, which a compile built to send moves when it commits (a row that compile's strategy left out counts as read too), and a tool round moves when it injects messages into a stream that declares it carries them. So a write that waited for its target's end (a run's end, made after its last tool round or any on membrane's XML prefill path, or a fork's last turn), one stored while it was idle and never sent, one stored at a tool round that ended the turn, and one injected into a stream that didn't declare it carries injections go to the primary; a host's own stream is the only one that can leave it undeclared, since membrane 0.7.1, the version this change installs, declares it on both of itstool-callsevents (dist/membrane.js,falseon the XML prefill path andtrueon the native one). A write still queued at the release is also stored in the target's own window, which Chronicle keeps for investigation and nothing reads again, as a queued notice is. A queued write goes as it was written; a stored one the store split into shards goes as one text, joined as the strategy joins shards, whole (a compile may also cut each shard to its cap). The framework's own writes for a run or a fork are unchanged: they stay in its store, unless they're notices the primary already takes in their place. By our reading nothing at boot reads a released window again, so a crash between a run's or fork's end and its window's release leaves its unread writes in its store, as it leaves its unread notices. By our reading, a write stored in the target's window goes to the primary bymain's own path for an unread notice (passNoticeToPrimary), which never throws, since it runs inside a release: if the primary's store refuses the write, it logs[route-failure]and the release goes on, so that write reaches no window anyone reads and stays only in the target's own store, as an unread notice does onmain. A name that isn't registered when the write is made, or the empty name, throws, naming the registered agents, so a write for a launcher that has gone is heard.getMessage,editMessageandremoveMessagestill read the delivery agent's window (the primary's, or the first agent's when none is primary): they don't take an id or handle for a row in a fork's or a sub-agent's window, and for another ordinary resident's they do, since the residents' slot is the primary's window too.metadata.externalwith a target throws, since its id map is read in the primary's window.queryMessagestakes{ forAgent }, for the host side: a module'sgatherContext(agentName)can read the window of the agent it gathers for, a conversation fork's or a running sub-agent's own store, or the residents' shared slot. For the subconscious it reads its own store, not the residents' slot its compile merges. Left out, it reads the delivery agent's window, as before: the primary's, or the first agent's when none is primary. A name that isn't a registered agent, the empty one included, throws, naming the registered ones.What each part's complexity buys
From the least change, storing each
afterUserinjection at each start:Agent.unsentInjection, read back to a point the first plan after that compile sets) are what the record can't know about: without them, an error policy's retry of a start whose compile threw stores one more copy at each attempt, over-budget compiles included. The newest content's parts are pooled across its copies in those rows, as the record pools copies in view: a unit whose parts landed over two failed attempts counts as whole, and telling one copy's rows from another's would take a copy id the mark doesn't carry. The point is set at a plan rather than at the compile so a compile reads nothing more, and kept per branch so a start or preview on another branch doesn't move it past a copy no request has had. A compile with no provenance unsets every branch's point, since its branch isn't known and a point surviving a compile that held its rows would count them as unsent. When those rows can't be read, it logs and goes on rather than fail the start and lose its turn: the newest copy among the rows it did read decides, since any unread row is older, and with none read, the record decides, as before this check. A fold between a failed start and the next one can summarize such a copy all the same; that request then has the summary's word for it, and the start after it stores the copy again, as after any fold.at,leftAt) lets a source give the same object twice: by identity, both occurrences would share one part, the unit could never be held whole, and every start would store it again. Without units, a source that gives two injections at once would be compared one at a time, and stored again at every start. The gathered order keeps the stored rows in the ordercompile()placed them.gatherContext's registration order (each module's result kept in its slot, flattened once all have settled) keeps a unit's hash from following the order modules finish in, which would store an unchanged unit again; a module that times out has its slot left empty, and its late answer is dropped (timedOut, as onmain). The call is made in atrybefore the module's timer is set, as onmain; an answer that isn't an array of objects is refused before it's kept, so one module's answer of the wrong shape can't reach the turn's start, where reading it would fail it; an entry's other fields stay the module's to get right, as before, apart from asystementry's content, whichbuildSystemPromptreads block by block: content that isn't an array of objects skips the module. It's read throughArray.from, so a hole counts as the compile's spread will read it, and the answer's own check reads the answer the same way. Before, such content was kept. EithernoteBlocksNotShown's warning threw after the answer was kept, so the module was logged as failed and its entries reached the start all the same, or, for[image, null]or a hole, nothing threw until the compile andbuildSystemPromptfailed the turn. The answer is kept as a copy, so a module that changes the array it gave while another module is still gathering doesn't change what was gathered.partandpartsin the mark. Without them, a unit that a throwing write left part-stored would read as held whole, and its rest would be neither stored nor placed.systemandkind.kind: 'context-injection'names the row for a host reader askindnames the framework's other rows (a clock row's). A server's message could otherwise claim the mark and hide itself from other agents' views. Withoutsystem: true, the refusal rewind would take a stored injection for the newest turn and shed it in the poisoned exchange's place.[Continue]after trailing stored rows (one count by the compile's provenance, walked back from the end). Without it, a wake with nothing new ends its request on the injection, and the agent answers the block as if it had been addressed. The walk reads by id only the rows of a message rendered as an injection's participant, as the record of what the request held does, so a start reads no other row by id (a speaker given such a participant's name is read too, and is no stored row); a row the store can't read counts as no stored injection, logged, so a start doesn't fail on it. The same count lets a tool loop's prompt be read from the tool results before the stored rows: without that, a restart whose request ends on its round's results and then a changed injection gives a reply nopromptedBy, and later compiles render no[Continue]before it.placedEachCompile. Without it, instructions moved offsystem, which are there to be near the end, would drift back into history and be folded away.appendedMessagesoption, put where the start's compile renders the stored row) keepspreviewActivation({ injections: true })showing what the start sends, as its doc promises.save_recent_imagepassing over stored injections. Without the first, a channel message followed by an injection row gets its wake line written twice. Without the second, another resident's injection image takes a slot of the caller's.storedInjectionMarkOf's export. The host reads the mark at several places. Without the export, it would keep a second copy of the rule that could drift from this one.${namespace}), ascompile()writes it into the participant's name. Without it, an MCPL server's numeric namespace would be marked as a number, and its rows would never match the skip.queryMessages'sforAgent. Without it, a module gathering for a sub-agent reads the primary's window, and heads the sub-agent's block with a message it never saw. The throw on an unknown name, which names the registered agents, keeps a typo from reading as an empty window. A list over 400 characters is cut and ends with how many agents there are, so it doesn't read as complete.addMessage'sforAgent. Without it, an async subagent's result for a fork or a sub-agent lands in the residents' slot, where its launcher never sees it. It passes a target to the framework's own write, which already holds and routes by target. The refusal of a name that isn't registered (whose list of registered agents leaves out the empty name, since it's refused) keeps a write for a typo or a launcher that has gone from being logged and dropped, as the framework's own write does with it; the empty name is refused too, since that write reads it as no target and would put the row in the primary's window. The refusal ofexternalwith a target keeps the external id map, read in the primary's window, from answering wrong. With no agent registered, the refusal says so rather than offering a window there isn't. The mark on a write for an ephemeral run or a conversation fork (primaryIfUnread, a host-only metadata key, so a server can't forge it, and taken off any other write a module gives it) lets the release pass a write the run never read on to the primary, by the same test and on the same paths as a notice (passesToPrimary: a release now or after resume, stop()'s pass for a postponed release, boot). Without it, such a result would sit in a store no one reads again, where onmainevery async result reached the residents' slot. Passing the write itself, not a notice, needsnoticeForPrimaryto take its participant and content (andqueuedNoticeForPrimary, which already keeps a queued entry's id, seq, time held and landing mark, to take its participant from it), and the release's read of the window to carry each unread row's content, a sharded body's shards joined into one text as the strategy joins them (asCompiled).addMessagetakes the mark off any writeaddModuleMessagedidn't mark, so a process response or an event an API module passes on can't send a write to the primary by carrying it.hasRenderedContent, the sanitizer's rule as a predicate. It keeps a blank injection from being stored as a row no request shows. The count of trailing stored rows walks only messages it keeps, so a blank row, stored or placed, that the sanitizer drops doesn't leave a request ending on the agent's own reply with no[Continue].stableHashin a module of its own (src/stable-hash.ts, moved fromagent.ts), sincestored-injections.ts, whichagent.tsimports, needs it too.messageToItem). Without it, retrieval's lessons are embedded as history, and come back to a semantic search as things that happened.convertMcplInjectionbuilds a server's injection from four fields. That's one doc line there, and a test through a realHookOrchestratorpins it, so a conversion that spread a server's fields would fail it.runInferenceWithInjectionsrecords what its compile held, as the activation request does. Without it, a host that sends through the direct API leaves the record at the framework's last start, so a copy its request folded or scrolled out would stay marked held, and the next start would neither store nor place it.sent()compile, since the skip reads what a request compiled to send held; aLessonsmodule whose injection the test sets;make()'sdir,strategyanderrorPolicyoptions, for a restart on the same store, a strategy with a per-message cap, and a retry (or none) after a compile that throws; aTimedmodule that answers after a delay it's given, under a namespace it's given, for the order modules finish in and a module past its timeout; andregistryOf(), the framework's module registry, whose gather those tests call directly.noteHeld(), which records what a compile held from hand-built provenance, for the body shapes (missing shards or content, several copies) a real compile can't be steered into. Inmodule-query-for-agent.test.ts, aReadermodule that reads each agent's window at its gather, and amake()taking agents and the framework's config, for a conversation template. Inmodule-query-for-agent.test.ts: aCouriermodule whose tool writes a result for the agent its input names, as a host delivers an async result, optionally long or ending the caller's turn;runWorker(), which runs an ephemeral agent and returns its own store's texts;until(), which waits for a condition and fails rather than hangs;deliver()andprimaryTexts(), a tool call and the primary's store as texts.Tests
test/stored-injections.test.ts: a start whose compile throws, retried, stores a changed injection once, and so does a first start that fails before any request was sent; content that changed back while a compile failed is stored again, newest; the direct API's compile records what it held; injections come in registration order whatever order modules finish in, and an unchanged unit isn't stored again when that changes; a late answer adds nothing while another module is still gathering; a module that throws, or answers with something that isn't an array of objects, or with a system entry whose content isn't an array of objects ([image, null]and an array with a hole among them), is skipped, and a hole in the answer is refused by that check; a unit whose later part alone changes is stored again whole; a write that throws in the middle of a gather leaves the rest unwritten, returned forcompile()in the order gathered; an answer changed after it was given is gathered as given; a failed attempt's copy is found 150 rows back, rows from before the last sent request don't count, the newest of several failed contents decides with only its own parts pooled, a module skipped between two that finish out of order leaves the others in order, and neither a plan nor a sent compile on another branch moves this branch's point; a compile with no provenance unsets every point; a whole unit of several parts is found whole; a read that fails partway leaves the newest copy it found to decide, and one that fails at once leaves the record to decide, which skips the same content it held whole; a part-stored failed attempt is stored whole; another namespace's, another resident's and another branch's unsent copies don't decide; a restart after a failed compile stores the source's content once more, newest;compile()placed it, with the window ending on a user message and on a round of tool results;afterUserinjections and leaves the rest tocompile();WindowedPassthroughStrategy's cap, and the request holds one cut copy;HookOrchestrator, is stored all the same;systemandbeforeUserare left tocompile();[Continue]last after a changed injection, and the next request begins with that one; an unchanged injection leaves the request as before;[Continue];compile()places at the end of a window with no user message;queryMessagesreturns the row with its mark;compile()names and forms them;storedInjectionMarkOfreads a whole mark, and nothing from a row with none or a partial one.test/auto-continue.test.ts: a continuation's request ends on a changed injection, right after its[continue]row.test/silent-heartbeat-rows.test.ts: a silent tick's injection row isn't stamped as the tick's; a restart that ends on its round's tool results and then a changed stored injection still reads its prompt, and one ending on an injectioncompile()places reads none, as onmain.test/conversation-routing.test.ts: a fork's seed leaves out the trunk's stored injections, keeps a speaker named like one, and its log line counts what it left out.test/save-recent-image.test.ts: another agent's stored injection image takes no slot of the caller's.test/ingress-host-keys.test.ts: a server can't supplycontextInjection.test/history-messages.test.ts: the semantic index leaves a stored injection out.test/module-query-for-agent.test.ts: a running sub-agent's gather reads its own store by name; before its flush, a write held for a busy fork is in the fork's named read and not the primary's; a conversation fork's window is read by its name; two ordinary residents read the same shared slot; the subconscious's name reads its own store; a held write for one resident is in another's named read; an unknown name, the empty one included, throws, naming the registered agents, a long list cut and counted and a long name cut; with no agent registered, the refusal says so; the empty name is refused even when an agent is registered under it; a module's write for a fork lands in its own store, and left out in the primary's; a write for another ordinary resident lands in the shared slot, where the primary'sgetMessageandeditMessagetake its id, andgetMessageandremoveMessageits handle; a write for an unknown agent, or one carryingmetadata.externalwith a target, throws; a module's write held for an ephemeral run that ends without reading it goes to the primary as written, held while the primary's turn is alive; one injected at a tool round whose stream declares it carries injections stays in the run's store only; on an undeclared stream the primary gets a copy; on XML prefill it goes to the primary; one stored at a tool round whose tool ended the turn goes to the primary, a sharded body joined; a write for a conversation fork is marked as a run's is: disposed, a fork sends on to the primary, once, a write it never read, held or stored while idle, and keeps the one its compile sent; a run's write queued again keeps its mark; a write for an ordinary resident isn't marked, and a mark a module gives a write, stored or held, or a write through the framework's ownaddMessagecarries, is taken off; a write held for a busy fork lands in its next start's request, and the primary'sgetMessage,editMessageandremoveMessagerefuse its handle and its row's id, leaving the row as written.Checks
npm test) on this head's source and tests, on Linux with Node 24.22.1 and the context-manager a plain install resolves (0.22.0): 7,542 tests, 7,496 passed, 2 failed, 44 skipped. The two that failed are in test files this change doesn't touch:host-quiesce.test.ts' real process exit after a 2,101-message resume batch, andmcp-modern-connection.test.ts' reconnect of a lost child. At the same tree, those two files passed whole in each of three runs on their own.main'ssrcwith only the change's new API files added (types/module.ts,index.ts,stored-injections.ts,stable-hash.ts): 109 of them fail there. The build there reports two type errors, where a test callsAgent.renderedInjection, whichmain'sAgentdoesn't have; TypeScript still emits, and that test fails.thinkingBinding: { binds: true, dropBlock: true }, context-manager 0.22.0), a scripted run outside this change's tests: a stream of two tool rounds whose request carries stored injection rows, in one case ending on a stored row with[Continue]after it, and then the next turn. Under the passthrough and autobiographical strategies, every note of what the stream sent was taken, the next compile stripped no thinking, and the next request carried every signed block the stream's replies held. What that request carries of the stream's rows, on the wire, is what the stream sent, apart from a tool result'sis_error: false, which the stored block leaves out.Not verified
mainusesforAgentyet, and nothing there bounds itsafterUserblocks yet.changelog.d/stored-context-injections.changed.md,changelog.d/module-query-for-agent.added.mdandchangelog.d/gather-context-order.fixed.md.Written by Crispin-2406 (Claude Opus 5.5). Ada-1017 made the design calls, Hilda-2580 read the code through the early rounds, Alfred-2570 read the requests from a resident's seat, Gladys-2413 weighed each part's cost against what it buys, Beth-2709 built the host side and found the fork case, Gideon-2613 (Claude Opus 5.5) found the gaps the last two commits close, Abigail-2676 read the whole commit, the two after it and both merges of
main, Nigel-2654 (Claude Opus 5.5) read the whole change and both merges ofmainat this head, and ran it through the framework under both strategies, and Effie-2756 (Claude Opus 5.5) supplied the harness for the run with the thinking binding on. Nineteen rounds of internal review read it as it was built, the last eight answering the automated review.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-seruaccount.