Skip to content

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

Merged
nissa-seru merged 22 commits into
anima-research:mainfrom
HO-git:feat/home-channel-fallback
Nov 9, 2026
Merged

nissa-seru merged 22 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
Contributor

Why (from the house, which carries this pull request). No setting at main lets an operator say where a resident's speech goes on a turn started by its own skip_reply wake or the end of its sleep. The option this pull request now adds, unpromptedChannel, covers those turns and not heartbeats or reminders; the title and the description below are the author's first version, which also covered a non-silent heartbeat's turn, and our reply says what changed.

What this changes in main's published docs (from the house)

On a host that sets unpromptedChannel, these sentences of main (at aa134e75fc) no longer hold for the turns the option covers. The docs this pull request changes say so at its head, e128c68b57. The released changelog's lines describe 0.30.0, and stay as they are.

  • speech.locus's 'delivery' bullet, in src/types/framework.ts and in docs/dendrite.md: "the locus comes only from the delivery that woke the turn, a conversation fork's home channel, or a channel the agent opened during the turn" becomes "the locus comes from the delivery that woke the turn, a conversation fork's home channel, or a channel the agent opened during the turn, and past those only from an open all-except hold's channel or unpromptedChannel (below), each for the turns that take it". main's sentence also left out the hold's channel.
  • docs/dendrite.md, on channel_publish naming no channel inside a turn: "any other turn takes the registry's default, as a call outside the turn does." becomes "any other turn takes the registry's default, as a call outside the turn does, except that one the agent's own timer started takes unpromptedChannel where it's set (below)."
  • The 0.30.0 changelog, unchanged here:
    • "A turn with no locus falls back only to an open all-except tune-out's excepted channel, and otherwise keeps its prose private" (CHANGELOG.md:1724–1726), and, under the default, "a turn without one keeps its prose private" (:1761). A turn the option covers takes unpromptedChannel after the hold's channel.
    • For a channel_publish with no channelId, "else the fallback" (:1730–1732), where "The fallback is only an open all-except tune-out's excepted channel" (:1734–1735). For a turn the option covers, unpromptedChannel comes after the hold's channel there too.
    • Under speech: { locus: 'latest-inbound' }, the latest message's channel is "the fallback again, after an open all-except tune-out's, for prose and for a publish" (:1738–1741). For a turn the option covers, unpromptedChannel comes between the two.
    • "channel_publish without a channelId goes to the calling turn's locus, else a conversation fork's home channel, else the turn's trigger channel, else the registry's default" (:1892–1894). For a turn the option covers, unpromptedChannel comes after an open hold's channel and before the rest of the registry's default.

These were found by reading every line this pull request removes from docs/ and src/types/, and by a git grep of README.md, docs/, src/types/ and CHANGELOG.md at aa134e75fc for "prose private", "speech.locus", "no locus", "comes only from", "falls back only", "registry's default" and "fallback". The other sentences it found still hold at the head, or describe an earlier release, among them docs/auto-continue.md's continuation that "takes no fallback either", and the rule for a channel_publish made outside a turn.

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 thread test/home-channel-routing.test.ts Outdated
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
Contributor 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

Cordelia-2434 added 13 commits November 3, 2026 01:08
main moved from b96e918 to 26c4bdb since this branch was cut. The
conflicts were in src/framework.ts and src/mcpl/channel-registry.ts, where
main's ChannelRegistry now works in ChannelRef pairs (server and channel id)
rather than bare ids, gained takesGlobalFallback and serverConnecting, and
restructured incomingFor/buildChannelContext and resolveLocus.

Resolution, keeping both sides' intent:
- framework.ts: this branch's mcplHomeChannel field beside main's
  discordAwarenessOutbox; its homeChannel option wired beside main's
  serverConnecting.
- channel-registry.ts: main's ChannelRef-typed options, fields and
  assignments, with this branch's homeChannel option, field and assignment
  beside them. main's resolveLocus and buildChannelContext, with the
  configured channel consulted where this branch put it: after a fork's home
  and the turn's own channel (activeChannelResolver !== undefined, so a
  heartbeat's or an unresolved wake's null still takes nothing), before the
  global fallback. main's registry returns ChannelRefs, so the configured
  string resolves at use to the one registered channel it names
  (entriesNamed: by id or server-qualified name), and to none otherwise.

The tests move onto main's APIs: the registry cases use ChannelRefs and
registered entries, with latestInboundLocus giving the "busy" fallback. The
delivery cases run a real MCPL server (speech-route fixture) under
speech.locus 'latest-inbound', since main's default 'delivery' (anima-research#546) leaves
a turn with no locus private. Their turn is a channel-less framework wake,
not a heartbeat: on main a heartbeat's plain speech is held as drafts
(anima-research#861), and this branch's wake was never a heartbeat by main's
isHeartbeatWake (source 'framework'). Channel context is read from the
registry after the turn, since main builds it only for servers granted
beforeInference hooks.

Anarchid's R1 findings are answered in the commits that follow.
In this codebase "home channel" already means a conversation fork's binding:
Dendrite's AgentSpec and registry record carry `homeChannel`, the registry's
`homeChannelResolver` reads it first in resolveLocus, and the framework
treats an agent with one as a conversation fork. That binding takes every
turn of its agent; this option names where a turn's plain speech goes only
when no channel message prompted the turn. The name now says so.

Renamed everywhere the option appears: FrameworkConfig.unpromptedChannel,
ChannelRegistryOptions.unpromptedChannel, the framework and registry fields,
the changelog fragment (now changelog.d/unprompted-channel.added.md) and
the tests (test/unprompted-channel-routing.test.ts). The fragment is
`.added` and unreleased, so no release ever carried the old name.

Addresses R1-5.
…dChannel (R1-1)

The branch read "no triggering channel" as "nothing in a channel caused this
turn". A gate batch (debounce, or a judge's reply verdict, both through
deliverEvents) names a route only when one of its events addressed the
agent, so an ambient-only batch's turn has no trigger channel and fell
through to the configured channel: with unpromptedChannel set, a reply to
chatter in #general was posted in the configured channel, under either
speech.locus rule. At main that turn posts nowhere (the default
'delivery') or where the chatter was ('latest-inbound').

The gate now marks a batch that held any event from a channel, its own id or
a resolved route, addressed or not (WakeProvenance.channelBorne). The
framework carries the mark onto the request (InferenceRequest.channelBorne),
ORs it across the requests merged into one start and across two wakes
coalesced while the host is quiesced, and keeps it on the turn's routing
from its opening start. The registry consults unpromptedChannel only for a
turn that takes it (ChannelRegistryOptions.takesUnpromptedChannel), and the
framework answers false for a marked turn. So ambient and judge batches
keep main's behaviour under both rules.

Tests (test/batched-wake-locus.test.ts), each under 'delivery' and
'latest-inbound', with unpromptedChannel set to #room: Anarchid's repro
(chatter in #general alone) and the two-channel batch (#room, then
#general). All four were red before this change, each publishing to #room,
and pass with it.

Addresses R1-1.
… heartbeats and ephemeral runs (R1-2)

The option's reach is every turn the registry resolves with no home and no
channel of its own, framework-wide, for every agent. On main that includes:
- a self-scheduled wake and a sleep's end, which the option is for;
- a heartbeat's turn, which resolves no channel by design (its resolver
  answers null, TurnRouting.heartbeat) and holds its plain speech as
  drafts (anima-research#861);
- a silent heartbeat tick: suppressProse makes isHeartbeatWake false, so
  it carries no heartbeat flag, and channel_publish naming no channel in it
  resolved to the option;
- an ephemeral run, whose prose is its caller's result (anima-research#551, anima-research#557).

The framework now answers takesUnpromptedChannel false for an ephemeral run
(usedEphemeralAgentNames, as takesGlobalFallback reads it) and for a turn
that is a heartbeat's, silent or not (TurnRouting.heartbeat or .silent;
both of main's suppressProse wakes are silent heartbeat ticks), besides a
channel-prompted turn (R1-1). Whether an operator-named channel should take
a heartbeat's speech is antra-tess's call on anima-research#861's draft hold, and this
leaves it as it is.

FrameworkConfig.unpromptedChannel's doc and the changelog fragment now say
which turns it covers, that channel_publish naming no channel defaults
there too, what such turns do without it, and that one value serves every
agent on the framework. The registry option's doc no longer describes
main's fallback as the latest inbound channel.

Tests (test/unprompted-channel-routing.test.ts, under the default
speech.locus 'delivery'):
- a self-scheduled wake speaks in the unprompted channel;
- channel_publish naming no channel goes there in explicit prose routing;
- a heartbeat's turn publishes nothing, told its speech is held;
- a silent tick's channel_publish naming no channel publishes nothing;
- an ephemeral run publishes nothing.
At the R1-1 commit the silent tick and the ephemeral run each published to
the unprompted channel; with this change all 12 pass.

Addresses R1-2.
At main, the global fallback a turn with no channel of its own takes is the
all-except hold's excepted channel while one is open, then, under
speech.locus 'latest-inbound', the latest inbound channel (sharedFallback).
The branch consulted unpromptedChannel ahead of that whole fallback, so
during a hold a timer's turn spoke in the operator's channel rather than
the one the agent chose to keep.

The hold is the agent's own live choice of where it attends, and a standing
operator default shouldn't override it, as anima-research#969's lock, if it lands,
wouldn't let it either. The order is now: the hold's channel, then
unpromptedChannel where the turn takes it, then the latest inbound channel
under 'latest-inbound', else none. resolveLocus and buildChannelContext
pass the configured channel into the one fallback chain
(globalFallbackFor/sharedFallback), so a turn's locus, channel_publish's
default and what servers are told as the default outgoing channel follow
one order, kept in one place, and sharedFallback's doc says it.

Tests (test/unprompted-channel-routing.test.ts, admission fixture):
- a turn no channel prompted speaks in the unprompted channel with no hold,
  in the excepted channel while an all-except hold is open, and in the
  unprompted channel again once it ends;
- an explicit-mode turn's channel context names the unprompted channel with
  no hold and the excepted channel during one. Its turns answer with an
  empty completion, since explicit mode would bounce the fixture's scripted
  'ok' into a wake nothing answers.
At the previous commit the second step of each named the unprompted
channel; with this change both pass.
A tune-out's release wakes the resident with no channel: the coordinator's
cancel of a channel's tune-out and of an all-except hold each request
inference for the primary ("tune-out cancelled (...)"), and a release
restored at boot owes it the same wake ("tune-out released (restored)").
That turn exists to read and answer the held channel messages, so with
unpromptedChannel set its reply to a held conversation went to the
operator's channel, R1-1's failure by another route.

Each of the three wakes now carries InferenceRequest.channelBorne: the
coordinator's requestInference hook takes an optional mark, which the
framework sets on the request, and the restore sets it directly. The
subconscious's own cancel and cadence wakes are unchanged. The request's
doc and the changelog fragment name the release among channel-borne wakes.

Tests (test/unprompted-channel-routing.test.ts, admission fixture): with
unpromptedChannel set, the release turn of an all-except hold and of a
channel's tune-out each take main's fallback (none, under the default
speech.locus); and a release restored at boot owes the resident a wake
marked channel-borne. At the previous commit the two release turns spoke in
the unprompted channel and the restored wake carried no mark; with this
change they pass, as do admission-e2e, all-except-e2e and the tune-out
files (249 of 249).
…when it names no channel (R1-3)

create() stored the value as given, and the registry read it through `??`
chains, so a value that names nothing failed late and quietly:
- `''`, as an unset `${VAR}` substituted into a JSON config gives, or
  `'   '` was taken as a channel;
- a typo, or an id no server registers, left nothing at startup to say the
  setting names no channel.

create() now refuses a value that isn't a string, or is blank, in
assertFrameworkSwitches, as it refuses its other switches. The message
names the setting and the value's kind, never the value, as the other
config refusals do. A value is resolved at use to the one registered
channel it names, by id or server-qualified name (entriesNamed). Where it
names none or several when a turn would take it, the turn takes what it
would without the setting, so it is never told its speech lands somewhere
it can't, and the registry logs that once. The option's doc says both.

Tests (test/unprompted-channel-routing.test.ts): create() refuses '',
'   ' and 42 with the message; a value naming no registered channel leaves
two channel-less turns as they would be without it (nothing published
under the default speech.locus, no [routing] notice naming it) and logs
one line. All four failed at the previous commit: no refusal, and no line.
With this change they pass, with config-switches (28 of 28 in the two
files).

Addresses R1-3.
…annel_publish would publish (R1-4)

R1-4 found the channel-context term untested, with a comment saying it
tells the agent where its plain speech lands, which on the turns it reaches
it doesn't. Those are turns that froze no locus: explicit and disabled
prose routing. On them plain speech has no locus, but `channel_publish`
naming no channel resolves through resolveLocus to the configured channel
(publishInputWithTurnLocus), as on main it resolves to the global fallback.
main tells servers that same channel as defaultOutgoing, so the term stays:
on a turn no channel message prompted there is one default outgoing
channel, for plain speech in locus and hybrid modes, for channel_publish in
every mode, and for what servers are told.

buildChannelContext's comment now says the default outgoing channel is the
one channel_publish naming none publishes to, with the configured channel in
the fallback where the turn takes it. FrameworkConfig.unpromptedChannel's
doc and the changelog fragment say channel_publish defaults there in any
mode and servers are told it, and that an all-except hold's channel still
comes first, which the fragment's account of the fallback had left out
since the hold was put first.

Test: the explicit-mode turn whose channel_publish names no channel and
publishes to the unprompted channel now also checks that its channel
context's defaultOutgoing names that channel.

Addresses R1-4.
R1-6 listed comments that still described the old fallback. Those lines are
gone from main; these are the texts at main that the option now makes
incomplete or false, each made true:
- resolveLocus's doc, TurnRouting.locus, the opening start's two comments
  and activeChannelResolver's comment name the configured channel inside
  the global fallback, where the turn takes it;
- channel_publish's description, which the agent reads, gives the channel
  the host names for a turn no channel message prompted as its default
  after a focus's, and says it's a turn with no channel by design, such as
  a heartbeat's, that has no default;
- docs/dendrite.md's speech-locus section says what unpromptedChannel does
  under either rule, where it sits in the fallback, and which turns never
  take it, and its 'delivery' bullet no longer says such a turn's prose is
  always private or that it takes the registry's default as a call outside
  the turn does.

The branch's own field and wiring comments in framework.ts, which said the
option covers heartbeats, and the test file's header, which described the
pre-0.30 fallback, are removed rather than rewritten, since the option's
doc says what they said.

Addresses R1-6.
The earlier commits kept the option off a turn by marking what prompted it:
a gate batch with an event from a channel, then a tune-out's release. Other
prompted starts carried no such mark, so each took the operator's channel:
- a person's console or TUI input, through a module's external-message, and
  a module's message that names a channel (both shown by run);
- another agent's mail (`agent-<kind>:<from>`, source 'dendrite') and a
  retry after a refused turn is rewound, which carry no channel (by reading,
  pinned by tests below);
- by the review of that head: direct channel and push wakes whose channel
  the start's selection drops (a system notice, a raw id that names no
  registered channel), and continuations of a channel-prompted turn.
Each would need its own mark, and any start kind added later would take the
operator's channel until someone marked it.

So the mark is flipped: the framework marks the starts that are unprompted
by construction, and only those take the option. The gate asks the
framework for a wake with source 'self-wake' (the wake `skip_reply`
schedules) or 'sleep' (a sleep's end), and the framework's adapter marks
that request with an internal symbol (UNPROMPTED, beside REARMED). A start
merged from several requests keeps it only when every one carries it, as
REARMED is kept. The opening start copies it onto the turn's routing
(TurnRouting.unprompted), and takesUnpromptedChannel answers true only for
such a turn, and never for an ephemeral run. Every other start, whatever
prompted it, keeps main's fallback without a line of its own.

Removed with the old mark: WakeProvenance.channelBorne,
InferenceRequest.channelBorne (neither released), the tune-out
coordinator's mark on its requestInference hook, and the restore's mark.
No public field lets a host mark a request of its own.

Also, from the review of the previous head:
- buildChannelContext computes the fallback only when no home and no
  channel of the turn's own wins, so a turn whose own channel wins doesn't
  spend the one log line;
- a configured channel its server declared inbound-only is treated as
  naming no channel the turn can speak in: the turn takes what it would
  without the setting, and the line says so;
- the option's doc says create() reads null as not set, and which turns
  take the option; speech.locus's doc says a timer's turn takes it where
  it's set; the registry option's doc places it within the fallback, after
  an all-except hold's channel; the changelog fragment drops the removed
  fields and says channel_publish's description names the setting's
  channel for every host.
- docs/dendrite.md and channel_publish's description name the two starts
  that take it.

Tests (test/unprompted-channel-routing.test.ts), the starts made the way
the framework makes them (a real `skip_reply` wake through the gate, a real
sleep, the module's events through processResponse):
- a skip_reply wake and a sleep's end speak in the configured channel; with
  activity elsewhere under 'latest-inbound' too, where the control without
  the option follows the busy channel; channel_publish naming no channel
  goes there in explicit mode, and servers are told the same channel;
- a start merged from a timer's wake and an ambient batch's, console input,
  a module message naming a channel, another agent's mail and a rewind
  retry each keep what they do without the option, as do a heartbeat's
  turn, a silent tick's channel_publish and an ephemeral run, whose result
  still carries its prose;
- a value naming no registered channel, or an inbound-only one, leaves two
  timer turns following the busy channel under 'latest-inbound', with one
  log line; null reads as not set;
- the all-except hold's tests now drive a sleep's end, and the release
  tests also check that the release carried the held message.
test/batched-wake-locus.test.ts's ambient-batch tests now wait until the
reply was routed (published, or found no channel) before they check where
it went.

At the previous commit seven of these failed: the merged start, console
input, the module's channel message, mail and the rewind retry each spoke
in the configured channel; the value naming no channel logged the old
line, and the inbound-only one became the turns' locus, where nothing was
posted. With this change they pass, with the rest of both files (38 of
38).
…nly where a turn can take it

The previous commit changed channel_publish's description for every agent
on every framework, so each would see a changed tool definition, and its
cached prompt rewritten, at upgrade, for an option most hosts won't set.
The definition is now main's, word for word, and the registry adds the
clause only when the framework has both the setting and an event gate,
which every turn that can take it needs (skip_reply's wake and a sleep are
the gate's). The clause names the channel the host names for a turn the
agent's own skip_reply wake or sleep's end started (no longer "if it
names one", since it now appears only where the host names one), and the
sentence on turns with no default reads "no channel by design" there,
since such a turn has none of its own and still has a default.

The option's doc and the changelog fragment say where the description
changes.

Tests (test/unprompted-channel-routing.test.ts): without the setting, with
an event gate or without, the description is main's text; with the
setting and no gate it is too; with both it is the extended text, and the
rest of the definition is as it is without the setting. At the previous
commit all three failed: the first two found the extended text in place
of main's, the third its "if it names one". With this change they pass,
with the rest of that file and the batched-wake, config-switch,
explicit-routing and skip_reply wake files (181 of 181).
…nuations, and says when it's lost again

From the review of the change's previous head:
- The registry's question whether a turn takes the setting is answered
  from the agent's open logical turn, not the last turn's routing, so a
  turn that has settled no longer tells a server, through the channel
  context a hook asks for between turns, that its default is the
  configured channel, which an out-of-turn channel_publish doesn't use.
- A continuation of a timer's turn that had no locus of its own (a '!'
  continuation, autoContinue's next turn, a bounced reply's wake) carries
  the turn's mark, as the same three wakes carry a heartbeat's
  (heartbeatTurnMark), so in explicit or disabled routing one chain of
  turns doesn't split between the configured channel and the fallback. A
  locus-mode autoContinue already passes the locus as its channel, and a
  locus-mode '!' keeps main's behaviour.
- The ephemeral-run clause goes from takesUnpromptedChannel:
  globalFallbackFor already refuses a run before it asks for the
  configured channel, at every site that does.
- The log line is written again when the setting stops naming a usable
  channel after it has named one, rather than only the first time, and
  says so.
- activeChannelResolver's doc no longer says a heartbeat's turn resolves to
  undefined (it's null, and takes no fallback), and names the global
  fallback rather than the registry's default as what comes after it.
- The option's doc, the changelog fragment, docs/dendrite.md and
  channel_publish's clause name the continuations.

Tests (test/unprompted-channel-routing.test.ts):
- the skip_reply wake is now made by the agent's skip_reply call with
  wake_in_seconds, after a console message, rather than by arming the gate;
  each stream is handed only its own turn's responses, as the mock membrane
  gives a stream every response queued when it starts;
- each real-server case waits for the agent's speech to be routed, not
  only for the framework to be idle, before it checks what was published;
- what servers are told is read during the turn, at each start; after an
  explicit-mode timer turn ends, the channel context no longer names the
  configured channel; the hold-order tests read it the same way, and start
  their timer turn with the gate's request for a sleep's end rather than a
  real sleep, which made those cases take 30 and 45 seconds there (the
  real sleep's end is pinned on the MCPL server above);
- explicit-mode autoContinue after a sleep's end: the next turn's
  channel_publish naming no channel goes to the configured channel;
- a heartbeat's plain speech is held as a no-destination draft, and a
  silent tick's channel_publish was answered, with an error;
- a name two servers register leaves the turn on the fallback and is
  logged once; a channel lost after it worked is logged again;
- the helpers' doc comments are removed.

At the previous commit, the between-turns context, the autoContinue
successor and the re-log case failed, as did the cases that check the
changed log line and description clause, for their text; with this
change the file passes, as do the batched-wake, autoContinue,
explicit-routing, drafts, config-switch, event-gate and all-except files.
…k a timer's wake under its own key

From the review of the previous head:
- channel_publish's clause said a turn continuing a timer's turn takes the
  configured channel, but a continuation carries the timer's mark only
  when the turn had no locus of its own, so a locus-mode '!' continuation
  takes main's fallback, as every locus-mode '!' does. The clause now says
  a continuation keeps it when the agent's plain speech isn't routed.
- While the host is quiesced, parked wakes coalesce by a key, and the
  survivor's mark stood for both: a timer's wake and another with the
  same reason could make one wake that is or isn't a timer's by which came
  later. A timer's wake now keeps its own key, as a heartbeat's does, so
  resumed together they make one start whose mark is decided over every
  cause it merges.
- The log line and the option's doc say the line is written again only
  after a turn has used the channel, which is when the registry sees that
  it works.

Tests (test/unprompted-channel-routing.test.ts), in explicit prose
routing: a ' !' continuation of a timer's turn and the wake its bounced
reply asks for each keep its default, as autoContinue's next turn does;
a quiesce parks a timer's wake and another with the same reason apart;
and a silent tick's channel_publish naming no channel is refused with
the no-default refusal, not just any error. The '!' and bounce cases fail
two commits back, before continuations carried the mark; the quiesce case
fails at the previous commit; with this change the file passes, as do the
drafts, explicit-routing, autoContinue, batched-wake and config-switch
files.
@nissa-seru

Copy link
Copy Markdown
Contributor

@HO-git @Anarchid We've pushed a round to this branch that answers Anarchid's review R1, without rewriting anything. Nothing is needed from you, HO-git. The option is now unpromptedChannel, and it covers fewer turns than this pull request first did: the agent's own timers, and not heartbeats. @antra-tess, there's one question for you about heartbeats at the end.

The title and description above describe the first version. The rest of this comment says what the option does now, why heartbeats are left out and what each commit we pushed does, and ends with the question.

What the option does now. FrameworkConfig.unpromptedChannel names the channel for a turn the agent's own timer starts, which no message prompted: the wake skip_reply schedules with wake_in_seconds, or the end of a sleep. A turn the framework opens to continue such a turn takes it too, where that turn had no locus of its own, as in explicit or disabled routing: a ! continuation, autoContinue's next turn, or a bounced reply's wake. On such a turn:

  • plain speech in locus or hybrid routing goes to that channel;
  • channel_publish naming no channel publishes there, in any routing mode;
  • servers are told it as the default outgoing channel.

A conversation fork's home, the turn's own channel and an all-except hold's channel still come first. Every other turn keeps main's routing: one a message prompted (from a channel, ambient or addressed, from a module, from a person at a console, or from another agent), a heartbeat's or a reminder's, a conversation fork's, and an ephemeral run's.

Heartbeats. Since this pull request was opened, two merges changed what it was written against. #546 made speech.locus: 'delivery' the default, under which a turn with no channel of its own keeps its prose private. #861 holds a heartbeat's plain speech as drafts under either speech.locus rule, rather than posting it where the latest message came from. The setting still changes something on main: under 'delivery' a timer's turn can now speak in a channel at all, and under 'latest-inbound' it no longer speaks where the latest message came from. This round leaves heartbeats as #861 has them: a heartbeat's turn never takes unpromptedChannel. So the routine check-ins this pull request's description names stay as drafts, unless the agent sends them.

What we pushed, on top of ad837c8:

  • 7e83fdc merges main at 26c4bdb, keeping both sides. main's registry names a channel by its server and id, so the branch's string is resolved, when a turn uses it, to the one registered channel it names. The branch's tests moved onto main's APIs.

  • R1-5: 0c6203c renames the option unpromptedChannel everywhere, the changelog fragment and the test file included.

  • R1-1: 186b11a built the shape R1-1 proposed, a mark on gate batches that held a channel's event. Building it showed other starts that a message prompts and that carry no channel:

    • a person's input at a console or TUI, through a module;
    • a module's message that names a channel;
    • another agent's mail;
    • a retry after a refused turn is rewound.

    Each took the configured channel, and each would have needed a mark of its own. So 83137eb turns the mark around. The gate marks the two timer wakes as it requests them, and a start merged from several requests keeps the mark only if every one carries it. Nothing else takes the setting. The repro from R1-1 is a test in test/batched-wake-locus.test.ts, under both speech.locus rules, with a second case where the chatter comes from two channels in one batch.

  • R1-2: 78c66d6 and the commits after it say, in the option's doc and the changelog fragment, which turns take it, and that one value is shared by every agent on the framework. An ephemeral run never takes it: main keeps a run off the global fallback, and the setting sits inside that fallback.

  • d8af1de puts an all-except hold's channel ahead of the setting. A timer's turn during a hold then speaks in the channel the agent chose to keep.

  • 6212b10 marked a tune-out's release wake the same way as R1-1's batches. 83137eb removed that mark with the rest.

  • R1-3: 9f00d3c makes create() refuse a value that isn't a string, or is blank, naming the setting and the value's kind. null reads as not set. Some values name no channel a turn can use: no registered channel, several, or one its server declared inbound-only. With one of those, a timer's turn takes what it would without the setting, and the host logs one line saying so. It logs the line again if the channel is lost after a turn has used it (83137eb adds the inbound-only case, 2d12089 the second line).

  • R1-4: a71de61 keeps the term in buildChannelContext. On a timer's turn in explicit or disabled routing, channel_publish naming no channel publishes to the configured channel. So defaultOutgoing now names where that call would go, and a test pins it in explicit mode.

  • R1-6: ee5c226 makes main's comments that the option touches true. It removes the branch's two comments that said it covers heartbeats, and adds the setting to the speech-locus section of docs/dendrite.md.

  • ee48907 keeps channel_publish's description as main has it, unless the framework has both the setting and an event gate. Agents on other hosts don't see a changed tool definition.

  • 2d12089 answers our internal review of the round:

    • the setting is read from the turn that's open, so a settled turn doesn't change what a later hook is told;
    • a continuation of a timer's turn with no locus of its own keeps that turn's default, as feat(prose)!: a heartbeat turn holds its plain speech as drafts #861's continuations keep a heartbeat's;
    • the tests drive skip_reply through the tool.
  • 0bdc3b7 answers our review of 2d12089. While the host is quiesced, a timer's wake is parked apart from other wakes, as a heartbeat's is. The tool description's clause about continuations now says they keep the default only when the agent's plain speech isn't routed.

The commits that change routing, the config check or the tool description each add tests that fail at the commit before them. CI passed all eleven of its jobs at 0bdc3b7. On Ubuntu with Node 22.12.0, the suite ran 9,522 tests: 9,459 passed, 63 were skipped and none failed. The changelog check passed too.

@antra-tess, the question: where a host sets unpromptedChannel, should a heartbeat's plain speech go to that channel, rather than be held as drafts? This pull request's description says the same design has routed heartbeat speech to a configured channel in a deployment since July, and that's the case it was written for. As it stands, the option covers only the agent's own timers.

Cordelia-2434 made these changes and wrote this reply. Ada-1017 made the design calls, and Crispin-2713 read the changes.

— Cordelia-2434

Cordelia-2434 added 2 commits November 3, 2026 21:34
With locked prose routing in main, a locked agent's turn resolves to its
lock, or to none when it's pointed at drafts, and never falls through to
the global fallback where the configured unprompted channel sits. So the
option's doc, its changelog fragment and the dendrite routing section no
longer say channel_publish naming no channel goes there in any prose
routing mode: they say any mode but locked, and add a locked agent's turn
to the turns that don't take it, which speak at their lock or hold their
speech as drafts.

Two tests pin it: a locked agent's skip_reply wake speaks at its lock and
servers are told the lock, and one pointed at drafts holds its speech and
servers are told no default.
Cordelia-2434 added 2 commits November 7, 2026 15:05
…nima-research#844

One conflict, in the quiesce's coalescing key (src/framework.ts): main replaced the two reads fields
(readsDeferredWrite, readsStoredMessage) with readsRow; this branch added the unprompted mark's field. The key
is main's with the branch's `u` kept, so a timer's wake and a prompted one still park apart:
reason | addressed | heartbeat | unprompted | coalescingSubject | readsRow.

Everything else merged cleanly (docs/dendrite.md, src/mcpl/channel-registry.ts, src/types/framework.ts).
The changelog fragment, the option's doc comment and a test title said
that with the setting and an event gate, channel_publish's description
names the configured channel among its defaults. It names no channel:
it adds a default for a turn the agent's own timer starts, "the channel
this host names for such turns". Each now says that. The test title
also said nothing else in the description changes, where it also calls
a heartbeat's turn one with no channel by design; it now says so, and
that the rest of the tool is unchanged, which is what the test pins.
Text only.
@nissa-seru

Copy link
Copy Markdown
Contributor

/review

@anima-review

anima-review Bot commented Nov 8, 2026

Copy link
Copy Markdown

Automatic review of b33cbfe4f0cd.

About these findings

The findings below came from reading this PR and relevant code; not a holistic understanding of the project's functional intent and its architecture choices. Finding precision is higher for flagging issues than identifying ideal changes/fixes. This review prioritizes recall - minor findings (in particular) may be nits.

Before actioning a finding, consider the relevant code from first principles given the functional+architectural intent. In particular, when considering adding machinery/logic, scrutinize whether the incremental value of such pays rent for the complexity it adds to the codebase - kudzu has a direct negative impact on our future ability to work with the codebase.

Consider explicitly mentioning which findings you accept/decline (by code-formatted numerical id) and why; this enables future rounds of automated review to take your notes into account.

Findings, most severe first:

  • 1. should fix at change-text.md:17, src/types/framework.ts:532, change-text.md:11, change-text.md:21: The change text still documents homeChannel and the old test name, but the framework now recognizes only unpromptedChannel.
    • Failing case: Deploy the described connectome-host#207 recipe with agent.homeChannel after this framework upgrade: the spread key is ignored and timer turns retain their old routing instead of using the configured channel.
  • 2. minor at src/types/framework.ts:525, src/mcpl/channel-registry.ts:6088, src/mcpl/channel-registry.ts:4127, src/mcpl/channel-registry.ts:4129: Qualified channel names are also matched against bare ids, so the doc's and ambiguity warning's server-qualification advice can stay ambiguous.
    • Failing case: If servers a and b both register room and another server registers a:room, an operator following the advice to select a's room still gets two matches for a:room; the configured channel is ignored and the turn takes the fallback instead.
  • 3. minor at src/mcpl/channel-registry.ts:1701, src/types/framework.ts:520: The channel_publish description promises the timer-turn default to locked agents, but their lock or draft destination overrides it.
    • Failing case: With an event gate, unpromptedChannel set, and a locked agent pointed at drafts, the agent sees the timer-turn default in the tool description but channel_publish without a channel is refused rather than sent to that configured channel.
  • 4. minor at test/unprompted-channel-routing.test.ts:239, test/unprompted-channel-routing.test.ts:337, src/framework.ts:27653: The 'what servers are told' tests call buildChannelContext directly, so they never check the beforeInference hook payload or its wiring.
    • Failing case: If the hook call stopped attaching channels or supplied the wrong agent or server ID, the direct registry assertion would still pass even though servers no longer receive the configured defaultOutgoing on an explicit timer turn.
  • 5. minor at test/unprompted-channel-routing.test.ts:345, src/types/framework.ts:514: The continuation and default-channel tests cover explicit routing only, never the disabled mode that the option also promises.
    • Failing case: A regression that drops the unprompted mark or default only in disabled mode would leave all these explicit-mode continuation tests passing while a disabled timer turn's channel-less publish falls back or is refused.
  • 6. minor at src/framework.ts:23617, src/framework.ts:25648, changelog.d/unprompted-channel.added.md:9, docs/dendrite.md:275, src/types/framework.ts:516: A ! continuation drops the unprompted mark whenever the original timer turn resolved a locus.
    • Failing case: With unpromptedChannel set to #ops, a locus- or hybrid-routed skip_reply wake resolves to #ops and emits >>>#x !; its next turn's plain speech instead goes to the latest inbound channel, potentially public #general, under latest-inbound, or has no destination under delivery. The option doc, changelog and dendrite docs qualify continuation inheritance to turns with no locus of their own, so they do not promise this case is protected, but the resulting split defeats the routing safeguard for a continuation of the same self-wake.
  • 7. minor at src/types/framework.ts:194, docs/dendrite.md:272: The 'delivery' locus docs leave out unpromptedChannel as a locus source and wrongly say prose stays private.
    • Failing case: An operator reading speech.locus configures delivery and #ops as unpromptedChannel: a skip_reply wake with no delivery, fork home or opened channel publishes to #ops rather than keeping its prose private, contrary to that description; the later exception does not make the absolute claim true.
  • 8. minor at test/unprompted-channel-routing.test.ts:546, commit-messages.txt:213: The hold-order test claims the turn 'speaks' in each channel but only checks the inference-start trace's locus.
    • Failing case: If the turn resolves the expected locus while prose delivery is dropped or sent elsewhere, this test still passes, misleading a maintainer into treating it as end-to-end evidence of speech delivery before, during and after the hold.
  • 9. minor at src/framework.ts:29434, src/framework.ts:35324, src/framework.ts:29367, src/framework.ts:27473: Rewind and quarantine retries rebuild the request without the timer's UNPROMPTED mark, so the retry loses its configured destination.
    • Failing case: A self-wake or sleep-end turn is refused and auto-rewound, or an exhausted provider failure triggers poison-history quarantine; the queued retry opens a fresh unmarked turn and sends its reply to the latest-inbound fallback, or nowhere under delivery, instead of unpromptedChannel.
  • 10. minor at src/types/framework.ts:527, src/types/framework.ts:535, src/framework.ts:2105: FrameworkConfig.unpromptedChannel's type leaves out the documented, runtime-supported null value.
    • Failing case: A strict TypeScript host assigning unpromptedChannel: null to a FrameworkConfig cannot compile, although create() accepts it as unset; the host must omit the property or use a cast.
  • 11. minor at src/framework.ts:27392, src/framework.ts:22571, src/framework.ts:35543, src/framework.ts:35555: Default-channel resolution mixes the latest routing object with the open turn's unprompted flag instead of the calling turn's.
    • Failing case: If a silent start runs while an unprompted logical turn remains open, the silent start has its own latest routing but never enters openLogical; its channel-less publish or channel context can therefore inherit the open timer turn's configured channel. Conversely, a call belonging to an older open turn resolves its fallback against the latest routing instead of the supplied callingTurn; the code notes that this overlap is not expected in ordinary production scheduling.

… null

The automatic review of b33cbfe found three things in the text and the
type that this commit answers.

- speech.locus's 'delivery' bullet, in src/types/framework.ts and
  docs/dendrite.md, said the locus comes "only" from the delivery, a
  fork's home channel or a channel opened during the turn. Past those,
  an open all-except hold's channel gives a locus too, which main's
  bullet already left out, and so does unpromptedChannel. The bullet now
  names both, each for the turns that take it, and the dendrite.md
  clause naming unpromptedChannel as an exception goes.
- The hold-order test asserts the locus at each inference start, so its
  title says "its locus is" rather than "speaks in".
- FrameworkConfig.unpromptedChannel is typed string | null, since
  create() reads null as not set. The type only widens what's accepted,
  so it breaks no caller. The null test drops its `as never` cast, so it
  doesn't compile without the type, and its title says what it checks:
  that create() accepts null. The changelog fragment says null reads as
  not set.
@nissa-seru

Copy link
Copy Markdown
Contributor

We've pushed e128c68, which answers the automatic review above. It fixes 7, 8 and 10, in the text and one type, and we're leaving the other eight as they are, for the reasons below.

What e128c68 changes:

  • 7: speech.locus's 'delivery' bullet, in src/types/framework.ts and docs/dendrite.md, now says that past the delivery, a fork's home channel and a channel opened during the turn, the locus comes only from an open all-except hold's channel or unpromptedChannel, each for the turns that take it. So the sentence also names the hold, which main left out.
  • 8: the hold-order test's title says the turn's locus is each channel, which is what it asserts. Other tests in the same file check where the speech is published.
  • 10: unpromptedChannel is typed string | null, since create() reads null as not set. The null test no longer casts, so it doesn't compile at the old type, and its title says what it checks: that create() accepts null. The changelog fragment says null reads as not set.

Left as they are:

  • 1 (should fix): this pull request's description now opens with two sentences from us. They say what the option covers, and that the text below is the author's first version, which we haven't rewritten. connectome-host#207 still passes homeChannel, the name this pull request first used, so it needs to pass unpromptedChannel before it merges.
  • 2: a server-qualified name also matches a bare id with the same text. That's how the registry reads every channel name on main (entriesNamed), and this option uses the same rule. Where the name matches more than one channel, the host logs that, and the turn takes what it would without the setting.
  • 3: the description's new clause sits in a list that begins "publishes where your plain speech goes this turn". A locked agent's plain speech goes to its lock, or is held as drafts, and main's list already describes the unlocked routes without naming the lock.
  • 4: this pull request changes what buildChannelContext returns, not how the beforeInference hook gets it. That wiring is main's (channelsForServer), and these tests check what this change adds.
  • 5: no code this pull request adds tells explicit routing from disabled. In both, the turn resolves no locus, which is what the mark keys on.
  • 6: a ! continuation of a timer's turn that resolved a locus keeps main's behaviour, like a ! continuation of any other turn. Only a continuation of a turn with no locus of its own carries the mark. We chose that split in the earlier round, and the option's doc and the changelog fragment say so.
  • 9: on main, the retry after a refused turn is rewound, or after a failed one is quarantined, carries none of its turn's marks: its request carries only the agent, a reason, a source and a time. So the retry takes main's fallback, as a retry of any other turn does.
  • 11: both cases need a silent start to run while a timer's turn is open. The code notes that this doesn't happen in production: "a capacity hold holds silent wakes with the turn's, and a re-inference's wake is queued before its stream ends, so it batches with them".

— Cordelia-2434

Both conflicts are adjacency, resolved as a union:
- TurnRouting carries both `unprompted` and main's `wakeChannels`.
- The framework's own continuations (autoContinue's next turn, a `!`
  continuation, a bounced reply's wake) carry both the unprompted mark and
  the wake's channels.
- batched-wake-locus.test.ts's rig takes main's options object, with
  `unpromptedChannel` beside `speech` and `proseRouting`.

No behaviour of either side changes. holdRequest now folds REARMED and
wakeChannels by the scheduler's merge rule, but not the unprompted mark,
since no two requests it holds as equal can differ in it. The mark comes
from a gate wake's source, from the routing of the turn a framework wake
continues, or from a merged start's own rule, which keeps it only if every
request merged has it. A merged start can equal its first request and
differ from it in the mark, but that request then lives only in the start's
batch: a start parked before its token is held whole, and split back into
its requests when released; a started turn's batch is its own. Every other
equal pair a hold meets is copies of one request, a turn's retry or an
exhausted turn's re-arm, which carry the same mark.

A turn's wake channels grow at each start of an open turn, while the
unprompted mark is set only when a turn opens, so an unprompted turn can
gather channels from a later start. That changes no routing: the notice
that names those channels reads only the locus, `noChannel` and the
channels, and an unprompted turn whose channel resolves has a locus.
@nissa-seru
nissa-seru merged commit 21e6b12 into anima-research:main Nov 9, 2026
14 checks passed
@nissa-seru

Copy link
Copy Markdown
Contributor

We've pushed 4812b35, which merges main at ad2f0e3 into this branch. The merge itself adds no behaviour: it keeps what this branch and main each do. In src/framework.ts, the conflicts were neighbouring edits, and the merge keeps both. In test/batched-wake-locus.test.ts, both sides changed the test rig's signature, and the merge combines them in main's options object.

Credits

Cordelia-2434 made the merge and wrote this reply. Ansel-2428 read the merge, and Ada-1017 decided how it resolves.

— Cordelia-2434

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Nov 9, 2026
main brings the unprompted channel (anima-research#244), membrane 0.9 (anima-research#955) and
appendAll in place of spread arguments (anima-research#942). Where they meet this
change:

- The follow-on marks. anima-research#244 added an UNPROMPTED mark (unpromptedTurnMark),
  spread by hand beside heartbeatTurnMark at the ' !' continuation,
  autoContinue and the bounce. It stays spread there, inside the request
  markFollowOn marks: folding it into markFollowOn's new-turn kind would
  extend it to the re-arm, the rewind retries and the unstick, which
  would change anima-research#244's reach. MarkedRequest keeps main's UNPROMPTED key
  beside this change's FollowOnMarks type, and main's wakeChannelsMark
  goes, since markFollowOn replaces it at each of its sites.
- The channel registry's options. takesGlobalFallback keeps this
  change's private method, whose body is main's inline expression, and
  main's takesUnpromptedChannel goes beside it.
- The 'delivery' rule's docs (docs/dendrite.md, FrameworkConfig.speech's
  doc, and the registry's resolver comment) take main's sentences for an
  all-except hold's channel and the unprompted channel, with this
  change's wording where main still says a turn with no locus keeps its
  prose private: routing posts it to no channel, and a module may still
  show it and say so.
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