Repository navigation
fix(refusal): mark the message a refused turn answered; give rewind retries their channel; count pushed messages as delivered - #947
Open
nissa-seru wants to merge 2 commits into
Conversation
added 2 commits
October 30, 2026 15:30
…etries their channel; count pushed messages as delivered A refused turn's reaction and its failure record's locus read the channel registry's latest delivered inbound message, which any arrival moves and which only the channels/incoming lane ever set. A mention pushed from a channel the agent hadn't opened, or a DM, arrives by push/event, which never set it. So its refused turn got no reaction and a null locus, or, after a message answered in an open channel, a reaction on that earlier message in the other channel. - Each wake for one message now carries the message's platform id (InferenceRequest.answersMessageId): channels/incoming residents, conversation forks and corrections take its messageId, a push its origin.messageId. Markers (reactions, deletions, chat:system notices, edits not delivered as the first view) carry none, and neither do batches, renders, timers, notices or a channel-scoped push that names no message. A merged start keeps it only when every merged wake names the same message in the same channel, and the quiesce key keeps wakes for different messages apart. - The turn's opening resolves the pair it answers once, onto TurnRouting.answers, which later starts of the same logical turn, re-pins and channel_open don't move. The refusal reaction goes on that message, or nowhere, and the failure record's locus names that channel, or the channel the turn was woken for. - A rewind's retry (the refusal auto-rewind, /unstick and the poison-history breaker) carries the shed turn's channel and message. Before, it had no channel, so under speech.locus 'delivery' it read a "[routing] ... no channel" notice and its answer stayed in the archive. Now it speaks in that channel, and a refusal of it marks that message. - The push lane applies admission's locus decision: a push that names a new message counts as the latest delivered inbound, as a channels/incoming message does, and a channel-scoped coalesced push's acceptance commits the anchor its delivery parked. - A parked anchor is committed only when no newer delivery has set the anchor since (a delivery sequence), on both lanes. A resend answered from its receipt after a failed acknowledgement, or a message delivered while the acceptance was being made durable, no longer makes an older message the latest. ChannelRegistry.noteDelivered keeps its public void signature and delegates to recordDelivered (internal), which says whether it set the anchor. - A delivery at assembly (a deferred push's render) neither notes nor parks, so a resend of that push can't commit it late. Parked anchors are keyed by [serverId, eventId] as JSON, so a colon in either can't make two pairs collide. Tests: test/refused-turn-mark.test.ts and test/push-delivery-anchor.test.ts, over the coalescing fixture's loopback MCPL server. Fakes in eight existing test files give the routing map or registry method the code now reads, and one existing test's title says what its control now shows.
…no locus The refusal mark and the failure record now read the message the refused turn answers, where they read the latest delivered inbound message before, guarded for an ephemeral run. 0.23.0's changelog promises that a refusal of an ephemeral run's inference adds no reaction anywhere and that its failures.log record names no locus. The tests beside this one call the mark and the record directly for a subagent that has had no turn; this one runs a subagent whose inference is refused, after a resident's turn has answered the latest inbound message, and checks both halves.
Contributor
Author
|
A run through a whole host: on agent-framework 0.32.0, the agent's reply on a turn retried after a refusal's rewind went nowhere, and with this pull request it reached the refused message's channel.
Beth-2389 ran the host, and Ada-1017 wrote this reply. — Ada-1017 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A refused turn's reaction now goes on the message the turn answered, in that message's channel, and the retry a rewind queues now takes that channel as the channel it was woken from. A pushed message, such as a mention in a channel the agent hasn't opened or a DM, now counts as the latest delivered inbound message. At
71193a9fefthe reaction and the failure log'slocusboth take the latest message delivered from any channel, and no push counts. So a refused turn woken by a pushed mention marks no message, or an earlier message in another channel, and itslocusisnullor names that other channel. A rewind's retry carries no channel. In two tests whose shed turn answered a message in an open channel, one a refusal auto-rewind and one the poison-history breaker, no answer is published in that channel within 60 seconds. This branch addsInferenceRequest.answersMessageId, the message a wake is for. Its two new test files fail 19 of their 26 tests over71193a9fef'ssrc, and at82122c4c9e, this branch's first commit, they pass, with the change's other test files and three more that callnoteDelivered, 506 of 506. Its second commit,00aeb8e911, adds a test that a subagent's refused inference still marks nothing and logs nolocus.@antra-tess, we're asking you to review this before we merge it, since it changes what an agent reads. A rewind's retry now takes the channel the shed turn was woken from. In a probe of three cases, with
speech.locusand prose routing at their defaults, the retry's request at71193a9fefcarried the "[routing] Your plain speech currently has no channel" notice, and on this branch it doesn't. "What an agent reads", below, gives the cases and a table of what changes. No notice's text changes.Problem
At
71193a9fef:locusread the registry's latest delivered inbound message.reactToRefusal(src/framework.ts:13388) andnoteRefusal(:31696) both readchannelRegistry.incomingFor(agentName)(src/mcpl/channel-registry.ts:2522), one message per agent across every channel.channels/incominglane notes a delivery (noteDeliveredOrPark,src/framework.ts:17461,:17525,:17648). The push lane (handleMcplPushEvent,:19161) computes admission'slocus(:19263) and doesn't apply it. discord-mcpl 0.6.0 sends a message from a channel the agent hasn't opened, or a DM, as a push (src/server.ts:4528at itsv0.6.0), and as a channel-scoped coalesced create when the host offers channel-scoped coalescing and the channel is registered (:3825,:4620). This framework always offers it (PUSH_COALESCING_SUPPORT,src/mcpl/push-coalescer.ts:25). Only the channel lane's acceptance commits a parked anchor (src/framework.ts:18711), so a coalesced push's stays parked./unstick(doRewind), and the poison-history breaker (quarantinePoisonedHistory,:32040), which is on by default.Run over
71193a9fef'ssrc, this branch's tests show each of these:locusisnullwhere the push's channel is expected;'latest-inbound', a mention pushed from a closed channel: the fallback locus stays on the earlier message's channel;Changes
locusnames that channel, or, for a turn that answers no single message, the channel it was woken for, unless the wake gave only a channel id that no single registered channel answers to. One pair,TurnRouting.answers, is set when the turn opens, from its first start's wake. It serves the reaction, the record and a rewind's retry through every later start of the turn, and a re-pin orchannel_opendoesn't move it.reactToRefusal's doc. agent-framework 0.31.0'sdist/src/framework.d.ts(:2857–2864) documents the private method as reacting "on the message that holds the refused agent's conversational locus (the most recent incoming channel message that isn't a reaction, reaction removal, edit or deletion marker or a chat:system notice, apart from an edit the coalescer delivers on acceptance as the agent's first view; an ephemeral run has none, so a subagent's refusal marks no message)". On this branch it reads "react on the message the refused turn answers". 0.23.0's changelog says of an ephemeral run that "a refusal of its inference adds no reaction anywhere, and itsfailures.logrecord names nolocus" (CHANGELOG.md:5491–5492). The test the second commit adds pins that (Tests).InferenceRequest.answersMessageId. Each wake for one message carries the message's platform id: achannels/incomingresident's, a conversation fork's and a correction's take the event'smessageId, and a push's itsorigin.messageId. A marker carries none (a reaction, a deletion, achat:systemnotice, or an edit not delivered as the agent's first view of its message), and neither does a batch, a render, a timer, a notice, or a channel-scoped push that names no message. A merged start keeps it only when every merged wake has the same one in the same channel, and the quiesce key keeps wakes for different messages apart./unstickand the poison-history breaker: its refusal is marked on that message, and its turn takes that channel as the channel it was woken from. In the shed message's place it sees the rewind's marker.noteDeliveredOrParkon both lanes, withorigin.threadId. A channel-scoped coalesced push counts once its acceptance commits its parked anchor. A delivery at assembly, such as a deferred push's render, neither notes nor parks, so a deferred create's anchor stays unset, as at71193a9fef.JSON.stringify([serverId, eventId]), so a colon in either can't make two pairs collide now that the map holds pushes too.ChannelRegistry.noteDelivered(...)still returnsvoid. It callsrecordDelivered, which returns whether it set the anchor and is marked@internal.What an agent reads
A probe outside this branch printed the bracket-tagged notices in each request, with this branch's tree and with
71193a9fef's, in three cases withspeech.locusand prose routing at their defaults, each of which kept the turn's channel: a refusal auto-rewind in an open channel, the same for a mention pushed from a closed channel, and the poison-history breaker. In each case, at71193a9fefthe retry's request added "[routing] Your plain speech currently has no channel — it stays in your archive. Use an explicit send tool to reach a channel." after the rewind's marker, and on this branch it added the marker alone. No notice's text changes.71193a9feflocus: nulllocusits channellocusits channellocusits channellocus: nulllocusthe push's channelincoming, and the'latest-inbound'fallbackOpen pull requests
By
git merge-treeat 21:14 UTC on October 30, this branch merges cleanly intomainata64cfc010f. Four open pull requests that merge cleanly into71193a9fefconflict with it, so whichever of each pair merges second needs the conflict resolved:src/framework.tsandsrc/types/agent.ts;src/framework.ts;src/framework.tsandsrc/types/agent.ts;src/framework.ts.Tests
On Linux with Node 24.22.2 and npm 11.19.1, with membrane 0.8.0, chronicle 0.5.1 and context-manager 0.24.2, as
npm lsprinted them at82122c4c9e:82122c4c9e, in a clean checkout, the 11 test files this change adds or edits and the 3 others that callnoteDelivereddirectly give 506 of 506. The whole suite runs in CI on this pull request.test/refused-turn-mark.test.tsandtest/push-delivery-anchor.test.tsfrom82122c4c9e, run over71193a9fef'ssrc, fail 19 of their 26 tests. Problem names what twelve of them show. The 7 that pass are:71193a9fefnever commits a push's anchor;82122c4c9e: 15 mutants, each taking one piece of the change out of the builtdist/src/framework.js, each run against both new test files whole. 14 fail at least one test. The one that doesn't is under Not verified.00aeb8e911, this head, in a clean checkout with the same versions,test/ephemeral-no-global-fallback.test.tspasses 12 of 12. Its new test refuses a subagent's inference after a resident's turn has answered the latest inbound message, and checks that the refusal record names nolocus. It then marks a refusal of the resident's as a control, and checks that the only reaction sent is that one, on the message the resident answered. Two mutants each take the ephemeral guard away from one half. With the mark falling back to the latest delivered inbound message, 2 of 12 fail: the new test, at its check that only the resident's refusal is marked, and the older direct test of a subagent's mark. With the record'slocusfalling back the same way, 2 of 12 fail: the new test, at its check of thelocus, and the older direct test of a subagent's record. Over71193a9fef'ssrcthe file passes 12 of 12 as well, so the rule held there, and the new test is red against the mutants, not the base.Not verified
test/helpers/coalescing-fixture.ts) that sends the push shapes discord-mcpl 0.6.0's source sends.src/mcpl/push-coalescer.ts:897–898says, so a refusal of the turn it wakes marks nothing. No test covers it.82122c4c9e,src/framework.ts:17480puts the message's id on that wake, so a refusal of its turn would be marked on that message. No test refuses such a turn: with that id taken off the wake, every test passes.speech.locus: 'latest-inbound', a turn with no locus of its own can now read the existing routing notice naming a pushed message's channel ("[routing] Your plain speech now lands in #…", or "[routing] The conversation is now in #…" for an inbound-only server), by whatannounceLocusIfChanged(src/framework.ts:21299at82122c4c9e) says. The tests pin the fallback moving, not that notice.channel_open. No test or probe runs one. A re-pin records the channel it announces (rememberAnnouncedLocus,src/framework.ts:21741at82122c4c9e), and so does achannel_openunder prose routing by locus or hybrid while the turn that called it is open (:36532). For a locus other than the one recorded,announceLocusIfChanged(:21299) gives "[routing] Your plain speech now lands in #…", or, for a channel whose server is inbound-only, "[routing] The conversation is now in #…" with that server's note on speech.resolveLocusreturns a fork's home channel before it asks for the turn's own channel, at71193a9fef(src/mcpl/channel-registry.ts:5765–5767) as at82122c4c9e(:5773–5775).changelog.d/fix-refusal-mark-trigger.fixed.mdandchangelog.d/fix-refusal-mark-trigger.added.md.Ada-1017 decided its shape, Gladys-2592 built it, and Cedric-2714 read it and ran its late-commit cases and mutants. Agnes-1009 reported the refusal whose
locuswasnull, and Gladys-2413 found where it came from. Fiona-2757 wrote this description.