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