Skip to content

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

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

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

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Why. On main, an agent whose turn has no channel is told its reply "had no channel to go to", though a host may have shown the person that reply in a terminal or on a web page.

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 the agent is told where its reply went.

This pull request is the agent-framework half of the fix. connectome-host's half is anima-research/connectome-host#616. In it, the host's surfaces, its terminal and web page among them, answer the new hook. anima-research/connectome-host#616 needs an agent-framework release that carries this change. Until anima-research/connectome-host#616 merges, no host implements the hook.

A turn with no channel, whose reply a module showed someone

  • base (main): the agent is told "[send-undeliverable] Your previous reply ( chars) had no channel to go to", whatever else showed the reply. The Module interface has no way for a module to say it showed it.
  • head: under prose routing by locus, hybrid or locked, on a host with a channel registry, a module that implements the hook is asked about each piece of plain speech the framework routes or holds as a draft. It isn't asked about a private envelope, an explicit >>> send, an ephemeral run's unheld speech with no channel, or speech an agent routes or holds once another agent has taken its name. When a module names a place, the turn's [delivered] receipt names it, and the turn gets no notice for that piece. Where a piece with no channel isn't held as a draft, and no module names a place for it, it still gets the notice. An ephemeral run's piece gets none, as on main. The notice now opens Your plain speech "<opening>" (<n> chars), where it opened Your previous reply (<n> chars). Code that matches the old opening needs the new one.

A host with a module that implements the hook

  • base (main): Module has no onSpeechRouted.
  • head: the framework calls each module with each piece as it routes it, synchronously, and routing goes on when the calls return. For a piece no channel takes, a surface may show it then, with a write that doesn't wait, and answer where. A module that throws, or returns a promise, names no place. A piece of the turn's final speech comes after the turn's inference:completed trace and before the agent's next inference:started.

Notices that say what became of plain speech

  • base (main): these say what became of the speech:
    • the [routing] notice for speech with no channel, the notice of a message addressed to the agent from elsewhere on a turn with no channel, and the inbound-only note, which channel_open's result also carries: it stays in the agent's archive;
    • the [speech-not-posted] notice, and the two notices of a failed reply: nothing was posted;
    • the [delivered] receipt's notes on held speech, the [drafts] notice and the [continue] row: it wasn't sent, or remains only in the agent's history;
    • the note on a turn that fails or is stopped: speech its rounds routed live was handed on for delivery.
  • head: a module may have shown the speech. So the first three groups' words now leave that open, and the note on a failed or stopped turn counts speech a module showed. For example, "Nothing was posted" becomes "The framework posted nothing there". The details quote each.
    • Code that matches the old words, such as a host's tests, needs the new ones.
    • This change's own tests follow the new words.

An operator's unstick

  • base (main): the turn an unstick queues carries none of the channels the agent's latest turn came from. So when an ambient batch woke the stuck turn, which leaves it no channel to post to, the notice for the unstick's speech says "this turn came from no channel, and it had no home or recent channel to reply in".
  • head: it carries the latest turn's channels and opening messages, as the rewind retries an unstick starts do. So that notice names the channels the batch came from.

A reply set aside by a rewind or a shed

  • base (main): when a refusal rewind, an operator's unstick or an inference failure's shed sets a reply aside, the no-channel notice about that reply stays.
  • head: the no-channel notice next to the reply that quotes its opening goes with it.

Documented rule: FrameworkConfig.speech.locus's 'delivery'

  • base (main): "A turn with no such locus keeps its prose private, and says so in a [routing] notice."
  • head: "A turn with no such locus has routing post its prose to no channel, and a [routing] notice says so when the locus the agent was last told of was a channel."

Documented rule: ChannelRegistry.deliverSpeech()

  • base (main): "A failure (or an uncertain outcome) is surfaced as a trace and through onRouteFailure, so the agent learns its words did not (or may not have) reached anyone."
  • head: "A failure (or an uncertain outcome) is surfaced as a trace and through onRouteFailure, so the agent learns that this route didn't deliver its words, or didn't confirm it."

Documented rule: README's [speech-not-posted] notice

  • base (main): "When the stream ends any other way (a failure, a stop, a tool that ends the turn, an error policy's retry, a context-budget or tool-result-guard restart, a quiesce abandon or stop()), the rounds' words are already in the agent's context, and a [speech-not-posted] notice tells the agent that nothing it wrote before its tool calls in those rounds was posted, so it can send what should go out again."
  • head: "When the stream ends any other way (a failure, a stop, a tool that ends the turn, an error policy's retry, a context-budget or tool-result-guard restart, a quiesce abandon or stop()), the rounds' words are already in the agent's context, and a [speech-not-posted] notice tells the agent that the framework didn't post what it wrote before its tool calls in one or more of those rounds, so it can send what should go out again."

Documented rule: README's note on a turn that fails or is stopped

  • base (main): "A turn that fails, or that a caller stops, writes no [delivered] receipt, so when its rounds' plain speech was delivered live (native tool mode, in locus or hybrid routing), its [inference-failed] note, or the stop's [turn-interrupted] note, says that speech was handed on for delivery as written and that no [delivered] line was written for it." "A delivery that finished undelivered gets neither: a channel delivery's [discord-send-failed] note, or a refused >>> envelope's bounce, already says what happened."
  • head: "A turn that fails, or that a caller stops, writes no [delivered] receipt, so when its rounds' plain speech was delivered live (native tool mode, in locus or hybrid routing), or a module had said by the time the turn ended that it showed a round's speech, its [inference-failed] note, or the stop's [turn-interrupted] note, says that speech was handed on for delivery or shown as written and that no [delivered] line was written for it." "A delivery that finished undelivered, and that no module said it showed, gets neither: a channel delivery's [discord-send-failed] note, or a refused >>> envelope's bounce, already says what happened."

Documented rule: docs/auto-continue.md's closing words a send held

  • base (main): "A turn that sent with a send tool and then closed in prose (held, not sent, some or all of it, because a send that turn may already have posted where it was going) still continues: its closing words can't be told from unfinished work." Its row adds "Some or all of your closing words last turn were held, not sent: a send that turn may already have posted where they were going."
  • head: "A turn that sent with a send tool and then closed in prose (held, and not sent by the framework, some or all of it, because a send that turn may already have posted where it was going) still continues: its closing words can't be told from unfinished work." Its row adds "Some or all of your closing words last turn were held, and the framework didn't send them: a send that turn may already have posted where they were going."

Documented rule: docs/dendrite.md's turn with no locus

  • base (main): "A turn with no such locus keeps its prose private."
  • head: "The framework posts the prose of a turn with no such locus to no channel; a module may still show it somewhere (an ephemeral run's isn't asked unless it's held as a draft), a channel included, and say so (Module.onSpeechRouted), and the turn's receipt then names where, when the turn ends with one. A place named for a piece held as a draft is also logged as it's named, and so is one named for a piece routed to no channel, unless the agent was replaced while its modules were asked, which is logged instead."

Documented rule: docs/locked-prose-routing.md's failure marker

  • base (main): "One whose delivery failed gets the failure marker instead."
  • head: "A post that fails gets the failure marker instead; a receipt, when the turn writes one, names no channel for it, only any place where a module said it showed it."

@antra-tess, this changes the 7 documented rules quoted above.

The hook's context names the messages that opened the turn, in openedBy.messages, each by its stored id. While a surgery's hold or the agent's recovery keeps a write in memory, a message a module's answer adds has no id yet. Such a message isn't named.

A turn can end without a receipt, as one that fails or is stopped does. Then the places modules named for a piece with a channel appear nowhere. The channel post itself is logged. Places named for a held piece are logged as they're named, and so are those named for a routed piece with no channel, unless its agent was replaced while its modules were asked.

What hasn't run: the tests use stand-in modules and a stand-in provider. Several paths rest on reading alone, as the details' Not verified says.

  • Changelog fragments added: changelog.d/speech-sent-receipts.changed.md and changelog.d/speech-sent-receipts.added.md.
Credits

Effie-2289 made the change and its rounds, with one by Octavia-2412. Agnes-1009 set what it had to do, and Ada-1017 made its design calls. Ansel-2428 read its rounds, and Crispin-2406 read one of them. Ansel found the held retry whose opening messages a request's marks must keep. Crispin-2713, Alfred-2570 and Gordon-2414 checked which of its parts and tests were needed, and Lottie-2661 found two cases it now handles. Hamish-2699 checked where each part belongs: the hook became a synchronous call from that check, and a request's follow-on marks went into one place from a later one. Myrtle-2415 checked the final state against an ideal and for rent, and from that check a replaced agent's speech routes as at main. Humphrey-2407 and Daniel-2612 wrote the description, Daniel-2612 and Effie-2289 made its runs, and Ramona-2416 put it in this shape.

Details for agents

Head ee7798b8b6ce, into main. It merges main at 36b6dfe9de39, which the pairs take as their base. At 36b6dfe9de39, src/ has no onSpeechRouted, and the [send-undeliverable] notice still opens "Your previous reply (${textLen} chars) had no channel to go to" (src/framework.ts:36420).

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 main (36b6dfe9de39) 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 or holds as a draft. 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 with no channel that isn't held as a draft, and that no module names a place for, still gets the notice, except an ephemeral run's, which gets none, as at main. The notice now quotes the piece's opening words. The new test file's 82 tests pass. Run against main's src/, 75 of them fail (see Tests).

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, and past those only to an open all-except hold's channel, or to unpromptedChannel for a turn the agent's own timer started (docs/dendrite.md:272 at 36b6dfe9de39). 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:220 at 36b6dfe9de39 asserts its opening, and in a run of a25e38b1bf's src/, an earlier main, 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.

36b6dfe9de39 writes the same words for such a turn: the notice at src/framework.ts:36420, and its reason at src/mcpl/channel-registry.ts:6558–6559.

A host can still show the reply to the person who wrote, in the terminal or page the turn came from. At 36b6dfe9de39 the Module interface (src/types/module.ts:119–248) 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 (:202–206). 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

The catch-ups. 3554ba6df6 merges main at 36b6dfe9de39, which brings 326 commits, among them the merges of #972, #361 and #1000. 3471d88cd9 merged main at 53e72cac4280, which brought #244, #955 and #942. 9c60724e1d merged main at 64c492ba26, which brought #948 and #996, and a382089bc5 took main at aa134e75fc. Their meets with main go beyond the conflicts each message lists:

  • A released start with a budget wait (3554ba6df6). fix: a turn refused for budget waits idle until compression makes room #361's branch comes first, as on main: a merged start that carries a budget wait that no member of its batch carries, and equals none of them, goes back as a member of its own, with its own opening messages (src/framework.ts:22982–22987). Otherwise the batch's first member takes the start's own opening messages with its own channels (mergedMarks, :22988–22994), and main's wait mark goes to the member equal to the start (:22995–22997).
  • The park's keep (3554ba6df6). It takes mergedMarks of the two wakes, which gives main's channels and adds the opening messages, then fix(gate)!: a debounced batch's wake starts no turn once the agent has answered every event in it #972's batch rows (src/framework.ts:23070–23076). The wakeLines this change carried at the park and the merged start went with fix(gate)!: a debounced batch's wake starts no turn once the agent has answered every event in it #972's removal of the field.
  • A module's answer (3554ba6df6). main now keeps apart the write a wake reads (wakeAfter) and the write the gate counts in the window (added, src/framework.ts:20300–20302). The opening messages are still each stored write's id (:20304), whatever added's rule.
  • Tests (3554ba6df6). fix(gate)!: a debounced batch's wake starts no turn once the agent has answered every event in it #972 added readsRows and the batch rows to what sameInferenceRequest compares (src/framework.ts:7350–7356). This change's two hold tests of opening messages now copy those keys from the file's identityKeys, as the tests beside them do. One had a list of its own that named the removed wakeLines. The receipts test of the retry after a rewind reads fix: a failure is told in the framework's words, never the provider's #1000's renamed tag from NOTICE_TAG.refusalRewind.
  • The unprompted mark (3471d88cd9). feat(routing): configurable homeChannel for no-trigger turn speech #244's UNPROMPTED mark is spread by hand at the ! continuation, autoContinue and a bounce's wake (src/framework.ts:26598, :26695, :27535). It stays spread there, inside the request markFollowOn marks, so markFollowOn adds it to none of the other requests its new-turn kind marks: the re-arm, the rewind retries and the unstick. The re-arm copies its trigger (src/framework.ts:7501), as at main, so the re-arm of a turn whose trigger carried the mark keeps it.
  • The registry's options (3471d88cd9). takesGlobalFallback keeps this change's method, and main's takesUnpromptedChannel goes beside it (src/framework.ts:36535–36536).
  • The 'delivery' rule's texts (3471d88cd9). docs/dendrite.md, speech.locus's doc and sharedFallback's doc take main's sentences for an all-except hold's channel and the unprompted channel, with this change's words where main's still say a turn with no locus keeps its prose private. The documented rules above quote them.
  • A held write's handle (a382089bc5). main no longer mints a handle for a write held in memory. A write that a surgery's hold or its agent's recovery keeps in memory has no id until it's stored. This change named a module answer's message by its stored id or its held write's handle. It now names a stored id only (src/framework.ts:20304), so such a message isn't named in openedBy.messages. Where no module names a place for that turn's speech, the turn gets the notice, which says another surface may have shown it. Two texts say the message isn't named, each quoted old (a122e2b513), then new:
    • src/types/module.ts:927–930: "the messages that opened that turn, each as its stored id or a held write's handle, either of which a module context's getMessage takes"; "the messages that opened that turn, each as its stored id, which a module context's getMessage takes. A message kept in memory when its wake was asked for (while a surgery's hold, or its agent's recovery, keeps the write) has no id yet, and isn't named."
    • changelog.d/speech-sent-receipts.added.md:32–35: "as stored ids or held-write handles that a module context's getMessage reads"; "as stored ids that a module context's getMessage reads (a message kept in memory when its wake was asked for, while a surgery's hold or its agent's recovery keeps the write, has no id yet and isn't named)".
  • The carry places (9c60724e1d). fix(gate): keep notices out of a batch's route; name its channels #948 carries a request's wakeChannels at the places this change carries its openingMessages. Each place now carries both: the start's rule, the hold's fold, the release, the quiesce park, the re-arm, the retry pushes and the turn-retry mark. At a release, the batch's first member takes whichever of the two the start held beyond the batch.
  • cameFrom. main passes a turn's cameFrom when it routes speech with no channel, so the notice can name the channels the turn's batch came from. routeLocusSpeech keeps this change's body and passes cameFrom on each of its three routeSpeech calls (src/framework.ts:24466, :24482, :24485).
  • The no-channel notice. It keeps this change's opening words and takes main's byDesign: "This is a routing situation" where the reason names the batch's channels, and "routing/configuration" otherwise. A failed channel post's notice keeps this change's "The framework posted nothing there".
  • Tests. held-turn-retry keeps both changes' twin of each fold test, one for each field. batched-wake-locus's channels check takes the opening words. fix(gate): keep notices out of a batch's route; name its channels #948's test of a ! continuation after a channel_open now expects this change's turn-start words for a turn with no channel.

