Skip to content

fix(view): five checks of whether an agent sees a row now use its own view filter - #853

Merged
nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:hilda/f486-boundary-asks-view-filter
Oct 27, 2026
Merged

nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:hilda/f486-boundary-asks-view-filter

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

The framework opens the store of each agent createAgent makes, and the subconscious's, with a view filter, which decides the stored rows that agent sees. Five other places in src/framework.ts check 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, :13535 at this pull request's head).

Since context-manager 0.23.1 (anima-research/context-manager#314), a render's null means 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 renders null is 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 real AgentFramework and 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 rendered null at 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

createAgent and the subconscious each name their predicate once, pass it to the store as its view filter, and record it beside the store (storeSees, :3718). agentSees asks 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 call agentSees:

  1. The tool boundary's injection skip (: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.
  2. noteArrival (:18483). A row the agent's view leaves out no longer re-pins its turn's locus to that row's channel.
  3. 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. placeWakeLines leaves a wake's line out when it is the wake's only line, not yet written, and isNewestIn answers true for 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".
  4. 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.
  5. The scan behind 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 null at a tool boundary is still injected as stored. On context-manager 0.23.0, a null didn't always mean the next compile leaves the row out: in our probe, a batch bigger than a 300-token recent window rendered null for 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 isNewestIn skips 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. tsc and the build pass at f4cd95bd8d, 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 in src/types/module.ts. The other, used for the real tune-out runs, has src that differs from the first commit's only in comments.

  • Seven new tests: two for the boundary, in test/mid-turn-injection-as-compiled.test.ts; one for each of the other four changes, in test/present-while-acting.test.ts, test/batched-wake-line.test.ts, test/stored-injections.test.ts and test/save-recent-image.test.ts; and a control, in test/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.
  • Those five files, on the first earlier version: 288 of 288 pass on context-manager 0.23.1, and 288 of 288 on 0.23.0.
  • Red first, on the same version: with the base's src/framework.ts and 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:
    • the boundary: the live stream held the other agent's injection;
    • the subconscious: the stamped row wasn't injected;
    • noteArrival: the second reply's locus moved to another channel;
    • isNewestIn: it answered false where the test expects true;
    • the image: index 0 held the wrong image;
    • unread: it answered false where the test expects true.
  • Through a real tune-out, with a real AgentFramework and 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 asks isNewestIn about the delivery agent, for an entry followed by a diverted message, answered false at the base and true on that version. Followed by an ordinary message as well, it answered false on both.
    • After an older GIF the agent saw and a diverted message carrying a PNG, save_recent_image with index: 0 saved the PNG at the base and the GIF on that version.
  • The full check, on the first earlier version: tsc and the build pass. npm test runs 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

  • What reaches changes 1, 2 and 4 from outside the process wasn't run. Their tests write the rows directly. By the code at this pull request's head:
    • both marks' keys are among HOST_METADATA_KEYS (:385), which a server's metadata is stored without;
    • a tune-out stamps metadata.tuneOut on a diverted channel message and stores it in the delivery agent's slot (:14695–14700).
  • macOS wasn't run.

  • Changelog fragment added: 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.ts that 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-seru account.

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.

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review

Recommendation: merge 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:

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 by agentSees(a, message.metadata), which checks the view filter's metadata predicate, not which store the row lives in. When agent is the subconscious and the row sits in its isolated subconscious/<primary> namespace, every resident in sharedSlotAgents still 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.

Comment thread src/framework.ts
Comment on lines 18479 to 18486
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 });

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, 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.
@nissa-seru
nissa-seru merged commit b55019c into anima-research:main Oct 27, 2026
12 checks passed
@nissa-seru
nissa-seru deleted the hilda/f486-boundary-asks-view-filter branch October 27, 2026 21:37
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 27, 2026
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 27, 2026
… 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.
tel-0s added a commit that referenced this pull request Oct 28, 2026
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>
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