Skip to content

docs(watermark): a tool boundary moves the consumed watermark only when its stream declares it carries injections - #604

Merged
nissa-seru merged 3 commits into
anima-research:mainfrom
nissa-seru:docs/consumed-watermark-declared-boundary
Oct 20, 2026
Merged

nissa-seru merged 3 commits into
anima-research:mainfrom
nissa-seru:docs/consumed-watermark-declared-boundary

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

The consumed watermark's docs say a tool boundary moves it whenever the boundary injects messages. The code moves it there only when the stream declares it carries them, which membrane does from 0.6.0. This brings the watermark's docs, and the texts that rely on what "unread" means, in line with the code. Comments, test comments and README only.

Problem

  • The watermark's doc (src/agent.ts) said it is the newest stored message "at the last compile of a model request, or at the last tool boundary that injected messages into a live one", and that "Everything above it has never been in a request". markContextConsumed's doc listed "a tool boundary that injected messages into the live stream". Neither names a condition.

  • The code: since c55eee9, the boundary moves the watermark to the store's head, over what it injected, only when the stream declared supportsInjectedMessages: true (the === true check after provideToolResults in src/framework.ts). With no declaration it injects and marks nothing, as that commit chose: membrane 0.5.87 declares nothing on either path, and its XML path drops what's injected.

  • Membrane declares the flag from 0.6.0: true on its native yielding path and false on its XML prefill path. The published 0.6.0 package sets it at dist/membrane.js:3794 and :3340, and membrane's tests/unit/round-report.test.ts asserts both. No file in the published 0.5.87 package contains the name.

  • So with membrane 0.5.x, which this package's ^0.5.87 range installs, no tool boundary moves the watermark. A message injected there on the native path sits above it though the stream carried it, so "has never been in a request" is false for that message. It's also false for each round's own content and tool results, which a boundary stores and the stream carries into its next request, whatever the stream declares.

  • The texts that rely on "unread" said the same unconditional things:

    • the README: a later occurrence "appends once a model has seen it", and "unread" is above a watermark "advanced at every compile of a model request";
    • CoalescerHost.isUnread: "no model request has included it";
    • the comments at the boundary, and test/deferred-window-only.test.ts's header: membrane's native and XML paths declare the flag, with no version.

    The failed-reply release's docs (releaseWindow and unreadFailedReplies) say the same. fix(channels): a channel notice reaches whoever can close the channel, and a released agent's unread ones reach the primary #598, which is open, rewrites both, so this change leaves them as they are.

Changes

  • src/agent.ts:
    • The consumed watermark's doc:
      • It names the watermark's three moves: the agent's creation, a committing compile that returns, and a tool boundary that injected into a stream declaring it carries them.
      • A compile counts from when it takes its value, a dry run doesn't move the watermark, and each move takes the store's head.
      • A message above it may already have been in one of the agent's requests, and the doc names the two main ways.
    • markContextConsumed's doc gains the same condition.
    • watermarkMarks' doc leaves the write-back rule to applyCompileWatermark's doc. That doc's example is now a committing compile rather than a preview, which is a dry run.
    • The shown watermark's doc no longer says nothing above it can have been shown. It names round content and results stored at a boundary that injected nothing as one way something above it can have been.
  • README.md: a later occurrence appends once the earlier one has been read. The definition of "unread" adds creation and the declared tool boundary, says a preview doesn't move the watermark, and says a message injected into a stream that doesn't declare stays unread though the model may have seen it. Two paragraphs are rewrapped.
  • src/mcpl/push-coalescer.ts: CoalescerHost.isUnread and refreshOccupant say "as far as the host can tell", and where it can't.
  • src/framework.ts:
    • The comments at the boundary say which membrane path carries injected messages and which drops them, by version.
    • The presentsInjections default's comment says an undeclared round counts as presenting them, which 0.5.x's XML path doesn't.
    • The deferral queue's doc names the declaring stream.
  • test/deferred-window-only.test.ts: the header and two comments give the version that declares the flag.

No code changes, and no changelog fragment, since the change is docs only (CONTRIBUTING, "What needs an entry"). It touches src/, so the changelog check needs the no-changelog label.

What each part is for

  • The watermark's docs in agent.ts are where a reader of Agent learns when the watermark moves and what sits above it. Both were wrong for any agent on membrane 0.5.x whose turns ran tool rounds.
  • The README's definition is the one a host reads to know when a later occurrence replaces an earlier one.
  • CoalescerHost.isUnread's doc is the contract the coalescer is written against.
  • The comments that name membrane's paths each say which path declares what. Without a version, they read as true of membrane 0.5.87, which has no such flag.

Tests

  • CI's steps on the head (33e05c05, Linux, Node 24.22.0): npm install, npx tsc --noEmit and npm run build exit 0. npm test ran 5,611 tests: 5,569 passed, 42 were skipped and none failed. main's CI at d8cc6909 (run 39327669985, ubuntu) ran 5,611: 5,568 passed, 43 were skipped and none failed.
  • No code changed: Bun's transpiler gives byte-identical output for main's and the head's src/agent.ts, src/framework.ts, src/mcpl/push-coalescer.ts and test/deferred-window-only.test.ts, the four TypeScript files the change touches. As a control, changing the boundary's === true to !== false changes that output.
  • What sits above the watermark, probed with test/deferred-window-only.test.ts's harness (a probe, not part of this change). The harness runs a turn of two rounds through the mock membrane, with one message queued for the tool boundary. After the turn, the probe read each stored message as the coalescer's unread test reads it:
    • No declaration: the round's content, its tool result, the injected message and the reply after them are all unread.
    • Declared true: the round's content, its tool result and the injected message are read, and only the reply after them is unread.
    • Declared true, nothing injected: the round's content, its tool result and the reply are unread.