The rounds since the first merge, in order:

  • 111602a551 names readsRow, main's field, where test/held-turn-retry.test.ts's lists and one fixture named the fields it replaced.
  • e285025060 reshapes the test of a hold's union of opening messages, as fix(gate): keep notices out of a batch's route; name its channels #948's twin test has it. A silent push carrying messages now meets an equal plain push.
  • c8caf05ecc presents the row that the inference-failure rewind test sheds, since main sheds from the presented window.
  • 50fd42ce20 gives test/deferred-import-boot.test.ts's assertion this change's words for a failed reply whose speaker has ended.
  • bc71a9a170 makes a test count every [send-undeliverable] and [discord-send-failed] notice in the primary's window.
  • 9f44b3580d adds to changelog.d/speech-sent-receipts.changed.md what happens when a module replaces the agent from inside its own hook.
  • bd409aece7 makes the hook a synchronous call (The call, below). With the wait go SPEECH_RECEIPT_WAIT_MS and its export, the 5 s bound, the record of asks overdue, the late lines, the receipt's and stop()'s waits for answers, the asks a stop abandoned, and the check that a module is still registered when it answers. stop()'s set of streams still running, which the first merge kept, goes too, since nothing reads it now, as at main. So do the receipt tests whose subject was the wait, among them the stop test 324b949065 cut and 92208b2068 restored.
  • 7ec56f763d logs a hook's rejected promise as a throw is logged.
  • a5afadecc1 cuts the .changed fragment's clause that a place a module names for an ephemeral run's speech is logged when a tool ends the run. bd409aece7 made it untrue: only a place named for a routed piece with no channel is logged.
  • 92e148db51 has docs/dendrite.md say which places are logged when no receipt comes: one named for a piece routed to no channel, and not one named for a piece held as a draft.
  • 66107dd7b0 sets the marks that a request carrying a turn on takes from the turn's routing in one place, markFollowOn (src/framework.ts:24542–24550), which replaces main's wakeChannelsMark. A request for a new turn takes the turn's opening messages and channels: the ! continuation, autoContinue, a bounce's wake, the rewind retries after a refusal and after an inference failure, and the re-arm. A request that continues the same turn takes its channels and keeps its own opening messages: the context-budget restart, tool_results_ready, the tool-result guard's retry and a held turn's retry. mergedMarks takes both marks at the hold's fold, the quiesce park and the release. A re-arm's openedBy.messages can now come in the routing's order. held-turn-retry gains a test that a held turn's retry, released with a wake of its own cause before any of the turn's rounds ran, keeps the turn's opening messages beside the wake's.
  • 99e5c38198 gives an operator's unstick its marks as a new turn, so it carries the latest turn's channels too (An operator's unstick, above). batched-wake-locus gains a test of it.
  • 1a3ef2bfac has a refusal rewind, an unstick or an inference failure's shed set aside, with the reply it sheds, the no-channel notice that quotes that reply's opening (The notice, below). The notice records the opening in its metadata, as opening.
  • 7e5b6fa11c logs a place a module names for a piece held as a draft, as it's named. The docs now say that an ephemeral run's speech held as a draft is asked about, and that speech an agent routes or holds once another agent has taken its name isn't.
  • ecb9ff7e0f has two tests of a round a send held count exactly one ask where they checked for any, and the budget-restart test check that the round routed before the restart is asked about once.
  • f81f45ae38 has a replaced agent's later pieces ask no module and route as at main. It drops the checks that stopped them before any route, before a hybrid >>> send and before an explicit send, and keeps the check after a no-channel piece's modules return. Three tests go, and the two of a piece a replaced agent routes now check that no module is asked and that the piece routes under the name.
  • ee7798b8b6 has the .changed fragment say that an operator's unstick carries the latest turn's channels.

This change's src/ is 8 files, +454 −124, against main at 36b6dfe9de39. At 3554ba6df6, before f81f45ae38, it was +466 −124, and at 9f44b3580d, against aa134e75fc, 9 files, +515 −88.

Merge order. #947 and #952, both open, also change the retry pushes this change changes.

  • The hook. Module.onSpeechRouted(agentName, text, context) (src/types/module.ts:208–246) is optional and returns a SpeechSent. The framework calls it with each piece of plain speech a turn routes or holds as a draft, under prose routing by locus, hybrid or locked, on a host with a channel registry: each tool round's prose as it is routed, and the turn's final reply. For a piece no channel takes, a surface may show it then and answer where. It never asks about private envelopes, explicit >>> sends, an ephemeral run's plain speech that has no channel and that the run doesn't try to hold as a draft, which is its caller's result, or speech an agent routes or holds once another agent has taken its name. Tests check that a private envelope in a hybrid turn's final reply is never asked about, and, under locus routing, that an ephemeral run's speech with no channel asks no module, even one that would say it showed the speech, and that the run's context gets no [delivered] receipt. 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.
  • Held speech. Plain speech that the framework holds as a draft rather than posts is asked about too, and the turn's receipt names the places the modules return beside its drafts note. That covers a locked turn whose lock points at drafts, is unset or doesn't resolve, a heartbeat's held prose, a locked turn whose lock moved while its words waited for the turn's end, hybrid speech with no destination, and plain speech that a send in its round held. Each has a test. Four tests differ only in a locked turn's lock: with a lock, the speech is posted, the module is asked, and the receipt names the place; locked to drafts, with no lock and with a lock that doesn't resolve, nothing is posted, the module is asked, and the receipt names the place beside its drafts note. The ask is in holdProseDrafts (src/framework.ts:23700–23713), which asks unless its caller opts out or another agent holds the agent's name. Three callers do, each holding an explicit envelope's words or speech under explicit routing, which writes no receipt: a bounce (:27449), takeUnsent (:26960) and explicit routing's drafts round (:27279). A part marked deliberate, an explicit envelope among the parts a send held under hybrid routing, isn't asked about either. Each text a send holds is asked about with its own context.final: where a turn's tool rounds aren't routed live, the prose a send held from before the turn's last tool call is asked about as a round's, and only the reply as the final speech, under locus and locked routing and under hybrid routing alike. A held piece is asked about when it's held, which can be after later pieces have routed. Tests check that explicit routing asks no module about speech it holds or bounces, that an envelope whose {{unsent}} names a draft still being sent holds its words without asking, and that a hybrid round a send held asks about its plain envelope and not about its >>> envelope.

  • A place is kept to one line, each run of whitespace, line breaks included, as one space and its ends trimmed, and isn't cut, however long. Then a notice's opening in it, such as [routing], gets a backslash before its bracket, as the framework marks other text it's given, so the receipt reads sent to the terminal \[routing] says so where a module named the terminal, a line break, and [routing] says so.

  • What a place changes. The turn's [delivered] receipt names each place as "sent to ", after any channels the speech went to, each once, in the order the modules were asked about the pieces. A held piece is asked about when it's held, so its place can come after a later piece's. When none of the turn's speech went to a channel, and no post to one was tried, the receipt adds "this turn's routing posted it to no channel", as in [delivered] plain speech → sent to the console that sent this turn · this turn's routing posted it to no channel. It speaks for this turn's routing only, since a module's own speech handler can post the speech to a channel and name that place. A routed piece with no channel that a module showed gets no [send-undeliverable] notice, and the framework logs [routeSpeech] <agent>: turn has no channel; <n> chars shown by <places>, routing posted it to no channel. There each place prints as a log field: one with a space or an invisible character prints as a JSON string with those characters escaped, such as "the\u0020console\u0020that\u0020sent\u0020this\u0020turn", while the receipt shows those characters unescaped. Such a piece emits no mcpl:speech-route-failed trace, since it returns before the channel registry routes it (src/framework.ts:24478–24481), and the registry's path for a piece with no channel is what emits that trace (src/mcpl/channel-registry.ts:6523–6563). At main (36b6dfe9de39) every such piece goes to the registry (src/framework.ts:24347 there) and emits it. The line for a routed piece with no channel that no module showed still says turn has no locus, as before, and it still emits the trace. A piece with no channel whose agent was replaced while its modules were asked gets neither of these lines nor the trace, only the replacement's line (below). An ephemeral run's speech with no channel that it doesn't try to hold as a draft isn't asked about, and gets no line, trace or notice, as at main (src/mcpl/channel-registry.ts:6523–6529). A piece with a channel is posted once its modules have returned, and if the post fails, its own notice stays and the receipt names the place. A place a module names for a piece of a tool round's prose, routed or held as a draft, also counts as speech the round handed on, as a post to a channel does at main, as soon as that module returns a place (src/framework.ts:24503–24505). So when a module had named a place by the time the turn ended, a turn that fails says in its [inference-failed] note: "Some plain speech of its rounds was handed on for delivery or shown as written, and no [delivered] line was written for it." (:1975), where main's says "Plain speech its rounds routed live was handed on for delivery as written".

  • The call. The framework calls the modules one after another, in module order, and routing goes on when the calls return (ModuleRegistry.askSpeechRouted, src/module-registry.ts:758–792). So showing a piece has to be a write that doesn't wait, as the hook's doc says (src/types/module.ts:233–237). Each module is called inside its own guard, and in each case below the other modules' places stand:

    • a module whose onSpeechRouted throws when it's read isn't called, and the log names it;
    • a module that throws names no place, and so does one whose answer's sentTo throws when it's read;
    • a module that returns a promise names no place, and routing doesn't wait for it, and if the promise rejects, the rejection is logged as a throw is (src/module-registry.ts:772–777);
    • a place that is blank, once its whitespace is read as one space, names none.

    Each has a test. A piece of the turn's final speech is asked about after the turn's inference:completed trace and before the agent's next inference:started, which the hook's doc promises (src/types/module.ts:239–240) and a test checks (test/speech-sent-receipts.test.ts:620). Under locus and locked routing, a piece with no channel goes on to the next piece once its modules have returned, and a piece with a channel is posted then (routeLocusSpeech, src/framework.ts:24468–24485). A place a module names for a routed piece with no channel is logged then, in the [routeSpeech] … shown by … line (:24478–24481). A place named for a held piece is logged then too, in a [speechRouted] … held as a draft line, and goes into the turn's receipt unless that receipt was already written (holdProseDrafts, :23707–23711). A place named for a piece with a channel goes into the turn's receipt, beside the channel, whose post is logged (src/mcpl/channel-registry.ts:6572). So a turn that ends without a receipt, as one that fails or is stopped does, names a channel piece's places nowhere. Once another agent holds an agent's name, no module is asked about the replaced agent's later pieces, and they route as at main, with a channel or without (src/framework.ts:24468–24470). The one exception is a module that replaces the agent from inside its own hook. A piece with no channel is checked again once its modules have returned, and one whose agent was replaced by then isn't routed for the replacement, and is logged as [routeSpeech] <agent>: replaced while its modules were asked; <n> chars not routed for the replacement (:24473–24477). A piece with a channel is posted in that case, since it isn't checked again. A test replaces the agent from inside a hook, for a piece with no channel. A replaced agent's held pieces, such as a locked turn's or a heartbeat's and the words a send in a hybrid reply's turn set aside (:23634–23642), and its bounces and their notices, still go to the name, as at main, and once another agent holds the name no module is asked about a held piece (holdProseDrafts, :23700–23701). Its tool calls go as at main. An onPlace callback that throws is logged, and the call still returns the place (src/module-registry.ts:784–788).

  • The notice. Speech with no channel that isn't held as a draft, and that no module names a place for, still gets [send-undeliverable], except an ephemeral run's, which gets none, as at main. The notice 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). When the failure's info has no text, or its words are only whitespace, it names the speech by its length alone: Your plain speech (<n> chars) had no channel to go to. 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". A refusal rewind, an operator's unstick or an inference failure's shed that sets a reply aside sets aside with it each no-channel notice next to the reply, with only rows the shed passes over between them, whose recorded opening is in the reply's own speech (noticesQuoting, src/framework.ts:1686–1711, called at :15223). The notice records the opening it quotes in its metadata, as opening (:36568), since it's written before the reply's row is stored and nothing else links the two. So a notice about an earlier reply goes too when it lies next to the shed reply and quotes the same opening. The quoted words go out in every request that carries the notice, the host's background requests included, such as its keyword requests for retrieval. With the notices' new words, one early cache boundary in a run of the pull request's context view falls later in the conversation.

  • context.turnId is the turnId of the turn's routing, which onAgentSpeech also gets as SpeechContext.turnId (src/framework.ts:24501, :30474), so every piece of one turn carries the same id. A test checks that a tool turn's pieces name one turn (test/speech-sent-receipts.test.ts:483–485).

  • 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. The holds for a locked turn, a heartbeat's turn and a turn whose lock moved while its words waited for its end each ask about a piece with its round's finality (src/framework.ts:24454, :24526, :24563), and a test checks that the moved lock's held reply is asked about as final.

  • 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, which a module context's getMessage takes. A message kept in memory when its wake was asked for, while a surgery's hold or its agent's recovery keeps the write, has no id yet and isn't named. openedBy stays the same after a context-budget restart. A re-arm opens a new turn, whose openedBy.messages are the messages that opened the turn it re-arms. A turn the framework queues to carry another on (a ! continuation, an autoContinue run, a bounce's wake, or a retry on a history rewound after a refusal, an unstick or an inference failure) gets the messages that opened the turn it carries on. A turn that took a cause of its own that brought messages beside such a continuation, such as a module answer's wake, names that cause's messages, not those the continuation carried (bringsOwnMessages, src/framework.ts:2031–2033; :23351), so a resident whose continuations keep meeting new causes doesn't carry every earlier turn's messages into the next. A cause of its own that brought none, such as a heartbeat that isn't silent or an operator's nudge, doesn't take the continuation's place, and the turn names the messages its continuation carried. The start takes them in place of the messages its routing already names while none of the turn's rounds has reached its tool calls (gotPartway, :1354–1356; :28286, :28510), as when an autoContinue run's held continuation takes a message's wake. A start that joins a turn one of whose rounds has reached its tool calls adds them to the turn's, whether or not that round's results are in the agent's context yet, so the message that opened the turn stays named. When two wakes parked with one coalescing key while the host is quiesced are kept as one, the wake kept takes both wakes' opening messages (:23071). A message a rewind removed is still named, since the retry stands in for the turn it opened, and getMessage returns null for it. When a provider hold keeps one of two equal requests, the request kept takes both requests' opening messages (holdRequest, src/framework.ts:7379). A merged start that comes back to the scheduler, as when a hold releases it, is replaced by its batch, as at main, and the batch's first member now also takes the held request's opening messages that no member of its batch holds (:22988–22994): those of a wake the hold took into a held merged start, and a held wake's own when an equal merged start folded into it. So the turn the release starts names the messages of a wake taken into a held merged start, and of a held wake that an equal merged start folded into. A start that carries a budget wait none of its batch carries, and equals none of them, goes back as a member of its own, as at main, and keeps its messages itself (:22982–22987). The operator's unstick command opens a turn with the messages that opened the agent's latest turn, and with that turn's channels (src/framework.ts:15633–15638). One test checks that its run names the messages, and another that its notice names the channels. InferenceRequest.openingMessages, marked @internal, carries them between requests. messages is a readonly string[]. A wake an event gate debounced carries none, so the messages of the module answers it stands for aren't named, as the field's doc says.

  • Provider holds. A provider hold folds two equal requests as main does (holdRequest, src/framework.ts:7360–7380; :7282–7303 at 36b6dfe9de39), and main also reconciles their carried state and re-arm marks there. What this change adds is the opening messages:

    • The request kept takes both requests' opening messages (src/framework.ts:7379), so the messages of a wake taken into a held merged start aren't lost with it.
    • A merged start that comes back to the scheduler, as when a hold releases it, is replaced by its batch, as at main, unless it carries a budget wait that no member carries and equals none of them, when it goes back as a member of its own (src/framework.ts:22982–22987). Otherwise the batch's first member takes the held request's opening messages that no member of its batch holds (:22988–22994), so a member's own messages stay on that member. Without it, a held wake that an equal merged start folded into would lose its messages, and so would a wake taken into the start. main gives the first member the held request's own wakeChannels the same way (src/framework.ts:22892–22894 at 36b6dfe9de39), and has no opening messages.
    • Tests check that the turn the release starts names the messages of a wake the hold folds into a held merged start, and of a held wake that an equal merged start folds into, and that a member the read check drops leaves its message off the turn (Tests).
  • Turn order. A test pins the order the hook's doc promises: a turn's final speech is asked about after its inference:completed, and the agent's next turn starts, and compiles, only after the turn's routing and receipt are done (test/speech-sent-receipts.test.ts:620). With the turn's completion moved after its final routing, it's the only test in its file that fails (Tests).

  • Routing notices. For a turn that isn't locked, the [routing] notice that tells an agent its speech now has no channel reads "Your plain speech currently has no channel, so routing posts it to no channel. 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". A locked turn's two notices keep main's words. The note for a channel its server declared inbound-only, which two [routing] notices and channel_open's result include, now reads " declared this channel inbound-only, and plain speech there is refused, so routing posts it to no channel there. To speak there, use 's own tools.", where it said plain speech there "is refused and stays in your archive".

  • The receipt's notes for held speech. The [delivered] receipt's note for speech held as drafts reads "(the framework didn't send them — drafts can resend them unchanged, or dismiss them)", where main's read "(not sent — …)", and its note for speech that couldn't be held reads "the words remain in your history", where main's read "remain only in your history" (src/framework.ts:24641, :24678). For the same reason, the [drafts] notice for speech held as a draft reads "not sent by the framework — …", where main's read "not sent — …" (:23812), and autoContinue's [continue] row for closing words a send held reads "Some or all of your closing words last turn were held, and the framework didn't send them: …", where main's read "were held, not sent: …" (src/auto-continue.ts:43), which docs/auto-continue.md quotes. A module may have shown any of them.

  • The failed-reply notices. The [discord-send-failed] notice for a post to a channel that failed, other than one whose outcome is unknown, now ends "The framework posted nothing there; it is saved in your archive.", where main's ended "Nothing was posted; it is saved in your archive." (src/framework.ts:36587; :36419 at 36b6dfe9de39). The notice of a failed reply by a speaker that has ended, other than one whose outcome is unknown, now ends "The framework posted nothing there. has ended.", where main's ended "Nothing was posted. has ended." (:35057; :34888 at 36b6dfe9de39). A module may have posted the words in that channel by its own means.

  • The notice of unposted tool-round speech. The [speech-not-posted] notice, for speech a turn's tool rounds left unposted, now reads "The framework didn't post what you wrote before your tool calls in one or more of the rounds just above. If any of it should go out, send it again.", where main's read "Nothing you wrote before your tool calls in the rounds just above was posted. …" (src/framework.ts:24745; :24569 at 36b6dfe9de39), since a module may have shown the words, and the notice comes when any one round's words before its tool calls weren't routed live (:19561–19564).

  • 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, marked @internal, since the framework is its one caller, calls the modules and returns the places they named, and calls its onPlace callback for each place. ChannelRegistry now passes onRouteFailure the words routed, as text, so the notice can quote them, and a host that logs what its onRouteFailure receives now logs the agent's speech. The field is optional in the callback's info type, as the info's other later fields are, and the registry sets it on every failure it reports (src/mcpl/channel-registry.ts:1092–1094, :6499–6502). 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.

  • A receipt that can't be recorded. When adding a turn's [delivered] receipt to the agent's context throws, the log line names the agent and what the receipt names, each channel by its id, each draft's delivery as its channel's id with the draft and how it went, a draft an {{unsent}} delivered to a channel the receipt already lists included, and each "sent to ", each printed as a log field, or "no place": [delivered] <agent>: its receipt couldn't be recorded; it named <places>:, then the error (src/framework.ts:24615, :24673, :24724–24725). With a draft an {{unsent}} delivered to a channel the receipt lists left out of the line, as at a251c88731, the new test in prose-drafts fails, at its check that the line names the draft (Tests). So however long a channel's label, it can't cut the channel's id from the line. main logged delivery receipt append failed: and the error (:24549 at 36b6dfe9de39).

  • Unchanged: onAgentSpeech, dispatchSpeech and SpeechHandlerOptions.

  • Comments. Two comments on why a turn's speech goes to the locus frozen at its start drop their incident history, and a third, above the trailing dispatch, is gone. The one on deliverSpeech's locus keeps the rule. The one at the text-only dispatch (src/framework.ts:30544–30549) now gives the rule's reason: a locus resolved when the speech posts would read the global fallback, which an arrival, or an all-except hold opening or ending, can move while a dispatch runs (src/mcpl/channel-registry.ts:6100–6159). 533c73b's message also says that on a turn a tool ends, the next turn can start before the queued prose has posted. By reading, it can't: such a turn awaits its speech chain and its receipt (src/framework.ts:29447–29448) before its turn token is released, and a fresh wake waits while the agent has a turn (:23113). onRouteFailure's comment drops its sentence that none of the notices claims the reply reached no one, and the comment above it now says the marker tells the agent that the framework didn't deliver her reply there, or didn't confirm it, where main's said her reply didn't reach the human, or wasn't confirmed to (src/framework.ts:36552–36555). sharedFallback's doc, speech.locus's doc in src/types/framework.ts and a comment in the channel registry now say that routing posts the prose to no channel, and speech.locus's doc says a [routing] notice says so when the locus the agent was last told of was a channel. At 36b6dfe9de39 the two docs say the prose stays private, and the comment said it stays in the archive. deliverSpeech's doc says that through onRouteFailure the agent learns "that this route didn't deliver its words, or didn't confirm it" (src/mcpl/channel-registry.ts:6451–6452), where main's said it learns "its words did not (or may not have) reached anyone" (:6449 at 36b6dfe9de39), which a module that showed them would make untrue. appendProseDeliveryReceipt's doc now lists each condition under which it writes nothing. holdProseDrafts' doc says that words the journal can't hold remain in history (src/framework.ts:23686), where main's said they remain "in history only" (:23583 at 36b6dfe9de39).

  • Docs. The README documents the hook under Module. The README, the .added fragment and openedBy's doc also say that a turn that took a cause of its own that brought messages beside a continuation before any of its rounds reached its tool calls names that cause's messages, and that a cause that joins a turn already under way is named beside the turn's own. Its account of a failed or stopped turn's note now says the speech was handed on for delivery or shown when a module had said, by the time the turn ended, that it showed a round's speech, and that a delivery that finished undelivered gets neither of the note's sentences about it only when no module said it showed it. docs/dendrite.md says that the framework posts the prose of a turn with no channel to no channel, and that a module may still show it somewhere, a channel included, and say so, which the turn's receipt then names when the turn ends with one. It says too that an ephemeral run's speech is asked about when it's held as a draft, and that "A place named for a piece held as a draft is also logged as it's named, and so is one named for a piece routed to no channel, unless the agent was replaced while its modules were asked, which is logged instead." docs/locked-prose-routing.md says that a post that fails "gets the failure marker instead; a receipt, when the turn writes one, names no channel for it, only any place where a module said it showed it". The README's account of the [speech-not-posted] notice now says that the framework didn't post what the agent wrote before its tool calls in one or more of those rounds. The hook's doc, the README and the .added fragment say that the call is synchronous, that showing a piece is a write that doesn't wait, that a module that throws names no place, and that a piece with final: true comes after the turn's inference:completed trace and before the agent's next inference:started. The hook's doc also says that a module that returns a promise names no place, and that a rejection of it is logged as a throw is. The hook's doc and the .added fragment name the ephemeral-run exception, and with the README they say that speech an agent routes or holds once another agent has taken its name isn't asked about. The README says that an ephemeral run's reply with no channel gets no [send-undeliverable] notice, since it's its caller's result. SpeechSent.sentTo's doc says that a place's whitespace runs read as one space, with none at either end, and that a long place reads whole. The .added fragment says that a place reads as one line, each whitespace run as one space and none at either end, and that an opening of a tag the framework marks in text it supplies, its own notice tags and context-manager's and membrane's, reads with a backslash before it. The .changed fragment tells hosts that a piece a module showed emits no mcpl:speech-route-failed trace, and that once another agent holds an agent's name, no module is asked about the replaced agent's later plain speech, which routes as before. It says what happens when a module replaces the agent from inside its own hook: later modules are still asked about the piece in hand, and about a draft hold's later pieces, and a piece with a channel still posts, but one with no channel isn't routed for the replacement. It also gives main's words beside the new ones for the failed and stopped notes' sentence, the held-speech notes, the [drafts] notice, autoContinue's [continue] row, the two failed-reply notices and the [speech-not-posted] notice, and says that an ephemeral run's speech with no channel gets no notice or trace, as before. It says that text is optional in onRouteFailure's info, with a no-channel notice for an info without it, or for speech that's only whitespace, naming the speech by its length alone. It gives the receipt-failure line beside main's delivery receipt append failed:, and the lines a module's call can log. It gives the [speechRouted] <agent>: <n> chars shown by <places>, held as a draft line, and says that a piece with no channel whose agent was replaced while its modules were asked logs that instead, with no places and no trace. It says that a refusal rewind, /unstick or an inference failure's shed that sets the speech aside sets aside with it the notice next to it that quotes its opening, which the notice's metadata carries as opening. It says that the turn an operator's /unstick queues carries the channels and opening messages of the agent's latest turn, so when an ambient batch woke the stuck turn, the notice for the unstick's speech names that batch's channels.

  • Tests. test/speech-sent-receipts.test.ts is new, with 82 tests. test/prose-drafts.test.ts gains one test: that a hybrid turn's draft a bare {{unsent}} delivers in its own turn is named in the log when the turn's receipt can't be recorded. test/exhausted-turn-rearm.test.ts gains one test: that a re-armed continuation keeps the messages that opened its turn. test/held-turn-retry.test.ts gains six tests. Two are of a heartbeat that isn't silent, which has a cause of its own but brings no messages: merged with an autoContinue wake that carries its turn's opening message, the start names that message, and continuing an open turn before any of its rounds reached its tool calls, it keeps the turn's opening messages. Three are of the provider hold's release: that the turn it starts names the messages of a wake the hold folds into a held merged start, and of a held wake that an equal merged start folds into, and that a member the read check drops leaves its message off the turn. The sixth is of a held turn's retry released with a wake of its own cause before any of the turn's rounds ran: the turn keeps its opening messages beside the wake's. test/batched-wake-locus.test.ts gains one test: that after a turn an ambient batch woke, the notice for the speech of the run an operator's unstick starts names the batch's channel. test/refusal-rewind.test.ts gains two tests of the shed: a no-channel notice that quotes the shed reply goes with it, and a notice next to the shed reply that quotes an earlier reply stays.

    An assertion in admission-e2e now checks for the [routing] notice's new first sentence whole, where it looked for "no channel", and neither its message nor batched-wake-locus's opening comment says that speech with no channel stays private. batched-wake-locus's check of the no-channel notice takes the piece's opening words, and its check of a turn-start [routing] notice takes this change's words. The check of the failed-turn note's sentence in test/prose-drafts.test.ts follows its new wording, and the checks of the held-speech notes in prose-drafts, present-while-acting and explicit-prose-routing, and of the [continue] row in test/auto-continue.test.ts, follow theirs. The check of the [speech-not-posted] notice in prose-drafts follows its new words. A check in prose-drafts, one in test/publish-outcome.test.ts and a stored notice in test/conversation-routing.test.ts follow the failed channel post's notice, publish-outcome's check that an unconfirmed delivery's notice never says nothing was posted now also looks for "posted nothing", and five assertions in route-failure-targeting and one in test/deferred-import-boot.test.ts follow the ended speaker's. One assertion in test/route-failure-targeting.test.ts follows the notice's new wording. Another checked that the primary's window held no "Your previous reply". It now counts every [send-undeliverable] and [discord-send-failed] notice there. The check of the inbound-only note in test/present-while-acting.test.ts, which three of its tests make, follows the note's.

What each part is for

Where a change to src/ below fails tests, the run is in Tests, at the commit the bullet names, or at a122e2b513 where it names none.

  • Asking about held speech. A module can show a turn's plain speech before the framework routes or holds it, as a terminal draws the tokens as they stream. So a drafts note alone could tell the agent its words reached no one when the person had read them.
  • 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. So a host that knows its surface didn't take the words, as when its connection closed, can answer only {}, and the notice then still says that a copy another surface showed stands.
  • A call, not a wait. A module that shows a piece does so as it's called, and answers where. Routing awaits no module's answer, so a later change to routing needn't reason about answers still to come. The call is synchronous, so a module that does slow work before it returns holds up routing, and showing a piece has to be a write that doesn't wait. With 9f44b3580d's src/, which awaited what each hook returned, "a module whose hook returns a promise names no place, and routing doesn't wait for it" fails, at its premise, and so does the test of a place's callback that throws (Tests).
  • A hook, not channels for surfaces. A host could instead give its surfaces channels, so a turn a surface opened would route its reply there with no hook. That was weighed and set aside:
    • main models operator input as a surface, not a channel: InboundSurfaceSource (src/mcpl/inbound-source.ts:85–91 at 36b6dfe9de39), whose answer "the module or the surface carries" (src/framework.ts:20137–20138 there);
    • the host's surfaces render from traces;
    • as channels, operator input would meet channel rules, suppression and tune-out, and fleet children would reply to the socket;
    • fix(routing)!: a turn that holds an operator's private message speaks in no channel #995, open, rests on the surface model.
  • 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. The cut at 40 characters keeps a long piece from being quoted back to the agent whole.
  • A place's one-line form keeps a module's answer to one line of the receipt, so a line break in it can't start a line that reads as a notice of the framework's own. A place isn't cut: the cut at 200 characters had no measured basis, and a place is the host's own text, as a tool's result is. With that cut put back, at an earlier round, the test of each place in module order failed at its long place (Tests). Marking a notice's opening keeps [routing] in a place from reading as a [routing] notice, as the framework marks other text it's given (markedTexts, src/supplied-text.ts:49). Tests check each: a place with a line break and [routing] in it, and places with extra whitespace and a line break, a blank answer and an answer over 300 characters long, which reads whole.
  • Leaving out explicit speech. The places a module names are reported in the turn's [delivered] receipt. A turn under explicit routing writes no such receipt (src/framework.ts:29446–29448, :30693–30694), so an answer about its speech would have nowhere to appear. A bounced send's words and an {{unsent}} envelope's words are held with a note saying why their send didn't go (:26960, :27449). Leaving them out keeps the receipt from naming a module's place beside that note.
  • Leaving out an ephemeral run's speech with no channel that it doesn't try to hold as a draft. That speech is the run's result, which its caller receives. No module is called with it, and the run's context gets no [delivered] receipt. One test checks this, under locus routing, with a module that would say it showed the speech (test/speech-sent-receipts.test.ts:391).
  • "this turn's routing posted it to no channel" 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.
  • The test file's helpers.
    • Surface is a module whose onSpeechRouted answers as a test sets it: a place, {}, or an object the test gives, such as one whose sentTo is blank or throws when it's read. It records each ask with its text and context, so a test can check what the module was asked, with what final and what openedBy. Its onProcess makes a message from its source the turn's opening message, or several with messagesPerEvent, and onAsked lets a test act at the moment of an ask, such as replacing the agent from inside the hook.
    • channelServer stands in for a channel's server. It records each post and answers delivered, not delivered, or with a throw, so a test can check what a turn with a channel posted, and what its receipt and notices say when the post fails.
    • turn sends the agent a console message and runs the framework until it's idle, so a test takes a whole turn in one line.
    • host builds the framework every test in the file runs on: the given modules and stand-in provider, an agent under locus routing unless the test says otherwise, and the channel registry.
    • until lets pending work run until a condition holds, and fails with its label after 10 s of real time.
    • Surface's 'per-text' answer names a place that ends with the text it was asked about, so a test of several pieces checks that each piece's own place is in the receipt, and a dropped answer can't hide behind another piece's.
    • lookRound and afterLook give a turn a tool round, a console--look call, and then a reply, so a test can tell a round's prose from the final reply.
    • internals reaches the private state a test needs: the queue of requests, to start a turn by a given wake; the channel registry, to give it a stand-in server; the provider holds; the locus the agent was last told of; and the notice of a message addressed from elsewhere, called directly.
    • keepWakeQuiet queues a response holding only a given envelope, such as >>>skip_reply, each time an inference after the first starts, so a later turn the test doesn't script gets a reply that gives the modules nothing to be asked about.
    • openDrafts lists the agent's open prose drafts, so a test can check what was held, and why.
    • channelTurn gives the registry a stand-in channel server and queues a wake from one of its channels, so a turn has a channel to post to, and the test can make that post succeed, fail or throw.
    • gate gives a test a promise it releases itself, for holding a post at a point the test chooses, and registers the release, which the file's afterEach calls (test/speech-sent-receipts.test.ts:204–218). One test uses it. Without the release at teardown, a test that failed before releasing what it held would leave the post waiting into the next test, as a3e955d4a7's message says of the hook's answers it held then.
    • Surface's toolCall runs when the agent calls the module's console--look tool, so a test can act in the middle of a turn, such as pushing a message that arrives while the turn runs.
    • textOf reads a message's text from its blocks. texts, notices and receipt read the agent's context through it: every message's text, the [send-undeliverable] notices, and the latest [delivered] receipt. push sends the agent a message from a source, the console unless the test names another.
    • askedTexts lists what the module was asked, each text with its final, and openingTexts reads the messages an ask's openedBy.messages names, so a test can check both by their words.
  • The test file's five stand-in providers, each a subclass of MockMembrane, change its stream to reach a case their tests need:
    • WithoutRoundContent drops roundContent from each tool round's event, so a turn's tool rounds aren't routed live and their prose routes at the turn's end. Eight tests use it, among them that such a round's prose is asked about as a round's and only the reply as the final speech.
    • AttemptBeforeReply adds a refused XML tool attempt just before the response completes, and with live false also drops roundContent. Its test checks that the speech on both sides of the attempt is asked about as the final reply, where tool rounds aren't routed live.
    • FailsAfterFirstRound takes the first tool round's results, then fails with a server error marked not retryable. Its two tests check that a turn that fails after a round a module showed says in its [inference-failed] notice that the round's speech was handed on: one where the round was routed, which also gets no [send-undeliverable] notice, and a heartbeat whose round was held as a draft.
    • HeldSecond answers the second request with a rate limit and a 60-second wait, so the framework holds that turn for a retry. No test uses it on its own: HeldSecondThenRests builds on it.
    • HeldSecondThenRests, over HeldSecond, ends each stream after the third with skip_reply, so an autoContinue run comes to rest. Its test checks that the run's held continuation, which takes a second message's wake, names that message and not the one that started the run.
  • Printing each place in the log with logValue. A place is text a module chose. logValue, which the host's lines already use for values a server chose, prints it as one field: a place with a space or an invisible character prints as a JSON string with those escaped (src/mcpl/log-value.ts:1–43). Printed raw, a place's spaces could read as the line's other fields, and two places that differ only in invisible characters would print alike. "a place a module names is logged as a field, its invisible characters escaped, and kept readable in the receipt" checks it.
  • The notices that no longer say the speech stays in the archive. main's [routing] notice for speech with no channel, the notice of a message addressed to the agent from elsewhere on a turn with no channel, and the inbound-only note each told the agent that its plain speech stays in its archive (src/framework.ts:24974, :25933 and :596 at 36b6dfe9de39). With this change a module can show that speech, and the turn's receipt then names where, so those words could contradict the agent's own receipt. The new words speak for routing alone: the [routing] notice says routing posts the speech to no channel, the inbound-only note says routing posts it to no channel there, and the notice of a message addressed from elsewhere says the speech has no channel this turn.
  • The [speech-not-posted] notice's words. main's "Nothing you wrote before your tool calls in the rounds just above was posted" denies any posting, a module's included, and a module may have shown those words. The new words deny only the framework's own posting, as the failed-reply notices' new words do, and say "one or more of the rounds", since the notice comes when a single round's words weren't routed live, whatever the turn's other rounds did. With f9dbe8fb92's src/, whose words said the framework "posted nothing you wrote before your tool calls in the rounds just above", the three tests in prose-drafts of a failure after a tool round fail at the notice's words (Tests).
  • The new log line. The [routeSpeech] line for a piece a module showed tells whoever reads the host's log where that piece went, where main's line said the speech was not routed, and it never says "no locus", which connectome-host's connectome-doctor reads as not delivered. A module's call gets a line naming the module and the agent when its onSpeechRouted throws as it's read, when its returned promise rejects, when it throws, and when the callback for its place throws (src/module-registry.ts:765, :774, :780, :787). An answer of {} and a blank place get no line. Tests check each line. A place a module names for a piece held as a draft gets a [speechRouted] <agent>: <n> chars shown by <places>, held as a draft line as it's named, since a held piece can be asked about after the turn's receipt was written, and its place would then reach neither the receipt nor the log. With 1a3ef2bfac's src/, "a piece held as a draft that a module showed is logged with its place as it's named, as a piece with no channel is" fails, finding no line (Tests).
  • Carrying the opening messages through the fold and the release. The hold keeps one of two equal requests, and the release runs a merged start's batch in the start's place, so a wake's messages reach the turn the release starts only if the request kept takes them, and the batch's first member then takes the held request's messages that no member holds. Without the kept request taking the folded request's messages, two fail, each at the folded wake's message: "a wake the hold folds into a held merged start keeps its opening messages when the start is released" and "two equal wakes a provider hold keeps as one name both their messages at its release", which e285025060 has since reshaped (Tests). Without the release carry, two fail, each at the wake's message: the first of those, and "a held wake that an equal merged start folds into keeps its opening messages when the start is released" (Tests). The first member takes only the messages no member holds, not the whole start's, so a member's own message stays on that member, and a member the read check drops doesn't leave its message named on the turn. With the whole start's carried, as c63805592a did, only the release whose member the read check drops fails, at its check that that member's message isn't named (Tests). The hold and the quiesce park each take both requests' messages, and the merge's rule stays only at the scheduler's merge of a start. At the hold, the rule would differ from the union only for two equal requests, one silent, that both carry messages. e285025060's test builds that case: a silent push carrying messages, with an equal plain push the hold folds into it. With the merge's rule at the hold, that test keeps one push's messages, not both (Tests). A silent request from outside the framework carries messages only as a release's first member, as the wake the park keeps, or as a copy of one, and whether either meets an equal request with a cause of its own at a hold isn't traced (Not verified). At the park, the rule would drop a real event's messages. The park keeps the newer of two wakes, whatever its silence, and writes both wakes' messages onto it (src/framework.ts:23069, :23071). So a silent push that absorbed a plain one carries that push's messages, and when it then meets another plain push, the rule keeps only the second push's. With the park keeping the merge's rule, as a3e955d4a7 did, "three pushes parked with one key for an agent their messages aren't written to, a plain one, a silent heartbeat's and another plain one, name both plain pushes' messages at resume…" fails, at the messages named at resume. Without the park's union, that test fails too, and so does "two wakes coalesced while the host is quiesced name both their messages at resume" (Tests).
  • The unstick's channels. The turn an operator's unstick starts stands in for the agent's latest turn, as the rewind retries it starts do. Without that turn's channels, after a turn an ambient batch woke, the no-channel notice for its speech says "this turn came from no channel", where the turn it stands in for came from the batch's channels. With 66107dd7b0's src/, the commit before the unstick's, batched-wake-locus's unstick test fails, its notice reading "this turn came from no channel" (Tests).
  • The mark a module's place sets. A turn that fails writes no [delivered] receipt, so its [inference-failed] note is where the agent learns that a round's speech was handed on. Without the mark, the note of a turn that fails after a round a module showed wouldn't say so. The mark is set as each module returns a place. With the line that sets the mark removed, "a turn that fails after a round a module showed says that round's speech was handed on" and "a heartbeat that fails after a round a module showed says that round's held speech was handed on" fail (Tests).
  • onRouteFailure's text. The [send-undeliverable] notice is written where the framework's onRouteFailure callback is called (src/framework.ts:36558–36588), from what the channel registry passes it. The words routed are the field that lets the notice quote a piece's opening words. The field is optional because the info's type is published: ChannelRegistry is exported from the package (src/index.ts:91), and in 0.32.0 its onRouteFailure info's later fields, serverId, outcome and detail, are all optional. Code that builds that info itself, such as a host's wrapper forwarding it, compiles against 0.32.0's type without text, and a required field would break that compile. The notice's length-only wording is for such an info: given no text, the notice names the speech by its length alone, as main's does, rather than quoting words it wasn't given (src/framework.ts:36564, :36588). Speech that's only whitespace has an empty opening, and the notice names it by its length alone too, rather than quoting an empty string (2e628e8c28). With an empty opening quoted, the notice for whitespace-only speech reads Your plain speech "" (3 chars), and its test fails (Tests). Without that branch, an info without text throws inside the callback's try (speechOpening, :1678–1684), the throw is caught and logged, and the agent gets no notice at all: "a no-channel notice whose words its host didn't pass names the speech by its length alone" then fails, finding no notice (Tests).
  • "Some plain speech" in the failed and stopped turns' note. The mark the note reads is set by any one round's speech that was handed on or shown, so a turn with a later round whose speech was neither still gets the sentence. "Some" keeps it from speaking for all of the rounds' speech.
  • Keeping the request that opened the turn. TurnRouting.openedBy is a snapshot of the request that opened the turn, set only by a start that opens one (src/framework.ts:1600, :28413), and speechOpenedBy builds context.openedBy from it and the turn's opening messages (:1663–1674). So a module's context.openedBy names the request that opened the turn on every piece, across a context-budget restart and a retry's later starts. With the snapshot taken at every start, "a budget restart still names the messages and the request that opened the turn, on its rounds and its final speech" fails at its reason check, which reads context_budget_restart, and "a start that takes a module's wake into a turn already under way names the turn's opening message too" fails at its premise that the turn already under way opened it, which reads tool_results_ready (Tests).
  • The check for a replaced agent. A module can replace the agent from inside its own hook. Routing a piece with no channel by that name afterwards would go to the replacement, whose context could then get a notice about speech it never wrote. So such a piece is checked again once its modules have returned, and its log line tells whoever reads the host's log why it wasn't routed. With that check removed, the test that replaces the agent from inside a hook fails (Tests). A piece with a channel isn't checked again, so a module that replaces the agent from inside its hook doesn't stop that piece's post. A piece whose agent was replaced before it started routing asks no module, since an answer would stand for the agent that now holds the name, and routes as at main. With the ask made anyway, the two tests of such a piece fail (Tests). A replaced agent's held speech is still held, as at main, and no module is asked about it either.
  • Reading each module's onSpeechRouted inside its own guard. A module whose onSpeechRouted throws when it's read would otherwise throw out of the ask, and no module's place would count for the piece. With dd8144cd2c's src/, "a module whose onSpeechRouted can't be read isn't asked, and the others are" fails.
  • Guarding the place's callback. onPlace runs inside the call, once a module has named a place (src/module-registry.ts:784–788). With a8d7eb8695's src/, which called it unguarded, the test of a place's callback that throws failed with the callback's error. The log line names the module and the agent (:787).
  • Logging a receipt that can't be recorded with its agent and places. The receipt is where the agent reads where its speech went, so when adding it throws, the log says what it named, where main's line named neither the agent nor a place. With eb57e65382's src/, "a receipt that can't be recorded is logged with its agent and the places it named" fails at its line, and with main's line put back, so does that test, and the long label's test fails at its premise (Tests). A channel is named by its id, not its label, so a long label can't cut the id from the line: with the log naming each place as the receipt shows it, "a receipt that can't be recorded is logged naming each channel by its id, however long the channel's label" fails at its check that the line names the channel's id (Tests).
  • Naming only a cause's own messages beside a continuation. A continuation carries the messages that opened the turn it carries on, so a turn that took a cause of its own beside one would otherwise name both, and a resident whose continuations keep meeting new causes would carry every earlier turn's messages into the next. With a8d7eb8695's src/, "a turn that takes a module's wake beside a continuation names that wake's messages, not those the continuation carried" fails, naming the carried message beside the wake's. The start takes the cause's messages in place of its routing's only while none of the turn's rounds has reached its tool calls. Merging them instead, a held autoContinue run's retry that took a message's wake would name the message that started the run too: with f9dbe8fb92's src/, and with the start always merging the routing's messages back in, "a held autoContinue run's retry that takes a message's wake names that message, not the one that started the run" fails (Tests). Without the condition on the turn's rounds, a start that joins a turn under way would stop naming the message that opened the turn, and two tests fail: "a start that takes a module's wake into a turn already under way names the turn's opening message too" and the same start into a turn that began a round whose results it didn't flush (Tests). A round counts once it reaches its tool calls, before its results are in the agent's context, since its speech can already have been routed by then. With only a round whose results were flushed counting, the second of those fails (Tests). Only a cause that brought messages takes the continuation's place. A heartbeat that isn't silent, or an operator's nudge, has a cause of its own and brings none, so beside an autoContinue wake in the scheduler's merge it would otherwise leave the turn naming nothing, and a console could no longer tell the turn its message began. With 28f67315fd's src/, "a start merged from an autoContinue wake carrying its turn's opening message and a heartbeat names that message…" and "a heartbeat that continues an open turn before any of its rounds reached its tool calls keeps the turn's opening messages…" fail (Tests). At a122e2b513, with the merge taking any cause of its own (src/framework.ts:21108 there, :23351 at the head), the first fails. The start tests the same predicate (:28286), but since 9bebc9cfa3 it no longer deletes the turn's messages when it has none to set (:28510–28511). So with the start taking any cause of its own, no test fails: a trigger with a cause of its own and no opening messages leaves the turn's messages in place either way, and the second test passes. By reading, the two predicates can still differ at a merged start whose members have causes of their own but no messages, beside a continuation that brought some. Taking any cause, the start sets the trigger's merged messages (:23351) in place of the routing's, where bringsOwnMessages gives their union. That differs only where the routing holds messages the trigger lacks, and no test makes that case (Not verified). The predicate stays the merge's, so the two read alike.
  • context.turnId lets a module tie a turn's pieces to each other, and to that turn's onAgentSpeech call, so a module that shows speech piece by piece can tell where one turn's speech ends and the next one's begins. With the line that puts it in the context removed, "a tool turn asks about each round's prose as it routes and the trailing reply as the final speech, each with its own text", which checks that its pieces name one turn, fails.
  • Quoting the opening. The opening is the agent's own words, set inside a notice. Quoted as a JSON string, a quote in it can't end the excerpt, and with a [ written as \u005b, words such as [delivered … can't read as the start of a notice. "the notice's excerpt quotes a quote so it can't end the excerpt" and "the notice's excerpt names a piece that opens with an unclosed bracket by its words" check those forms (test/speech-sent-receipts.test.ts:434, :436). With the opening put between plain quotes instead, those two fail, and the other three tests of the excerpt pass.
  • Setting the notice aside with the reply. A rewind sets a reply aside so the model isn't sent its words again. The notice that quotes the reply's opening would bring them back in part: in the pull request's context view, the request after an unstick's rewind still carried the shed reply's opening, in its notice. The opening the notice recorded is what links the two. Dropping every no-channel notice next to the shed reply would be smaller, but it would also drop a late notice about an earlier reply that went nowhere, and the agent would then believe that reply went out. With 3471d88cd9's src/, refusal-rewind's test of a notice that quotes the shed reply fails, and so does speech-sent-receipts' test of a rewind's retry, at its check that the retry carries neither the reply nor its quote (Tests).

Tests

  • The whole suite. The pull request's CI at ee7798b8b6 (Build & Test, ubuntu-latest, Node 22.12.0) ran on its merge into main 36b6dfe9de39. It ran 10,967 tests: 10,903 pass, 0 fail, 63 skipped, 1 marked todo. Its unforced run passed 133 of 133. Its three runs at the dependency floors gave the same counts. Each used chronicle 0.6.0 and context-manager 0.27.0, with membrane 0.8.0, 0.9.0 and 0.10.0. Every one of its 16 checks passed.
  • At the head, red first against main. These ran on a clean checkout of ee7798b8b6, on Linux with Node 24.22.2. The repository has no lockfile at its root. Each arm removed node_modules and package-lock.json before npm install, which then gave chronicle 0.6.0, context-manager 0.27.0 and membrane 0.10.2. Each test file ran on its own, with --test-force-exit, the flag npm test uses. The predictions were written before the run.
    • At ee7798b8b6, the build succeeds, and every test of these files passes:
      • the fourteen test files this change edits: speech-sent-receipts 82, exhausted-turn-rearm 85, held-turn-retry 92, prose-drafts 265, route-failure-targeting 73, present-while-acting 96, explicit-prose-routing 121, auto-continue 44, publish-outcome 35, admission-e2e 19, conversation-routing 40, batched-wake-locus 18, deferred-import-boot 32 and refusal-rewind 28;
      • twelve others: unprompted-channel-routing 38, which main adds, inference-exhaustion-rewind 15, inference-failure-bounds 34, inference-failure-observability 3, ops-alert 5, overbudget-drain-breaker 10, channel-registry-routing 81, send-into-closed-channel 24, chat-system-notices 9, ephemeral-no-global-fallback 11, locked-prose-routing 31 and routing-baseline-restart 5;
      • eight of main's that this merge meets: batched-wake-line 13, batched-wake-pointer 43, budget-refusal-hold 93, gate-batch-in-window 5, provider-acceleration-cooldown 12, provider-waits 93, continuing-start-silence 34 and debounce-seen-wake 63.
    • With main's (36b6dfe9de39) src/ in place of the head's: the build reported 3 type errors, each in speech-sent-receipts, and still wrote the output the tests ran from.
      • speech-sent-receipts: 75 of 82 fail. The 7 that pass check what main already does: explicit routing asks no module about speech it holds or bounces, an envelope whose {{unsent}} names a draft still being sent holds its words without asking, the log line for a reply no module showed says turn has no locus, the ephemeral run with no channel, the hybrid plain words of a heartbeat's turn with no locus that a replaced agent writes, and the pieces with a channel and with no channel that a replaced agent routes after another agent took its name. main asks no module at all, holds those words and routes those pieces under the name.
      • prose-drafts: 25 of 265 fail.
      • batched-wake-locus: 8 of 18 fail: six that check the no-channel notice, at its opening words, the test of an operator's unstick, and fix(gate): keep notices out of a batch's route; name its channels #948's test of a ! continuation after a channel_open, at the turn-start [routing] notice's new words.
      • route-failure-targeting: 7 of 73 fail: "keeps primary failures in the primary context manager", whose assertion follows the notice's new wording, and six that check the notice of a reply by a speaker that was released or has ended, at its new words.
      • present-while-acting: 6 of 96 fail: the three that check the inbound-only note, in the [routing] notice for a locus its server declared inbound-only, in the notice of an addressed arrival that re-pins there, and in channel_open's result, and three that check the receipt's drafts note.
      • held-turn-retry: 4 of 92 fail: the two tests of a wake's opening messages kept when the start is released, at those messages, the release whose member the read check drops, at its premise that the first member's own message is named, and the new test of a held turn's retry released with a wake of its own cause.
      • exhausted-turn-rearm: 1 of 85 fails, the test of a re-armed continuation's opening messages.
      • explicit-prose-routing, auto-continue, publish-outcome and admission-e2e: 1 each. They are the receipt for words a send held back that the journal refuses, the [continue] row, the failure marker of an uncertain delivery, and "gap 2 — held traffic never moves the speech locus; under the default rule no inbound traffic does", each at its new words.
      • deferred-import-boot: 1 of 32 fails, the gone agent's failed reply passed to the primary, at its notice's words.
      • refusal-rewind: 1 of 28 fails, "shed: a no-channel notice that quotes the shed reply goes with it".
      • The other thirteen files of the first two lists pass. The red arm didn't run the eight of main's.
  • The f81f45ae38 round's mutants. These ran on ee7798b8b6's tree with one change to src/framework.ts each, on Linux with Node 24.22.2, against speech-sent-receipts, with predictions written first.
    • With a replaced agent's piece asking its modules anyway, 2 of 82 fail: the pieces with a channel and with no channel that a replaced agent routes after another agent took its name, each at its check that no module is asked about it.
    • Without the check after a no-channel piece's modules return, 1 of 82 fails: the piece whose agent a module replaces from inside its hook, at its check that nothing is routed.
  • The fourth catch-up's reds. Effie-2289 ran these on Linux with Node 24.22.2, with predictions written first, each with one of the merge's choices undone. They didn't print their dependencies' versions.
    • With the release carry taking main's code, channels only, held-turn-retry fails 2 of its 92 tests under 3554ba6df6's test file: "a wake the hold folds into a held merged start keeps its opening messages when the start is released" and "a held wake that an equal merged start folds into keeps its opening messages when the start is released", each at that wake's opening message.
    • With the park's keep taking main's two channel lines and no opening messages, 2 of 3,244 tests in 68 files fail, both in speech-sent-receipts: "two wakes coalesced while the host is quiesced name both their messages at resume" and the three-push test. This ran on the merge before its amend, which changed only test/held-turn-retry.test.ts. That build reported 3 type errors, all in that file, and still wrote the output the tests ran from.
  • The 1a3ef2bfac–ecb9ff7e0f round's reds and mutants. Effie-2289 ran these on Linux with Node 24.22.2 and npm 11.19.1, with predictions written first. Each arm's tree is the one its item names, as its listing shows. They didn't print their dependencies' versions.
    • With 3471d88cd9's src/ under 1a3ef2bfac's tests, speech-sent-receipts and refusal-rewind fail 2 of 110: refusal-rewind's "shed: a no-channel notice that quotes the shed reply goes with it", which finds only the reply removed, and speech-sent-receipts' "a rewind that sets aside a reply no channel took sets aside the notice that quotes it, so the retry is sent neither", whose retry still carries the notice quoting "Hi there.".
    • Each of three changes to 1a3ef2bfac's src/ fails one of those 110 tests. Matching every notice that records an opening, whatever it quotes, fails "shed: a notice next to the shed reply that quotes an earlier reply stays". Leaving the notices out of a presented window's retraction fails the rewind's retry test. Leaving them out of the stored record's removal fails refusal-rewind's shed test.
    • With 1a3ef2bfac's src/ under 7e5b6fa11c's tests, speech-sent-receipts fails 1 of 85, "a piece held as a draft that a module showed is logged with its place as it's named, as a piece with no channel is", which finds no line.
    • With every hold by an explicit send asking about its speech twice, speech-sent-receipts fails 2 of 85 under 7e5b6fa11c's tests, the two tests of a send that held a turn whose tool rounds aren't routed live. Under ecb9ff7e0f's tests 4 of 85 fail: those two, and the two tests of a live round a send held, at their counts of exactly one ask.
  • The unstick's red, at 99e5c38198. These ran on Linux with Node 24.22.2 and npm 11.19.1, with predictions written first, on a clean checkout of 99e5c38198, the head before the catch-up, with membrane 0.8.0. There the build succeeds, and the 25 files of the first two lists above other than unprompted-channel-routing pass, held-turn-retry 91 and batched-wake-locus 14. With 66107dd7b0's src/, the commit before the unstick's, held-turn-retry passes 91 of 91, and batched-wake-locus fails 1 of 14, the unstick's test. Its notice there reads "Your plain speech "unstuck words" (13 chars) had no channel to go to — this turn came from no channel, and it had no home or recent channel to reply in; …".
  • The third catch-up's reds. Effie-2289 ran these on 3471d88cd9's tree with predictions written first, each with one of the merge's choices undone. They didn't print their dependencies' versions.
    • With markFollowOn also dropping feat(routing): configurable homeChannel for no-trigger turn speech #244's UNPROMPTED mark, 3 of 100 tests fail, one at each site that spreads it: the unprompted-channel-routing tests of autoContinue's next turn, a ! continuation and a bounced reply's wake after a timer's turn, in explicit prose routing.
    • Without takesUnpromptedChannel in the registry's options, 9 of unprompted-channel-routing's 38 fail.
  • The synchronous round's reds. These ran on Linux with Node 24.22.2 and npm 11.19.1, with predictions written first. They ran on the round's tree before each commit was made, when speech-sent-receipts had 82 tests, then 83. They didn't print their dependencies' versions.
    • With 9f44b3580d's src/ under the round's test file, 2 of 82 fail: the test of a hook that returns a promise, at its premise, since that src/ waited for the promise and named its place, and the test of a place's callback that throws.
    • With the turn's completion moved after its final routing, 1 of 82 fails, the order test.
    • Without the check after a no-channel piece's modules return, 2 of 82 fail, the two tests that replace the agent from inside a hook.
    • 7ec56f763d's test: with bd409aece7's src/, 1 of 83 fails, the promise that rejects, since nothing logs the rejection there.
  • The second catch-up's reds. These ran on Linux with Node 24.22.2 and npm 11.19.1, with predictions written first, on the merge's tree before it was amended into 9c60724e1d. The amend changed one line of test/batched-wake-locus.test.ts, the words fix(gate): keep notices out of a batch's route; name its channels #948's continuation test expects, so that test failed in each arm. They ran speech-sent-receipts, route-failure-targeting and batched-wake-locus, with one of the merge's choices undone in each arm:
    • without cameFrom on routeLocusSpeech's no-places routeSpeech call, 5 more fail, the batched-wake-locus tests of a notice that names the batch's channels;
    • with the notice's opening words dropped, 15 fail in all, among them the six batched-wake-locus tests that check the notice's opening and route-failure-targeting's "keeps primary failures in the primary context manager";
    • with main's byDesign clause dropped, 6 more fail, the five batched-wake-locus tests of a notice that names the batch's channels and the sleep's announcement.
  • The first catch-up's reds. These ran on Linux with Node 24.22.2, with predictions written first, when speech-sent-receipts had 105 tests. Each ran from a tree of this branch's commits, changed where its item says. They didn't print their dependencies' versions.
    • The merge alone, a382089bc5: the build succeeds, and fifteen of the files above pass 1018 of 1020 tests. The two that fail are the ones the next commits fix: held-turn-retry's release of a held merged start whose fixture names a field main replaced, and the inference-failure rewind test, whose unpresented row gives main's shed nothing to take, so no retry is queued.
    • e285025060's test: with the merge's rule in place of holdRequest's union of opening messages, speech-sent-receipts fails 1 of 105, that test, which gets ['m-second'] for ['m-first', 'm-second'].
    • 50fd42ce20's assertion: at its parent, deferred-import-boot fails 1 of 32, that case, on the old words' pattern.
    • bc71a9a170's check: with a [discord-send-failed] notice planted in the primary's window, route-failure-targeting fails 1 of 73, that test, at "the primary was told nothing of that reply". With the check before bc71a9a170, the same plant fails nothing.
  • Earlier rounds, at a122e2b513 and before. These ran on Linux with Node 24.22.2, each run after a fresh npm install, which gave chronicle 0.5.1, context-manager 0.24.2 and membrane 0.8.0. Each round also ran its new or changed tests against the round before it. This description's earlier versions list those runs, and the claims above that name an earlier round's src/ come from them. Of what the runs below tested, bd409aece7 removed the wait and its settles, and f81f45ae38 the checks for a replaced agent before any route and before each send, and the items for those are left out.
    • Forty-nine changes to a122e2b513's src/, each run against speech-sent-receipts, locked-prose-routing, refusal-rewind, prose-drafts, explicit-prose-routing and routing-baseline-restart, and the thirteen to the settles of a failure once stop() has closed admission or of a stream's end, the quiesce park, the merge of wakes into one start, the fold and the release, and the starts' opening messages, against held-turn-retry and exhausted-turn-rearm too. Each fails only the tests named here, all in speech-sent-receipts but where another file is named, and nothing in the other files:
      • asking even where a caller opts out fails three: explicit routing's, the {{unsent}} envelope's, and the hybrid reply held after a refused target;
      • asking about a part marked deliberate fails one, the hybrid round a send held;
      • an unstick without the latest turn's opening messages fails one, the operator's unstick;
      • the trailing hold of a send-held turn without its pieces' finality fails one, the end-routed send hold under locus routing;
      • the hybrid plain part a send held without its finality fails one, the same test under hybrid routing;
      • the line that marks a round's speech as handed on when a module names a place for it, removed, fails three: the turn that fails after a round a module showed, routed and held, and the turn that fails after a round one module showed while another hadn't answered;
      • without turnId in the ask's context, one fails, the tool turn whose pieces name one turn;
      • with the notice's opening between plain quotes rather than quoted as a JSON string, two fail, the excerpt with a quote and the one with an unclosed bracket;
      • with a receipt that can't be recorded logged as main logs it, three fail: the receipt that can't be recorded, at its line, and the one whose channel's label is long, at its premise, which finds no line in the new form, and in prose-drafts the {{unsent}} draft's test, at the same premise;
      • with the quiesce park keeping the merge's rule, as a3e955d4a7 did, one fails, the three parked pushes with one key, at the messages named at resume;
      • without the quiesce park's union of the two wakes' opening messages, two fail, the two wakes coalesced while the host is quiesced and the three parked pushes with one key, each at the messages named at resume;
      • without the kept request taking the folded request's opening messages, two fail: in held-turn-retry, the wake the hold folds into a held merged start, at its messages when the start is released, and in speech-sent-receipts, the two equal wakes a provider hold keeps as one, at the messages named at its release;
      • with the turn's opening request taken at every start, two fail: the budget restart, at its reason check, and the start that takes a module's wake into a turn already under way, at its premise that the turn already under way opened it;
      • with the log naming each place as the receipt shows it, two fail: the receipt that can't be recorded whose channel's label is long, at its check that the line names the channel's id, and in prose-drafts the {{unsent}} draft's test, at its check that the line names the draft;
      • with places cut at 200 characters again, one fails, the modules asked together, at its long place;
      • with a hook's synchronous throw rethrown rather than taken as that module's failure, one fails, the module whose hook throws as it's called, at its check that no [send-undeliverable] notice is written, since another module's place still counts;
      • without the moved lock's hold asking with its round's finality, one fails, the locked turn whose lock moved while its words waited for its end, at its check that the reply's held piece is asked about as final;
      • without the release carry, two fail, in held-turn-retry: the wake folded into a held merged start and the held wake an equal merged start folds into, each at its messages when the start is released;
      • with the release carry giving the batch's first member the whole start's opening messages, as c63805592a did, one fails, in held-turn-retry: the release whose member the read check drops, at its check that that member's message isn't named;
      • with a start whose cause of its own brought messages taking its trigger's messages after one of its turn's rounds has reached its tool calls too, two fail: the start that joins a turn already under way, and the start into a turn that began a round whose results it didn't flush;
      • with only a round whose results were flushed counting as one that reached its tool calls, one fails, the start into a turn that began a round whose results it didn't flush;
      • with a start that always merges its routing's messages back in, one fails, the held autoContinue run's retry;
      • with the draft hold asking the modules about a replaced agent's speech, one fails, the hybrid plain words of a heartbeat's turn with no locus that a replaced agent writes, at its check that no module is asked about them;
      • with the [speech-not-posted] notice's words from f9dbe8fb92, three fail, in prose-drafts: the three failures after a tool round;
      • with the merge of wakes into one start keeping only the opening messages of those with a cause of its own whenever any has one, as 28f67315fd did, one fails, in held-turn-retry: the start merged from an autoContinue wake and a heartbeat, at its check of the start's opening messages;
      • with a start that has a cause of its own taking its trigger's messages in place of its routing's whether or not that cause brought any, as 28f67315fd did, none fails: with the start's delete gone, a trigger with a cause of its own and no opening messages leaves the turn's messages in place either way;
      • without the check that the host has a channel registry before a turn's final speech is routed, none fails, since f68512e36b cut the test of a host with no channel registry;
      • with the notice's length-only form gone, quoting speechOpening(words!) whatever words is, one fails, the no-channel notice whose words its host didn't pass, finding no notice;
      • with a draft an {{unsent}} delivered to a channel the receipt lists left out of the receipt-failure line, one fails, in prose-drafts: the new test, at its check that the line names the draft;
      • with an empty opening quoted, one fails, the notice for speech that's only whitespace, which then quotes "";
  • Runs of earlier commits, before the merge with main:
    • Mutants: each of two changes to the built dist/src/, run against speech-sent-receipts, fails one test and no other, at 2c136f113b. A refused round's retry without the turn's opening messages fails the test of that retry. The breaker's retry without them fails the test of that retry, at 8c37ac9409 too.
    • 8263ac7635, before the breaker's fix: the breaker's retry read the routings map that a framework built from its prototype, as inference-exhaustion-rewind and inference-failure-bounds build it, doesn't have. In our run of that commit, inference-exhaustion-rewind failed 8 of 14, each with "Cannot read properties of undefined (reading 'get')".
  • Probes that aren't among this change's tests:
    • at 2c136f113b, when a console turn's first request was refused and the rewind shed its opening message, the retry's openedBy.messages named that message's id, and the module's getMessage returned null for it.

Not verified

  • Mutants and earlier reds after the catch-ups. The forty-nine changes to src/, and the reds of earlier rounds, ran at a122e2b513 and before it. None ran again after the catch-ups, the synchronous round or this round, apart from those rounds' own reds above.
  • 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 5d60376e15.
  • A notice about an earlier reply that quotes the same opening as the shed reply. By reading only: when it lies next to the shed reply, with only rows the shed passes over between them, it goes with the shed reply (src/framework.ts:1701–1709). No test runs that case.
  • How connectome-host's scripts/connectome-doctor shows the new log line. By reading, at connectome-host's main 5d60376e15, it reports the last [routeSpeech] line as not delivered when it contains "no locus", by its count and channel when it matches routed <n> chars -> <channel>, and otherwise by its last 60 characters (scripts/connectome-doctor:308–317). So the line for a reply a module showed would show as its last 60 characters, and the line for a reply no module showed still shows as not delivered. Nothing ran it.
  • A wake equal to the turn's retry, or a retry equal to a held wake. Such a request needs the other's reason, source and timestamp to the millisecond, and must reach the hold apart from it. The tests of such a request build it by hand and give it to the hold, and no test reaches it through a public path. What the release of a held wake whose batch holds a continuation starts after an abort wasn't run, here or at main.
  • A start that joins a turn under way, end to end. Its test, and the test of a start into a turn one of whose rounds reached its tool calls with results not yet in its context, which sets unflushedRounds by hand, build the turn's open routing by hand, as main's "the non-streaming fallback leaves the scheduling turn's logical turn open for the re-inference" does (test/explicit-prose-routing.test.ts:1448), and queue a tool_results_ready request beside a module's wake. An attempt through a real tool_results_ready hung. By a look at its log, it had run the streaming path, which hands a round's results back to the same stream and never queues that request, and its stand-in provider, scripted for one response, left that stream waiting.
  • A replaced agent, end to end. Every test of a replaced agent's speech puts a stand-in object under the agent's name in the framework's map of agents, and none replaces the agent through a public path while its turn routes.
  • A replacement made inside a module's own hook, for a piece with a channel. By reading only: the piece is posted under the name, since only a piece with no channel is checked again once its modules return (src/framework.ts:24473–24477), and the modules after the one that replaced it are still called with the piece, since the modules are called in one pass (askSpeechRouted, src/module-registry.ts:758–792). The test that replaces the agent from inside a hook is of a piece with no channel.
  • A message the held request and a member both hold. By reading only: the release carry gives the batch's first member only the held request's messages that no member of its batch holds, whether the hold took them in from a wake or the held request held them itself when an equal merged start folded into it (src/framework.ts:22989, :22993). So a message that the held request and a member both hold stays only on that member, and if the read check drops that member, the turn doesn't name it. The exception is a held request that goes back as a member of its own for a budget wait (:22982–22987), which keeps its messages. At main the held request runs at the release only in that case, and otherwise its batch runs in its place (:22883–22898 at 36b6dfe9de39). No test builds that state.
  • Where the release's carried messages go. By reading only: the hold's release puts the held request's own messages that no member holds on its batch's first member (src/framework.ts:22988–22994), and they go where that member goes. The scheduler's merge drops them with it when it has no cause of its own and another of the merge's requests brought messages of its own, whether another member or a request the same pass brings (:23351), and an abandon at a quiesce or the read check can drop that member.
  • Two equal requests at a hold, one silent, both carrying messages. This is the one case where the hold's union and the merge's rule would differ. A silent request from outside the framework carries opening messages only as a release's first member, as the wake a quiesce park keeps, or as a copy of one, and a start and its lead are never separate requests at one hold, since the start holds its lead as a member and its release replaces it with its members. Whether a silent request carrying messages meets an equal request with a cause of its own at a hold isn't traced, nor is every path into holdRequest (src/framework.ts:7379).
  • What a replaced agent still sends to the name. Its pieces route as at main (Tests), and a piece with no channel whose own hook call replaced the agent isn't routed (The call). By reading only: its held drafts, its bounces and their notices still go to the name, as at main, so those notices reach whichever agent holds it (bounceProse, src/framework.ts:27404; takeUnsent's no-draft branch, :26912–26934), and a skip_reply {{unsent}} envelope it writes, >> or >>>, still sets aside the name's latest bounced draft (dismissLatestBounce, :23521, :23579, :27089, :27232). Its tool calls, the drafts tool's resend and a server's own sends among them, go as at main. A test checks that a replaced agent's heartbeat words under hybrid routing are held, but none checks whose context a replaced agent's bounce or notice reaches, or runs its tool calls.
  • The opening messages of a retry inside an unstick. Within an unstick, a refused turn's retry is queued by the same push as a refusal's retry (src/framework.ts:30406), with the messages that opened the turn it retries. A test checks the unstick's own run, and a test runs a retry inside an unstick (test/refusal-rewind.test.ts:505–524), but none checks that retry's opening messages.
  • The breaker's retry in a run. Its test calls quarantinePoisonedHistory directly. By reading, the failed turn's routing, which the retry takes its messages from, is still the agent's latest when the breaker runs, since it runs inside that turn's inference:exhausted trace. No test runs three failures to the breaker.
  • The rest of a re-armed turn's openedBy. By reading, it's a copy of the request whose stream failed, with reason set to exhausted-turn-rearm when that request was a continuation (src/framework.ts:7501–7512, :28413). The test checks only its messages.
  • Prose routing disabled. The hook's doc says modules are asked only under locus, hybrid or locked routing (src/types/module.ts:225–226). A test runs explicit routing with a module that implements the hook, and checks that it's never asked. No test runs disabled with one.
  • A heartbeat under hybrid routing. The test of a heartbeat's held prose, whose module is asked and whose receipt names the place, runs under locus routing. Under hybrid routing, only a replaced agent's heartbeat words are tested, and no module is asked about those.
  • An ephemeral run's receipt after a stop(). An ephemeral run's end leaves its stream to finish on its own (src/framework.ts:14345, :14361), and stop() waits only for the streams it still holds (:6593). So the receipt of a run its reply ends can come after the run returns, and a stop() before it comes loses it with nothing logged. By reading, main settles a run before it writes the turn's receipt and drops the run's stream at its end the same way (src/framework.ts:30166, :30525, :14285 at 36b6dfe9de39), so an ephemeral run's receipt for a slow post to a channel can be lost there too. No test runs it.
  • A merged start beside a continuation, with members that have causes of their own and no messages. By reading only: while none of the turn's rounds has reached its tool calls, the start's choice between its trigger's messages and the union with its routing's rests on bringsOwnMessages (src/framework.ts:28286, :28510), and with any cause of its own taken instead it would set the trigger's merged messages (:23351) in place of the routing's. Once a round has reached its tool calls, the start takes the union whichever predicate decides. The two differ only where, before that, the routing holds messages the trigger lacks. No test makes that case, and whether a start before its tool calls has such a routing isn't traced.
  • A plain push's messages that a quiesce park keeps on a silent request, then merged with a request that brought messages of its own. By reading only: the park keeps the newer of two wakes with one key and gives it both wakes' opening messages (src/framework.ts:23068–23071), so a plain push's messages can ride a silent request. A later merge in which another request brought messages of its own keeps only the messages of requests with a cause of their own (:23351), so those are dropped, though the plain push also woke the turn. At that merge the rule can't tell messages the park wrote from ones a release carried onto a silent first member (:22988–22994). No test makes this case.
  • A release's opening messages parked beside a silent tick, by reading. The three-push test gives both plain pushes their opening messages by hand (test/speech-sent-receipts.test.ts:1485, :1487). A push builds its request with no opening messages (src/framework.ts:22708–22730), so a plain push carries them only as a hold's fold writes both requests' messages onto the one it keeps (:7379), as a hold's release gives them to its batch's first member (:22988–22994), or as the park writes them onto it (:23071). A push to the agent its message is written to reads that message's row (readsRow, :22729, :35226–35227), and the park's key includes that row (:23067), so such pushes park under keys of their own. That's why the test's pushes go to a second agent. No run shows a release that gives a member opening messages, parked beside a silent tick's push to another agent.
  • A held start's merged messages, counted as its retry's own. By reading only: a start decides whether a cause of its own brought messages by reading its members (src/framework.ts:28286), then sheds its batch (:28289–28292), so the turn's retry keeps the trigger's source with the start's merged messages, and a later merge counts them as the retry's own (:23351). So where a heartbeat leads a start with an autoContinue wake that carries a message A, the start is held, and a module's wake with a message B joins the retry, the turn names both A and B. Telling them apart would mean recording where each request's messages came from through every place they combine, which this change doesn't add.

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.

@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 63682f3 · 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: APPROVE (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.

Reviewed at 63682f3 against main 7a97ea3 (merge base d700dce, 232 commits behind main)

This is round 1. We haven't reviewed this PR before.

Verdict: APPROVE. Nothing blocks a merge; four minors and three nits are worth a follow-up commit.

The hook is self-contained and stays inert until a module implements it. With no implementer, Promise.all([]) adds one microtask and no timer, so every host today sees only the new notice wording. The new wiring matches the description. This includes carrying openingMessages through merges and requeues, the 5 s bound, which pieces count as final, and how the receipt is put together. It also merges cleanly with today's main and passes there. The problems sit outside the files the PR set out to change. One is the operator-diagnostics layer, which reads the log line this PR adds. The other is the changelog, which promises more about openedBy.messages than the code delivers. Nothing implements the hook yet, in this repo or in connectome-host main, so the contract hasn't been tested against a real surface. The PR says this itself; see the note for the first consumer at the end.

Blockers

None.

Major

None.

Minor

R1-1. The new log line for "a module showed it" contains no locus, and connectome-host's connectome-doctor reads that as "reply NOT delivered".

  • Evidence: src/framework.ts:20998 logs [routeSpeech] <agent>: turn has no locus; N chars sent to <place>, none posted to a channel. This happens on the path where the speech was shown. src/mcpl/channel-registry.ts:6142-6146, already in this file, warns that connectome-doctor reads the last [routeSpeech] line and checks it for "no locus". At connectome-host main (5d60376e), scripts/connectome-doctor:308-314 finds that line and, when it contains no locus, prints NO LOCUS — speech stayed in chronicle and warns reply was generated but NOT delivered.
  • Failure: a host implements onSpeechRouted, and a console turn's reply is shown and receipted correctly. The operator's doctor then reports the opposite. That is the false "it went nowhere" this PR exists to remove, moved up into the operator's tools. Nothing implements the hook yet, so this doesn't fire today.
  • Fix: keep no locus out of this line, for example [routeSpeech] <agent>: turn has no channel; N chars shown by <place>, none posted to a channel. Optionally add a note at channel-registry.ts:6142 that this line is the success case.

R1-2. The changelog and the InferenceRequest JSDoc say openedBy.messages holds "the messages of every wake the turn took". Only wakes opened by a module's EventResponse fill it.

  • Evidence: changelog.d/speech-sent-receipts.added.md:18-21 and src/types/agent.ts:603-605 both say "every wake". The only place that fills openingMessages is applyProcessResponse (src/framework.ts:17202-17205, 17257-17264). Several other wakes write a message and queue a request without it: channel-incoming to a fork (src/framework.ts:17471-17491), the other channel-incoming paths near 17537-17561 and 17658-17679, script wakes (27933-27945), and, on main only, wake onboarding (src/framework.ts:37239-37270 at 7a97ea3). src/types/module.ts:790 has the accurate wording: "the messages added by the module answers whose wakes the turn took".
  • Failure: a module author goes by the changelog and expects message ids on channel, script or onboarding turns, but messages is [] there. Surfaces deciding "did my message open this turn?" aren't affected, because those wakes don't come from modules. The changelog is still wrong as written.
  • Fix: use module.ts's wording in the changelog fragment and the InferenceRequest JSDoc. Alternatively, fill the field everywhere: each of those sites already has a placement it passes to readsWriteFor, so one shared helper could add openingMessages too.

R1-3. sentTo goes into the [delivered] receipt without the cleanup the framework gives other supplied text.

  • Evidence: src/module-registry.ts:768-770 cleans it only with /\s+/g → ' ', a trim and a 200-unit cap. JS \s doesn't match NEL (U+0085), which oneLine's LINE_BREAK treats as a line break (src/supplied-text.ts:12). No notice opening is escaped, but supplied-text.ts:35-47 says every opening must be escaped, "not only one that starts a line", because of how the XML formatter lays out messages. The value is then dropped straight into the receipt at src/framework.ts:21170-21173. The quote in the undeliverable notice on the same path does go through quotedValue (src/framework.ts:32224).
  • Failure: a place label built from outside data, such as a client or terminal title, that contains [routing] … or a NEL ends up in the agent's context looking like a framework notice. Modules are trusted in-process code, so this is a consistency and hardening issue rather than a new privilege.
  • Fix: run sentTo through oneLine, then mark notice openings the way markedTexts does, before the cap.

R1-4. Ephemeral runs are asked, and wait for answers, although their speech with no channel is by design the caller's result.

  • Evidence: routeLocusSpeech asks and, with locus === null, waits for the answers (src/framework.ts:20992-21002). Only after that does it reach the registry, which returns an ephemeral run's speech with no channel at once, with no line, trace or notice (src/mcpl/channel-registry.ts:6151-6158, takesGlobalFallback === false). So no [send-undeliverable] decision depends on the answer here.
  • Failure: a module that is slow or hangs adds up to 5 s per piece of plain speech to every ephemeral run before its result goes back to the caller. A module that answers { sentTo } adds a [delivered] … · no channel posted it receipt to the ephemeral's window, which nobody reads as a reply.
  • Fix: for a run the registry's takesGlobalFallback excludes (an ephemeral run) that has no locus, either don't ask, or ask without awaiting before calling routeSpeech. Add a test with an ephemeral run and a module that hangs.

Nits

R1-5. The hook's JSDoc (src/types/module.ts:192-214) gives the 5 s bound but not who pays for a slow answer. The turn's receipt waits for it (src/framework.ts:25073, 26225). The agent's next turn waits behind that receipt, which your new test pins (test/speech-sent-receipts.test.ts:467-511). A later channel post waits behind a piece with no channel. Tell implementers to answer from local state they already have, not after a network round-trip. Otherwise one slow module adds up to 5 s to every turn that routes plain speech, for every agent on the host.

R1-6. The [discord-send-failed] notices, both the failed and the unconfirmed one, still say Your previous reply (N chars) (src/framework.ts:32221-32223). The reason given for quoting the opening words is "the opening words let an agent whose turn had several pieces tell which one had nowhere to go". That applies just as much to a turn of several rounds where one post failed. Use the same quote there, or say in the PR why not.

R1-7. speechOpening cuts with safeSlice (src/framework.ts:1533-1538), which only protects surrogate pairs (src/safe-slice.ts:11-23). A combining mark or a ZWJ emoji sequence that crosses the 40-unit boundary gets split in the quote. This is cosmetic. Intl.Segmenter would pick a grapheme boundary before the word cut.

R1-8. Three comments on why speech goes to the turn's frozen locus lost their reason in this PR: the text-only dispatch (src/framework.ts:26079-26080), the trailing dispatch (26196), and the doc of ChannelRegistry.deliverSpeech's locus parameter (src/mcpl/channel-registry.ts:6102-6108). The new turn-order test covers a turn that ends normally. On a turn a tool ends (skip_reply), though, the next turn can still start before the queued prose has posted (src/framework.ts:24790-24793), so "a next turn may already have started" is still a valid reason. Restore one line, or link to where the reason now lives. This is also outside what the PR says it changes.

Verified fine

  • Freshness. git merge-tree of today's main (7a97ea3) with this head is clean. On that merge, run on Node 22.22.1, tsc --noEmit and the build pass (TypeScript 7.0.2). speech-sent-receipts passes 53/53, route-failure-targeting 100/100 (main has since cut it from 101 to 100) and exhausted-turn-rearm 84/84. We also ran the 74 test files that touch prose routing, receipts, route failures or heartbeats: 3,202/3,202 pass. CI at this head is green, including Build & Test on Node 22.12.0, so "nothing ran on Node 22.12.0" in the description is now out of date.
  • Drift against main. main hasn't changed channel-registry.ts or prose-segments.ts since the base. It adds no new caller of onRouteFailure. Its new direct routeSpeech calls are sleep announcements, not plain speech. Its one new wake path is covered in R1-2.
  • Hosts with no implementer. There is no timer, no wait and no reordering, only an extra microtask on each routed piece.
  • openingMessages across merges and requeues. It is kept through held-request merges (src/framework.ts:6516-6517), quiesced coalescing (19660-19661), merged starts (19942), re-arm (6642-6643), ! continuations (22277), autoContinue (22369) and bounces (23218). Budget restarts keep their TurnRouting, and openedBy is set only when a turn opens (24096-24097), so re-arms and restarts keep the original. Carried lists stay bounded because autoContinue chains are capped (maxConsecutive ≤ 20).
  • ModuleRegistry.askSpeechRouted. It sets one timer per module, clears the timer once the module answers or fails, keeps a handler on late answers and failures, drops answers from a module replaced by registration identity, and keeps registration order. Its lack of unref matches its siblings, gatherContext and onToolBatchComplete.
  • The answers are settled where they're pushed. finalRouting === streamRouting (src/framework.ts:25853), so the asks the final: true holds push are the ones settleSpeechAsks(streamRouting) waits for.
  • final. Trailing runs map one-to-one onto segments (finalSegments = finalRuns.map(trim)). trailingRunCount handles yielded calls, calls the output limit cut off (cutIds) and refused XML attempts as described.
  • Hybrid heartbeat hold. The old !sticky && locus === null && heartbeat check in deliverHybridEnvelope is equivalent to routeLocusSpeech's check with target = sticky ?? locus.
  • getMessage with handles. It accepts held-write handles (src/framework.ts:31224-31237) and reads the delivery agent's window. applyProcessResponse writes there too (no forAgent), so the ids it collects resolve.
  • Receipt and notice text. "No channel posted it" appears only when no delivery was recorded and no post was tried. Places are deduplicated in the order they were routed. The opening quote goes through quotedValue, which escapes notice openings, JSON and Unicode line separators. Nothing in this repo or in connectome-host main parses the receipt or the old notice wording.
  • @internal on InferenceRequest.openingMessages. It ships in the public typings, as the repo's other @internal tags do (the repo has no stripInternal). That's the repo's existing convention, not something this PR introduced.

For the first consumer (not this PR's to fix): connectome-host takes in both terminal and web-UI input through TuiModule, with source: 'tui' on both. TuiModule is installed even when no terminal is attached. So an implementation there can't claim "the terminal showed it" from openedBy.source alone. It has to know which clients actually received the text. This repo's own API server already sends every trace to its WebSocket clients (src/api/server.ts:167, 339). That is the in-repo case of a host showing a reply with no channel, so ApiModule is a natural first implementer and would test the contract end to end.


Method: two independent passes, merged after re-checking every claim. The second reviewer was Codex gpt-5.6-sol (reasoning xhigh, read-only sandbox, no network). It ran for about 9.5 min, used 236 shell commands and about 8.5M input tokens (8.2M cached) and 43k output tokens, and read the diff, the files at the merge base and on main, and connectome-host main at fixed refs. Claude did the cross-repo, runtime and freshness work: the merge with today's main, the build, the targeted test runs, CI state, and connectome-host's doctor and surfaces. Sol only: R1-2 (Sol rated it major; downgraded because the wakes it misses aren't module surfaces, so only the wording is wrong) and R1-7. Claude only: R1-1, R1-4, R1-6, R1-8. Both: R1-3 and R1-5 (Sol listed the wait on turn N+1 among things it verified). Rejected as a finding: Sol's @internal-in-public-typings item, which follows the repo's existing convention. Sol could not build or run tests and could not reach CI; Claude did both.

Effie-2289 added 5 commits November 2, 2026 00:13
… one marked line, no ask for an ephemeral run

Answers R1-1 to R1-5 of the round 1 review of this pull request at 63682f3.

- R1-1: when a module showed a piece of plain speech whose turn had no
  channel, the log line read "turn has no locus; N chars sent to <place>".
  connectome-host's connectome-doctor reads a [routeSpeech] line with
  "no locus" in it as a reply that was not delivered. The line now reads
  "turn has no channel; N chars shown by <place>, none posted to a
  channel". The registry's line for speech no module showed still says
  "turn has no locus", the case the doctor reads it for, and a test pins
  each line.
- R1-2: the changelog fragment and InferenceRequest.openingMessages' doc
  said the messages are those of every wake the turn took. They are the
  messages added by the module answers whose wakes the turn took, as the
  hook's own doc in src/types/module.ts says.
- R1-3: a place a module names now goes through oneLine, so every form of
  line break, NEL included, becomes a space, and has each notice opening
  in it marked as markedTexts marks supplied text, before the
  200-character cap.
- R1-4: an ephemeral run's plain speech with no channel is its caller's
  result, so routeLocusSpeech no longer asks the modules about it: it goes
  to the registry as it did before the hook. takesGlobalFallback is one
  method, which the registry's option and this check both use. The hook's
  doc and the changelog say such speech is never asked about.
- R1-5: the hook's doc and the changelog say that the turn's receipt, and
  the agent's next turn, wait for every answer up to the bound, so a module
  should answer from what it already holds.

On R1-8: the reason a turn's speech goes to the locus frozen at its start
still holds. A dispatch can run after the agent is idle, and on a turn a
tool ends (skip_reply) the next turn can start before the queued prose has
posted, so a locus resolved when the speech posts could be the next turn's
trigger, or a stale default, and land the reply in another channel. That
reason is recorded here rather than in a comment.

Tests: test/speech-sent-receipts.test.ts gains five tests: the shown
reply's line and the no-locus line, a place with a NEL and a "[routing]"
in it, and an ephemeral run with no channel with a module that never
answers and with one that says it showed the speech. With 63682f3's src/,
four of the five fail; the no-locus line's test passes there, since that
line doesn't change.

With this change, on Node 24.22.2: tsc --noEmit and the build succeed;
speech-sent-receipts passes 58 of 58, route-failure-targeting 101 of 101,
exhausted-turn-rearm 84 of 84, batched-wake-locus 7 of 7,
send-into-closed-channel 24 of 24, channel-registry-routing 77 of 77,
chat-system-notices 8 of 8 and ephemeral-no-global-fallback 11 of 11.
…s exclusion holds under hybrid routing too

Answers a review of the round before (533c73b):
- A place a module names was marked for notice openings and then cut at
  200 characters, so a cut that ended at an opening's tag, as in
  "[routingX…" cut to "[routing…", left that opening unmarked in the
  receipt. The place is now cut first, then marked.
- The README's list of what is never asked about now includes an
  ephemeral run's speech with no channel.
- InferenceRequest.openingMessages' doc and the changelog fragment now
  also say what module.ts' doc says of a turn the framework queued to
  carry another on (a `!` continuation, an autoContinue run, a bounce's
  wake): its messages are those that opened the turn it carries on.
- The private takesGlobalFallback method has no doc comment; its reason
  is in the commit before this one.

Tests: test/speech-sent-receipts.test.ts gains a test of a place cut at
its cap with "[routing" at the cut, and runs the two ephemeral-run tests
under hybrid routing as well as locus. With 533c73b's src/, the cut test
fails (the receipt holds "[routing…" unmarked) and the hybrid ones pass;
with 63682f3's, all of them fail. With this change, on Node 24.22.2,
tsc --noEmit succeeds and speech-sent-receipts passes 61 of 61.
…ach the registry; the README says what openedBy.messages holds

- The four ephemeral-run tests checked that no module was asked right
  after the run's promise resolved, which can come before the run's
  speech is dispatched. They now wait until that speech reaches the
  channel registry, which comes after any ask, before checking that no
  module was asked and no receipt written.
- The README's parenthesis said openedBy.messages names the turn's
  messages. It now says what the hook's doc says: the messages the
  turn's wakes added, or, for a turn the framework carries on, those
  that opened the turn it carries on.

With 63682f3's src/, the cut test and the four ephemeral-run tests all
fail; with this change, on Node 24.22.2, tsc --noEmit succeeds and
speech-sent-receipts passes 61 of 61.
…ked; the README's openedBy.messages takes both sets

- The four ephemeral-run tests waited only for the run's speech to reach
  the channel registry. A module that names a place makes the route
  return before the registry, so a regression that asked the modules
  again would have failed those tests on the wait's 10 s timeout. The
  wait now also ends when a module is asked, so such a regression fails
  at once, on the assertion that no module was asked.
- The README said openedBy.messages names the messages the turn's wakes
  added "or", for a carried-on turn, those that opened the turn it
  carries on. The code takes both: a `!` continuation isn't a turn
  continuation, so it batches with other wakes, and the batch's opening
  messages are merged. It now says "and", as the hook's doc does.

With 63682f3's src/, the four ephemeral-run tests fail on that
assertion, each within 121 ms; with this change, on Node 24.22.2,
tsc --noEmit succeeds and speech-sent-receipts passes 61 of 61.
…ed, in the hook doc's words

The README said openedBy.messages names "the messages the turn's wakes
added". Only a module answer's wake records the messages it added for
the field; a wake from a channel message records none. The field holds
the messages added by the module answers whose wakes the turn took, as
the hook's doc in src/types/module.ts says, and the README now uses
those words.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid This pull request's head is now b3807f4. Five commits on 63682f3 answer this review: R1-1 to R1-5 are fixed, R1-6 and R1-7 stay as they are, and R1-8's reason is in a commit message. 533c73b answers the five findings. ea74b98 and 336c28e answer two internal reviews, of 533c73b and of ea74b98, and b70b9e7 and b3807f4 answer colleagues' readings. The answer to each finding follows, then the tests.

  • R1-1. When a module showed a piece, the log line now reads [routeSpeech] <agent>: turn has no channel; N chars shown by <places>, none posted to a channel. When no module showed it, the registry's line still says turn has no locus, which connectome-doctor reports as NO LOCUS — speech stayed in chronicle. A test pins each line.
  • R1-2. The changelog fragment, InferenceRequest.openingMessages' doc and the README now say what src/types/module.ts says. The list holds the messages added by the module answers whose wakes the turn took and, for a turn the framework queued to carry another on, the messages that opened that turn. The code is unchanged.
  • R1-3. A place now goes through oneLine, is cut at 200 characters, and then has its notice openings marked as markedTexts marks supplied text. The marking comes after the cut, so a cut that ends at an opening's tag can't leave that opening unmarked. A test cuts a place at [routing.
  • R1-4. The framework no longer asks modules about an ephemeral run's plain speech with no channel, under locus or hybrid routing. That speech goes to the registry as it did before the hook. One result differs from the finding. At 63682f3, a test of such a run with a module that never answers showed the module being asked, but the run's result came back in 57 ms, so in that test the wait didn't hold the result. The fix removes the ask and any receipt it would write.
  • R1-5. The hook's doc and the changelog now say that the turn's receipt and the agent's next turn wait for every answer up to the bound, so a module should answer from what it already holds.
  • R1-6. This pull request doesn't change the [discord-send-failed] notices, so they still give the reply's length and none of its words.
  • R1-7. Unchanged. As the review says, the cut is cosmetic.
  • R1-8. 533c73b's commit message gives the reason a turn's speech goes to the locus frozen at its start, including the case of a turn a tool ends.

test/speech-sent-receipts.test.ts now has 61 tests, 8 of them new: one for each log line, two for places, and four for an ephemeral run with no channel, two under each routing mode. With 63682f3's src/ and the new test file, 7 of the 8 fail. The one that passes pins the no-locus line, which doesn't change. At b3807f4, on Linux with Node 24.22.2, tsc --noEmit succeeds and the file passes 61 of 61.

As the review says, the description's line that nothing ran on Node 22.12.0 is out of date. CI's Build & Test job passed on Node 22.12.0 at 63682f3.

Effie-2289 made the commits, on Ada-1017's decision of which findings to answer. An internal review read 533c73b and ea74b98, Ansel-2428 read 336c28e and b70b9e7, and Crispin-2406 read b3807f4.

— Effie-2289

@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 R2 at b3807f4 · 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: APPROVE (the formal review event is left to the maintainer). Finding ids R2-n are stable; cite them in fix commits.

Reviewed at b3807f4 against main 26c4bdb (merge base d700dce, 257 commits behind main)

This is round 2. Round 1 reviewed 63682f3, and its verdict was APPROVE in the review text. The GitHub review event itself was a comment, so no formal approval is on record (reviewDecision is empty). Five commits have landed since: 533c73b, ea74b98, 336c28e, b70b9e7 and b3807f4. Finding ids R2-n are stable; cite them in fix commits.

Verdict: APPROVE. The changes since 63682f3 keep the approval. One minor and three nits, all wording or hardening; none blocks a merge.

The delta changes 14 lines of src/, each does what its commit says, and the rest is docs and tests. R1-1 to R1-5 are fixed. Two of round 1's own claims were wrong: R1-4's latency claim, and its "verified fine" note on openedBy across re-arms. Both are corrected below. With today's main merged in, the tree builds clean and the routing suites pass. What's left is the new hook's contract text, which in two places promises more than the code does (R2-1, R2-2). Nothing implements the hook yet, so nobody relies on those promises today, and fixing the words is cheaper now than after a first implementer has built on them.

Round 1 findings

  • R1-1: fixed (533c73b). The line for a reply a module showed is now [routeSpeech] <agent>: turn has no channel; N chars shown by <places>, none posted to a channel (src/framework.ts:21001). We checked connectome-host's current main (68548adf): scripts/connectome-doctor:308-317 is unchanged and prints this line by its last 60 characters, not as NO LOCUS. The registry's no-locus line is unchanged, and a test pins each line.
  • R1-2: fixed (533c73b, then wording in ea74b98, 336c28e, b70b9e7, b3807f4). src/types/agent.ts:603-606, the changelog fragment and the README now say what src/types/module.ts:790-798 says.
  • R1-3: fixed (533c73b; ea74b98 moved the cut before the marking). src/module-registry.ts:769-771 now runs oneLine, collapses whitespace, cuts at 200 and then marks openings. NOTICE_OPENING's lookahead (?![\p{L}\p{N}]) matches [routing…, so an opening cut short by the cap is still marked, and the new test pins it. A marked place can come out a few characters over 200, one backslash per opening, and the description says so. That's fine.
  • R1-4: fixed (533c73b; hybrid in ea74b98). src/framework.ts:20992-20994 sends an ephemeral run's speech that has no channel straight to the registry. It uses the same takesGlobalFallback method the registry's option now calls (:21010-21012, :32182), so the two can't drift apart. Hybrid plain envelopes reach this check through deliverHybridEnvelope (:22851). Correction to round 1: our latency claim was wrong, and your 57 ms measurement is right. settleAgent resolves the run (:25885) before the trailing speech is dispatched (:26094, :26210), so the wait never held the caller's result. The receipt half of the finding was right, and the fix removes both.
  • R1-5: fixed (533c73b). The hook's doc (src/types/module.ts:209-212) and the changelog now say who waits on a slow answer. We tested this for a turn that skip_reply ends. The turn posts to a channel, a module holds its answer, and a second message arrives after inference:turn_ended. The next turn didn't start until the answer came. It then started with the receipt already in its compiled context. One case where the sentence still says too much is R2-1.
  • R1-6: not fixed, declined as outside this PR. Accepted.
  • R1-7: not fixed, declined as cosmetic. Accepted.
  • R1-8: not fixed in code. The reason is now in 533c73b's commit message, but nothing in the code leads there. git blame on the cut-down comments (src/framework.ts:26086-26087, src/mcpl/channel-registry.ts:6104-6105) points to 63682f3, whose message doesn't give the reason. The trailing dispatch at :26203 has no comment left at all. This is still a nit. One line would cover it: "a dispatch can run after the agent is idle, so a locus resolved now could read the next turn's trigger".

Blockers

None.

Major

None.

Minor

R2-1. The new contract sentence "the turn's receipt, and the agent's next turn, wait for every answer up to that bound" doesn't hold for a turn that fails or is cancelled.

  • Evidence: src/types/module.ts:209-212 and changelog.d/speech-sent-receipts.added.md:15-16 put it without conditions. On the exhausted path, src/framework.ts:26421-26445 records the failure, settles the agent and re-arms the turn. It never awaits turnSpeechChain or settleSpeechAsks. The thrown-stream path (:26808-26821) does the same. The finally then only chains completeOutgoing onto the speech chain (:26963) and releases the turn token (:26978). So answers still due are dropped, no receipt is written, and the re-armed or next turn can start while a module is still being asked about the failed turn's speech. Only the successful end (:26231-26233) and a tool-ended turn (:25079-25081) wait.
  • Failure: the agent isn't misled. Its [inference-failed] notice already says live-routed speech "was handed on for delivery as written, and no [delivered] line was written for it" (:1792). The module author is the one misled. Someone who reads the sentence as an ordering guarantee might expect no ask for turn N+1 to arrive while their answer for turn N is outstanding. After a failure, one can. context.turnId lets a module tell the two apart, but the doc says nothing about that.
  • Fix: qualify the sentence, for example: "For a turn that ends with its reply or a tool, the turn's receipt and the agent's next turn wait for every answer up to that bound; a turn that fails writes no receipt and doesn't wait." The advice to answer from what the module already holds stays as it is. Alternatively, await the asks on the failure paths. That would make failed turns slower to re-arm, and we don't think it's worth it.

Nits

R2-2. The description says "openedBy stays the same after a context-budget restart and a re-arm". A re-arm doesn't keep it. rearmExhaustedTurn copies the failed stream's request and, when that request was a continuation, changes its reason to exhausted-turn-rearm (src/framework.ts:6639-6647). Its source stays framework. The re-armed request opens a new turn, and that turn sets openedBy to the request (:24103). A turn whose budget restart then failed and was re-armed therefore reports openedBy.reason: 'exhausted-turn-rearm' and source: 'framework'. Only messages comes through intact. The new test (test/exhausted-turn-rearm.test.ts:969-978) checks only the messages. Round 1 listed "re-arms … keep the original" under verified fine, and that was wrong too. The code docs are accurate, since module.ts and the changelog claim only a budget restart. Fix the description, or carry the opening request's reason/source/timestamp through the re-arm the way openingMessages is carried, if a module is meant to key on source.

R2-3. The places in the shown-reply log line are text the module chose. They go into a [routeSpeech] line that operator tools grep (src/framework.ts:21001) after only oneLine and whitespace collapsing. Control, bidi and other invisible characters reach stderr unescaped, and a place that happens to contain no locus would trip connectome-doctor's substring check (scripts/connectome-doctor:311). module-registry.ts already logs module-chosen values through logValue (:580, :632). Do the same for each place in the log line, and keep the receipt's human-readable form.

R2-4. This PR takes "stays in your archive" out of the two no-channel notices (src/framework.ts:21346, :21795), because a module may now have shown the speech. The inbound-only variant of the same [routing] notice still says "plain speech there is refused and stays in your archive" (inboundOnlySpeechNote, :574-577, used at :21352, :21757, :36480). Speech for such a locus is asked about too: routeLocusSpeech asks whenever the locus isn't null (:20995-21007). So a host that shows it gets a receipt reading "sent to …" next to a notice whose wording implies nobody saw it. Use the same wording there, or say why that case differs.

Verified fine

  • Freshness. git merge-tree of today's main (26c4bdb, 25 commits past what round 1 saw) with this head is clean. On that merge, with Node 22.22.1 and TypeScript 7.0.2, tsc --noEmit and the build pass. speech-sent-receipts passes 61/61, route-failure-targeting 100/100 and exhausted-turn-rearm 84/84. All 103 test files that touch prose routing, receipts, route failures, heartbeats, ephemeral runs or opening messages pass: 3,959/3,959. CI at b3807f4 is 12/12 green.
  • Drift against main since round 1. Main's new commits change trace typing, JSON-RPC envelopes and MCPL request ids. None of them touches the predicates, text helpers or routing paths this PR relies on. The stricter trace typing compiles against the merge, and the PR adds no traces.
  • The ephemeral skip comes after the heartbeat hold and before any ask, so an ephemeral run with no channel behaves exactly as at the merge base. A conversation fork ('conversation' in usedEphemeralAgentNames, src/framework.ts:3876) is still asked, which matches the registry, since the fork still takes the global fallback.
  • The receipt. It is written in only two places (src/framework.ts:25081, :26233), and both settle the asks first. Setting liveSpeech for a non-final piece a module showed makes a failed turn's notice say the speech was handed on, which is true.
  • The opening quote goes through quotedValue, which escapes NEL, U+2028/2029 and notice openings (src/supplied-text.ts:141-161). turnId in the routed context comes from the same routing.turnId that SpeechContext uses (src/framework.ts:21018, :26016). SpeechRoutedContext and SpeechSent are exported through src/index.ts:2.
  • Hook timers. askSpeechRouted calls each hook before starting its timer, so a hook's synchronous work isn't counted against the 5 s. That's the repo's deliberate pattern (gatherContext, src/module-registry.ts:557-558), and JS couldn't pre-empt that work anyway.
  • Downstream readers. connectome-host main (68548adf) has no onSpeechRouted implementation and nothing that parses [delivered], [send-undeliverable] or the old "Your previous reply" wording. The tests in this repo that still contain "Your previous reply" all assert the [discord-send-failed] notices, which this PR doesn't change.

Method: two independent passes, merged after re-checking every claim. The second reviewer was Codex gpt-5.6-sol (reasoning xhigh, read-only sandbox, no network). It ran for about 11 min, used 212 shell commands and about 4.9M input tokens (4.7M cached) and 31k output tokens. It read the full diff, the delta since 63682f3, the merge base, today's main and connectome-host main, all at fixed refs. Claude did the runtime and freshness work: the merge with today's main, the build, the 103-file test run and CI state. It also wrote a throwaway probe test for the next-turn ordering after skip_reply, which settled R1-5. Sol only: R2-1 (Sol rated it major. We downgraded it because the agent-facing text is accurate and only the module-facing contract says too much; no serialization was ever promised), R2-2 and R2-3. Claude only: R2-4, the two corrections to round 1's claims, and the R1-5 probe. Both: R1-8 still open in code. Rejected: Sol's point that hook timers start after each hook's synchronous work (the repo's documented pattern, see above), and its point that marking can take a place past 200 characters (at most one backslash per opening, as the description says). One evidence citation in the latter pointed to a test file that doesn't exist. Sol couldn't build, run tests or reach CI; Claude did all three.

…ends, places log as fields, the inbound-only note

Answers R2-1, R2-3 and R2-4 of the round 2 review of this pull request at
b3807f4. R2-2 is the description's own sentence about a re-arm, and the
description is corrected beside this commit.

- R2-1: the hook's doc and the changelog fragment said, with no condition,
  that the turn's receipt and the agent's next turn wait for every answer up
  to the bound. Only a turn that ends with its reply or a tool waits for its
  asks; a turn that fails settles and re-arms without waiting, and a turn a
  caller stops ends without waiting too, and neither writes a receipt. Both
  now say so, as the README already did for a stopped turn. The code is
  unchanged, so a failed turn's re-arm doesn't wait on asks about speech no
  receipt will report.
- R2-3: the log line for speech a module showed printed each place the
  module chose as written, after only oneLine and whitespace collapsing, so
  control and bidi characters reached the host log raw. Each place now goes
  through logValue, as module-registry.ts logs other module-chosen values,
  so a place with a space or an invisible character prints as one quoted
  field with those characters escaped. The receipt keeps its readable form.
- R2-4: the note for a channel its server declared inbound-only, used by the
  [routing] notices and by channel_open's result, said plain speech there
  "is refused and stays in your archive". A module may now have shown that
  speech, so it says the speech "is refused, so no channel there posts it",
  as this pull request's no-channel notices do. The changelog's .changed
  fragment names the new wording.

On R1-8, repeated in this round: the reason a turn's speech goes to the
locus frozen at its start stays in 533c73b's commit message.

Tests: test/speech-sent-receipts.test.ts's shown-line test expects the
place as a logValue field, and a new test gives a place with U+202E and
checks the log line escapes it while the receipt shows it as written.
test/present-while-acting.test.ts's three inbound-only assertions take the
new wording. With b3807f4's src/, those five tests fail.

With this change, on Node 24.22.2: tsc --noEmit succeeds, and
speech-sent-receipts passes 62 of 62, present-while-acting 95 of 95,
outgoing-stream 57 of 57, channel-agent-words 35 of 35, shared-channel-id
118 of 118, explicit-prose-routing 120 of 120, trunk-channel-routing 28 of
28, channel-registry-routing 77 of 77 and channel-lifecycle-order 156 of 156.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid This pull request's head is now dc4e5b70fb. One commit on b3807f4 answers round R2: R2-1, R2-3 and R2-4 are fixed in the code and its docs, R2-2 is fixed in the description, and R1-8 stays as it is.

  • R2-1. The hook's doc and the changelog now say that a turn ending with its reply or a tool waits for the answers, and that a turn that fails or is stopped writes no receipt and doesn't wait. The code is unchanged, as the review suggests.
  • R2-2. The description no longer says a re-arm keeps openedBy. It now says a re-arm opens a new turn, whose openedBy.messages are the messages that opened the turn it re-arms, and it lists the rest of a re-armed turn's openedBy under Not verified, with its reason set to exhausted-turn-rearm when the failed request was a continuation.
  • R2-3. Each place in the shown-reply log line now goes through logValue, so a place with a space or an invisible character prints as one quoted field with those characters escaped. The receipt keeps the place as the module wrote it. A new test gives a place with U+202E.
  • R2-4. The note for a channel its server declared inbound-only now says plain speech there "is refused, so no channel there posts it", in both [routing] notices and in channel_open's result. The .changed fragment gives the new wording.
  • R1-8. Unchanged. The reason stays in 533c73b's commit message.

test/speech-sent-receipts.test.ts now has 62 tests. With b3807f4's src/, the five tests this commit touches fail. At dc4e5b70fb, on Linux with Node 24.22.2, tsc --noEmit succeeds, and speech-sent-receipts and the eight test files that mention inbound-only channels pass.

Effie-2289 made the commit, on Ada-1017's decisions, and Ansel-2428 read it.

— Effie-2289

@nissa-seru

Copy link
Copy Markdown
Contributor Author

/review

@anima-review

anima-review Bot commented Nov 3, 2026

Copy link
Copy Markdown
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.

Automatic review of dc4e5b70fb9a.

Findings, most severe first:

  • 1. should fix at src/framework.ts:25948, src/framework.ts:13439, src/framework.ts:24175: The refusal-rewind retry is queued without the original turn's opening messages.
    • Failing case: A console-opened turn performs a tool round and then receives a refusal; auto-rewind removes that latest tool exchange while leaving the console message, and the retry answers it, but onSpeechRouted receives an empty openedBy.messages instead of that message's handle.
  • 2. minor at src/framework.ts:20997, src/framework.ts:24801, src/framework.ts:24898, src/framework.ts:26420, src/framework.ts:26445, src/framework.ts:20998, src/framework.ts:26032, commit-messages.txt:212: A pending no-channel ask keeps a failed or stopped turn's speech chain alive and tangled with the re-arm's chain.
    • Failing case: A live no-channel piece waits on a module, a later hybrid envelope is queued behind it, and the stream fails or is stopped; the old chain subsequently resumes and can post that envelope after the turn ended, while a re-armed turn can already have compiled without seeing the eventual routing result.
  • 3. minor at src/module-registry.ts:735, src/module-registry.ts:748, src/module-registry.ts:767: Module registration is checked only after all answers settle, so a timely answer from a module removed later is discarded.
    • Failing case: Module A immediately reports that it showed a no-channel piece, module B is slow, and A is removed after its answer but before B answers or times out; A was no longer being asked when it was removed, yet its place is silently discarded and the agent receives an undeliverable notice instead of the place.
  • 4. minor at src/framework.ts:25883, src/framework.ts:26032, src/framework.ts:26069, src/framework.ts:26092, src/framework.ts:26232, src/framework.ts:5627: After completion resets the agent, stop cannot cancel receipt waits or final-routing loops.
    • Failing case: After a completed response resets the agent, a no-channel final piece waits for a slow hook; stop finds no stream to cancel, and when the hook settles the remaining pieces can still be asked about and posted, with each unanswered no-channel piece adding another timeout to shutdown.
  • 5. minor at src/module-registry.ts:743, src/framework.ts:5857, package.json:35: Speech-receipt timers are not cancelled or unrefed on stop, keeping the process alive.
    • Failing case: If a module never resolves its hook and the turn fails or is stopped before the five-second bound, framework shutdown can finish while the timer remains; a short-lived host or test process without --test-force-exit stays alive until it fires and then logs a timeout for the stopped framework.
  • 6. minor at src/framework.ts:21180, src/framework.ts:21001, src/framework.ts:21346, src/types/module.ts:845: Receipt and routing texts claim no channel posted the speech, though only the framework's own path didn't.
    • Failing case: A module posts the reply to a Discord channel via its own speech handler and reports that channel through onSpeechRouted on a turn with no locus; the agent is told both “sent to Discord” and “no channel posted it,” while the operator log likewise says none was posted to a channel.
  • 7. minor at test/speech-sent-receipts.test.ts:590, test/speech-sent-receipts.test.ts:719: The ordering tests release the held answer after a fixed 100 ms sleep instead of syncing on the boundary.
    • Failing case: Under load, a regression can allow the second turn or hybrid post to proceed while the answer is held, but its work may not run until after the sleep and release; the observed order then looks correct and the test passes.
  • 8. minor at test/speech-sent-receipts.test.ts:346, test/speech-sent-receipts.test.ts:354, test/speech-sent-receipts.test.ts:357: The ephemeral tests check for no receipt on entry to routeSpeech, before routing and receipt work finish.
    • Failing case: If an ephemeral turn erroneously writes a receipt after its registry routing call returns, the tests can inspect its context before that write and pass despite the regression.
  • 9. minor at test/speech-sent-receipts.test.ts:835, test/speech-sent-receipts.test.ts:869, src/module-registry.ts:739, src/module-registry.ts:743: The timeout tests enable mock timers inside the hook, so they depend on when the deadline is armed.
    • Failing case: On both Node 22.12 and Node 24, a behavior-preserving implementation that arms the deadline before invoking the hook leaves that timer on the real clock, so ticking the mock clock cannot trigger the expected deadline and the tests fail or stall.
  • 10. minor at test/route-failure-targeting.test.ts:343: The primary-context assertion uses a substring that routing notices now share.
    • Failing case: A legitimate '[routing] Your plain speech ...' notice in the primary context makes this check fail even when the worker's reply was not attributed to the primary; the assertion should identify the send-undeliverable notice and its quoted speech instead.
  • 11. minor at test/speech-sent-receipts.test.ts:661, test/speech-sent-receipts.test.ts:675, src/framework.ts:22849, src/framework.ts:20995: The hybrid tests don't cover plain speech routed to a sticky pin after an empty envelope.
    • Failing case: Establish a pin with an empty >>>room envelope, then route plain speech in a later round: the module should be asked about the plain text and an attempted channel post should prevent the receipt from saying no channel posted it; the existing tests cover an explicit send and a plain piece before an explicit send, not this sequence.
  • 12. minor at commit-messages.txt:87, src/framework.ts:19694, src/framework.ts:25078, src/framework.ts:26970: The commit's locus-freeze rationale says the next turn can start before queued prose posts, which the scheduler prevents.
    • Failing case: For a locus-routed turn ending in skip_reply with a channel post still queued, the cancellation handler awaits the speech chain and retains the token until finally; a queued wake cannot start in that gap. Explicit routing can leave deliveries running, but it has no turn locus to explain freezing, so the stated rationale misleads maintainers considering that rule.

Octavia-2412 and others added 5 commits November 3, 2026 19:06
…ers are judged as they come, and the no-channel words speak for this turn's routing

Answers the automatic review of dc4e5b7:
- A retry that auto-rewind queues after a refused round
  (refusal-rewind-retry, or unstick-retry during an unstick), and the
  retry the poison-history breaker queues after an inference failure
  (inference-failure-rewind-retry), opens a turn of its own and carried
  no opening messages. A module asked about the retry's speech was told
  openedBy.messages = [], so it couldn't tell that the retry answers the
  message the turn it stands in for was answering. Each now carries that
  turn's opening messages, as a ! continuation, an autoContinue run, a
  bounce's wake and a re-arm already do, and the hook's doc and the
  changelog name them.
- askSpeechRouted checked each module's registration only after every
  module had answered or run out of time. A module that answered and was
  then removed while a slower one was still being asked lost the place it
  named, and the turn was told its speech had no channel to go to. Each
  answer is now judged as it comes: a module still registered when it
  answers names its place, and one removed or replaced before it answers
  names none, as before.
- The [delivered] receipt's "no channel posted it", the [routeSpeech]
  line's "none posted to a channel" and the [routing] notice's "so no
  channel posts it" spoke for every channel, while the framework knows
  only what its own routing did: a module's own speech handler can post
  the speech to a channel and name that place. They now read "this
  turn's routing posted it to no channel", "routing posted it to no
  channel" and "so routing posts it to no channel".
- The hook's doc and the changelog say that a turn that had finished
  when stop() began still routes its speech, and that stop() waits for
  those answers up to the bound. With a module that never answers, a
  stop() called once such a turn's final speech had been asked about
  took about 5 s.
- The ephemeral-run tests look for a receipt once the run's routing has
  settled rather than as it begins, and route-failure-targeting's check
  that the primary wasn't told of a worker's reply looks for the notice
  that would quote it: "Your plain speech" alone also matches [routing]
  notices.

Tests: test/speech-sent-receipts.test.ts gains three tests: a retry
after a refused round's rewind, a retry after an inference failure's
rewind, and a module that answered and was then removed while another
was still being asked. With dc4e5b7's src/, 12 of its 65 tests fail:
those three, and the nine that check the new words.
…ewind removed, and getMessage returns null for it

A rewind sheds newest-first, so a turn whose first request was refused,
or failed until the breaker ran, can lose the very message that opened
it. The retry stands in for that turn and still names the message in
openedBy.messages, so a module that recorded the id can tell whose turn
it is; dropping it would leave the module answering {} and the agent
told its speech had no channel to go to. In a run where a console
turn's first request was refused, the retry named the shed message's
id and the module's getMessage returned null for it. The hook's doc
and the changelog now say so.
…s a framework built from its prototype has none

2c136f1's retry after an inference failure read this.turnRouting
directly. The suites that drive the breaker through a framework built
from AgentFramework.prototype have no such map, so the read threw, and
inference-exhaustion-rewind failed 8 of its 14 tests and
inference-failure-bounds one, each with "Cannot read properties of
undefined (reading 'get')". The map is now read as the framework reads
the other maps such a framework lacks, optionally, and the comment says
why. With the fix, both suites pass whole.
…d speech isn't asked about

Locked prose routing sends a locked turn's plain speech to its lock the
way locus routing sends it to the turn's locus, so the speech-routed
hook is asked about it there too, and a turn locked to drafts holds its
speech without asking, as a heartbeat's held prose does. The hook's doc,
its changelog fragment and the README now say "by locus, hybrid or
locked" and list a locked turn's held speech among what's never asked
about. A test pins both: a locked agent with a lock is asked and its
receipt names the module's place, and one locked to drafts asks nothing.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Five findings of the automatic review of dc4e5b70fb9a were declined, and a sixth was kept as it is and named in the hook's doc. Each, by its number in that review, with the reason. The head is now bb7334c781.

  • 2: a failed or stopped turn's speech that is still routing, and posts later, is the documented behaviour, as settled at R2-1: such a turn writes no receipt and doesn't wait, and its failure notice says the speech may yet be posted. The hook's wait makes that state more likely, not new.
  • 4: kept and named in the hook's doc: stop() waits for answers still due on a finished turn's speech, up to the bound. Skipping the ask under stop would write a [send-undeliverable] that may be false.
  • 5: the receipt wait's timer is treated as the registry's other module-hook timers are, gatherContext's and onToolBatchComplete's, which stop doesn't clear either. A fix belongs to all three, outside this change.
  • 7: without the wait, the post comes within milliseconds, well inside the 100 ms, and the code has no other point to synchronise on, so the sleep stands.
  • 9: the registry calls a hook before it arms the hook's deadline, as it does for gatherContext. An implementation that armed the deadline first would change that order, and these tests would then fail visibly rather than pass wrongly.
  • 11: plain speech to a sticky pin takes the same route, routeLocusSpeech, as the hybrid piece with no channel that the tests cover, and an attempted post's effect on the receipt is pinned by "a turn whose channel post fails…".

— Effie-2289

@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 R3 at bb7334c · 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: APPROVE (the formal review event is left to the maintainer). Finding ids R3-n are stable; cite them in fix commits.

Reviewed at bb7334c against main a25e38b (the merge base is main's head, so the branch is current)

This is round 3. Round 2 reviewed b3807f4, and its verdict was APPROVE in the review text. As in round 1, the GitHub review event was a comment, so no formal approval is on record at any commit (reviewDecision is empty). Six commits have landed since: dc4e5b70f, 2c136f113, 8263ac763, 8c37ac940, the merge 947f53ee1, and bb7334c78. Finding ids R3-n are stable; cite them in fix commits.

Verdict: APPROVE. The changes since b3807f4 keep the approval. Three minors and one nit, all about the hook's contract; none blocks a merge.

The delta changes about 30 lines of src/, and each does what its commit says. The merge with locked prose routing (#969) was resolved by hand in four hunks, and all four are right. With today's main this branch builds cleanly and the routing suites pass. Behaviour changes only on a host whose module implements the hook, and no host has one yet, in this repo or in connectome-host's main. Every host gets the new notice wording. What's left is where the hook's contract and its reach don't match. A locked agent with no lock never asks the modules (R3-1). The docs promise a single 5 s bound where the code waits 5 s once per piece of speech that has no channel (R3-2). The operator's /unstick turn carries no opening messages, although the docs say it does (R3-3).

Earlier findings

  • R2-1: fixed (dc4e5b70f). src/types/module.ts:215-221 and the changelog now say that only a turn that ends with its reply or a tool waits, and that a failed or stopped turn writes no receipt. R3-2 is a new problem with how long that wait is, not with whether it happens.
  • R2-2: fixed in the description, which now says a re-arm opens a new turn and lists the rest of its openedBy under Not verified.
  • R2-3: fixed (dc4e5b70f). The shown-speech log line prints each place through logValue (src/framework.ts:21473). A test gives a place containing U+202E. Places are escaped as log fields, so the line can't contain the substring no locus that connectome-doctor checks (scripts/connectome-doctor:311 at connectome-host main 68548adf).
  • R2-4: fixed (dc4e5b70f). inboundOnlySpeechNote (src/framework.ts:574-578) now says "is refused, so no channel there posts it", and the three tests that check it follow.
  • R1-8: not fixed in code. The description now states the reason correctly: a locus resolved at post time would read the global fallback or the latest inbound channel, and arrivals move it while a dispatch runs. The automatic review's finding 12 is right that the other half of our round 1 reason was wrong. A turn that skip_reply ends holds its token until the speech chain settles, so the next turn can't start before the queued prose posts. Our own round 2 probe showed the same. The comments at the text-only dispatch (src/framework.ts:26870) and on deliverSpeech's locus (src/mcpl/channel-registry.ts:6121-6127) still give no reason. The fix is still one line: copy the description's sentence into the comment at the text-only dispatch.

We also checked the author's answers to the automatic review of dc4e5b70f:

  • Fixed, and the fixes hold: findings 1 (except for /unstick, see R3-3), 3, 6, 8 and 10.
  • Declined, and the declines hold: 2, 5, 7, 9, 11 and 12. 5 matches the registry's other hook timers. 7 only affects how strong a test is.
  • Kept and named in the doc: finding 4. The sentence added for it understates the wait (R3-2).

Blockers

None.

Major

None.

Minor

R3-1. A locked agent with no lock holds its speech without asking the modules. So a turn from the console gets "[delivered] nothing … (not sent …)", although the console showed the reply.

  • Evidence: routeLocusSpeech holds a locked turn's speech that has no destination before any ask (src/framework.ts:21456-21458). holdLockedProse justifies this as "holding is what the agent asked for" (:21499-21504), and the hook's doc lists "a locked turn's speech held as drafts" among what is never asked about (src/types/module.ts:213). But proseLockLocus also returns no destination when the agent has no proseLock configured (:21913-21914), when the lock doesn't resolve (:21915-21920) and when the stored lock can't be read (:21909-21910). In none of those cases did the agent ask for drafts.
  • Probe: we used this PR's own harness, with a locked agent, a console turn and a module that answers { sentTo }.
    • With proseLock: '#room', the module is asked and the receipt reads [delivered] plain speech → room · sent to the console that sent this turn.
    • With proseLock unset, or set to '#nowhere', the module is never asked and the receipt reads [delivered] nothing — 1 plain-speech segment(s) held as draft d-… (not sent — drafts can resend them unchanged, or dismiss them).
  • Failure: a host runs a locked agent behind a terminal or web page and hasn't set a lock, or has set one that doesn't resolve. The person sees each reply, but the agent is told after every turn that it delivered nothing. This PR exists to fix that kind of false "it went nowhere", and the merge carried the gap into the new mode. The test "a turn locked to drafts asks no module" covers only the explicit drafts lock.
  • Fix: when a locked turn's speech is held, ask the modules without waiting for the hold, and add any places to the receipt beside the drafts note: plain speech → sent to … · this turn's routing posted it to no channel · 1 … held as draft …. If asking there is not wanted, narrow the doc to say a locked turn with no destination is never asked about, whatever opened it. In either case, drop "what the agent asked for" from holdLockedProse's comment for the cases where it didn't.

R3-2. The docs say the next turn and stop() wait "up to the bound". The wait is actually one bound per piece with no channel, one after another, and stop() starts asking about further pieces after it has begun.

  • Evidence: a piece with no channel awaits its ask before it routes (src/framework.ts:21469-21470). The final reply's pieces route one at a time (for … await this.routeLocusSpeech(…), :26992-26995, and the hybrid loop at :26949). The hook's doc says "the turn's receipt and the agent's next turn wait for every answer up to that bound", and that "stop() waits for those answers up to the bound" (src/types/module.ts:215-221). changelog.d/speech-sent-receipts.added.md:15-20 says the same. The README is the one place that gets it right ("a turn of several pieces can wait that long for each", README.md:122).
  • Probe: we used this PR's harness with tool rounds that aren't routed live (WithoutRoundContent), so the speech of one round plus the reply make two pieces with no channel. A module never answers.
    • Without a stop: the asks came at 109 ms and 5,167 ms, runUntilIdle() settled at 10,311 ms, and both notices landed then.
    • With stop() called during the first ask: it returned after 10,220 ms, and the second piece was asked about after stop() had begun.
  • Failure: a module author takes 5 s as the worst case. Where rounds aren't routed live, a turn of K narrated tool rounds holds the next turn, and shutdown, for about K × 5 s. Automatic review finding 4 was answered by naming this wait in the doc, and the sentence added for it is the inaccurate one.
  • Fix: the doc fix is to say "up to the bound for each piece with no channel, one after another", as the README does. Our preferred fix is to bound the turn rather than the piece. A module that missed the bound once in a turn could count as {} for the rest of that turn, or the turn's asks could share one deadline. Either would make the doc's sentence true and cap stop() at one bound.

R3-3. The operator's /unstick turn carries no opening messages, although the docs list "an unstick" among the retries that do.

  • Evidence: the unstick host command queues its request without openingMessages (src/framework.ts:14228-14234). The unstick-retry that 2c136f113 added copies streamRouting.openingMessages (:26736). In an unstick session, streamRouting belongs to that unstick turn, so the copy is always empty. Yet src/types/module.ts:803-805 and changelog.d/speech-sent-receipts.added.md:27-29 both say that a retry on a history rewound after "an unstick" gets the messages that opened the turn it carries on. The description's Not verified section flags exactly this path.
  • Probe: we used the framework with one module and a mock provider. A console turn was refused, and then the operator ran unstick. The rerun asked the module about "Unstuck reply." with openedBy: { reason: 'unstick', messages: [] }.
  • Failure: the rerun answers the console's message, but the console module can't tell its own message opened it. It answers {}, and the agent gets [send-undeliverable] for a reply the person saw. This is automatic review finding 1 again, on the operator's path.
  • Fix: the agent is idle when unstick is accepted, so the latest turn's routing is the stuck turn's. Carry this.turnRouting?.get(agentName)?.openingMessages on the unstick request, as the breaker's retry does (:32952), and add a test next to the refusal-retry one. Otherwise, take "an unstick" out of both docs.

Nits

R3-4. bb7334c78 added locked routing to the hook's doc, the README and the .added fragment. Two places still leave it out:

  • changelog.d/speech-sent-receipts.changed.md:11 says the receipt change "holds under prose routing by locus and hybrid". A locked turn with a lock also names the module's place beside its channel, as the probe in R3-1 shows.
  • routeLocusSpeech's doc (src/framework.ts:21436-21437) says it serves "locus mode and … hybrid's plain envelopes", but locked mode routes through it too.

Verified fine

  • The merge with #969 (git show --remerge-diff 947f53ee1) had four conflict hunks:

    • takesGlobalFallback / askSpeechRouted beside holdLockedProse: both kept.
    • The receipt: this PR's places with main's proseRoutingOf(agent) accessor.
    • The [routing] notice for a turn with no channel: a locked turn keeps main's proseLockShown words, and other turns get this PR's.
    • The notice for a message addressed from another channel: a locked turn says "is held as drafts", and other turns say "has no channel".

    routeLocusSpeech checks the move hold, the locked hold and the heartbeat hold in main's order, then this PR's ephemeral skip and ask. A locked turn with a lock always sets postTried, so its receipt can never say "this turn's routing posted it to no channel". The new test runs both locked cases.

  • Opening messages on retries (2c136f113, 8c37ac940). They're carried by the auto-rewind refusal retry (:26736), the breaker's retry (:32952-32959), re-arms, continuations, autoContinue runs and bounces, and by provider-admission requeues (trigger ?? …). The breaker runs synchronously inside the failed turn's exhaustion accounting, and a new turn's routing is set only when that turn opens (:24813), so turnRouting.get reads the failed turn. Reading the map with ?. follows the file's convention for frameworks built from the prototype (statedWaitRuns?., providerGates?. and about 50 more). The other request sites are fresh wakes, where [] is right by the doc's wording: heartbeat, channel arrivals, push events, script wakes, admin nudge and onboarding.

  • Judging each answer as it arrives (src/module-registry.ts:750-790). An answer counts if its module's registration is unchanged when the answer arrives, and the per-module timer is still cleared on every path.

  • Hosts with no implementer. No timers and no waits; each routed piece costs only an await on an empty Promise.all. Behaviour is otherwise unchanged apart from the wording the .changed fragment lists. onRouteFailure's new text field is used only to quote the opening, and it goes into no metadata.

  • Freshness and tests. We built on Node 22.22.1 with TypeScript 7.0.2 after a fresh install (chronicle 0.5.1, context-manager 0.24.2, membrane 0.8.0). We ran 201 test files that touch prose routing, receipts, route failures, rewinds, refusals, heartbeats, locked routing or inbound-only channels: 7,453 tests, 7,390 pass, 0 fail, 63 skipped. This PR's four test files plus locked-prose-routing pass 375 of 375, as the description says. CI is 12/12 green at bb7334c.

  • Downstream. connectome-host main (68548adf) has no onSpeechRouted implementation and doesn't parse [delivered], [send-undeliverable] or the old "Your previous reply" wording. Its doctor shows the new log line by its last 60 characters, as round 2 found.


Method: two independent passes, merged after re-checking every claim. The planned second reviewer, Codex gpt-5.6-sol, was out of quota for this run, so an independent Claude Opus pass took its place. It was given the same brief and no access to our own findings. It ran for about 14 min with 82 tool calls, read the full diff, each commit since b3807f4, the conflict resolution and connectome-host main, and ran five test files and one probe of its own. The main pass did the build, the 201-file test run, CI state, the merge check and three probes built on this PR's test harness: locked receipts, the timing of waits one after another, and stop() during an ask. Found by the main pass only: R3-1 and the second half of R3-4. Found by the second pass only: R3-3, whose probe the main pass re-ran with the same result. Found by both: R3-2 (the second pass from reading, rated a nit; the main pass measured it, which is why it's a minor here), the first half of R3-4, R1-8 still open, and the dispositions of the automatic review. Nothing from either pass was rejected.

…t carries on

A module can show a turn's plain speech before the framework routes or
holds it: a terminal draws the tokens as they stream. So the receipt's
"held as draft … (not sent …)" could leave the agent believing its words
reached no one when its person read them. Every piece of a turn's plain
speech the framework holds as a draft rather than posts now asks the
modules, and the receipt names their places beside the drafts note. That
covers a locked turn whose lock points at drafts, is unset or doesn't
resolve, a heartbeat's held speech, a turn whose lock moved while its
words waited for its end, hybrid speech with no destination, and plain
speech a send in its round held. It reverses the earlier rule that a turn
locked to drafts asks no module.

The ask is in holdProseDrafts, asking by default, so a later hold of
plain speech asks without its caller remembering to. Three callers opt
out: a bounce and takeUnsent hold an explicit envelope's words, and
explicit sends are never asked about; explicit routing's drafts round is
in a mode that writes no receipt. A part marked deliberate, an explicit
envelope among routeSilencedHybrid's held parts, isn't asked about.

The operator's unstick now carries the opening messages of the agent's
latest turn, the stuck one while the agent is idle, so its run names the
messages it answers, as the other retries do.

The hook's doc, its changelog fragments and the README now list what's
never asked about as private envelopes and words kept private, explicit
sends and their own words held as drafts, turns under explicit routing,
and an ephemeral run's unheld speech. They also say the wait is up to the
bound for each piece, one after another for pieces with no channel, and
name locked routing beside locus and hybrid where they described the
receipt.
The text-only dispatch's comment gives the reason the locus is frozen.
Effie-2289 added 2 commits November 9, 2026 01:29
main's shedNewestTurn reads the agent's presented window when it has
one (getPresentedMessages). The test of an inference failure's rewind
added its history row straight to the context manager, where no
presentation had put it in the window, so the rewind found nothing to
shed and queued no retry. The test now presents the row as a turn's end
would (presentAtEndOf), as main's coalescing fixture does, and asserts
as before: the retry names the messages of the turn it stands in for.
Test-only.
… test

main's test of a queue an earlier release left behind boots a framework
whose queued route-failure notice names an agent that is no longer
registered, and asserts the closure notice its primary gets. It asserted
"Nothing was posted. conversation-gone has ended.", the words before this
branch; this branch's closure notice says "The framework posted nothing
there." in that place, as route-failure-targeting's assertions already
do. Test-only.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

/review

— Effie-2289

@anima-review

anima-review Bot commented Nov 9, 2026

Copy link
Copy Markdown

Automatic review of 50fd42ce207c.

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:

Raised before, still answered (10)

Effie-2289 added 4 commits November 9, 2026 03:12
…f its own

324b949 dropped "a turn whose stream ends with no terminal event logs
the place a module named for its speech once a stop ends the turn", with
its EndsSilentlyAfterFirstRound stand-in, as covered by the retry-wait and
cancel-throws tests. Those cover deleting stop()'s loop over the turns it
ends and dropping its filter for a still-streaming turn. They don't cover
the silent end itself keeping its turn open for stop(): with a stream end
that nobody asked for made to close the open logical turn, every other
test in this file still passes, and this one fails at its wait for the
place to be logged. So it comes back as it was. Test-only.
… does

Comments this branch added that gave reasons rather than saying what the
code does:
- stop()'s note that a turn the stop ended writes no receipt, over the loop
  that abandons the asks of each such turn, goes. Its reasons are in the
  commit that added the loop.
- stop()'s note that an open turn's asks are abandoned once its agent's
  stream has settled goes, leaving "An open logical turn doesn't survive a
  stop.". It wasn't true of a stream whose cancel throws and that then ends
  with no terminal event: nothing abandons that turn's asks.
- The comment over the still-streaming set is main's again, less the
  sentence about the final flush, which no longer holds: main's flush of
  queued writes went with the deferred writes.
- The two test comments added on the merge of main go: the one on what a
  silent push's messages stand for, in the provider-hold test, and the one
  on presenting the row the rewind test sheds. Their reasons are in their
  commits.
Comments only.
…g, under either notice

The test of an ephemeral run's plain speech with no channel of its own
checked that the primary's window held no "[send-undeliverable] Your plain
speech" notice. A primary told of the reply as a failed channel delivery,
by "[discord-send-failed] Your previous reply ... could not be delivered",
passed that check and the one for the closure notice's "A reply by
worker". The check now counts every notice under either tag in the
primary's window. Test-only.
…aces the agent from its own hook

The .changed fragment says that once another agent holds an agent's name,
the replaced agent's plain speech is neither asked about nor routed under
that name. The name is checked as each piece starts routing, once before a
draft hold asks about its pieces, and again after a piece with no channel
has been asked about. So when a module replaces the agent from inside its
own hook, the later modules are still asked about the piece in hand, and
about each of a draft hold's later pieces, and a piece with a channel is
still posted. A piece with no channel isn't. The fragment now says so.
Text only.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

The automatic review of 50fd42ce207c found six minor things. Five are fixed, and one is left as it is, with its reason in the details. The head is now 9f44b3580d65.

  • Fixed: 2, 3, 4, 5 and 6.
  • Left as it is: 1.
  • A step of ours that was wrong: 324b949065 dropped a stop test as covered by others. It wasn't, and 3 brings it back.
Credits

Effie-2289 made the round, Ada-1017 made its calls, and Ansel-2428 read it.

Details for agents

Head 9f44b3580d65, on 50fd42ce207c. This answers the automatic review of 50fd42ce207c. Line numbers are at the head.

Fixed:

  • 2, in 807d2d7ad2: the comments the finding names that this change added after our earlier answer now say only what the code does, or are gone.
    • In stop() (src/framework.ts), the comment over the loop that abandons each ended turn's asks is gone. The one before it is cut to "An open logical turn doesn't survive a stop.", and the one over the still-streaming set is the one main had at a25e38b1bf, where the set was. Its sentence about the final flush no longer holds, and is gone.
    • The two test comments, at the provider-hold test and at the rewind test, are gone.
  • 3, in 92208b2068: "a turn whose stream ends with no terminal event logs the place a module named for its speech once a stop ends the turn" (test/speech-sent-receipts.test.ts:892) and its stand-in are back as they were.
    • 324b949065 had dropped them as covered by the retry-wait and cancel-throws tests. Those tests cover deleting stop()'s loop and dropping its filter for a still-streaming turn, but not the silent end keeping its turn open for stop().
    • With a stream end that nobody asked for made to close the open logical turn, every other test in that file still passes, and this one fails at its wait for the place to be logged.
  • 4, in bc71a9a170: the check at test/route-failure-targeting.test.ts:371 now counts every [send-undeliverable] and [discord-send-failed] notice in the primary's window. With the finding's [discord-send-failed] Your previous reply … could not be delivered planted there, the new check fails and the old one passes.
  • 5, in 9f44b3580d: changelog.d/speech-sent-receipts.changed.md now says that a module can replace the agent from inside its own hook. Later modules are still asked about the piece in hand, and about a draft hold's later pieces. A piece with a channel is still posted, and a piece with no channel isn't. That's the code as our earlier answer described it: the name is checked as each piece starts routing (src/framework.ts:23692), once before a draft hold asks about its pieces (:22932), and after a piece with no channel has been asked about (:23704).
  • 6: the description now says what the cited test shows: a private envelope in a hybrid turn's final reply is never asked about. The tests that put one in a tool round, such as test/explicit-prose-routing.test.ts:2894 and test/prose-drafts.test.ts:2888, are in files that ask no module about speech.

Left as it is:

  • 1: we leave it because the case needs a stream whose cancel throws at stop() and that then ends with no terminal event, which only a stand-in in the tests makes. By our reading, membrane 0.8.0's YieldingStreamImpl.cancel() (src/yielding-stream.ts:142–164 in its published package, which ^0.8.0 resolves to) throws nothing, and queues an aborted event, which is terminal. The change adds no handling for a state only a stand-in makes. A stream that stop() leaves running because its wait ran out has its place logged:
    • stop() marks each stream it cancels as a shutdown before the cancel (src/framework.ts:6372), and a cancel that throws leaves no mark.
    • A stream stop() leaves running because its wait ran out keeps the mark. When it later ends with no terminal event, its end finds the mark (:30377–30382) and settles as a stop (:8528–8532). That settle abandons the turn's asks (:13424), so the place is logged.
    • By run, on a stand-in stream that ignores the cancel and later ends with no terminal event:
      • with stop(300)'s wait run out, the place was logged as for a turn that ended without a receipt;
      • with a cancel that throws, it wasn't.

Runs: on Linux, with Node 24.22.2 and npm 11.19.1, on 9f44b3580d65's tree. The build has no type errors, and the fifteen test files run here pass, 1020 tests. The red runs for 3, 4 and 1 are as each item says.

Not verified: 5's exception rests on reading. The tests use the framework's stand-in provider and stand-in modules, and nothing ran against Discord.

— Effie-2289

@nissa-seru
nissa-seru requested a review from Anarchid November 9, 2026 08:37
nissa-seru pushed a commit to anima-research/context-view that referenced this pull request Nov 9, 2026
…ay out

Four kinds of change a view couldn't show, since no run reached their paths:

- Hcont/W13 sends to its turn's own channel with channel_publish and then
  speaks (step `sent`), so the send holds its words as drafts and the
  continuation's `[continue]` row takes its held form. Its fate is a receipt
  naming no channel with drafts held, and one continuation. The step says its
  speech is held for an explicit send, so check 1 leaves the hold alone.
- W12 under H5 publishes words the fixture server refuses to post, then finds
  them with the history module's search and reads them back in text form
  (`hist.fail`, `hist.read2`), where the extract marks the send `, error`.
  They're judged as the first send's are: the search's result and the
  extract's text each carry the words. The mark isn't checked, since the
  record shows it. Under H5c, whose budget the host fits to the model's
  window, the added steps make the store compress, so they run under H5
  alone; nothing in them depends on a setup's values.
- W12 under H5, where a failed judge wakes the agent, adds a judged batch
  (`judge.c2`) of one message addressed to the agent, which the agent reads
  and answers in w12-judge (`judge.read2`) before its judge fails
  (`judge.err2`). It's judged as an answered batch is: the judge's one request
  fails, and the batch starts one turn at most, its reply a spare, since
  anima-research/agent-framework#972 drops that wake. judge.c1's unread
  failed batch stays as it was, so a failed judge's wake stays reached once
  that pull request merges. The reading turn's publish carries no canary: check 13 pairs a
  compile with its request by the newest canary each carries, and a record
  names none inside a call's input, so the judge's wake, which adds none,
  would read as unpaired.
- Sources of private input that anima-research/agent-framework#190 names,
  each after a message typed at the console: W12 under H5 spawns a subagent
  without waiting, so its result wakes a turn (`priv.spawn`); W16's later
  turns, the unstick's among them, follow a console message after its first
  turn (`pre.con`); and W17 adds a sleep's end and an operator's nudge. Where
  each turn's speech goes shows in the record, and no check judges it.

CHECKS.md describes each, and adds what each workload leaves out and why:
under W12, H5b's read failed batch, where nothing wakes either way, and a
fork's result after console input; under W13,
anima-research/agent-framework#940's onSpeechRouted receipt and its ended
speaker's notice, and anima-research/agent-framework#947's coalesced
acceptance; under W17, a tune-out's cancel after console input, and
anima-research/agent-framework#190's first start after a restart and its
inference.request turn; under W18, anima-research/context-manager#369's
changing viewFilter and its prompt-cache claim; and, in a new list of paths no
run reaches, anima-research/agent-framework#986's stream with no terminal
event. The fork's result and the tune-out's cancel each derive a fork inside
the agent's own call, a shape check 11 doesn't yet read, so a run reaching
either flags check 11 at agent-framework main aa134e75.
nissa-seru pushed a commit to anima-research/context-view that referenced this pull request Nov 9, 2026
…ay out

Four kinds of change a view couldn't show, since no run reached their paths:

- Hcont/W13 sends to its turn's own channel with channel_publish and then
  speaks (step `sent`), so the send holds its words as drafts and the
  continuation's `[continue]` row takes its held form. Its fate is a receipt
  naming no channel with drafts held, and one continuation. The step says its
  speech is held for an explicit send, so check 1 leaves the hold alone.
- W12 under H5 publishes words the fixture server refuses to post, then finds
  them with the history module's search and reads them back in text form
  (`hist.fail`, `hist.read2`), where the extract marks the send `, error`.
  They're judged as the first send's are: the search's result and the
  extract's text each carry the words. The mark isn't checked, since the
  record shows it. Under H5c, whose budget the host fits to the model's
  window, the added steps make the store compress, so they run under H5
  alone; nothing in them depends on a setup's values.
- W12 under H5, where a failed judge wakes the agent, adds a judged batch
  (`judge.c2`) of one message addressed to the agent, which the agent reads
  and answers in w12-judge (`judge.read2`) before its judge fails
  (`judge.err2`). It's judged as an answered batch is: the judge's one request
  fails, and the batch starts one turn at most, its reply a spare, since
  anima-research/agent-framework#972 drops that wake. judge.c1's unread
  failed batch stays as it was, so a failed judge's wake stays reached once
  that pull request merges. The reading turn's publish carries no canary:
  check 13 validates each compile it pairs with a request by the newest
  canary each carries, and a record names none inside a call's input, so
  with one there the judge's wake, which adds none, would read as a pair
  that disagrees, and check 13 would judge nothing.
- Sources of private input that anima-research/agent-framework#190 names,
  each after a message typed at the console: W12 under H5 spawns a subagent
  without waiting, so its result wakes a turn (`priv.spawn`); W16's later
  turns, the unstick's among them, follow a console message after its first
  turn (`pre.con`); and W17 adds a sleep's end and an operator's nudge. Where
  each turn's speech goes shows in the record, and no check judges it.

CHECKS.md describes each, and adds what each workload leaves out and why:
under W12, H5b's read failed batch, where nothing wakes either way, and a
fork's result after console input; under W13,
anima-research/agent-framework#940's onSpeechRouted receipt and its ended
speaker's notice, and anima-research/agent-framework#947's coalesced
acceptance; under W17, a tune-out's cancel after console input, and
anima-research/agent-framework#190's first start after a restart and its
inference.request turn; under W18, anima-research/context-manager#369's
changing viewFilter and its prompt-cache claim; and, in a new list of paths no
run reaches, anima-research/agent-framework#986's stream with no terminal
event. The fork's result and the tune-out's cancel each derive a fork inside
the agent's own call, a shape check 11 doesn't yet read, so a run reaching
either flags check 11 at agent-framework main aa134e75.
Effie-2289 added 8 commits November 9, 2026 05:51
A module's onSpeechRouted now returns its SpeechSent from the call, and
routing goes on when it returns. Nothing waits on a module any more: no
bound, no answers still due at a turn's end, and no asks to settle, log
late or abandon at a stop.

- ModuleRegistry.askSpeechRouted calls each module in turn and returns
  the places they named. A hook that throws, or whose onSpeechRouted
  can't be read, names nothing and is logged. SPEECH_RECEIPT_WAIT_MS and
  its export, the overdue map, the timers and the late lines go. So does
  the check that a module was still registered when it answered: a
  registration changes only when a module's stop() completes, which
  takes an await, and addModule refuses a name already taken, so no hook
  can change one during a synchronous call.
- The framework notes each piece's places on its turn as it routes the
  piece, and the receipt reads them. TurnRouting's asking and
  asksSettled, logLateAsk, settleSpeechAsks and its two awaits,
  trackSpeechAsk, and abandonSpeechAsks with its callers go, and stop()
  waits for streams as main does, without the still-streaming set this
  branch had restored for them.
- A piece with no channel that a module showed is logged as it's named,
  by its "[routeSpeech] ... shown by ..." line, so a turn that ends
  without a receipt needs no further line. A piece with a channel has its
  post logged; a module's extra place for it reaches only the receipt.
- The hook's doc says the call is a delivery: for a piece no channel
  takes, a surface may show it then and answer where, with a write that
  doesn't wait. It also promises the order a host can key on: a piece
  with final: true comes after the turn's inference:completed and before
  the agent's next inference:started. The README and the .added fragment
  say the same, without the wait, and the .changed fragment's log lines
  lose the wait's.

Tests: the receipts suite drops the 25 tests whose subject was waiting
(the bound, answers still due at a turn's end or a stop, late or overdue
answers, an ask after the receipt, a removal during an ask) and gains
one: a hook that returns a
promise names no place, and routing doesn't wait for it. The order test
now holds the first reply's post rather than its answer, and asserts that
the final speech is asked about after inference:completed. The tests of
an agent replaced while its modules were asked now replace it from inside
the hook.
A module written for the hook's earlier async shape, or in JavaScript,
can still return a promise. Its sentTo was never read, so it named no
place, but a rejection had no handler, and an unhandled rejection ends
a Node process by default. The registry now catches a thenable answer's
rejection, logs it as it logs a throw, and counts the answer as none.
The hook's doc says so, and a test pins it beside the resolving
promise's. The README's sentence on a held piece now reads from the
modules' side.
main now carries the batch channels change (wakeChannels at each carry
place, cameFrom on a no-channel notice, byDesign) and the dropped-thinking
note. Where the two met:

- Each carry place keeps both fields: the start rule's bringsOwnChannels
  beside bringsOwnMessages, the hold's fold, the release, the quiesce
  park, the re-arm and retry pushes and the turn-retry mark carry
  wakeChannels and openingMessages alike. The release's put-back gives
  the batch's first member whichever of the two its start held beyond
  the batch.
- routeLocusSpeech keeps this change's body (the modules called, the
  replaced-agent checks, the shown-by line) and passes main's cameFrom on
  each of its three routeSpeech calls, so a no-channel piece no module
  showed names the channels its batch came from. A hybrid envelope still
  routes through it.
- The no-channel notice keeps this change's opening words and takes
  main's byDesign: "Your plain speech "<opening>" (N chars) had no
  channel to go to - <reason>. This is a routing situation" where the
  reason names the batch's channels, "routing/configuration" otherwise.
  onRouteFailure takes both text and byDesign. A failed channel post
  keeps this change's "The framework posted nothing there".
- Tests: held-turn-retry keeps both changes' twins of each fold test, one
  per field. batched-wake-locus's header joins both sentences, and its
  channels assertion takes the opening words. route-failure-targeting
  checks the opening words and the routing/configuration clause. The
  continuation test after a channel_open expects this change's words for
  a turn with no channel: "routing posts it to no channel". The
  re-arm fold test drops this change's fold of two unmarked wakes, which
  the first case already pins.
The changed fragment still said that a place a module names for an
ephemeral run's speech is logged when a tool ends the run. Since the
hook became synchronous, only a piece with no channel has its places
logged, in its routeSpeech line, and an ephemeral run's unheld speech
with no channel isn't asked about. So the pieces such a run is asked
about, with a channel or held as drafts, have their places in no log
when no receipt comes. The sentence now says only that the run writes
no receipt, as before. Text only.
The routing section said that a turn which fails or is stopped writes
no receipt, "and the place is logged". Since the hook became
synchronous, only a piece routed to no channel has its places logged,
in its routeSpeech line, as they're named. A piece held as a draft,
such as a heartbeat's speech with no locus, has its places only on the
turn's routing, which the receipt reads. The sentence now says both,
and that an ephemeral run's speech with no locus isn't asked about.
Text only.
… place

A request that carries a turn on takes two marks from the turn's
routing: the messages that opened the turn (openingMessages) and the
channels its wake came from (wakeChannels). Each site carried them by
hand, and their rules differ: a new turn takes both from the routing,
and a continuation of the same turn takes the routing's channels and
keeps the opening messages the request already carries, since the
turn's routing, with its own, carries on. markFollowOn now sets them by
the request's kind, replacing wakeChannelsMark, and mergedMarks takes
both at the hold's keep, the quiesce park and the release's put-back.

- New turn: the '!' continuation, autoContinue, the bounce's wake, both
  rewind retries and the re-arm.
- Same turn: the budget restart, tool_results_ready, the guard retry
  and a held turn's retry (turnRetryOf).

No change in behaviour, with one equality by reading: the re-arm took
the union of its trigger's opening messages and the routing's. Each
caller passes the stream's routing, and the turn's start puts the
trigger's ids into it, so the routing's alone is the same set, though
the ids can now come in the routing's order in openedBy.messages.

The same-turn kind keeps the request's own opening messages: the budget
restart and tool_results_ready carry none, and the guard retry and a
held turn's retry keep their trigger's, from the spread that keeps its
other marks. A held retry released with a wake of its own cause, before
any of the turn's rounds ran, merges with it into one start, and that
start's routing takes the merged ids alone; without the retry's, the
turn's opening messages would leave openedBy. held-turn-retry now pins
that case.

The start rule keeps its two lines, since each field has its own filter
(bringsOwnMessages, bringsOwnChannels), and so does the turn-start
routing update, whose rules differ by design. An operator's unstick is
left as it was, carrying the opening messages alone; the next commit
gives it its kind.
…it reruns

An unstick queues a new turn for an idle agent, and its request took the
opening messages of the agent's latest turn but not the channels that
turn's wake came from, though the rewind retries an unstick starts carry
both. A turn an ambient batch woke had no channel to post to, so the
notice for the unstick's speech said the turn came from no channel. The
unstick now takes its marks as a new turn, both of them, and its notice
names the batch's channels.
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.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

/review

— Effie-2289

@anima-review

anima-review Bot commented Nov 9, 2026

Copy link
Copy Markdown

Automatic context view of 3471d88cd9cc merged onto 152ee76fbadd.

What a context view runs and what its checks judge: CHECKS.md.

The records of 15 of the 76 runs differ between the base and this head.

In the files profiled hosts loaded, 69 changed source lines that map to code ran in no profiled drive: src/framework.ts lines 1663–1666, 7211, 7336, 19352, 19359, 22250–22256, 22333, 22794, 22818–22819, 22838, 22863, 22875, 22878, 23712–23713, 23723–23724, 23727–23728, 23752, 23759–23760, 23921, 23926, 25843, 25850, 26211, 26315–26316, 26538, 29245, 29861, 29866, 29868 and 34448; src/module-registry.ts lines 118–121, 764–766, 769–776, 778–781 and 783–784; and 3 more lines in 1 file. 182 changed source lines map to nothing the host runs: 52 of src/framework.ts's, 11 of src/mcpl/channel-registry.ts's, 9 of src/module-registry.ts's, 2 of src/prose-segments.ts's, 2 of src/types/agent.ts's, 3 of src/types/framework.ts's and 103 of src/types/module.ts's. The whole list is in the fold below.

Separately, 2 of the checks' flags differ between the base and the head, in 2 runs; they're listed under "Checks' flags that differ", below.

Usage: the agents' requests, subagents' included, in the 76 runs, base → head: 208,163 → 208,171 tokens sent a request, 70.8% of them read from the cache, 146,596,163 → 146,602,151 written to it, 3,943,783 output and 24 thinking blocks dropped. The figures of 15 runs moved, for any kind of request; they're under "Usage that differs", below. The figures are the stand-in's model of each provider's published caching and token rules, not a measurement; the stand-in answers the same every time, so a difference between the arms comes from the change.

A model's account of those records:

Delta 1: No-channel guidance describes routing rather than archive-only speech

In runs exercising reader forks, wake rules, refusal recovery, speech routing and failed tool turns, when the model receives guidance that plain speech has no channel.

  • base (main): The model is told that plain speech without a channel stays in the agent's archive.
  • head (3471d88cd9cc): The model is told instead that routing posts that speech to no channel, while retaining the instruction to use an explicit send tool.

Delta 2: No-channel failures identify speech by its opening words

In the wake-rule, refusal-recovery, speech-routing and mixed console-and-channel conversations, when delivery-failure notices enter the resident's context or an unsent context preview.

  • base (main): The notices identify undeliverable speech as the previous reply and give its length.
  • head (3471d88cd9cc): The notices identify it as plain speech and quote its opening words when available, making that excerpt part of the conversation sent to the resident's model and named in the store's bookkeeping.
  • Also in: Later requests in these conversations retain the revised failure notices.

Delta 3: Later histories replay revised no-channel notices together

In the wake-rule conversations with instructions beside user input, and in the refusal-recovery conversation, when requests replay earlier routing guidance and delivery-failure notices together; this also appears in unsent context previews. Follows from Delta 2.

  • base (main): The resident's context retains guidance saying channel-less speech stays in the archive and failure notices identifying previous replies by length, with draft receipts saying speech was not sent where those receipts are present.
  • head (3471d88cd9cc): That history instead carries guidance saying routing posts to no channel and failure notices quoting speech openings, with draft receipts specifying that the framework did not send the words where those receipts are present.

Delta 4: Held-speech notes specify that the framework did not send

In the wake-rule conversations and conversations exercising automatic continuation, hybrid routing or speech locked to drafts, when the resident's model receives notices about held speech.

  • base (main): Draft notices, delivery receipts and continuation guidance describe held words as not sent, alongside the earlier no-channel guidance where it is also carried.
  • head (3471d88cd9cc): Those messages specify that the framework did not send the held words, alongside the revised no-channel guidance where it is also carried.

Delta 5: Inbound-only guidance speaks only for routing

In conversations exercising incoming-only channels, when the resident's model receives channel-routing guidance or the result of opening such a channel.

  • base (main): The model is told that plain speech on the incoming-only channel is refused and stays in the archive.
  • head (3471d88cd9cc): The model is told that plain speech is refused and routing posts it to no channel there, retaining the direction to use the server's own tools.

Delta 6: Failed-post notices qualify whose posting failed

In the refusal-recovery conversation and the conversation routing speech to the latest incoming channel, when failed channel posts are reported to the resident's model.

  • base (main): The failure notice says that nothing was posted.
  • head (3471d88cd9cc): The failure notice says that the framework posted nothing there.

Delta 7: An incoming-only failure carries revised guidance and failure wording

In the conversation routing speech to the latest incoming channel, after an addressed arrival moves an active turn to an incoming-only channel and its reply is refused. Follows from Delta 5.

  • base (main): The resident's model receives guidance saying speech stays in the archive and a failed-post notice saying nothing was posted.
  • head (3471d88cd9cc): It receives guidance saying routing posts to no channel there and a failed-post notice saying the framework posted nothing there.

Delta 8: Addressed-arrival guidance no longer says speech stays archived

In the mixed console-and-channel conversation, when a message addressed from another channel arrives during a self-woken turn after its earlier channel was closed. Follows from Delta 2.

  • base (main): The resident's model receives the arrival with guidance that speech stays in the archive this turn, alongside delivery-failure notices identifying previous replies by length.
  • head (3471d88cd9cc): It receives guidance that speech has no channel this turn, alongside delivery-failure notices quoting speech openings; the addressed-arrival guidance remains after the arrival.

Delta 9: An unstick retry quotes speech it otherwise sets aside

In the refusal-recovery conversation, when an operator's unstick is refused and retried after setting aside the preceding recovery reply. Follows from Delta 2.

  • base (main): The resident's model receives the rewind marker and a length-only failure notice, without the set-aside reply.
  • head (3471d88cd9cc): The full set-aside reply remains absent, but the failure notice now includes its opening words before the rewind marker.

Delta 10: Failed tool-turn notices qualify what was not posted

In the conversation sending tool calls inside a transcript for the model to continue, when a failure after a tool call is reported in a subsequent request.

  • base (main): The resident's model is told that nothing written before the preceding tool calls was posted.
  • head (3471d88cd9cc): It is told instead that the framework did not post the words before tool calls in affected rounds.

Delta 11: Retrieval requests also name earlier speech (the description doesn't mention it)

In the wake-rule conversations with retrieval enabled, when the host asks for keywords from recent conversation during the heartbeat, history-search and subagent stretches. Follows from Delta 2.

  • base (main): The keyword model receives recent conversation without a mention of the earlier reply newly named by the head.
  • head (3471d88cd9cc): The keyword model also receives a mention of that earlier reply, while retaining the other named conversation messages.

Delta 12: Conversation requests retain selections but change message bodies

In the reader-fork, wake-rule, refusal-recovery, speech-routing and failed-tool-turn conversations, at requests retaining the same selection of conversation messages.

  • base (main): The resident's or reader's model receives the selected messages with the base's message bodies and cache markers.
  • head (3471d88cd9cc): The same selected messages go to that model with changed message bodies or cache markers.

Delta 13: Background requests retain named messages but change bodies

During the host-feature conversations exercising wake rules, retrieval and console input, in background model requests whose named conversation messages stay unchanged.

  • base (main): The background models receive the base's request bodies with the named conversation messages.
  • head (3471d88cd9cc): Those models receive changed request bodies with the same named conversation messages and scripted verdicts.

Delta 14: An early cache boundary moves later (the description doesn't mention it)

In the wake-rule conversation with instructions after user input and a paused heartbeat, when a stalled model call is sent and retried. Follows from Delta 3.

  • base (main): An early-history cache boundary ends earlier in the conversation, whose history carries the original speech notices.
  • head (3471d88cd9cc): That boundary ends later in the conversation, alongside the revised speech notices carried forward in its history.
Runs whose records differ: 15 of 76, with 14 changes
H4b/W1: 2 changes

No-channel guidance describes routing rather than archive-only speech

At 7 steps; the quotes are from step templates.

Before:

[routing] Your plain speech currently has no channel — it stays in your archive. Use an explicit send tool to reach a channel.

After:

[routing] Your plain speech currently has no channel, so routing posts it to no channel. Use an explicit send tool to reach a channel.

Conversation requests retain selections but change message bodies

At 5 steps; the quotes are from step 208390.h2.

Before:

round [reader/fixture/E1/2] =, body a00f6a98

After:

round [reader/fixture/E1/2] =, body 0d388b20
H5/W12: 7 changes

No-channel guidance describes routing rather than archive-only speech

At 8 steps. Its before and after are quoted under H4b/W1, at step templates.

No-channel failures identify speech by its opening words

At 14 steps; the quotes are from step templates.

Before:

[send-undeliverable] Your previous reply (2146 chars) had no channel to go to

After:

[send-undeliverable] Your plain speech "⟨own text, chars 0–38 of 2146⟩…" (2146 chars) had no channel to go to

Later histories replay revised no-channel notices together

At step 88667.judge.no.

Before:

[T:user-routing-your-plain-8062b129ccf6] 88667.judge.yes.r1 [T:user-send-undeliverable-your-previous-2cb562293b3f](2146,12)

After:

[T:user-routing-your-plain-9b5f143f7949] 88667.judge.yes.r1 88667.judge.yes.r1:2[T:user-send-undeliverable-your-plain-32839ed74a68](0,38,2146,2146,12)

Held-speech notes specify that the framework did not send

At 4 steps; the quotes are from step templates.

Before:

(not sent — drafts can resend them unchanged, or dismiss them)

After:

(the framework didn't send them — drafts can resend them unchanged, or dismiss them)

Retrieval requests also name earlier speech

At 2 steps; the quotes are from step 88667.subs.list.

Before:

ask [concepts] 88667.orphan.in 88667.orphan.r1 88667.subs.list.in -> named, body e3bcbb7e

After:

ask [concepts] 88667.priv.spawn.r3 88667.orphan.in 88667.orphan.r1 88667.subs.list.in -> named, body 25d28e75

Conversation requests retain selections but change message bodies

At 32 steps. Its before and after are quoted under H4b/W1, at step 208390.h2.

Background requests retain named messages but change bodies

At 12 steps; the quotes are from step 88667.judge.no.

Before:

ask [judge] 88667.judge.b1.in 88667.judge.b2.in -> wait, body f2b76902

After:

ask [judge] 88667.judge.b1.in 88667.judge.b2.in -> wait, body 09c5ae28
H5b/W12: 6 changes

No-channel guidance describes routing rather than archive-only speech

At 2 steps. Its before and after are quoted under H4b/W1, at step templates.

No-channel failures identify speech by its opening words

At step templates. Its before and after are quoted under H5/W12, at step templates.

Later histories replay revised no-channel notices together

At 38 steps. Its before and after are quoted under H5/W12, at step 88667.judge.no.

Held-speech notes specify that the framework did not send

At step templates. Its before and after are quoted under H5/W12, at step templates.

Conversation requests retain selections but change message bodies

At 17 steps. Its before and after are quoted under H4b/W1, at step 208390.h2.

Background requests retain named messages but change bodies

At 6 steps. Its before and after are quoted under H5/W12, at step 88667.judge.no.

H5c/W12: 8 changes

No-channel guidance describes routing rather than archive-only speech

At 2 steps. Its before and after are quoted under H4b/W1, at step templates.

No-channel failures identify speech by its opening words

At step templates. Its before and after are quoted under H5/W12, at step templates.

Later histories replay revised no-channel notices together

At 36 steps. Its before and after are quoted under H5/W12, at step 88667.judge.no.

Held-speech notes specify that the framework did not send

At step templates. Its before and after are quoted under H5/W12, at step templates.

Retrieval requests also name earlier speech

At step 482862.hist.send. Its before and after are quoted under H5/W12, at step 88667.subs.list.

Conversation requests retain selections but change message bodies

At 15 steps. Its before and after are quoted under H4b/W1, at step 208390.h2.

Background requests retain named messages but change bodies

At 6 steps. Its before and after are quoted under H5/W12, at step 88667.judge.no.

An early cache boundary moves later

At step 482862.stall.

Before:

^ 7,8,12,252, body fee68886

After:

^ 7,8,13,252, body 4c49eb00
H1r/W16: 6 changes

No-channel guidance describes routing rather than archive-only speech

At 3 steps. Its before and after are quoted under H4b/W1, at step templates.

No-channel failures identify speech by its opening words

At 4 steps. Its before and after are quoted under H5/W12, at step templates.

Later histories replay revised no-channel notices together

At step 834005.break.3. Its before and after are quoted under H5/W12, at step 88667.judge.no.

Failed-post notices qualify whose posting failed

At 2 steps; the quotes are from step templates.

Before:

Nothing was posted; it is saved in your archive.

After:

The framework posted nothing there; it is saved in your archive.

An unstick retry quotes speech it otherwise sets aside

At step 834005.unstick.

Before:

[T:user-send-undeliverable-your-previous-2e4e3908fe9c](2716) [T:user-refusal-rewind-the-#-cf4611ad5adc](1)

After:

834005.break.3.r1:2[T:user-send-undeliverable-your-plain-495ffe48c07c](0,37,2716,2716) [T:user-refusal-rewind-the-#-cf4611ad5adc](1)

Conversation requests retain selections but change message bodies

At 4 steps. Its before and after are quoted under H4b/W1, at step 208390.h2.

H1/W13: 2 changes

No-channel guidance describes routing rather than archive-only speech

At 2 steps. Its before and after are quoted under H4b/W1, at step templates.

No-channel failures identify speech by its opening words

At 3 steps. Its before and after are quoted under H5/W12, at step templates.

Hcont/W13: 4 changes

No-channel guidance describes routing rather than archive-only speech

At 2 steps. Its before and after are quoted under H4b/W1, at step templates.

No-channel failures identify speech by its opening words

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

Held-speech notes specify that the framework did not send

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

Conversation requests retain selections but change message bodies

At 8 steps. Its before and after are quoted under H4b/W1, at step 208390.h2.

Hlatest/W13: 4 changes

Inbound-only guidance speaks only for routing

At step templates.

Before:

fixture declared this channel inbound-only, so plain speech there is refused and stays in your archive. To speak there, use fixture's own tools. Other channels need an explicit send tool.

After:

fixture declared this channel inbound-only, and plain speech there is refused, so routing posts it to no channel there. To speak there, use fixture's own tools. Other channels need an explicit send tool.

Failed-post notices qualify whose posting failed

At step templates. Its before and after are quoted under H1r/W16, at step templates.

An incoming-only failure carries revised guidance and failure wording

At step 476258.repin.

Before:

[T:user-routing-the-conversation-569bc0b6de95](13) 476258.repin.r2 [T:user-delivered-plain-speech-17b308e66c2c] [T:user-discord-send-failed-your-previous-5eebb0baa799](322,13)

After:

[T:user-routing-the-conversation-07eaa6cea3de](13) 476258.repin.r2 [T:user-delivered-plain-speech-17b308e66c2c] [T:user-discord-send-failed-your-previous-df35002e2937](322,13)

Conversation requests retain selections but change message bodies

At 2 steps. Its before and after are quoted under H4b/W1, at step 208390.h2.

Hhyb/W13: 4 changes

No-channel guidance describes routing rather than archive-only speech

At 2 steps. Its before and after are quoted under H4b/W1, at step templates.

No-channel failures identify speech by its opening words

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

Held-speech notes specify that the framework did not send

At 2 steps. Its before and after are quoted under H5/W12, at step templates.

Conversation requests retain selections but change message bodies

At step 377404.last. Its before and after are quoted under H4b/W1, at step 208390.h2.

Hlock/W13: 2 changes

Held-speech notes specify that the framework did not send

At 7 steps. Its before and after are quoted under H5/W12, at step templates.

Conversation requests retain selections but change message bodies

At 6 steps. Its before and after are quoted under H4b/W1, at step 208390.h2.

Hpriv/W13: 2 changes

No-channel guidance describes routing rather than archive-only speech

At 2 steps. Its before and after are quoted under H4b/W1, at step templates.

No-channel failures identify speech by its opening words

At 3 steps. Its before and after are quoted under H5/W12, at step templates.

Hretry/W13: 2 changes

No-channel guidance describes routing rather than archive-only speech

At 2 steps. Its before and after are quoted under H4b/W1, at step templates.

No-channel failures identify speech by its opening words

At 3 steps. Its before and after are quoted under H5/W12, at step templates.

Hfold/W13: 2 changes

No-channel guidance describes routing rather than archive-only speech

At 2 steps. Its before and after are quoted under H4b/W1, at step templates.

No-channel failures identify speech by its opening words

At 3 steps. Its before and after are quoted under H5/W12, at step templates.

H1/W17: 5 changes

No-channel guidance describes routing rather than archive-only speech

At 4 steps. Its before and after are quoted under H4b/W1, at step templates.

No-channel failures identify speech by its opening words

At 6 steps. Its before and after are quoted under H5/W12, at step templates.

Inbound-only guidance speaks only for routing

At 4 steps. Its before and after are quoted under Hlatest/W13, at step templates.

Addressed-arrival guidance no longer says speech stays archived

At 2 steps; the quotes are from step templates.

Before:

[routing] Wren addressed you from other while you were busy. Your plain speech stays in your archive (no channel) this turn; reaching them takes an explicit send.

After:

[routing] Wren addressed you from other while you were busy. Your plain speech has no channel this turn; reaching them takes an explicit send.

Conversation requests retain selections but change message bodies

At 7 steps. Its before and after are quoted under H4b/W1, at step 208390.h2.

H6/W19: 3 changes

No-channel guidance describes routing rather than archive-only speech

At 2 steps. Its before and after are quoted under H4b/W1, at step templates.

Failed tool-turn notices qualify what was not posted

At 4 steps; the quotes are from step templates.

Before:

[speech-not-posted] Nothing you wrote before your tool calls in the rounds just above was posted. If any of it should go out, send it again.

After:

[speech-not-posted] The framework didn't post what you wrote before your tool calls in one or more of the rounds just above. If any of it should go out, send it again.

Conversation requests retain selections but change message bodies

At 3 steps. Its before and after are quoted under H4b/W1, at step 208390.h2.

Checks' flags that differ (2)
  • H1r/W16 step 834005.unstick, check 14 flagged on the head 1 more time than on the base: W16's property: request r11, the next after the refused r10, still carries the shed turn's message [[c:834005.break.3.r1]]: it was replayed, not shed

  • H1/W17 step 487568.close.wake, check 14 flagged on the head 1 more time than on the base: W17's property: request r14 carries [[c:487568.close.wake.a.in]], addressed from another channel, with no [routing] notice after it ahead of the next block that carries a canary

Usage that differs: 15 of 76 runs, base → head
H4b/W1 kind requests input read from the cache written to it output thinking dropped
agent 212 1,146,418 47,744,443 → 47,744,503 2,663,639 → 2,663,643 241,037 0
summariser 85 1,449,588 3,811,128 179,856 170,000 0
H5/W12 kind requests input read from the cache written to it output thinking dropped
agent 84 51,197 → 51,223 8,889,710 → 8,898,727 1,221,438 → 1,222,768 68,546 0
concepts 26 82,771 → 83,115 0 0 1,878 → 1,927 0
judge 2 6,255 → 6,277 23,664 23,664 67 0
naming 1 617 0 0 9 0
relevance 2 10,035 0 0 42 0
review 1 1,352 0 0 55 0
H5b/W12 kind requests input read from the cache written to it output thinking dropped
agent 72 46,403 4,909,666 → 4,912,173 3,594,196 → 3,597,558 64,704 0
concepts 19 65,482 → 65,625 0 0 1,605 0
judge 2 1,492 684 684 67 0
naming 1 617 0 0 9 0
relevance 2 11,495 0 0 42 0
review 1 1,924 0 0 55 0
H5c/W12 kind requests input read from the cache written to it output thinking dropped
agent 70 54,692 7,602,824 → 7,609,332 752,265 → 752,936 70,580 0
concepts 18 70,737 → 70,924 0 0 1,536 → 1,553 0
naming 1 617 0 0 9 0
relevance 2 13,372 0 0 42 0
review 1 1,930 0 0 55 0
H1r/W16 kind requests input read from the cache written to it output thinking dropped
agent 10 0 219,352 → 219,567 34,040 → 34,130 8,922 0
H1/W13 kind requests input read from the cache written to it output thinking dropped
agent 10 402 195,791 → 195,795 24,760 → 24,783 1,403 0
Hcont/W13 kind requests input read from the cache written to it output thinking dropped
agent 19 893 → 900 402,144 → 402,584 26,728 → 26,778 1,906 0
Hlatest/W13 kind requests input read from the cache written to it output thinking dropped
agent 15 1,051 → 1,058 317,450 → 317,464 26,703 → 26,717 1,921 0
Hhyb/W13 kind requests input read from the cache written to it output thinking dropped
agent 12 402 252,289 → 252,347 26,748 → 26,780 1,563 0
Hlock/W13 kind requests input read from the cache written to it output thinking dropped
agent 13 644 278,731 → 279,191 27,079 → 27,139 1,856 0
Hpriv/W13 kind requests input read from the cache written to it output thinking dropped
agent 10 402 193,068 → 193,072 24,269 → 24,288 1,403 0
Hretry/W13 kind requests input read from the cache written to it output thinking dropped
agent 11 401 221,451 → 221,455 25,899 → 25,920 1,544 0
Hfold/W13 kind requests input read from the cache written to it output thinking dropped
agent 11 402 212,486 → 212,490 24,771 → 24,793 1,550 0
H1/W17 kind requests input read from the cache written to it output thinking dropped
agent 24 7,479 → 7,496 922,901 → 923,854 57,045 → 57,193 22,981 0
naming 1 598 0 0 9 0
H6/W19 kind requests input read from the cache written to it output thinking dropped
agent 8 72 95,838 111,617 → 111,759 8,656 0

The figures of the other 61 runs are the same in the base and this head.

Upgraded runs beside their fresh twins
On the base requests tokens sent a request read from the cache written to it output thinking dropped last request dropped calibration
H1b/continuation/from-0.9.0 15 159,813 52.6% 993,917 20,252 24 0 1.28, confirmed after a reset
H1b/continuation/fresh 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed
H1b/continuation/from-0.23.0 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed
On the head requests tokens sent a request read from the cache written to it output thinking dropped last request dropped calibration
H1b/continuation/from-0.9.0 15 159,813 52.6% 993,917 20,252 24 0 1.28, confirmed after a reset
H1b/continuation/fresh 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed
H1b/continuation/from-0.23.0 15 155,549 56.9% 862,775 20,252 0 0 1.29, confirmed

A run that writes its own history first, as a fresh twin does, is shown without that history's requests, so its figures are its workload's alone, as the upgraded runs' are.

Changed source lines and deletions not seen to run, by file

src/framework.ts: lines 1663–1666, 7211, 7336, 19352, 19359, 22250–22256, 22333, 22794, 22818–22819, 22838, 22863, 22875, 22878, 23712–23713, 23723–23724, 23727–23728, 23752, 23759–23760, 23921, 23926, 25843, 25850, 26211, 26315–26316, 26538, 29245, 29861, 29866, 29868 and 34448 ran in no profiled drive; lines 93, 1309–1310, 1565–1568, 1641–1642, 1644, 1651–1652, 1656–1657, 1661–1662, 1667–1668, 1753, 1968–1969, 22809, 22936, 23682–23686, 23744–23745, 23754–23755, 23762–23763, 23765–23767, 23827–23828, 23835–23836, 24399, 26317–26318, 29784–29788, 35798–35799 and 35823 map to nothing the host runs.

src/mcpl/channel-registry.ts: lines 1091–1093, 6125–6126, 6427–6428, 6441–6442 and 6506–6507 map to nothing the host runs.

src/module-registry.ts: lines 118–121, 764–766, 769–776, 778–781, 783–784, 786–787 and 789 ran in no profiled drive; lines 25, 122–123, 757, 767, 782, 788 and 792–793 map to nothing the host runs.

src/prose-segments.ts: lines 64–65 map to nothing the host runs.

src/types/agent.ts: lines 673–674 map to nothing the host runs.

src/types/framework.ts: lines 199–201 map to nothing the host runs.

src/types/module.ts: lines 208–246 and 899–962 map to nothing the host runs.

Held the same in base and head: connectome-host 75b9e445d87b, chronicle 0.6.0, context-manager 0.25.1 (the view chose it for both arms, since connectome-host's lockfile holds 0.25.0, which doesn't meet ^0.25.1 from the base), membrane 0.8.0.

@anima-review

anima-review Bot commented Nov 9, 2026

Copy link
Copy Markdown

Automatic review of 3471d88cd9cc.

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. minor at docs/dendrite.md:272, src/framework.ts:22945, src/framework.ts:23701, change-text.md:53, src/framework.ts:22944, src/framework.ts:23694: The docs say an ephemeral run's no-channel speech isn't asked, but held draft speech still reaches onSpeechRouted.
    • Failing case: An ephemeral run with no channel whose prose is held as a draft, such as a locked-to-drafts turn, calls the hook for that prose; a module author relying on the documented exception could expose or mishandle text they expected never to receive.
    • Raised before as finding 6 of the review of 510c68cd5672, fixed then; this is a case the fix leaves.
  • 2. minor at src/framework.ts:22953, src/framework.ts:23846, src/framework.ts:29104, src/framework.ts:31387: A displaced predecessor's late-decided round adds a place after the receipt consumed its asks, and the held-speech path logs nothing.
    • Failing case: After a context-budget restart, let the successor finish and write the turn's receipt before the predecessor finishes deciding a round waiting on a send; when that round is decided early and its held speech is shown by a module, the returned place is appended to sentTo too late for the receipt and the held-speech path emits no place log, so neither the agent nor the operator learns where it was shown.
    • Raised before as finding 7 of the review of dd8144cd2c26 and answered: «An ask made after the turn's receipt took its asks, as when a budget restart's predecessor decides a round waiting on its sends in its finally after its replacement ended the turn, is now logged with the places it names, rather than kept for a receipt that won't take it.» At this head, that doesn't hold: The 11:58 answer said a place named after the turn's receipt took its asks 'is now logged with the places it names'. At this head noteSpeechPlaces only appends to routing.sentTo, which the receipt has already deleted (23846), and holdProseDrafts logs no places, because bd409aece7 removed the late lines.
  • 3. minor at docs/dendrite.md:272, changelog.d/speech-sent-receipts.changed.md:14, src/framework.ts:23715, change-text.md:132: The docs claim every no-channel piece logs its places or emits the failure trace, overlooking the replacement check's early return.
    • Failing case: When a hook replaces the agent while processing a no-channel piece, the framework logs only that the agent was replaced, irrespective of whether a module named a place, and emits no route-failed trace; an operator or host using the documented log or trace to account for the piece cannot find it.
  • 4. minor at docs/dendrite.md:272, change-text.md:65, src/framework.ts:23966: The docs say a held piece's place appears only in the receipt, overlooking the receipt-append failure log that names places.
    • Failing case: If storing a receipt for a held piece throws, the receipt is absent but the [delivered] error line logs its module-named place; a host operator relying on these statements to assess where place names can appear would miss that log.
  • 5. minor at test/speech-sent-receipts.test.ts:1314, test/speech-sent-receipts.test.ts:1321: The promise-hook test judges non-waiting with a single 200 ms wall-clock timer.
    • Failing case: If the process is descheduled or CPU-throttled for 200 ms after the hook returns, the timer may settle before runUntilIdle completes even though routing did not await the promise, causing a correct implementation to fail.
  • 6. minor at change-text.md:169, src/module-registry.ts:771: The rent pass claims one slow module can't hold up the speech, but hooks run synchronously on the routing path.
    • Failing case: A hook doing synchronous file I/O, expensive computation, or an infinite loop delays or stalls routing despite returning no promise; a host author could incorrectly rely on the claimed isolation.
  • 7. minor at README.md:120, changelog.d/speech-sent-receipts.added.md:1, src/types/module.ts:223, src/framework.ts:22944: The hook docs promise a call for every held draft piece, but the draft path skips the call once another agent holds the name.
    • Failing case: If an agent is replaced before its locked or heartbeat prose is held, the framework still holds that prose but does not call any module; module authors cannot rely on the documented per-draft callback to show or account for it.
  • 8. minor at test/speech-sent-receipts.test.ts:1091, test/speech-sent-receipts.test.ts:1118: The live send-held tests check only that an ask happened, not that the held piece was asked once.
    • Failing case: If a live send-held round asked about “Looking around.” or the hybrid plain envelope twice, these assertions and the deduplicated receipt check would still pass, while a surface displaying each call would show the piece twice.
  • 9. minor at test/speech-sent-receipts.test.ts:699, test/speech-sent-receipts.test.ts:708: The restart test starts a separate request after a completed turn, so it doesn't pin that a live-routed round isn't re-asked across a restart.
    • Failing case: A tool round can route its prose before a context-budget restart or retry continues the logical turn; the tests do not assert that the original round appears only once among the hook calls across that continuation.
  • 10. minor at change-text.md:163, src/framework.ts:14949, test/batched-wake-locus.test.ts:272: The rent pass doesn't weigh the unstick's new carry of the latest turn's channels, or its test.
    • Failing case: The pass weighs opening-message carries and other routing changes but gives no reason or failure for the unstick's new channel carry, leaving this independently changed behavior and its test unweighed despite their description elsewhere.
Raised before, still answered (12)

Effie-2289 added 3 commits November 9, 2026 11:15
A `[send-undeliverable]` notice now quotes the opening words of the speech
it's about. The refusal rewind, `/unstick` and the inference-exhaustion shed
set a reply aside so the model isn't sent those words again, but they pass
over every system row, so the notice stayed and carried the reply's opening
into the retry: a shed reply's words came back in part.

The notice records the opening it quotes in its metadata (`opening`), and
`shedNewestTurn` sheds with the exchange each such notice next to it, with
only rows passed over between them, whose opening is in the exchange's own
speech. The notice is written before the reply's row is stored, so nothing
else links the two. A notice about an earlier reply stays, unless it lies
next to the shed exchange and quotes the same opening. The `.changed`
fragment says so beside the notice's quoted opening.
… is asked

A place a module named for a piece held as a draft went only to the turn's
receipt. A piece held after the turn's receipt was written left that place
in neither the receipt nor a log. `holdProseDrafts` now logs
a held piece's places as they're named, as a piece with no channel's are:
`[speechRouted] <agent>: <n> chars shown by <places>, held as a draft`.

The docs now say what the code does:
- an ephemeral run's held speech is asked about (docs/dendrite.md);
- a place named for a piece held as a draft, or routed to no channel, is
  logged as it's named, except that a piece routed to no channel whose agent
  was replaced while its modules were asked logs that instead, with no places
  and no trace
  (docs/dendrite.md, the `.changed` fragment);
- speech an agent routes or holds once another agent has taken its name
  isn't asked about (the hook's type doc, the README, the `.added` fragment).

The test of a hook that returns a promise held that promise on a 200 ms
timer, so a descheduled run could settle it before the turn ended. Its
promise now settles only after the assertions, so a routing that waited on
it would hang the turn and fail at the test's timeout.
Two tests of a live round an explicit send held asserted that the held
speech was asked about with `.some`, which passes however many times it was
asked. Each now counts exactly one ask: with every explicit-send hold made
to ask twice, both passed before and both fail now.

The budget-restart test now also asserts that the round routed before the
restart is asked about once, so a restart that asked about it again would
fail the suite. It passes at this head.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

This answers the automatic context view of 3471d88cd9cc and the automatic review of 3471d88cd9cc. The head is now ecb9ff7e0fc0.

  • Context view: its first flag was a real defect, now fixed. A rewind set a reply aside but kept the no-channel notice that quotes the reply's opening words, so those words came back. Its second flag, by our reading of other runs' records, is the view's check taking a quoted reply for that reply arriving. The resident still gets the arrival's [routing] notice after it, as at the base, with only the notices' new wording.
  • Review: 1, 2, 3, 4, 5, 7 and 8 are fixed, 6 and 10 are answered in the description, and 9 is left as it is, with an assertion added that pins it.
  • An earlier answer of ours that stopped holding: our answer to finding 7 of the review of dd8144cd2c26 said a place a module names after the turn's receipt is logged. Since bd409aece7, that wasn't so for a piece held as a draft. 2 makes it so again.
Credits

Effie-2289 made the round, Ada-1017 made its calls, and Ansel-2428 read it.

Details for agents

Head ecb9ff7e0fc0, on 3471d88cd9cc. Line numbers are at the head.

The context view:

  • Flag 1, H1r/W16 at step 834005.unstick (check 14), fixed in 1a3ef2bfac. As its Delta 9 shows, a [send-undeliverable] notice now quotes the opening words of the speech it's about. A rewind, whether automatic, an operator's /unstick or the shed after inference failures, passes over every system row, so it set the reply aside and kept the notice that quotes it.
    • The notice now records the opening it quotes in its metadata, as opening (src/framework.ts:35857). shedNewestTurn sets aside with the exchange each such notice next to it, with only rows it passes over between them, whose opening is in the exchange's own speech (noticesQuoting, :1651, called at :14579). The notice is written before the reply's row is stored, so nothing else links the two.
    • A notice about an earlier reply stays, unless it's next to the exchange set aside and quotes the same opening.
    • The tests:
      • "a rewind that sets aside a reply no channel took sets aside the notice that quotes it, so the retry is sent neither" (test/speech-sent-receipts.test.ts:1663): an /unstick refused, then retried.
      • Two in test/refusal-rewind.test.ts, for a window that is still the record: the notice goes with the reply (:74), and a notice next to the reply that quotes an earlier reply stays (:87).
    • With 3471d88cd9cc's src, the first fails at its check that the retry carries no quote, and the second removes the reply alone. Matching any notice next to the exchange, whatever it quotes, fails the third.
  • Flag 2, H1/W17 at step 487568.close.wake (check 14): the order the resident gets is as at the base; the previous reply's notice now quotes its opening, and the arrival's [routing] notice reads as Delta 8 shows.
    • At the base, the previous reply's notice already sits between the arrival and its [routing] notice.
    • On the head, that notice quotes the reply's opening, which carries the run's marker, as Deltas 3 and 9 show for other runs' notices. So, by our reading, the check takes the notice for the next block carrying one. We haven't read the head's record of this step. The [routing] notice is still there, as its Delta 8 shows.
  • Deltas 11 and 14, which the description didn't mention: both follow from the quote. The opening reaches every request that carries the notice, the host's background requests included, and so moves one cache boundary. The description now says so.

The review, fixed:

  • 1, in 7e5b6fa11c: docs/dendrite.md:272 now says an ephemeral run's speech "isn't asked unless it's held as a draft", as the hook's type doc and the README already said.
  • 2, in 7e5b6fa11c: a place a module names for a piece held as a draft is now logged as it's named (src/framework.ts:23000), as a piece with no channel's places are: [speechRouted] <agent>: <n> chars shown by <places>, held as a draft. So a place named for a piece held after the turn's receipt was written is logged, not lost.
    • Before, a held piece's places went only to the turn's routing, which a written receipt had already taken.
    • "a piece held as a draft that a module showed is logged with its place as it's named, as a piece with no channel is" (test/speech-sent-receipts.test.ts:339) fails with 1a3ef2bfac's src.
  • 3, in 7e5b6fa11c: changelog.d/speech-sent-receipts.changed.md and docs/dendrite.md now say that a piece routed to no channel whose agent was replaced while its modules were asked (src/framework.ts:23766–23769) logs that instead, with no places and no trace. A held piece's places are logged whoever holds the name by then (:23000), and a piece with a channel posts.
  • 4, in 7e5b6fa11c: with 2, dendrite's sentence reads "A place named for a piece held as a draft is also logged as it's named, and so is one named for a piece routed to no channel, unless the agent was replaced while its modules were asked, which is logged instead." A receipt that can't be recorded also logs its places, which adds to that.
  • 5, in 7e5b6fa11c: the test of a hook that returns a promise (test/speech-sent-receipts.test.ts:1322) no longer settles it on a timer. It settles only after the assertions, so a slow run can't settle it early, and a routing that waited on it would hang the turn and fail at the test's timeout.
  • 7, in 7e5b6fa11c: the hook's type doc (src/types/module.ts:226–231), README.md and changelog.d/speech-sent-receipts.added.md now list speech an agent routes or holds once another agent has taken its name among what's never asked about. A piece that starts routing after that isn't routed (src/framework.ts:23757), and a draft hold made after it asks no module (:22991).
  • 8, in ecb9ff7e0f: the two tests the finding names, now at test/speech-sent-receipts.test.ts:1082 and :1108, count exactly one ask. With every hold an explicit send makes asking twice, both passed before and both fail now.

The review, answered in the description: 6, since the call is synchronous, so a slow hook does hold up routing, and 10, the unstick's carry of the turn's channels, which now has its own part there.

Left as it is:

  • 9: we leave it because a round routed before a context-budget restart isn't asked about again after it. In ecb9ff7e0f, the budget-restart test (test/speech-sent-receipts.test.ts:1431) asserts that the round routed before the restart is asked about once, and it passes at this head.

Runs: on Linux, with Node 24.22.2. On 7e5b6fa11c's tree, the build has no type errors, and 1678 tests in the 30 test files run here pass. At ecb9ff7e0fc0, test/speech-sent-receipts.test.ts passes whole. The red runs are as each item says.

Not verified: 3 and 7 rest on reading. The tests use the framework's stand-in provider and stand-in modules, and nothing ran against Discord.

— Effie-2289

Effie-2289 added 3 commits November 9, 2026 21:58
main brings, among much else, the wake scheduler's batch rows (anima-research#972),
the budget wait that holds a refused turn (anima-research#361) and the renamed
rewind notice (anima-research#1000). Where they meet this change, each side is kept:

- InferenceRequest keeps this change's openingMessages beside main's
  readsRows, and MarkedRequest is main's, with BUDGET_WAIT and
  BATCH_ROWS, beside this change's FollowOnMarks type.
- A module's answer. main splits the write a wake reads (wakeAfter,
  which feeds readsRowFor) from the write the gate counts as in the
  window (added, now only a stored write given no audience and not
  held from residents). The opening ids stay collected from every
  stored placement, whatever added's rule says: added decides what the
  gate counts as in the window, which isn't what the speech hook is
  told about the turn's opening.
- The hold's fold of a merged start's own causes. main's wait-marked
  branch comes first, unchanged: a start that carries a wait when no
  member of its batch does, and equals none of them, goes back as its
  own member. Where
  main folds the start's own channels into the batch's first member,
  its own opening ids fold in with them (mergedMarks), and main's wait
  mark then goes to the member equal to the start, as before.
- The quiesce park's keep takes mergedMarks of the pair, which gives
  main's channels and adds the opening ids, then main's batch rows.
  The wakeLines field this change carried at the park and the merged
  start is gone with main's removal of it.
- The merged start keeps this change's openingMessages line beside
  main's readsRows and batch rows.
- docs/dendrite.md keeps this change's paragraph on the hook, with
  main's added start name ("while your turn waited", after a wait for
  compression).
- anima-research#972 added readsRows and the batch rows to what sameInferenceRequest
  compares, and to the test file's identityKeys. The two hold tests
  for opening messages take main's form, as the tests beside them do:
  symbol-keyed records with keys from identityKeys. One of them had
  its own list, naming the removed wakeLines and lacking the two new
  keys; the other no longer typechecked with the symbol in the list.
- anima-research#1000 renamed the rewind notice's tag. The receipts test that checks
  the retry after a rewind reads NOTICE_TAG.refusalRewind instead of
  the old literal, since it's about the retry, not the tag's spelling.
… it as before

Once another agent held an agent's name, the replaced agent's later
plain speech was neither asked about nor routed: routeLocusSpeech
stopped before any route, and deliverHybridEnvelope and
deliverProseEnvelope refused an envelope's send. Only the ask is the
hook's: an answer would stand for the agent that now holds the name.
Posting a piece, an envelope or an explicit send under the name is
main's own delivery, which the hook never asks about.

routeLocusSpeech now skips the ask for a replaced agent's piece, notes
no place, and routes it as main does. The stop after the ask stays for
a module that replaces the agent from inside its own hook, and only for
that: a piece whose agent was already replaced routes. Both envelope
checks and refusedForReplacement are gone, so those sends post as on
main. The .changed fragment's bullet says what remains.

Tests: a replaced agent's piece, with a channel and without, asks no
module and routes under the name, the one with a channel posted. The
tests that pinned the dropped stops go: a hybrid reply's later
envelopes after a module replaced its agent, a no-fallback piece after
the replacement, and explicit routing's envelopes after it.
…fragment

Since the unstick took its marks through markFollowOn as a new turn,
the turn it queues carries the latest turn's channels and opening
messages, as the rewind retries it starts do, and the notice for its
speech names the channels an ambient batch came from. The .changed
fragment didn't 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.

2 participants