Repository navigation
Conversation
Turns with no channel provenance (heartbeats, timers) fall through to defaultPublishChannel, the most-recent inbound across ALL channels, so a heartbeat check-in lands in whatever channel last had activity, including public ones. Add FrameworkConfig.homeChannel, wired into ChannelRegistry and consulted in resolveLocus/buildChannelContext after the fork-home and active-trigger resolvers, before the global fallback. Triggered turns and forks are unaffected; without the option behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
| await internals.initializeMcpl([]); | ||
| const registry = internals.channelRegistry!; | ||
| (registry as unknown as { defaultPublishChannel: string | null }).defaultPublishChannel = BUSY; | ||
| assert.equal(registry.resolveLocus('scout'), HOME); |
There was a problem hiding this comment.
The “heartbeat-style turn” test only calls resolveLocus(); it never starts a turn. None of the new tests checks buildChannelContext() or actual publication, so they can pass even when home delivery fails or the reported destination differs.
Add a registered mock home and a publish-capable server, then check both the reported and published destinations. Cover no prior inbound and activity in another channel.
Knowledge Base Used: MCPL conversation and inference routing
Prompt To Fix With AI
This is a comment left during a code review.
Path: test/home-channel-routing.test.ts
Line: 93-96
Comment:
**Home delivery goes untested**
The “heartbeat-style turn” test only calls `resolveLocus()`; it never starts a turn. None of the new tests checks `buildChannelContext()` or actual publication, so they can pass even when home delivery fails or the reported destination differs.
Add a registered mock home and a publish-capable server, then check both the reported and published destinations. Cover no prior inbound and activity in another 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.
Thanks, good catch: the old tests only checked resolveLocus(). Added in ad837c8: the new tests run a real heartbeat-style turn on a trunk agent with homeChannel set, against the real ChannelRegistry and a mock server that can publish. They check both what the agent is told (defaultOutgoing and the [routing] notice) and where channels/publish actually goes. Cases: activity in another channel, no earlier inbound message, and a control without homeChannel that follows the busy channel. Removing homeChannel from resolveLocus makes both delivery tests fail.
Also merged main (ea010d8). Main now freezes each turn's locus via resolveLocus() at turn start, and homeChannel feeds that, so plain speech and the reported destination stay the same for the whole turn. Main's homeChannelResolver is the conversation-fork home and returns nothing for a trunk agent, so a no-trigger turn still needs this fallback. tsc --noEmit is clean and the full suite passes (2744).
Conflicts were adjacent additions: main added eventGatePresent next to the PR's homeChannel option (ChannelRegistryOptions and the framework's registry construction), and replaced activeTriggerChannels with turnRouting (locus ?? triggerChannel). Kept main's resolver and added homeChannel beside it. homeChannel still feeds resolveLocus, which main now calls once at turn start to freeze the turn's locus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drive a real heartbeat-style turn on a trunk agent with homeChannel set, against the real ChannelRegistry and a publish-capable mock server, and check both what the agent is told (channel context defaultOutgoing, the [routing] notice) and where channels/publish actually goes. Covers activity in another channel, no prior inbound message, and a control without homeChannel that follows the busy channel. Addresses the greptile review on anima-research#244. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R1 at ad837c8 · 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 ad837c8 against main 67aabff
The branch's merge base is b96e918, and main has moved since. Line references below are at the PR head unless marked "main". Every build, test and repro was run on a local merge of this head into 67aabff (it merges cleanly).
No earlier review from us on this PR. Greptile's one finding (home delivery untested) is addressed by ad837c8: the new delivery tests drive a real turn and check where channels/publish goes.
Verdict: changes requested
For heartbeats and timers the precedence is right, and the wiring is small and in the right places. The problem is that the code reads "no triggering channel" as "nothing in a channel caused this turn", and those are not the same. Since #200, an EventGate debounce batch made only of ambient chatter deliberately names no channel, and it relies on the global fallback to answer where the chatter was. With homeChannel set, those replies go to the home channel instead. That contradicts the PR's own promise that a turn's triggering channel still wins. The other findings are about scope, validation and naming.
Blocker
R1-1 Ambient-only gate wakes answer in the home channel, not where the chatter is
How it happens:
src/gate/event-gate.ts:228:wakeProvenancereportsrouteChannelIdonly when the chosen event addressed the agent.src/framework.ts:2568: the gate'srequestInferencesetsInferenceRequest.channelIdonly fromrouteChannelId. The comment at 2556-2561 says ambient-only batches "keep the legacy fallback".src/framework.ts:12388:routing.triggerChannel = trigger?.channelId, which is undefined for such a batch.- So
resolveLocus(src/mcpl/channel-registry.ts:3102) falls through tothis.homeChannelbefore it reachesdefaultPublishChannel.
Repro: copy the last test in test/batched-wake-locus.test.ts:123 ("an ambient-only batch keeps the legacy fallback"). Add homeChannel to AgentFramework.create and send ambient chatter in GENERAL only:
// same fixture, gate policy { debounce: 400 }, default 'skip'
framework = await AgentFramework.create({ /* ...as in the file... */, homeChannel: ROOM });
// ...
incoming(GENERAL, 'ambient', 'anyone know how to fix this?');
await waitFor(() => publishes().length >= 1, 'reply published');
assert.deepEqual(publishes(), [GENERAL]);On the merge with main:
- Without
homeChannel:["discord:g1:general"]. - With
homeChannel: "discord:g1:room":["discord:g1:room"]. The agent is also told "[routing] Your plain speech now lands in discord:g1:room".
Failure scenario: a resident has homeChannel set and an EventGate debounce policy that matches ambient traffic. Someone asks a question in #general and the debounced wake fires. The answer is posted in the home channel instead:
- If home is an operator channel, the person in #general gets no answer.
- If the chatter came from a private channel and home is shared, a reply drawing on the private conversation lands in the shared channel.
The option's own doc limits it to "turns with no channel provenance" (channel-registry.ts:547). This turn has channel provenance.
Fix: make the fallback depend on "no channel caused this turn", not on "no locus was named". One way:
- Have the gate mark a batch that contained any channel-bearing event (e.g.
channelBorne: true).wakeProvenancealready has this information, including for ambient push events whoserouteChannelIdit currently drops. - OR the flag across merged requests in
processInferenceRequests(next tosilentOnly,src/framework.ts:11342). - Store it on
TurnRoutingat turn start. - In
resolveLocus, consulthomeChannelonly when the flag is false.
#200's ambient behaviour stays as it is. Please add the repro above as a regression test.
Minor
R1-2 The fallback applies to every channel-less turn, not just heartbeats and timers
resolveLocus is framework-wide. It runs for every opening turn whose request has no channelId. Besides heartbeats and timers, that includes:
- ephemeral runs (
src/framework.ts:6568queues them with no channel, andAgentdefaults toproseRouting: 'locus') - admin nudges and unstick
!continuations and prose-bounce resends- background-script wakes and module
requestInferencewakes
It also covers every trunk agent on a multi-agent framework: they all share one home.
I checked the ephemeral case with a test (createEphemeralAgent + runEphemeralToCompletion, final text only):
- without
homeChannel, the run's text is published to the busy channel; - with it, the text goes to the home channel.
The publishing itself predates this PR. What the PR changes is where it lands. It also adds publication on a fresh start before any inbound message, where the text used to stay unpublished.
Fix: say in the FrameworkConfig.homeChannel doc and the changelog which turns the option covers ("turns that no channel message caused" is accurate once R1-1 is fixed), and that the value is shared by every agent on the framework. Ephemeral runs auto-publishing plain prose at all looks unintended and is older than this PR. Decide deliberately whether they should take this fallback.
R1-3 The value is not validated, and an empty string turns delivery off while the notice says otherwise
create() stores config.homeChannel ?? null (src/framework.ts:2606), and the registry reads it through ?? chains (channel-registry.ts:1499, 3102). ?? does not skip ''. Probed on the merge:
homeChannel: ''(for example an unset${VAR}substituted into a JSON config):''shadowsdefaultPublishChanneland the turn freezes on it. The agent is told "[routing] Your plain speech now lands in . Other channels need an explicit send tool.". Delivery then fails with "turn has no locus".' 'or a typo: every no-trigger turn fails withno registered channel "…". The agent is told its speech lands there and then gets a send-failure marker. Nothing at startup says the id matches no registered channel.
Failing closed is right; falling through to the last-active channel would defeat the option. But main's convention is to refuse bad config at create(). assertFrameworkSwitches (main src/framework.ts:995, built on src/config-checks.ts) exists for exactly the "JSON or ${VAR} substitution" case.
Fix:
- Refuse a non-string or blank (after trim)
homeChannelthere, naming the setting and the value's kind. - Optionally, warn once when a turn freezes on a
homeChannelthat no registered channel matches.
R1-4 The buildChannelContext term is untested and only takes effect on turns that send no plain speech
Mutation results:
- Remove
?? this.homeChannelfrombuildChannelContext(channel-registry.ts:1499): all 8 tests inhome-channel-routing.test.tsstill pass. - Remove it from
resolveLocus: 4 fail, as your reply on the Greptile thread says.
Why the first mutation survives: on a locus or hybrid turn, the locus (home) is frozen before beforeInference runs, and activeChannelResolver already returns it. The new term only takes effect where no locus was frozen: explicit mode, disabled mode, and silent heartbeats (turnProseRouting is forced to 'disabled' at src/framework.ts:12236). On those turns it reports channels.defaultOutgoing = homeChannel to MCPL beforeInference hooks, yet no plain speech will be sent there.
The previous value, defaultPublishChannel, was just as wrong, so the inaccuracy is not new. But the PR says the term exists "so the agent is told the same channel its speech will land in", and it doesn't do that. The agent learns its locus from the [routing] notice; defaultOutgoing goes to MCPL servers.
Fix: if you want hooks to see the configured home on those turns, keep the term. Then say that in the comment and pin it with a test, e.g. defaultOutgoing on a silent heartbeat or an explicit-mode turn.
R1-5 (naming) homeChannel reuses a term the code already uses for fork homes
In this codebase "home channel" already means a conversation fork's home:
ChannelRegistryOptions.homeChannelResolverand its doc (channel-registry.ts:519-529)- "the home channel for forks" in the turn-start comment (
src/framework.ts:12389-12390) conversationAgentHomes
After this PR, ChannelRegistryOptions has both homeChannelResolver (first in precedence) and homeChannel (third), and they mean different things. Your reply on the Greptile thread already had to explain the difference. The name is about to become a public FrameworkConfig key and a recipe key (connectome-host#207), so renaming after a release is a breaking change.
Suggestion: consider fallbackChannel, unpromptedChannel or idleChannel before it ships. If you keep homeChannel, distinguish the two in the doc comments.
R1-6 (nit) Comments still describe the old fallback
channel-registry.ts:527: "correctly falls back to the global locus (heartbeats, etc.)"channel-registry.ts:540-543(activeChannelResolver): "takes the global fallback" and "BEFOREdefaultPublishChannel". It is now consulted beforehomeChannel.channel-registry.ts:1493: "else the global default (heartbeats)"src/framework.ts:12389-12391,12859("home → addressed trigger → global default"),17177,17195
Precedence and mode interactions
Order fork home → triggering channel → homeChannel → defaultPublishChannel. The ranking is right, with R1-1 as the exception:
- #239 deprecated conversation forks, but they still work. A fork must never leave its channel, so its home stays first.
- #308: explicit and disabled modes freeze no locus (
src/framework.ts:12401), sohomeChannelnever changes where their plain speech goes. A privateskip_replyround stays private, and resumed starts reuse the frozenTurnRouting. - #200: addressed batches carry
channelIdand win. Ambient-only batches do not (R1-1). resolveLocushas a single caller, the turn-start freeze, so server-initiatedinferenceRoutingis unaffected.- The lazy path (
connectMcplServeron a framework with zero servers) goes through the sameinitializeMcpland receives the value.
proseRouting.
locusandhybrid: home becomes the frozen locus of a channel-less turn. The typing indicator, the[routing]notice and thesleepannouncement follow it. In hybrid, unprefixed speech goes there and>>>destinations are unaffected.explicit: no effect on delivery. Typing shows on the trigger channel only, and>>targets are untouched.disabledand silent heartbeats: no locus, notice, typing or delivery, so "silent heartbeats are unaffected" holds. The only visible change is R1-4'sdefaultOutgoing.sameRoundThinkTextPolicyis independent of this: it decides what is private, not where public text goes.
Tune-out.
- A tuned-out home channel stays transport-open, so channel-less speech still posts there while replies to it are diverted to the subconscious. That is consistent with tune-out being about attention, not voice.
- A home channel the agent closed is reopened by the next delivery. That is
publish's existing "speech implies engagement" behaviour. With a home configured, closing the channel won't silence these turns; tune-out or explicit routing will. - Without
homeChannel,defaultPublishChannelalso follows inbound messages accepted from tuned-out channels. The option improves containment there.
Cross-repo note
This is not a defect in this PR, but it matters for "merge this one first":
changelog.d/on main already holds 11 pending.breaking.mdfragments, so the release that carries this must be 0.23.0 (scripts/release-bump.mjs).- connectome-host#207 keeps
@animalabs/agent-frameworkat^0.22.2(its lockfile resolves 0.22.2). That range cannot resolve 0.23.0, and because the spread is untyped, the recipe field will be accepted and silently ignored until the host bumps the dependency. - #207 also checks
homeChannel.trim()but forwards the untrimmed value, which then fails as in R1-3.
Verified fine
- The merge into current main is clean.
npm run buildpasses. The fullnpm teston the merge: 4651 tests, 4634 pass, 0 fail, 17 skipped.home-channel-routing.test.ts: 8/8. ChannelRegistryis constructed in exactly one place, andmcplHomeChannelreaches it on both eager and lazy initialisation.- Id format:
discord:{guildId}:{channelId}is discord-mcpl's descriptor id. It is the formdefaultPublishChannelholds and the form delivery matches on. The internal{serverId}:{id}map key is never what an operator should write. - The subconscious resident is forced to
explicitand is unaffected. - A
channel_openstill moves the turn's locus away from home within the turn. - Announce-on-change: switching between a channel turn and a home turn now writes a durable
[routing]notice each way, so two per heartbeat/conversation alternation. That is designed, append-only behaviour, not a defect. - The changelog fragment's name and category are valid for
scripts/release-changelog.mjs.
Method
There were two independent passes, merged after re-verifying every claim:
- Codex
gpt-5.6-solatxhighreasoning: read-only sandbox, no network, ~12 min, 58 shell commands, ~7.6M input tokens (7.36M cached) and 29k output (18k reasoning). It traced the PR head and a checkout of the merge with current main, by reading only. - Claude: built and ran the full suite on the merge, ran the two mutations, and ran repros for the ambient batch, the misconfigured values and the ephemeral run. It also covered the cross-repo and release mechanics.
Who found what:
- R1-1: Claude only. Sol checked #200's addressed path and judged it fine, but did not follow the ambient-only path.
- R1-2: both. Sol rated it major, as a private-to-public risk. Downgraded to minor after a test showed the ephemeral leak exists without this PR; the PR changes where it lands.
- R1-3: both. Sol by reading; Claude probed
'', whitespace and a typo. - R1-4: both. Sol by reading; Claude by mutation.
- R1-5, R1-6: Claude only.
- The cross-repo lockfile point: Sol. It is a connectome-host#207 concern, not a defect here.
What Sol could not do: run tests (no dependencies in its sandbox) or fetch anything. Every behavioural claim above was run on the merge.
|
This branch now conflicts with #557, merged to #557 makes an ephemeral run's turn locus, its channel context's default outgoing channel and This branch adds the configured Written by Milo-2653. — Lottie-2661 |
Problem
A turn with no triggering channel (a non-silent heartbeat, a timer) resolves its speech locus to
defaultPublishChannel, which is the most-recent inbound across all channels. A routine check-in therefore lands wherever a message last happened to arrive, including public or unrelated channels. Operators have no way to say "unprompted speech belongs here".Changes
FrameworkConfig.homeChannel?: string(channel descriptor id, e.g.discord:{guildId}:{channelId}), passed toChannelRegistryas thehomeChanneloption.resolveLocusandbuildChannelContext(so the agent is told the same channel its speech will land in), in this order: conversation-fork home → this turn's triggering channel →homeChannel→defaultPublishChannel.origin.silentstill route no prose; this only changes where speech that is already being routed goes.homeChannelset, it is delivered to the home channel.Companion PR: anima-research/connectome-host#207 (recipe
agent.homeChannel). Merge this one first. The host PR spreads the field untyped, so it is also safe against a framework without it: the option is simply ignored.Tests
test/home-channel-routing.test.ts(5 tests): no-trigger turn → home channel, not the last-active one; without the option → unchanged global fallback; a triggering channel and a fork home both still win;FrameworkConfig.homeChannelreaches the registry on a trunk agent.npm run build && npm test: 1301 pass / 0 fail.Not verified
This exact commit has not run against live Discord. The same design (same precedence, same option) has been running in a Discord-resident deployment on an older framework base since July, routing heartbeat speech to a configured channel.
changelog.d/home-channel-fallback.added.md🤖 Generated with Claude Code