Skip to content

feat(routing): configurable homeChannel for no-trigger turn speech - #244

Open
HO-git wants to merge 3 commits into
anima-research:mainfrom
HO-git:feat/home-channel-fallback
Open

HO-git wants to merge 3 commits into
anima-research:mainfrom
HO-git:feat/home-channel-fallback

Conversation

@HO-git

@HO-git HO-git commented Oct 7, 2026 •

Copy link
Copy Markdown

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

  • New FrameworkConfig.homeChannel?: string (channel descriptor id, e.g. discord:{guildId}:{channelId}), passed to ChannelRegistry as the homeChannel option.
  • Consulted in resolveLocus and buildChannelContext (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.
  • Opt-in. Without the option, resolution is unchanged.
  • Silent heartbeats are unaffected. Turns marked origin.silent still route no prose; this only changes where speech that is already being routed goes.
  • One behaviour difference when set: right after a restart, before any inbound has arrived, a no-trigger turn currently has no locus and its prose stays in the chronicle. With homeChannel set, 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

  • New 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.homeChannel reaches 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 fragment added — changelog.d/home-channel-fallback.added.md

🤖 Generated with Claude Code

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>
@greptile-apps

greptile-apps Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5 Tier: apex

[Medium risk] Adds configurable home channel for speech routing on no-trigger turns.

The PR appears safe to merge, with a non-blocking gap in tests for home delivery and the reported destination.

Findings

  1. P2 Home delivery goes untested ▶
Fix with agent prompt
### Issue 1
test/home-channel-routing.test.ts:93-96
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.

---

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

Summary

Adds opt-in FrameworkConfig.homeChannel for speech on turns with no triggering channel.

  • Both resolveLocus() and buildChannelContext() place it after fork homes and triggering channels, before the global fallback.
  • Silent heartbeats remain suppressed.
  • Intentional change stated by HO-git: non-silent no-trigger speech may be delivered before the first inbound message after restart.
  • The new tests cover channel lookup and config wiring, but should also check reported destinations and publication.
  • Tests were inspected, not run.

Reviews (1) · Last reviewed commit: "feat(routing): configurable homeChannel ..." · Reviewed by Greptile

Comment on lines +93 to +96
await internals.initializeMcpl([]);
const registry = internals.channelRegistry!;
(registry as unknown as { defaultPublishChannel: string | null }).defaultPublishChannel = BUSY;
assert.equal(registry.resolveLocus('scout'), HOME);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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).

Ubuntu and others added 2 commits October 18, 2026 00:48
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 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 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: wakeProvenance reports routeChannelId only when the chosen event addressed the agent.
  • src/framework.ts:2568: the gate's requestInference sets InferenceRequest.channelId only from routeChannelId. 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 to this.homeChannel before it reaches defaultPublishChannel.

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:

  1. Have the gate mark a batch that contained any channel-bearing event (e.g. channelBorne: true). wakeProvenance already has this information, including for ambient push events whose routeChannelId it currently drops.
  2. OR the flag across merged requests in processInferenceRequests (next to silentOnly, src/framework.ts:11342).
  3. Store it on TurnRouting at turn start.
  4. In resolveLocus, consult homeChannel only 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:6568 queues them with no channel, and Agent defaults to proseRouting: 'locus')
  • admin nudges and unstick
  • ! continuations and prose-bounce resends
  • background-script wakes and module requestInference wakes

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): '' shadows defaultPublishChannel and 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 with no 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) homeChannel there, naming the setting and the value's kind.
  • Optionally, warn once when a turn freezes on a homeChannel that 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.homeChannel from buildChannelContext (channel-registry.ts:1499): all 8 tests in home-channel-routing.test.ts still 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.homeChannelResolver and 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 "BEFORE defaultPublishChannel". It is now consulted before homeChannel.
  • 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), so homeChannel never changes where their plain speech goes. A private skip_reply round stays private, and resumed starts reuse the frozen TurnRouting.
  • #200: addressed batches carry channelId and win. Ambient-only batches do not (R1-1).
  • resolveLocus has a single caller, the turn-start freeze, so server-initiated inferenceRouting is unaffected.
  • The lazy path (connectMcplServer on a framework with zero servers) goes through the same initializeMcpl and receives the value.

proseRouting.

  • locus and hybrid: home becomes the frozen locus of a channel-less turn. The typing indicator, the [routing] notice and the sleep announcement 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.
  • disabled and silent heartbeats: no locus, notice, typing or delivery, so "silent heartbeats are unaffected" holds. The only visible change is R1-4's defaultOutgoing.
  • sameRoundThinkTextPolicy is 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, defaultPublishChannel also 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.md fragments, so the release that carries this must be 0.23.0 (scripts/release-bump.mjs).
  • connectome-host#207 keeps @animalabs/agent-framework at ^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 build passes. The full npm test on the merge: 4651 tests, 4634 pass, 0 fail, 17 skipped. home-channel-routing.test.ts: 8/8.
  • ChannelRegistry is constructed in exactly one place, and mcplHomeChannel reaches it on both eager and lazy initialisation.
  • Id format: discord:{guildId}:{channelId} is discord-mcpl's descriptor id. It is the form defaultPublishChannel holds 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 explicit and is unaffected.
  • A channel_open still 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-sol at xhigh reasoning: 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.

@nissa-seru

Copy link
Copy Markdown
Contributor

This branch now conflicts with #557, merged to main as a0dae7c8, in the lookups it uses to keep a subagent off channels nobody chose for it. Putting the configured homeChannel behind the same check as the fallback keeps its rule.

#557 makes an ephemeral run's turn locus, its channel context's default outgoing channel and channel_publish's default its own channel or nothing. ChannelRegistry takes a takesGlobalFallback option, and a conversation it answers false for gets null where it used to get defaultPublishChannel (globalFallbackFor). The framework answers false for its ephemeral runs.

This branch adds the configured homeChannel to the same two lookups, after the conversation's own channels and ahead of defaultPublishChannel, in buildChannelContext and in resolveLocus. git merge-tree against main at 9c6964c9 shows this branch and main conflicting there, and in the option, its field and the constructor. By our reading, a resolution that keeps homeChannel where this branch puts it, with only the fallback behind main's check, would give a subagent whose run has no channel of its own the home channel, and its final prose would be published into it, as #551 reported for the fallback. With homeChannel behind the check too, a resident still lands in the home channel, and a subagent names a channel of its own.

Written by Milo-2653.

— Lottie-2661

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants