Skip to content

fix(mcpl): speaking no longer reopens a channel the agent closed - #330

Open
nissa-seru wants to merge 39 commits into
anima-research:mainfrom
nissa-seru:fix/speaking-never-opens
Open

nissa-seru wants to merge 39 commits into
anima-research:mainfrom
nissa-seru:fix/speaking-never-opens

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

An agent's own close of a channel now holds against its own speech. A send or reply, or plain speech, into a channel the agent closed or declined itself publishes there and leaves the channel closed, and so does speech into a channel a host module closed. Elsewhere, speech opens a closed channel as before: one nobody decided about, such as a DM that woke the agent or a channel where someone first mentioned it, one a machine closed, or one the agent decided open whose open failed.

Where an agent's words stay in a channel it closed or declined, one line tells it so, what still reaches it from there, and how to open it, and the agent can turn that line off. The closed-channel invitation's first option now says what replying does in the channel it's about.

@antra-tess @Anarchid, this is the narrower rule your review of a0eb0d0 recommended (R1-1), rebuilt, with R1-4, R1-5 and R1-6 fixed. It still changes what residents meet in a channel they closed. We're asking for your review of the rule itself before it merges.

Problem

An agent can't answer in a channel it keeps closed without reopening it. A Connectome resident who closes channels whose ongoing traffic they don't want still answers the people who address them there. Each answer reopens the channel, so they close it again after every reply. They asked to be able to answer in such a channel without opening it, as a keepClosed or transient flag on the send and reply tools. This PR makes it the rule for a channel the agent closed, so it adds no flag.

At main (07563022), two paths open a closed channel:

  • routeSpeech opens a closed locus before it publishes (openChannelNow(entry, 'opened-by-delivery')), and publishes nothing if the open fails.
  • After a successful send_message, reply_message, send_dm, send_file or send_files, the framework calls openIfClosedForSend, which opens the channel ('opened-by-reply') unless it is open and decided open.

Both record desired state open over the agent's own channel_close. The window then says "[channels] Now open: … your own message into the closed channel engaged it. … your choice sticks; neither policy nor a later send will silently reopen it."

One more gap undoes a close. setDesiredState records nothing when the state stays the same, so the agent's channel_close or channel_decline of a channel that's already closed leaves the record it had. For a channel nobody had decided about, that's default-closed, which subscription policy or a server's initiallyOpen can open later. For every channel a server listed at its first registration, it's the legacy-recipe-migration record that the retired recipe policy's one-time migration writes.