Not verified

  • Membrane 0.6.0's behaviour rests on its published package and its own tests. This package wasn't run against 0.6.0 for this change.
  • That the stream carries each round's content and results into its next request is read in membrane's source: 0.5.87's native loop appends the round, then its tool results, then any injected messages, to the messages of its next request (src/membrane.ts:4177-4201 at v0.5.87). The probe shows they sit above the watermark, not what reached a provider.
  • The two other ways a message above the watermark can have been in a request are by reading:
    • a compile that retries after taking its value (the boot restore's OverBudgetError retry in compileAtLiveBudgetNoting);
    • membrane 0.5.87's XML resumption, which sends the turn's accumulated output as prefill (src/membrane.ts:3870-3876 at v0.5.87).

🤖 Written by Meg-2646 (Claude Opus 5.5), with our internal review, using the Rookery's tooling. Petra-1012 (Claude Opus 5.5) found the mismatch while reading another change, and Ada-1017 (Claude Opus 5.5) set its scope. Duncan-2410 (Claude Opus 5.5), who wrote the boundary's check, reviewed it.

Description checked and posted by Fiona-2757 (Claude Opus 5.5).

Meg-2646 and others added 3 commits October 19, 2026 18:20
…en its stream declares it carries injections

The consumed watermark's doc said it moves at "the last tool boundary that
injected messages into a live one", and markContextConsumed's doc listed "a
tool boundary that injected messages into the live stream", each with no
condition. Since c55eee9 the boundary marks only where the stream declares
supportsInjectedMessages true, which membrane's native path does from
membrane 0.6.0. Membrane 0.5.x declares nothing, so with it no tool
boundary moves the watermark, as that commit chose.

The doc also said everything above the watermark "has never been in a
request". A live stream stores each round's own content and results at its
tool boundaries and carries them into its next request, and on a stream
that doesn't declare the flag, the messages injected at a boundary may have
been carried too, so both can sit above it. The doc now names the three
moves (the agent's creation, a compile, and a tool boundary that injected
into a stream declaring it carries them) and those two exceptions.

The README's coalescing section defined "unread" as above a watermark
"advanced at every compile of a model request"; it adds the declared tool
boundary. Three comments in framework.ts that name membrane's native and
XML prefill paths as declaring the flag now say "from membrane 0.6.0".

Comments and README only: no code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eam doesn't declare

The first commit rewrote the consumed watermark's doc. The texts that
depend on what "unread" means said the same unconditional things, so
they change with it. Comments, test comments and README only: no code
changes.

- src/agent.ts: the consumed watermark's doc says a compile counts from
  when it takes its value (so one that began before a boundary's mark
  doesn't roll it back), that a dry run such as previewActivation's
  doesn't move it, and that each move takes the store's head, so it also
  covers messages the agent wasn't given. It no longer claims to list
  every way a message above it can have been in a request: a compile that
  retries after taking its value, and an XML resumption that sends a
  turn's output as prefill, are two more. watermarkMarks' doc no longer
  states the write-back rule, which applyCompileWatermark's gives; that
  doc names a committing compile, not a preview (a dry run), as the case
  its rule guards. The shown watermark's "nothing above it can have been
  shown" allows for the round content and results a boundary that
  injects nothing stores.
- README.md: a later occurrence appends once the earlier one "has been
  read", not once "a model has seen it". The definition of unread adds
  the watermark set at the agent's creation, says a preview doesn't move
  it, and says a message injected into a stream that doesn't say whether
  it carries injections stays unread though the model may have seen it.
- src/mcpl/push-coalescer.ts: CoalescerHost.isUnread is true when, as far
  as the host can tell, no request has included the placement, and says
  where the host can't tell. refreshOccupant's doc says the same.
- src/framework.ts: the comments at the boundary say which membrane path
  carries injected messages and which drops them, by version. The
  presentsInjections default's comment says an undeclared round counts as
  presenting them, which 0.5.x's XML path doesn't. releaseWindow's and
  unreadFailedReplies' docs say a notice injected into a stream that
  doesn't declare is passed on (it may have been dropped), that the
  watermark is set at creation, and that "every one" applies to an agent
  with no watermark rather than one that never compiled. The deferral
  queue's doc names the declaring stream.
- test/deferred-window-only.test.ts: the header and two comments give
  the membrane version that declares the flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on; leave the failed-reply docs to anima-research#598

- src/agent.ts: the shown watermark's doc no longer says nothing above it
  can have been shown "apart from" one case. As with the consumed
  watermark, a compile that retries after taking its value and an XML
  resumption reach it too, so it names the round's own content and
  results as one way, not the only one. The consumed watermark's doc says
  "a committing compile that returns", since one that throws doesn't move
  it.
- src/framework.ts: releaseWindow's and unreadFailedReplies' docs are back
  to main's text. anima-research#598 rewrites both, and its text already says what the
  previous commit changed them to say.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru nissa-seru added the no-changelog Exempts this PR from the changelog check (internal-only change) label Oct 19, 2026
@nissa-seru
nissa-seru merged commit f8c23e1 into anima-research:main Oct 20, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-changelog Exempts this PR from the changelog check (internal-only change)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant