From b17ff6854971e12eee33b6cd3004239055d2f58b Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 19:54:27 -0400 Subject: [PATCH 01/14] fix(routing): explicit mode's gateway neither delivers nor bounces a 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. --- .../explicit-skip-reply-private.fixed.md | 14 ++ src/framework.ts | 44 ++++- test/explicit-prose-routing.test.ts | 156 ++++++++++++++++++ 3 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 changelog.d/explicit-skip-reply-private.fixed.md diff --git a/changelog.d/explicit-skip-reply-private.fixed.md b/changelog.d/explicit-skip-reply-private.fixed.md new file mode 100644 index 000000000..582700fc1 --- /dev/null +++ b/changelog.d/explicit-skip-reply-private.fixed.md @@ -0,0 +1,14 @@ +- In explicit prose routing, when a round calls `skip_reply` itself (in + native tool mode, and not from a `code_execution` script), the prose + gateway no longer delivers or bounces that round's plain text. + `skip_reply` promises that "Any plain text you wrote this turn stays + private and is NOT posted". The round's segments used to go through the + gateway like any other round's. Unprefixed text with no destination + bounced: it went onto the clipboard for an `{{unsent}}` resend, got a + bounce notice and could wake the resident again. Unprefixed text after a + destination set earlier in the turn was published there. Now none of that + round's envelopes is delivered or bounced, and none moves the destination + or asks for a continuation. A `>>skip_reply {{unsent}}` in it still sets + the retained text aside. A valid destination prefix, or a destination set + earlier in the turn, can still send the round's text through the live + outgoing stream as it is written. diff --git a/src/framework.ts b/src/framework.ts index f82e3e8df..dcdebe6c4 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -8758,7 +8758,7 @@ export class AgentFramework { * nothing is ever sent to a destination the model did not name (directly * or via the turn's sticky target). */ - private async deliverProse(agent: Agent, rawText: string): Promise { + private async deliverProse(agent: Agent, rawText: string, round?: { keepPrivate?: boolean }): Promise { // Multi-envelope: a single prose block may address SEVERAL destinations — // every line beginning with `>>` opens a new envelope, routed // independently (first live use: Tilde 2026-07-24, one block carrying a @@ -8782,7 +8782,7 @@ export class AgentFramework { for (const envelope of envelopes) { if (!envelope.trim()) continue; - await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, '')); + await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, ''), round); } } @@ -8877,10 +8877,18 @@ export class AgentFramework { } } - private async deliverProseEnvelope(agent: Agent, rawText: string): Promise { + private async deliverProseEnvelope( + agent: Agent, + rawText: string, + round?: { keepPrivate?: boolean }, + ): Promise { const name = agent.name; const parsed = parseProsePrefix(rawText); - if (parsed.continueTurn) this.proseContinuations.add(name); + // A private round (one that called skip_reply) asks for no continuation. + // Its turn ends at the round's tool results, and explicit mode doesn't + // await the speech chain there, so this delivery can run after the next + // turn has begun: a flag set here would reach that turn. + if (parsed.continueTurn && !round?.keepPrivate) this.proseContinuations.add(name); if (parsed.kind === 'private') { // The text already lives in the assistant message (window/chronicle); @@ -8891,6 +8899,18 @@ export class AgentFramework { return; } + if (round?.keepPrivate) { + // skip_reply's promise: "Any plain text you wrote this turn stays + // private and is NOT posted". Every envelope of the round stays in the + // context, as a >>skip_reply envelope does: it isn't sent, doesn't + // bounce to the clipboard and doesn't move the destination. + console.error( + `[prose] ${name}: ${parsed.kind === 'target' ? `>>${parsed.target}` : 'unprefixed'} — ` + + `${parsed.body.length} chars kept in context (skip_reply round)`, + ); + return; + } + let targetChannel: string; let body: string; if (parsed.kind === 'target') { @@ -9911,16 +9931,22 @@ export class AgentFramework { } } } else if (turnProseRouting === 'explicit') { - // Explicit mode: every segment through the prose gateway. - // Silencing does not apply — unprefixed prose bounces and - // prefixed prose is deliberate; think-privacy still holds. + // Explicit mode: the segments go through the prose + // gateway, unless a private think in the round holds them + // back. Send silencing does not apply: unprefixed prose + // bounces, and prefixed prose is deliberate. skip_reply + // does: its round's envelopes all stay in the context + // (deliverProseEnvelope's keepPrivate), as the tool + // promises, and skip_reply then ends the turn at this + // round's tool results. if (!hasSameRoundPrivateThink) { + const keepPrivate = roundToolNames.some((n) => bareToolName(n) === 'skip_reply'); console.error( - `[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway`, + `[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway${keepPrivate ? ', kept private (skip_reply)' : ''}`, ); for (const seg of roundSegments) { turnSpeechChain = turnSpeechChain - .then(() => this.deliverProse(agent, seg)) + .then(() => this.deliverProse(agent, seg, { keepPrivate })) .catch((err) => console.error('mid-turn prose delivery failed:', err)); } } diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 98a000067..321c435bf 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -225,6 +225,162 @@ describe('explicit prose routing', () => { await framework.stop(); }); + /** The registry stub answers skip_reply as the real registry does: the turn ends, and nothing is sent. */ + function answerSkipReply(framework: AgentFramework): { calls: number } { + const answered = { calls: 0 }; + const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; + registry.handleChannelToolCall = async (name: string) => { + if (name === 'skip_reply') answered.calls++; + return { success: true, endTurn: true, data: { skipped: true, note: 'Turn ended; nothing sent.' } }; + }; + return answered; + } + + /** The texts of the agent's context, and the bounce notices among them. */ + function context(framework: AgentFramework): { texts: string[]; bounces: number } { + const messages = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ + content: Array<{ type: string; text?: string }>; + metadata?: { kind?: string }; + }>; + return { + texts: messages.flatMap((m) => m.content.filter((b) => b.type === 'text').map((b) => b.text ?? '')), + bounces: messages.filter((m) => m.metadata?.kind === 'prose-bounce').length, + }; + } + + const clipboardOf = (framework: AgentFramework): string | undefined => + (framework as unknown as { proseClipboards: Map }).proseClipboards.get('assistant'); + + it('a round that calls skip_reply keeps its unprefixed prose private: nothing delivered, bounced, retained or woken for', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Weighing whether this needs an answer from me.' }, + { type: 'tool_use', id: 'sr1', name: 'skip_reply', input: { reason: 'nothing to add' } }, + ] as ContentBlock[], 'tool_use')); + + const framework = await createFramework(); + const routed = stubRegistry(framework); + const skip = answerSkipReply(framework); + // Were the text bounced, its wake would start a second turn: give that + // turn a response, so the test fails on its count instead of hanging. + feedTurn(framework, 2, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + + let turns = 0; + framework.onTrace((e) => { + if ((e as { type: string }).type === 'inference:started') turns++; + }); + + trigger(framework); + await framework.runUntilIdle(); + + assert.equal(skip.calls, 1, 'the skip_reply round ran'); + assert.equal(routed.length, 0, 'nothing is delivered'); + const seen = context(framework); + assert.equal(seen.bounces, 0, 'no bounce notice reaches the context'); + assert.ok(seen.texts.some((t) => t.includes('Weighing whether this needs an answer')), 'the text stays in the context'); + assert.equal(clipboardOf(framework), undefined, 'nothing is retained for {{unsent}}'); + assert.equal(turns, 1, 'no bounce wake starts another turn'); + + await framework.stop(); + }); + + it('a round that calls skip_reply keeps its unprefixed prose from a sticky destination', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#alpha first, deliberately' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Second thoughts I keep to myself.' }, + { type: 'tool_use', id: 'sr2', name: 'skip_reply', input: {} }, + ] as ContentBlock[], 'tool_use')); + + const framework = await createFramework(); + const routed = stubRegistry(framework); + const skip = answerSkipReply(framework); + + trigger(framework); + await framework.runUntilIdle(); + + assert.equal(skip.calls, 1, 'the skip_reply round ran'); + assert.deepEqual(routed, [{ text: 'first, deliberately', locus: 'chan-alpha' }], + 'only the earlier, deliberately addressed segment goes out'); + assert.ok(context(framework).texts.some((t) => t.includes('Second thoughts I keep to myself.')), 'the text stays in the context'); + + await framework.stop(); + }); + + it('a round that calls skip_reply sends nothing, a prefixed segment included, and asks for no continuation', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#alpha ! addressed, then thought better of' }, + { type: 'tool_use', id: 'sr3', name: 'skip_reply', input: {} }, + ] as ContentBlock[], 'tool_use')); + + const framework = await createFramework(); + const routed = stubRegistry(framework); + const skip = answerSkipReply(framework); + // A continuation would start a second turn: give it a response, so the + // test fails on its count instead of hanging. + feedTurn(framework, 2, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + let turns = 0; + framework.onTrace((e) => { + if ((e as { type: string }).type === 'inference:started') turns++; + }); + + trigger(framework); + await framework.runUntilIdle(); + + assert.equal(skip.calls, 1, 'the skip_reply round ran'); + assert.equal(routed.length, 0, 'skip_reply ends the turn without sending anything'); + assert.equal(context(framework).bounces, 0); + assert.equal(turns, 1, "the round's ' !' starts no continuation after skip_reply ended the turn"); + + await framework.stop(); + }); + + it("a private round's ' !' leaves no continuation request behind", async () => { + const framework = await createFramework(); + const routed = stubRegistry(framework); + const internals = framework as unknown as { + deliverProse(agent: unknown, text: string, round?: { keepPrivate?: boolean }): Promise; + proseContinuations: Set; + }; + const agent = framework.getAgent('assistant')!; + + // When skip_reply ends an explicit turn, the speech chain isn't awaited, + // so the round's delivery can run once the next turn has begun. + await internals.deliverProse(agent, '>>#alpha ! kept private', { keepPrivate: true }); + assert.equal(routed.length, 0, 'nothing is delivered'); + assert.equal(internals.proseContinuations.has('assistant'), false, 'and no continuation is asked for'); + + // The same envelope outside a private round asks for one. + await internals.deliverProse(agent, '>>#alpha ! sent'); + assert.equal(routed.length, 1); + assert.equal(internals.proseContinuations.has('assistant'), true); + + await framework.stop(); + }); + + it('a skip_reply round that sets the retained text aside with >>skip_reply {{unsent}} clears it', async () => { + // Turn 1 bounces its text, which retains it and wakes turn 2. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'An answer with nowhere to go.' }] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubRegistry(framework); + const skip = answerSkipReply(framework); + feedTurn(framework, 2, [ + { type: 'text', text: '>>skip_reply {{unsent}}' }, + { type: 'tool_use', id: 'sr4', name: 'skip_reply', input: {} }, + ] as ContentBlock[], 'tool_use'); + + trigger(framework); + await framework.runUntilIdle(); + + assert.equal(skip.calls, 1, 'the bounce wake ran the skip_reply round'); + assert.equal(routed.length, 0); + assert.equal(clipboardOf(framework), undefined, 'the retained text is set aside, as >>skip_reply {{unsent}} says'); + + await framework.stop(); + }); + it("' !' continuation starts another turn immediately after prose ends the current one", async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: '>>#alpha ! step one done, continuing' }, From a03e42248b51e6339704ef5e5ef3be51a25b8461 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 19:54:27 -0400 Subject: [PATCH 02/14] fix(routing): explicit mode's outgoing stream starts with no destination 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-skip-reply-private.fixed.md | 6 +++ src/framework.ts | 5 +- test/explicit-prose-routing.test.ts | 47 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/changelog.d/explicit-skip-reply-private.fixed.md b/changelog.d/explicit-skip-reply-private.fixed.md index 582700fc1..e51e671ac 100644 --- a/changelog.d/explicit-skip-reply-private.fixed.md +++ b/changelog.d/explicit-skip-reply-private.fixed.md @@ -12,3 +12,9 @@ the retained text aside. A valid destination prefix, or a destination set earlier in the turn, can still send the round's text through the live outgoing stream as it is written. +- Explicit prose routing no longer streams unprefixed text to the trigger + channel. The MCPL outgoing stream (`channels/outgoing/chunk` and + `complete`) started with the trigger channel as its destination, so a + streaming-capable server there received text that delivery would bounce. + It now starts with no destination, since explicit text has none until a + `>>` prefix names one. diff --git a/src/framework.ts b/src/framework.ts index dcdebe6c4..3367cca46 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9682,7 +9682,10 @@ export class AgentFramework { !agent.toolWrapperProseGuard ? new ProseStreamRouter({ mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus', - initialTarget: typingChannel, + // 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, resolve: (spec) => { const r = this.channelRegistry!.resolveProseTarget(spec); return 'channelId' in r ? r.channelId : null; diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 321c435bf..e6a12b9b2 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -22,6 +22,7 @@ import type { ToolDefinition, ToolCall, ToolResult, + InferenceRequest, } from '../src/index.js'; import { AgentFramework } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; @@ -236,6 +237,20 @@ describe('explicit prose routing', () => { return answered; } + /** What the registry stub's streaming surface receives (MCPL Spec 14.3 chunk and complete). */ + function captureStream(framework: AgentFramework) { + const chunks: Array<{ channelId: string; delta: string }> = []; + const completes: Array<{ channelId: string; text: string }> = []; + const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; + registry.sendOutgoingChunk = (channelId: string, _agent: string, _id: string, _index: number, delta: string) => { + chunks.push({ channelId, delta }); + }; + registry.sendOutgoingComplete = (channelId: string, _agent: string, _id: string, text: string) => { + completes.push({ channelId, text }); + }; + return { chunks, completes }; + } + /** The texts of the agent's context, and the bounce notices among them. */ function context(framework: AgentFramework): { texts: string[]; bounces: number } { const messages = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ @@ -381,6 +396,38 @@ describe('explicit prose routing', () => { await framework.stop(); }); + it('explicit mode streams nothing before a destination: unprefixed text never reaches the trigger channel', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Unprefixed musing.\n>>#alpha streamed after the prefix' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + stubRegistry(framework); + const stream = captureStream(framework); + // The unprefixed line bounces, and its wake starts a second turn. + feedTurn(framework, 2, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + + // A turn the trigger channel woke: chan-alpha is where its typing shows. + const internals = framework as unknown as { + pendingRequests: InferenceRequest[]; + processInferenceRequests(): Promise; + }; + internals.pendingRequests.push({ + agentName: 'assistant', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), + channelId: 'chan-alpha', addressed: true, + }); + await internals.processInferenceRequests(); + await framework.runUntilIdle(); + + const streamed = stream.chunks.map((c) => c.delta).join(''); + assert.ok(streamed.includes('streamed after the prefix'), 'prefixed text streams to its destination'); + assert.equal(streamed.includes('Unprefixed musing.'), false, 'unprefixed text is never streamed'); + assert.equal(stream.completes.some((c) => c.text.includes('Unprefixed musing.')), false, + 'nor finalized by the stream complete'); + + await framework.stop(); + }); + it("' !' continuation starts another turn immediately after prose ends the current one", async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: '>>#alpha ! step one done, continuing' }, From 0ee16a9b4be47b87865d807d412882d313817650 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 20:15:17 -0400 Subject: [PATCH 03/14] fix(routing): explicit mode's stream resumes at the sticky destination and reads an indented prefix From Greptile's review of a03e4224. 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 a03e4224 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. --- .../explicit-skip-reply-private.fixed.md | 7 ++-- src/framework.ts | 13 ++++--- src/mcpl/prose-stream-router.ts | 17 ++++++---- test/prose-stream-router.test.ts | 11 ++++++ test/tool-result-guard.test.ts | 34 +++++++++++++++++++ 5 files changed, 69 insertions(+), 13 deletions(-) diff --git a/changelog.d/explicit-skip-reply-private.fixed.md b/changelog.d/explicit-skip-reply-private.fixed.md index e51e671ac..78f599c04 100644 --- a/changelog.d/explicit-skip-reply-private.fixed.md +++ b/changelog.d/explicit-skip-reply-private.fixed.md @@ -16,5 +16,8 @@ channel. The MCPL outgoing stream (`channels/outgoing/chunk` and `complete`) started with the trigger channel as its destination, so a streaming-capable server there received text that delivery would bounce. - It now starts with no destination, since explicit text has none until a - `>>` prefix names one. + It now starts where delivery does: with no destination on a fresh turn, + since explicit text has none until a `>>` prefix names one, and at the + turn's sticky destination when a tool-result-guard retry or a budget + restart resumes the turn. The stream also reads an indented `>>` prefix, + as delivery does, rather than streaming the line as text. diff --git a/src/framework.ts b/src/framework.ts index 3367cca46..375121db5 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9682,10 +9682,15 @@ export class AgentFramework { !agent.toolWrapperProseGuard ? new ProseStreamRouter({ mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus', - // 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, + // Explicit text has no destination until a `>>` prefix names one. + // A fresh turn's stream starts suppressed (its sticky destination + // was cleared above), and one that resumes the turn (a tool-result + // guard retry, a budget restart) starts at the sticky destination + // delivery still holds. The trigger channel is only where explicit + // mode's typing indicator shows. + initialTarget: turnProseRouting === 'explicit' + ? this.proseTargetPins.get(agent.name) ?? null + : typingChannel, resolve: (spec) => { const r = this.channelRegistry!.resolveProseTarget(spec); return 'channelId' in r ? r.channelId : null; diff --git a/src/mcpl/prose-stream-router.ts b/src/mcpl/prose-stream-router.ts index e14ad8c3b..51df2f649 100644 --- a/src/mcpl/prose-stream-router.ts +++ b/src/mcpl/prose-stream-router.ts @@ -30,7 +30,7 @@ export interface RoutedDelta { delta: string; } -type State = 'line-start' | 'hybrid-indent' | 'maybe-prefix' | 'prefix-gap' | 'prefix-token' | 'after-token' | 'body'; +type State = 'line-start' | 'indent' | 'maybe-prefix' | 'prefix-gap' | 'prefix-token' | 'after-token' | 'body'; export class ProseStreamRouter { private target: string | null; @@ -87,10 +87,10 @@ export class ProseStreamRouter { * no body — dropped, matching delivery which would deliver empty body). */ finish(): RoutedDelta[] { const out: RoutedDelta[] = []; - if (this.state === 'hybrid-indent') this.emit(this.held, out); + if (this.state === 'indent') this.emit(this.held, out); if (this.state === 'maybe-prefix') { const full = this.opts.mode === 'hybrid' ? '>>>' : '>>'; - const arrows = this.opts.mode === 'hybrid' ? this.held.trimStart() : this.held; + const arrows = this.held.trimStart(); if (arrows !== full) this.emit(this.held, out); } // prefix-token / after-token at EOF: prefix without body — nothing to emit. @@ -111,8 +111,11 @@ export class ProseStreamRouter { return; case 'line-start': - if (this.opts.mode === 'hybrid' && (ch === ' ' || ch === '\t')) { - this.state = 'hybrid-indent'; + // Indentation before a prefix is held until the line's decision: the + // delivery path reads an indented `>>` (explicit) or `>>>` (hybrid) + // line as a prefix too. + if (ch === ' ' || ch === '\t') { + this.state = 'indent'; this.held = ch; return; } @@ -121,7 +124,7 @@ export class ProseStreamRouter { this.step(ch, out); return; - case 'hybrid-indent': + case 'indent': if (ch === ' ' || ch === '\t') { this.held += ch; return; } if (ch === '>') { this.held += ch; this.state = 'maybe-prefix'; return; } this.emit(this.held, out); @@ -132,7 +135,7 @@ export class ProseStreamRouter { case 'maybe-prefix': { const prefix = this.opts.mode === 'hybrid' ? '>>>' : '>>'; - const arrows = this.opts.mode === 'hybrid' ? this.held.trimStart() : this.held; + const arrows = this.held.trimStart(); if (ch === '>' && arrows.length < prefix.length) { this.held += '>'; return; diff --git a/test/prose-stream-router.test.ts b/test/prose-stream-router.test.ts index fa177c918..763d22cac 100644 --- a/test/prose-stream-router.test.ts +++ b/test/prose-stream-router.test.ts @@ -67,6 +67,17 @@ test('unresolvable target suppresses that envelope only', () => { assert.equal(textFor(out, 'discord:g:100'), 'to somewhere'); }); +test('explicit: an indented prefix routes, as delivery reads it', () => { + const out = run(explicit(), ' >>#general indented reply\n\t>>#ops tabbed aside\n plain indented line'); + assert.equal(textFor(out, 'discord:g:100'), 'indented reply\n'); + assert.equal(textFor(out, 'discord:g:200'), 'tabbed aside\n plain indented line'); +}); + +test('explicit: indentation before a quoted arrow or a lone `>` stays body', () => { + const out = run(explicit(), '>>#general look:\n >> quoted arrow\n > quote'); + assert.equal(textFor(out, 'discord:g:100'), 'look:\n >> quoted arrow\n > quote'); +}); + test('` !` continuation modifier is swallowed; `!` as body survives', () => { const out = run(explicit(), '>>#general ! next turn continues\n>>#ops !bang body'); assert.equal(textFor(out, 'discord:g:100'), 'next turn continues\n'); diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index 71ad0787a..da686b222 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -346,6 +346,40 @@ test('native Membrane observes the first refusal even when guard is enabled by a } finally { await framework.stop(); } }); +test('explicit mode: a guard retry streams on to the sticky destination delivery uses', async () => { + const h = await harness([ + [createMockResponse([ + { type: 'text', text: '>>#alpha first' }, + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), refused()], + [answer()], + ], { toolResultGuard: true, proseRouting: 'explicit', refusalHandling: { retries: 3 } }); + const routed: Array<{ text: string; channelId: string }> = []; + const streamed: Array<{ channelId: string; delta: string }> = []; + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } : { error: `no channel ${spec}` }, + routeSpeech: async (_agent: string, speech: string, channelId: string) => { + routed.push({ text: speech, channelId }); return { delivered: true, channelId }; + }, + sendOutgoingChunk: (channelId: string, _agent: string, _id: string, _index: number, delta: string) => { + streamed.push({ channelId, delta }); + }, + getDefaultPublishChannel: () => null, isChannelOpen: () => true, + getDescriptor: () => undefined, getChannelTools: () => [], + }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + try { + await h.run(); + assert.equal(h.membrane.requests.length, 2, 'the guard retried once'); + assert.deepEqual(routed, [ + { text: 'first', channelId: 'chan-alpha' }, + { text: 'continued', channelId: 'chan-alpha' }, + ], 'delivery keeps the sticky destination across the retry'); + const toAlpha = streamed.filter((d) => d.channelId === 'chan-alpha').map((d) => d.delta).join(''); + assert.match(toAlpha, /continued/, 'the resumed stream follows delivery there'); + assert.doesNotMatch(streamed.map((d) => d.delta).join(''), /discard-this-partial-output/); + } finally { await h.framework.stop(); } +}); + test('backward-compatible direct Agent inference also guards tool results', async () => { const h = await harness([], { toolResultGuard: true }); const responses = [calls('one'), refused(), answer()]; From 30320e6a5aa9fedfd3284042bf9d6b95b508b2e3 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 20:27:43 -0400 Subject: [PATCH 04/14] fix(routing): a resumed explicit stream carries on the earlier stream's destination; the router reads whitespace as delivery does From a review of 0ee16a9b, 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. --- .../explicit-skip-reply-private.fixed.md | 11 +-- src/framework.ts | 30 +++++-- src/mcpl/prose-stream-router.ts | 21 ++++- test/explicit-prose-routing.test.ts | 24 ++++++ test/prose-stream-router.test.ts | 17 ++++ test/tool-result-guard.test.ts | 84 +++++++++++++++++++ 6 files changed, 171 insertions(+), 16 deletions(-) diff --git a/changelog.d/explicit-skip-reply-private.fixed.md b/changelog.d/explicit-skip-reply-private.fixed.md index 78f599c04..6020de8c9 100644 --- a/changelog.d/explicit-skip-reply-private.fixed.md +++ b/changelog.d/explicit-skip-reply-private.fixed.md @@ -16,8 +16,9 @@ channel. The MCPL outgoing stream (`channels/outgoing/chunk` and `complete`) started with the trigger channel as its destination, so a streaming-capable server there received text that delivery would bounce. - It now starts where delivery does: with no destination on a fresh turn, - since explicit text has none until a `>>` prefix names one, and at the - turn's sticky destination when a tool-result-guard retry or a budget - restart resumes the turn. The stream also reads an indented `>>` prefix, - as delivery does, rather than streaming the line as text. + It now starts with no destination on a fresh turn, since explicit text + has none until a `>>` prefix names one. When a tool-result-guard retry or + a budget restart resumes the turn, it starts where the turn's earlier + stream had routed. The stream also reads a `>>` prefix after any leading + whitespace, and ends its target at any whitespace, as delivery does, + rather than streaming such a line as text or dropping it. diff --git a/src/framework.ts b/src/framework.ts index 375121db5..2ea17073d 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1083,6 +1083,12 @@ export class AgentFramework { /** Sticky per-TURN delivery target set by the turn's first `>>` prefix. * Cleared at every non-restart turn start; survives context restarts. */ private proseTargetPins: Map = new Map(); + /** + * Explicit mode: where a turn's outgoing stream had routed when it ended, + * so a stream that resumes the turn (a tool-result-guard retry, a budget + * restart) carries on there. Turn-scoped, like the sticky destination. + */ + private proseStreamTargets: Map = new Map(); /** Hybrid router is fail-closed after a malformed/unresolved envelope until a new valid target. */ private proseHybridSuppressed: Set = new Set(); /** Agents whose current turn requested `!` continuation — re-woken when the @@ -9313,6 +9319,7 @@ export class AgentFramework { // Explicit mode uses a turn-scoped `>>` target; disabled mode has no // prose target at all. Neither mode freezes or announces a locus. this.proseTargetPins.delete(agent.name); + this.proseStreamTargets.delete(agent.name); this.proseContinuations.delete(agent.name); this.midTurnInputSignals.delete(agent.name); this.turnLocusPins.delete(agent.name); @@ -9331,6 +9338,7 @@ export class AgentFramework { if (trigger?.suppressProse) { this.turnLocusPins.delete(agent.name); this.proseTargetPins.delete(agent.name); + this.proseStreamTargets.delete(agent.name); this.proseContinuations.delete(agent.name); } @@ -9677,19 +9685,26 @@ export class AgentFramework { // an exact invocation-shaped body from ordinary XML/prose. Buffer guarded // turns until completion: otherwise speculative outgoing chunks could // expose the wrapper before the fail-closed classifier runs. + // A stream that resumes its turn, as beginAgentTurn's continuingTurn: the + // turn-scoped routing state (sticky destination, stream destination) was + // kept for it. + const resumesTurn = trigger?.reason === 'context_budget_restart' + || trigger?.reason === 'tool_result_guard_retry'; const proseStream = this.channelRegistry && turnProseRouting !== 'disabled' && !agent.toolWrapperProseGuard ? new ProseStreamRouter({ mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus', - // Explicit text has no destination until a `>>` prefix names one. - // A fresh turn's stream starts suppressed (its sticky destination - // was cleared above), and one that resumes the turn (a tool-result - // guard retry, a budget restart) starts at the sticky destination - // delivery still holds. The trigger channel is only where explicit - // mode's typing indicator shows. + // Explicit text has no destination until a `>>` prefix names one, + // so a fresh turn's stream starts suppressed. One that resumes the + // turn (a tool-result-guard retry, a budget restart) carries on + // where the previous stream's prefixes had routed. That is the + // destination the text has authored, which delivery's sticky + // destination reaches only once its queued envelopes have run. + // The trigger channel is only where explicit mode's typing + // indicator shows. initialTarget: turnProseRouting === 'explicit' - ? this.proseTargetPins.get(agent.name) ?? null + ? (resumesTurn ? this.proseStreamTargets.get(agent.name) ?? null : null) : typingChannel, resolve: (spec) => { const r = this.channelRegistry!.resolveProseTarget(spec); @@ -10996,6 +11011,7 @@ export class AgentFramework { this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text); } } + if (proseStream) this.proseStreamTargets.set(agent.name, proseStream.currentTarget); this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`); if (ownsPhysicalStream) { this.activeStreams.delete(agent.name); diff --git a/src/mcpl/prose-stream-router.ts b/src/mcpl/prose-stream-router.ts index 51df2f649..b5c09dd22 100644 --- a/src/mcpl/prose-stream-router.ts +++ b/src/mcpl/prose-stream-router.ts @@ -32,6 +32,9 @@ export interface RoutedDelta { type State = 'line-start' | 'indent' | 'maybe-prefix' | 'prefix-gap' | 'prefix-token' | 'after-token' | 'body'; +/** Whitespace as the delivery path reads it (`trimStart()`, `\s`, `\S`). */ +const isSpace = (ch: string): boolean => /\s/.test(ch); + export class ProseStreamRouter { private target: string | null; private state: State = 'line-start'; @@ -59,6 +62,11 @@ export class ProseStreamRouter { return this.accumulated; } + /** Where text goes now: the destination the stream's prefixes have set, or null. */ + get currentTarget(): string | null { + return this.target; + } + /** * Drop everything routed so far and return to the constructed state. * @@ -113,8 +121,8 @@ export class ProseStreamRouter { case 'line-start': // Indentation before a prefix is held until the line's decision: the // delivery path reads an indented `>>` (explicit) or `>>>` (hybrid) - // line as a prefix too. - if (ch === ' ' || ch === '\t') { + // line as a prefix too, after any leading whitespace. + if (ch !== '\n' && isSpace(ch)) { this.state = 'indent'; this.held = ch; return; @@ -125,7 +133,7 @@ export class ProseStreamRouter { return; case 'indent': - if (ch === ' ' || ch === '\t') { this.held += ch; return; } + if (ch !== '\n' && isSpace(ch)) { this.held += ch; return; } if (ch === '>') { this.held += ch; this.state = 'maybe-prefix'; return; } this.emit(this.held, out); this.held = ''; @@ -146,7 +154,9 @@ export class ProseStreamRouter { this.state = 'prefix-gap'; return; } - if (ch === '\n' || ch === ' ' || ch === '\t') { + // `>>` and then whitespace is a quoted arrow: delivery's prefix + // needs a non-whitespace token right after the arrows. + if (isSpace(ch)) { if (this.opts.mode === 'explicit') this.emit(this.held, out); this.held = ''; this.state = ch === '\n' ? 'line-start' : 'body'; @@ -175,6 +185,9 @@ export class ProseStreamRouter { case 'prefix-token': if (ch === ' ' || ch === '\t') { this.applyTarget(); this.state = 'after-token'; this.sawBang = false; return; } if (ch === '\n') { this.applyTarget(); this.state = 'line-start'; return; } + // Any other whitespace ends the token too (`\S+`), and starts the + // body: the grammar's gap after a token is spaces and tabs only. + if (isSpace(ch)) { this.applyTarget(); this.state = 'body'; this.step(ch, out); return; } this.token += ch; return; diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index e6a12b9b2..ed312db9c 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -26,6 +26,7 @@ import type { } from '../src/index.js'; import { AgentFramework } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import { ProseStreamRouter } from '../src/mcpl/prose-stream-router.js'; import type { ContentBlock } from '@animalabs/membrane'; class ToolboxModule implements Module { @@ -428,6 +429,29 @@ describe('explicit prose routing', () => { await framework.stop(); }); + it('the stream reads leading whitespace and a token\'s end as delivery does', async () => { + const framework = await createFramework(); + const routed = stubRegistry(framework); + const gateway = framework as unknown as { deliverProse(agent: unknown, text: string): Promise }; + const agent = framework.getAgent('assistant')!; + const cases = [' >>#alpha hello', '\u00a0>>#alpha hello', '\u2003>>#alpha hello', '\r>>#alpha hello', '\t>>#alpha\r\nhello']; + try { + for (const text of cases) { + routed.length = 0; + await gateway.deliverProse(agent, text); + const router = new ProseStreamRouter({ + mode: 'explicit', initialTarget: null, resolve: (spec) => (spec === '#alpha' ? 'chan-alpha' : null), + }); + const streamed = [...router.feed(text), ...router.finish()]; + assert.deepEqual(routed.map((r) => r.locus), ['chan-alpha'], `delivery sends ${JSON.stringify(text)} to #alpha`); + assert.ok(streamed.length > 0 && streamed.every((d) => d.channelId === 'chan-alpha'), + `the stream sends ${JSON.stringify(text)} there too`); + } + } finally { + await framework.stop(); + } + }); + it("' !' continuation starts another turn immediately after prose ends the current one", async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: '>>#alpha ! step one done, continuing' }, diff --git a/test/prose-stream-router.test.ts b/test/prose-stream-router.test.ts index 763d22cac..8ae0a8407 100644 --- a/test/prose-stream-router.test.ts +++ b/test/prose-stream-router.test.ts @@ -78,6 +78,23 @@ test('explicit: indentation before a quoted arrow or a lone `>` stays body', () assert.equal(textFor(out, 'discord:g:100'), 'look:\n >> quoted arrow\n > quote'); }); +test('explicit: any leading whitespace before a prefix, as delivery trims it', () => { + const out = run(explicit(), '\u00a0>>#general nbsp\n\u2003>>#ops em space\n\r>>@sol after a CR'); + assert.equal(textFor(out, 'discord:g:100'), 'nbsp\n'); + assert.equal(textFor(out, 'discord:g:200'), 'em space\n'); + assert.equal(textFor(out, 'discord:dm:300'), 'after a CR'); +}); + +test('explicit: whitespace other than a space or tab ends the token and starts the body', () => { + const out = run(explicit(), '\t>>#ops\r\nhello'); + assert.equal(textFor(out, 'discord:g:200'), '\r\nhello'); +}); + +test('explicit: `>>` and then any whitespace is a quoted arrow', () => { + const out = run(explicit(), '>>#general look:\n>>\u00a0quoted'); + assert.equal(textFor(out, 'discord:g:100'), 'look:\n>>\u00a0quoted'); +}); + test('` !` continuation modifier is swallowed; `!` as body survives', () => { const out = run(explicit(), '>>#general ! next turn continues\n>>#ops !bang body'); assert.equal(textFor(out, 'discord:g:100'), 'next turn continues\n'); diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index da686b222..f0a1db86d 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -380,6 +380,90 @@ test('explicit mode: a guard retry streams on to the sticky destination delivery } finally { await h.framework.stop(); } }); +test('explicit mode: a budget restart streams on to the destination the turn last addressed, before delivery catches up', { timeout: 15000 }, async () => { + const h = await harness([ + [ + createMockResponse([ + { type: 'text', text: '>>#alpha first' }, + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + createMockResponse([ + { type: 'text', text: '>>#cafe second' }, + { type: 'tool_use', id: 'two', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + ], + [answer()], + ], { proseRouting: 'explicit', maxStreamTokens: 1000 }); + const routed: Array<{ text: string; channelId: string }> = []; + const streamed: Array<{ channelId: string; delta: string }> = []; + // Delivery of 'first' is held until the resumed stream has written + // 'continued', so '>>#cafe second' is still queued, and delivery's sticky + // destination still #alpha, when the restart begins. + let releaseFirst!: () => void; + const firstHeld = new Promise((resolve) => { releaseFirst = resolve; }); + let secondDelivered!: () => void; + const secondDone = new Promise((resolve) => { secondDelivered = resolve; }); + // The second tool call drops the stream budget, so the turn restarts. + const handle = h.module.handleToolCall.bind(h.module); + h.module.handleToolCall = async (call: ToolCall) => { + if (call.id === 'two') h.framework.getAgent('assistant')!.maxStreamTokens = 1; + return handle(call); + }; + const open = h.membrane.streamYielding.bind(h.membrane); + h.membrane.streamYielding = (request: NormalizedRequest, options?: YieldingStreamOptions) => { + const stream = open(request, options); + if (h.membrane.requests.length === 2) { + const iterate = stream[Symbol.asyncIterator].bind(stream); + (stream as unknown as { [Symbol.asyncIterator]: () => AsyncIterator })[Symbol.asyncIterator] = async function* () { + const events = { [Symbol.asyncIterator]: iterate } as AsyncIterable<{ type?: string; content?: unknown }>; + for await (const event of events) { + yield event; + if (event.type === 'tokens' && event.content === 'continued') { + releaseFirst(); + await secondDone; + } + } + }; + } + return stream; + }; + const restarts: string[] = []; + h.framework.onTrace((event) => { + if (event.type === 'inference:stream_restarted') restarts.push(String((event as { reason?: string }).reason)); + }); + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } + : spec === '#cafe' ? { channelId: 'chan-cafe' } : { error: `no channel ${spec}` }, + routeSpeech: async (_agent: string, text: string, channelId: string) => { + routed.push({ text, channelId }); + if (text === 'first') await firstHeld; + if (text === 'second') secondDelivered(); + return { delivered: true, channelId }; + }, + sendOutgoingChunk: (channelId: string, _agent: string, _id: string, _index: number, delta: string) => { + streamed.push({ channelId, delta }); + }, + getDefaultPublishChannel: () => null, isChannelOpen: () => true, + getDescriptor: () => undefined, getChannelTools: () => [], + }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + try { + await h.run(); + assert.equal(h.membrane.requests.length, 2); + assert.ok(restarts.includes('context_budget'), 'the turn restarted for its budget'); + assert.deepEqual(routed, [ + { text: 'first', channelId: 'chan-alpha' }, + { text: 'second', channelId: 'chan-cafe' }, + { text: 'continued', channelId: 'chan-cafe' }, + ]); + assert.deepEqual(streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-cafe'], + 'the resumed stream follows the destination the turn last addressed'); + } finally { + releaseFirst(); + secondDelivered(); + await h.framework.stop(); + } +}); + test('backward-compatible direct Agent inference also guards tool results', async () => { const h = await harness([], { toolResultGuard: true }); const responses = [calls('one'), refused(), answer()]; From 3af9a533c69d3c58042e559f45b571a8f7b007d3 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 20:33:55 -0400 Subject: [PATCH 05/14] fix(routing): explicit stream keeps its destination live, and starts each round's text on a new line From a review of 30320e6a, whose two resumed-stream tests failed. - A guard retry builds its successor stream before the old stream's teardown runs, so the destination 30320e6a 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. --- .../explicit-skip-reply-private.fixed.md | 5 +++- src/framework.ts | 27 ++++++++++++++++--- src/mcpl/prose-stream-router.ts | 22 ++++++++++----- test/prose-stream-router.test.ts | 15 +++++++++++ test/tool-result-guard.test.ts | 3 +++ 5 files changed, 62 insertions(+), 10 deletions(-) diff --git a/changelog.d/explicit-skip-reply-private.fixed.md b/changelog.d/explicit-skip-reply-private.fixed.md index 6020de8c9..d2f2f6f5e 100644 --- a/changelog.d/explicit-skip-reply-private.fixed.md +++ b/changelog.d/explicit-skip-reply-private.fixed.md @@ -21,4 +21,7 @@ a budget restart resumes the turn, it starts where the turn's earlier stream had routed. The stream also reads a `>>` prefix after any leading whitespace, and ends its target at any whitespace, as delivery does, - rather than streaming such a line as text or dropping it. + rather than streaming such a line as text or dropping it. Each round's + text starts a new line for the stream, as delivery parses each round on + its own: a round that began with a `>>` prefix streamed it as text to the + previous round's destination. diff --git a/src/framework.ts b/src/framework.ts index 2ea17073d..60f7744d9 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9719,6 +9719,20 @@ export class AgentFramework { ); } }; + // Explicit mode keeps the stream's destination as it changes, so a stream + // that resumes this turn starts there, however soon after this one it + // starts: a guard retry can begin before this stream's teardown runs. A + // stream another has since replaced keeps nothing. + const noteStreamTarget = (): void => { + if (proseStream && turnProseRouting === 'explicit' && agent.streamId === myStreamId) { + this.proseStreamTargets.set(agent.name, proseStream.currentTarget); + } + }; + const feedStream = (text: string): void => { + if (!proseStream) return; + emitOutgoing(proseStream.feed(text)); + noteStreamTarget(); + }; // Tool-result-guard publication boundary. While a SUBMITTED batch is // pending (or a guard recovery is running), a refusal can still abandon @@ -9732,7 +9746,7 @@ export class AgentFramework { guardHeld = []; for (const item of held) { this.emitTrace(item.trace); - if (proseStream && item.text !== undefined) emitOutgoing(proseStream.feed(item.text)); + if (item.text !== undefined) feedStream(item.text); } }; const discardGuardHeld = (): void => { guardHeld = []; }; @@ -9776,7 +9790,7 @@ export class AgentFramework { guardHeld.push({ trace, text }); } else { this.emitTrace(trace); - if (proseStream && text !== undefined) emitOutgoing(proseStream.feed(text)); + if (text !== undefined) feedStream(text); } break; } @@ -9804,6 +9818,7 @@ export class AgentFramework { outgoingIndex = 0; discardGuardHeld(); proseStream?.reset(); + noteStreamTarget(); this.emitTrace({ type: 'inference:tokens', agentName: agent.name, @@ -9833,6 +9848,12 @@ export class AgentFramework { // preceding results before storing/dispatching this new round. agent.toolResultGuard.accept(); releaseGuardHeld(); + // The round's text ends here: delivery parses each round's text + // on its own, so the next round's text starts a new line. + if (proseStream) { + emitOutgoing(proseStream.endSegment()); + noteStreamTarget(); + } adoptInjectedRound(); hadToolCalls = true; this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length); @@ -10021,6 +10042,7 @@ export class AgentFramework { // persisted as assistant speech, including a terminal refusal // after the single recovery retry. proseStream?.reset(); + noteStreamTarget(); if (withheld) { const usage = response.details?.usage ?? response.usage; const tokenUsage = usage ? { @@ -11011,7 +11033,6 @@ export class AgentFramework { this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text); } } - if (proseStream) this.proseStreamTargets.set(agent.name, proseStream.currentTarget); this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`); if (ownsPhysicalStream) { this.activeStreams.delete(agent.name); diff --git a/src/mcpl/prose-stream-router.ts b/src/mcpl/prose-stream-router.ts index b5c09dd22..3d69d3e20 100644 --- a/src/mcpl/prose-stream-router.ts +++ b/src/mcpl/prose-stream-router.ts @@ -90,10 +90,14 @@ export class ProseStreamRouter { return coalesce(out); } - /** End of stream: flush any held line-start text (it can no longer become a - * complete prefix; an unterminated `>>tok` at EOF is routing metadata with - * no body — dropped, matching delivery which would deliver empty body). */ - finish(): RoutedDelta[] { + /** + * The end of a segment: one round's text, which delivery parses on its + * own. The pending line's decision is made now. Held line-start text can + * no longer become a prefix, so it is body. A prefix whose target is + * written but has no body takes effect, as delivery reads `>>#chan` with + * nothing after it. The next segment starts a new line. + */ + endSegment(): RoutedDelta[] { const out: RoutedDelta[] = []; if (this.state === 'indent') this.emit(this.held, out); if (this.state === 'maybe-prefix') { @@ -101,12 +105,18 @@ export class ProseStreamRouter { const arrows = this.held.trimStart(); if (arrows !== full) this.emit(this.held, out); } - // prefix-token / after-token at EOF: prefix without body — nothing to emit. + if (this.state === 'prefix-token') this.applyTarget(); this.held = ''; - this.state = 'line-start'; + this.token = ''; + if (this.opts.mode !== 'locus') this.state = 'line-start'; return coalesce(out); } + /** End of stream: the last segment ends. */ + finish(): RoutedDelta[] { + return this.endSegment(); + } + private step(ch: string, out: RoutedDelta[]): void { switch (this.state) { case 'body': diff --git a/test/prose-stream-router.test.ts b/test/prose-stream-router.test.ts index 8ae0a8407..0c20cb093 100644 --- a/test/prose-stream-router.test.ts +++ b/test/prose-stream-router.test.ts @@ -95,6 +95,21 @@ test('explicit: `>>` and then any whitespace is a quoted arrow', () => { assert.equal(textFor(out, 'discord:g:100'), 'look:\n>>\u00a0quoted'); }); +test('explicit: each segment starts a new line, and a prefix with no body takes effect at its end', () => { + // Delivery parses each round's text on its own: a round may begin with a + // prefix, and a round that is only a prefix sets the sticky destination. + const r = explicit(); + const out: RoutedDelta[] = []; + out.push(...r.feed('>>#general first'), ...r.endSegment()); + out.push(...r.feed('>>#ops second'), ...r.endSegment()); + out.push(...r.feed('>>@sol'), ...r.endSegment()); + out.push(...r.feed('to sol'), ...r.finish()); + assert.equal(textFor(out, 'discord:g:100'), 'first'); + assert.equal(textFor(out, 'discord:g:200'), 'second'); + assert.equal(textFor(out, 'discord:dm:300'), 'to sol'); + assert.equal(r.currentTarget, 'discord:dm:300'); +}); + test('` !` continuation modifier is swallowed; `!` as body survives', () => { const out = run(explicit(), '>>#general ! next turn continues\n>>#ops !bang body'); assert.equal(textFor(out, 'discord:g:100'), 'next turn continues\n'); diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index f0a1db86d..5c7d135f1 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -455,6 +455,9 @@ test('explicit mode: a budget restart streams on to the destination the turn las { text: 'second', channelId: 'chan-cafe' }, { text: 'continued', channelId: 'chan-cafe' }, ]); + assert.deepEqual(streamed.filter((d) => d.delta.includes('second')).map((d) => d.channelId), ['chan-cafe'], + "the second round's prefix starts a new line in the stream, as delivery reads it"); + assert.equal(streamed.some((d) => d.delta.includes('>>')), false, 'no prefix is streamed as text'); assert.deepEqual(streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-cafe'], 'the resumed stream follows the destination the turn last addressed'); } finally { From d82ac0ffbd839e28ffad8cd569b8c46669d1a164 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 20:39:17 -0400 Subject: [PATCH 06/14] fix(routing): a guard refusal's reset keeps the destination the accepted rounds left The guard-refusal path resets the stream router, so nothing from the abandoned attempt is published, and 3af9a533 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. --- src/framework.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 60f7744d9..b3527191c 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -10040,9 +10040,11 @@ export class AgentFramework { const withheld = agent.toolResultGuard.withhold(category); // Nothing from the abandoned physical attempt is published or // persisted as assistant speech, including a terminal refusal - // after the single recovery retry. + // after the single recovery retry. The destination noted for + // the stream that resumes the turn stays as the accepted rounds + // left it: the refused round's text was held, so the router + // never read it. proseStream?.reset(); - noteStreamTarget(); if (withheld) { const usage = response.details?.usage ?? response.usage; const tokenUsage = usage ? { From 0d71e434c2433ce6bc3e1e2ab5e83337708c726d Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 20:47:28 -0400 Subject: [PATCH 07/14] fix(routing): a resumed explicit stream starts at the turn's public destination From a review of d82ac0ff, 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: 30320e6a 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. --- .../explicit-skip-reply-private.fixed.md | 19 +-- src/framework.ts | 133 ++++++++++-------- src/mcpl/prose-stream-router.ts | 14 +- test/explicit-prose-routing.test.ts | 52 +++++++ test/prose-stream-router.test.ts | 1 - test/tool-result-guard.test.ts | 53 +++++++ 6 files changed, 193 insertions(+), 79 deletions(-) diff --git a/changelog.d/explicit-skip-reply-private.fixed.md b/changelog.d/explicit-skip-reply-private.fixed.md index d2f2f6f5e..e7713df5f 100644 --- a/changelog.d/explicit-skip-reply-private.fixed.md +++ b/changelog.d/explicit-skip-reply-private.fixed.md @@ -16,12 +16,13 @@ channel. The MCPL outgoing stream (`channels/outgoing/chunk` and `complete`) started with the trigger channel as its destination, so a streaming-capable server there received text that delivery would bounce. - It now starts with no destination on a fresh turn, since explicit text - has none until a `>>` prefix names one. When a tool-result-guard retry or - a budget restart resumes the turn, it starts where the turn's earlier - stream had routed. The stream also reads a `>>` prefix after any leading - whitespace, and ends its target at any whitespace, as delivery does, - rather than streaming such a line as text or dropping it. Each round's - text starts a new line for the stream, as delivery parses each round on - its own: a round that began with a `>>` prefix streamed it as text to the - previous round's destination. + It now starts with no destination on a fresh turn, since explicit text has + none until a `>>` prefix names one. When a tool-result-guard retry or a + budget restart resumes the turn, it starts at the destination the turn's + delivered rounds have set, decided at each round's end, so a private + round's prefix doesn't count. The stream also reads a `>>` prefix after + any leading whitespace, and ends its target at any whitespace, as delivery + does, rather than streaming such a line as text or dropping it. Each + round's text starts a new line for the stream, as delivery parses each + round on its own: a round that began with a `>>` prefix streamed it as + text to the previous round's destination. diff --git a/src/framework.ts b/src/framework.ts index b3527191c..3777dde12 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -481,6 +481,31 @@ function withDeferredWriteId(metadata: MessageMetadata | undefined, id: string): return { ...(metadata ?? {}), deferredWriteId: id } as MessageMetadata; } +/** + * A prose block's envelopes, as explicit mode routes them: every line whose + * text, after its leading whitespace, is `>>` and then a non-space opens a + * new envelope (first live use: Tilde 2026-07-24, one block carrying a + * letter to #tilde-mythos AND a `>>#tilde` aside; the single-envelope + * parser delivered the aside literally into the first destination — exactly + * the leak class this mode exists to kill). Text before the first `>>` is + * its own unprefixed envelope. A quoted `>> arrow` (space after) stays body + * text. Blank envelopes are dropped, and an envelope's whitespace before its + * `>>` is trimmed. + */ +function splitExplicitEnvelopes(rawText: string): string[] { + const envelopes: string[] = []; + let current: string[] = []; + for (const line of rawText.split('\n')) { + if (/^>>\S/.test(line.trimStart()) && current.length > 0) { + envelopes.push(current.join('\n')); + current = []; + } + current.push(line); + } + if (current.length > 0) envelopes.push(current.join('\n')); + return envelopes.filter((envelope) => envelope.trim()).map((envelope) => envelope.replace(/^\s+(?=>>)/, '')); +} + function isTurnContinuation(reason: string): boolean { return reason === 'context_budget_restart' || reason === 'tool_results_ready' || reason === 'tool_result_guard_retry'; @@ -1084,11 +1109,13 @@ export class AgentFramework { * Cleared at every non-restart turn start; survives context restarts. */ private proseTargetPins: Map = new Map(); /** - * Explicit mode: where a turn's outgoing stream had routed when it ended, - * so a stream that resumes the turn (a tool-result-guard retry, a budget - * restart) carries on there. Turn-scoped, like the sticky destination. + * Explicit mode: the destination the turn's delivered rounds have set, + * decided at each round boundary (roundDestination). A stream that resumes + * the turn (a tool-result-guard retry, a budget restart) starts there, + * however far the gateway's queued deliveries have got. Turn-scoped, like + * the sticky destination. */ - private proseStreamTargets: Map = new Map(); + private proseTurnDestinations: Map = new Map(); /** Hybrid router is fail-closed after a malformed/unresolved envelope until a new valid target. */ private proseHybridSuppressed: Set = new Set(); /** Agents whose current turn requested `!` continuation — re-woken when the @@ -8765,31 +8792,31 @@ export class AgentFramework { * or via the turn's sticky target). */ private async deliverProse(agent: Agent, rawText: string, round?: { keepPrivate?: boolean }): Promise { - // Multi-envelope: a single prose block may address SEVERAL destinations — - // every line beginning with `>>` opens a new envelope, routed - // independently (first live use: Tilde 2026-07-24, one block carrying a - // letter to #tilde-mythos AND a `>>#tilde` aside; the single-envelope - // parser delivered the aside literally into the first destination — - // exactly the leak class this mode exists to kill). Text before the - // first `>>` is its own unprefixed envelope (sticky/bounce as usual). - const lines = rawText.split('\n'); - const envelopes: string[] = []; - let current: string[] = []; - for (const line of lines) { - // Only a REAL prefix line opens an envelope: `>>` immediately followed - // by a target token. A quoted `>> arrow` (space after) stays body text. - if (/^>>\S/.test(line.trimStart()) && current.length > 0) { - envelopes.push(current.join('\n')); - current = []; - } - current.push(line); + // Multi-envelope: a single prose block may address SEVERAL destinations, + // each envelope routed independently (sticky/bounce as usual). + for (const envelope of splitExplicitEnvelopes(rawText)) { + await this.deliverProseEnvelope(agent, envelope, round); } - if (current.length > 0) envelopes.push(current.join('\n')); + } - for (const envelope of envelopes) { - if (!envelope.trim()) continue; - await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, ''), round); + /** + * Where a delivered round leaves its turn's sticky destination, decided at + * the round boundary, as the explicit gateway sets it once the round's + * envelopes run: each `>>` envelope whose target resolves becomes the + * destination. One that doesn't resolve bounces and leaves it, as do + * `>>skip_reply` and unprefixed text. + */ + private roundDestination(current: string | null, segments: string[]): string | null { + let destination = current; + for (const segment of segments) { + for (const envelope of splitExplicitEnvelopes(segment)) { + const parsed = parseProsePrefix(envelope); + if (parsed.kind !== 'target') continue; + const resolved = this.channelRegistry!.resolveProseTarget(parsed.target!); + if ('channelId' in resolved) destination = resolved.channelId; + } } + return destination; } /** Locus-preserving explicit publication. Source remains byte-identical in Chronicle. */ @@ -9319,7 +9346,7 @@ export class AgentFramework { // Explicit mode uses a turn-scoped `>>` target; disabled mode has no // prose target at all. Neither mode freezes or announces a locus. this.proseTargetPins.delete(agent.name); - this.proseStreamTargets.delete(agent.name); + this.proseTurnDestinations.delete(agent.name); this.proseContinuations.delete(agent.name); this.midTurnInputSignals.delete(agent.name); this.turnLocusPins.delete(agent.name); @@ -9338,7 +9365,7 @@ export class AgentFramework { if (trigger?.suppressProse) { this.turnLocusPins.delete(agent.name); this.proseTargetPins.delete(agent.name); - this.proseStreamTargets.delete(agent.name); + this.proseTurnDestinations.delete(agent.name); this.proseContinuations.delete(agent.name); } @@ -9686,7 +9713,7 @@ export class AgentFramework { // turns until completion: otherwise speculative outgoing chunks could // expose the wrapper before the fail-closed classifier runs. // A stream that resumes its turn, as beginAgentTurn's continuingTurn: the - // turn-scoped routing state (sticky destination, stream destination) was + // turn-scoped routing state (sticky destination, turn destination) was // kept for it. const resumesTurn = trigger?.reason === 'context_budget_restart' || trigger?.reason === 'tool_result_guard_retry'; @@ -9697,14 +9724,13 @@ export class AgentFramework { mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus', // Explicit text has no destination until a `>>` prefix names one, // so a fresh turn's stream starts suppressed. One that resumes the - // turn (a tool-result-guard retry, a budget restart) carries on - // where the previous stream's prefixes had routed. That is the - // destination the text has authored, which delivery's sticky - // destination reaches only once its queued envelopes have run. - // The trigger channel is only where explicit mode's typing - // indicator shows. + // turn (a tool-result-guard retry, a budget restart) starts at the + // destination the turn's delivered rounds have set: a private + // round's prefixes don't count, and the gateway's queued + // deliveries needn't have run. The trigger channel is only where + // explicit mode's typing indicator shows. initialTarget: turnProseRouting === 'explicit' - ? (resumesTurn ? this.proseStreamTargets.get(agent.name) ?? null : null) + ? (resumesTurn ? this.proseTurnDestinations.get(agent.name) ?? null : null) : typingChannel, resolve: (spec) => { const r = this.channelRegistry!.resolveProseTarget(spec); @@ -9719,20 +9745,6 @@ export class AgentFramework { ); } }; - // Explicit mode keeps the stream's destination as it changes, so a stream - // that resumes this turn starts there, however soon after this one it - // starts: a guard retry can begin before this stream's teardown runs. A - // stream another has since replaced keeps nothing. - const noteStreamTarget = (): void => { - if (proseStream && turnProseRouting === 'explicit' && agent.streamId === myStreamId) { - this.proseStreamTargets.set(agent.name, proseStream.currentTarget); - } - }; - const feedStream = (text: string): void => { - if (!proseStream) return; - emitOutgoing(proseStream.feed(text)); - noteStreamTarget(); - }; // Tool-result-guard publication boundary. While a SUBMITTED batch is // pending (or a guard recovery is running), a refusal can still abandon @@ -9746,7 +9758,7 @@ export class AgentFramework { guardHeld = []; for (const item of held) { this.emitTrace(item.trace); - if (item.text !== undefined) feedStream(item.text); + if (proseStream && item.text !== undefined) emitOutgoing(proseStream.feed(item.text)); } }; const discardGuardHeld = (): void => { guardHeld = []; }; @@ -9790,7 +9802,7 @@ export class AgentFramework { guardHeld.push({ trace, text }); } else { this.emitTrace(trace); - if (text !== undefined) feedStream(text); + if (proseStream && text !== undefined) emitOutgoing(proseStream.feed(text)); } break; } @@ -9818,7 +9830,6 @@ export class AgentFramework { outgoingIndex = 0; discardGuardHeld(); proseStream?.reset(); - noteStreamTarget(); this.emitTrace({ type: 'inference:tokens', agentName: agent.name, @@ -9850,10 +9861,7 @@ export class AgentFramework { releaseGuardHeld(); // The round's text ends here: delivery parses each round's text // on its own, so the next round's text starts a new line. - if (proseStream) { - emitOutgoing(proseStream.endSegment()); - noteStreamTarget(); - } + if (proseStream) emitOutgoing(proseStream.endSegment()); adoptInjectedRound(); hadToolCalls = true; this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length); @@ -9985,6 +9993,12 @@ export class AgentFramework { // round's tool results. if (!hasSameRoundPrivateThink) { const keepPrivate = roundToolNames.some((n) => bareToolName(n) === 'skip_reply'); + if (!keepPrivate) { + this.proseTurnDestinations.set( + agent.name, + this.roundDestination(this.proseTurnDestinations.get(agent.name) ?? null, roundSegments), + ); + } console.error( `[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway${keepPrivate ? ', kept private (skip_reply)' : ''}`, ); @@ -10040,10 +10054,7 @@ export class AgentFramework { const withheld = agent.toolResultGuard.withhold(category); // Nothing from the abandoned physical attempt is published or // persisted as assistant speech, including a terminal refusal - // after the single recovery retry. The destination noted for - // the stream that resumes the turn stays as the accepted rounds - // left it: the refused round's text was held, so the router - // never read it. + // after the single recovery retry. proseStream?.reset(); if (withheld) { const usage = response.details?.usage ?? response.usage; diff --git a/src/mcpl/prose-stream-router.ts b/src/mcpl/prose-stream-router.ts index 3d69d3e20..d9ef9d8d3 100644 --- a/src/mcpl/prose-stream-router.ts +++ b/src/mcpl/prose-stream-router.ts @@ -62,11 +62,6 @@ export class ProseStreamRouter { return this.accumulated; } - /** Where text goes now: the destination the stream's prefixes have set, or null. */ - get currentTarget(): string | null { - return this.target; - } - /** * Drop everything routed so far and return to the constructed state. * @@ -164,9 +159,12 @@ export class ProseStreamRouter { this.state = 'prefix-gap'; return; } - // `>>` and then whitespace is a quoted arrow: delivery's prefix - // needs a non-whitespace token right after the arrows. - if (isSpace(ch)) { + // Explicit: `>>` and then whitespace is a quoted arrow, since + // delivery's prefix needs a non-whitespace token right after the + // arrows. Hybrid: `>>>` before a newline is a quoted arrow too, but + // before other whitespace it is an attempted envelope, which + // delivery bounces: its token can't resolve, so nothing streams. + if (this.opts.mode === 'explicit' ? isSpace(ch) : ch === '\n') { if (this.opts.mode === 'explicit') this.emit(this.held, out); this.held = ''; this.state = ch === '\n' ? 'line-start' : 'body'; diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index ed312db9c..106cc664c 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -452,6 +452,58 @@ describe('explicit prose routing', () => { } }); + it("a round's destination, decided at its boundary, is the sticky destination its delivery leaves", async () => { + const framework = await createFramework(); + stubRegistry(framework); + const internals = framework as unknown as { + deliverProse(agent: unknown, text: string): Promise; + roundDestination(current: string | null, segments: string[]): string | null; + proseTargetPins: Map; + }; + const agent = framework.getAgent('assistant')!; + const rounds: string[][] = [ + ['>>#alpha first\n>>#alpha'], + [' >>#alpha indented'], + ['>>#nowhere bounced'], + ['>>skip_reply kept\nand unprefixed'], + ['unprefixed', '>>#alpha second segment'], + ]; + try { + for (const segments of rounds) { + for (const start of [null, 'chan-alpha']) { + internals.proseTargetPins.clear(); + if (start) internals.proseTargetPins.set('assistant', start); + for (const segment of segments) await internals.deliverProse(agent, segment); + assert.equal(internals.roundDestination(start, segments), internals.proseTargetPins.get('assistant') ?? null, + `${JSON.stringify(segments)} from ${start}`); + } + } + } finally { + await framework.stop(); + } + }); + + it("hybrid: a `>>>` envelope with no target before a CR streams nothing, as delivery bounces it", async () => { + const framework = await createFramework('hybrid'); + const routed = stubRegistry(framework); + const gateway = framework as unknown as { + deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean): Promise; + }; + const agent = framework.getAgent('assistant')!; + const text = '>>>\r\nsecret'; + try { + await gateway.deliverHybridProse(agent, text, 'chan-alpha', true); + const router = new ProseStreamRouter({ + mode: 'hybrid', initialTarget: 'chan-alpha', resolve: (spec) => (spec === '#alpha' ? 'chan-alpha' : null), + }); + const streamed = [...router.feed(text), ...router.finish()]; + assert.equal(routed.length, 0, 'delivery bounces the envelope'); + assert.deepEqual(streamed, [], 'and the stream sends none of it'); + } finally { + await framework.stop(); + } + }); + it("' !' continuation starts another turn immediately after prose ends the current one", async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: '>>#alpha ! step one done, continuing' }, diff --git a/test/prose-stream-router.test.ts b/test/prose-stream-router.test.ts index 0c20cb093..4dc725b1b 100644 --- a/test/prose-stream-router.test.ts +++ b/test/prose-stream-router.test.ts @@ -107,7 +107,6 @@ test('explicit: each segment starts a new line, and a prefix with no body takes assert.equal(textFor(out, 'discord:g:100'), 'first'); assert.equal(textFor(out, 'discord:g:200'), 'second'); assert.equal(textFor(out, 'discord:dm:300'), 'to sol'); - assert.equal(r.currentTarget, 'discord:dm:300'); }); test('` !` continuation modifier is swallowed; `!` as body survives', () => { diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index 5c7d135f1..16ea94ba7 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -467,6 +467,59 @@ test('explicit mode: a budget restart streams on to the destination the turn las } }); +test("explicit mode: a private round's prefix doesn't move where a budget restart resumes", { timeout: 15000 }, async () => { + // A same-round private think keeps its round's text out of delivery, so + // its `>>#cafe` sets no destination: the resumed reply goes to #alpha. + const privateRound = createMockResponse([ + { type: 'text', text: '>>#cafe private plan' }, + { type: 'tool_use', id: 'two', name: 'think', input: { content: 'plan privately' } }, + ] as ContentBlock[], 'tool_use'); + privateRound.usage = { inputTokens: 100, outputTokens: 5 }; + const h = await harness([ + [ + createMockResponse([ + { type: 'text', text: '>>#alpha first\n' }, + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + privateRound, + ], + [answer()], + ], { proseRouting: 'explicit', maxStreamTokens: 20, sameRoundThinkTextPolicy: 'private' }); + const routed: Array<{ text: string; channelId: string }> = []; + const streamed: Array<{ channelId: string; delta: string }> = []; + const restarts: string[] = []; + h.framework.onTrace((event) => { + if (event.type === 'inference:stream_restarted') restarts.push(String((event as { reason?: string }).reason)); + }); + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } + : spec === '#cafe' ? { channelId: 'chan-cafe' } : { error: `no channel ${spec}` }, + routeSpeech: async (_agent: string, text: string, channelId: string) => { + routed.push({ text, channelId }); + return { delivered: true, channelId }; + }, + sendOutgoingChunk: (channelId: string, _agent: string, _id: string, _index: number, delta: string) => { + streamed.push({ channelId, delta }); + }, + handleChannelToolCall: async () => ({ success: true, data: { noted: true } }), + getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, + getChannelTools: () => [{ name: 'think', description: 'Reason privately', inputSchema: { type: 'object', properties: { content: { type: 'string' } } } }], + }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + try { + await h.run(); + assert.equal(h.membrane.requests.length, 2); + assert.ok(restarts.includes('context_budget'), 'the turn restarted for its budget'); + assert.deepEqual(routed, [ + { text: 'first', channelId: 'chan-alpha' }, + { text: 'continued', channelId: 'chan-alpha' }, + ], 'the private round moves no destination'); + assert.deepEqual(streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-alpha'], + 'the resumed reply streams where it is delivered'); + } finally { + await h.framework.stop(); + } +}); + test('backward-compatible direct Agent inference also guards tool results', async () => { const h = await harness([], { toolResultGuard: true }); const responses = [calls('one'), refused(), answer()]; From 94b410dd0eea6b99e7a894314a9d14c69f9d3167 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 20:57:11 -0400 Subject: [PATCH 08/14] fix(routing): a resumed turn's deliveries queue behind its earlier ones; hybrid reads a targetless envelope by its grammar From a review of 0d71e434, 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. --- .../explicit-skip-reply-private.fixed.md | 5 ++ src/framework.ts | 45 +++++++----- src/mcpl/prose-stream-router.ts | 22 ++++-- test/explicit-prose-routing.test.ts | 21 +++--- test/tool-result-guard.test.ts | 73 +++++++++++++++++++ 5 files changed, 132 insertions(+), 34 deletions(-) diff --git a/changelog.d/explicit-skip-reply-private.fixed.md b/changelog.d/explicit-skip-reply-private.fixed.md index e7713df5f..6e07f71b4 100644 --- a/changelog.d/explicit-skip-reply-private.fixed.md +++ b/changelog.d/explicit-skip-reply-private.fixed.md @@ -26,3 +26,8 @@ round's text starts a new line for the stream, as delivery parses each round on its own: a round that began with a `>>` prefix streamed it as text to the previous round's destination. +- A turn resumed by a tool-result-guard retry or a budget restart now queues + its prose deliveries behind the turn's earlier ones, in every mode, so the + turn delivers in the order it wrote. Before, a resumed reply could be + delivered ahead of an earlier round's queued envelope, to the destination + that envelope was about to change. diff --git a/src/framework.ts b/src/framework.ts index 3777dde12..54e818561 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1116,6 +1116,11 @@ export class AgentFramework { * the sticky destination. */ private proseTurnDestinations: Map = new Map(); + /** + * The latest link of each agent's chain of queued prose deliveries, so a + * stream that resumes the turn queues behind the earlier streams' ones. + */ + private proseSpeechChains: Map> = new Map(); /** Hybrid router is fail-closed after a malformed/unresolved envelope until a new valid target. */ private proseHybridSuppressed: Set = new Set(); /** Agents whose current turn requested `!` continuation — re-woken when the @@ -9631,14 +9636,27 @@ export class AgentFramework { // awaits the chain before routing trailing prose, so in-channel ordering // is preserved end-to-end. Each link catches its own error: one failed // post must not silence the rest of the turn. - let turnSpeechChain: Promise = Promise.resolve(); + // + // A stream that resumes its turn (as beginAgentTurn's continuingTurn: a + // budget restart, a tool-result-guard retry) keeps the turn's routing + // state, and queues its deliveries behind the earlier streams' ones. So + // the turn's deliveries run in the order its text was written, and the + // sticky destination moves in that order too, whichever stream wrote the + // text and however long an earlier post takes. + const resumesTurn = trigger?.reason === 'context_budget_restart' + || trigger?.reason === 'tool_result_guard_retry'; + let turnSpeechChain: Promise = resumesTurn + ? this.proseSpeechChains.get(agent.name) ?? Promise.resolve() + : Promise.resolve(); + const chainSpeech = (link: () => Promise, failure: string): void => { + turnSpeechChain = turnSpeechChain.then(link).catch((err) => console.error(failure, err)); + this.proseSpeechChains.set(agent.name, turnSpeechChain); + }; const enqueueSpeech = (text: string, locus: string | null): void => { - turnSpeechChain = turnSpeechChain - .then(async () => { - const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus); - this.recordProseDelivery(agent.name, outcome); - }) - .catch((err) => console.error('mid-turn speech routing failed:', err)); + chainSpeech(async () => { + const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus); + this.recordProseDelivery(agent.name, outcome); + }, 'mid-turn speech routing failed:'); }; // Sticky explicit-send suppression: prose after send_message stays quiet @@ -9712,11 +9730,6 @@ export class AgentFramework { // an exact invocation-shaped body from ordinary XML/prose. Buffer guarded // turns until completion: otherwise speculative outgoing chunks could // expose the wrapper before the fail-closed classifier runs. - // A stream that resumes its turn, as beginAgentTurn's continuingTurn: the - // turn-scoped routing state (sticky destination, turn destination) was - // kept for it. - const resumesTurn = trigger?.reason === 'context_budget_restart' - || trigger?.reason === 'tool_result_guard_retry'; const proseStream = this.channelRegistry && turnProseRouting !== 'disabled' && !agent.toolWrapperProseGuard @@ -9977,9 +9990,7 @@ export class AgentFramework { } else if (!hasSameRoundPrivateThink) { const locus = resolveTurnLocus(); for (const seg of roundSegments) { - turnSpeechChain = turnSpeechChain - .then(() => this.deliverHybridProse(agent, seg, locus, true)) - .catch((err) => console.error('mid-turn hybrid prose delivery failed:', err)); + chainSpeech(() => this.deliverHybridProse(agent, seg, locus, true), 'mid-turn hybrid prose delivery failed:'); } } } else if (turnProseRouting === 'explicit') { @@ -10003,9 +10014,7 @@ export class AgentFramework { `[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway${keepPrivate ? ', kept private (skip_reply)' : ''}`, ); for (const seg of roundSegments) { - turnSpeechChain = turnSpeechChain - .then(() => this.deliverProse(agent, seg, { keepPrivate })) - .catch((err) => console.error('mid-turn prose delivery failed:', err)); + chainSpeech(() => this.deliverProse(agent, seg, { keepPrivate }), 'mid-turn prose delivery failed:'); } } } else if (turnSilenced) { diff --git a/src/mcpl/prose-stream-router.ts b/src/mcpl/prose-stream-router.ts index d9ef9d8d3..966b53601 100644 --- a/src/mcpl/prose-stream-router.ts +++ b/src/mcpl/prose-stream-router.ts @@ -159,13 +159,18 @@ export class ProseStreamRouter { this.state = 'prefix-gap'; return; } - // Explicit: `>>` and then whitespace is a quoted arrow, since - // delivery's prefix needs a non-whitespace token right after the - // arrows. Hybrid: `>>>` before a newline is a quoted arrow too, but - // before other whitespace it is an attempted envelope, which - // delivery bounces: its token can't resolve, so nothing streams. - if (this.opts.mode === 'explicit' ? isSpace(ch) : ch === '\n') { - if (this.opts.mode === 'explicit') this.emit(this.held, out); + if (isSpace(ch)) { + if (this.opts.mode === 'explicit') { + // `>>` and then whitespace is a quoted arrow: body text, since + // delivery's prefix needs a target right after the arrows. + this.emit(this.held, out); + } else { + // `>>>` and then whitespace other than a space or tab (a + // newline included) is an envelope with no target. Delivery + // bounces it and holds back what follows until a later `>>>` + // names a target. + this.target = null; + } this.held = ''; this.state = ch === '\n' ? 'line-start' : 'body'; if (ch !== '\n') this.step(ch, out); @@ -186,6 +191,9 @@ export class ProseStreamRouter { case 'prefix-gap': if (ch === ' ' || ch === '\t') return; if (ch === '\n') { this.target = null; this.state = 'line-start'; return; } + // Other whitespace can't start hybrid's target (`[ \t]*\S+`): an + // envelope with no target, as above. + if (isSpace(ch)) { this.target = null; this.state = 'body'; return; } this.state = 'prefix-token'; this.token = ch; return; diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 106cc664c..ec3c0ca86 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -483,22 +483,25 @@ describe('explicit prose routing', () => { } }); - it("hybrid: a `>>>` envelope with no target before a CR streams nothing, as delivery bounces it", async () => { + it('hybrid: a `>>>` envelope with no target streams nothing, as delivery bounces it', async () => { const framework = await createFramework('hybrid'); const routed = stubRegistry(framework); const gateway = framework as unknown as { deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean): Promise; }; const agent = framework.getAgent('assistant')!; - const text = '>>>\r\nsecret'; + // The registry's resolver trims its argument, as the router's must here. + const resolve = (spec: string) => (spec.trim() === '#alpha' ? 'chan-alpha' : null); + const cases = ['>>>\r\nsecret', '>>>\nsecret', '>>>\u00a0#alpha secret', '>>> \u00a0#alpha secret']; try { - await gateway.deliverHybridProse(agent, text, 'chan-alpha', true); - const router = new ProseStreamRouter({ - mode: 'hybrid', initialTarget: 'chan-alpha', resolve: (spec) => (spec === '#alpha' ? 'chan-alpha' : null), - }); - const streamed = [...router.feed(text), ...router.finish()]; - assert.equal(routed.length, 0, 'delivery bounces the envelope'); - assert.deepEqual(streamed, [], 'and the stream sends none of it'); + for (const text of cases) { + routed.length = 0; + await gateway.deliverHybridProse(agent, text, 'chan-locus', true); + const router = new ProseStreamRouter({ mode: 'hybrid', initialTarget: 'chan-locus', resolve }); + const streamed = [...router.feed(text), ...router.finish()]; + assert.equal(routed.length, 0, `delivery bounces ${JSON.stringify(text)}`); + assert.deepEqual(streamed, [], `and the stream sends none of ${JSON.stringify(text)}`); + } } finally { await framework.stop(); } diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index 16ea94ba7..c97cb1fcf 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -520,6 +520,79 @@ test("explicit mode: a private round's prefix doesn't move where a budget restar } }); +test("explicit mode: a resumed stream's deliveries queue behind the turn's earlier ones", { timeout: 15000 }, async () => { + // From a review's probe. Delivery of 'first' is held until after the + // resumed stream has finished, so '>>#cafe second' is still queued behind + // it when the resumed turn delivers 'continued'. Queued behind them, it + // goes to #cafe, where the turn last addressed, as the stream sends it. + const secondRound = createMockResponse([ + { type: 'text', text: '>>#cafe second' }, + { type: 'tool_use', id: 'two', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'); + secondRound.usage = { inputTokens: 100, outputTokens: 5 }; + const h = await harness([ + [ + createMockResponse([ + { type: 'text', text: '>>#alpha first\n' }, + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + secondRound, + ], + [answer()], + ], { proseRouting: 'explicit', maxStreamTokens: 20 }); + const routed: Array<{ text: string; channelId: string }> = []; + const streamed: Array<{ channelId: string; delta: string }> = []; + let releaseFirst!: () => void; + const firstHeld = new Promise((resolve) => { releaseFirst = resolve; }); + const open = h.membrane.streamYielding.bind(h.membrane); + h.membrane.streamYielding = (request: NormalizedRequest, options?: YieldingStreamOptions) => { + const stream = open(request, options); + if (h.membrane.requests.length === 2) { + const iterate = stream[Symbol.asyncIterator].bind(stream); + (stream as unknown as { [Symbol.asyncIterator]: () => AsyncIterator })[Symbol.asyncIterator] = async function* () { + const events = { [Symbol.asyncIterator]: iterate } as AsyncIterable<{ type?: string; content?: unknown }>; + for await (const event of events) { + if (event.type === 'tokens' && event.content === 'continued') setTimeout(releaseFirst, 50); + yield event; + } + }; + } + return stream; + }; + const restarts: string[] = []; + h.framework.onTrace((event) => { + if (event.type === 'inference:stream_restarted') restarts.push(String((event as { reason?: string }).reason)); + }); + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } + : spec === '#cafe' ? { channelId: 'chan-cafe' } : { error: `no channel ${spec}` }, + routeSpeech: async (_agent: string, text: string, channelId: string) => { + routed.push({ text, channelId }); + if (text.trim() === 'first') await firstHeld; + return { delivered: true, channelId }; + }, + sendOutgoingChunk: (channelId: string, _agent: string, _id: string, _index: number, delta: string) => { + streamed.push({ channelId, delta }); + }, + getDefaultPublishChannel: () => null, isChannelOpen: () => true, + getDescriptor: () => undefined, getChannelTools: () => [], + }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + try { + await h.run(); + assert.equal(h.membrane.requests.length, 2); + assert.ok(restarts.includes('context_budget'), 'the turn restarted for its budget'); + assert.deepEqual(routed.map((r) => ({ text: r.text.trim(), channelId: r.channelId })), [ + { text: 'first', channelId: 'chan-alpha' }, + { text: 'second', channelId: 'chan-cafe' }, + { text: 'continued', channelId: 'chan-cafe' }, + ], 'the turn delivers in the order it wrote'); + assert.deepEqual(streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-cafe']); + } finally { + releaseFirst(); + await h.framework.stop(); + } +}); + test('backward-compatible direct Agent inference also guards tool results', async () => { const h = await harness([], { toolResultGuard: true }); const responses = [calls('one'), refused(), answer()]; From c1aca0de33d247cea025e0ee73b253e7f5bd440a Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 21:09:35 -0400 Subject: [PATCH 09/14] fix(routing): a resumed text-only reply waits for the turn's queued deliveries 94b410dd 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. --- src/framework.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/framework.ts b/src/framework.ts index 54e818561..628f79b77 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -10462,6 +10462,9 @@ export class AgentFramework { .join('\n') .trim(); if (speechText) { + // A turn resumed after its earlier rounds delivers after + // them: their queued deliveries are this chain's. + await turnSpeechChain; if (turnProseRouting === 'disabled') { console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`); this.recordProseSuppression(agent.name, 1); From 2ae63efbeabce960dbaf7cdd270918771779907f Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 21:27:53 -0400 Subject: [PATCH 10/14] fix(routing): resolve a round's targets once when it ends; hybrid decides a targetless prefix at a segment's end; reset turn state in every mode From a review of c1aca0de, 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. --- .../explicit-skip-reply-private.fixed.md | 4 +- src/framework.ts | 80 ++++++++---- src/mcpl/prose-stream-router.ts | 9 +- test/explicit-prose-routing.test.ts | 35 +++++- test/tool-result-guard.test.ts | 115 ++++++++++++++++++ 5 files changed, 216 insertions(+), 27 deletions(-) diff --git a/changelog.d/explicit-skip-reply-private.fixed.md b/changelog.d/explicit-skip-reply-private.fixed.md index 6e07f71b4..ccddecd75 100644 --- a/changelog.d/explicit-skip-reply-private.fixed.md +++ b/changelog.d/explicit-skip-reply-private.fixed.md @@ -30,4 +30,6 @@ its prose deliveries behind the turn's earlier ones, in every mode, so the turn delivers in the order it wrote. Before, a resumed reply could be delivered ahead of an earlier round's queued envelope, to the destination - that envelope was about to change. + that envelope was about to change. In explicit mode, a round's `>>` + targets are resolved once, when the round ends, and its queued delivery + posts to those channels. diff --git a/src/framework.ts b/src/framework.ts index 628f79b77..46f23911b 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -506,6 +506,15 @@ function splitExplicitEnvelopes(rawText: string): string[] { return envelopes.filter((envelope) => envelope.trim()).map((envelope) => envelope.replace(/^\s+(?=>>)/, '')); } +/** + * An envelope of explicit prose as its round was admitted: for a `>>` target, + * the registry's answer then, which its delivery uses. + */ +interface AdmittedEnvelope { + text: string; + resolved?: { channelId: string } | { error: string; candidates?: string[] }; +} + function isTurnContinuation(reason: string): boolean { return reason === 'context_budget_restart' || reason === 'tool_results_ready' || reason === 'tool_result_guard_retry'; @@ -8797,29 +8806,46 @@ export class AgentFramework { * or via the turn's sticky target). */ private async deliverProse(agent: Agent, rawText: string, round?: { keepPrivate?: boolean }): Promise { + await this.deliverAdmittedProse(agent, this.admitProse(rawText), round); + } + + /** + * Explicit prose's envelopes, each `>>` target resolved now, once. A live + * round is admitted at its boundary: its turn's destination is decided from + * these resolutions (roundDestination), and its queued delivery posts to + * them, however the registry's names change before it runs. + */ + private admitProse(rawText: string): AdmittedEnvelope[] { + return splitExplicitEnvelopes(rawText).map((text) => { + const parsed = parseProsePrefix(text); + return parsed.kind === 'target' + ? { text, resolved: this.channelRegistry!.resolveProseTarget(parsed.target!) } + : { text }; + }); + } + + private async deliverAdmittedProse( + agent: Agent, + envelopes: AdmittedEnvelope[], + round?: { keepPrivate?: boolean }, + ): Promise { // Multi-envelope: a single prose block may address SEVERAL destinations, // each envelope routed independently (sticky/bounce as usual). - for (const envelope of splitExplicitEnvelopes(rawText)) { + for (const envelope of envelopes) { await this.deliverProseEnvelope(agent, envelope, round); } } /** - * Where a delivered round leaves its turn's sticky destination, decided at - * the round boundary, as the explicit gateway sets it once the round's - * envelopes run: each `>>` envelope whose target resolves becomes the - * destination. One that doesn't resolve bounces and leaves it, as do - * `>>skip_reply` and unprefixed text. + * Where a delivered round leaves its turn's sticky destination, as the + * explicit gateway sets it once the round's envelopes run: each `>>` + * envelope whose target resolved becomes the destination. One that didn't + * resolve bounces and leaves it, as do `>>skip_reply` and unprefixed text. */ - private roundDestination(current: string | null, segments: string[]): string | null { + private roundDestination(current: string | null, envelopes: AdmittedEnvelope[]): string | null { let destination = current; - for (const segment of segments) { - for (const envelope of splitExplicitEnvelopes(segment)) { - const parsed = parseProsePrefix(envelope); - if (parsed.kind !== 'target') continue; - const resolved = this.channelRegistry!.resolveProseTarget(parsed.target!); - if ('channelId' in resolved) destination = resolved.channelId; - } + for (const envelope of envelopes) { + if (envelope.resolved && 'channelId' in envelope.resolved) destination = envelope.resolved.channelId; } return destination; } @@ -8917,10 +8943,11 @@ export class AgentFramework { private async deliverProseEnvelope( agent: Agent, - rawText: string, + envelope: AdmittedEnvelope, round?: { keepPrivate?: boolean }, ): Promise { const name = agent.name; + const rawText = envelope.text; const parsed = parseProsePrefix(rawText); // A private round (one that called skip_reply) asks for no continuation. // Its turn ends at the round's tool results, and explicit mode doesn't @@ -8952,7 +8979,7 @@ export class AgentFramework { let targetChannel: string; let body: string; if (parsed.kind === 'target') { - const res = this.channelRegistry!.resolveProseTarget(parsed.target!); + const res = envelope.resolved ?? this.channelRegistry!.resolveProseTarget(parsed.target!); if ('error' in res) { this.bounceProse(agent, parsed.body, res.error, res.candidates); return; @@ -9345,13 +9372,15 @@ export class AgentFramework { this.turnProseDeliveries.delete(agent.name); this.turnProseSuppressed.delete(agent.name); this.proseHybridSuppressed.delete(agent.name); - if (turnProseRouting === 'hybrid') this.proseTargetPins.delete(agent.name); + // The sticky destination and the turn's destination are turn-scoped in + // every mode, so a turn whose mode changes before it resumes can't + // start from an earlier turn's. + this.proseTargetPins.delete(agent.name); + this.proseTurnDestinations.delete(agent.name); if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') { // Explicit and disabled prose routing have no inferred locus. // Explicit mode uses a turn-scoped `>>` target; disabled mode has no // prose target at all. Neither mode freezes or announces a locus. - this.proseTargetPins.delete(agent.name); - this.proseTurnDestinations.delete(agent.name); this.proseContinuations.delete(agent.name); this.midTurnInputSignals.delete(agent.name); this.turnLocusPins.delete(agent.name); @@ -9648,6 +9677,9 @@ export class AgentFramework { let turnSpeechChain: Promise = resumesTurn ? this.proseSpeechChains.get(agent.name) ?? Promise.resolve() : Promise.resolve(); + // Kept at once, so a fresh turn that queues nothing still replaces the + // previous turn's chain for a stream that resumes it. + this.proseSpeechChains.set(agent.name, turnSpeechChain); const chainSpeech = (link: () => Promise, failure: string): void => { turnSpeechChain = turnSpeechChain.then(link).catch((err) => console.error(failure, err)); this.proseSpeechChains.set(agent.name, turnSpeechChain); @@ -10004,17 +10036,21 @@ export class AgentFramework { // round's tool results. if (!hasSameRoundPrivateThink) { const keepPrivate = roundToolNames.some((n) => bareToolName(n) === 'skip_reply'); + // The round is admitted now: its targets are resolved + // once, for the turn's destination and for its queued + // delivery alike. + const admitted = roundSegments.map((seg) => this.admitProse(seg)); if (!keepPrivate) { this.proseTurnDestinations.set( agent.name, - this.roundDestination(this.proseTurnDestinations.get(agent.name) ?? null, roundSegments), + this.roundDestination(this.proseTurnDestinations.get(agent.name) ?? null, admitted.flat()), ); } console.error( `[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway${keepPrivate ? ', kept private (skip_reply)' : ''}`, ); - for (const seg of roundSegments) { - chainSpeech(() => this.deliverProse(agent, seg, { keepPrivate }), 'mid-turn prose delivery failed:'); + for (const envelopes of admitted) { + chainSpeech(() => this.deliverAdmittedProse(agent, envelopes, { keepPrivate }), 'mid-turn prose delivery failed:'); } } } else if (turnSilenced) { diff --git a/src/mcpl/prose-stream-router.ts b/src/mcpl/prose-stream-router.ts index 966b53601..a852a9b67 100644 --- a/src/mcpl/prose-stream-router.ts +++ b/src/mcpl/prose-stream-router.ts @@ -96,10 +96,15 @@ export class ProseStreamRouter { const out: RoutedDelta[] = []; if (this.state === 'indent') this.emit(this.held, out); if (this.state === 'maybe-prefix') { - const full = this.opts.mode === 'hybrid' ? '>>>' : '>>'; + // Hybrid's `>>>` with nothing after it is an envelope with no target: + // delivery bounces it and holds back what follows. Anything else held + // is text to delivery, explicit's bare `>>` included. const arrows = this.held.trimStart(); - if (arrows !== full) this.emit(this.held, out); + if (this.opts.mode === 'hybrid' && arrows === '>>>') this.target = null; + else this.emit(this.held, out); } + // Hybrid's `>>>` and a gap with nothing after it: no target either. + if (this.state === 'prefix-gap') this.target = null; if (this.state === 'prefix-token') this.applyTarget(); this.held = ''; this.token = ''; diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index ec3c0ca86..0927ad605 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -457,7 +457,8 @@ describe('explicit prose routing', () => { stubRegistry(framework); const internals = framework as unknown as { deliverProse(agent: unknown, text: string): Promise; - roundDestination(current: string | null, segments: string[]): string | null; + admitProse(text: string): unknown[]; + roundDestination(current: string | null, envelopes: unknown[]): string | null; proseTargetPins: Map; }; const agent = framework.getAgent('assistant')!; @@ -474,7 +475,8 @@ describe('explicit prose routing', () => { internals.proseTargetPins.clear(); if (start) internals.proseTargetPins.set('assistant', start); for (const segment of segments) await internals.deliverProse(agent, segment); - assert.equal(internals.roundDestination(start, segments), internals.proseTargetPins.get('assistant') ?? null, + const admitted = segments.flatMap((segment) => internals.admitProse(segment)); + assert.equal(internals.roundDestination(start, admitted), internals.proseTargetPins.get('assistant') ?? null, `${JSON.stringify(segments)} from ${start}`); } } @@ -507,6 +509,35 @@ describe('explicit prose routing', () => { } }); + it('hybrid: a segment that is only `>>>`, or `>>>` and a gap, holds back the next segment, as delivery does', async () => { + const framework = await createFramework('hybrid'); + const routed = stubRegistry(framework); + const gateway = framework as unknown as { + deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean): Promise; + }; + const agent = framework.getAgent('assistant')!; + const resolve = (spec: string) => (spec.trim() === '#alpha' ? 'chan-alpha' : null); + try { + for (const prefix of ['>>>', '>>> ', '>>>\t']) { + routed.length = 0; + await gateway.deliverHybridProse(agent, '>>>#alpha first', 'chan-locus', true); + await gateway.deliverHybridProse(agent, prefix, 'chan-locus', true); + await gateway.deliverHybridProse(agent, 'secret', 'chan-locus', true); + const router = new ProseStreamRouter({ mode: 'hybrid', initialTarget: 'chan-locus', resolve }); + const streamed = [ + ...router.feed('>>>#alpha first'), ...router.endSegment(), + ...router.feed(prefix), ...router.endSegment(), + ...router.feed('secret'), ...router.finish(), + ]; + assert.deepEqual(routed.map((r) => r.text), ['first'], `delivery sends only first, after ${JSON.stringify(prefix)}`); + assert.equal(streamed.filter((d) => d.delta.includes('secret')).length, 0, + `and the stream holds secret back after ${JSON.stringify(prefix)}`); + } + } finally { + await framework.stop(); + } + }); + it("' !' continuation starts another turn immediately after prose ends the current one", async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: '>>#alpha ! step one done, continuing' }, diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index c97cb1fcf..1256e93b7 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -593,6 +593,121 @@ test("explicit mode: a resumed stream's deliveries queue behind the turn's earli } }); +test("explicit mode: a round's targets are resolved once, when it ends, for its delivery and the resumed stream alike", { timeout: 15000 }, async () => { + // From a review's probe. While delivery of 'first' is held, the registry's + // #cafe starts naming another channel. The round that wrote '>>#cafe second' + // had already ended, so its delivery and the resumed reply both go to the + // channel #cafe named then, where the stream sent them. + const secondRound = createMockResponse([ + { type: 'text', text: '>>#cafe second' }, + { type: 'tool_use', id: 'two', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'); + secondRound.usage = { inputTokens: 100, outputTokens: 5 }; + const h = await harness([ + [ + createMockResponse([ + { type: 'text', text: '>>#alpha first\n' }, + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + secondRound, + ], + [answer()], + ], { proseRouting: 'explicit', maxStreamTokens: 20 }); + let cafe = 'chan-cafe'; + const routed: Array<{ text: string; channelId: string }> = []; + const streamed: Array<{ channelId: string; delta: string }> = []; + let releaseFirst!: () => void; + const firstHeld = new Promise((resolve) => { releaseFirst = resolve; }); + const open = h.membrane.streamYielding.bind(h.membrane); + h.membrane.streamYielding = (request: NormalizedRequest, options?: YieldingStreamOptions) => { + const stream = open(request, options); + if (h.membrane.requests.length === 2) { + const iterate = stream[Symbol.asyncIterator].bind(stream); + (stream as unknown as { [Symbol.asyncIterator]: () => AsyncIterator })[Symbol.asyncIterator] = async function* () { + const events = { [Symbol.asyncIterator]: iterate } as AsyncIterable<{ type?: string; content?: unknown }>; + for await (const event of events) { + yield event; + if (event.type === 'tokens' && event.content === 'continued') { + cafe = 'chan-rebound'; + releaseFirst(); + } + } + }; + } + return stream; + }; + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } + : spec === '#cafe' ? { channelId: cafe } : { error: `no channel ${spec}` }, + routeSpeech: async (_agent: string, text: string, channelId: string) => { + routed.push({ text, channelId }); + if (text.trim() === 'first') await firstHeld; + return { delivered: true, channelId }; + }, + sendOutgoingChunk: (channelId: string, _agent: string, _id: string, _index: number, delta: string) => { + streamed.push({ channelId, delta }); + }, + getDefaultPublishChannel: () => null, isChannelOpen: () => true, + getDescriptor: () => undefined, getChannelTools: () => [], + }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + try { + await h.run(); + assert.equal(h.membrane.requests.length, 2); + assert.deepEqual(routed.map((r) => ({ text: r.text.trim(), channelId: r.channelId })), [ + { text: 'first', channelId: 'chan-alpha' }, + { text: 'second', channelId: 'chan-cafe' }, + { text: 'continued', channelId: 'chan-cafe' }, + ]); + assert.deepEqual(streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-cafe']); + } finally { + releaseFirst(); + await h.framework.stop(); + } +}); + +test("a fresh turn that delivers nothing still replaces an earlier turn's speech chain for its restart", { timeout: 15000 }, async () => { + // An earlier turn's delivery that never settles: a stream resuming this + // turn must not queue behind it. + const quiet = createMockResponse([ + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'); + quiet.usage = { inputTokens: 100, outputTokens: 5 }; + const h = await harness([[quiet], [answer()]], { proseRouting: 'explicit', maxStreamTokens: 20 }); + (h.framework as unknown as { proseSpeechChains: Map> }) + .proseSpeechChains.set('assistant', new Promise(() => {})); + const routed: string[] = []; + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } : { error: `no channel ${spec}` }, + routeSpeech: async (_agent: string, text: string, channelId: string) => { + routed.push(text); + return { delivered: true, channelId }; + }, + getDefaultPublishChannel: () => null, isChannelOpen: () => true, + getDescriptor: () => undefined, getChannelTools: () => [], + }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + try { + const run = h.run(); + const timeout = new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 3000)); + assert.notEqual(await Promise.race([run.then(() => 'done' as const), timeout]), 'timeout', + 'the resumed turn finishes'); + assert.equal(h.membrane.requests.length, 2); + } finally { + await h.framework.stop(); + } +}); + +test("a fresh turn clears the turn's destination in every mode", async () => { + const h = await harness([[answer()]], { proseRouting: 'hybrid' }); + const internals = h.framework as unknown as { proseTurnDestinations: Map }; + internals.proseTurnDestinations.set('assistant', 'chan-earlier'); + try { + await h.run(); + assert.equal(internals.proseTurnDestinations.has('assistant'), false); + } finally { + await h.framework.stop(); + } +}); + test('backward-compatible direct Agent inference also guards tool results', async () => { const h = await harness([], { toolResultGuard: true }); const responses = [calls('one'), refused(), answer()]; From 10b936c4ba8a7691cc23a912ccf424da67fdf4d4 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 21:38:53 -0400 Subject: [PATCH 11/14] test(routing): the fresh-turn chain test's reply names its destination 2ae63efb'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. --- test/tool-result-guard.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index 1256e93b7..f1b852da0 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -672,7 +672,9 @@ test("a fresh turn that delivers nothing still replaces an earlier turn's speech { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, ] as ContentBlock[], 'tool_use'); quiet.usage = { inputTokens: 100, outputTokens: 5 }; - const h = await harness([[quiet], [answer()]], { proseRouting: 'explicit', maxStreamTokens: 20 }); + // The reply names its destination, so nothing bounces into another turn. + const reply = createMockResponse([{ type: 'text', text: '>>#alpha continued' }] as ContentBlock[], 'end_turn'); + const h = await harness([[quiet], [reply]], { proseRouting: 'explicit', maxStreamTokens: 20 }); (h.framework as unknown as { proseSpeechChains: Map> }) .proseSpeechChains.set('assistant', new Promise(() => {})); const routed: string[] = []; @@ -691,6 +693,7 @@ test("a fresh turn that delivers nothing still replaces an earlier turn's speech assert.notEqual(await Promise.race([run.then(() => 'done' as const), timeout]), 'timeout', 'the resumed turn finishes'); assert.equal(h.membrane.requests.length, 2); + assert.deepEqual(routed.map((text) => text.trim()), ['continued']); } finally { await h.framework.stop(); } From 914b24298de10def45a107c5e61e21f1105d293b Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 21:49:58 -0400 Subject: [PATCH 12/14] fix(routing): the explicit stream returns to the turn's destination at each round and retry; a round's text blocks start new lines; admission before every wait From an automated review of a03e4224..2ae63efb. - 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. --- .../explicit-skip-reply-private.fixed.md | 7 +- src/framework.ts | 78 +++- src/mcpl/prose-stream-router.ts | 29 +- test/explicit-prose-routing.test.ts | 23 ++ test/prose-stream-router.test.ts | 6 + test/tool-result-guard.test.ts | 340 +++++++++++++----- 6 files changed, 371 insertions(+), 112 deletions(-) diff --git a/changelog.d/explicit-skip-reply-private.fixed.md b/changelog.d/explicit-skip-reply-private.fixed.md index ccddecd75..89a74b3f7 100644 --- a/changelog.d/explicit-skip-reply-private.fixed.md +++ b/changelog.d/explicit-skip-reply-private.fixed.md @@ -25,7 +25,12 @@ does, rather than streaming such a line as text or dropping it. Each round's text starts a new line for the stream, as delivery parses each round on its own: a round that began with a `>>` prefix streamed it as - text to the previous round's destination. + text to the previous round's destination. A round's later text block + starts a new line too, as delivery joins a round's text blocks with a + newline. At each round's end, and when the membrane retries a refused + attempt, the stream returns to the turn's destination, so a private + round's prefix, a `>>skip_reply` envelope or a target that didn't resolve + no longer moves where later rounds stream. - A turn resumed by a tool-result-guard retry or a budget restart now queues its prose deliveries behind the turn's earlier ones, in every mode, so the turn delivers in the order it wrote. Before, a resumed reply could be diff --git a/src/framework.ts b/src/framework.ts index 46f23911b..b75733f0a 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -515,6 +515,16 @@ interface AdmittedEnvelope { resolved?: { channelId: string } | { error: string; candidates?: string[] }; } +/** + * Whether a stream resumes its logical turn rather than starting one: a budget + * restart, or a tool-result-guard retry. Such a stream keeps the turn's + * routing state (the sticky destination, the turn's destination) and queues + * its deliveries behind the turn's earlier ones. + */ +function resumesLogicalTurn(reason: string | undefined): boolean { + return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry'; +} + function isTurnContinuation(reason: string): boolean { return reason === 'context_budget_restart' || reason === 'tool_results_ready' || reason === 'tool_result_guard_retry'; @@ -1114,8 +1124,9 @@ export class AgentFramework { // ---- Explicit prose routing (docs/explicit-prose-routing.md) ---------- /** Latest bounced (undelivered) prose per agent — the `{{unsent}}` source. */ private proseClipboards: Map = new Map(); - /** Sticky per-TURN delivery target set by the turn's first `>>` prefix. - * Cleared at every non-restart turn start; survives context restarts. */ + /** Sticky per-TURN delivery target, set by each `>>` prefix that resolves. + * Cleared at every fresh turn start, and on a silent wake; a stream that + * resumes the turn (resumesLogicalTurn) keeps it. */ private proseTargetPins: Map = new Map(); /** * Explicit mode: the destination the turn's delivered rounds have set, @@ -8818,9 +8829,14 @@ export class AgentFramework { private admitProse(rawText: string): AdmittedEnvelope[] { return splitExplicitEnvelopes(rawText).map((text) => { const parsed = parseProsePrefix(text); - return parsed.kind === 'target' - ? { text, resolved: this.channelRegistry!.resolveProseTarget(parsed.target!) } - : { text }; + if (parsed.kind !== 'target') return { text }; + try { + return { text, resolved: this.channelRegistry!.resolveProseTarget(parsed.target!) }; + } catch (err) { + // Admission runs in the stream's event loop, so a resolver that + // throws makes the envelope bounce rather than stop the round. + return { text, resolved: { error: `the destination couldn't be resolved: ${err instanceof Error ? err.message : String(err)}` } }; + } }); } @@ -8979,7 +8995,10 @@ export class AgentFramework { let targetChannel: string; let body: string; if (parsed.kind === 'target') { - const res = envelope.resolved ?? this.channelRegistry!.resolveProseTarget(parsed.target!); + // Resolved once, when the round was admitted (admitProse), never again + // here after the wait: a `>>` envelope without its resolution is a bug. + const res = envelope.resolved; + if (!res) throw new Error(`[prose] ${name}: a >> envelope reached delivery without the target admitted with it`); if ('error' in res) { this.bounceProse(agent, parsed.body, res.error, res.candidates); return; @@ -9220,8 +9239,7 @@ export class AgentFramework { turnToken: number, ownsProviderGate: boolean, ): Promise { - const continuingTurn = trigger?.reason === 'context_budget_restart' - || trigger?.reason === 'tool_result_guard_retry'; + const continuingTurn = resumesLogicalTurn(trigger?.reason); const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting; // Flush messages deferred during the PREVIOUS turn — before the // checkpoint, the locus announcement, and the compile — so a turn started @@ -9672,8 +9690,7 @@ export class AgentFramework { // the turn's deliveries run in the order its text was written, and the // sticky destination moves in that order too, whichever stream wrote the // text and however long an earlier post takes. - const resumesTurn = trigger?.reason === 'context_budget_restart' - || trigger?.reason === 'tool_result_guard_retry'; + const resumesTurn = resumesLogicalTurn(trigger?.reason); let turnSpeechChain: Promise = resumesTurn ? this.proseSpeechChains.get(agent.name) ?? Promise.resolve() : Promise.resolve(); @@ -9682,7 +9699,8 @@ export class AgentFramework { this.proseSpeechChains.set(agent.name, turnSpeechChain); const chainSpeech = (link: () => Promise, failure: string): void => { turnSpeechChain = turnSpeechChain.then(link).catch((err) => console.error(failure, err)); - this.proseSpeechChains.set(agent.name, turnSpeechChain); + // A stream another has replaced doesn't move the turn's latest link. + if (agent.streamId === myStreamId) this.proseSpeechChains.set(agent.name, turnSpeechChain); }; const enqueueSpeech = (text: string, locus: string | null): void => { chainSpeech(async () => { @@ -9798,6 +9816,8 @@ export class AgentFramework { // other trace consumers voice it). Hold both; release in order on a clean // round (tool-calls / non-guard completion), discard on a guard refusal. let guardHeld: Array<{ trace: Parameters[0]; text?: string }> = []; + // The text block the stream's last text came from, within this round. + let lastTextBlock: number | undefined; const releaseGuardHeld = (): void => { const held = guardHeld; guardHeld = []; @@ -9842,7 +9862,17 @@ export class AgentFramework { // turn's prose is bound for, without re-deriving routing. channelId: typingChannel ?? undefined, }; - const text = event.meta.type === 'text' ? event.content : undefined; + // Delivery joins a round's text blocks with a newline + // (splitProseSegments), so a later block starts a new line for + // the stream too. + let text = event.meta.type === 'text' ? event.content : undefined; + if (text !== undefined) { + const block = event.meta.blockIndex; + if (block !== undefined) { + if (lastTextBlock !== undefined && block !== lastTextBlock) text = '\n' + text; + lastTextBlock = block; + } + } if (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering) { guardHeld.push({ trace, text }); } else { @@ -9875,6 +9905,11 @@ export class AgentFramework { outgoingIndex = 0; discardGuardHeld(); proseStream?.reset(); + lastTextBlock = undefined; + // Explicit mode's retried attempt starts where the turn's + // delivered rounds have set the destination, not at the stream's + // seed: rounds this stream delivered before the refusal stand. + if (turnProseRouting === 'explicit') proseStream?.retarget(this.proseTurnDestinations.get(agent.name) ?? null); this.emitTrace({ type: 'inference:tokens', agentName: agent.name, @@ -9907,6 +9942,7 @@ export class AgentFramework { // The round's text ends here: delivery parses each round's text // on its own, so the next round's text starts a new line. if (proseStream) emitOutgoing(proseStream.endSegment()); + lastTextBlock = undefined; adoptInjectedRound(); hadToolCalls = true; this.recordLogicalTurnToolCalls(agent, myTurnToken ?? -1, event.calls.length); @@ -10053,6 +10089,12 @@ export class AgentFramework { chainSpeech(() => this.deliverAdmittedProse(agent, envelopes, { keepPrivate }), 'mid-turn prose delivery failed:'); } } + // The stream's next round starts where delivery sends + // unprefixed text: the turn's destination. The stream + // followed this round's prefixes as it was written, but a + // private round's, a >>skip_reply envelope's or a target + // that didn't resolve leave the destination as it was. + proseStream?.retarget(this.proseTurnDestinations.get(agent.name) ?? null); } else if (turnSilenced) { console.error( `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`, @@ -10101,6 +10143,7 @@ export class AgentFramework { // persisted as assistant speech, including a terminal refusal // after the single recovery retry. proseStream?.reset(); + if (turnProseRouting === 'explicit') proseStream?.retarget(this.proseTurnDestinations.get(agent.name) ?? null); if (withheld) { const usage = response.details?.usage ?? response.usage; const tokenUsage = usage ? { @@ -10498,6 +10541,9 @@ export class AgentFramework { .join('\n') .trim(); if (speechText) { + // Explicit text is admitted as its round ends, as a live + // round's is: its targets resolve now, not after the wait. + const admittedSpeech = turnProseRouting === 'explicit' ? this.admitProse(speechText) : null; // A turn resumed after its earlier rounds delivers after // them: their queued deliveries are this chain's. await turnSpeechChain; @@ -10522,7 +10568,7 @@ export class AgentFramework { } else if (turnProseRouting === 'explicit') { console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`); try { - await this.deliverProse(agent, speechText); + await this.deliverAdmittedProse(agent, admittedSpeech!); } catch (err) { console.error('text-only prose delivery failed:', err); } @@ -10569,6 +10615,8 @@ export class AgentFramework { : turnSilenced || toolNames.some(isSilencingTool); const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content); + // Explicit text is admitted as its round ends, before the wait. + const admittedSegments = turnProseRouting === 'explicit' ? segments.map((seg) => this.admitProse(seg)) : []; // Preserve in-channel ordering: everything enqueued live must // land before the trailing prose. Awaited even when silenced — @@ -10600,9 +10648,9 @@ export class AgentFramework { console.error( `[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`, ); - for (const seg of segments) { + for (const envelopes of admittedSegments) { try { - await this.deliverProse(agent, seg); + await this.deliverAdmittedProse(agent, envelopes); } catch (err) { console.error('trailing prose delivery failed:', err); } diff --git a/src/mcpl/prose-stream-router.ts b/src/mcpl/prose-stream-router.ts index a852a9b67..1814b9071 100644 --- a/src/mcpl/prose-stream-router.ts +++ b/src/mcpl/prose-stream-router.ts @@ -19,8 +19,13 @@ * emits NOTHING — the delivery path will bounce/retain it; a stream consumer * must never see text that delivery would refuse to send. * - * Two modes, mirroring proseRouting: - * - explicit: parse `>>` prefixes; initial target null (suppressed). + * Three modes, mirroring proseRouting: + * - explicit: parse `>>` prefixes. A fresh turn's stream starts suppressed; + * one that resumes a turn starts at the turn's destination, and + * each round's end returns the stream to it (retarget). + * - hybrid: parse `>>>` envelopes, with the frozen turn locus as the + * fallback. An envelope with no target, or `>>>skip_reply`, + * holds back what follows until a later `>>>` names a target. * - locus: NO prefix parsing (locus mode's `>>` lines are plain text); * everything streams to the frozen turn locus. */ @@ -47,7 +52,9 @@ export class ProseStreamRouter { private opts: { /** explicit parses `>>`; hybrid parses `>>>` while retaining locus fallback. */ mode: 'explicit' | 'hybrid' | 'locus'; - /** Initial destination: the frozen turn locus (locus mode) or null. */ + /** Initial destination: the frozen turn locus (locus and hybrid mode); in + * explicit mode, null on a fresh turn, or the turn's destination on a + * stream that resumes one. */ initialTarget: string | null; /** Resolve a `>>spec` to a channelId, or null (unresolved/ambiguous). */ resolve: (spec: string) => string | null; @@ -62,6 +69,17 @@ export class ProseStreamRouter { return this.accumulated; } + /** + * Point the stream at a destination decided outside it. Explicit mode does + * this at each round's end, with the turn's destination: a private round's + * prefixes, a `>>skip_reply` envelope and a target that didn't resolve all + * leave that destination as it was, though the stream followed them while + * the round was written. + */ + retarget(target: string | null): void { + this.target = target; + } + /** * Drop everything routed so far and return to the constructed state. * @@ -176,9 +194,10 @@ export class ProseStreamRouter { // names a target. this.target = null; } + // The whitespace itself is body, a newline included. this.held = ''; - this.state = ch === '\n' ? 'line-start' : 'body'; - if (ch !== '\n') this.step(ch, out); + this.state = 'body'; + this.step(ch, out); return; } this.state = 'prefix-token'; diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 0927ad605..a7cc06210 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -538,6 +538,29 @@ describe('explicit prose routing', () => { } }); + it('admission turns a resolver that throws into an envelope that bounces', async () => { + const framework = await createFramework(); + const routed = stubRegistry(framework); + const internals = framework as unknown as { + channelRegistry: { resolveProseTarget: (spec: string) => unknown }; + admitProse(text: string): Array<{ text: string; resolved?: { channelId?: string; error?: string } }>; + deliverAdmittedProse(agent: unknown, envelopes: unknown[]): Promise; + }; + const resolve = internals.channelRegistry.resolveProseTarget.bind(internals.channelRegistry); + internals.channelRegistry.resolveProseTarget = (spec: string) => { + if (spec === '#boom') throw new Error('directory unavailable'); + return resolve(spec); + }; + try { + const admitted = internals.admitProse('>>#boom one\n>>#alpha two'); + assert.match(admitted[0]!.resolved?.error ?? '', /directory unavailable/); + await internals.deliverAdmittedProse(framework.getAgent('assistant')!, admitted); + assert.deepEqual(routed.map((r) => r.text), ['two'], 'the other envelope still goes out'); + } finally { + await framework.stop(); + } + }); + it("' !' continuation starts another turn immediately after prose ends the current one", async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: '>>#alpha ! step one done, continuing' }, diff --git a/test/prose-stream-router.test.ts b/test/prose-stream-router.test.ts index 4dc725b1b..8875ee237 100644 --- a/test/prose-stream-router.test.ts +++ b/test/prose-stream-router.test.ts @@ -95,6 +95,12 @@ test('explicit: `>>` and then any whitespace is a quoted arrow', () => { assert.equal(textFor(out, 'discord:g:100'), 'look:\n>>\u00a0quoted'); }); +test('explicit: a bare `>>` line is text, its newline included, as delivery sends it', () => { + const r = new ProseStreamRouter({ mode: 'explicit', initialTarget: 'discord:g:100', resolve: (spec) => RESOLVE[spec] ?? null }); + const out = [...r.feed('>>\nhello'), ...r.finish()]; + assert.equal(textFor(out, 'discord:g:100'), '>>\nhello'); +}); + test('explicit: each segment starts a new line, and a prefix with no body takes effect at its end', () => { // Delivery parses each round's text on its own: a round may begin with a // prefix, and a round that is only a prefix sets the sticky destination. diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index f1b852da0..660d54dc6 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -520,54 +520,60 @@ test("explicit mode: a private round's prefix doesn't move where a budget restar } }); -test("explicit mode: a resumed stream's deliveries queue behind the turn's earlier ones", { timeout: 15000 }, async () => { - // From a review's probe. Delivery of 'first' is held until after the - // resumed stream has finished, so '>>#cafe second' is still queued behind - // it when the resumed turn delivers 'continued'. Queued behind them, it - // goes to #cafe, where the turn last addressed, as the stream sends it. +/** + * A budget-restart turn whose first round's delivery is held: round one + * writes `first`, round two `second` and crosses the budget, and the resumed + * stream writes `continued`. `held` resolves once the resumed stream has + * handed over its 'complete' and either its reply has gone out or the event + * loop has turned 200 times, enough for a delivery that doesn't wait on the + * held one to run; `release` lets the held delivery finish. + */ +async function heldRestart(mode: 'explicit' | 'locus', texts: { first: string; second: string; continued: string }) { const secondRound = createMockResponse([ - { type: 'text', text: '>>#cafe second' }, + { type: 'text', text: texts.second }, { type: 'tool_use', id: 'two', name: 'test--read', input: {} }, ] as ContentBlock[], 'tool_use'); secondRound.usage = { inputTokens: 100, outputTokens: 5 }; const h = await harness([ [ createMockResponse([ - { type: 'text', text: '>>#alpha first\n' }, + { type: 'text', text: texts.first }, { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, ] as ContentBlock[], 'tool_use'), secondRound, ], - [answer()], - ], { proseRouting: 'explicit', maxStreamTokens: 20 }); - const routed: Array<{ text: string; channelId: string }> = []; + [createMockResponse([{ type: 'text', text: texts.continued }] as ContentBlock[])], + ], { proseRouting: mode, maxStreamTokens: 20 }); + const routed: Array<{ text: string; channelId: string | null }> = []; const streamed: Array<{ channelId: string; delta: string }> = []; - let releaseFirst!: () => void; - const firstHeld = new Promise((resolve) => { releaseFirst = resolve; }); + // What the registry's #cafe names; a test may rebind it while 'first' is held. + const directory = { cafe: 'chan-cafe' }; + let release!: () => void; + const firstHeld = new Promise((resolve) => { release = resolve; }); + let resumedEnded!: () => void; + const ended = new Promise((resolve) => { resumedEnded = resolve; }); const open = h.membrane.streamYielding.bind(h.membrane); h.membrane.streamYielding = (request: NormalizedRequest, options?: YieldingStreamOptions) => { const stream = open(request, options); if (h.membrane.requests.length === 2) { const iterate = stream[Symbol.asyncIterator].bind(stream); (stream as unknown as { [Symbol.asyncIterator]: () => AsyncIterator })[Symbol.asyncIterator] = async function* () { - const events = { [Symbol.asyncIterator]: iterate } as AsyncIterable<{ type?: string; content?: unknown }>; + // The framework stops pulling once it has 'complete', so the signal + // goes with that event rather than after the loop. + const events = { [Symbol.asyncIterator]: iterate } as AsyncIterable<{ type?: string }>; for await (const event of events) { - if (event.type === 'tokens' && event.content === 'continued') setTimeout(releaseFirst, 50); + if (event.type === 'complete') resumedEnded(); yield event; } }; } return stream; }; - const restarts: string[] = []; - h.framework.onTrace((event) => { - if (event.type === 'inference:stream_restarted') restarts.push(String((event as { reason?: string }).reason)); - }); (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } - : spec === '#cafe' ? { channelId: 'chan-cafe' } : { error: `no channel ${spec}` }, - routeSpeech: async (_agent: string, text: string, channelId: string) => { - routed.push({ text, channelId }); + : spec === '#cafe' ? { channelId: directory.cafe } : { error: `no channel ${spec}` }, + routeSpeech: async (_agent: string, text: string, channelId: string | null) => { + routed.push({ text: text.trim(), channelId }); if (text.trim() === 'first') await firstHeld; return { delivered: true, channelId }; }, @@ -577,91 +583,231 @@ test("explicit mode: a resumed stream's deliveries queue behind the turn's earli getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + const restarts: string[] = []; + h.framework.onTrace((event) => { + if (event.type === 'inference:stream_restarted') restarts.push(String((event as { reason?: string }).reason)); + }); + const run = h.run(); + const held = ended.then(async () => { + for (let turn = 0; turn < 200 && !routed.some((r) => r.text === texts.continued.replace(/^>>\S+\s*/, '').trim()); turn++) { + await new Promise((resolve) => setImmediate(resolve)); + } + }); + return { h, run, held, release, routed, streamed, restarts, directory }; +} + +test("explicit mode: a resumed stream's deliveries queue behind the turn's earlier ones", { timeout: 15000 }, async () => { + // From a review's probe. While 'first' is still posting, '>>#cafe second' + // is queued behind it, and the resumed reply waits behind both: it then + // goes to #cafe, where the turn last addressed, as the stream sends it. + const t = await heldRestart('explicit', { first: '>>#alpha first\n', second: '>>#cafe second', continued: 'continued' }); try { - await h.run(); - assert.equal(h.membrane.requests.length, 2); - assert.ok(restarts.includes('context_budget'), 'the turn restarted for its budget'); - assert.deepEqual(routed.map((r) => ({ text: r.text.trim(), channelId: r.channelId })), [ + await t.held; + assert.deepEqual(t.routed.map((r) => r.text), ['first'], 'nothing goes out while the first post is held'); + t.release(); + await t.run; + assert.equal(t.h.membrane.requests.length, 2); + assert.ok(t.restarts.includes('context_budget'), 'the turn restarted for its budget'); + assert.deepEqual(t.routed, [ { text: 'first', channelId: 'chan-alpha' }, { text: 'second', channelId: 'chan-cafe' }, { text: 'continued', channelId: 'chan-cafe' }, ], 'the turn delivers in the order it wrote'); - assert.deepEqual(streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-cafe']); + assert.deepEqual(t.streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-cafe']); } finally { - releaseFirst(); - await h.framework.stop(); + t.release(); + await t.h.framework.stop(); } }); -test("explicit mode: a round's targets are resolved once, when it ends, for its delivery and the resumed stream alike", { timeout: 15000 }, async () => { - // From a review's probe. While delivery of 'first' is held, the registry's - // #cafe starts naming another channel. The round that wrote '>>#cafe second' - // had already ended, so its delivery and the resumed reply both go to the - // channel #cafe named then, where the stream sent them. - const secondRound = createMockResponse([ - { type: 'text', text: '>>#cafe second' }, - { type: 'tool_use', id: 'two', name: 'test--read', input: {} }, - ] as ContentBlock[], 'tool_use'); - secondRound.usage = { inputTokens: 100, outputTokens: 5 }; - const h = await harness([ - [ - createMockResponse([ - { type: 'text', text: '>>#alpha first\n' }, - { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, - ] as ContentBlock[], 'tool_use'), - secondRound, - ], - [answer()], - ], { proseRouting: 'explicit', maxStreamTokens: 20 }); - let cafe = 'chan-cafe'; +test("locus mode: a resumed stream's deliveries queue behind the turn's earlier ones", { timeout: 15000 }, async () => { + const t = await heldRestart('locus', { first: 'first', second: 'second', continued: 'continued' }); + try { + await t.held; + assert.deepEqual(t.routed.map((r) => r.text), ['first'], 'nothing goes out while the first post is held'); + t.release(); + await t.run; + assert.ok(t.restarts.includes('context_budget'), 'the turn restarted for its budget'); + assert.deepEqual(t.routed.map((r) => r.text), ['first', 'second', 'continued'], 'the turn delivers in the order it wrote'); + } finally { + t.release(); + await t.h.framework.stop(); + } +}); + +/** + * The registry stub for one-stream explicit turns: #alpha and #cafe resolve, + * deliveries and stream chunks are recorded, and a `think` tool is offered. + */ +function explicitRegistry(h: Awaited>) { const routed: Array<{ text: string; channelId: string }> = []; const streamed: Array<{ channelId: string; delta: string }> = []; - let releaseFirst!: () => void; - const firstHeld = new Promise((resolve) => { releaseFirst = resolve; }); - const open = h.membrane.streamYielding.bind(h.membrane); - h.membrane.streamYielding = (request: NormalizedRequest, options?: YieldingStreamOptions) => { - const stream = open(request, options); - if (h.membrane.requests.length === 2) { - const iterate = stream[Symbol.asyncIterator].bind(stream); - (stream as unknown as { [Symbol.asyncIterator]: () => AsyncIterator })[Symbol.asyncIterator] = async function* () { - const events = { [Symbol.asyncIterator]: iterate } as AsyncIterable<{ type?: string; content?: unknown }>; - for await (const event of events) { - yield event; - if (event.type === 'tokens' && event.content === 'continued') { - cafe = 'chan-rebound'; - releaseFirst(); - } - } - }; - } - return stream; - }; (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } - : spec === '#cafe' ? { channelId: cafe } : { error: `no channel ${spec}` }, + : spec === '#cafe' ? { channelId: 'chan-cafe' } : { error: `no channel ${spec}` }, routeSpeech: async (_agent: string, text: string, channelId: string) => { - routed.push({ text, channelId }); - if (text.trim() === 'first') await firstHeld; + routed.push({ text: text.trim(), channelId }); return { delivered: true, channelId }; }, sendOutgoingChunk: (channelId: string, _agent: string, _id: string, _index: number, delta: string) => { streamed.push({ channelId, delta }); }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, - getDescriptor: () => undefined, getChannelTools: () => [], + handleChannelToolCall: async () => ({ success: true, data: { noted: true } }), + getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, + getChannelTools: () => [{ name: 'think', description: 'Reason privately', inputSchema: { type: 'object', properties: { content: { type: 'string' } } } }], }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + return { routed, streamed }; +} + +test("explicit mode: within one stream, each round after a private round or >>skip_reply streams where delivery sends it", { timeout: 15000 }, async () => { + // The stream follows each prefix as it's written, a private round's too. + // At each round's end it returns to the turn's destination, which a private + // think's >>#cafe and a >>skip_reply envelope leave at #alpha. + const h = await harness([[ + createMockResponse([ + { type: 'text', text: '>>#alpha first\n' }, + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + createMockResponse([ + { type: 'text', text: '>>#cafe private plan' }, + { type: 'tool_use', id: 'two', name: 'think', input: { content: 'plan privately' } }, + ] as ContentBlock[], 'tool_use'), + createMockResponse([ + { type: 'text', text: 'middle' }, + { type: 'tool_use', id: 'three', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + createMockResponse([ + { type: 'text', text: '>>skip_reply aside' }, + { type: 'tool_use', id: 'four', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + createMockResponse([{ type: 'text', text: 'later' }] as ContentBlock[]), + ]], { proseRouting: 'explicit', sameRoundThinkTextPolicy: 'private' }); + const { routed, streamed } = explicitRegistry(h); try { await h.run(); - assert.equal(h.membrane.requests.length, 2); - assert.deepEqual(routed.map((r) => ({ text: r.text.trim(), channelId: r.channelId })), [ + assert.deepEqual(routed, [ + { text: 'first', channelId: 'chan-alpha' }, + { text: 'middle', channelId: 'chan-alpha' }, + { text: 'later', channelId: 'chan-alpha' }, + ]); + for (const text of ['middle', 'later']) { + assert.deepEqual(streamed.filter((d) => d.delta.includes(text)).map((d) => d.channelId), ['chan-alpha'], + `${text} streams to #alpha`); + } + } finally { + await h.framework.stop(); + } +}); + +test("explicit mode: a membrane retry within a stream starts again at the turn's destination", { timeout: 15000 }, async () => { + // Round one set #alpha. The membrane then refuses the next attempt and + // retries it; the retried attempt's unprefixed reply is delivered to + // #alpha, and streams there too. + const h = await harness([[ + createMockResponse([ + { type: 'text', text: '>>#alpha first\n' }, + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + createMockResponse([{ type: 'text', text: 'continued' }] as ContentBlock[]), + ]], { proseRouting: 'explicit' }); + const open = h.membrane.streamYielding.bind(h.membrane); + h.membrane.streamYielding = (request: NormalizedRequest, options?: YieldingStreamOptions) => { + const stream = open(request, options); + const iterate = stream[Symbol.asyncIterator].bind(stream); + (stream as unknown as { [Symbol.asyncIterator]: () => AsyncIterator })[Symbol.asyncIterator] = async function* () { + const events = { [Symbol.asyncIterator]: iterate } as AsyncIterable<{ type?: string; content?: unknown }>; + for await (const event of events) { + if (event.type === 'tokens' && event.content === 'continued') { + yield { type: 'tokens', content: 'refused partial', meta: { type: 'text', visible: true, blockIndex: 0 } }; + yield { type: 'retrying', attempt: 1, maxAttempts: 3 }; + } + yield event; + } + }; + return stream; + }; + const { routed, streamed } = explicitRegistry(h); + try { + await h.run(); + assert.deepEqual(routed, [ + { text: 'first', channelId: 'chan-alpha' }, + { text: 'continued', channelId: 'chan-alpha' }, + ]); + assert.deepEqual(streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-alpha']); + assert.equal(streamed.some((d) => d.delta.includes('refused partial')), true, + 'the refused attempt had streamed, as text streams before a refusal is known'); + } finally { + await h.framework.stop(); + } +}); + +test("explicit mode: a round's later text block starts a new line for the stream, as delivery joins blocks", { timeout: 15000 }, async () => { + // Two text blocks in one round, the second opening with a prefix, the first + // not ending with a newline. Delivery reads them joined by a newline. + const h = await harness([[ + createMockResponse([ + { type: 'text', text: '>>#alpha first part' }, + { type: 'text', text: '>>#cafe second' }, + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + createMockResponse([{ type: 'text', text: 'continued' }] as ContentBlock[]), + ]], { proseRouting: 'explicit' }); + const open = h.membrane.streamYielding.bind(h.membrane); + h.membrane.streamYielding = (request: NormalizedRequest, options?: YieldingStreamOptions) => { + const stream = open(request, options); + const iterate = stream[Symbol.asyncIterator].bind(stream); + (stream as unknown as { [Symbol.asyncIterator]: () => AsyncIterator })[Symbol.asyncIterator] = async function* () { + const events = { [Symbol.asyncIterator]: iterate } as AsyncIterable<{ type?: string; content?: unknown }>; + let split = false; + for await (const event of events) { + if (!split && event.type === 'tokens' && typeof event.content === 'string' && event.content.includes('>>#cafe')) { + // The mock streams a round's text as one token: stream it as the + // two blocks the round holds. + split = true; + yield { type: 'tokens', content: '>>#alpha first part', meta: { type: 'text', visible: true, blockIndex: 0 } }; + yield { type: 'tokens', content: '>>#cafe second', meta: { type: 'text', visible: true, blockIndex: 1 } }; + continue; + } + yield event; + } + }; + return stream; + }; + const { routed, streamed } = explicitRegistry(h); + try { + await h.run(); + assert.deepEqual(routed.slice(0, 2), [ + { text: 'first part', channelId: 'chan-alpha' }, + { text: 'second', channelId: 'chan-cafe' }, + ]); + assert.deepEqual(streamed.filter((d) => d.delta.includes('second')).map((d) => d.channelId), ['chan-cafe']); + assert.equal(streamed.some((d) => d.delta.includes('>>')), false, 'no prefix is streamed as text'); + } finally { + await h.framework.stop(); + } +}); + +test("explicit mode: a round's targets are resolved once, when it ends, for its delivery and the resumed stream alike", { timeout: 15000 }, async () => { + // From a review's probe. Both rounds that name #cafe have ended, and their + // deliveries wait behind 'first', when the registry's #cafe starts naming + // another channel. Each goes to the channel #cafe named when its round + // ended, where the stream sent it: the queued '>>#cafe second', and the + // resumed '>>#cafe continued', whose text-only round ended before the wait. + const t = await heldRestart('explicit', { first: '>>#alpha first\n', second: '>>#cafe second', continued: '>>#cafe continued' }); + try { + await t.held; + t.directory.cafe = 'chan-rebound'; + t.release(); + await t.run; + assert.deepEqual(t.routed, [ { text: 'first', channelId: 'chan-alpha' }, { text: 'second', channelId: 'chan-cafe' }, { text: 'continued', channelId: 'chan-cafe' }, ]); - assert.deepEqual(streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-cafe']); + assert.deepEqual(t.streamed.filter((d) => d.delta.includes('continued')).map((d) => d.channelId), ['chan-cafe']); } finally { - releaseFirst(); - await h.framework.stop(); + t.release(); + await t.h.framework.stop(); } }); @@ -689,9 +835,14 @@ test("a fresh turn that delivers nothing still replaces an earlier turn's speech }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); try { const run = h.run(); - const timeout = new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 3000)); - assert.notEqual(await Promise.race([run.then(() => 'done' as const), timeout]), 'timeout', - 'the resumed turn finishes'); + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise<'timeout'>((resolve) => { timer = setTimeout(() => resolve('timeout'), 10000); }); + try { + assert.notEqual(await Promise.race([run.then(() => 'done' as const), timeout]), 'timeout', + 'the resumed turn finishes'); + } finally { + clearTimeout(timer); + } assert.equal(h.membrane.requests.length, 2); assert.deepEqual(routed.map((text) => text.trim()), ['continued']); } finally { @@ -699,15 +850,22 @@ test("a fresh turn that delivers nothing still replaces an earlier turn's speech } }); -test("a fresh turn clears the turn's destination in every mode", async () => { - const h = await harness([[answer()]], { proseRouting: 'hybrid' }); - const internals = h.framework as unknown as { proseTurnDestinations: Map }; - internals.proseTurnDestinations.set('assistant', 'chan-earlier'); - try { - await h.run(); - assert.equal(internals.proseTurnDestinations.has('assistant'), false); - } finally { - await h.framework.stop(); +test("a fresh turn clears the sticky destination and the turn's destination in every mode", async () => { + for (const mode of ['explicit', 'hybrid', 'locus'] as const) { + const h = await harness([[answer()]], { proseRouting: mode }); + const internals = h.framework as unknown as { + proseTurnDestinations: Map; + proseTargetPins: Map; + }; + internals.proseTurnDestinations.set('assistant', 'chan-earlier'); + internals.proseTargetPins.set('assistant', 'chan-earlier'); + try { + await h.run(); + assert.equal(internals.proseTurnDestinations.has('assistant'), false, `${mode}: the turn's destination`); + assert.equal(internals.proseTargetPins.has('assistant'), false, `${mode}: the sticky destination`); + } finally { + await h.framework.stop(); + } } }); From adf9ee1de73a44ff7c14e86baae754dfc5e906c7 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 22:58:32 -0400 Subject: [PATCH 13/14] fix(routing): admit a turn's final explicit text before the speech callback runs From a review of 914b2429. The 'complete' case awaits moduleRegistry.dispatchSpeech() before it routes the turn's final text, and 914b2429 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. --- src/framework.ts | 23 +++++++++-------- test/tool-result-guard.test.ts | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index b75733f0a..bbe8b1706 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -10324,6 +10324,17 @@ export class AgentFramework { const speechContent = hadToolCalls ? [] : allText; const thoughts = hadToolCalls ? allText : []; + // Explicit text is admitted as its round ends, before anything + // below waits (a module's speech callback, the turn's earlier + // deliveries): its targets resolve now, as a live round's do. + const finalSpeechText = speechContent.map((b) => b.text).join('\n').trim(); + const finalSegments = hadToolCalls && allText.length > 0 + ? splitProseSegments(liveProseRouting ? terminalContent : response.content) + : []; + const admitFinal = turnProseRouting === 'explicit' && !!this.channelRegistry; + const admittedSpeech = admitFinal && finalSpeechText ? this.admitProse(finalSpeechText) : null; + const admittedSegments = admitFinal ? finalSegments.map((seg) => this.admitProse(seg)) : []; + const du = response.details?.usage; const tokenUsage = du ? { @@ -10536,14 +10547,8 @@ export class AgentFramework { // they are never routed here — which is precisely how the `think` // tool (and any explicit send tool) yields a silent turn. if (speechContent.length > 0 && this.channelRegistry) { - const speechText = speechContent - .map((b) => (b as ContentBlock & { type: 'text' }).text) - .join('\n') - .trim(); + const speechText = finalSpeechText; if (speechText) { - // Explicit text is admitted as its round ends, as a live - // round's is: its targets resolve now, not after the wait. - const admittedSpeech = turnProseRouting === 'explicit' ? this.admitProse(speechText) : null; // A turn resumed after its earlier rounds delivers after // them: their queued deliveries are this chain's. await turnSpeechChain; @@ -10614,9 +10619,7 @@ export class AgentFramework { ? turnSilenced : turnSilenced || toolNames.some(isSilencingTool); - const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content); - // Explicit text is admitted as its round ends, before the wait. - const admittedSegments = turnProseRouting === 'explicit' ? segments.map((seg) => this.admitProse(seg)) : []; + const segments = finalSegments; // Preserve in-channel ordering: everything enqueued live must // land before the trailing prose. Awaited even when silenced — diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index 660d54dc6..a9599a66e 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -869,6 +869,52 @@ test("a fresh turn clears the sticky destination and the turn's destination in e } }); +for (const shape of ['text-only', 'trailing'] as const) { + test(`explicit mode: a turn's ${shape} text is admitted before a module's speech callback runs`, { timeout: 15000 }, async () => { + // From a review's probe: a speech callback that rebinds #cafe can't move + // where the turn's final text goes, since that text was admitted first. + const scripts = shape === 'text-only' + ? [[createMockResponse([{ type: 'text', text: '>>#cafe final' }] as ContentBlock[])]] + : [[ + createMockResponse([ + { type: 'text', text: '>>#alpha first\n' }, + { type: 'tool_use', id: 'one', name: 'test--read', input: {} }, + ] as ContentBlock[], 'tool_use'), + createMockResponse([{ type: 'text', text: '>>#cafe final' }] as ContentBlock[]), + ]]; + const h = await harness(scripts, { proseRouting: 'explicit' }); + let cafe = 'chan-cafe'; + const routed: Array<{ text: string; channelId: string }> = []; + const streamed: Array<{ channelId: string; delta: string }> = []; + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } + : spec === '#cafe' ? { channelId: cafe } : { error: `no channel ${spec}` }, + routeSpeech: async (_agent: string, text: string, channelId: string) => { + routed.push({ text: text.trim(), channelId }); + return { delivered: true, channelId }; + }, + sendOutgoingChunk: (channelId: string, _agent: string, _id: string, _index: number, delta: string) => { + streamed.push({ channelId, delta }); + }, + getDefaultPublishChannel: () => null, isChannelOpen: () => true, + getDescriptor: () => undefined, getChannelTools: () => [], + }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + const modules = (h.framework as unknown as { moduleRegistry: { dispatchSpeech: (...args: unknown[]) => Promise } }).moduleRegistry; + const dispatch = modules.dispatchSpeech.bind(modules); + modules.dispatchSpeech = async (...args: unknown[]) => { + cafe = 'chan-rebound'; + await dispatch(...args); + }; + try { + await h.run(); + assert.deepEqual(routed.at(-1), { text: 'final', channelId: 'chan-cafe' }); + assert.ok(streamed.some((d) => d.channelId === 'chan-cafe' && d.delta.includes('final'))); + } finally { + await h.framework.stop(); + } + }); +} + test('backward-compatible direct Agent inference also guards tool results', async () => { const h = await harness([], { toolResultGuard: true }); const responses = [calls('one'), refused(), answer()]; From 52d06e0e90a1a580ab03bfaa58cde5a99a9a1c42 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 23:31:42 -0400 Subject: [PATCH 14/14] docs(changelog): the stream parses each round from a line's start 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. --- changelog.d/explicit-skip-reply-private.fixed.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/changelog.d/explicit-skip-reply-private.fixed.md b/changelog.d/explicit-skip-reply-private.fixed.md index 89a74b3f7..9376a4537 100644 --- a/changelog.d/explicit-skip-reply-private.fixed.md +++ b/changelog.d/explicit-skip-reply-private.fixed.md @@ -22,15 +22,15 @@ delivered rounds have set, decided at each round's end, so a private round's prefix doesn't count. The stream also reads a `>>` prefix after any leading whitespace, and ends its target at any whitespace, as delivery - does, rather than streaming such a line as text or dropping it. Each - round's text starts a new line for the stream, as delivery parses each + does, rather than streaming such a line as text or dropping it. The stream + parses each round's text from the start of a line, as delivery parses each round on its own: a round that began with a `>>` prefix streamed it as text to the previous round's destination. A round's later text block - starts a new line too, as delivery joins a round's text blocks with a - newline. At each round's end, and when the membrane retries a refused - attempt, the stream returns to the turn's destination, so a private - round's prefix, a `>>skip_reply` envelope or a target that didn't resolve - no longer moves where later rounds stream. + starts a new line, as delivery joins a round's text blocks with a newline. + At each round's end, and when the membrane retries a refused attempt, the + stream returns to the turn's destination, so a private round's prefix, a + `>>skip_reply` envelope or a target that didn't resolve no longer moves + where later rounds stream. - A turn resumed by a tool-result-guard retry or a budget restart now queues its prose deliveries behind the turn's earlier ones, in every mode, so the turn delivers in the order it wrote. Before, a resumed reply could be