Skip to content

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
anima-research:mainfrom
nissa-seru:fix/refusal-mark-trigger
Open

nissa-seru wants to merge 2 commits into
anima-research:mainfrom
nissa-seru:fix/refusal-mark-trigger

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

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 71193a9fef the reaction and the failure log's locus both 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 its locus is null or 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 adds InferenceRequest.answersMessageId, the message a wake is for. Its two new test files fail 19 of their 26 tests over 71193a9fef's src, and at 82122c4c9e, this branch's first commit, they pass, with the change's other test files and three more that call noteDelivered, 506 of 506. Its second commit, 00aeb8e911, adds a test that a subagent's refused inference still marks nothing and logs no locus.

@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.locus and prose routing at their defaults, the retry's request at 71193a9fef carried 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:

  • The refusal mark and the failure locus read the registry's latest delivered inbound message. reactToRefusal (src/framework.ts:13388) and noteRefusal (:31696) both read channelRegistry.incomingFor(agentName) (src/mcpl/channel-registry.ts:2522), one message per agent across every channel.
  • No push sets it. Only the channels/incoming lane notes a delivery (noteDeliveredOrPark, src/framework.ts:17461, :17525, :17648). The push lane (handleMcplPushEvent, :19161) computes admission's locus (: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:4528 at its v0.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.
  • A rewind's retry is queued with no channel: the refusal auto-rewind and /unstick (doRewind), and the poison-history breaker (quarantinePoisonedHistory, :32040), which is on by default.
  • The channel lane commits a parked anchor whatever was delivered since. A resend answered from its receipt after a failed acknowledgement, or a message delivered while the acceptance waits for its commit, leaves the older message as the latest.

Run over 71193a9fef's src, this branch's tests show each of these:

  • a mention pushed from a closed channel after a message answered in an open one: the reaction goes on that earlier message, in the other channel;
  • a pushed mention alone, and the same mention as a channel-scoped coalesced create: the refusal is logged, and no reaction comes within 60 seconds;
  • a channel-scoped push that names no message, a deferred push's rendered batch, and a debounced push: locus is null where the push's channel is expected;
  • a refusal rewind's retry, and the poison-history breaker's: no answer published in the shed message's channel within 60 seconds;
  • a turn started from a merge of two wakes for messages that share an id in two channels: a reaction goes on that id in one of the channels;
  • under 'latest-inbound', a mention pushed from a closed channel: the fallback locus stays on the earlier message's channel;
  • on the channel lane, a coalesced create resent after a failed acknowledgement, or committed after a newer message arrived: the older message is the latest.

Changes

  • The reaction goes on the message the refused turn answered, in that message's channel, or on none. The failure record's locus names 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 or channel_open doesn't move it.
  • reactToRefusal's doc. agent-framework 0.31.0's dist/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 its failures.log record names no locus" (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: a channels/incoming resident's, a conversation fork's and a correction's take the event's messageId, and a push's its origin.messageId. A marker carries none (a reaction, a deletion, a chat:system notice, 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.
  • A rewind's retry carries the shed turn's channel and message, for the refusal auto-rewind, /unstick and 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.
  • A push that names a new message counts as the latest delivered inbound, through noteDeliveredOrPark on both lanes, with origin.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 at 71193a9fef.
  • A parked anchor is committed only when no newer delivery has set the anchor since, on both lanes, by a delivery sequence. The parked key is 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 returns void. It calls recordDelivered, 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 with speech.locus and 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, at 71193a9fef the 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.

71193a9fef this branch
Refused turn woken by a mention pushed from a closed channel no reaction, locus: null reaction on that mention, locus its channel
The same, after a message answered in an open channel reaction on that earlier message, locus its channel reaction on the mention, locus its channel
Refused turn woken by a debounced push, with nothing delivered before it no reaction, locus: null no reaction, locus the push's channel
Refused turn started from a merge of wakes for different messages reaction on the latest delivered message none
Refused turn woken by a reaction reaction on the latest delivered message none
A rewind's retry, the breaker's included, after a turn that answered a message in an open channel no answer published in that channel answer published in that channel
A pushed message (closed channel, DM) never the latest inbound the latest inbound: incoming, and the 'latest-inbound' fallback
A coalesced acceptance after a newer message the older message becomes the latest the newer stays the latest
A deferred create anchor unset anchor unset

Open pull requests

By git merge-tree at 21:14 UTC on October 30, this branch merges cleanly into main at a64cfc010f. Four open pull requests that merge cleanly into 71193a9fef conflict with it, so whichever of each pair merges second needs the conflict resolved:

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 ls printed them at 82122c4c9e:

  • At 82122c4c9e, in a clean checkout, the 11 test files this change adds or edits and the 3 others that call noteDelivered directly give 506 of 506. The whole suite runs in CI on this pull request.
  • Red first: test/refused-turn-mark.test.ts and test/push-delivery-anchor.test.ts from 82122c4c9e, run over 71193a9fef's src, fail 19 of their 26 tests. Problem names what twelve of them show. The 7 that pass are:
    • three controls: an addressed message in an open channel, refused, is marked on itself; every resident a message wakes marks it; a turn refused in its retry after a provider's stated wait marks the message it opened for;
    • the push lane's two late-commit tests, since 71193a9fef never commits a push's anchor;
    • the two tests that a push naming no message, and a deferred create, leave the latest inbound where it was.
  • Mutants, at 82122c4c9e: 15 mutants, each taking one piece of the change out of the built dist/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.
  • At 00aeb8e911, this head, in a clean checkout with the same versions, test/ephemeral-no-global-fallback.test.ts passes 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 no locus. 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's locus falling back the same way, 2 of 12 fail: the new test, at its check of the locus, and the older direct test of a subagent's record. Over 71193a9fef's src the 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

  • No run against discord-mcpl or Discord. The tests use a loopback MCPL server (test/helpers/coalescing-fixture.ts) that sends the push shapes discord-mcpl 0.6.0's source sends.
  • An edit that starts its coalescing subject is delivered without the first-view flag, by what src/mcpl/push-coalescer.ts:897–898 says, so a refusal of the turn it wakes marks nothing. No test covers it.
  • A correction delivered to a conversation fork as the agent's first view of its message. At 82122c4c9e, src/framework.ts:17480 puts 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.
  • Under 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 what announceLocusIfChanged (src/framework.ts:21299 at 82122c4c9e) says. The tests pin the fallback moving, not that notice.
  • A retry whose shed turn's speech moved to another channel, by a re-pin or channel_open. No test or probe runs one. A re-pin records the channel it announces (rememberAnnouncedLocus, src/framework.ts:21741 at 82122c4c9e), and so does a channel_open under 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.
  • A conversation fork's rewind retry. The two new test files run none. resolveLocus returns a fork's home channel before it asks for the turn's own channel, at 71193a9fef (src/mcpl/channel-registry.ts:5765–5767) as at 82122c4c9e (:5773–5775).
  • Left out of this branch: a mark on a turn that ended failed rather than refused, and whether the poison-history breaker should set history aside on a configuration error. A deferred create's anchor stays unset, since no server we know of sends one.

  • Changelog fragments added: changelog.d/fix-refusal-mark-trigger.fixed.md and changelog.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 locus was null, and Gladys-2413 found where it came from. Fiona-2757 wrote this description.

Gladys-2592 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.
@nissa-seru

Copy link
Copy Markdown
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.

  • Setup: connectome-host 0.21.0, with context-manager 0.24.2 and membrane 0.8.0, a scripted provider stand-in, and one agent with refusalHandling: { autoRewind: true }, no home channel, the default speech.locus, and no channel opened during a turn. Four turns, each from a message in one channel:
    1. a plain turn;
    2. a refusal, then the rewind's retry;
    3. two refusals in a row, then the retry;
    4. a plain turn.
  • On agent-framework 0.32.0: both retried turns end with the host logging mcpl:speech-route-failed, "turn has no locus (no home/trigger channel; nothing to deliver into)". Only the two plain turns publish a reply. So in this setup, an agent with autoRewind on gives no answer in the channel for a turn its rewind retried. The reason's words are those 0.32.0 writes when a turn has no locus to deliver into (src/mcpl/channel-registry.ts:6156–6171).
  • With this pull request: its head, 00aeb8e911, merged onto v0.32.0 with no conflict, built, packed and installed in the same host, with the same four turns. No speech-route-failed, and all four turns publish their reply, both retried ones included.
  • This is the path the pull request's test "a refusal rewind's retry speaks in the refused message's channel" (test/refused-turn-mark.test.ts) covers, here seen from a host.

Beth-2389 ran the host, and Ada-1017 wrote this reply.

— Ada-1017

@nissa-seru
nissa-seru requested a review from antra-tess November 1, 2026 09:02
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