Repository navigation
fix(routing): an ephemeral run takes no global fallback - #557
Conversation
A locus or hybrid ephemeral run (a subagent) with no channel of its own froze its turn's locus to the process-global fallback, the channel of the most recent delivered inbound message, so its final plain prose was posted there: delegated work, perhaps drawn from a DM, landing in an unrelated and perhaps public channel (anima-research#551). Its prose is its caller's result. The channel registry takes a `takesGlobalFallback(conversationId)` option, and the framework answers false for any name createEphemeralAgent reserved, by name so that a speech dispatch after the run's release still counts. A conversation fork, whose name is reserved in the same set, keeps the fallback: the set now records which kind reserved each name. For a run, whatever its prose routing: - resolveLocus takes no fallback, so a turn with no channel of its own has no locus; - deliverSpeech returns that null locus as not delivered, with its own reason, and writes no log line, mcpl:speech-route-failed trace or [send-undeliverable] notice; a sleep announcement with no locus goes the same way; - its channel context has no incoming message, and no default outgoing channel unless it opened one; the context keys on whether a name was given, so a run named '' is no exception; - channel_publish without a channelId is refused, saying to name the channel; - the refusal reaction and the refusal's failures.log record read the refused agent's own channel context, so a subagent's refusal marks no message and logs no locus. channel_publish's description now says where an omitted channelId goes: the conversation's bound channel, else the most recent incoming one, and nowhere for a subagent. Comments that said every non-channel or no-trigger turn takes the global fallback, and deliverSpeech's promise that a null locus fails loudly, name the exception. The trailing-prose log prints '(none)' for a turn with no locus. A channel the run opens itself, and a destination it names in its prose, still route as before. In route-failure-targeting, the three tests that sent an ephemeral run's reply through the fallback now route the ended run's speech to a channel given directly, and the first of them gains a sibling asserting that the default channel is never tried; the test of a reply with no channel now asserts that it is no failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The changes affect channel routing for ephemeral runs and refusal handling. The PR appears safe to merge, with a small channel-context fix recommended for subagents named Findings
Fix with agent prompt### Issue 1
src/mcpl/channel-registry.ts:1692-1693
For the supported `name: ''` case, `buildChannelContext` drops even a channel the subagent opened. The `home` and `active` lookups above still skip empty names, so removing the global fallback leaves `defaultOutgoing` unset. Meanwhile, `resolveLocus('')` sees the opened channel and speech still goes there. Hooks therefore receive channel context that disagrees with where speech goes.
Use `agentName !== undefined` for both resolver checks too, and extend the empty-name test to cover an opened channel.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryStops ephemeral runs from using the channel selected by the latest incoming message.
Reviews (1) · Last reviewed commit: "fix(routing): an ephemeral run takes no ..." · Reviewed by Greptile |
| const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel; | ||
| const outgoing = home ?? active ?? fallback; |
There was a problem hiding this comment.
Opened channel disappears from context
For the supported name: '' case, buildChannelContext drops even a channel the subagent opened. The home and active lookups above still skip empty names, so removing the global fallback leaves defaultOutgoing unset. Meanwhile, resolveLocus('') sees the opened channel and speech still goes there. Hooks therefore receive channel context that disagrees with where speech goes.
Use agentName !== undefined for both resolver checks too, and extend the empty-name test to cover an opened channel.
Knowledge Base Used: MCPL conversation and inference routing
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/channel-registry.ts
Line: 1692-1693
Comment:
**Opened channel disappears from context**
For the supported `name: ''` case, `buildChannelContext` drops even a channel the subagent opened. The `home` and `active` lookups above still skip empty names, so removing the global fallback leaves `defaultOutgoing` unset. Meanwhile, `resolveLocus('')` sees the opened channel and speech still goes there. Hooks therefore receive channel context that disagrees with where speech goes.
Use `agentName !== undefined` for both resolver checks too, and extend the empty-name test to cover an opened channel.
**Knowledge Base Used:** [MCPL conversation and inference routing](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/mcpl-conversation-and-inference.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Addressed in cba2011f: buildChannelContext's home and active lookups now key on whether a name was given, as its fallback already did, so a subagent named '' is told a channel it opened, where its speech goes. A new test pins it, and it fails at e6e4a1fd.
Written by Milo-2653.
— Lottie-2661
buildChannelContext keyed the fallback on whether a name was given, but its
home and active lookups still tested the name's truthiness. So a subagent
named '' that opened a channel got no default outgoing channel in its
context, while resolveLocus('') sent its speech there. Both lookups now key
on whether a name was given, and a test pins that such a run is told the
channel its speech goes to.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in anima-research#557 (an ephemeral run takes no global fallback) and anima-research#559. Conflicts, all in src/mcpl/channel-registry.ts: - onRouteFailure's doc: both sides extended the list of when it's called. It keeps this branch's error-response case and main's exception for an ephemeral run's speech with no locus. - deliverSpeech's doc: both sides added an exception to "surfaced through onRouteFailure": this branch's held failure (heldOnFailure) and main's ephemeral run with no locus. It names both. - handleToolPublish's signature: main added `origin` as its second parameter, for channel_publish's default channel, and this branch added `seen`, publishForAgent's outcome. `origin` stays second, as main's handleChannelToolCall passes it, and `seen` comes third. publishForAgent passes no origin: it always names its channel, so the default never applies to it. Elsewhere both sides changed buildChannelContext, deliverSpeech's body and framework.ts, and git merged them: main's fallback rule for an ephemeral run and this branch's inbound-only guard on defaultOutgoing sit side by side in buildChannelContext, and main's early return for an ephemeral run with no locus comes before this branch's publish() in deliverSpeech.
…llback, the all-except hold's channel included What main brings since aae86b7 (0.22.3): #557, an ephemeral run's turn locus, channel context and channel_publish default are its own channel or nothing (`takesGlobalFallback`, `globalFallbackFor`; `usedEphemeralAgentNames` becomes a Map of run | conversation); #559's sized waits in the modern MCP outcome tests; #544's reconnect registration and late-response rules; #547 and the F194 workspace spelling work; #402's BOM-tolerant gate files; #540's agent words; #541/#542 publish and redaction fixes. Conflicts, all in src/mcpl/channel-registry.ts, resolved by composition with the rule nissa-seru's review of #546 named: the all-except hold's channel goes BEHIND the same check as the legacy fallback, so a subagent whose run has no channel of its own never gets the focused channel either. - buildChannelContext: main's `!== undefined` name keying and its `fallback` variable, where the fallback is `globalFallbackFor(agentName)` for a named conversation and the shared fallback for none. - resolveLocus: `home ?? active ?? globalFallbackFor(conversationId)`. `globalFallbackFor` answers null for a conversation that takes no global fallback, else `sharedFallback()` = the focus channel, else `fallbackLocus()` (the latest delivered inbound under the legacy rule, otherwise null — Dendrite's `speech.locus`). - channel_publish's default: main's caller-keyed form over the same `globalFallbackFor`, so a resident's publish with no channelId goes to the focus channel while a hold is open, else the legacy fallback; a subagent's is refused and names one. Before this merge the publish default on this branch skipped the focus channel; the three lookups now agree. Two seams the build and tests found: - framework.ts resumeAgent still called `.add` on the Set main made a Map; a resumed job is an ephemeral run, so it is `set(name, 'run')` and takes no fallback. - A message delivered to a conversation fork (the `deliverTo` path and routeConversationIncoming) returned before `noteDelivered`, so on this branch it never became the latest delivered inbound — main's `markAccepted` recorded every accepted message, and #557's "a conversation fork keeps the fallback" test caught the gap. Both paths now record a delivered message as a resident's path does; held and dropped ones still never are. Test adaptation: test/ephemeral-no-global-fallback.test.ts is about the global fallback itself, so its fixture opts into `speech.locus: 'latest-inbound'`, as route-failure-targeting did at the third catch-up; under Dendrite's `delivery` default a resident with no delivery has no locus, which admission-e2e covers. Composed rig: npm test 4862 tests, 4831 pass, 1 fail, 30 skipped; the one failure (host-quiesce's 2,101-message resume batch, a 60 s wait) was under three concurrent suites and passes alone, 48/48. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Main's anima-research#557 (an ephemeral run takes no global fallback) reworded the same texts this branch rewords, so three places conflict in src/mcpl/channel-registry.ts and one in src/framework.ts. Each keeps both sides' rules: - channel_publish's description and its channelId parameter's keep this branch's account of the default channel (a conversation's bound channel first; otherwise the channel of the most recent incoming message the host received on any channel, shown or not, which reactions, reaction removals, deletions and edits don't set, though an edit of a message not yet shown can count, and which a push never sets) and add main's rule that a subagent has neither default and must name a channel. - handleToolPublish takes main's resolution (the caller's global fallback, which an ephemeral run never takes); its comment keeps this branch's description of the default. - reactToRefusal's doc keeps main's "the refused agent's" locus and its clause that an ephemeral run has none, with this branch's exclusion of reaction, reaction removal, edit and deletion markers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nge marker, with no awareness gate main moved on from fe82187 by anima-research#543, anima-research#540, anima-research#402, anima-research#545, anima-research#547, anima-research#544, anima-research#557, anima-research#559, anima-research#560 and anima-research#562. Of these, anima-research#544 meets anima-research#250: an MCPL reconnect now holds control traffic as well as data until its new grant, with a per-connection marker for a policy exchange in flight, which the awareness gate's release and completion consulted. What anima-research#250 removes stays removed, and what main added keeps working beside it. Conflicts: - src/framework.ts, the gate: anima-research#544 added mcplPolicyExchanges, mcplPlanesMayOpen and settlePolicyExchange, made the gate's release and completion pass a connection by while its exchange is in flight, and added openMcplPlanesAfterExchange for a connection a newer gate had passed by. anima-research#250 removes the gate, so readyMcplPlanes, its one opener, takes the marker's check: a resume or startup's staged open passes such a connection by, and the exchange's own caller opens it once it settles. openMcplPlanesAfterExchange has no gate to follow, and goes; its callers call readyMcplPlanes for their connection. - src/framework.ts, the connect: anima-research#544's marker at registration and its transport-bound exchange stay. A staged connect runs its exchange on the current transport, as anima-research#544's does, and a runtime connect on the transport it registered, closed for good if the exchange throws (settlePolicyExchange's closeOnFailure, in place of anima-research#250's own close). module:added keeps anima-research#544's check that the registered transport still serves, made after a runtime connect's open. - src/framework.ts, the reconnect handler: anima-research#544's marker and transport checks in anima-research#250's shape: the exchange, then readyMcplPlanes for the connection, which is relisted and announced only while its transport still serves. - src/mcpl/server-connection.ts: main's file, with anima-research#250's rewrites of the comments that named the awareness barrier or gate. - README.md: anima-research#250's text, with anima-research#544's sentence about an exchange in flight moved into the delivery bullet. - test/fixtures/reregistering-mcpl-server.mjs: main's, its header naming the three test files that use it. - test/mcpl-awareness-barrier.test.ts stays deleted. anima-research#544's assertion there, that a failing runtime connect is closed while its exchange's marker still holds it, moves to mcpl-runtime-connect-failure.test.ts, where anima-research#250 keeps those tests. Outside the conflicts: - test/mcpl-reconnect-registration.test.ts, anima-research#544's: its harness stood on the gate. Its tests now stand on readyMcplPlanes, as startup's staged open and a resume reach it, and the cases only a gate had (a list change's or an awareness sync's completion, a runtime connect's awareness accounting, a newer gate replacing a connect's) go with the gate. Its first staged-startup test records startup's open of every connection through readyMcplPlanes instead of the gate's completion. - changelog.d: 0.23.0 folded modern-mcp-client.added.md, stop-reaches-every-cleanup.fixed.md and mcpl-reconnect-registration.fixed.md, so anima-research#250 no longer edits the first two. Its one-shot fragment says what changes from 0.23.0 for all three: only startup's staged open and resume() open every connection's planes, an exchange in flight still holds its connection through both, and the marks a drain hadn't sent at stop() stay queued in the ledger for the next start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
A
locusorhybridephemeral run (a subagent) with no channel of its own posts its final plain prose to whichever channel last received a message.runEphemeralToCompletionqueues the run's request with nochannelId. Its turn then freezes its locus throughChannelRegistry.resolveLocus, which falls back to the process-globaldefaultPublishChannel, the channel of the most recent delivered inbound message. Delegated work, possibly drawn from a DM or a private channel, can land in an unrelated and possibly public channel. connectome-host passes a parent'sproseRoutingto its subagents so that children of adisabledcaller "cannot leak prose into its live channel". A child of alocuscaller had no such protection.Fixes #551.
Changes
No ephemeral run takes the global fallback, whatever its prose routing. A subagent's plain prose is its caller's result. So its locus, its channel context's default and its
channel_publishdefault are a channel of its own or nothing, never a channel that only the latest inbound message picked.takesGlobalFallback(conversationId). The framework answers false for any namecreateEphemeralAgentreserved. It goes by name, so a speech dispatch after the run's release still counts. A conversation fork's name is reserved in the same set, and it keeps the fallback: the set now records which kind reserved each name.resolveLocustakes no fallback for such a run, so a turn with no channel of its own has no locus.deliverSpeechreturns that null locus as not delivered, with its own reason, since the prose is its caller's result. It writes no[routeSpeech] … NOT routedline, nomcpl:speech-route-failedtrace and no[send-undeliverable]notice.buildChannelContextgives such a run noincomingmessage, and no default outgoing channel unless it opened one.channel_publishwith nochannelIdis refused for such a run: "Nothing was published: name the channel with channelId. A subagent has no default channel, since its plain speech is returned to whoever called it, so channel_publish sends only to a channel it names."channelIdgoes: the conversation's bound channel, else the most recent incoming one, and nowhere for a subagent. The old text was already wrong for conversation forks, which publish to their bound channel.failures.logrecord read the refused agent's own channel context. So a subagent's refusal marks no message and logslocus: null.What a run chooses still routes: a channel it opens itself, and a destination it names in its prose (
hybrid's>>>,explicit's>>). Residents and conversation forks keep the fallback.What callers newly see:
channel_publishwithout achannelIdreturns the refusal above instead of posting to the latest inbound channel;beforeInference'schannels) has noincoming, and nodefaultOutgoingunless it opened a channel;failures.logrecord haslocus: null.locusorhybridrun with no channel of its own, its final plain prose isn't published.runEphemeralToCompletionreturns it as before. A sleep announcement it makes isn't published either.channel_publish's description and itschannelIddescription have changed.What each part is for
homeChannelResolverandactiveChannelResolveralready do.'run' | 'conversation') keeps conversation forks unchanged. Without it, a fork would be treated as a run: its speech with no locus would stop raising its failure notice, and its channel context would loseincoming. That's outside this fix.deliverSpeechis needed because, without it, every subagent turn with no channel of its own would write the route-failure line, trace and notice that a resident's turn with no locus gets. Every speech delivery reachesdeliverSpeech, throughrouteSpeechor directly, so it sits there once.PublishOutcomehas no status for "not delivered, and not a failure", so the outcome isfailedwith its own reason.routeSpeechturns every non-delivery intonull, and nothing records it.channel_publishrefusal closes the same default from the tool side. Without it, a subagent that omitschannelIdwould still post to the latest inbound channel.'disabled'. That would also stop a channel the run opens itself, andhybrid's>>>envelopes, which callers were told that mode routes. The defect is a destination nobody chose, and those are chosen.Tests
CI on
cba2011fmerged withmain76623a3(run 39207861926) passes all four Build & Test legs and the packed install, and the changelog check passes too.npm teston Ubuntu runs 4,756 tests with Node 20 and with Node 24, and on macOS 4,758 with each: 0 fail and 27 skipped everywhere. That'smain76623a3's totals (run 39205375038: 4,744 and 4,746, 27 skipped) plus this PR's 12 tests. The three files CI runs without forced exit pass 129 of 129 on each.CI runs the typecheck, the build and the whole suite on Node 20 and 24. The runs below were on Linux with Node 24.22.0, at tree
6d34d3e, which branches frommainat70ca785.e6e4a1f's tree,176edbb, added one assertion to the sleep test, and that file, its red-first and the quiet-outcome removal ran again at it. This head addscba2011, the empty-name lookups below: that file, the three nearest, its red-first and its removal ran at it. CI doesn't run the red-first or the removals.test/ephemeral-no-global-fallback.test.ts, on the loopback MCPL server. Each subagent runs after a resident's reply has madebusythe latest inbound channel. A run's final speech is routed after the run settles, so the tests that assert nothing was posted first wait for the run's speech to reachdeliverSpeechand settle.locussubagent's final prose, and ahybridone's, is returned to its caller, isn't published, and is neither routed nor counted as a failed route. Control: a name with no turn of its own still resolvesbusy.channel_publishwith nochannelIdis refused for a subagent with the words above, and one that namesbusyis published. Control: a resident's unnamedchannel_publishgoes tobusy.''included, has no default outgoing channel and noincoming. A resident's has both.failures.logrecord haslocus: null. A resident's adds one, and its record namesbusy.''whose turn opened a channel is told that channel as its default outgoing one, where its speech goes.sleepannouncement isn't published, and is neither routed nor counted as a failed route.test/route-failure-targeting.test.ts:[send-undeliverable]notice that a turn with no locus is told.main'ssrc/framework.tsandsrc/mcpl/channel-registry.tsand this branch's tests: 9 of the 11 new tests fail. The 2 that pass are the controls, the conversation fork and the self-opened channel. Inroute-failure-targeting, 2 of 50 fail: the default-channel test and the no-failure test. The other changed tests drive their delivery directly and pass on both.resolveLocustakes no fallback for a runlocusandhybridtests, the released name, the sleep announcement), 1 changed (the default channel)'')''), by theactivehalf; thehomehalf alone fails none of the 11 new tests, since by our reading no conversation fork can be named''incomingchannel_publish's default for a runchannel_publishlocus,hybrid, released-name and sleep tests), 1 changed (no failure)takesGlobalFallbackhasin place of'run')route-failure-targetingephemeral-no-global-fallback11/11,route-failure-targeting50/50,channel-registry-routing24/24 andtrunk-channel-routing23/23. At tree6d34d3e,batched-wake-locus5/5,present-while-acting57/57,tool-result-guard146/146,agent-allowed-tools68/68 andexplicit-prose-routing88/88.Not verified
hybrid's>>>andexplicit's>>are left as they were by reading: neither readsdefaultPublishChannel. No test here drives them for an ephemeral run.Out of scope
explicitrun's unprefixed answer, and any run's answer after it called a send tool it may use, whether or not the send went out. Making its plain answer its result under every mode is its own change.recordChannelAutoOpenNotice(src/framework.ts:14381,:14394) sends the "Now open" notice for a channel that a run named''opened by its own send to the primary, not to the run. That's outside the fallback rule.DiscordModulesends every agent's speech, a subagent's included, to its one default reply context. Its typing loop anddiscord--replyread the same default. Making it subagent-aware across speech, typing and those tools is its own change.Merge notes
One rule resolves each of these conflicts: an ephemeral run's locus, its channel context's default and its
channel_publishdefault are a channel of its own or nothing. Every other destination a lookup takes for it, in whatever order, sits behindtakesGlobalFallback.Trial merges of this branch with each open pull request, against
mainat2d3d5c2, add conflicts with 11:focusLocus) and feat(dendrite): one registry, lifecycle and fork primitive for agents; admission, reader forks, the all-except scope and speech.locus (#232, #77, #270) #546 (the all-except scope'sfocusChannelResolver) put a focus channel ahead of the fallback without asking whose turn it is. feat(routing): configurable homeChannel for no-trigger turn speech #244 puts a configuredhomeChannelbetween the trigger channel and the fallback, inresolveLocusandbuildChannelContext. Under the rule, none of them reaches an ephemeral run.defaultPublishChannelwith a fallback route of their own, and each keeps an ephemeral run off it (usedEphemeralAgentNames.has(agent.name) ? null : …). feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256's channel context andchannel_publishdefault follow the turn's route. In a merge with either, its own form of the rule takes the place of this branch's reads where it removes what they guard.incoming;handleToolPublish;channel_publish's description and parameter text, and fix(mcpl): a channel marked speech: none is never the automatic locus of an agent's prose #436 also changes the locus lookup (firstSpeakable).usedEphemeralAgentNames.add(…). That's aMaphere:.set(name, 'run')increateEphemeralAgent, and.set(name, 'conversation')for a conversation fork.changelog.d/551-ephemeral-no-global-fallback.fixed.mdReported by Anarchid in #551. Written by Milo-2653 (Claude Opus 5.5), contributing through nissa-seru, on design calls by Ada-1017 (Claude Opus 5.5), and reviewed by Ramona-2416 (Claude Opus 5.5).
Description checked and posted by Lottie-2661 (Claude Opus 5.5).
🤖 Written with Claude Opus 5.5 through the Rookery's tooling.