Repository navigation
feat(speech): let a module say where it showed a turn's plain speech - #940
Open
nissa-seru wants to merge 1 commit into
Open
nissa-seru wants to merge 1 commit into
nissa-seru wants to merge 1 commit into
Conversation
Under prose routing by locus or hybrid with a channel registry, a turn
with no channel told the agent its reply "had no channel to go to" even
when the host had shown it to the person who wrote. This adds an
optional module hook through which a host says where it showed a piece
of speech, so the turn's receipt names the place and the notice goes.
- Module.onSpeechRouted(agentName, text, context) is asked from
routeLocusSpeech about every plain piece of speech as it is routed,
all modules at once, about exactly that text. A module resolves to a
SpeechSent: { sentTo } where it showed the text, {} otherwise.
context.final says whether the piece is the turn's final reply, and
context.openedBy names the request that opened the turn and the
messages of every wake the turn took. A heartbeat's held prose,
private envelopes and explicit sends are never asked about.
- With no locus, a piece waits for the answers up to
SPEECH_RECEIPT_WAIT_MS (5 s, exported). A named place stands in for
the delivery, with no [send-undeliverable]. A piece no module showed
gets [send-undeliverable], which now names it by its opening words,
quoted and cut within 40 characters; as before, it says another
surface may have shown it. A module that throws, hasn't answered by then, or is removed or
replaced while asked names no place.
- A piece's own channel post never waits for its answers. Pieces route
in order, so a post after a piece with no channel waits behind that
piece's answers while one is due.
- The turn's [delivered] receipt waits, up to the bound, for answers
still due, and names the channels and then the places modules named.
It adds "no channel posted it" only when none of the turn's speech
went, or was tried, to a channel. A failed post keeps its own notice.
- A re-armed turn, a `!` continuation, an autoContinue run and a
bounce's wake carry the opening messages of the turn they carry on.
An agent's next turn starts only after its previous turn's final
routing and receipt, which a test pins.
- The [routing] notice for a turn with no channel and the
addressed-elsewhere notice on such a turn no longer say "stays in
your archive".
onAgentSpeech, dispatchSpeech and SpeechHandlerOptions are unchanged.
This was referenced Oct 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This adds
Module.onSpeechRouted, an optional hook through which a module tells the framework where it showed a piece of a turn's plain speech, so that an agent whose reply reached someone without going through a channel is told where it went, rather than only that it had no channel to go to.Under prose routing by locus or hybrid, no channel posts the plain speech of a turn that has no channel, and at
d700dce18bthe agent is then told[send-undeliverable] Your previous reply (<n> chars) had no channel to go to, whatever else showed the reply. With this change the framework asks each module that implements the hook about each piece of plain speech it routes. When a module names a place, the turn's[delivered]receipt names it, and a turn with no channel gets no notice for that piece. Speech that no module names a place for still gets the notice, which now quotes the piece's opening words. The new test file's 53 tests pass at this head. Run againstd700dce18b'ssrc/, with the one constant the base doesn't export defined in the test file, 48 of them fail (see Tests).@antra-tess, we're asking you to review this before it's merged, since it changes what an agent reads after a turn with no channel: its
[delivered]receipt can name where a module showed a piece of its plain speech, and the[send-undeliverable]notice now quotes the piece's opening words.Problem
Under the default rule for a turn's channel (
speech.locus: 'delivery'), a turn's plain speech goes to the channel of the delivery that woke it, a conversation fork's home channel, or a channel the agent opened during the turn (docs/dendrite.md:271atd700dce18b). A turn that has none of these, such as one woken by a message a host takes in from a terminal or a web page, has no channel, and no channel posts its speech. The framework then writes the agent a notice.test/route-failure-targeting.test.ts:215atd700dce18basserts its opening, and in a run ofd700dce18b'ssrc/it read in full:A host can still show the reply to the person who wrote, in the terminal or page the turn came from. At
d700dce18btheModuleinterface (src/types/module.ts:108–232) has no method through which a module could tell the framework so.onAgentSpeech, through which a speech handler receives the agent's speech, resolves tovoid(:186–190). So an agent whose reply the host did show to the person it was answering gets this notice, and nothing tells it where the reply went.Changes
This branch is on
mainatd700dce18b.mainwas at48f26ce56con October 30 at 07:13 UTC and ata64cfc010fat 20:23 UTC, andgit merge-treefinds no conflicts between the branch and either.Module.onSpeechRouted(agentName, text, context)(src/types/module.ts:192–214) is optional and resolves to aSpeechSent. The framework calls it with each piece of plain speech a turn routes, under prose routing by locus or hybrid, on a host with a channel registry: each tool round's prose as it is routed, and the turn's final reply. It never asks about private envelopes, explicit>>>sends or a heartbeat's held prose. A module answers in one of two ways:{ sentTo: '<place>' }, when it showed that text to someone;{}otherwise, such as for a turn that none of its surfaces opened.[delivered]receipt names each place as "sent to ", after any channels the speech went to, each once and in the order the speech was routed. When none of the turn's speech went to a channel, and no post to one was tried, the receipt adds "no channel posted it", as in[delivered] plain speech → sent to the console that sent this turn · no channel posted it. A piece with no channel that a module showed gets no[send-undeliverable]notice. A piece with a channel is posted without waiting for the modules' answers about it, and if the post fails, its own notice stays and the receipt names the place.SPEECH_RECEIPT_WAIT_MS(5 s, exported from the package). A module that throws, hasn't answered by then, or is removed while it's being asked names no place. A throw, a missed wait and an answer after the wait each get a log line naming the module. A piece with no channel waits for its answers before its notice is decided, and the turn's receipt waits, up to the same bound, for answers still due. Since a turn routes its pieces in order, a later piece waits behind one with no channel: in a test, a hybrid>>>post that follows such a piece posts only once that piece's answer has come.[send-undeliverable], which now names the piece by its opening words:Your plain speech "<opening>" (<n> chars) had no channel to go to, where it saidYour previous reply (<n> chars). The opening is quoted as a JSON string, with a[that would open a notice written as\u005b, and when the piece is longer than 40 characters it's cut at the last word that fits, or inside a first word longer than that, and marked "…". The notice still says "if another surface showed it (a reply thread, the console), that copy stands".context.finalis true for the turn's final speech, the speech after its last tool call. A refused XML tool attempt and a call that the output limit cut off unrun don't end a speech. Neither tracesinference:tool_calls_yielded, so a relay that starts a new speech at each of those events doesn't start one there either.context.openedBynames the request that opened the turn (reason,source,timestamp), andmessages, the messages added by the module answers whose wakes the turn took. Each message is its stored id or a held write's handle, and a module context'sgetMessagetakes either.openedBystays the same after a context-budget restart and a re-arm. A turn the framework queues to carry another on (a!continuation, an autoContinue run, a bounce's wake) gets the messages that opened the turn it carries on.InferenceRequest.openingMessages, marked@internal, carries them between requests.[routing]notice that tells an agent its speech now has no channel reads "Your plain speech currently has no channel, so no channel posts it. Use an explicit send tool to reach a channel.", and on a turn with no channel, the notice of a message addressed to the agent from another channel ends "Your plain speech has no channel this turn; reaching them takes an explicit send." Each said the speech "stays in your archive".routeLocusSpeech, the function locus mode routes plain speech with, so modules are asked about them. The hold of a heartbeat's prose thatdeliverHybridEnvelopemade moved with them into that function.ModuleRegistry.askSpeechRoutedasks the modules and returns the places they named.ChannelRegistrynow passesonRouteFailurethe words routed, so the notice can quote them.trailingRunCount(src/prose-segments.ts) counts the reply's runs of prose where a turn's rounds aren't routed live, so that only those are asked about as final.onAgentSpeech,dispatchSpeechandSpeechHandlerOptions.onRouteFailure's comment drops its sentence that none of the notices claims the reply reached no one.Module, anddocs/dendrite.mdsays that a module may say it showed the speech of a turn with no channel, which the turn's receipt then names.test/speech-sent-receipts.test.tsis new, with 53 tests.test/exhausted-turn-rearm.test.tsgains a test that a re-armed continuation keeps the messages that opened its turn, and two assertions intest/route-failure-targeting.test.tsfollow the notice's new wording.What each part is for
{}covers every other case, and the notice keeps its word about another surface.openedBy.messages, carried across restarts and continuations, lets a module tell which of its surfaces sent the turn, so it can answer for the surface the turn came from.context.finallets a module whose surface receives only a turn's reply answer for the reply and not for each round's prose.>>>post whose outcome is unknown, since a channel may then have posted it.With other open pull requests
At 07:13 UTC on October 30, 37 of the 41 open pull requests change a file that this change changes, each by its diff from its merge base with
main. Each was merged bygit merge-treewithmainat48f26ce56c, and with this change merged into it, and the conflict blocks in each file were counted.mainwithout conflicts. Six of them, fix(history, prose): one table of the tools that carry an agent's words, read by silencing, search, extract and the semantic index #914, Add opt-in required admission for inbound MCPL traffic #906, fix(references): while a reference is markednever, don't fetch or save its payload, and forget an earlier copy's path #851, fix(framework)!: decline a maxStreamTokens restart that won't fit #842, feat(agent): declare later tool and system-text changes in the context #690 and fix(mcpl,api): read a JSON-RPC envelope once: answer id null, refuse an invalid request with -32600 #534, at5d9af7beec,119cb8662d(Add opt-in required admission for inbound MCPL traffic #906's head at that time, which has since moved),2f15c4293d,81ef4cfaf1,22dfd5a59bandf9337c4794, also merge with this change without conflicts.813fe034fc, merges withmainwithout conflicts and conflicts with this change: 13 blocks insrc/framework.tsand one, in a comment, insrc/mcpl/channel-registry.ts. The 13 are inholdRequestandrunInferencePass, where held and coalesced wake requests are combined; threedeliverHybridEnvelopecalls inrouteSilencedHybridand adeliverHybridProsecall indriveStream; two places inappendProseDeliveryReceipt; the no-channel[routing]notice inannounceLocusIfChanged; the requestsrequestProseContinuationandbounceProsequeue; and two places inbeginAgentTurn.main. With this change merged, 26 of them have the same number of conflict blocks in each file, and four change:f088f4f37d:src/framework.tsgoes from 4 blocks to 8, andsrc/types/agent.tsgains one;665957dd5f:src/framework.tsgoes from 112 to 110;0bae78b1dd:src/framework.tsgoes from 109 to 107;c3b440879a:src/mcpl/channel-registry.tsgoes from 10 to 11.Tests
Both runs below were on Linux. The first printed Node 24.22.2 and npm 11.19.1.
ecf46b99, this head's tree.npm installgave TypeScript 7.0.2, chronicle 0.5.1, context-manager 0.24.1, membrane 0.8.0, mcpl-core 0.4.0,@modelcontextprotocol/clientand@modelcontextprotocol/server2.4.0, discord.js 14.27.0, ws 8.22.0, chokidar 4.0.3 and@babel/parser7.29.12.tsc --noEmitand the build succeed, and 36 test files pass: 1,778 tests, none failing or cancelled. 33 of the files ran one at a time with--test-force-exit, the flagnpm testuses, andstop-with-work-in-flight,stop-with-host-operationsandconversation-routingran without it, as CI runs them.speech-sent-receiptspassed 53 of 53,route-failure-targeting101 of 101 andexhausted-turn-rearm84 of 84.ecf46b99withd700dce18b'ssrc/in place of this change's:speech-sent-receipts: 48 of 53 fail.d700dce18bexports noSPEECH_RECEIPT_WAIT_MS, so with the test file's import as it is, the file fails to load. For this run the test file defined the constant itself, as 5,000. The 5 tests that pass check whatd700dce18balready does: an explicit>>>send isn't asked about, a host with no channel registry writes no notice or receipt, a hybrid reply that no module showed gets the notice, a heartbeat's held prose stays a draft, and a message read during a running turn still wakes a turn of its own.route-failure-targeting: 1 of 101 fails, the test of the notice's new wording.exhausted-turn-rearm: 1 of 84 fails, the new test of a re-armed continuation's opening messages.Not verified
Surfacein the new test file) and a stand-in provider (MockMembrane). No module in this repository implements the hook, andgit grepfinds noonSpeechRoutedin connectome-host'smainatbf21143bdf.explicitanddisabled. The hook's doc says modules are asked only under locus or hybrid routing (src/types/module.ts:204). No test runs either of the other two modes with a module that implements the hook.mainat48f26ce56c, and with the other pull requests above, was only checked for conflicts. No test ran on any of the merges.changelog.d/speech-sent-receipts.changed.mdandchangelog.d/speech-sent-receipts.added.md.Effie-2289 made the change. Agnes-1009 set what it had to do, and Ada-1017 made its design calls. Crispin-2713 and Alfred-2570 checked whether each part was needed. Internal reviews read its rounds, and Ansel-2428 read the commits that answered the last two. Humphrey-2407 wrote this description.