Repository navigation
fix(wakes): a write the agent heard mid-turn starts no turn of its own once it's done - #723
Conversation
There was a problem hiding this comment.
Automated review
Recommendation: merge and fast-follow. 3 medium severity findings can be fixed after the merge.
Reviewed commit 7574c10 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 (#264) belongs here. It extends the deferred-write wake drop that #572 added, so a message the agent already heard mid-turn no longer starts an extra turn.
5 reasons with sources
The work sits in the framework's own scheduler, agent and gate code, with tests and a changelog fragment.
- It fixes a reported bug in behaviour the project already has: event processing and the inference trigger. The README describes the pipeline from events to the inference trigger to the tool loop. The PR stops a wake that was queued behind a busy agent from starting a turn after the live turn has already heard and answered the message. It closes #264. (README.md (Overview))
- It builds on the existing drop instead of adding a second way to do it. #572's
deferredWriteReadOrGonebecomesdeferredWriteDone, and the existingreadsDeferredWritemark now covers more kinds of writes. The new watermarks live onAgent, next to the consumed and shown watermarks it already keeps. (src/framework.ts, src/agent.ts) - It adds an optional parameter to the gate's two hooks but no new dependency or service.
addMessagegets an optional placement andrequestInferencean optionalafter. Both are optional, so existing callers keep working. The code review notes separately that the new type isn't exported from the package index. (src/gate/event-gate.ts) - The PR follows the contributing guide: a changelog fragment, tests for the new behaviour, and a Problem/Changes body. It adds
changelog.d/heard-mid-turn-wake.fixed.mdandtest/heard-mid-turn-wake.test.ts, and updates the existing coalescing, prose-drafts and present-while-acting tests. (CONTRIBUTING.md (What a PR should contain; Changelog)) - It is one coherent change, though wide. Every site it touches feeds the same wake-drop rule. The edits to send tools, sleep,
channel_publishand draft resends all exist to record publishes the drop rule reads. None of them carries unrelated work. The code review reports that render wakes may now get #572's empty turn back. That is a defect for the author to fix, not a scope question. (CONTRIBUTING.md (How changes land))
Findings
3 findings of medium or high severity, see the inline comments:
- Medium severity. A refused completion counts as answering the write, so a heard write's wake is dropped although the model never answered it.
- Medium severity. A coalesced render's wake now needs a publish in its channel, which brings back the empty turn that #572 removed.
- Medium severity. A watched script's publish takes the read watermark from when the call is dispatched, not from when the script's words were written.
4 more findings, less severe
- Low severity, test coverage,
src/framework.ts:14174-14195. No test covers a push event that carries a channel and was heard mid-turn.handleMcplPushEventnow marks its wake, and that wake carrieschannelId: triggerChannel?.channelIdwithsource: event.serverId. Through those two fields the scheduler builds the destination it checks withpublishedAfterReading. In heard-mid-turn-wake.test.ts, the only push case is 'a push with no channel', so the channel branch is never exercised end to end on the push path. Nothing checks that a derived trigger channel matches the key that prose, sends or channel_publish record. Add a case where a push with a channel is heard and then answered in that channel (the wake is dropped), and one where it is not answered (the wake is kept). - Low severity, maintainability,
src/gate/event-gate.ts:576-579. New public hook type WritePlacement is not exported from the package index. The public EventGate'saddMessageandrequestInferencehooks now take aWritePlacementargument.src/index.tsexportsEventGateandWakeProvenance(line 70) but notWritePlacement. A host that builds its own EventGate, such as connectome-host, cannot name the type without importing an internal path. The fragment also doesn't mention the new hook parameters, so such hosts won't learn that they must fillplacementand passafterto get the new wake-dropping behaviour. - Low severity, test coverage,
src/framework.ts:27414-27417. No test drops a wake after a send-tool answer. The send-tool route (sender?.notePublished(destinationKey({ serverId, channelId: channelId ?? target }), readThrough)) is tested only in present-while-acting.test.ts, against a stub agent that records the call. No test sends a channel message mid-turn, answers it withsend_messageorreply_message, and checks that the scheduler drops the wake. The key matters here: the wake carries the server's channel id, while the send records the id the open resolves (or the rawtargetwhen the open returns none). A mismatch would silently keep every such wake, and these tests would still pass. - Low severity, test coverage,
test/heard-mid-turn-wake.test.ts:631. Hybrid>>addressing is claimed in the PR but never tested. The PR description and changelog say prose addressed with>>(as well as>>>and{{unsent}}) counts as publishing into the channel. Theanswerstable covers only plain prose and>>>#room. No case covers a>>address or{{unsent}}, so these routes could fail to reach theonPublishedsink without any test failing.
| case 'complete': { | ||
| streamTerminal = true; | ||
| adoptInjectedRound(); | ||
| // The final request was answered, and the prose routed below was | ||
| // written by it when rounds route live; on the fallback path it's | ||
| // the whole turn's, and that stream's boundaries move no consumed | ||
| // watermark (it doesn't declare that it carries injected messages). | ||
| const finalRead = agent.getConsumedWatermark(); | ||
| agent.markAnswered(finalRead); | ||
| const endPublished = publishedFrom(finalRead); |
There was a problem hiding this comment.
Medium severity. A refused completion counts as answering the write, so a heard write's wake is dropped although the model never answered it.
In the 'complete' handler, agent.markAnswered(finalRead) runs as soon as the case starts, before response.stopReason === 'refusal' is checked. Take a terminal refusal with no autoRewind or forced rewind configured (the branch near line 18956 that only logs and adds a reaction). The turn ends without speaking, but the answered watermark already covers every write injected at the last boundary. For a wake with no channel (a module's one message, a push with no channel, a script wake, a self-wake notice), deferredWriteDone then returns true and the scheduler drops the wake. The model never produced an answer to that write and no other turn is queued for it. On main, that wake started a turn. A refusal should not count as an answered request, the same way a failed request doesn't, so the mark belongs after the refusal and guard handling.
There was a problem hiding this comment.
This one is kept as intended. A refusal is the provider's answer to the request that carried the write, and the write is already in the context that request compiled, so a turn started for its wake would have nothing new to read. That's the turn #264 is about. A channel's wake isn't dropped on a refusal alone. It also waits for the agent to publish in that channel after reading the message, so a message someone wrote in a channel keeps its turn. df9afd28 adds two tests in heard-mid-turn-wake that pin both halves: a push with no channel, heard at the boundary of a request that ends in a refusal, starts no turn of its own, and a channel message heard there keeps its wake.
— Crispin-2713
| // No row yet for a write drained from the queue and still to be stored | ||
| // (un-acked); one stored and awaiting a sync is judged by its row. | ||
| if (!row) return !this.unackedDeferredWrites.some((m) => m.id === writeId); | ||
| const watermark = agent.getConsumedWatermark(); | ||
| return !!watermark && watermark.branch === cm.currentBranch().name && row.sequence <= watermark.sequence; | ||
| const watermark = agent.getAnsweredWatermark(); | ||
| const branch = cm.currentBranch().name; | ||
| if (!watermark || watermark.branch !== branch || row.sequence > watermark.sequence) return false; | ||
| return to === undefined || agent.publishedAfterReading(destinationKey(to), branch, row.sequence); |
There was a problem hiding this comment.
Medium severity. A coalesced render's wake now needs a publish in its channel, which brings back the empty turn that #572 removed.
deferredWriteDone now requires publishedAfterReading whenever the wake carries a channel. queueCoalescedWakes (line 13925) gives a render's wakeToRead wake a channel for every channel-scoped batch. Before this change, that wake was dropped once the consumed watermark covered the render. Now a render the agent has read but has no reason to reply to (an edit to an ambient message, say) still starts another turn with nothing new to read. That is the redundant turn #572 fixed, and its 0.24.0 changelog entry promised the fix. The PR body says this is deliberate, but it is a user-visible regression for the render case, which never had an 'answer' requirement. Consider keeping the read-only rule for render wakes, or for unaddressed wakes.
There was a problem hiding this comment.
Fixed in df9afd28, by the message's own mark rather than by exempting renders. A wake whose message its server tagged chat:ambient (MCPL RFC-001) is dropped once a request that carried it was answered, with no publish asked of the agent. A render's wake counts as ambient only when every notice the render folds was tagged so, since a deferred notice keeps no tags of its own; the coalescer records it per batch and in its snapshot. A render with an addressed notice, or one with neither tag, still waits for the agent to speak in its channel, because hearing a message isn't answering it. "Not addressed" alone wouldn't do, because isAddressedMessage reads only the chat:addressed tag, so every message from a server that sends no tags, a direct message included, has addressed: false. mcpl-coalescing tests the render wake with ambient and untagged batches, and the batch's mark with ambient-only, mixed and untagged batches and through a snapshot, one taken mid-render included. The new fragment says an all-ambient render is dropped once read, as 0.24.0 says, and that any other render that carries a channel waits for a publish there.
— Crispin-2713
| // ordinary wake, starts only once its over-budget boundary has every | ||
| // result of the round. | ||
| const callingTurn = this.routingOf(agentName); | ||
|
|
||
| this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName }) | ||
| // A publish is noted on the agent with what it had read when it made the | ||
| // call (Agent.notePublished), once its outcome says delivered. A call | ||
| // made in no turn, a background script's, isn't: its words were written | ||
| // with the script, before what the agent may have read since. | ||
| const publisher = call.name === 'channel_publish' && call.callerTurnId !== undefined ? this.agents.get(agentName) : undefined; | ||
| const readThrough = publisher?.getConsumedWatermark() ?? null; |
There was a problem hiding this comment.
Medium severity. A watched script's publish takes the read watermark from when the call is dispatched, not from when the script's words were written.
The publisher rule (call.callerTurnId !== undefined, readThrough = getConsumedWatermark() at dispatch) is meant to exclude words that were written before the agent read something. Background scripts are excluded for exactly that reason. But dispatchToolCall gives a script call the turn id of whichever code_execution call is watching the script when the call reaches the host (comment at 21687-21692). Consider a script launched in round N that keeps running and is later watched by a wait call in round N+2. Its channel_publish, send or drafts resend gets that turn id and the consumed watermark of round N+2, which includes messages injected after round N. The script's text was written in round N, before those messages were read. A wake for one of them in that channel is then dropped as answered when it was not. The same applies to the send-tool path (line 27236) and the drafts path (line 21859).
There was a problem hiding this comment.
Fixed in df9afd28. code_execution's run now takes the agent's consumed watermark as it starts the script and passes it in the script's context (startReadThrough). #710's record of each script call's start, now scriptCallStarts, holds it while the call is dispatched, beside the start turn, and a script's channel_publish, send, draft resend and sleep announcement are credited with it (publishReadThrough), whichever later round's wait watches the script. A call from a script that an earlier turn started isn't the current turn's call (#710's callingTurnOf), so it isn't noted at all. In heard-mid-turn-wake, a script's channel_publish, send and sleep announcement, made through the bridge with a start reading below the agent's current one, are each credited with the start reading; prose-drafts does the same for a resend; and in turn-id, a real script's second call still carries the start reading after the agent has read more.
— Crispin-2713
An edit's push carried only chat:edited (and chat:thread in a thread), so a host couldn't tell an edit to a message addressed to the bot from an edit to chatter. A host's wake rules route on the address tags, and anima-research/agent-framework#723 drops a render the agent read mid-turn only when every notice in it is chat:ambient. - The adapter reads an edit's address facts from the edited message itself: the users it mentions, a mention of the bot's managed role, the author it replies to, and whether a bot wrote it. discord.js builds that message from MESSAGE_UPDATE's payload, which Discord sends as a whole message, so this holds for a message the client never cached. One method gives a create's facts and an edit's. A partial message has none to read, so its edit stays untagged and the host keeps its turn for it. - The server derives the flags and tags by one rule for creates and edits (addressFlags, addressTags). An edit's push carries chat:edited, the address tags, then chat:thread. - CHANGELOG (Changed) and the README's tag line. - The DM-allowlist tests' cached messages carry mentions, as discord.js's whole messages do; the adapter now reads them on an edit. Left out: admitting an addressed edit in a channel that isn't open or subscribed. That changes what reaches the agent, not how it's tagged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7574c10 to
df9afd2
Compare
|
Replying to this review. The four lower-severity findings are addressed in
— Crispin-2713 |
07f7e03 to
93675ae
Compare
…n once it's done A message that arrives while an agent's turn is alive is deferred, and its wake waits behind the busy agent. At the turn's next tool boundary the message is stored and injected into the live stream, so the turn reads it (hear-while-acting). Nothing linked the wake to that: once the turn ended, the wake started a second turn that read nothing new (anima-research#264). A wake queued for a write deferred for its agent now carries the write's deferred id (readsDeferredWrite), as a render's wake already did: a channel message (handleMcplChannelIncoming), a push event (handleMcplPushEvent), a module response's one message (applyProcessResponse), a background script's wake, and the gate's self-wake notice, whose write and wake the gate's hooks now link (the write's placement comes out of addMessage and goes into requestInference; its type, WritePlacement, is exported from the package index). A channel message's, a push's and a render's wake also record whether the message's server tagged it chat:ambient (InferenceRequest.ambient). A render's wake is ambient only when every notice the render stands for was, a plain occurrence a notice displaced unread included: the coalescer keeps that per batch (allAmbient), in its snapshot too, and an unread plain occupant's mark in memory; a batch restored without it reads as not all ambient. A coalesced push's own batch wake carries no mark. These keep a plain wake: console and API input; a module response with several messages, or whose message's metadata or event's names a channel, which its module answers through its own speech; a tune-out cancellation; and a prose bounce notice, whose answer is a resend of its held draft. The gate's debounced summary keeps a plain wake: the gate writes it only once it counts no agent inferring. When the scheduler comes to start such a wake, it drops it once a request that carried the write was answered (its row is at or below the agent's answered watermark on the current branch) and, for a wake that carries a channel and isn't marked ambient, the agent has since published into that channel, on the wake's server, having read it; or once the write is in neither the queue nor the store's current branch. The answered watermark (Agent.markAnswered) is the consumed watermark a request carried, taken when its round of tool calls or its completion came back. A boundary moves the consumed watermark when it hands the stream an injection, before the request runs, so a write whose request fails, ending the stream with an error, keeps its wake. A completion the provider refused counts as an answer. The consumed watermark, which the coalescer reads, is left as it was. A render's wake counts a read on the same terms. Hearing a message isn't answering it: a wake whose channel the agent hasn't spoken in since reading still starts its turn. What the agent had read when it published is kept per destination (a server's channel, by destinationKey) on the Agent (notePublished, publishedAfterReading): the newest consumed watermark, on the branch it was taken on. These routes note it, with the watermark from when the words were written or the call made: - plain prose, with a `>>` or `>>>` address too: each round's publish sink is made as the round yields, since its deliveries run on the speech chain, which a tool boundary doesn't wait for; the prose routed at the stream's end takes the watermark at `complete` when rounds route live, and on the fallback path, where that prose is the whole turn's, what the turn's first request had read; - the sleep tool's announcement, in the locus or at an explicit pin, taken at the call and noted once routeSpeech returns a delivered result; - channel_publish, taken at dispatch and noted when its outcome is delivered (handleChannelToolCall now reports the outcome to a caller that asks); - a draft resend that delivers, taken at the drafts call; - a send tool whose channel the framework records (SEND_ENGAGEMENT_TOOLS), taken at dispatch and noted under the channel its open resolves; an open that finds no such channel, or an ambiguous one, notes nothing. A call made outside the agent's current turn (anima-research#710's callingTurnOf), such as a background script's, isn't noted: its words were written with the script, before what the agent read since. Nor is a call through executeToolCall, the admin puppet's and ephemeral agents'. A script's call in the turn that started it is credited with what the agent had read when code_execution's run started the script (startReadThrough, carried in anima-research#710's record of a script call's start, now scriptCallStarts with turnId and readThrough; publishReadThrough), not with what a later round's wait has read since. The record lives as long as the Agent object: an agent made again starts it empty, and a wake that carries a channel is then kept. Tests: - heard-mid-turn-wake (new), each checking the scheduler's drop line: the issue's case (a channel message heard and answered in its channel starts no turn), with the answer in the final reply, a later round's words, either addressed with `>>>`, a final reply addressed with `>>` in explicit mode, a server's send_message, or a sleep's announcement; an ambient channel message drops once heard, one with neither tag keeps its wake; a push with a channel answered there drops, an untagged one not answered keeps its wake, an ambient one drops; a refused completion drops a push with no channel and keeps a channel message's wake; on the fallback path a message heard at the first of two boundaries keeps its wake though the turn spoke in its channel at its end, and that prose is noted with what the first request had read; a script's channel_publish, send and sleep announcement are credited with its start reading; a text-only reply is noted with what its request read; a sleep's announcement is noted in the locus or at an explicit pin with what the agent had read at the call, and not when it isn't delivered, whether the registry returns nothing or reports it undelivered; a write handed to a stream whose request fails, or stored at an endTurn boundary, keeps its wake; a channel_publish made outside the agent's turn isn't noted; a message heard when the turn spoke only elsewhere after, or spoke in its channel only before hearing it (with that round's words landing after the boundary), keeps its wake; a delivered channel_publish answers, a refused one doesn't; a push with no channel, a module's one message, a script wake and the gate's self-wake drop once heard; console input, API input, a module's several messages, a module message naming a channel, an external message whose event names one, and a prose bounce notice keep their wakes. - mcpl-coalescing: a render wake that carries a channel starts its turn until the agent has published there having read it, on the same branch and server, the newer publish standing over a later-landing older one and replaced by one on another branch, unless the render is ambient; a render's wake, through the framework's own coalescer host, is ambient only when the coalescer says every notice it stands for was, and a batch is all ambient only while every notice folded in, past maxNotices too, and a plain occurrence it displaced unread, was tagged chat:ambient, through a snapshot, a mid-render one included, and not from a snapshot written without it; anima-research#572's test of a render's row against the watermark reads the answered watermark, set first below the render. - present-while-acting: a send given a raw id is noted under the canonical channel its open resolves, with what the agent had read at dispatch, however late the open resolves; a refused one, one made outside the turn, or one whose open finds no such channel or an ambiguous one, isn't; a channel_publish is noted with the watermark at dispatch, however late its outcome comes back. - prose-drafts: a resend in a turn is noted as a publish into its channel, one made outside the turn isn't, a refused or unconfirmed one notes nothing, and a script's is credited with its start reading. - publish-outcome: channel_publish reports its outcome, and where a delivered post went, to a caller that asks. - turn-id: a script's calls carry what the agent had read when the run call started the script, after the agent has read more. - code-execution-isolate-scripts: a script's calls carry the reading their run was given, under a later turn's wait too. - deferred-window-only: the test that a held write whose landing is unknown is settled before it's judged asks deferredWriteDone, the drop's new name, after marking a request answered. Changelog: a new fragment, heard-mid-turn-wake.fixed.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
93675ae to
debce5a
Compare
anima-research#726 adds tests to test/deferred-window-only.test.ts just above the test this change renames, and one of them calls deferredWriteReadOrGone, which this change replaces with deferredWriteDone. The resolution keeps anima-research#726's tests and this change's title, and that call becomes deferredWriteDone(agent, 'w-said', undefined).
main gained anima-research#726 (F427, deferred writes found later), anima-research#723 (a write heard mid-turn starts no turn of its own) and more since this branch's merge of 5143b30. Two conflicts, both in the tool boundary's comments in src/framework.ts: - The flush's account of what it injects: this change's "One the render gives null goes in as stored", in place of main's left-out notice, with anima-research#726's account of a write that threw after it was stored: looked up by its id and carried where it landed, and the known limit, one neither read back nor looked up, carried at the boundary that finds it. - After the consumed mark: main's paragraph from anima-research#723 on a message the boundary stored and left out of the injection, which the notice told the agent of. This change leaves no message out of the injection (a null goes in as stored, and a tune-out message is skipped before the render, as before), so the paragraph has nothing to describe and goes. The null branch's comment still names a write found at an earlier flush as the exception, which is anima-research#726's known limit. No code conflicted.
…qualified channels meet the registry, tune-out and the reply anchor What main brings since e4d1e46, the sixth catch-up's base: 0.25.0 and 0.26.0, and anima-research#709, anima-research#710, anima-research#719, anima-research#722, anima-research#724, anima-research#728, anima-research#727, anima-research#743, anima-research#744, anima-research#755, anima-research#759, anima-research#761, anima-research#763, anima-research#767, anima-research#707, anima-research#740, anima-research#760, anima-research#726, anima-research#371, anima-research#452, anima-research#768, anima-research#465, anima-research#770, anima-research#771, anima-research#723, anima-research#777, anima-research#776, anima-research#773, anima-research#775, anima-research#778, anima-research#774, anima-research#779 and anima-research#781. Most of the conflicts are where anima-research#710 met the branch: in main a channel the framework acts on is a ChannelRef, { serverId, channelId }, since two MCPL servers can register one id. Conflicts in 14 files (61 hunks), resolved by keeping both sides' intent; where the two had different designs for one thing, the branch's stays, moved onto main's types: - src/framework.ts: main's conversationAgentHomes map stays gone. A fork's home is the registry record's homeChannel, which homeOf reads wherever main read the map (isForkBound, mayTakeGateRoute, canCloseChannels, holdsChannelTraffic, agentForPrimary, the drafts resend, the channel tools' guard, the registry's homeChannelResolver, stop()'s unbinding, releaseAgentRuntime's). The record still keeps a bare id, read as main reads a wake that names its channel without its server (channelNamed), or, where it names no one registered channel, as a home on no server ({ serverId: '' }), which a path reading the pair finds unregistered and one reading the name finds no channel or two for. main's dropContinuations goes into releaseAgentRuntime, so every way a live non-ephemeral agent ends drops a continuation of its turn, and beside the registry's end of an ephemeral run. Arrival carries main's channel ref and the branch's addressed; the addressed-elsewhere notice names channels with main's channelShown and keys its once-per-channel set by destinationKey. isAddressedMessage stays tag-only, with main's ambient marks. publishInputWithTurnLocus gives the locus with its server, and only to a call made inside the turn (callingTurnOf); outside it, a conversation fork's call takes its home, as its dispatched calls do, and anyone else's the registry's default. The tune_out handler keeps the branch's shape (serverId, scope, check, peek) and resolves through ChannelRegistry.resolveChannel, now resolveToolChannelEntry with the calling turn's own channels, check's peek included; its answers name the channel with nameShown. holdForProviderWait holds nothing for an agent a listener of the failure it reports ended (stopAgent), where main's report-before-hold (anima-research#774) meets the branch's stopAgent at a conversation agent's hold after its compile. handleSubconsciousTool gets main's own channels, then the branch's caller. - src/tune-out/coordinator.ts: main's shown() names beside the branch's reader helpers; deliver_summary, cancel_tuneout and speak_in_channel resolve as main's do, for the subconscious and a reader fork alike; cancel_tuneout keeps the branch's serverId and all-except fallback, and a name that picks no channel (one two servers share, or a turn's own channel ownChannelFor refuses) doesn't keep a call naming the scope from ending it; findByChannel goes, as on main. focusChannel() returns a ref. - src/mcpl/channel-registry.ts: markAccepted records no anchor (admission does), and handleIncoming's arrival count of removals, which main's guard there read, goes with it; noteDelivered takes the channel with its server and sets nothing for a channel its server no longer registers, read at admission where main's guard compared removal counts at arrival and acceptance (a message admitted after its server registers the channel again sets the anchor here). buildChannelContext is main's, per server, with the branch's sharedFallback; incomingFor reads the anchor itself, not the speech fallback. globalFallbackFor, sharedFallback, fallbackLocus and focusChannelResolver are on refs. - src/index.ts: main's DefaultErrorPolicy beside the branch's exports. - Tests: the branch's registry stubs take main's shapes; main's appendInTwoParts in batched-wake-locus's rig. Beyond the conflicts: the all-except announcement goes to the excepted channel on its server (publishForAgent refuses a shared bare id since anima-research#710), and tune_out's all-except answer names its channel with nameShown; the tune_out schema's serverId description says channelId takes serverId:channelId too; main's refusals for a tuned-out channel no name picks alone give the tune_out cancel by id and server, since this branch's tune_out takes a serverId; a stale comment naming conversationAgentHomes in channel-registry.ts names the registry; docs/dendrite.md says where a publish naming no channel goes outside the turn, and no longer lists ChannelRef as unbuilt. Test adaptations: main's tests that set conversationAgentHomes give the fork its home through the registry. main's tests written to the latest-inbound rule run under it (reply-server-routes: speech LEGACY; shared-channel-id's registry fixture: latestInboundLocus, and noteDelivered as admission records). Two reply-server-routes tests of an in-turn publish naming no channel now check the branch's rule, the turn's locus through its server. tune_out's shared-channel-id tests wait for the handler's answer, and its shared-id refusal offers serverId. all-except- unit's stub registry has nameShown; present-while-acting and deferred-window-only build routing state in main's shapes. main's marker and removed-server anchor tests record the anchor at admission (noteDelivered), where this branch records it. At this commit alone, the tests where a fork's home has to name its server fail (two servers' channels with one id, or a home the registry doesn't register at that moment); the pull request lists them. The next commit stores the home with its server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes #264
Problem
While an agent's turn is alive, a message that arrives for it is deferred, and its wake is queued behind the busy agent. At the turn's next tool boundary the message is stored and injected into the live stream, so the turn's next round reads it (hear-while-acting). Nothing linked the queued wake to that. Once the turn ended, the wake started another turn, which read nothing new. With the issue's reproduction on
mainat12b478ca, 5 runs of 5 sent a third provider request, and all it added was the turn's own reply and its[delivered]receipt.Other writes queue a wake beside them in the same way: a push event, a module's message, a background script's wake and the gate's self-wake notice. A coalesced render's wake already starts no turn once the agent has read the render.
Changes
readsDeferredWrite, as a render's wake already did). It carries it only when the write was deferred for the agent that wake is for. That covers five sites:handleMcplChannelIncoming);handleMcplPushEvent);applyProcessResponse), unless it's console or API input, or its metadata or its event's names a channel;addMessagefills an optional placement, andrequestInferencetakes it asafter. Their type,WritePlacement, is exported from the package index.ambient: trueforchat:ambient, besideaddressed). A render stands for a batch of notices, and for a plain occurrence a notice displaced unread, so the coalescer keeps whether every one of them was marked ambient (allAmbienton the batch, in its snapshot too; an unread plain occurrence's mark in memory), and the render's wake is ambient only then. A batch restored without it, from an older snapshot or an acceptance receipt, reads as not all ambient. A coalesced push's own batch wake carries no mark, since only a wake to read a render is judged by what was read.main;deferredWriteDone. The write is done in two cases:source), having read the row. Hearing a message isn't answering it. An ambient message asked nothing of the agent, so reading it was all its wake was for. A message with neitherchat:addressednorchat:ambientis in doubt, and is judged as an addressed one. A write the boundary stored but left out of the live round, with a notice telling the agent it came, counts as read too, as RFC-006's consumed watermark already counts it.Agent.markAnswered,getAnsweredWatermark) is the consumed watermark a request carried, taken when that request's round of tool calls, or its completion, comes back. A boundary moves the consumed watermark when it hands the stream what it injected, before the next request runs. So a write that the first request to carry it never answered, because that request failed and the stream ended with an error, isn't heard, and its wake stays. A write an earlier answered request carried is heard, whatever a later request does. A completion the provider refused is an answer too: a turn started after it would read nothing new, and a channel's wake still waits for the agent to speak there. This holds for a render's wake too.Agent.notePublished,publishedAfterReading). A destination is a server's channel, keyed bydestinationKey, so two servers' channels with the same id stay apart. The record is kept on the branch its watermark was taken on, for as long as theAgentobject lives. These routes note it:>>or>>>. By our reading, every publish a stream makes reaches its outgoing sink. Each round's sink takes the watermark as the round yields, because the round's deliveries run on the agent's speech chain, which an ordinary tool boundary doesn't wait for. The prose a stream routes at its end takes the watermark atcompletewhen its rounds route live. On the fallback path, where no round does, that prose is the whole turn's, and it takes what the turn's first request had read.routeSpeechreturns a delivered result. The announcement isn't awaited, so a wake judged before it lands stays.channel_publish, whose watermark is taken at dispatch and noted when its outcome is delivered.handleChannelToolCallreports that outcome to a caller that asks for it.draftscall.SEND_ENGAGEMENT_TOOLS:send_message,reply_message,send_dm,send_file,send_files). The watermark is taken at dispatch. It's noted on success, under the channel the open-for-send resolves. An unknown or ambiguous channel records nothing.publishReadThrough).code_execution's run takes the consumed watermark into the script's context, and fix(mcpl): tell two servers' channels with one id apart #710's record of a script call's start (scriptCallStarts) carries it beside the start turn while the call is dispatched. A later round'swaitthat watches the script shares the turn's id, but not what the agent has read since the script was written.callingTurnOf), such as a background script's, or a call from a script an earlier turn started. Its words were written with the script, before whatever the agent has read since. A sleep, achannel_publish, a send or a resend counts only when its turn makes it.executeToolCall, which serves the admin puppet and ephemeral agents. None of these is the resident publishing in its own turn.What agents see
main, unless its server marked it ambient.mainit starts no turn once read. A render whose every notice was marked ambient starts none once read, as onmain. discord-mcpl tags a message edit onlychat:edited, so where a gate wakes on edits, an edit read mid-turn now starts a turn unless the agent speaks in its channel (see Out of scope).What each part is for
chat:ambientmarks it.addressedis false for every message from a server that sends no tags, so readingaddressed: falseas ambient would drop the wake of a direct message that server never tagged.allAmbient. A render covers every notice in its batch, but a deferred notice keeps no tags, so the latest notice's tag would mark a batch ambient though an earlier notice addressed the agent. A notice that displaces an unread plain occurrence puts the render in that occurrence's place, so its mark counts too.TurnRouting.deliveries,engaged) hold channels only, and they belong to the turn. By our reading, the receipt emptiesdeliveriesat the turn's end, before the wake is judged. They also hold no order between reading and publishing.notePublishedkeeps the newest watermark per destination, on its branch, andpublishedAfterReadingreads it only on the same branch. A publish written earlier that lands later can't lower the record, and a record from another branch says nothing about this one.complete. The prose a stream routes at its end was written by its final request, so what that request carried is what those words can answer. The same moment marks that request answered. On the fallback path that prose is the whole turn's, so it takes the first request's reading instead: a boundary may have moved the watermark between words written early in the turn and its end.channel_publish, a send and a resend. Each lands after the call, and a send's channel is recorded only once its open resolves, which can come after the next boundary, or after a later turn has started. A resend's words are the held ones, but the agent chooses at the call to send them there, so the call's watermark is what they answer.channel_publish's outcome holder. The registry resolves a defaulted channel inside the call, and the holder says where the publish went and whether it was delivered.WritePlacementexported from the package index. A host that builds its ownEventGatecan name the type of the hooks' new argument.main.heard-mid-turn-wake). A module tool holds its round open until a write made during it is deferred, so the next boundary injects it, asdeferred-window-only'sInterjectingModulealso does. This suite adds a stub registry that records where speech andchannel_publishland, and a set of responses for each scheduled turn, on the native path. It also reads the scheduler's drop line, so that a turn that didn't start is shown to be a marked wake dropped, not a wake never queued.RoundThenFails, a stream that takes the boundary's injection and then ends with an error. It carries the case of a request that failed, which no other stream in the suite can show.+154 −52 lines of
src, without comments and blank lines.Tests
heard-mid-turn-wake(new), on MockMembrane's native path, where a boundary's injected messages are carried and marked read. Each test that expects no second turn also checks the scheduler's drop line, and each that expects one checks there was no drop.>>>;>>, in explicit mode;send_messagethere;>>>, is noted as a publish into its channel, made having read its request.channel_publish, send and sleep announcement, made through the bridge, are credited with what the agent had read when the script's run call started it, not with what it has read since.channel_publishmade outside the agent's turn, as a background script's is, isn't noted, though it was delivered and the agent was in a turn, and one made in the turn is.channel_publishinto its channel answers it, and a refused one doesn't.mcpl-coalescing:chat:ambient: two ambient notices are, and an addressed or untagged notice before or after an ambient one makes the batch not all ambient, pastmaxNoticestoo. A notice that displaces an unread plain occurrence makes a batch all ambient only if that occurrence was. All ambient and mixed batches keep their mark through a snapshot, one taken while the batch renders included, and a snapshot written without it reads as not all ambient.present-while-acting:channel_publishis noted with what the agent had read at dispatch, however late its outcome comes back.prose-drafts: a resend in a turn is noted as a publish into its channel, and one made outside the turn isn't. A refused or unconfirmed resend notes nothing. A script's resend is noted with what the agent had read when its run call started the script.publish-outcome:channel_publishreports its outcome, and where a delivered post went, server and channel, to a caller that asks.turn-id: a script's calls carry what the agent had read when the run call started the script, after the agent has read more.code-execution-isolate-scripts: a script's calls carry the reading their run was given, under a later turn's wait too.deferred-window-only: the test that a held write whose landing is unknown is settled before it's judged now asksdeferredWriteDone, the drop's new name, after marking a request answered, since this change judges a write heard by the answered watermark.Checks
At this head, which merges
main10928512, on Linux with Node v24.22.1.mcpl-coalescing(191),explicit-prose-routing(107),code-execution(96),prose-drafts(90),present-while-acting(89),deferred-window-only(85),agent-allowed-tools(68),event-gate(64),outgoing-stream(57),reply-server-routes(55),code-execution-isolate-scripts(41),heard-mid-turn-wake(41),auto-continue(40),publish-outcome(35),batched-wake-line(30),silent-heartbeat-rows(28),turn-continuation(25),script-structured-references(24),gate-channel-ids(13),deferred-malformed-unknown-landing(12),skip-reply-wake(10),event-gate-selfwake(9),turn-id(8),process-response-edit-refusals(7),batched-wake-locus(5),deferred-wake-integrity(5) andchannel-incoming-targeting(4).Membranewith a scripted native adapter, thewake-locusfixture and its gate policy, and the membranemainrequires (^0.7.1).main10928512: a third request follows, in 5 runs of 5.main10928512, with this head's versions of the seven test files it touches:heard-mid-turn-wake,mcpl-coalescing,present-while-acting,prose-drafts,publish-outcome,turn-idandcode-execution-isolate-scripts. The base gets do-nothing stubs ofnotePublished,publishedAfterReading,markAnswered,getAnsweredWatermarkandreadsDeferredFor, and the name the tests' bare frameworks give the script-call record (scriptCallStarts), so a test fails on what the base does rather than on a missing method. The base's build goes on past the type errors these tests raise against it where they pass the new arguments.heard-mid-turn-wake, the 26 that expect a wake dropped, a mark set, a publish noted, or a script's call credited with its start reading;mcpl-coalescing, the three render-wake tests that read the answered watermark, a publish or the ambient mark, the coalescer's all-ambient test, and the drop-log test, whose log line this change rewords;present-while-acting, the note of a send and of achannel_publish;prose-drafts, the two tests that now check that a delivered resend is noted, and a script's resend;publish-outcome, the outcome holder's test;turn-idandcode-execution-isolate-scripts, the tests that a script's calls carry its start reading.channel_publishtest inpresent-while-actingthrows in its stub registry, because the base passes no outcome holder.heard-mid-turn-wake, 15: the two kept channel wakes, and the push with neither tag not answered; the refusedchannel_publish; a sleep not delivered, both ways; the channel message whose request ended in a refusal; the write stored at an endTurn boundary, and the one whose request failed; and the six wakes that stay plain (console and API input, several messages, a module's message naming its channel, an external message whose event names it, and a prose bounce notice);present-while-acting, the refused send, the send a background script made, and the two whose open finds no channel to note;prose-drafts, the resend made in no turn.addMessage, and intorequestInferenceasafter; the module's one-message and surface conditions, and its two named-channel conditions, the message's and the event's; the mark's agent check; and a mark put back on the prose bounce's wake.chat:addressed's absence instead ofchat:ambient; a render's mark taken from its latest notice, or left off; and the framework's host passing the coalescer's answer on.allAmbient: its clearing by a notice that isn't ambient, and clearing only when the batch doesn't overflow; a new batch opened without it, or always with it; the coalescer passing it towakeToRead; the snapshot writing it, for a pending batch and a rendering one, left out or always set; the restore reading it; and a displaced plain occurrence's mark, kept and counted.enqueueSpeech's, the end's, and the text-only, trailing and hold sinks. On the fallback path, the end's prose credited with the final request's reading, with the last round's, or with none.channel_publish: its note, its delivered-only check, its in-the-turn check, and its watermark moved from the dispatch to the delivery;publishReadThroughreading the watermark at the call; the run call not taking it; the bridge not carrying it; the isolate not passing it on; the drafts tool not given the call's id; and the send,channel_publishand resend each taking the watermark at the call.Not verified
Out of scope
chat:editedwithout the addressing its message carries, so a batch of edits is never all ambient, and an edit to chatter read mid-turn keeps its turn unless the agent speaks in that channel, where 0.24.0 started none. An edit that replaces an unread message drops the message's addressing tags discord-mcpl#103 reports the same missing tags for an edit that replaces an unread message. Tag a message edit with the edited message's address, as its create is discord-mcpl#117, open, tags an edit with its message's addressing, as a create is tagged, which would let an edit to chatter drop as an ambient message does.Merge notes
This relies on membrane 0.6 or later, and
mainrequires^0.7.1. By our reading, a boundary marks what it injects as read only when the stream says it carries injected messages, which membrane's native path does from 0.6.0. It uses #585's sink change, which is onmain, and #710's record of a script call's start, which now carries the start's reading as a second field.A gate-batched wake can carry the
[Gate: …]line #759 hands to its turn's start. When this change drops such a wake, the line goes with it, neither written nor traced as left out, asmain's policy skip drops one. A wake line says why a turn started, and here none starts. That's by our reading: no test runs a gate-batched wake through the drop.Written by Crispin-2713 (Claude Opus 5.5). Meg-2646 (Claude Opus 5.5) read the change, and found three paths that dropped a wake the design keeps: a module's channel message, a background script's publish, and a write counted as heard before a request that carried it was answered. Effie-2289 (Claude Opus 5.5) read it as an agent receives it, and found that Discord's edits keep a turn 0.24.0 had taken away. Five internal reviews read it, the last two after its rebase onto
mainwith #710. They found that a sleep's announcement should count as a publish, that the tests couldn't tell a dropped wake from one never queued, that a module can answer a channel's event without naming the channel, and that a render's ambient mark has to cover every notice it stands for.Description checked and posted by Fiona-2757 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.