Skip to content

feat(context): store each afterUser context injection where compile() placed it, so it stays; a module's queryMessages and addMessage take forAgent - #782

Merged
nissa-seru merged 6 commits into
anima-research:mainfrom
nissa-seru:crispin/cm155-part5-stored-injections
Oct 25, 2026
Merged

nissa-seru merged 6 commits into
anima-research:mainfrom
nissa-seru:crispin/cm155-part5-stored-injections

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

Part of anima-research/context-manager#155.

An afterUser context injection 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, 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

Before After
An unchanged afterUser injection, at the next turn or a restart after tool results placed again after the newest user message: it moves stored once, where it was first placed: it stays
A changed one placed after the newest user message stored there, after the turn's opening message; the earlier copy stays where it was
A wake with nothing new (a timer, a [Continue] turn), the window ending on the agent's reply, and a changed injection placed after the last user message, before the reply; [Continue] last stored after the reply; [Continue] after it, still last
The same, in a window with no user message at all placed at the end; the request ended on it, with no [Continue] stored at the end; [Continue] after it. One compile() still places there (one that opts out, or a changed one while the window is held) ends the request, as before
An autoContinue continuation and a changed injection placed after its [continue] row, ending the request stored there, ending the request
system and beforeUser injections placed by compile() the same
A module's injection with placedEachCompile: true — placed by compile() at each start, as before
Another agent reading the same slot (a second resident, the subconscious) never saw it doesn't see it: its view leaves the row out
  • The stored row has participant system_context:<namespace> and the injection's content in its stored form, which is what compile() 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 where compile() placed it, and the request is the one compile() 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 an afterUser injection compile() 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 before compile() 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.
  • It's history to the strategy. A stored row is sharded, selected by the budget and folded like any message. Under the windowed-passthrough strategy, one larger than maxMessageTokens reaches the request cut to it, with the cap's omission marker, where a placed one went whole. The autobiographical strategy, with adaptiveResolution on, stores a message over twice targetChunkTokens in shards of about targetChunkTokens each, and caps each shard on its own. connectome-host's two afterUser blocks, 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.
  • Its metadata carries system: true, kind: 'context-injection' and contextInjection: { agent, namespace, contentHash, part, parts }. contentHash is the hash of what the source gave at that start, in its stored form; part is the row's index among those, and parts is 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.
  • The same source's unchanged content isn't stored again while the agent's last request held it whole: the last compile built to send, which records what it held (one whose request is then stopped or fails records too, since its rows stay in the window). A source is a namespace, and what it gives at one start is a unit. A unit is skipped only when that request's newest unit of its namespace, the newest it held at all, has the same content and every part of it was there with every shard. An injection that would leave nothing in a request (no block but blank text) isn't stored, and is placed as before. A part the strategy cut at its per-message cap, or stripped an image from, counts as held, since a new copy would meet the same. Otherwise the unit is stored again, as one more row: after a fold has summarized it, a strategy's window has passed it, a write left it part-stored, the host has restarted, or the window has changed branch. A fold between two starts, or one the same start's compile makes, or one a host's own committing compile makes, leaves the next request with the summary's word, and the start after that stores the copy again. A scroll-out works the same way with no summary to stand in: when the rows since the last request push a copy that request held out of this start's selection, this start skips it, its request goes without it, and the start after that stores it again. A unit part-stored by a throwing write is stored whole at the next start, so the part that landed has two copies. A copy a start stored whose compile then threw is newer than what the last request held, and no request has had it yet, so it decides instead: among this agent's rows stored since its last compile built to send, the newest content of the namespace is compared, with its parts pooled. So a retry of that start (an error policy's) doesn't store it again, and content that changed back while a compile failed is stored again rather than left older than the copy that failed. Where those rows can't be read, the newest copy among the rows read before the failure decides, and with none read, the last request's record does, as it did before. Neither that point nor the record outlives the process, so a restart after a compile threw leaves the first start after it storing each unit once more, as after any restart, and the source's content is newest.
  • Each change adds a row until a fold summarizes it, and a copy whose source stops giving it stays in the window until a fold, where before it was gone at once.
  • [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's promptedBy keeps 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.
    • A request that ends on an injection 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 on main, where every afterUser injection was placed.
    • Where another participant spoke last (another resident, a visitor), no [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.
  • gatherContext returns ModuleContextInjection[], ContextInjection with an optional placedEachCompile: true. A module sets it for context meant to stay near the end, such as instructions moved off system. Its cost is the old one: the injection moves, and replies after its old place lose their thinking. An existing module's ContextInjection[] is still accepted. An MCPL server's injection is built from namespace, position, content and metadata alone, so a server can't opt out.
  • Nothing is stored while the window is held: while the host is quiesced, or while another reader of the residents' shared slot has a turn alive, as for a clock row. compile() then places a changed injection, and one in view isn't placed again. A preview or any other dry compile stores nothing either.
  • previewActivation with injections: true shows 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. getRequestCompileStats of the preview counts what its dry compile selected, so it doesn't count the stand-in, which is added after the compile.
  • A conversation fork's seed leaves out its template's stored injections, as it had none before they were stored. It goes by the compile's account of each message's stored rows and their marks, so a speaker named like an injection's participant stays. A summary that folded a stored injection in is seeded, as summaries are.
  • The history module's semantic index leaves stored injection rows out, so retrieval's own lessons aren't embedded as messages that happened. A summary that folded one in is indexed as summaries are. Its extract, search, stats and overview list 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.
  • Framework readers that look at the newest rows pass over stored injections: the refusal rewind (it skips system rows), the check of whether a channel message is still the newest entry (as it passes over a clock row), and save_recent_image, which leaves out another agent's.
  • A write that throws leaves the injection to 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.
  • gatherContext returns modules' injections in their registration order, whatever order they finish in, so a unit's content hash doesn't change with timing and compile() 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 as null or a string), or a system entry 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 of main's source, a null entry, [image, null] or a hole then failed the turn (for a conversation fork, a null entry lost every module's injections for the turn instead). The loop keeps what main'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.
  • The first start after upgrading stores each injection once, so that request's cached prefix ends where the injection used to be placed.
  • A module's addMessage takes { forAgent } too, so a host can deliver an async subagent's result to the window of the fork or sub-agent that launched it. On main it 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; on main every 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 its tool-calls events (dist/membrane.js, false on the XML prefill path and true on 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 by main'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 on main. 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, editMessage and removeMessage still 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.external with a target throws, since its id map is read in the primary's window.
  • A module's queryMessages takes { forAgent }, for the host side: a module's gatherContext(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 afterUser injection at each start:

  1. The same-content skip, read from what the agent's last request held (one record per agent, from the compile's provenance, kept per namespace and content, and one comparison per namespace). A part counts as held when all its shards are in the request, however the strategy shaped them: without that, a copy the cap cut would never count, and every start would store it again. Without it, every start stores the unchanged injection again. Reading it from the request rather than the newest stored copy means a copy that has scrolled out or been folded is given back rather than lost, and a start reads back through the window only as far as its rows since the last sent compile, 64 rows at a time. The rows stored since the last sent compile (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.
  2. Units by namespace, written in the order gathered, planned by position. Position rather than object identity (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 order compile() 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 on main). The call is made in a try before the module's timer is set, as on main; 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 a system entry's content, which buildSystemPrompt reads block by block: content that isn't an array of objects skips the module. It's read through Array.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. Either noteBlocksNotShown'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 and buildSystemPrompt failed 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.
  3. part and parts in 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.
  4. The mark naming the agent, and the view filters. Ordinary residents share one slot, and the subconscious reads it through a merged view. Without the mark and the filters, each would read the others' lessons as rows of its own context.
  5. The mark as a host-only key, with system and kind. kind: 'context-injection' names the row for a host reader as kind names 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. Without system: true, the refusal rewind would take a stored injection for the newest turn and shed it in the poisoned exchange's place.
  6. [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 no promptedBy, and later compiles render no [Continue] before it.
  7. placedEachCompile. Without it, instructions moved off system, which are there to be near the end, would drift back into history and be folded away.
  8. The held-window check keeps a frozen window frozen, and keeps a resident's rows out of another reader's live turn in the shared slot.
  9. The fork seed's filter keeps a fork's seed as it was, and the provenance keeps it exact.
  10. The preview's stand-in (an appendedMessages option, put where the start's compile renders the stored row) keeps previewActivation({ injections: true }) showing what the start sends, as its doc promises.
  11. The landed check on a throwing write, with the trace for a row that landed. Without it, a row that landed is placed a second time, and trace listeners miss it.
  12. The newest-entry check and save_recent_image passing 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.
  13. 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.
  14. Logging of a failed write and of what a fork's seed left out, each naming the agent and the count. A flush that passes a gone agent's entries to the primary already logs how many; passed module writes get a line of their own beside the notices'.
  15. A namespace read as text (${namespace}), as compile() 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.
  16. queryMessages's forAgent. 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.
  17. addMessage's forAgent. 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 of external with 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 on main every async result reached the residents' slot. Passing the write itself, not a notice, needs noticeForPrimary to take its participant and content (and queuedNoticeForPrimary, 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). addMessage takes the mark off any write addModuleMessage didn'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.
  18. 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].
  19. stableHash in a module of its own (src/stable-hash.ts, moved from agent.ts), since stored-injections.ts, which agent.ts imports, needs it too.
  20. The silent tick's rule: a tick's start stores its injection unstamped, as history the agent saw, so a policy that drops a tick's rows leaves it. It's one comment, since the store happens before the tick's reply stamps its own rows.
  21. The semantic index's skip (one line in messageToItem). Without it, retrieval's lessons are embedded as history, and come back to a semantic search as things that happened.
  22. An MCPL server can't opt out, because convertMcplInjection builds a server's injection from four fields. That's one doc line there, and a test through a real HookOrchestrator pins it, so a conversion that spread a server's fields would fail it.
  23. The direct API records too. runInferenceWithInjections records 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.
  24. Test helpers: a sent() compile, since the skip reads what a request compiled to send held; a Lessons module whose injection the test sets; make()'s dir, strategy and errorPolicy options, for a restart on the same store, a strategy with a per-message cap, and a retry (or none) after a compile that throws; a Timed module 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; and registryOf(), 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. In module-query-for-agent.test.ts, a Reader module that reads each agent's window at its gather, and a make() taking agents and the framework's config, for a conversation template. In module-query-for-agent.test.ts: a Courier module 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() and primaryTexts(), 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 for compile() 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;
    • a stored injection renders byte for byte as compile() placed it, with the window ending on a user message and on a round of tool results;
    • an unchanged injection is stored once, and each request begins with the last;
    • a changed one is stored after the turn's opening message, and the earlier copy stays;
    • content that changes back is stored again;
    • a gather that interleaves sources is stored in the order gathered, byte for byte, and each source is skipped or stored as a unit;
    • a gather mixing positions stores its afterUser injections and leaves the rest to compile();
    • a copy the last request held without all its shards, or didn't hold, is stored again, and one it held, whole or shaped by the strategy, is skipped;
    • an injection over the strategy's per-message cap is stored once, under WindowedPassthroughStrategy's cap, and the request holds one cut copy;
    • after a restart, and on a new branch, an unchanged injection is stored once more, then skipped;
    • a module's opt-out is placed as before, and an MCPL server's claim, through a real HookOrchestrator, is stored all the same;
    • an MCPL server's injection gathered at a turn's start is stored there;
    • system and beforeUser are left to compile();
    • a timer wake keeps [Continue] last after a changed injection, and the next request begins with that one; an unchanged injection leaves the request as before;
    • a speaker named like an injection's participant is an arrival, with no [Continue];
    • two residents and the subconscious each see only their own, and the subconscious keeps its own;
    • a row whose mark isn't whole is kept by every view;
    • the skip is keyed by agent;
    • a preview shows what the next start sends and stores nothing; it leaves out an injection in view, places one that opts out, places a changed one while quiesced, and puts a stand-in before what compile() places at the end of a window with no user message;
    • while quiesced, a changed injection is placed and one in view isn't placed again; while another reader of the shared slot has a turn alive, nothing is stored;
    • a module's queryMessages returns the row with its mark;
    • a namespace that isn't a string, and an injection with a lone surrogate, are stored as compile() names and forms them;
    • a write that throws before or after its row lands, a unit left part-stored, which the next start stores whole, and a read-back that fails;
    • a channel message followed only by stored injections is still the newest entry;
    • the refusal rewind sheds the newest exchange, not a stored injection after it;
    • storedInjectionMarkOf reads 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 injection compile() places reads none, as on main.
  • 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 supply contextInjection.
  • 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's getMessage and editMessage take its id, and getMessage and removeMessage its handle; a write for an unknown agent, or one carrying metadata.external with 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 own addMessage carries, is taken off; a write held for a busy fork lands in its next start's request, and the primary's getMessage, editMessage and removeMessage refuse its handle and its row's id, leaving the row as written.

Checks

  • The full suite (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, and mcp-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.
  • Red-first: the 303 tests in the files this change touches, run against main's src with 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 calls Agent.renderedInjection, which main's Agent doesn't have; TypeScript still emits, and that test fails.
  • Removals: each of the change's 104 pieces taken out alone, rebuilt, and run against the change's test files on this head's source and tests, counting only failures beyond an unmutated baseline that passes: 102 are caught. Two survive. One stores an injection's content in its stored form. The store's own write mends the content the same way, but the same call names the namespace, which the store doesn't mend, so it stays. The other reads an answer's entries for the warning about blocks the system prompt can't carry before keeping the answer. That order mattered while the warning's read was what refused unreadable content. The content check before it now does that, so the order no longer changes an outcome.
  • With the thinking binding on (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's is_error: false, which the stored block leaves out.

Not verified

  • No live provider ran it: every run used the framework's mock membrane.
  • Nothing in connectome-host's main uses forAgent yet, and nothing there bounds its afterUser blocks yet.

  • Changelog fragments added: changelog.d/stored-context-injections.changed.md, changelog.d/module-query-for-agent.added.md and changelog.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 of main at 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-seru account.

… 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>

@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: 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 keeps afterUser injections 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: forAgent on a module's queryMessages and addMessage. 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 exported storedInjectionMarkOf, and a metadata schema on stored rows. gatherContext now returns ModuleContextInjection[], and the README excerpt is updated to match. The old ContextInjection[] is still accepted. Host authors are told to filter rows by contextInjection metadata. 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.md and *.added.md fragments plus new test files. (CONTRIBUTING.md (How changes land; Changelog))
  • The code is placed to match the existing structure: a new src/stored-injections.ts helper, with hooks in agent, framework and module-registry. The logic goes into a dedicated module next to existing helpers such as text-as-stored.ts. The shared stableHash is pulled out into stable-hash.ts instead 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 requires storedMarkOf(...).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. Checking mark.agent === this.name here, as the neighbouring helper does, would remove that dependency.

Comment thread src/framework.ts Outdated
Comment on lines 18816 to 18825
// 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,

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

Taking the points in turn:

  • forAgent in this pull request: we chose to land it here, beside the stored injections: a module gathering for a sub-agent needs to read that agent's window, so the block it builds isn't headed by a message the sub-agent never saw, and an async subagent's result belongs in the window of the agent that launched it.
  • A source that changes every turn adds a stored copy each start: intended. Stored rows are history to the strategy: budgeted, sharded and folded like any message, so a changed block costs what a new message costs. A block placed at every compile instead cost every later reply its thinking on a model that binds thinking to what came before it. Bounding what a source injects is the host's job, as the description says.
  • The unit hash following the order modules finish in: fixed in 83e7b35. gatherContext keeps each module's answer in registration order and flattens once all have settled, so a unit's hash doesn't change with timing, and a module that answers after its timeout adds nothing. That also ends a late answer being pushed into the array the gather had already returned. A module whose call throws rather than rejecting, or whose answer isn't an array of objects, is now skipped like one that rejects. compile() now places what it places in registration order too, which the changelog names.
  • noteRenderedInjections not checking the mark's agent: intended. Every compile of a shared store has the view filter that leaves another agent's stored rows out (isAnotherAgentsInjection), so no compile gives it another agent's row, and its doc comment says that's what it relies on.

Written by Crispin-2406 (Claude Opus 5.5).

— Lottie-2661

Crispin-2406 and others added 4 commits October 24, 2026 03:43
…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>
@nissa-seru
nissa-seru merged commit 3b9c7dd into anima-research:main Oct 25, 2026
12 checks passed
tel-0s added a commit that referenced this pull request Oct 25, 2026
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>
tel-0s added a commit that referenced this pull request Oct 25, 2026
… 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>
nissa-seru pushed a commit to anima-research/connectome-host that referenced this pull request Oct 26, 2026
…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>
nissa-seru pushed a commit to anima-research/connectome-host that referenced this pull request Oct 26, 2026
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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@antra-tess Flagging for your awareness this PR, which made the following changes (in brief):

  • An afterUser injection, from a module's gatherContext or an MCPL beforeInference hook, used to be placed by compile() after the newest user message at each start, so it moved every turn. Now it's appended to the agent's history at the start that gathers it, as participant system_context:<namespace>, and stays where the agent saw it. While the host is quiesced, or another reader of the residents' shared slot has a turn alive, nothing is stored: compile() places each injection that start would have stored, as it used to place them all. Unchanged content isn't stored again while the copy the agent reads as current holds every part of it, a part cut at the per-message cap counting as held. After a fold summarizes that copy or the strategy's window passes it, and after a host restart or a branch change, it's stored again as one more row. Changed content is appended as a new row, and the earlier copy stays until a fold summarizes it. A module can keep the old placement with placedEachCompile: true; an MCPL server's injection can't.
  • A stored injection whose source stops giving it used to be gone at once. Now it stays until a fold. As history, it's selected by the budget, sharded, capped and folded like any message, where a placed one went whole. Under the windowed-passthrough strategy, one larger than maxMessageTokens reaches the request cut to it, with the cap's omission marker.
  • Where the host adds the history module, its extract, search, stats and overview list stored injections under their participant.
  • Modules' injections used to come in the order their gatherContext calls finished. Now they come in the modules' registration order.

— Lottie-2661

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