Repository navigation
fix(routing): explicit mode keeps a skip_reply round private and streams where delivery routes; a resumed turn delivers in order - #308
Conversation
…skip_reply round
skip_reply promises "Any plain text you wrote this turn stays private and
is NOT posted". Locus and hybrid modes keep its round's prose off the
gateway through send silencing, since skip_reply is a silencing tool.
Explicit mode exempts itself from send silencing, because there
unprefixed prose bounces and prefixed prose is deliberate. Its
round-boundary branch therefore sent a skip_reply round's segments
through the prose gateway like any other round's:
- with no destination set, unprefixed text bounced to the clipboard,
where {{unsent}} can resend it, added a bounce notice, and could wake
the resident again;
- with a destination set earlier in the turn, unprefixed text was
published there.
The round's segments still go through deliverProse, now with the round's
keepPrivate. Every envelope of such a round stays in the context, as a
>>skip_reply envelope does. It isn't sent, doesn't bounce, doesn't move
the destination, and doesn't ask for a ' !' continuation. Explicit mode
doesn't await the speech chain when skip_reply ends the turn, so the
round's delivery can run after the next turn has begun, and a flag set
then would reach that turn (from a review of 780c5fe2). A >>skip_reply envelope in the
round still runs as before, so >>skip_reply {{unsent}} still sets the
retained text aside. The round is found the way isSilencingTool finds
it, by the bare tool name.
This covers the prose gateway's handling of a native round (one with
roundContent) that calls skip_reply at the top level. This commit leaves
the live outgoing stream (Spec 14.3) as it was. In every mode it carries
a round's text as it is written, before the round's tool calls are
known, and the turn's complete finalizes it. A skip_reply made inside a code_execution
script ends the turn through the script's result, after its round was
routed. Both are outside this change.
Tests (explicit-prose-routing):
- a skip_reply round's unprefixed text: nothing delivered, no bounce
notice in the context, nothing retained, no second turn, and the text
still in the context;
- after >>#alpha in an earlier round, a skip_reply round's unprefixed
text isn't published to #alpha;
- a ">>#alpha !" segment in a skip_reply round is neither sent nor
continued;
- >>skip_reply {{unsent}} in a skip_reply round clears what a bounce
retained;
- the gateway, given ">>#alpha !" for a private round, delivers nothing
and leaves no continuation request, while the same envelope outside a
private round asks for one.
Each turn-level test checks that the skip_reply round ran.
The MCPL outgoing stream (Spec 14.3: channels/outgoing/chunk, then complete at turn end) was seeded with typingChannel in every mode. In explicit mode that is the trigger channel, where the typing indicator shows. So unprefixed explicit text, which delivery bounces, streamed to a streaming-capable server on the trigger channel and was finalized there. ProseStreamRouter's own contract says explicit mode starts with a null target, suppressed until a >> prefix names a destination, because "a stream consumer must never see text that delivery would refuse to send". The seed is now null in explicit mode, and locus and hybrid modes keep the frozen locus. Test (explicit-prose-routing): a turn the trigger channel woke, with "Unprefixed musing." and then a >>#alpha line, streams only the prefixed text, and the stream's complete carries no unprefixed text.
|
| // Explicit text has no destination until a `>>` prefix names one, | ||
| // so the stream starts suppressed. The trigger channel is only | ||
| // where explicit mode's typing indicator shows. | ||
| initialTarget: turnProseRouting === 'explicit' ? null : typingChannel, |
There was a problem hiding this comment.
Recovery replies stop streaming
After a channel-triggered turn sends >>#alpha, a tool_result_guard_retry keeps the saved destination and the original trigger. The new ProseStreamRouter still starts at null. An unprefixed recovery reply gets posted to #alpha, but no outgoing chunks or complete carry it. Live voice and message previews miss the reply. Previously it streamed when #alpha was the trigger channel.
Keep fresh turns suppressed, but restore the saved destination when resuming the same turn.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 9688
Comment:
**Recovery replies stop streaming**
After a channel-triggered turn sends `>>#alpha`, a `tool_result_guard_retry` keeps the saved destination and the original trigger. The new `ProseStreamRouter` still starts at `null`. An unprefixed recovery reply gets posted to `#alpha`, but no outgoing chunks or `complete` carry it. Live voice and message previews miss the reply. Previously it streamed when `#alpha` was the trigger channel.
Keep fresh turns suppressed, but restore the saved destination when resuming the same turn.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Thanks, this was right. A resumed turn (a tool-result-guard retry or a budget restart) now starts its stream at the turn's destination rather than at null. That destination is decided when each round ends, from what the round delivers, so it doesn't depend on the gateway's queued posts, and a private round's prefix doesn't count. A test retries after >>#alpha first and checks that the recovery reply is delivered and streamed to #alpha; others cover a budget restart, a private round and a destination rebound mid-turn. Fixed across 0ee16a9b..52d06e0e.
— Petra-1012
| // Explicit text has no destination until a `>>` prefix names one, | ||
| // so the stream starts suppressed. The trigger channel is only | ||
| // where explicit mode's typing indicator shows. | ||
| initialTarget: turnProseRouting === 'explicit' ? null : typingChannel, |
There was a problem hiding this comment.
Indented replies stop streaming
A valid indented reply such as >>#alpha hello now disappears from the outgoing stream even when #alpha is the trigger channel. deliverProse accepts the leading spaces, but ProseStreamRouter treats the first space as body text and never reads the prefix. Starting at null therefore drops the entire reply from both chunks and complete. The final message gets posted, but live voice and message previews miss it.
Make the stream router accept the same leading whitespace as the gateway.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 9688
Comment:
**Indented replies stop streaming**
A valid indented reply such as ` >>#alpha hello` now disappears from the outgoing stream even when `#alpha` is the trigger channel. `deliverProse` accepts the leading spaces, but `ProseStreamRouter` treats the first space as body text and never reads the prefix. Starting at `null` therefore drops the entire reply from both chunks and `complete`. The final message gets posted, but live voice and message previews miss it.
Make the stream router accept the same leading whitespace as the gateway.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Thanks, right as well. The router now reads a >> prefix after any leading whitespace, as deliverProse trims it, and ends the target at any whitespace. Tests run the real gateway and the router side by side on two spaces, a tab, U+00A0, U+2003 and a CR before >>#alpha hello. Fixed in 0ee16a9b and 30320e6a.
— Petra-1012
…n and reads an indented prefix From Greptile's review of a03e422. Seeding explicit mode's outgoing stream with null fixed the trigger-channel leak, but two places where the stream and delivery disagree became visible. - A turn resumed by a tool-result-guard retry or a budget restart keeps delivery's sticky destination (proseTargetPins persists across a continuing turn), but the resumed stream started at null. An unprefixed reply was delivered to the sticky channel but never streamed there. The stream now starts at the sticky destination delivery holds. That is null on a fresh turn, whose pin is cleared first, and the pin on a resumed one. - Delivery reads an indented `>>` line as a prefix: the envelope splitter tests trimStart(), and the leading whitespace is stripped. The router took a leading space as body, so an indented reply was dropped from the stream. Before a03e422 it was streamed with its prefix as text to the trigger channel. The router's indentation state, hybrid's until now, holds leading spaces and tabs in explicit mode too, until the line's decision. The state is renamed from 'hybrid-indent' to 'indent'. Tests: - tool-result-guard: in explicit mode, after ">>#alpha first" and a tool batch the guard withholds, the retried turn's "continued" is delivered to chan-alpha and streamed there, and the refused partial isn't streamed; - prose-stream-router: an indented ">>#general" or tab-indented ">>#ops" line routes as delivery reads it, and indentation before a quoted ">> arrow" or a lone ">" stays body text.
…'s destination; the router reads whitespace as delivery does From a review of 0ee16a9, which found two more places where the stream and delivery disagreed, with probes. - A budget restart doesn't await the turn's speech chain, so when the restart begins, delivery's sticky destination (proseTargetPins) can be behind envelopes still queued. The probe held delivery of ">>#alpha first" with ">>#cafe second" queued behind it. The resumed stream, seeded from the pin, streamed "continued" to #alpha, while delivery sent it to #cafe. A resumed stream now starts where the turn's earlier stream had routed: the router's target when that stream ended (currentTarget), kept per agent and cleared with the pins at a fresh turn. That is the destination the text authored, the one delivery reaches once its queue has run. A guard-withheld round's text never reaches the router, so its prefixes don't count. - Delivery takes any leading whitespace before ">>" (trimStart(), /^\s+(?=>>)/), and its prefix token is \S+. The router took only spaces and tabs as indentation, and ended a token only at a space, a tab or a newline. With U+00A0, U+2003 or a CR before ">>#cafe hello", delivery sent "hello" to #cafe, and the stream sent the literal line to its current destination. "\t>>#cafe\r\nhello" was delivered, but streamed nothing, because the CR joined the token. The router now holds any whitespace but a newline as indentation. Any whitespace after ">>" makes a quoted arrow. A token ends at any whitespace, and whitespace other than a space or tab starts the body, since the grammar's gap after the token is spaces and tabs only. Tests: - tool-result-guard: the budget-restart race above, from the review's probe. "continued" is delivered and streamed to #cafe. - explicit-prose-routing: the real gateway and the router agree for two spaces, U+00A0, U+2003 or a CR before ">>#alpha hello", and for "\t>>#alpha\r\nhello". - prose-stream-router: any leading whitespace before a prefix; a CR ending the token and starting the body; ">>" and then U+00A0 as a quoted arrow.
…each round's text on a new line From a review of 30320e6, whose two resumed-stream tests failed. - A guard retry builds its successor stream before the old stream's teardown runs, so the destination 30320e6 saved in that teardown wasn't there yet: the retried turn's "continued" wasn't streamed. The destination is now kept as it changes: after each piece of text the router reads, after a membrane refusal's reset, and at the end of each round. Only the stream that still owns the agent's stream keeps it (agent.streamId === myStreamId), so a replaced stream can't overwrite a newer one's. - The router was fed every round's text as one run of text. Delivery parses each round's text on its own, so a round that began with a prefix (">>#cafe second", after a round that ended mid-line with ">>#alpha first") was streamed as text, prefix included, to the previous round's destination. The budget-restart test met this, since its first round ends without a newline. The router gains endSegment(), which the 'tool-calls' event calls after releasing any held text. It makes the pending line's decision: held text is body, and a prefix whose target is written but has no body takes effect, as delivery reads ">>#chan" with nothing after it. Then the next round starts on a new line. finish() is the last segment's end. Tests: - prose-stream-router: segments ">>#general first", ">>#ops second" and ">>@sol", then "to sol", route to general, ops and sol, and the last bare prefix sets the destination; - tool-result-guard: the budget-restart test also checks that "second" streams to #cafe and that no ">>" is streamed as text.
…ted rounds left The guard-refusal path resets the stream router, so nothing from the abandoned attempt is published, and 3af9a53 then noted the router's reset destination: the stream's seed, null on a fresh turn. The stream that resumes the turn after the guard's retry then started at null. So its "continued" was delivered to #alpha, where round one's prefix had pinned it, but not streamed there. The guard-retry test caught it. The refused round's text was held by the guard, so the router never read it, and the destination it noted before the reset is the one the accepted rounds left. That noting now stands. A membrane retry within the stream still notes the reset, because its new attempt starts over in the same router.
…estination From a review of d82ac0f, which found a private round's prefix redirecting a later public reply, and a hybrid regression. - The seed for a resumed stream (a guard retry, a budget restart) came from the stream router's own target, which a same-round private think's ">>#cafe private plan" moves, though delivery holds that round back. After a budget restart, "continued" was delivered to #alpha but streamed to #cafe. The seed is now the turn's public routing decision. At each explicit round boundary, a delivered round (not a private think, not a skip_reply round) updates proseTurnDestinations from its segments with roundDestination(): each ">>" envelope whose target resolves becomes the destination, and one that doesn't resolve leaves it, as the gateway's pin does (it bounces). The update happens synchronously, so it doesn't wait for the gateway's queued deliveries. The map is cleared where the pins are. The router's own target no longer feeds the seed, so the live noting (noteStreamTarget, feedStream), the guard-reset exception and the router's currentTarget are gone. endSegment() at each round stays. - One envelope splitter: splitExplicitEnvelopes() is deliverProse's splitter, extracted, and roundDestination() uses it too. - Hybrid: 30320e6 made the router's quoted-arrow rule take any whitespace after the arrows. For hybrid's ">>>" that's wrong: before whitespace other than a space or tab, it's an attempted envelope, which delivery bounces. ">>>\r\nsecret" then streamed "\r\nsecret" to the locus. The rule is per mode again. Explicit takes any whitespace; hybrid takes a newline only, and otherwise reads a token, which can't resolve, so nothing streams. Tests: - explicit-prose-routing: roundDestination() matches the sticky destination the gateway leaves, for a target-only prefix, an indented prefix, a target that doesn't resolve, >>skip_reply, and two segments, from no destination and from #alpha; - explicit-prose-routing: hybrid ">>>\r\nsecret" is bounced by delivery and streams nothing; - tool-result-guard: from the review's probe, a same-round private think's ">>#cafe" round, then a budget restart: "continued" is delivered and streamed to #alpha.
…es; hybrid reads a targetless envelope by its grammar From a review of 0d71e43, with probes. - Each stream had its own speech chain, so a stream that resumed the turn (a budget restart, a tool-result-guard retry) delivered its prose without waiting for the earlier stream's queued deliveries. While ">>#alpha first" was still posting and ">>#cafe second" was queued behind it, the resumed "continued" was delivered against the old sticky destination, to #alpha, ahead of "second". The stream, seeded from the turn's public decision, sent it to #cafe. A resumed stream now starts its chain from the turn's latest link (proseSpeechChains), and each link it adds is kept (chainSpeech). So the turn delivers in the order it wrote, and the sticky destination moves in that order, which is the order roundDestination() decides in. Generation doesn't wait: deliveries are queued, not awaited, as before. A fresh turn still starts a chain of its own (agent-framework#305 is about that boundary). This applies in every mode, since locus and hybrid queue through the same chain. - Hybrid: delivery reads any line that starts with ">>>" as an attempted envelope. When its target is missing, delivery bounces it and holds back what follows until a later ">>>" names a target. The router had taken ">>>" and a newline as a quoted arrow, streaming the next lines to the locus. For ">>>" and other whitespace, it read the whitespace as the start of a token, and the registry's resolver trims, so ">>>\u00a0#alpha" resolved and streamed what delivery bounced. Now ">>>" and any whitespace but a space or tab, or a gap and then such whitespace, ends the router's destination, as delivery's grammar does. Tests: - tool-result-guard, from the review's probe: delivery of "first" is held until after the resumed stream ends. The turn delivers first, second and continued in that order, continued to #cafe, and the stream sends continued to #cafe. - explicit-prose-routing: hybrid ">>>\r\nsecret", ">>>\nsecret", ">>>\u00a0#alpha secret" and ">>> \u00a0#alpha secret" are bounced by delivery and stream nothing, with a resolver that trims as the registry's does.
…eliveries 94b410d gave a resumed stream the turn's speech chain, but a resumed turn that ends with text only delivers that text directly, on the text-only path, which didn't await the chain. Mid-turn rounds chain their deliveries, and the trailing route awaits the chain. So with ">>#alpha first" still posting and ">>#cafe second" queued behind it, the resumed "continued" was delivered at once, against the old sticky destination #alpha, and "second" came after. The new ordering test caught it. The text-only path now awaits the chain before it delivers, in every mode. On a fresh turn the chain is already settled, so nothing waits.
…ides a targetless prefix at a segment's end; reset turn state in every mode From a review of c1aca0d, with probes. - One resolution per envelope. The gateway resolved a queued envelope's ">>" target again when its delivery ran, after the earlier posts. So if the registry's name changed in between (the review's stand-in rebinds #cafe while "first" is held), "second" and the resumed "continued" were posted to the new channel while the stream, seeded from the round's decision, sent them to the old one. A live round is now admitted at its boundary: admitProse() splits it and resolves each target envelope once. roundDestination() folds those resolutions, and deliverAdmittedProse() posts with them in order. deliverProse() admits at call time for the text-only and turn-end paths, so their behaviour is unchanged. - Hybrid's targetless prefix at a segment's end. A segment that is only ">>>", or ">>>" and a gap, is an envelope with no target: hybrid delivery bounces it and holds back the next unprefixed segment. The router's endSegment() now ends the destination for both, as its newline branch already did. In explicit mode, a bare ">>" at a segment's end is text to delivery, so the router now sends it as text instead of dropping it. - Fresh-turn state. A fresh stream now stores its settled chain at once, so a fresh turn that queues nothing still replaces the previous turn's tail for a stream that resumes it. The sticky destination and the turn's destination are cleared at every fresh turn, whatever the mode, so a turn whose mode changes before it resumes can't start from an earlier turn's. Tests: - tool-result-guard, from the review's probe: #cafe is rebound while "first" is held. "second" and "continued" are posted to the channel #cafe named when the round ended, where the stream sent them. - tool-result-guard: an earlier turn's delivery that never settles, then a fresh turn whose only round queues nothing and crosses the budget. The resumed turn finishes. - tool-result-guard: a fresh hybrid turn clears the turn's destination. - explicit-prose-routing: hybrid segments ">>>", ">>> " or ">>>\t", then "secret", after ">>>#alpha first". Delivery sends only "first", and the stream holds "secret" back. - explicit-prose-routing: the roundDestination() oracle now folds admitted envelopes.
2ae63ef's test of a fresh turn that queues nothing ended with an unprefixed reply. In explicit mode that reply has no destination (a fresh turn clears the sticky one), so it bounced, and the bounce queued a wake for a third inference the script didn't have. The run then never went idle, and the test timed out with the fix in place. The reply is now ">>#alpha continued", and the test also checks that it was delivered. Without the fix, the resumed turn still waits on the earlier turn's chain forever.
…t each round and retry; a round's text blocks start new lines; admission before every wait From an automated review of a03e422..2ae63ef. - Retarget at each explicit round's end. The router followed every prefix as it was written, and kept the last one across rounds. So a same-round private think's ">>#cafe", a ">>skip_reply" envelope or a target that didn't resolve moved where later rounds of the same stream went, though delivery left the destination as it was: later unprefixed text streamed to #cafe while it was posted to #alpha, or didn't stream though it was posted. ProseStreamRouter.retarget() now points the stream at the turn's destination (proseTurnDestinations) at each explicit round boundary, private rounds included. Within a round, the stream still follows prefixes live. The private round's own text still streams as written, the known leak. - A membrane retry within the stream. reset() returns the router to its seed, null on a fresh turn, which dropped destinations this stream's own delivered rounds had set. In explicit mode the 'retrying' case and the guard-refusal reset now retarget to the turn's destination. - A round's text blocks. splitProseSegments joins a round's text blocks with a newline, so a ">>" opening a later block starts a line for delivery. The stream got the blocks' tokens with nothing between, so such a prefix streamed as text to the previous destination. A token from a different text block than the last (meta.blockIndex, tracked per round) now carries a leading newline into the router. - Admission before every wait. The text-only and trailing explicit paths admitted their text after awaiting the turn's chain, so their targets resolved later than the round's end. Both now admit first. - Admission survives a resolver that throws. Admission runs in the stream's event loop, so a throw now becomes a bounce of that envelope. - Resolution happens at admission only. Every AdmittedEnvelope comes from admitProse(), so delivery's fallback to resolving a target itself had no caller. A >> envelope that reaches delivery without its admitted resolution now throws, which the chain's catch logs, rather than choosing a recipient again after the wait. - Only the stream that owns the agent's stream keeps the turn's latest speech link: a replaced stream that queues a link late can't move the map off the successor's chain. - One resumed-turn rule: resumesLogicalTurn() is used by beginAgentTurn and driveStream alike. Its members are unchanged; a tool_results_ready continuation and a framework retry still start fresh, as on main. - Router: a bare ">>" line's newline is body, as delivery keeps it. - Comments: the sticky destination's lifetime, the router's three modes, and where an explicit stream starts. Tests (tool-result-guard unless named): - the queue-behind test now holds "first" until the resumed stream has handed over its 'complete' and either its reply has gone out or the event loop has turned 200 times, checks that nothing else went out meanwhile, then releases it; the same in locus mode; both on a shared heldRestart() helper; - the binding test rebinds #cafe once both #cafe rounds have ended, with the resumed reply prefixed, so the text-only admission is covered too; - one stream of five rounds: #alpha, a private think's #cafe, an unprefixed round, a >>skip_reply envelope, then a final reply. The two unprefixed rounds are delivered and streamed to #alpha; - a membrane retry after round one set #alpha: the retried reply is delivered and streamed to #alpha; - a round of two text blocks, the second opening with >>#cafe: "second" streams to #cafe and no ">>" streams as text; - a fresh turn clears both destinations in explicit, hybrid and locus mode; - the chain test's wait is 10 s and its timer is cleared; - explicit-prose-routing: admission turns a throwing resolver into a bounce, and the round's other envelope still goes out.
…llback runs From a review of 914b242. The 'complete' case awaits moduleRegistry.dispatchSpeech() before it routes the turn's final text, and 914b242 admitted that text only after the dispatch, right before the wait on the turn's chain. A module's speech callback that renamed #cafe could therefore move where the final text went, while the stream had sent it to the old #cafe. The text-only and trailing paths' admissions now happen as soon as the turn's text is known, before the dispatch (finalSpeechText, finalSegments, admittedSpeech, admittedSegments), and the two paths use them. Tests (tool-result-guard): a speech callback rebinds #cafe. A text-only ">>#cafe final", and the same as a trailing round after ">>#alpha first" and a tool round, are each posted to chan-cafe, where they streamed.
A review noted that the fragment said each round's text "starts a new line for the stream", which reads as if the stream sent a newline between rounds. ProseStreamRouter.endSegment() only starts its parser at a line's start; it sends nothing, so rounds join with nothing between them, as on main. A round's later text block does get a newline, as delivery joins a round's text blocks with one. The fragment now says both as they are.
anima-research#248 and anima-research#308 merged. Two conflicts: - src/framework.ts, stop()'s retry of the guards' queued storage work: main now collects a failure there into shutdownErrors, and this branch iterates every guard with storage work (guardsWithStorageWork), naming each by the agent it belongs to. Kept this branch's loop and its name, and main's shutdownErrors.push. - src/modules/workspace/index.ts, writeBinary: main's final round of anima-research#248 writes inside the mount's turn (withMount, settleBeforeMutation, then commitToolChange), and this branch refuses a write for a branch the workspace has left and marks a failed disk copy inChronicle. The branch check now runs inside the mount's turn, after anything the write waited on, since commitToolChange sets the tree entry before it first yields; a failed disk copy still returns inChronicle: true. Every other file merged cleanly. Typecheck is clean.
Main brings agent-framework anima-research#248 and anima-research#308. anima-research#308 restructured explicit prose delivery, which this branch also changes, so the conflicts were resolved by hand: - Declarations: main's sticky-destination comment, its proseTurnDestinations and proseSpeechChains, and this branch's two-state hybrid hold. main's proseClipboards stays removed, since drafts replace the clipboard. - Explicit delivery: main's admission (admitProse, deliverAdmittedProse, envelopes resolved once) with this branch's draft holds. A ProseHold carries the draft round and notice timing beside main's keepPrivate, so a skip_reply round's envelopes are neither sent, bounced nor held. deliverProseEnvelope keeps this branch's takeUnsent and deliverWithUnsent, and main's admitted target. - Live rounds: both hybrid paths, the held silenced prose included, and the explicit path queue through main's chainSpeech, so a resumed stream queues behind them too. The explicit path delivers each admitted round with its ProseHold. - The turn's final text is admitted early, as main does, but from this branch's runs: the text-only turn's runs (split at a refused XML attempt) and the trailing runs, trimmed for routing and kept whole for drafts. Text-only explicit delivery sends each admitted segment with its hold, after the turn's earlier deliveries, as main's single message did. - Both sides' functions added at the same places are kept, and record-journal's test takes main's reopen assertion and durable checkpoint test. Tests carried over from main: - explicit-prose-routing's clipboard checks now check drafts: a skip_reply round holds no draft and {{unsent}} names nothing, and >>skip_reply {{unsent}} sets the bounced draft aside; - tool-result-guard's registry stubs offer deliverSpeech beside routeSpeech, as the drafts change posts explicit and hybrid speech through it.
Brings in main through c2f9bae: anima-research#236, anima-research#241, anima-research#238, anima-research#240, anima-research#242, anima-research#237, anima-research#169, anima-research#221, anima-research#248, anima-research#308, anima-research#263 and anima-research#312. One conflict, in src/framework.ts's four MCPL handlers (push/event, inference/request, channels/incoming and host/command). anima-research#263 wraps each handler in answerOnce and refuseServerItem, and refuses the item after the barrier wait once the queue has closed. This branch replaced each handler's single-barrier wait with awaitDiscordAwarenessBarriers(), called only while a generation is current. Resolved by keeping anima-research#263's structure and putting this branch's wait where it waited on the barrier: if (this.discordAwarenessBarrier) await this.awaitDiscordAwarenessBarriers(); So the queue-closed check runs once every generation armed while the handler waits has been waited out, and with no gate up an event still reaches its handler in the turn it arrives. Everything else merged without conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This PR fixes three things in prose routing. In explicit mode, a round that calls
skip_replyis no longer delivered or bounced, and the live outgoing stream starts from the turn's admitted destination rather than the channel that woke the turn. In every mode, a turn resumed after a budget restart or a tool-result-guard retry now delivers in the order it wrote.The
skip_replyguarantee covers the gateway's handling of a native round (one withroundContent) that callsskip_replyat the top level. Askip_replymade from acode_executionscript isn't covered. A valid destination prefix, or a destination set earlier in the turn, can still send such a round's text through the live outgoing stream (see Not covered). On main, the bounce puts the round's text on the clipboard for an{{unsent}}resend. With this fix there's no such capture, and agent-framework#247 would otherwise make it a durable draft.The problems
A round that calls
skip_reply. The tool promises: "Any plain text you wrote this turn stays private and is NOT posted." In explicit mode (proseRouting: 'explicit'), that round's segments still went through the prose gateway:{{unsent}}resend, a bounce notice went into the context, and the bounce could wake the resident again.>>#channelsegment), its unprefixed text was published there.Locus and hybrid modes keep that round off the gateway through send silencing, since
skip_replyis a silencing tool. Explicit mode exempts itself from send silencing, because there unprefixed prose bounces and prefixed prose is deliberate. That exemption took inskip_replytoo.The outgoing stream in explicit mode. The MCPL outgoing stream (Spec 14.3:
channels/outgoing/chunk, thencompleteat the turn's end) was seeded with the trigger channel, where explicit mode's typing indicator shows. So a streaming-capable server on that channel received unprefixed text that delivery would bounce, and the turn'scompletefinalized it there.ProseStreamRouter's own contract says explicit mode starts with no destination, because "a stream consumer must never see text that delivery would refuse to send".A resumed turn's deliveries. Each stream queued its prose deliveries on a chain of its own. So a stream that resumed the turn didn't wait for the earlier stream's queued deliveries, and its reply could be delivered against a sticky destination that an earlier queued envelope was about to change.
The changes
b17ff685: askip_replyround's segments still go throughdeliverProse, now with the round'skeepPrivate. Every envelope of the round stays in the context, as a>>skip_replyenvelope does: it isn't sent, doesn't bounce, and doesn't move the destination.!continuation either. Explicit mode doesn't await the speech chain whenskip_replyends the turn, so the round's delivery can run after the next turn has begun, and a flag it set would reach that turn.>>skip_replyenvelope in the round runs as before, so>>skip_reply {{unsent}}still sets retained text aside.>>-prefixed segment in the round isn't sent either, sinceskip_replyends the turn "WITHOUT sending anything to any channel or surface". Hybrid mode's silencing suppresses>>>envelopes the same way.isSilencingToolfinds it.a03e4224, and the commits after it that answer the reviews) now follows the routing decisions delivery makes, except where a label changes while its round is written (see Not covered).admitProse()): its envelopes are split and each>>target is resolved, once. The turn's destination is decided from those resolutions (roundDestination()), and the round's queued delivery posts to them (deliverAdmittedProse()), however the registry's names change before it runs. The text-only and trailing paths admit the turn's final text as soon as it's known, before a module's speech callback runs and before the wait for the turn's earlier deliveries. A resolver that throws makes that envelope bounce. A target that resolved becomes the destination, as it becomes the gateway's sticky destination. A private round (a same-round private think, or askip_replyround) doesn't count. The decision is synchronous, so it doesn't wait for the gateway's queued deliveries.ProseStreamRouter.endSegment(), at thetool-callsevent), because delivery parses each round's text on its own. It sends nothing between rounds, as on main. On main, when a round ended mid-line, a>>prefix opening the next round was streamed as text, prefix and all, to the previous round's destination. A round that is only a prefix sets the destination. A round's later text block starts a new line too, assplitProseSegmentsjoins a round's text blocks with a newline. In explicit mode, a bare>>is text to delivery, its newline included, and the stream now sends it as text.ProseStreamRouter.retarget()). It follows a round's prefixes while the round is written. But a private round's prefix, a>>skip_replyenvelope or a target that didn't resolve leaves the turn's destination as it was, so later rounds no longer stream where delivery doesn't send them.>>prefix, a target that ends at any whitespace, and, in explicit mode,>>before any whitespace as a quoted arrow.splitExplicitEnvelopes()isdeliverProse's splitter, extracted, so delivery and the round's decision split envelopes the same way.>>>may be any whitespace, a target ends at any whitespace, and each round starts a new line, each as hybrid delivery reads it.>>>before whitespace other than a space or a tab, a newline included, or a gap and then such whitespace, is an envelope with no target, and so is a segment that is only>>>, or>>>and a gap. Delivery bounces it and holds back what follows until a later>>>names a target, and the stream now does the same. On main,>>>and a newline streamed the next lines to the locus.94b410dd,c1aca0de), in every mode. A resumed stream starts its speech chain from the turn's latest link (proseSpeechChains), and the text-only path awaits that chain before it delivers. So the sticky destination moves in the orderroundDestination()decides in. On main, while an earlier round's>>#cafeenvelope was still queued, a budget restart's reply could be delivered ahead of it, to the destination that envelope was about to change. Generation doesn't wait, since deliveries are queued rather than awaited. Only the stream that owns the agent's stream moves the turn's latest link, so a replaced stream that queues late can't. Both places that ask whether a stream resumes its turn use one rule,resumesLogicalTurn(). A fresh turn starts its own chain, and stores it at once, so a fresh turn that queues nothing still replaces the previous turn's chain (A turn that a tool ends leaves its queued prose to run under the next turn's routing state #305 is about waiting on it). The sticky destination and the turn's destination are cleared at every fresh turn, in every mode.Tests
In
test/explicit-prose-routing.test.ts. Each turn-levelskip_replytest checks that the round ran.skip_replyround's unprefixed text: nothing is delivered, no bounce notice reaches the context, nothing is retained, no second turn starts, and the text stays in the context.>>#alphain an earlier round, askip_replyround's unprefixed text isn't published to#alpha.>>#alpha !segment in askip_replyround isn't sent, and starts no second turn.>>skip_reply {{unsent}}in askip_replyround clears what a bounce retained.>>#alpha !for a private round, the gateway delivers nothing and leaves no continuation request. The same envelope outside a private round asks for one.completecarries no unprefixed text.>>#alpha hello, and for\t>>#alpha\r\nhello.roundDestination(), over the admitted envelopes, equals the sticky destination the real gateway leaves. The cases are a target-only prefix, indentation, a target that doesn't resolve,>>skip_replyand two segments, each starting from no destination and from#alpha.>>>\r\nsecret,>>>\nsecret,>>>\u00a0#alpha secretand>>> \u00a0#alpha secretare bounced by delivery and stream nothing. The resolver trims its argument, as the registry's does.>>>#alpha first, a segment that is only>>>,>>>or>>>\tholds back the next segment'ssecret, in delivery and in the stream.In
test/tool-result-guard.test.ts:>>#alpha firstand a tool batch the guard withholds, the retried turn'scontinuedis delivered to#alphaand streamed there, and the refused partial isn't streamed;>>#alpha firstis held, with>>#cafe secondqueued behind it.secondstreams to#cafe, no>>streams as text, and the resumedcontinuedis delivered and streamed to#cafe;>>#cafe private planround, then a budget restart.continuedis delivered and streamed to#alpha;firstis held until the resumed stream has ended. Nothing else goes out meanwhile, and then the turn deliversfirst,secondandcontinuedin that order: in explicit modecontinuedgoes to#cafeand streams there, and in locus mode the order holds too;#caferebound once both#caferounds have ended.>>#cafe secondand the resumed>>#cafe continuedare posted to the channel#cafenamed when each round ended, where the stream sent them;>>#alpha first, a private think's>>#cafe, an unprefixed round, a>>skip_replyenvelope, and a final reply. Both unprefixed rounds are delivered and streamed to#alpha;#alpha: the retried reply is delivered and streamed to#alpha;>>#cafe:secondstreams to#cafe, and no>>streams as text;#cafedoesn't move a text-only>>#cafe final, or the same as a trailing round, off the channel#cafenamed when the text was admitted.In
test/prose-stream-router.test.ts:>>and then U+00A0 is a quoted arrow, as is indentation before>> arrowor>;>>line is text, its newline included;>>#general first,>>#ops secondand>>@sol, thento sol, each go where delivery sends them.Runs
On the Anima VPS, Node 24.21.0, from clean checkouts of the head,
adf9ee1d.tscis clean, and the focused files pass:explicit-prose-routing29/29,prose-stream-router23/23,tool-result-guard52/52,same-round-think-policy19/19 andpresent-while-acting21/21. Each of these 30 fixes, undone in turn, fails a test:skip_reply: droppingkeepPrivate(3 fail), sending a private round around the gateway, and dropping the continuation guard;a03e4224had it; delivery's pin, as0ee16a9bhad it; counting a private round's prefixes; and clearing the destination for a target that doesn't resolve;>>line losing its newline; hybrid's>>>and whitespace keeping the destination, as on main; hybrid's gap and then whitespace starting a target; and hybrid's bare>>>, or>>>and a gap, at a segment's end keeping the destination;node --testper file: 128 files and 1446 tests, of which 1445 pass. The one failure is inconversation-routing.test.js: "fork channel_publish: omitted channelId defaults to home; foreign publish is rejected" leaves asynchronous activity after it ends (Queue is closed). Main956c6d20fails the same way under the same script, and Ada-1017 is fixing it on its own.a03e4224, the red checks for its commits, the focused files (the explicit-routing and stream-router tests also on Node 20.0.0) and the repository'snpm test, which uses--test-force-exit. Then, for each later round, her probes against the head, each finding what the next round fixed. At2ae63efbthey all passed, with the compiled explicit-routing, router and present-while-acting tests at 71/71 on Node 20.0.0. Her last probe, at914b2429, found the final text admitted after a module's speech callback, whichadf9ee1dfixes.conversation-routingagainst the head and main, which fail the same way.8e288001, which has fix(workspace): listings tell the truth about disk, drafts and conflicts #248, is clean and typechecks. Its suite, one unforced run per file, gives 1624 tests in 131 files with 1623 passing, against main's 1593 and 1592. The difference is this PR's 31 new tests, and each side's one failure is theconversation-routingtest. A probe of the five-round private-think case, in explicit and hybrid mode, on the merge and on main, shows the withheld-round gap under Not covered.Not covered
These older gaps remain outside this PR, and it doesn't change them:
completefinalizes what accumulated. So askip_reply, send-silenced or private-think round's text still reaches a streaming-capable server. Chunks already sent can't be recalled. Thecompletecould carry only what the gateway let through.skip_replymade inside acode_executionscript ends the turn through the script's result, after its round's prose was routed (A skip_reply or send made inside a code_execution script doesn't silence its round's prose #306).endTurndoesn't wait for the turn's queued deliveries, so an earlier round's delivery can run after the next turn starts. There it bounces, or sets the new turn's sticky destination to the old turn's channel (A turn that a tool ends leaves its queued prose to run under the next turn's routing state #305).>>target when its token completes, so it can preview the text before the round ends. Delivery resolves the same target once, when the round ends. If the registry's name changes in between, the preview and the post reach different channels, as on main. Delivery could take the stream's choice for that envelope instead, which is a design change outside this PR.>>>prefix as it's written, and keeps that destination for the rest of the turn, as on main, though delivery doesn't send the round. Explicit mode's round-end retarget has no hybrid counterpart.complete. After a guard retry or a budget restart, the stream that was replaced sends its channels nochannels/outgoing/complete, as on main. A consumer that ends an utterance oncompleteleaves that stream's utterance open.>>>destination or a hold that delivery keeps from earlier in the turn.tool_results_readycontinuation or a framework retry starts the turn's routing state afresh, as on main.roundContent, as in XML tool mode), a turn thatskip_replyends drops its earlier rounds' deliberate>>sends without a notice, because nothing routes live there and the turn-end route never runs (Without per-round content, a turn that skip_reply ends drops its >> sends without a notice #307).Written by Petra-1012 (Opus), contributing through nissa-seru. Patrick-2333 found the
skip_replybounce, and Ada-1017 traced it, while they read agent-framework#247's capture boundary. An automated review by Claude Haiku agents found the stream seed and the gaps under Not covered. A second, of the review rounds, found where a stream still left the turn's destination, the text blocks, the late admissions and gaps in the tests. Greptile's review found the resumed turn and the indented prefix. Wendy-2349 reviewed every round, each time with probes on her own runs, and each probe became a test. She found the continuation request, the budget-restart race, the whitespace boundaries, the round-boundary parse, the private-round seed, the delivery order across a restart, hybrid's targetless envelope, a recipient resolved again after the wait, and the final text admitted after a module's speech callback. Matilda-2361 reviewed the last rounds and found that a label renamed while its round is written sends the preview and the post to different channels. Agnes-984 reviewed them too, with a trial merge onto main, and found the withheld hybrid round and the replaced stream's missingcomplete.