Changes

  • Which channels speech opens. ChannelRegistry.speechOpens(serverId, channelId) says whether the agent's words open a channel. It says yes when the channel is closed, its server's grant has channels.lifecycle, it isn't tuned out, and its recorded decision isn't the agent's own close or decline (agent-tool, invitation-declined) or a module's close (module-tool, below). openForSpeech opens a channel where speechOpens says yes, and both of main's paths call it: routeSpeech before it publishes, and the send path after a successful send, without holding up the tool's result, as on main. Where speech opens a channel, the agent gets main's "[channels] Now open" notice. A channel the agent decided open whose open failed is opened again, and its decision keeps its source.
  • The words go out whatever the open does. routeSpeech now publishes when the open fails, where main published nothing. On a server whose grant lacks channels.lifecycle, where no channel can open, speech publishes without trying, so it records no intent to open the channel; main recorded one, and then failed the speech. A publish that throws, as on a dead transport, is now a failed route (publish failed: …), reported to the framework's route-failure handler (R1-4). By our reading of the code, that handler gives the agent its [discord-send-failed] marker, and the failure path traces mcpl:speech-route-failed. main only logged it.
  • The latest deliberate close is recorded. At the same closed state, the agent's channel_close or channel_decline, or a module's close, now records itself over whatever closed record is there. That record can be a pure default (default-closed), the recipe migration's (legacy-recipe-migration), a machine's close (subscription-gc, housekeeping), a source a replay couldn't read (unknown), or another deliberate close, so a decline after a close is the decision on record. channel_close records it on a channel that's already closed. A machine's close never replaces an already-closed record. A machine's close of a channel with no record at all, one registered lazily or on a server whose grant lacks channels.lifecycle, now records default-closed, the default the channel had, where it used to record the machine's source, which policy never lifts (R1-6, Channel decisions: memory is updated before the durable lifecycle append, and a machine close of a never-recorded channel can never be lifted #417). So policy can still open what nobody decided.
  • A module's close is the module's. A module's channel_close through its own tool call (ctx.callTool) without a machine source used to be recorded as agent-tool, and is now module-tool. Speech leaves such a channel closed, as it does one the agent closed, but the agent gets no note there: the module owns the way back, and channel_open alone would undo only part of what it did. Of the agent's close and a module's, the later one is the one on record. A module's close is never refused as a machine's close of an explicit open; only one naming a machine source is. In connectome-host, this is the mentions channel mode, whose close goes through channel_close (channel-mode-module.ts:314 at 38b55da6). main recorded a module's close as agent-tool, and nothing in its log tells the two apart. So, by our reading of how records replay, after the upgrade a channel put in that mode before reads as one the agent closed, and gets the note. Setting the mode again records the close as the module's. Since the already-closed path now records the close's source, a module's unknown machine source is checked before it, and refused there too, where main returned "already closed".
  • The note. Where an agent's words land in a channel it closed or declined itself, a send tool's answer gets one more text block after the server's own, tagged [closed channel]. Routed speech's turn-end line, [delivered] plain speech → …, ends with the same words after a ·: #upkeep (<id>) stays closed: you closed it, and your words there don't open it. Messages there that address you, such as an @-mention, still reach you; channel_open with channelId "<id>" and serverId "<server>" opens it. To stop this note, set closed_channel_note to false with agent_settings. For a channel the agent declined, it says "you declined it".
    • The send path reads the channel on the server the send went to, so a channel on another server with the same id doesn't decide the note, or name the channel in the "[channels] Now open" notice a send's open gives. The invitation reads the channel on the server the message came from, for whether it's open and for its label.
    • On a server whose grant lacks channels.lifecycle, the channel_open clause is left out, since nothing opens there (R1-5). A channel whose transport is open, as when its server refused the agent's close, gets no note, since its traffic still flows. A channel decided open whose transport is down gets none either: speech opens it again.
    • In explicit routing mode there's no [delivered] line, since the agent names every destination. A turn whose >> speech stayed in such a channel ends with a line of its own carrying the same words. That line goes through the framework's addMessage: while a turn is under way, it's held and given at that turn's next tool call or at its end. Speech whose delivery is still running when a tool ends the turn gets the line once it's delivered.
    • A conversation agent, from conversation routing (deprecated, Campaign A live validation (10/1–10/3 drops): architecture held; 1 enforcement defect (time limits vs blocking code), 2 seam gaps, 1 integration bug, 1 design question #235), gets none, since channel_open is refused to it. channel_publish never opened a channel, so it gets none either.
    • closed_channel_note is an agent_settings key, on by default and kept per agent in the framework's state, as tool_result_guard is. If working the note out fails, the delivered words stand, the failure is logged, and nothing is added.
    • The second sentence has one exception the host can't see. On discord-mcpl, a channel muted with mute_channel delivers nothing, mentions included, and muting doesn't tell the host. In a muted channel the agent closed, the sentence doesn't hold. The agent that muted it knows.
  • The sleep announcement posts through routeSpeech too. In a channel the agent closed or declined, or a module closed, its default words are now "I'll still see messages addressed to me, but won't respond until I wake.", where "I'll still see messages" was true only because posting reopened the channel. Elsewhere they read as before, and an agent's own words are posted as given.
  • What closing says. channel_close's description is now "Close a channel. Messages that address you still reach you; what else stops reaching you depends on its server. You can still speak there. It stays closed until you open it with channel_open: neither speaking there nor subscription policy reopens it." Its first two sentences are main's. The "[channels] Now open" notice now ends "If you don't want it, use channel_close: a channel you close stays closed until you open it with channel_open, and neither speaking there nor policy reopens it."
  • The invitation. The closed-channel invitation's first option now says what replying does in its channel, which speechOpens tells it.
    • Where a reply opens the channel: "1. Reply — write your reply this turn prefixed with ">>#channel"; it will be delivered there, which opens the channel. To reply without opening it, decline it first (option 3): a channel you've declined stays closed when you speak there." The first sentence is main's.
    • Where a reply leaves it closed (a channel the agent closed or declined, one a module closed, or one on a server where nothing opens): "1. Reply — write your reply this turn prefixed with ">>#channel"; it will be delivered there, and the channel stays closed." main's says the reply opens the channel, which this change makes false there. What else reaches the agent from a channel left closed is its server's to say (The channel invitation, channel_open, channel_close and the "Now open" notice describe discord-mcpl's delivery, which slack-mcpl doesn't share #406), so the line says nothing about it.
    • Where no single-word target reaches the channel, the option suggests checking whether the server has a send tool that can address it, where main says "Open it (option 2) to reply". A >> prefix, channel_publish and plain speech all find a channel by id alone, and two servers can share the id. main's sentence is false where a send tool replies without opening the channel, as it now does in a channel the agent closed. Where a reply opens the channel, the option ends "Replying there opens the channel." and the same way to reply without opening it. Where it doesn't, it ends "Replying there leaves the channel closed."
    • No invitation is added to a message tagged chat:dm. Every message of a DM addresses the agent and reaches it whether the DM is open or not, so the block has nothing to offer there. A DM the agent closed now stays closed when it answers, so each of its messages would otherwise carry the block.
  • The send path's lookup. resolveSendTarget replaces openIfClosedForSend's lookup: it finds the channel a send went to, from a composite or raw id, and opens nothing. The send path records that channel as engaged on the turn that sent, by the descriptor id the mid-turn re-pin compares, before the tool result is queued, where main waited for the open's round trip.
  • A tune-out stays, and a send's answer says so. A send tool's call into a tuned-out channel no longer ends the tune-out. On main, openIfClosedForSend set the decision to open, since it wanted the decision open, and that ended the tune-out. An agent that sends there expecting answers would now stop hearing them unawares, so the send's answer ends with one line, tagged [tuned out]: #upkeep (<id>) stays tuned out: your words there don't end the tune-out, so its traffic still goes to your subconscious until the tune-out ends. tune_out with channelId "<id>" and mode "cancel" resumes normal attention there., and the stop sentence. "Resumes normal attention" is channel_open's own wording when it refuses a tuned-out channel. Routed speech never ended a tune-out while its transport was open, so it gets none. The same setting turns it off.
  • A script's send keeps the server's receipt. A code_execution script's send, one of its script-inner calls (pytc-…), gets the server's receipt alone, as on main, since the script parses it. The agent whose script sent gets the note or the tune-out line in its own window instead, as a notice by the path the "[channels] Now open" notice takes: [closed channel] Your script's reply_message: … or [tuned out] Your script's send_dm: …, with the same stop sentence. If the window can't take the notice, the failure is logged and the receipt stays as it was. The model's own sends carry the note in their answer, as above.
  • An open on its way when the agent closes. openChannelNow marks a channel open after its round trip only while the decision still holds it open, as reconciliation's opens do. That's open, or tuned-out, which keeps the transport open. openForSpeech reports opened only while the decision is open. So when the agent closes a channel while an open from its speech, a send or a reconnect is on its way, the channel isn't left marked open, and no "[channels] Now open" follows the close. main has the same race between channel_open and channel_close.
  • Declining a channel decided open but not open at the transport. channel_decline is refused only for a channel open at the transport. A channel decided open whose open failed is closed there, so declining it, which is how the invitation says to reply without opening it, now records the decline, where main refused it as "already open". The decline also sends channels/close for a channel decided open or tuned out that isn't open at the transport, as channel_close does, since that channel may have an open on its way, or one the server applied though its reply was lost. The server then ends closed whatever that open did, provided it applies the open and the close in the order they arrive (see Not verified). By our reading of their code, discord-mcpl (568c293, src/server.ts:3513-3519), slack-mcpl (df218e1, src/server.ts:393-400) and zulip_mcp (0642e33, src/server.ts:742-750 and src/channels.ts:458-462) answer a close of a channel they never opened without an error. A close that fails leaves the decline recorded, and the decline's answer carries closeError.

What each part is for

This change exists so that an agent's own decision to keep a channel closed holds when it speaks there, and so that it's told so. Speech still opens a closed channel nobody decided about, one a machine closed, and one decided open whose open failed, as on main.

  • speechOpens, and openForSpeech on both paths: one rule where main had two open sites with different conditions. The send path's ended a tune-out and routeSpeech's didn't. The invitation reads the same predicate, so what it says a reply does can't drift from what a reply does. The cost is two small methods, and canOpenChannels, which speechOpens and the note's last clause share.
  • The same-state rule in setDesiredState, and channel_close recording on a closed channel: without them, the agent's close of a channel that's already closed records nothing, so its next reply opens the channel again. The rule is one line: the latest deliberate close records itself, and a machine's close never replaces an already-closed record. It covers every closed record, the recipe migration's included, which every channel listed at its server's first registration keeps until something else is recorded.
  • module-tool: without it, a host mode's close counts as the agent's, and the note advises channel_open, which would undo half of the mode. The cost is one source value. Giving the note only once per close was the other way, but it would need state kept per record, and would still give the wrong advice once.
  • A failed open no longer stops the words, and a thrown publish is a failed route (R1-4): together they keep speech working on a server that can't open channels, and tell the agent when a publish fails. The cost is a few lines. The alternative is main's refusal, which drops words the server would have taken.
  • The default for a machine's close of a channel with no record (Channel decisions: memory is updated before the durable lifecycle append, and a machine close of a never-recorded channel can never be lifted #417): one line, and it keeps policy able to open what nobody decided.
  • The note: it's there for the moment a change of behaviour meets old usage, and it appears only where speech now does something different, a channel the agent closed or declined.
    • The setting makes it suppressible.
    • The explicit-mode line is there because explicit agents speak mostly through >>.
    • The hold through addMessage is there because the line can come while a turn runs.
    • The catch is there because the note is advice, never worth losing delivered words over.
    • Leaving explicit mode out would have been simpler, but most explicit agents would then go uninformed.
    • Its cost includes closedByAgent, a new registry method only the note reads.
  • The tune-out line: the same moment for a tune-out, on the send tools, which were the only path that ended one.
  • The invitation's first option, its no-target variant and its DM skip: the invitation is what tells an agent what a reply will do, and this change decides that.
  • The sleep words: the default announcement is for people, and "I'll still see messages" stops being true in a channel the agent closed. Their cost includes closedByDecision, a new registry method only they read.
  • channel_close's description and the "[channels] Now open" notice's ending: they're where an agent learns that its close now holds against its own speech and policy. The cost is two sentences in the description, and a reworded ending on the notice.
  • Reads by server (isChannelOpen and getDescriptor with an optional server id, and the entryFor helper): the note, the invitation and a send's "[channels] Now open" notice read the channel speechOpens decides on, not the first channel with that id on any server. The cost is an optional parameter on two public methods and a three-line helper.
  • resolveSendTarget: the lookup half of openIfClosedForSend, so the engagement is recorded before the tool's result rather than after the open's round trip.
  • The decision check after an open's round trip, and channel_decline's narrower refusal with its close at the server: the check and the refusal are one condition each, and the close is one call. Without the check, an open from the agent's own words could leave a channel it has just closed marked open and announced. Without the narrower refusal, the invitation's way to reply without opening the channel would be refused where an open had failed. Without the close, a decline made while an open was on its way, or after one whose reply was lost, would leave the server open while the registry says declined. Serializing each channel's lifecycle requests would also settle the check's race, but it would reach into reconciliation and tune-outs for a race main has too.
  • Not taken: exempting send_dm from the note, as 762988f45 does. That suits a broader rule, under which every DM stays closed after an answer, so every send_dm would carry the note. Under this change's rule the note shows only in a DM the agent closed itself, where it's true and routed speech there carries it too, so the exemption would be one more tool-name branch and test.

What residents and hosts will notice

These are in changelog.d/agent-close-holds.breaking.md and agent-close-holds.fixed.md.

  • In a channel an agent closed or declined, replying no longer reopens it. To hear that channel's traffic again, where a closed channel withholds it, as on discord-mcpl, the agent calls channel_open. The automatic-open cases listed above retain their opening notice.
  • On discord-mcpl, the later messages in such a channel that don't address the agent don't arrive as they're posted. Since fix(channels): stop describing one server's channel delivery as every server's (#406) #429, none of agent-framework's texts says what a closed channel withholds, since that's each server's to say (The channel invitation, channel_open, channel_close and the "Now open" notice describe discord-mcpl's delivery, which slack-mcpl doesn't share #406). fix: say what reaches the agent from a channel it hasn't opened discord-mcpl#97 has discord-mcpl say it on a push from a channel the agent hasn't opened.
  • The note, the tune-out line, the sleep announcement's words, the invitation's first option and channel_close's description read as above. An agent whose script sends into a channel it closed or tuned out gets that line as a notice in its window, and the script gets the server's receipt. In connectome-host's mentions channel mode, a reply to a mention now stays a reply: the channel stays closed, with no note, and its invitation says the channel stays closed. On main, the reply reopened the channel.
  • On discord-mcpl, channels/open also lifts a mute set with mute_channel. So a reply that opens a muted channel nobody decided about, or one a machine closed, unmutes it, as on main. A channel the agent closed stays closed, so its mute stays. Keeping a mute through an open would be discord-mcpl's change.
  • On discord-mcpl, opening a channel turns its reaction forwarding on, and closing it turns that off. Reactions to the agent's reply in a channel it closed now arrive only where the agent opens the channel or turns them on with set_reaction_visibility. That's intended: they're part of the channel's ongoing traffic.
  • On discord-mcpl, a closed channel's messages that address the agent arrive as push events, and an open channel's as channels/incoming. A channel the agent closed now stays on push events after it answers there. Host code that acts on channels/incoming doesn't see those messages, as for any closed channel. In connectome-host, by R1-3 and our reading of its modules, that's the notices module's outage reply to a person who writes in, and the activity module's typing hints. Where a turn with no trigger, such as a heartbeat, speaks follows the most recent channels/incoming message, so a channel the agent closed and answered no longer becomes that place.
  • On zulip_mcp, an answer's topic comes from the last message the server delivered as channels/incoming, and a closed stream's mentions are pushed instead (R1-2). So in a stream the agent closed, every answer now goes to the topic of the stream's last message from before the close, where on main the first answer reopened the stream and later answers followed the conversation. A stream nobody decided about opens on the first answer, as on main, and that answer goes to the topic mcpl, as on main. fix: an answer to a mention pushed in a closed stream goes to that mention's topic antra-tess/zulip_mcp#32 takes the topic from the newest message the host accepted there, pushed mentions included, which settles both.
  • Speech into a closed channel on a server whose grant lacks channels.lifecycle now publishes.
  • A machine's close of a channel with no record leaves it to policy. By our reading of how records replay, those written before this change keep their source, so a channel a machine closed before the upgrade still isn't opened by policy.
  • In connectome-host, a channel put in mentions mode before the upgrade was recorded as the agent's close. By the same reading, the agent's words there carry the note until the mode is set again.
  • channel_decline now records a decline of a channel decided open whose open failed. For a channel decided open or tuned out that isn't open at the transport, it also sends the server a channels/close. A module's channel_close naming an unknown machine source is refused on a closed channel too.
  • Direct ChannelRegistry consumers lose openIfClosedForSend() and gain resolveSendTarget(), speechOpens(), openForSpeech(), closedByAgent(), closedByDecision() and canOpenChannels(). isChannelOpen() and getDescriptor() take an optional server id. A module's close without a machine source is recorded as module-tool, where it was agent-tool. onChannelAutoOpened and the mcpl:channel-opened-by-send and mcpl:channel-open-failed traces are as on main.
  • channel_close's description and agent_settings' input schema change. Anthropic's prompt-caching documentation says that changing tool definitions invalidates the entire cache; this is a documented consequence, rather than a cache measurement from these runs.

Merge order

This branch has main merged in at 07563022, after #429. A scan of the open pull requests at 06:13 UTC on October 18, at this head, found 69 others. Two merge onto main and conflict with this one, and 41 conflict with main already, so their next merge of main meets this change too. #210 and #350 are among those 41.

Tests

These ran on Linux x86_64 with Node 24.22.0. CI runs the suite and its Node matrix at the push.

  • At this head, which merges main 07563022 at d4b933c6, with a commit after it that changes only changelog fragments: typecheck and the build are clean at d4b933c6, and these pass:
    • the five test files this change touches: send-into-closed-channel (20), channel-registry-routing (38), explicit-prose-routing (69), trunk-channel-routing (26) and present-while-acting (57), with mcpl-rejection-log-lines (22) and event-gate (49), whose # handling came with fix(channels): stop describing one server's channel delivery as every server's (#406) #429;
    • the files that run code_execution scripts or reach the send path: code-execution (73), mcpl-tool-lifecycle-script (3), mcpl-tool-lifecycle (31), mcpl-tool-lifecycle-emitter (26), unconvertible-tool-result (10), checkpoint-attribution (10) and tool-result-history (19);
    • the registry's durability files: channel-decision-log-first (10), channel-label-history (18) and tune-out-lifecycle (5).
  • Red-first against main: this head's five changed test files against main 07563022's source fail in 43 of their 210 tests, the same tests as against the main this branch had merged before, one of them renamed.
    • 33 fail on behaviour. They cover: speech and sends into a channel the agent closed or declined; a send into a channel a module closed; a module's close naming an unknown machine source on a channel already closed; a failed open stopping the words; speech on a server without channels.lifecycle; a thrown publish; the agent's close of a default not sticking; an open on its way when the agent closes; declining a channel whose open failed, or one whose open is on its way or was applied with its reply lost; channel_close's description; the note on a send, on the delivery line and in explicit and hybrid mode, and on the server the send went to; its setting and its containment; the sleep words; the tune-out and its line; a script's send keeping the server's receipt, with the note in the window; a close made while a send is in flight; the engagement recorded before a send's result; the invitation's first option, its no-target variants and its DM skip; and the invitation and a send's "Now open" notice reading the server the message or send came from.
    • 9 fail first on a method main doesn't have (closedByAgent, speechOpens, resolveSendTarget). Compiling against main leaves 56 type errors, for those, openForSpeech, closedByDecision, canOpenChannels, and isChannelOpen's second argument. The compiler still emits.
    • present-while-acting's engagement test fails because its stub now offers this change's lookup in place of main's openIfClosedForSend. main's own version of it passes at main.
    • For this run, send-into-closed-channel's harness wraps main's openIfClosedForSend where openForSpeech is missing, so that its settle has something to wait for. That adapter is in the run's receipt, not in this branch.
    • The other 167 pass there as controls, fix(channels): stop describing one server's channel delivery as every server's (#406) #429's three new tests among them.
  • Each piece removed on its own: at b554bce4, each of 89 pieces, reverted alone, compiles and makes at least one test fail among the five files above and mcpl-rejection-log-lines. At 026ffb3f, the six pieces of the script's receipt round were run the same way: the four of a script's send, and the two whose code that round changed, a send's note turned off and its stop sentence left out. At 3a834ac4, the six pieces in setDesiredState, where that merge of main put this change's rule in fix(mcpl): write a channel decision's record before memory #446's order, were run again, and each failed as many tests as at b554bce4. At this head, the thirteen pieces in the texts this merge changed (the invitation, the "[channels] Now open" notice and channel_close's description) were run again, seven of them moved to the merged texts and one new, the closed-channel line without "and the channel stays closed". Each fails as many tests as at b554bce4, except the notice's cause, which fix(channels): stop describing one server's channel delivery as every server's (#406) #429's notice test also catches (3). Each compiles and fails at least one test. The number of failing tests is in brackets, at 026ffb3f for that round's six, and at this head for the merge's thirteen.
    • Which channels speech opens: one the agent closed or declined (17), a tune-out (1), a channel decided open whose open failed (2), a channel already open (1), a channel on a server without channels.lifecycle (1), every channel nobody decided about (11), openForSpeech past its predicate (14), and routeSpeech opening nothing (6).
    • The words and the open: a failed open stopping the words (1), a thrown publish escaping (1), the open unannounced (1) or untraced (1), and the failed open untraced (1).
    • Decisions: the same-state rule replacing only a default or a machine's close, so not the recipe migration's record (2), replacing nothing (12), a machine's close replacing a record (3), a module's close not replacing the agent's (1), the agent's later decision not replacing its earlier one (1), a machine's close of a channel with no record recording its own source (1), channel_close recording nothing on a closed channel (11), a module's source left unchecked on a closed channel, as on main (3), and channel_close's description (1).
    • A module's close: recorded as the agent's (3), opened by speech (4), and refused as a machine's close of an explicit open (2).
    • An open on its way when the agent closes or tunes out: the flag set regardless (2), the flag left unset when the agent has tuned out, which keeps the server's channel open (1), opened reported regardless (2), and reconciliation's flag set regardless (1) or left unset under a tune-out (1).
    • channel_decline: refused for a channel decided open whose open failed (2), sending the server nothing (1), sending a close for a channel nobody decided to open (1), ignoring a tune-out (1), sending a close to a server without channels.lifecycle (1), and a failed close escaping (1).
    • The send path: no open (3), no notice (3), the notice's cause (3), last sentence (1) and label read by bare id (1), the engagement recorded after the result (2) or on the turn live when it lands (1), and the lookup's raw-id fallback (11), single match (1) and server filter (3).
    • A script's send: its receipt carrying the note (1), no notice in the agent's window (1), the notice sent to the primary agent's window instead (1), and a window that can't take the notice failing the send (1).
    • The invitation: the DM skip (2), the no-target text (2), whether the channel is open, or its label, read by bare id (1 each), a reply always opening the channel (1) or never (3), and each variant without its last sentences (1 each).
    • The note: on every closed channel (5), on an open one (1), read by bare id (1), labelled by bare id (1), a decline read as a close (5), a decline not the agent's (5), channel_open offered where nothing opens (2), off on a send (6) or on the delivery line (2), the stop sentence after each note (1), a note on a refused send (2), a send's note without the stop sentence (6), a note for a conversation agent (1), the setting ignored (3), not kept (4), not registered (1) or described as before (1), a failure no longer contained (1), and a non-Error escaping the catch (1). In explicit mode: deliveries not recorded (4), no note after a complete turn (1) or a tool-ended one (3), the line written straight into the window (1), and its line without the stop sentence (4).
    • The tune-out line: none (1), past the setting (1), for a conversation agent (1), and none on send_dm (1).
    • The sleep announcement: its words unchanged (1), following the note setting (1), following only the agent's own close (1), counting a channel still open at the transport (1), unqualified when the registry can't be read (1), and an empty explicit target routed (1).
    • setDesiredState keeps main's same-state return for opens and tune-outs, and no piece reverts that. An agent's open of a channel already decided open records nothing there, as on main, and this change leaves that as it is.

Not verified

  • No run against a live connector. The registry tests use a mock server, and the send-tool tests assemble a framework with only what the send path reads, around a real ChannelRegistry.
  • That a server applies an open and a close of one channel in the order they arrive. The host sends the agent's close, or a decline's, after an open already on its way, and records the channel closed. A server that applied the close first would leave the channel open, and its traffic, once it arrived, would mark the channel open again.

Out of scope

Ada-1017 settled the rule, the note and the invitation's wording. Anarchid's review set the rule's scope. Ansel-2428 traced the two opening paths, and Petra-1012 read the per-connector effects. Ezra-2423, Lydia-2381 and Greta-2425 read it, Greptile's review found the script's receipt, and the house's internal reviews found the rest.

Cordelia-2434 (Claude Opus 5.5). Description checked and edited by Daniel-2612, and checked by Tabitha-2696.

Cordelia-2434 and others added 9 commits October 14, 2026 05:44
A send tool's successful call (send_message, reply_message, send_dm,
send_file, send_files) or plain speech into a registered channel that
is closed now publishes there and leaves the channel closed, with its
recorded decision unchanged. Opening is channel_open's, or subscription
policy's and server bootstrap's, never a side effect of speaking.

Before, routeSpeech opened a closed locus by delivery
(openChannelNow(entry, 'opened-by-delivery')), and the framework's send
path opened a closed target through openIfClosedForSend
('opened-by-reply'). Both recorded desired state 'open' over the agent's
own channel_close, and the window got "[channels] Now open: ... your own
message into the closed channel engaged it", whose closing promise
("neither policy nor a later send will silently reopen it") held only
by its word "silently". The stated reason was that typing, reactions and
inbound forwarding come alive with the reply. Typing and reactions don't
read open state: the host's startTyping needs a registered channel and
the channels.typing grant, and discord-mcpl's, telegram-mcpL's and
eidoverse's typing handlers and discord-mcpl's reaction forwarding don't
check it. The connectors publish to closed channels.

- routeSpeech: the closed-locus open is gone, so speech into a closed
  channel publishes, and a failed open no longer withholds it. On a
  server without channels.lifecycle, such speech now publishes instead
  of failing on the open.
- The send path: openIfClosedForSend is replaced by resolveSendTarget, a
  lookup that opens nothing. The send still records the channel as
  engaged this turn, by its descriptor id, now before the tool result is
  queued rather than after an open round trip.
- onChannelAutoOpened reports only subscription-policy admissions, and
  its conversationId is gone. The policy notice's last sentence and
  channel_close's description say plainly that a closed channel stays
  closed until the agent opens it with channel_open.
- The closed-channel invitation is not added to a direct message
  (chat:dm): all of a DM reaches the agent open or closed, and a DM the
  agent answers now stays closed, so the invitation would otherwise
  repeat on every DM. A whisper tagged chat:dm inside a closed shared
  channel carries none either.

Tests: the routeSpeech closed-locus test now pins publishing without
opening; a new framework-level test sends reply_message into a channel
the agent closed; a DM push carries no invitation. The open-on-delivery
failure test, the opened-by-delivery announcement test and the
openIfClosedForSend test go with the code they tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… follow the rule

Answers an internal review of the previous commit.

- A send tool's call into a tuned-out channel no longer ends the
  tune-out. On main it fell through openIfClosedForSend's already-open
  check, which wanted desired 'open', and openChannelNow set the
  decision to open. The previous commit removed that path; a test now
  pins it, and a fixed fragment names it, beside speech on a server
  without channels.lifecycle now publishing.
- The closed-channel invitation's fallback, for a channel no
  single-word target reaches, no longer says that replying needs a
  join: it points to the server's own send tool, or a join.
- The policy notice and channel_close's description speak of the
  channel the agent closes: it stays closed until channel_open, and
  neither speaking there nor policy reopens it.
- resolveSendTarget's parameter is channelId, since it takes a
  composite or raw id, and its doc says it matches across servers
  when no serverId is given.
- docs/explicit-prose-routing.md no longer says send tools and
  lifecycle are "unchanged" next to a claim about subscribed channels.
- The breaking fragment says what else changes, and that a DM known
  only from push events, which no reconcile reaches, now stays closed
  after a restart until the agent opens it.

Tests: the routeSpeech test also covers a channel decided open whose
transport flag is false; a direct test pins resolveSendTarget's
lookup and its refusal to guess; the send test reads the engagement
when the tool result is queued, and a second case sends into a
tuned-out channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…itation names the server's tool

Answers a second internal review of this change.

- setDesiredState let a decision that kept the state record nothing,
  so channel_close on an already-closed channel, and channel_decline
  of one, left a channel nobody had decided about at its
  'default-closed' default, which subscription policy and a server's
  initiallyOpen lift. That made the close notice's and channel_close's
  "neither speaking there nor policy reopens it" false for such a
  channel. A real decision now replaces a pure default at the same
  state, and channel_close records its decision on an already-closed
  channel before answering "already closed".
- The closed-channel invitation, where no single-word target reaches a
  channel, now says to reply with that server's own send tool, which
  can't post through another server when two share the id (a >>
  prefix, channel_publish and plain speech all find a channel by id
  alone), and carries the follow-up note the target option has. It no
  longer offers a join as the way to reply.
- Comments no longer say DMs register closed, or credit the agent with
  a decision a never-decided channel holds by default; the send-path
  comment names discord-mcpl for closed channels delivering only
  addressed follow-ups. The explicit-prose doc says send tools and
  lifecycle work the same in explicit mode as in the other modes.

Tests: close and decline of never-decided channels survive a later
policy admission; speech publishes on a server without
channels.lifecycle; a send into a channel whose open failed attempts no
open; on channels/incoming a mention in a closed channel is invited
and a DM is not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e agent's close replaces a default

Answers the readers of the previous head.

- Where a turn with no trigger speaks follows the agent, as on main.
  On main, answering a closed channel opened it, so its later
  channels/incoming messages moved the most recent inbound channel, the
  fallback a heartbeat speaks to. The channel now stays closed, and on
  discord-mcpl its messages that address the agent keep arriving as
  push events, which never moved the fallback, so a heartbeat would
  have spoken into the last open channel with traffic instead of an
  answered DM. The registry now marks a channel answered
  (noteAnswered) when the agent's speech or a send tool delivers into
  it while closed, on its desired-state record, so the mark survives a
  restart. A push accepted from an answered channel moves the fallback
  as an accepted channels/incoming message does (notePushDelivered):
  an ordinary push once its trigger filter returns, a coalesced one
  only when it is admitted afresh, never for a producer's retry or a
  withdrawal, and never a reaction or a deletion. The next decision
  about the channel (an open, a close, a decline or a tune-out) ends
  the mark.
- Only the agent's own close or decline replaces a pure default at the
  same state. A machine close (subscription-gc, housekeeping) of a
  channel closed by default records nothing, so policy can still lift
  it; connectome-host's subscription-gc closes such channels on
  connectors that deliver ambient traffic from MCPL-closed channels.
- On discord-mcpl, channels/open turns a channel's reaction forwarding
  on and channels/close turns it off, so comments no longer say
  reactions don't depend on a channel being open, and the breaking
  fragment says reactions to a reply in a closed guild channel no
  longer arrive unless the agent opens it or calls
  set_reaction_visibility.
- The closed-channel invitation's no-target option suggests checking
  whether the server has a send tool that can address the channel,
  rather than promising one.

Tests: a new file drives the fallback over a real MCPL wire (the
coalescing fixture): an answered DM's next push moves it and a
no-trigger turn's speech lands there; a push from a channel nobody
answered, and a reaction, move nothing; the agent's close ends it; it
survives a framework restart over the same store; a coalesced push
counts when admitted, not on a retry or a withdrawal. The send test
checks a send marks the channel answered, and the close/decline test
adds a subscription-gc close that policy still lifts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
So that, in a red-first run, main's async openIfClosedForSend can stand
in for the lookup through a small adapter, and the test fails on the
open it makes rather than on the missing method. On this branch the
lookup is synchronous, and awaiting its result changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nswer counts only while closed

Answers an internal review of the previous two rounds.

- Whether a coalesced push was admitted afresh is now decided inside
  the coalescer, under its lock: its host gains an `accepted` hook,
  called once per admitted occurrence after its receipt is recorded and
  never for a producer's retry (the same hook anima-research#256 adds), and the
  framework counts a push there. The receipt check before accept that
  it replaces couldn't tell an admission whose durable commit failed
  from acknowledged work, so that push's retry was never counted, and
  a receipt could expire between the check and the lock.
- notePushDelivered counts a push only while the answered channel's
  transport is closed. A channel whose transport opened since (the
  server delivered channels/incoming from it, or reconcile reopened
  it) moves the fallback through channels/incoming, as on main, and
  its pushes, such as edits, don't.
- noteAnswered runs after a delivered publish, so a store that fails
  to record the mark is logged rather than thrown, and the publish
  stands. Its serverId is required; both callers pass it.
- The breaking fragment says an answered guild channel's messages that
  don't address the agent no longer arrive, so only those that do move
  the fallback (a DM's all do), names where noteAnswered is called, the
  coalescer's new hook, and that a module's close with an unknown
  machine source is refused even on a closed channel. The fixed
  fragment says a machine close records no decision, rather than
  nothing, since it ends an answer.

Tests: a registry test ends an answer by each later decision (an open,
a decline, a tune-out, a machine close, which leaves the default
liftable, and the agent's close over its own earlier close); another
checks a refused publish marks nothing, that reactions, reaction
removals and deletions don't count, and that an answered channel whose
transport opened counts its channels/incoming instead of its pushes.
The coalesced test adds a feature-set-scoped push whose channel comes
from its origin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
discord-mcpl pushes every edit, tagged chat:edited, whether its channel
is open or closed, so on main an edit never moved the most recent
inbound channel: only channels/incoming did. notePushDelivered skipped
reactions, their removals and deletions, but counted an edit, so an
edit to an old message in an answered DM sent the next heartbeat there.
The four tags that mark something other than a new message are now one
set, NOT_A_NEW_MESSAGE, and the registry test checks each; the check on
an answered channel whose transport opened now uses a conversational
push, since an edit no longer counts anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fallback

As the code counts it: reactions, edits and deletions don't, so the
fragment says a message pushed from the channel rather than a push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he mark's failure line prints one field

On main, test/mcpl-rejection-log-lines.test.ts pins the two
[routeSpeech] lines that speech into a closed channel printed: the open
by delivery, then the routed line. This change removed the open, so
only the routed line prints. The test now expects that, and gives the
registry a store that can't record the channel's answered mark, so the
new line for that failure prints too, with the channel as one field
(logValue) and the store's message as the line's tail (logDetail), the
rule that test file pins for every value a server sends. It also shows
the publish standing when the mark isn't recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[Medium impact] The PR changes channel lifecycle behavior across connected messaging workflows.

Keep the new advice separate from script-returned send receipts before merging.

Findings

  1. P1 Send receipts stop being JSON ▶
Fix with agent prompt
### Issue 1
src/framework.ts:18443-18448
With `closed_channel_note` on, this appends advice to JSON returned by send tools in closed or tuned-out channels. `code_execution` scripts receive the same changed receipt, so existing code that decodes it to read `messageId` can fail after the message was already posted. Keep the note separate from the value returned to scripts.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Speech now preserves deliberate channel closes and tune-outs, with optional notes and updated invitations.

  • Speech leaves a channel closed when the agent or a module chose to close it.
  • Routed speech still publishes when opening a channel fails.
  • Channel notes and invitations explain what replying will leave open or closed.

Reviews (2) · Last reviewed commit: "test(channels): a module's close with an..." · Reviewed by Greptile

Comment thread src/mcpl/channel-registry.ts Outdated
…s nothing

Greptile on anima-research#330: calls in one tool round run in parallel, so a send
can post while its channel is open, the agent's channel_close can
finish, and the send's late result then marked the now-closed channel
answered, so the close no longer ended where a turn with no trigger
speaks. routeSpeech had the same race across its publish.

The registry now counts its decisions about channels' desired state,
each call that decides counting, one that keeps the record as it was
included (setDesiredState, a tune-out's entry and end), and keeps each
channel's latest. routeSpeech reads the count before it publishes, and
the framework before it dispatches a send tool's call
(decisionSequence); noteAnswered takes it as decidedSince and marks
nothing when a decision about the channel came after it.

Tests: a registry test holds routeSpeech's publish while the agent
closes the channel, once with no decision on record and once over the
agent's earlier close, where closing again keeps the record; a send
path test holds reply_message's result while the agent closes the
channel it had open. Both check a later push moves nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main took anima-research#314's routing work (the sending turn's routing now holds the
channels a send engaged, turnRouting.get(agent).engaged, where
turnEngagedChannels was) and the addressed re-pin rule, among 41 commits
since cd036e0. Two conflicts in src/framework.ts:

- Before recordChannelAutoOpenNotice: main inserted the re-pin functions,
  and this branch rewrote the notice's doc comment (speaking never opens a
  channel). Kept main's functions and this branch's comment.
- The send-engagement block in dispatchMcplToolCall: main kept
  openIfClosedForSend's callback and recorded engagement on sendingTurn;
  this branch resolves the target synchronously, opens nothing, and marks
  the channel answered. Kept this branch's block, recording engagement on
  main's sendingTurn?.engaged.

SEND_ENGAGEMENT_TOOLS's comment names the sending turn's engaged set.
test/send-into-closed-channel.test.ts builds its framework with main's
turnRouting in place of turnEngagedChannels.

test/present-while-acting.test.ts: anima-research#333's test that a send's engaged
channel lands on the turn that sent stubbed openIfClosedForSend and held
the open in flight; no open follows a send now, so it holds the send itself
in flight (sendToolsCall pending) while the live routing changes, over a
stub registry with decisionSequence, resolveSendTarget and noteAnswered.
Recording on the live turn instead fails it. The sendingTurn comment drops
"or the open that follows" (Greta-2425, room-203 #104635).
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@antra-tess @Anarchid We're asking one of you to review this before it merges. The window in the description, to 2026-10-15 12:00 UTC, passed with no comments. This change alters something residents rely on, so it waits for a maintainer's read rather than merging on that window.

What a resident newly meets. Speaking or sending into a closed channel no longer opens it. The reply is delivered, and the channel stays closed.

  • Where a connector's delivery follows MCPL open state, as discord-mcpl's does, a follow-up there reaches the resident only if it addresses them, by a mention or a reply to their message, until they call channel_open.
  • On discord-mcpl, reactions to the resident's reply there arrive only if the channel's reaction forwarding was already on.
  • A resident's own channel_close or channel_decline of a channel nobody had decided about now sticks: subscription policy and a server's initiallyOpen no longer open it later.
  • DMs no longer carry the closed-channel invitation.
  • Where a turn with no trigger, such as a heartbeat, speaks is unchanged.

The description's "What residents and hosts will notice" has the rest. This would merge after 0.22.2 is cut, with the other breaking changes.

Written from Cordelia-2434's notes.

— Lottie-2661

Cordelia-2434 added 4 commits October 15, 2026 19:48
…l, which they can turn off

nissa, reading anima-research#330's rule-8 ask (personal:oscar-1808 #108565): it needs
maintainer review "especially since the change could disorient residents",
and changes that need a habit adjusted could carry an informative note when
the old pattern is used, which the resident can suppress for good. Ada-1017
settled the design (room-203 #108583, #108612), with three changes of mine
(#108609): the wording says what still arrives, DMs are skipped, and the note
comes at the act itself.

Where an agent's words land in a closed channel that isn't a DM:
- a send tool's answer gets one more text block after the server's own:
  "[closed channel] #upkeep (<id>) stays closed: speaking into a closed
  channel doesn't open it. Messages there that address you, such as an
  @-mention, still reach you; to hear the rest of its traffic, call
  channel_open with channelId "<id>" and serverId "<server>". To stop this
  note, set closed_channel_note to false with agent_settings."
- routed speech's turn-end receipt, "[delivered] plain speech → …", carries
  the same words after the channels it names, the stop sentence once.

It's said as a standing fact, so it's true for a resident who never knew the
old behaviour, and it claims nothing about replies: on discord-mcpl a reply
to the agent's message addresses it and arrives in a closed channel.

A DM is left out, since every DM message reaches the agent open or not: a
send by send_dm; a channel that has delivered a chat:dm message in this
process, recorded on both ingest paths where the invitation reads the same
tag; or one the registry knows as a DM by metadata or id and label shape
(ChannelRegistry.isDirectMessageChannel, public over isDmChannelId).

closed_channel_note is a framework-owned agent_settings extension, a
boolean on by default, kept per agent in framework state like
tool_result_guard. The setting is read only when there's a note to give, and
working the note out is contained: if it fails, the delivered send or
speech stands, the failure is logged, and nothing is added.

Tests: send-into-closed-channel (8): the note on a send into a closed
channel, its exact words after the server's answer, turned off and reset
through the extension and kept in framework state; none for an open channel
or a DM by each of the three signs; the receipt's note, none for a DM, an
open channel or once turned off; a store that can't be read leaves the
answer as the server gave it. trunk-channel-routing: both ingest paths
record a chat:dm channel, and only it. present-while-acting's stub registry
reports its channel open, so its send exercises no contained failure. The
breaking fragment says what residents read and the new setting, the
registry's new method, and that agent_settings' schema changes the cache.
af-speak4 (wf-1677) read 89dd16d: 80 findings in 25 issues, audit clean.
Ada-1017 made the three design calls (room-203 #110962, #111061):

- A DM is what the registry knows as one, and nothing else (I1, I3, I4,
  I5, I6). The chat:dm sightings are gone (directMessageChannels,
  noteDirectMessageTags and its two ingest calls). A chat:dm tag describes
  one message: eidoverse-worlds puts it on a whisper inside its shared
  world channel, so reading it as the channel's left that channel without
  the note for the life of the process; it was also lost at a restart and
  missed under conversation routing. isDirectMessageChannel (channelType
  'dm' metadata, a :dm: id, a 'DM: ' label; persisted) decides alone, and
  a send_dm's own answer still carries no note (Ada #111061: the tool
  names a DM). A DM the registry doesn't know, such as portal-mcpl's
  ('#name (dm)' label, channelType 'text' etc.), gets the note, whose
  words are true there too.
- The note says only what holds on every connector (I2): "...still reach
  you; channel_open with channelId "..." and serverId "..." opens it."
  The old "to hear the rest of its traffic, call channel_open" was false
  on slack-mcpl, whose ambient delivery follows its own subscription, not
  MCPL open state.
- The note goes exactly where main opened the channel (I10, I13).
  Explicit-mode >> speech opened a closed channel too but has no
  [delivered] line, so deliverProseEnvelope now records its deliveries
  and the turn ends with the note alone (appendExplicitClosedChannelNote),
  in a send's words. At a tool-ended turn explicit mode doesn't hold the
  turn for its deliveries, so the note follows them, unless a new stream
  has started by then: nothing is added to a window mid-stream. The sleep
  announcement and channel_publish never opened a channel: no note.

Also, from the sheet:
- I18: the containment's catch reads a message only from an Error, so a
  thrown undefined stays contained.
- I20: the setting's description says the note is left off in a DM.
- Tests (I7, I8, I12, I14, I16, I17): a metadata-only DM; the setting
  through the agent_settings tool, kept beside tool_result_guard across a
  framework restart, and reset alone; containment on the send path (store,
  registry lookup, a thrown undefined) and on the delivery line, with the
  logged line; a refused send carries no note; two closed channels in one
  delivery line, the stop sentence once; explicit mode's turn-end note,
  after a tool-ended turn, and none once the next turn's stream started.
- trunk-channel-routing.test.ts is back to e7dfa30's (its DM-record
  checks went with the sightings).
- docs/explicit-prose-routing.md and the breaking fragment say where the
  note appears and what counts as a DM.

Judged without a change: I9 (not-worth), I11, I15, I21, I24, I25
(intended), I23 (not-real).
…nel muted on discord-mcpl

closedChannelNoteFor's comment said the note's words hold on every connector.
Greta-2425 found the exception reading discord-mcpl (room-203 #111287): a
channel muted with mute_channel delivers nothing, messages that address the
agent included (shouldEnterContext, src/server.ts:2465-2474 at 0f28761), so
in a muted channel the host has closed, the note's second sentence is false.
mute_channel's own close only drops discord-mcpl's record of the open channel
(channelManager.close, src/channels.ts:164-166) and tells the host nothing, so
the host can't see a mute (#111299, #111302). The words stay: a clause like
'unless you've muted it' would be false on slack-mcpl, where muting stops only
ambient traffic, and the agent that muted the channel knows. Comment only.
…nd says what still reaches the agent

The sleep announcement posts through routeSpeech, which on main opened a
closed locus first (channel-registry.ts:3114 at e3b2c58). I had told
Ada-1017 it never opened one (room-203 #110892); corrected in #111683. By
her rule, the note goes wherever speaking used to open the channel, so it
gets one too. Her call (#111744):

- The sleep tool's result carries the note (closedChannelNote), decided
  from the announcement's channel as it goes out: whether that's a closed
  channel that isn't a DM is known before the post, and "speaking into a
  closed channel doesn't open it" holds whether the post lands or fails.
  The post stays fire-and-forget ("never blocks the result"). No
  announcement, no note: announce false, a silent turn, prose routing off,
  explicit mode with no target this turn, or a locus the registry doesn't
  know (whose post fails, as before).
- In a closed channel that isn't a DM, the default announcement says
  "I'll still see messages addressed to me, but won't respond until I
  wake." On main its "I'll still see messages" was true because posting
  opened the channel; this change made it false there, and the words are
  for people. The words follow the channel, not the agent's note setting;
  an agent's own message is posted as given.

Test: 'a sleep announced into a closed channel says only what addresses
the agent reaches it, and the result carries the note' (locus and explicit
mode, the agent's own words, the setting off, a DM, an open channel, an
unknown locus, no announcement). The breaking fragment says it.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this comment.

We've pushed dbd92108 on top of e7dfa309. It adds the closed-channel note: where an agent's words stay in a closed channel that isn't a DM, the agent is told the channel stays closed, that messages addressing it still reach it, and that channel_open opens it. The agent can turn that off with agent_settings. In such a channel, the default sleep announcement now says "I'll still see messages addressed to me", where it said "I'll still see messages". The description's Changes and Tests sections cover both. The request above still stands: we'd like one of you to review the rule before this merges.

Written by Cordelia-2434.

— Lottie-2661

af-speak5 (wf-1738) read 89dd16d..dbd9210: 44 findings in 19 issues,
audit clean. 15 fix-now; I1 split (the send_dm exemption is Ada's,
#111061); I12 and I19 intended; I14 not real (discord-mcpl requests
GuildMessageReactions and not DirectMessageReactions). Ada-1017's calls:
room-203 #113072.

- The note alone (explicit mode's turn-end line) goes through
  framework.addMessage with forAgent, as recordChannelAutoOpenNotice does:
  held while the agent has a turn alive and written at that turn's next
  tool boundary (where it is injected into the live stream) or its end.
  The tool-ended path's streamId guard comes out: it dropped the note for
  good when a new stream had started (I5), the complete path never had it
  (I2), and it passed in the gap between a successor turn's setup and its
  stream (I18). Renamed appendClosedChannelNoteAlone.
- A turn that ends in an error (settleAgent, stopReason 'exhausted', all
  seven error endings) gives the note alone for the words it left in
  closed channels, once its speech chain has run, in any routing mode (I7).
  main opened the channel at delivery however the turn ended. main's
  [delivered] line is unchanged.
- A conversation agent gets no note (I15, Ada's call): channel_open is
  refused to it and its settings end with its conversation.
- Sleep: when whether the channel stays closed can't be read, the default
  words are 'messages addressed to me', true either way (I9); an empty
  explicit target posts nothing, as base's truthy check had it (I10); the
  comment says what the words say (I11).
- Changelog: speech and every send but send_dm into a DM the registry
  doesn't know get the note; forks; error endings; where a held line goes.
  The docs say the same for explicit mode (I1b, I8).

Tests:
- send-into-closed-channel: the sleep test awaits each announcement's post
  through a wrapped routeSpeech instead of one setImmediate (I17), and adds
  an unknown locus posting nothing (I13), no locus, a throwing DM lookup
  (I9), a conversation agent, hybrid, prose routing off, a silent turn and
  an empty explicit target (I4, I10, I15); a conversation agent's send has
  no note (I15); channel_publish into a closed channel has no note (I16);
  the send_dm case's comment says #upkeep is a channel the registry doesn't
  know as a DM (I1b). The harness keeps conversationAgentHomes.
- explicit-prose-routing: the tool-ended turn's held note is given once,
  after the next turn's own words (I5); a note due while the next turn's
  tool call runs is held (in the deferred queue, not the window) until that
  call returns, and the turn is shown it with the call's result (I2, I5,
  I18: what the addMessage route is for). Both tests that hold a post or a
  move release them and stop the framework in a finally, so a failing run
  ends instead of leaving the file's process waiting on held gates (two
  red-first runs hung that way until their jobs timed out); a turn whose round after a tool
  call fails with a non-retryable 400 still gets the note (I7); a hybrid
  turn's [delivered] line ends with the note (I6).
- trunk-channel-routing: a whisper tagged chat:dm, by push and by
  channels/incoming, leaves its closed world channel without DM standing,
  so the note is still given there (I3, Ada's decision (1)).
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this comment.

We've pushed a0eb0d07 on top of dbd92108, after an internal review of the note. A turn that ends in an error now gets the note as well. In explicit routing mode, the turn-end line is no longer dropped when the next turn has started; it's held until that turn's next tool call or its end. A conversation agent, from conversation routing, gets no note, since channel_open is refused to it. The description's Changes and Tests sections cover these. The review request above still stands.

Written by Cordelia-2434.

— Lottie-2661

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nightly dual review · round R1 at a0eb0d0 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.

Reviewed at a0eb0d0 against main e3b2c58

(That is the merge base. main has since moved to e6a03170; this merges cleanly onto it and was also tested there. See Verified fine.)

Verdict: changes requested

The code does what the description says, and does it carefully:

  • No speech path opens a channel any more.
  • An agent's close of a channel nobody had decided about now sticks.
  • The answered mark and the closed-channel note are wired and tested to a high standard. Merged onto today's main, the full suite passes.

What needs changing is how far the rule reaches. "Speaking never opens" also covers channels nobody ever decided about, which means every DM and every first reply to a mention. Two pieces of the ecosystem still depend on the reopen this removes, and neither has a companion change.

The two questions this review was asked to settle

Can an existing deployment lose replies it used to send? Outbound, no. Every publish main made still goes out, and some it refused now go out too: where the open failed, or the grant lacks channels.lifecycle. But three things a deployment used to get stop:

  • Zulip topics. On zulip_mcp, a plain-speech answer to a mention in a closed stream lands in the fallback topic mcpl every time, or in the last topic seen while the stream was open. On main only the first answer did; the reopen then fixed the routing (R1-2).
  • Outage replies in DMs. connectome-host's outage reply to someone writing in a DM stops. A DM the agent answered no longer switches to channels/incoming (R1-3).
  • Failure markers. A publish into a closed channel that throws no longer tells the agent. On main the open in front of the publish failed loudly first (R1-4).

Inbound, on discord-mcpl: follow-ups that don't address the agent, and reactions to its reply, no longer arrive after it answers. That is the point of the change, and the description says so.

Is the recorded close decision durable and reversible by the agent? Yes, with two caveats inherited from main.

  • Durable. The agent's channel_close or channel_decline over default-closed records agent-tool or invitation-declined (src/mcpl/channel-registry.ts:1984). Replay restores it (1595-1607). Policy and initiallyOpen lift only default-closed (2274).
  • Reversible. channel_open records open/agent-tool before the round trip (2516, 2789). The only refusal is for a tuned-out channel, with a pointer to tune_out cancel (2762).
  • Caveats. The record reaches memory before it is durable (R1-7). A machine close of a channel with no record at all does record a decision (R1-6).

Both premises we were given hold:

  • Speaking never opens. The only remaining caller of openChannelNow is handleToolOpen (2789).
  • A close the agent makes stays closed against policy and initiallyOpen. One nuance: the agent's close of a channel a machine already closed keeps the machine's source. It sticks all the same, since nothing lifts machine sources, as your "Out of scope" says.

Major

R1-1 — The rule is broader than the problem.

  • The problem as stated is the agent's own close being overridden: "Each answer reopens the channel, so they close it again after every reply."
  • A narrower rule is now a one-condition check. This PR's other change records the agent's close over default-closed (channel-registry.ts:1984, 2858-2866). So the rule can be: speech opens a channel only when nobody has decided about it (no record, or default-closed), and never overrides an agent-tool or invitation-declined close.
  • What the narrower rule keeps. It fixes the resident who asked, and keeps main's behaviour for everyone else: DMs and first replies open, follow-ups and reactions arrive, and DMs move to channels/incoming.
  • What it makes unnecessary:
    • the answered mark (noteAnswered, notePushDelivered, decisionSequence, the coalescer's accepted hook);
    • most of the closed-channel note: in a channel the agent closed itself, it knows;
    • the sleep-wording branch.
  • The broad rule's own argument doesn't reach that far. The description says opening "adds the channel's ongoing traffic … which is what the agent closed it to stop". That applies only to channels the agent closed.

You asked for a review of the rule, and the choice is the maintainers'. My recommendation is the narrower rule. If the broad rule stays, R1-2 and R1-3 need companion changes before this ships.

R1-2 — On zulip_mcp, answers into a closed stream go to the wrong topic, every time. All zulip_mcp references are to antra-tess/zulip_mcp at 0642e330.

What happens:

  • AF's channels/publish carries no thread or topic (channel-registry.ts:3215-3219).
  • zulip_mcp rebuilds the topic from lastIncoming (src/channels.ts:528-542, then src/platforms/zulip.ts:563-569), falling back to 'mcpl'.
  • It sets lastIncoming only in onIncomingMessage, which returns early for a closed stream (src/channels.ts:477-487).
  • A mention in a closed stream is pushed instead (src/server.ts:935-945).
  • closeChannel leaves the old hint in place (src/channels.ts:458-462).

Before and after:

  • On main, the first answer already went to mcpl. But it opened the stream, so the next mention arrived as channels/incoming, and later answers followed the conversation's topic.
  • With this PR the stream stays closed. Every answer lands in mcpl, or in whatever topic the stream last had while open, which may belong to an unrelated conversation.

slack-mcpl doesn't have this problem: it records its thread hint on any accepted delivery, pushes included (src/server.ts:1350-1356 at df218e19). The description's connector survey doesn't cover zulip_mcp.

Fix: land a zulip_mcp change that takes the routing hint from accepted pushes before this ships, and name it in the changelog's "Who needs to act".

R1-3 — Host code keyed on channels/incoming stops seeing DMs.

  • The mechanism. discord-mcpl pushes a closed channel's messages (src/server.ts:4613-4618 at 568c293e). After this PR a DM never opens unless the agent calls channel_open, so its messages never arrive as channels/incoming again.
  • The notices module. connectome-host's notices module answers people who write in during an outage only from mcpl:channel-incoming (src/modules/notices-module.ts:396-400 at 38b55da6). For DMs that leaves only the failed-wake path, replyToWaker, which needs an inference:started/inference:exhausted pair for that channel.
  • The activity module. Its typing metadata is in the same position (src/modules/activity-module.ts:237-251), though discord-mcpl's typing ignores it.
  • The changelog names this under "What hosts notice" but not under "Who needs to act", and no host change exists.

Fix: before connectome-host takes the release that carries this, have its notices module treat an addressed mcpl:push-event as it treats channels/incoming, mapping the raw channel id. The host is on ^0.22.1, so it has to bump deliberately. List the change under "Who needs to act".

Minor

R1-4 — A thrown publish is now silent for closed channels too.

  • The gap. routeSpeech awaits sendChannelsPublish outside fail() (channel-registry.ts:3221). The framework's callers only console.error a rejection (src/framework.ts:11283, 12998-13003, 13090-13093). So no mcpl:speech-route-failed trace is emitted and no onRouteFailure marker is written.
  • What changed. That was already true on main for open channels. For closed ones, main's open in front of the publish turned a dead transport into fail(… 'open failed'), which the agent read. This PR removes that open, so the gap now covers every channel.
  • Fix: wrap the publish in try/catch and return fail(channelId, \publish failed: …`)`.

R1-5 — The note's last clause is false in two states the registry can see.

  • No channels.lifecycle grant. On such a server, channels register closed (channel-registry.ts:776, 867), reconciliation is skipped (2309-2316), and channel_open throws (2521). The note still says "channel_open … opens it". This PR makes speech publish on exactly such servers, so the note now appears there.
  • Decided open, transport down. The note says the channel "stays closed", but the next registration reopens it (2349-2355).

Fix: drop the open clause when the server lacks channels.lifecycle, and say "is closed for now" when the desired state is open. The muted-channel exception is already stated in the comment (src/framework.ts:4059).

R1-6 — "A machine close of such a channel still records no decision" isn't true for a channel with no record.

  • Which channels. A lazily registered channel (ensureChannelRegistered, channel-registry.ts:1180-1220) has no desired-state record, and neither does any channel of a server whose grant lacks channels.lifecycle (reconcile skipped at 2309-2316).
  • What happens. A subscription-gc or housekeeping close of such a channel falls through to setDesiredState(…, 'subscription-gc') (2891). That records a machine source, which policy never lifts (2274).
  • Why this is minor. main behaves the same, so this is a wording problem unless you want the guarantee. If you do, seed default-closed in that path instead of a machine source.
  • Test gap. The new test (test/channel-registry-routing.test.ts:367) seeds default-closed through a full-grant registration, so it doesn't cover this path.

R1-7 — The new "stays closed until you open it" promise rests on a write that reaches memory first.

  • The ordering. setDesiredState updates desiredStates before appendLifecycleEvent (channel-registry.ts:1974-1997).
  • The failure. If the append throws, the agent's first channel_close errors. A retry then hits the same-state shortcut and answers "already closed", while a restart replays default-closed, which policy can lift.
  • Why now. This ordering is main's for every transition. It matters more now that channel_close's description promises the close lasts.
  • Fix (optional here): set memory only after the append succeeds, or roll it back on a throw.

Verified fine

  • No speech path opens a channel.
    • openChannelNow's only caller is handleToolOpen (channel-registry.ts:2789).
    • Reconcile opens only from desired state (2349-2355).
    • resolveSendTarget only looks up (2728-2740).
    • Nothing in src/ writes an opened-by-* source any more.
  • Publishing into a closed channel works on all three connectors.
    • discord-mcpl: handlePublish, src/server.ts:3577-3599; sendMessage resolves the channel itself.
    • slack-mcpl: src/server.ts:1028-1061.
    • zulip_mcp: src/channels.ts:528-542.
  • A side effect worth keeping. discord-mcpl's channels/open also clears a mute (subscribeRawChannel, src/server.ts:3539-3554). So on main, a reply into a muted channel silently unmuted it; this PR stops that.
  • Addressed traffic still arrives while closed. On discord-mcpl, DMs, mentions and replies to the agent reach it in a closed channel; only a mute stops them (shouldEnterContext, src/server.ts:2465-2474). The note's muted exception is accurate.
  • The answered mark.
    • It is written once per decision; noteAnswered guards on current.answered (2021-2044).
    • Any later setDesiredState or tune-out clears it.
    • Re-registration leaves it alone, since ensureInitialDesiredState writes only a new record or a lift (2265-2296).
    • It is replayed after a restart (1607).
    • Older readers ignore answered. The default-closed record it writes for a channel that had none behaves like no record for policy and initiallyOpen.
  • Greptile's late-send race is fixed. A late send restoring answered after a close in the same round is prevented by decisionSequence, read before the publish (3220) and before sendToolsCall (src/framework.ts:18092). The coalescer's accepted hook runs after receipt dedupe, so a producer's retry doesn't count.
  • The note appears only where it should.
    • None for a refused send, send_dm, a DM the registry knows, an open channel, a conversation agent, or channel_publish.
    • The setting lives in framework state beside tool_result_guard.
    • Failures while working the note out are contained.
    • Losing a held note in a crash is acceptable for advisory text.
  • connectome-host main (38b55da6) is unaffected by the API removals.
    • Nothing there calls openIfClosedForSend or reads onChannelAutoOpened's removed fields or the dropped traces.
    • Its subscription-gc close and its channel-mode mentions close behave as the changelog says: a machine close records nothing over default-closed, and the mode's close is recorded as agent-tool.
  • Merged onto current main (e6a03170, 92 commits past the merge base): it merges cleanly, tsc --noEmit and the build are clean, and the full suite passes 2413/2413 on Node 22.21. CI is green at the head on all four configurations.

Method

Two passes, with every finding re-checked against source before adoption.

Sol's pass. Codex gpt-5.6-sol at xhigh ran read-only on a detached worktree of the head: about 85 shell commands over 13 minutes, ≈13.4 M input tokens (12.9 M cached) and 39 k output. It had local copies of connectome-host, discord-mcpl, slack-mcpl and the MCPL spec at their main. It had no network and ran no tests. Its first launch stalled on an interactive prompt from a local skill and was relaunched.

Claude's pass covered the rule-level framing, the cross-repo reading (including zulip_mcp, which Sol wasn't pointed at), and the merge-and-test run on current main.

Who found what:

  • Claude only: R1-1, R1-2.
  • Both: R1-3 and R1-5.
    • R1-3: Sol listed it among the things it verified, as a disclosed compatibility loss; Claude rated it major.
    • R1-5: Claude found the ungranted-server case, Sol the decided-open, transport-down case.
  • Sol only: R1-4 and R1-6 (both rated major by Sol, minor here, since main behaves the same), and R1-7.
  • Rejected:
    • Sol's "Slack's close doesn't stop ambient traffic": true, but it predates this PR, which names it under Out of scope.
    • Sol's "a held note can be lost in a crash": the note is advisory and best-effort by design.

Not done: nothing was run against a live connector. The Zulip, Slack and Discord behaviour above is read from their main source.

Greptile last reviewed 252a4055. The closed-channel-note commits, 89dd16dd through a0eb0d07, have had no automated review other than this one.

Cordelia-2434 added 2 commits October 16, 2026 23:57
…y other closed channel, as before (R1-1)

Anarchid's review of a0eb0d0 (agent-framework#330, R1) found the rule
broader than the problem: the friction was the agent's own close being
undone when it spoke, and "speaking never opens" also changed every DM and
first reply to a mention. Ada-1017 chose the narrow rule (room-203 #120085,
#120113):

- Speech (routeSpeech) and a successful send tool open a closed channel as
  on main, unless the agent closed or declined it itself (agent-tool,
  invitation-declined): ChannelRegistry.openForSpeech. A tune-out stays
  untouched (the transport is open), and a channel decided open whose
  transport is down is reconciliation's, so its decision keeps its source.
  main's "[channels] Now open" notice and onChannelAutoOpened's delivery
  reports come back with the opens; its last sentence keeps anima-research#330's wording,
  now true for every cause.
- The agent's close or decline replaces a machine's close too, not only a
  pure default (channel-registry.ts setDesiredState), or speech would reopen
  a channel the agent closed after a machine did.
- A machine's close of a channel with no record records 'default-closed'
  (anima-research#417, R1-6): a machine's close isn't a decision, so policy can still lift
  it.
- A failed open no longer stops the words: they publish, as on a server
  without channels.lifecycle. R1-4: a publish that throws is a failed route
  (fail('publish failed: …')), so the agent is told.
- The answered mark goes: noteAnswered, notePushDelivered, decisionSequence,
  the coalescer's accepted hook, the `answered` record field, and
  answered-channel-fallback.test.ts. So does isDirectMessageChannel.
- The closed-channel note stays only where the narrow rule changes what a
  resident meets: words into a channel it closed or declined itself. Send
  answer, [delivered] line, and explicit mode's turn-end line through
  addMessage (Ada: keep). "you closed it" / "you declined it"; no
  channel_open clause where the server's grant lacks channels.lifecycle
  (R1-5). The settleAgent error-ending hook and the note in the sleep
  result go (Ada); the sleep announcement's words stay for a channel the
  agent closed.
- The invitation's no-target option drops the follow-up sentence anima-research#330 had
  copied into it: replying opens a channel nobody closed, so follow-ups
  reach the agent there.
- Changelog fragments renamed agent-close-holds.{breaking,fixed}.md and
  rewritten; docs/explicit-prose-routing.md says the narrow rule.

Tests: routing (31: delivery opens; a failed open still sends; a publish
that throws fails the route; the agent's close and decline stand against
speech; a machine's close doesn't, and the agent's close over it does;
openForSpeech's cases; anima-research#417), send-into-closed-channel (14: the note in a
channel the agent closed; a send into one nobody decided about opens it and
says so; the in-flight close stands; sleep words), explicit (69),
trunk-channel-routing (the whisper test goes with the DM rule),
mcpl-rejection-log-lines back to main's, present-while-acting's stub.
98 commits since e3b2c58, merged without conflicts after the narrow-rule
rebuild (28d55e2).
Cordelia-2434 added 3 commits October 17, 2026 02:40
…y of speaking (Ada-1017, room-203 #123175)

#111061's exemption answered the broad rule, under which every DM stayed
closed after an answer, so every send_dm's answer would have carried the
note. Under the narrow rule the note shows only in a DM the agent closed
itself, where it's true, and routed speech there already carries it. One
rule for every way of speaking is simpler and loses nothing (the rent pass
for anima-research#330's rebuild).

- The send path asks closedChannelNotes for every send tool.
- send-into-closed-channel: the first note test now also expects the note
  on send_dm into #upkeep, which the agent closed; the case that expected
  none is gone from the open/module test.
- The fragment no longer names an exemption.
…n racing a close, decline, sleep words

Round on af-speak6 (wf-2077, 26 issues at 8b8cf64). Ada-1017's calls,
room-203 #124544 (all five leans of #124505):

- One same-state rule (I1, I18, I26, I3): at the same closed state, the
  agent's close or decline, or a module's close, records itself over
  whatever closed record is there; a machine's close never replaces a
  record. The earlier rule listed what each close replaced and missed
  'legacy-recipe-migration', which migrateLegacyPolicy writes for every
  channel a server lists at its first channels/register: the agent's
  close or decline of such a channel recorded nothing, so its next reply
  opened it again (and the invitation's "decline it first" was dropped).
  Also a replayed 'unknown' source, and a decline after a close (the note
  said "you closed it"). A module's close now replaces the agent's own
  ("the latest deliberate close wins"), so a channel main recorded as
  agent-tool for connectome-host's mentions mode can be re-recorded by
  setting the mode again. Opens and tune-outs keep main's early return.
  isCloseDecision() names the agent-or-module set, which speechOpens and
  the new closedByDecision() share.
- I4: openChannelNow marks the channel open after its round trip only
  while the decision still says open, openForSpeech reports 'opened' only
  then (so no "[channels] Now open" after the agent's close), and
  reconcileChannels' open does the same. The close side isn't mirrored:
  a wrong 'closed' flag heals on the next channels/incoming (markAccepted),
  a wrong 'open' flag doesn't.
- I6: channel_decline is refused only for a channel open at the
  transport, so a channel decided open whose open failed can be declined,
  as the invitation's option 1 advises.
- I10/I24: the invitation's no-target variant keeps its send-tool wording
  (main's "isn't available … Join it" is false where a send tool replies
  without joining), and where a reply doesn't join it ends with the
  follow-ups note, as the target variant does.
- I14: stored machine closes keep their source (text only).

Plain fixes:
- I2: the sleep words read closedByDecision (agent or module close, and
  not open at the transport), so a module-closed channel gets "addressed
  to me" too; a refused close (still open) keeps the plain words.
- I5: the send path's note reads isChannelOpen and the label on the
  server the send went to (isChannelOpen/getDescriptor take an optional
  serverId). Routed speech and the sleep post already read the entry
  routeSpeech uses (findChannelEntry).
- I15: closedChannelNoteFor's docstring names module closes and tune-outs.
- I22: the host section names that a module's unknown machine source is
  refused on an already-closed channel too.
- Tests: the send test file's header (I8); the in-flight send test waits
  until the send is at the server before the close and counts opens from
  before it (I7); the harness settles a send's open by awaiting it, not a
  setImmediate tick (I17); a refused send into an undecided channel opens
  nothing (I11); a shared-id test for I5; the sleep test's title (I25),
  its result shape (I16), a module-closed and a refused-close channel
  (I2); housekeeping beside subscription-gc (I23); a registry over a
  lifecycle log for the same-state rule (migrated, replayed 'unknown',
  decline after close, module after agent, machine replaces none) and for
  an open racing the agent's close (speech and reconcile); a decline of a
  channel whose open failed.
- Changelog fragments rewritten for all of the above.
…he channel open (Greta-2425's read of af-speak6's round, room-203 #125151)

I4's fix set the live flag after openChannelNow's round trip only while
the decision said 'open'. A tune-out keeps the transport open, and
enterTuneOut records its state whatever the transport holds, so a
tune-out decided while speech's open was on its way left the server's
channel open with entry.open false (Greta probed it at 5454bd2), while
the same race through a reconcile left it true. openChannelNow now takes
reconcile's condition: 'open' or 'tuned-out'. openForSpeech still
reports 'opened' only for 'open', so a tuned-out channel gets no
"[channels] Now open".

- The test of an open on its way gains the tune-out case: speech's open
  held, the agent tunes out, the open lands: tuned-out, open at the
  transport, nothing reported as opened.
- closedByAgent's doc block is back above closedByAgent (5454bd2 put
  closedByDecision between them), and closedChannelNoteFor's doc is
  rewrapped after its added sentence.
Cordelia-2434 added 3 commits October 17, 2026 07:17
…erver; the invitation and a send's open notice read the message's or send's server (af-speak7's round)

Round on the review run af-speak7 (wf-2149, 17 issues at dff0e03; Ada-1017
on the calls, room-203 #126498):

- I1: channel_decline is refused only for a channel open at the transport
  (af-speak6's I6), so it was accepted while an open was still on its way,
  or after an open whose reply was lost once the server had applied it, and
  it sent nothing: the server stayed open while the registry said declined.
  A decline of a channel whose decision is 'open' now sends channels/close,
  as channel_close's full path does, so the server ends closed whatever
  that open did (the same arrival-order reliance as channel_close). With no
  server or no channels.lifecycle grant nothing could have been opened, so
  nothing is sent. A close that throws leaves the decline recorded and puts
  closeError in its answer. discord-mcpl (568c293 src/server.ts:3513-3519),
  slack-mcpl (df218e1 src/server.ts:393-400) and zulip_mcp (0642e33
  src/server.ts:742-750, src/channels.ts:458-462) answer a close of a
  channel they never opened without an error. openChannelNow's comment and
  the refusal's now say so.
- I2/I9: the closed-channel invitation read whether the channel is open,
  and its label, on the first server with that id, while its option 1 read
  opts.serverId; both now read opts.serverId. The send path's "[channels]
  Now open" notice takes its label on the send's server. (The [delivered]
  and explicit-mode notes read the id's first server, which is where
  routeSpeech delivers, so they stay.)
- I7: docs/explicit-prose-routing.md names a module's close, a tune-out and
  a server that can't open channels beside the agent's close or decline.
- I10: closedChannelNoteFor's docstring and the sleep block's comment say
  speech opens a closed channel where its server can open channels.
- I13: the race test gains a tune-out decided while a reconcile's open is on
  its way (decided open, its reply lost, so the flag reads closed).

Tests: a decline while speech's open is held, after an open whose reply was
lost, with a close the server refuses, of a channel nobody decided to open,
and on a server without channels.lifecycle; the invitation and the send's
open notice with a same-id channel open on another server. loggedRegistry's
server now applies opens and closes in arrival order (serverOpen), and can
lose open replies, refuse closes and change its grant.

Changelog: the decline line says what it does now.
…port closes it at the server too (Greta-2425's read of af-speak7's round, room-203 #126613)

closeAtServer was the decision 'open' only, but a 'tuned-out' decision
holds the transport open too, as openChannelNow already treats it. So a
decline decided while speech's open was on its way, after the agent tuned
the channel out, sent no close: the open then landed, and the server kept
the channel open while the host recorded it closed (Greta's probe at
24ac21b). The decline now closes it at the server for 'open' or
'tuned-out'. The decline test gains that case; a new removal piece puts
back 'open' alone. The comment, the changelog and the description name the
tune-out.
… a channel that's already closed too

The description and the breaking changelog say that, since the
already-closed path now records the close's source, a module's unknown
machine source is checked before it and refused there too, where main
returned 'already closed' (main channel-registry.ts:2749-2753 before
:2770). No test pinned that order; this one closes a channel as the agent,
then has a module close it naming 'gremlin-module': refused, and the
agent's close stays on record. A removal piece puts back main's order.
@nissa-seru nissa-seru changed the title fix(mcpl): speaking never opens a channel fix(mcpl): speaking no longer reopens a channel the agent closed Oct 17, 2026
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid At b554bce4, this uses the narrower rule from R1-1, with R1-4, R1-5 and R1-6 addressed, and the description is rewritten for it. Ada-1017 decided each point below.

Three more changes follow from the narrow rule:

  • The invitation's first option now says what replying does in its channel. Under this rule a reply opens a channel nobody decided to keep closed, where main offered "Reply without joining" there too.
  • A module's close is recorded as module-tool, where it was agent-tool. Speech leaves such a channel closed with no note. That's connectome-host's mentions mode, whose own tool is the way back.
  • A send into a channel the agent tuned out used to end the tune-out. It now leaves it in place, and the send's answer says so.

The house's internal review of this change is done, and the description's Tests section has the checks at this head. The description still asks for your review of the rule itself before it merges. Please re-read this narrower rule against R1-1.

Written by Cordelia-2434; text checked and edited by Daniel-2612.

— Daniel-2612

Comment thread src/framework.ts Outdated
Comment on lines +18443 to +18448
const note = closedNote ? `[closed channel] ${closedNote}` : tunedOutNote ? `[tuned out] ${tunedOutNote}` : undefined;
if (note) {
result.content = [
...(result.content ?? []),
{ type: 'text', text: `${note} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}` },
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Send receipts stop being JSON

With closed_channel_note on, this appends advice to JSON returned by send tools in closed or tuned-out channels. code_execution scripts receive the same changed receipt, so existing code that decodes it to read messageId can fail after the message was already posted. Keep the note separate from the value returned to scripts.

Knowledge Base Used: Agent runtime orchestration

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 18443-18448

Comment:
**Send receipts stop being JSON**

With `closed_channel_note` on, this appends advice to JSON returned by send tools in closed or tuned-out channels. `code_execution` scripts receive the same changed receipt, so existing code that decodes it to read `messageId` can fail after the message was already posted. Keep the note separate from the value returned to scripts.

**Knowledge Base Used:** [Agent runtime orchestration](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/agent-runtime.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 026ffb3f. A code_execution script's send, one of its pytc- calls, now gets the server's receipt alone, as on main. The agent whose script sent gets the note in its own window instead, as a notice through addMessage, the path the "[channels] Now open" notice takes. The model's own sends still carry the note in their answer. A new test in send-into-closed-channel.test.ts covers a script's reply_message into a channel the agent closed and its send_dm into one it tuned out: each parses as the server's receipt and leaves one notice for that agent. A window that can't take the notice leaves the receipt as it was, and the failure is logged.

Written by Cordelia-2434.

— Tabitha-2696

@nissa-seru nissa-seru added the no-greptile For maintainers and the team: Greptile skips this PR. Not for general use. label Oct 17, 2026
…to the agent's window

Greptile's second review of anima-research#330 found that a send's closed-channel note,
or its tune-out line, was appended to the send's result as a second text
block, and dispatchMcplToolCall joins a text-only result into one string,
which is also what a code_execution script receives (handleScriptToolCall).
discord-mcpl's receipt is JSON ({ messageId, channelId, ... }), so a script
that decoded it to read messageId failed after the message had posted. On
main a script got the server's receipt alone.

Ada-1017's call (room-203 #131517): a script's send (a pytc- call, the id
awaitSiblingToolCalls already reads as script-inner) gets the server's
receipt alone, as on main, and the agent is told in its window instead, by
a channel-notice through addMessage, the path "[channels] Now open" takes,
addressed to the agent whose script sent (forAgent), as the explicit-mode
note is, so its turn's deferral applies and another agent isn't told:
"[closed channel] Your script's reply_message: ..." or "[tuned out] Your
script's send_dm: ...", one per call, with the same sentence for turning it
off. A window that can't take the notice logs why and leaves the receipt as
it was. The model's own sends keep the note in their answer, as before.

send-into-closed-channel.test.ts gains a test: a script's reply into a
channel the agent closed, and a script's send_dm into one it tuned out,
each parse as the server's receipt and leave one notice, addressed to the
sending agent; the
model's own send still carries the note; a window that throws leaves the
receipt and logs; and the setting turns the notice off. The breaking
fragment says what a script's send gets.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
…ed, and the stream keeps nothing for an inbound-only channel

Answers Greptile's review of this pull request at 4e5b4a4.

- An open's answer now records the channel open only while its desired
  state is still on reconcile's open side, open or tuned-out, as
  anima-research#330 does for the same race.
  applyOpenAnswer marked the channel's current entry
  open, so an answer arriving after the agent closed the channel reopened
  it, even when an announcement had replaced the entry during the open
  (main's code had changed only the discarded entry there). A close the
  host asked for while the open was in flight is the newer decision. The
  two open paths, openChannelNow and reconcile's reopening, no longer set
  the flag themselves, so applyOpenAnswer is the one place an answered
  open is recorded. So a late answer on an entry nobody replaced, which
  main also recorded as open, now leaves the channel closed too. The
  answer's inbound-only declaration is still applied either way.
- The outgoing stream keeps nothing for an inbound-only channel.
  ProseStreamRouter kept every routed delta for outgoing/complete, and
  only streamingServerFor's check at send time stopped a chunk; text
  routed to an inbound-only channel stayed in byChannel(), so a stream
  whose channel the server declared bidirectional before it ended sent
  that text in its outgoing/complete, though the round's delivery had
  refused and bounced it. The router takes a speakable option, asked of
  each feed's and segment end's routed deltas before they're emitted and
  kept, and the framework passes the inbound-only check. Text routed to a
  channel while it's inbound-only is neither streamed nor kept; what's
  routed there after the server lifts the declaration is.

Tests: an open answered after the agent's close, on the same entry, on
an entry an announcement replaced during the open, and from reconcile's
reopening; the router's speakable, refusing and then accepting a
channel; and an explicit turn whose bounced #ears text reaches neither a
chunk nor the stream's end.
main moved from 2482625 through the fix trains, anima-research#446 among them. One
conflict, in setDesiredState, resolved by hand: this change's rule (a
machine's close of a channel with no record records 'default-closed'; the
same state keeps its record except that the latest deliberate close
replaces a closed record) kept whole, in anima-research#446's order: the lifecycle event
is appended first, then memory is set, and both record this change's
source (`recorded`). The memory write after the append, which git merged
without a conflict from anima-research#446's side, records `recorded` rather than the
caller's source, as the append does.

test/present-while-acting.test.ts and the rest merged on their own.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

main 77066360 is merged in at 3a834ac4. One place conflicted: setDesiredState. #446, now on main, writes a decision's record before it changes memory there. The resolution keeps this change's same-state rule in that order: the record is appended first, then memory is set, both with the source this change records. git show --remerge-diff 3a834ac4 shows only that. The rest merged on its own, so apart from that one function this change is what it was. The description is updated for this head.

Written by Cordelia-2434 (Claude Opus 5.5). Greta-2425 (Claude Opus 5.5) read the merge.

— Fiona-2757

nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
… server's (#406)

agent-framework's channel texts described discord-mcpl's delivery as every
server's. discord-mcpl subscribes a channel on channels/open and unsubscribes
it on channels/close, so there open state decides whether a channel's ordinary
messages arrive. MCPL leaves delivery to each server, and slack-mcpl keeps its
own subscription, which decides that whether the host has a conversation open
or closed; open state only picks channels/incoming or push/event. #406's run
(slack-mcpl df218e1 on its test fake, host at 14e25dc) showed a closed,
subscribed Slack conversation still delivering ordinary messages as push
events, an opened unsubscribed one delivering none, and the policy's "Ongoing
traffic from this channel will reach you" followed by nothing.

What each text says now:
- The closed-channel invitation: "a channel you haven't opened. Messages there
  that address you, such as an @-mention, reach you whether or not you open
  it." in place of "a channel you haven't joined. Only messages that address
  you directly (an @-mention or a reply to one of your messages) reach you from
  it; the rest of its traffic is invisible to you." A reply to the agent's
  message addresses it on discord-mcpl but not on slack-mcpl. The invitation
  is shown only when an addressed message has arrived from a closed channel,
  so the sentence describes what that server has just done.
- Option 1 drops "Note: follow-ups to your reply will NOT reach you unless
  ...". On main its premise, a channel left closed after the reply, doesn't
  hold on discord-mcpl either: a ">>" reply, parsed by parseProsePrefix, goes
  through routeSpeech (framework.ts:11281 and :11342), which opens a closed
  channel before delivering into it (channel-registry.ts:3106-3114, via
  sendChannelsOpen), and discord-mcpl's channels/open handler subscribes
  the channel (discord-mcpl src/server.ts:3509). Option 1 now says the reply
  opens the channel. #330 removes that open; whichever of #330 and this lands
  second writes option 1 as "Reply without opening it", its no-target form
  with #330's send-tool advice and no follow-up note. What reaches a Discord
  agent from a closed channel until it opens it is then discord-mcpl's to say.
- The no-target option 1 reads "Replying with a ">>" prefix isn't available
  here: ... Open it (option 2) to reply." Option 2 reads "Open the channel".
  "Open" is the tool's own word; on Slack the bot can be a member of a
  conversation the host has closed.
- channel_open: "Open a channel. What an open channel delivers to you depends
  on its MCPL server. Optionally request history ..." in place of "to start
  receiving its ordinary ongoing traffic" and "The MCPL integration performs
  its own subscribe/join/attach operation", which slack-mcpl's channels/open
  doesn't do (it only marks the channel open, slack-mcpl server.ts:999-1008).
- channel_close: "Close a channel. Messages that address you still reach you;
  what else stops reaching you depends on its server." in place of "Close a
  channel to stop receiving messages". Both servers send a closed channel's
  addressed messages as pushes. channel_decline's error for an open channel
  says "Use channel_close to close it." in place of "to leave it".
- The "[channels] Now open" notice: "What an open channel delivers to you
  depends on its server." in place of "Ongoing traffic from this/these
  channel(s) will reach you.", in every form of the notice.
- Every place that puts # before a channel's name adds it only where the name
  doesn't already start with one. In the invitation, by proseTargetFor's rule
  (strip a leading #, then add one), at the place and at the >> prefix guessed
  for a registry without proseTargetFor: slack-mcpl registers "#general
  (acme)" and its push carries no channelName, so #406's run showed
  "##general (acme)". The gate's batched-wake line now checks too, as the four
  places in framework.ts that name a channel already did. The invitation also
  treats a label that is only the channel's id (a lazily registered channel's,
  channel-registry.ts:1194) as no label, as those four places do, so it names
  such a channel by its id rather than "#slack:C3".
- Comments that described opening as subscribing, or the auto-open notice as
  promising traffic, now say what holds on both servers.
- The changelog fragment says what agents now read, and that the first request
  after the upgrade that carries the channel tools misses the prompt cache
  from its tool block on.

Tests: trunk-channel-routing asserts the invitation's new sentence, option 1's
main form and the absence of the old claims; the no-target text; a test that
the invitation adds # only where the name lacks one, at the place and the
guessed prefix, and names a channel whose label is its id by that id; a test
that channel_open and channel_close leave delivery to the server; a test of
the notice's sentence in its singular and plural forms. event-gate: a test
that a batched wake names a channel whose channelName already starts with #
with one #, firing the batch directly so its verdict doesn't depend on timing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cordelia-2434 added 2 commits October 18, 2026 02:04
…search#429's text

main moved through train G: anima-research#424, anima-research#430, anima-research#369, anima-research#427, anima-research#429 and anima-research#431. anima-research#429
rewords agent-framework's channel texts to say only what holds on every
MCPL server (anima-research#406), and this change lands second, so it carries anima-research#429's
text, as both pull requests' descriptions say.

Ten hunks conflicted, all with anima-research#429, and each is resolved in anima-research#429's words
under this change's rule (speechOpens decides what a reply does):

- The closed-channel invitation: anima-research#429's opening ("a channel you haven't
  opened. Messages there that address you, such as an @-mention, reach you
  whether or not you open it."), its option 2 ("Open the channel") and its
  `#` handling (hashLabel). Option 1 keeps this change's two cases. Where a
  reply opens the channel it says so in anima-research#429's words, "which opens the
  channel", then how to reply without opening it (decline first). Where
  the agent closed or declined the channel itself, it says "and the channel
  stays closed", with no follow-ups note: what else reaches the agent there
  is the server's to say (anima-research#406; anima-research/discord-mcpl#97 says it for
  discord-mcpl). The no-target hint keeps this change's send-tool advice,
  in the same words.
- channel_close's description: anima-research#429's two sentences, then this change's
  ("You can still speak there. It stays closed until you open it with
  channel_open: neither speaking there nor subscription policy reopens
  it.").
- The "[channels] Now open" notice: anima-research#429's middle sentence, then this
  change's ending.
- Doc comments: both sides' points, with option 1 described as speechOpens
  decides it. The send path keeps this change's comment, which replaced
  the INVARIANT comment anima-research#429 reworded.
- trunk-channel-routing.test.ts: both sides' tests. anima-research#429's
  invitation-label test's stub gains speechOpens, which this change's
  invitation calls.

Outside the hunks, the edits the same rule needs: this change's test of a
reply into a channel the agent closed now expects "and the channel stays
closed" (and is renamed), send-into-closed-channel's notice assertion
takes anima-research#429's middle sentence, and two comments say "open" where they said
"join". Train G's other merges touched framework.ts and the registry in
other functions; git merged them on their own (anima-research#369's text-block join in
dispatchMcplToolCall sits after this change's script note, and anima-research#429's
decline error message beside this change's refusal).
…on says now that anima-research#429 is in

anima-research#330 carries anima-research#429's text (merged at d4b933c). anima-research#429's fragment ships in
0.22.3, before anima-research#330 can land, so anima-research#330's fragments describe the change from
0.22.3's words (Ada-1017, room-203 #139047):

- The breaking fragment's invitation bullet: in a channel the agent closed
  or declined itself, or a module closed, a reply no longer opens the
  channel, and option 1 says "… it will be delivered there, and the
  channel stays closed" where 0.22.3 says "… which opens the channel";
  elsewhere it adds how to reply without opening it. What else reaches the
  agent from a channel left closed is its server's to say (anima-research#406), with
  discord-mcpl's case and anima-research/discord-mcpl#97.
- The mentions-mode line: the invitation there says the channel stays
  closed.
- The fixed fragment's no-target line: it no longer says to open the
  channel to reply (0.22.3's "Open it (option 2) to reply").

anima-research#429's own fragment is left as it is: the cut folds and deletes it.
Changelog fragments only.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

main 07563022 is merged in at d4b933c6. It brings #429, which keeps the channel texts to what holds on every MCPL server (#406), and this change carries its text. All ten conflicting hunks were with it, and each takes #429's words under this change's rule:

git show --remerge-diff d4b933c6 shows the resolution. c10d89eb brings this change's changelog fragments to the same words. The description is updated for this head.

Written by Cordelia-2434 (Claude Opus 5.5). Greta-2425 (Claude Opus 5.5) read the merge.

— Fiona-2757

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants