Skip to content

feat(speech): let a module say where it showed a turn's plain speech - #940

Open
nissa-seru wants to merge 1 commit into
anima-research:mainfrom
nissa-seru:effie/f1-speech-receipts
Open

nissa-seru wants to merge 1 commit into
anima-research:mainfrom
nissa-seru:effie/f1-speech-receipts

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

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 d700dce18b the 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 against d700dce18b's src/, 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:271 at d700dce18b). 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:215 at d700dce18b asserts its opening, and in a run of d700dce18b's src/ it read in full:

[send-undeliverable] Your previous reply (9 chars) had no channel to go to — this turn came from no channel, and it had no home or recent channel to reply in; if it should have had one, ask whoever runs this host. This is a routing/configuration situation, not a channel failure. This route delivered it nowhere; if another surface showed it (a reply thread, the console), that copy stands. It is saved in your archive.

A host can still show the reply to the person who wrote, in the terminal or page the turn came from. At d700dce18b the Module interface (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 to void (: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 main at d700dce18b. main was at 48f26ce56c on October 30 at 07:13 UTC and at a64cfc010f at 20:23 UTC, and git merge-tree finds no conflicts between the branch and either.

  • The hook. Module.onSpeechRouted(agentName, text, context) (src/types/module.ts:192–214) is optional and resolves to a SpeechSent. 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.
  • What a place changes. The turn's [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.
  • The wait. The framework asks the modules about a piece all at once, and waits for their answers for at most 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.
  • The notice. Speech with no channel that no module names a place for still gets [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 said Your 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.final is 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 traces inference:tool_calls_yielded, so a relay that starts a new speech at each of those events doesn't start one there either.
  • context.openedBy names the request that opened the turn (reason, source, timestamp), and messages, 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's getMessage takes either. openedBy stays 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.
  • Turn order. A new test pins that an agent's next turn starts, and compiles its context, only after its previous turn's routing and receipt are done, so the answers about a turn's final reply come before the next turn begins.
  • Two routing notices. The [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".
  • Hybrid's plain envelopes now go through routeLocusSpeech, the function locus mode routes plain speech with, so modules are asked about them. The hold of a heartbeat's prose that deliverHybridEnvelope made moved with them into that function.
  • Other additions. ModuleRegistry.askSpeechRouted asks the modules and returns the places they named. ChannelRegistry now passes onRouteFailure the 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.
  • Unchanged: onAgentSpeech, dispatchSpeech and SpeechHandlerOptions.
  • Comments. Two comments on why a turn's speech goes to the locus frozen at its start keep the rule and drop their reasons and incident history, and a third, above the trailing dispatch, is gone. onRouteFailure's comment drops its sentence that none of the notices claims the reply reached no one.
  • Docs. The README documents the hook under Module, and docs/dendrite.md says that a module may say it showed the speech of a turn with no channel, which the turn's receipt then names.
  • Tests. test/speech-sent-receipts.test.ts is new, with 53 tests. test/exhausted-turn-rearm.test.ts gains a test that a re-armed continuation keeps the messages that opened its turn, and two assertions in test/route-failure-targeting.test.ts follow the notice's new wording.

What each part is for

  • Two answers. A module names a place only where it showed the text. A module asked about a turn its surfaces didn't open can't know whether something else showed the speech, such as a parent process the host relays the reply to, so {} covers every other case, and the notice keeps its word about another surface.
  • Asking at once, with a bound. The longest a piece waits is one bound, however many modules there are, and a module that never answers can't hold up a notice or a receipt for longer.
  • Posting without waiting. A piece with a channel is posted without waiting for the modules' answers about it.
  • 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.final lets a module whose surface receives only a turn's reply answer for the reply and not for each round's prose.
  • The opening words let an agent whose turn had several pieces tell which one had nowhere to go.
  • "no channel posted it" is left out once a post to a channel was tried, such as a hybrid >>> 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 by git merge-tree with main at 48f26ce56c, and with this change merged into it, and the conflict blocks in each file were counted.

Tests

Both runs below were on Linux. The first printed Node 24.22.2 and npm 11.19.1.

  • In a clean checkout of ecf46b99, this head's tree. npm install gave TypeScript 7.0.2, chronicle 0.5.1, context-manager 0.24.1, membrane 0.8.0, mcpl-core 0.4.0, @modelcontextprotocol/client and @modelcontextprotocol/server 2.4.0, discord.js 14.27.0, ws 8.22.0, chokidar 4.0.3 and @babel/parser 7.29.12. tsc --noEmit and 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 flag npm test uses, and stop-with-work-in-flight, stop-with-host-operations and conversation-routing ran without it, as CI runs them. speech-sent-receipts passed 53 of 53, route-failure-targeting 101 of 101 and exhausted-turn-rearm 84 of 84.
  • Red first, in a checkout of ecf46b99 with d700dce18b's src/ in place of this change's:
    • speech-sent-receipts: 48 of 53 fail. d700dce18b exports no SPEECH_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 what d700dce18b already 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

  • The whole suite. The runs cover 36 of the repository's 388 test files. Nothing ran on Node 22.12.0, which CI's test job uses.
  • A real host. The tests use stand-in modules (Surface in the new test file) and a stand-in provider (MockMembrane). No module in this repository implements the hook, and git grep finds no onSpeechRouted in connectome-host's main at bf21143bdf.
  • Prose routing explicit and disabled. 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.
  • A heartbeat under hybrid routing. The new test of a heartbeat's held prose runs under locus routing.
  • Merges. This change merged with main at 48f26ce56c, and with the other pull requests above, was only checked for conflicts. No test ran on any of the merges.

  • Changelog fragments added: changelog.d/speech-sent-receipts.changed.md and changelog.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.

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

1 participant