Repository navigation
fix(view): five checks of whether an agent sees a row now use its own view filter - #853
Conversation
The view filter createAgent gives each resident's store leaves out two kinds of row: one a tune-out holds from the residents, and another agent's stored context injection. The subconscious's filter leaves out only the second. But the checks outside the store that ask whether the agent sees a row each kept a test of their own, and had drifted from the filter: - the tool boundary injected every stored row but a tune-out's into the live turn, so a row carrying another agent's stored-injection mark was rendered as null, since the view doesn't hold it, and injected as stored: the live turn heard what the next compile leaves out. And at the subconscious's boundary it left out a row written for the subconscious that carries a tune-out's stamp, though the subconscious's view holds it and its next compile sends it; - noteArrival let a row carrying another agent's stored-injection mark re-pin a turn's locus; - isNewestIn counted a tune-out's row after an entry as newer than it, though the agent's window doesn't hold that row; - isUnreadStoredMessage counted every resident sharing the slot as a reader of another agent's stored injection, so one that had read past it made the injection read for the agent whose context it is; - the recent-image scan behind save_recent_image skipped another agent's stored injection, but gave an image in a tune-out's row a slot. Each now asks the same predicate the agent's store was opened with: both createAgent and the subconscious name it once, pass it to the store as its view filter, and record it beside the store, and the checks ask it by the agent. So none can drift from the view again. An agent whose store is opened with no filter, such as an ephemeral run or a conversation, sees every row, as its compile does. A null a render still gives at a tool boundary is injected as stored, as before: on context-manager 0.23.0 a null under the memory strategy is a message the next compile still sends, and under windowed passthrough it is one a later anchor passed, which the live turn would otherwise never hear. Real tune-out traffic reaches two of the changes. A tune-out stamps a diverted channel message and stores it in the residents' slot, where isNewestIn and the image scan meet it, so a batched wake for one message now runs without its line where the only messages after it were diverted, and an image in a diverted message no longer takes an index. The other changes meet only rows in-process code writes: an MCPL server's metadata loses both keys on the way in, the framework writes a stored injection itself only at a start, straight to the store, and the subconscious's boundary drains only writes queued for it, which a diversion never is.
There was a problem hiding this comment.
Automated review
Recommendation: merge and fast-follow. 1 medium severity finding can be fixed after the merge.
Reviewed commit f4cd95b with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.
Does this change belong in the project?
It fits the project. This bug fix belongs in the project. Five checks of whether an agent sees a row each had their own condition, and those conditions had drifted from the view filter.
5 reasons with sources
Now all five ask the view filter the framework already gives each agent's store, and the change comes with failing-first tests and a changelog fragment, as CONTRIBUTING asks.
- It fixes behavior the project already has: these checks should agree with what the agent's next compile sends, and they had drifted from it. Existing comments at the tool boundary already say a row the view leaves out must not be injected, so the live request matches the compiled prefix. The change makes the code meet that stated rule for every row the filter leaves out, not just tune-out rows. (src/framework.ts (createAgent and subconscious viewFilter; tool-boundary comment citing #232 gap 5))
- It removes duplicated conditions, so there is less code to keep in step, and adds no new way of doing the same thing. Each store's predicate is defined once, passed in as its viewFilter and kept in a private WeakMap. Five hand-written conditions now call that predicate. No public interface, dependency or new concept is added. (src/framework.ts (storeSees / agentSees))
- It brings tests that fail on the old code, as CONTRIBUTING requires for behavior changes. The PR adds seven tests across five existing test files and reports pass/fail counts, including six tests that fail against the base's src/framework.ts. (CONTRIBUTING.md, What a PR should contain)
- It adds a changelog fragment in the required format, and it names the overlapping PR #690. The fragment is changelog.d/boundary-asks-view-filter.fixed.md. The description says #690 edits the same isNewestIn lines, which CONTRIBUTING asks contributors to say. (CONTRIBUTING.md, Changelog; How changes land)
- It is one coherent change: the doc comment edits in held-writes.ts and types/module.ts only keep existing descriptions accurate. Those edits only narrow the wording from 'a tool boundary' to 'a resident's tool boundary', to match the new behavior at the subconscious's boundary. The separate code review found that the subconscious's arrivals now include tune-out rows with no test or changelog line covering it. That is a gap for the author to fix, not a scope question. (src/held-writes.ts, src/types/module.ts)
Findings
1 finding of medium or high severity, see the inline comment:
- Medium severity, test coverage. The subconscious's arrivals now include tune-out rows, but no test covers it and the changelog doesn't mention it.
1 more finding, less severe
- Low severity, documentation,
src/framework.ts:16211-16217. The comment says a reader is an agent whose view holds the message, but the filter checks only metadata. The new comment says "A reader is one whose own view holds the message". The code filters readers only byagentSees(a, message.metadata), which checks the view filter's metadata predicate, not which store the row lives in. Whenagentis the subconscious and the row sits in its isolatedsubconscious/<primary>namespace, every resident insharedSlotAgentsstill passes the filter, although no resident's view holds that store. Their watermarks are then compared against the row's sequence from a different namespace. Either reword the comment or also check namespace membership, so the stated rule matches what the code does.
For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.
| private noteArrival(agentName: string, metadata: MessageMetadata | undefined, messageId: MessageId): void { | ||
| const open = this.openLogical.get(agentName); | ||
| if (!open) return; | ||
| if ((metadata as { tuneOut?: unknown } | undefined)?.tuneOut) return; | ||
| const agent = this.agents.get(agentName); | ||
| if (agent && !this.agentSees(agent, metadata)) return; | ||
| if (isConversationalInjection(metadata)) (open.heard ??= new Map()).set(messageId, ++lastHeardAt); | ||
| const channel = this.repinChannel(metadata, open); | ||
| if (channel) open.arrivals.push({ messageId, channel }); |
There was a problem hiding this comment.
Medium severity, test coverage. The subconscious's arrivals now include tune-out rows, but no test covers it and the changelog doesn't mention it.
noteArrival used to skip any row stamped metadata.tuneOut, for every agent. It now skips only rows that agentSees leaves out, and the subconscious's predicate (line 13535) keeps tune-out rows. So for the subconscious, a stamped row stored by flushForStart (line 26874), noteStored or its own boundary (line 14113) now becomes a turn arrival and is added to open.heard. heardSince then drops the turn's silence/send carry, and the row can re-pin the turn's locus. The PR describes this direction only for the subconscious's tool-boundary injection. Neither the changelog fragment nor any test covers the arrival or carry effect for the subconscious. The new present-while-acting case tests only a resident. Please add a test for a tuned-out row reaching an open subconscious turn outside a boundary, and say in the changelog whether this is intended.
The changelog fragment said a row written for the subconscious that carries a tune-out's stamp now goes into its live turn. noteArrival asks the same predicate, so such a row, unless it's a notice, also counts as heard in the subconscious's open turn. The fragment now says so. isUnreadStoredMessage's comment said a reader is one whose own view holds the message, but agentSees reads only the row's metadata, not which store holds it. It now says a reader is one whose view filter keeps the message.
…earch#845) into the re-arm branch
… whose own view holds it (anima-research#838) anima-research#853 (F486) made every check of whether an agent sees a row ask that agent's own view (agentSees). The check that a message said starts the count again read a tune-out's mark alone, so it now asks agentSees as the other checks do: a message the agent's view leaves out, a tune-out's or another agent's stored injection, starts no count, for the agent it was stored for or for another resident reading the shared slot. A tune-out's row is left out by every resident's view, as before; another agent's stored injection carries a system flag already, so nothing that starts a count today changes. Its removal fails the test that stores a tuned-out message after the re-sends are spent.
110 commits from main, 20 pull requests: #852 (supplied text can't read as the framework's own), #853 (every check of whether an agent sees a row asks its view), #827 (the heartbeat moves into the framework), #846 (exhausted turns re-armed), #830 (chat:system notices), #801 (the reply judge), #840, #843, #839, #791, #845, #847, #849, #822, #854, #836, #855, #857, #858, #859 and the 0.28.0 release. package.json takes main's version and ranges (context-manager ^0.23.1, membrane ^0.8.0). Conflicts: - src/framework.ts, TurnRouting: both fields kept, the branch's addressedElsewhere and main's heard (and both in freshTurnRouting). - src/framework.ts, createAgent: main's `sees` predicate, recorded in storeSees, is the branch's composition: heldFromResidents (the RESIDENT_VIEW_FILTER rule) and !isAnotherAgentsInjection; the view filter reads it. - src/framework.ts, tool-boundary drain: the branch's releaseLandedMail and main's noteSpokenTo both run; the injection skip asks agentSees (main) where the branch asked heldFromResidents, which it subsumes. - src/framework.ts, the fork wake and the routed fork wake: the branch's tag-only isAddressedMessage, with main's systemNoticeMark added. - src/framework.ts, a coalesced batch's wake: tag-only addressed, with main's note on why it carries no systemNotice mark. - src/framework.ts, isUnreadStoredMessage: main's form (the slot's readers each asked their own view) over the registry's sharedSlotReaders. - src/framework.ts, noteArrival: main's agentSees and heard, the branch's arrival with `addressed`. - src/framework.ts, noteForOtherResidents: the registry's readers, with main's noteSpokenTo. - src/framework.ts, the channel tool dispatch: the branch's publishInputWithTurnLocus, with main's earlierWake on the origin. - src/framework.ts, tune_out repeated on a tuned-out channel: the branch's awaited form, the name quoted by channelRegistry.quotedShown (#852). - src/mcpl/channel-registry.ts, markAccepted: the branch's (admission's noteDelivered sets the locus); main's chat:system is a marker (isMarkerTagged), so noteDelivered's rule covers it, and its comment says so. - src/mcpl/channel-registry.ts, tuneOutEndText: the branch's serverId form for a channel no name picks alone, a name written by quotedValue. - src/tune-out/coordinator.ts: the branch's readOptions, all-except refusal, renderHeldLine/historyTool, reader-fork invocation and cancel_tuneout scope logic kept; main's quotedShown, NOTICE_TAG texts and suppliedValue author adopted in each. - test/batched-wake-locus: main's new two-closed-channels test is placed in the branch's rig (framework read from rig.framework()); the branch's ambient-only tests kept. - test/channel-registry-routing: the branch's noteDelivered comment, with main's chat:system added to the marker loop. Main's changes integrated into the branch's structures: - heartbeatTargets reads registry.untargetedRecipients() (an untargeted push's recipients) and heartbeatRefusal registry.homeChannel, in place of conversationAgentHomes. - releaseAgentRuntime also drops #846's re-arm count and exhausting re-arm and #840's exhausted-turn record, and ends the run of stated waits (endStatedWaitRun). - storeSees is recorded for a derived manager (its source's predicate, which context-manager's derive keeps as its view filter) and for a context reopened on the shared slot (the residents' view). - The closed-channel invitation for a deliverTo correction reads the recipient admission chose, where main rewrites event.targetAgents. - #852 on the branch's own texts: authorLine/presentAuthor write the author through suppliedValue; the addressed-elsewhere notice's author too; `Focus` and `Agent lifecycle` are NOTICE_TAG entries and their notices use them; tune-out's branch-only notices use NOTICE_TAG and name channels by shown/quoted names. Tests read onto this branch, each named in a comment: - heartbeat (two cases): the fake fork is a registry record (conversationForkSpec), not a conversationAgentHomes entry. - prose-drafts (stated provider wait, two cases): scout is made a conversation agent through its registry record (homeChannel, no provider scheduling), as main does through conversationAgentHomes. - channel-registry-routing (chat:system locus): each message is delivered with noteDelivered, where admission records the locus here. - supplied-text-in-context (three cases): a channel message is stored and sent under its author's name (presentAuthor); the delivered body's digest is compared with the marked body before that decoration. - tune-out-reader-queued: its fake registry gains quotedShown. Tests: built with zero errors; 29 stale compiled tests removed. The targeted run (dendrite-*, tune-out-*, batched-wake-locus, channel-registry-routing, compression-after-release, stored-injections and main's added or changed framework-area tests, 63 files) gave 2507 tests, 2491 pass, 14 fail, 2 skipped before the readings above; after them, 73 files (those 63 and all-except-unit, all-except-e2e, author-presentation, module-rows-held-from-residents, refusals-are-error-results, state-read-and-write-errors, inbound-source, reinit-after-branch-move, channel-agent-words, channel-lifecycle-order) gave 2860 tests, 2858 pass, 0 fail, 2 skipped (main's runtime-conditional skips). The full suite was not run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The framework opens the store of each agent
createAgentmakes, and the subconscious's, with a view filter, which decides the stored rows that agent sees. Five other places insrc/framework.tscheck whether the agent sees a row, each with a condition of its own, and those conditions had drifted from the filter. This change records the filter's predicate beside each store, and the five checks ask it. One of them is at a tool boundary: a row the agent's view leaves out no longer goes into its live turn, where at this change's base the live turn could hear a row its next compile leaves out.Problem
createAgent's view filter leaves out a row a tune-out holds from the residents, and another agent's stored context injection. The subconscious's leaves out only another agent's stored context injection (src/framework.ts:13441–13443,:13535at this pull request's head).Since context-manager 0.23.1 (anima-research/context-manager#314), a render's
nullmeans the next compile won't send the row. At a tool boundary (#720), the rows stored for the agent mid-turn go into the live stream as the next compile would render them, and a row that rendersnullis injected as stored. At this change's base,59d60958(0.28.0), the boundary skipped only a tune-out's rows. A probe with a realAgentFrameworkand the mock provider, in which a module's message arrived while a tool call was held open, showed the mismatch: a row carrying another agent's stored-injection mark renderednullat the boundary, went into the live stream, and wasn't sent by the next compile. It did so on context-manager 0.23.1 and on 0.23.0.The other four checks had drifted the same way. Each is described under Changes below. Under Tests, each has a test that fails against the base's
src/framework.ts.Changes
createAgentand the subconscious each name their predicate once, pass it to the store as its view filter, and record it beside the store (storeSees,:3718).agentSeesasks it for an agent (:18467). An agent whose store was opened with no view filter, such as an ephemeral run, sees every row. The five checks now callagentSees::13921). A row carrying another agent's stored-injection mark is stored but no longer injected into the live turn. At the subconscious's boundary, a row written for it that carries a tune-out's stamp, which its view holds, is now injected, where before it was left out.noteArrival(:18483). A row the agent's view leaves out no longer re-pins its turn's locus to that row's channel.isNewestIn(:26385–26391). A row the agent's view leaves out, after a gate entry, no longer keeps that entry from counting as the newest in the agent's window.placeWakeLinesleaves a wake's line out when it is the wake's only line, not yet written, andisNewestInanswerstruefor its entry (:26340–26341), as 0.27.0's changelog says the line is omitted for "one MCPL message that is still the newest entry in the agent's window".isUnreadStoredMessage(:16203–16217). Its readers are now the agents whose own view holds the message. So another agent's stored injection counts as read only once the agent whose context it is has read it, not when a resident whose view leaves it out has. A tune-out's row is read by the subconscious or, without one, by nobody, as before.save_recent_image(:33647). An image in a row the agent's view leaves out, such as one a tune-out holds, no longer takes an index.A row the render still gives as
nullat a tool boundary is still injected as stored. On context-manager 0.23.0, anulldidn't always mean the next compile leaves the row out: in our probe, a batch bigger than a 300-token recent window renderednullfor both messages, and the next compile sent both.A changelog fragment,
changelog.d/boundary-asks-view-filter.fixed.md, describes the five.#690, also open, changes the rows
isNewestInskips too: it adds system changes. The two pull requests edit the same lines there.Tests
On Linux, with Bun 1.4.4 and Node 24.22.1 unless named.
tscand the build pass atf4cd95bd8d, the first of this pull request's two commits. The second changes one code comment and the changelog fragment. The other runs were on two earlier versions of the first commit, as named. One differs from it only in a doc comment insrc/types/module.ts. The other, used for the real tune-out runs, hassrcthat differs from the first commit's only in comments.test/mid-turn-injection-as-compiled.test.ts; one for each of the other four changes, intest/present-while-acting.test.ts,test/batched-wake-line.test.ts,test/stored-injections.test.tsandtest/save-recent-image.test.ts; and a control, intest/stored-injections.test.ts. The control checks that a row a tune-out holds from the residents stays unread while the subconscious hasn't read it, whatever a resident has read, a rule this change keeps.src/framework.tsand these tests, on context-manager 0.23.1, 282 pass, the control among them, and the six other new tests fail, each on its own assertion:noteArrival: the second reply's locus moved to another channel;isNewestIn: it answeredfalsewhere the test expectstrue;falsewhere the test expectstrue.AgentFrameworkand a subconscious, where an MCPL server's channel was tuned out and the framework's own ingestion diverted its traffic, on the second earlier version and at the base:isNewestContextEntry, which asksisNewestInabout the delivery agent, for an entry followed by a diverted message, answeredfalseat the base andtrueon that version. Followed by an ordinary message as well, it answeredfalseon both.save_recent_imagewithindex: 0saved the PNG at the base and the GIF on that version.tscand the build pass.npm testruns 8,527 tests with none failing or cancelled: 8,465 pass on Node 24.22.1 and on 22.12.0, and 8,463 on Node 26.12.1. The rest are skipped. The unforced files pass 138 of 138 on each.Not verified
HOST_METADATA_KEYS(:385), which a server's metadata is stored without;metadata.tuneOuton a diverted channel message and stores it in the delivery agent's slot (:14695–14700).changelog.d/boundary-asks-view-filter.fixed.md.Hilda-2580 (Claude Opus 5.5) wrote the change, on Ada-1017's (Claude Opus 5.5) design calls. Our internal review read an earlier version. Desmond-2579 (Claude Opus 5.5) read the change and, later, the round that answered that review, without running either. While reading the round, he suggested the doc-comment edit in
src/types/module.tsthat the first commit carries. No review run read that round or the second commit, which Desmond also read without running it. Lottie-2661 (Claude Opus 5.5) wrote this description, from Hilda-2580's facts.🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.