From b095a41c144871b10eed7d7faa906c85c1319d36 Mon Sep 17 00:00:00 2001 From: Nigel-2405 Date: Tue, 13 Oct 2026 09:27:34 -0400 Subject: [PATCH 01/17] fix(routing): a start that continues a logical turn keeps the turn's routing A framework retry after a stream error, the re-inference that consumes unstreamed tool results, and the compile a provider-capacity hold defers a turn to all continue the same logical turn (#312), but beginAgentTurn gave each of them freshTurnRouting(). So the turn lost: - hybrid's `>>>skip_reply` hold: later unprefixed prose was published to the turn's locus; - its sticky destination, in both modes: hybrid prose went to the locus, and explicit prose bounced, with a notice and a wake; - explicit mode's turn destination: the new stream streamed nothing; - its locus, which was resolved again: a retry lost a mid-turn addressed re-pin, a re-inference took the locus of whichever wake started it, and a held turn's later compile let a newer ambient message move it. Fixes #314. TurnRouting is now one object per logical turn, holding everything the turn has decided about where its speech goes: the trigger channel, the locus and the engaged channels (moved from the per-agent activeTriggerChannels, turnLocusPins and turnEngagedChannels maps), the sticky destination, the destination and the hold, and the ` !` and receipt tallies (LogicalTurn is folded in). The start that opens a turn registers it in openLogical, every start while it is open takes it, and a resume keeps it. A silent start gets an object of its own, so it neither takes nor disturbs an open turn's, and it has no locus. Explicit mode's stream router starts every stream at the turn's destination. A start that continues the turn keeps its locus, and applies the mid-turn re-pin to the wakes in its batch that arrived after the turn opened: the newest one that addressed the agent, or came from a channel the turn engaged, moves the locus, with the same [routing] notice (repinTurnLocus, now shared with the mid-turn boundary). No turn is alive during a capacity hold, so such arrivals are stored without passing a boundary, and the mid-turn re-pin never saw them. Ambient traffic no longer moves a held turn's locus. A replaced stream's queued deliveries are on the agent's chain ahead of its replacement's, so what they set, such as a pin or a hold, applies to the replacement's prose in the order the turn wrote them. A stream queues deliveries only while it is the agent's current stream. Tests: each of the three starts keeps the hold, the pin and the destination; a retry's text waits for the replaced stream's late deliveries and follows what they set; a silent start leaves an open turn untouched; a silent resume shows no locus; the locus across a retry, a capacity hold (addressed and ambient arrivals) and a re-inference. Tests that seed routing state use the flat shape. settleAgent's comment no longer says no test reaches its identity check, and #312's changelog fragment no longer says these starts begin the sticky destination and hold afresh. Co-Authored-By: Claude Opus 5.5 --- .../continuing-starts-keep-routing.fixed.md | 18 + changelog.d/turn-routing-per-turn.fixed.md | 5 +- docs/explicit-prose-routing.md | 4 +- docs/hybrid-prose-routing.md | 4 + src/framework.ts | 558 +++++++++--------- src/mcpl/prose-stream-router.ts | 6 +- src/types/trace.ts | 2 +- test/explicit-prose-routing.test.ts | 386 ++++++++++-- test/present-while-acting.test.ts | 187 +++++- test/tool-result-guard.test.ts | 26 +- test/trunk-channel-routing.test.ts | 20 +- 11 files changed, 864 insertions(+), 352 deletions(-) create mode 100644 changelog.d/continuing-starts-keep-routing.fixed.md diff --git a/changelog.d/continuing-starts-keep-routing.fixed.md b/changelog.d/continuing-starts-keep-routing.fixed.md new file mode 100644 index 000000000..7b925b649 --- /dev/null +++ b/changelog.d/continuing-starts-keep-routing.fixed.md @@ -0,0 +1,18 @@ +- A start that continues a logical turn now keeps what the turn has decided + about where its speech goes. Those starts are a framework retry after a + stream error, the re-inference that consumes unstreamed tool results, and + the compile a provider-capacity hold defers a turn to (#314). Before, each + began with fresh routing: + - in hybrid mode, the hold after `>>>skip_reply` was dropped, so the new + stream's unprefixed prose was published to the turn's locus; + - the sticky destination was dropped in both modes, so unprefixed prose + went to the locus in hybrid mode, and in explicit mode bounced with a + notice and a wake, while the new stream streamed nothing; + - the turn's locus was resolved again: a retry lost a mid-turn addressed + re-pin, a re-inference took the locus of whichever wake started it, and + a held turn's later compile let a newer ambient message move it. +- At a start that continues the turn, a wake that arrived after the turn + began moves the turn's locus only as a mid-turn message does: when it + addressed the agent, or came from a channel the turn sent into. The agent + gets the same `[routing] The conversation moved to …` notice. Ambient + traffic in another channel no longer moves a held turn's locus. diff --git a/changelog.d/turn-routing-per-turn.fixed.md b/changelog.d/turn-routing-per-turn.fixed.md index f3e16bbc4..a27b32fdc 100644 --- a/changelog.d/turn-routing-per-turn.fixed.md +++ b/changelog.d/turn-routing-per-turn.fixed.md @@ -29,9 +29,8 @@ every start that routes prose while it is open continues it: a framework retry, the start that consumes unstreamed tool results (whichever wake the scheduler starts it with), the later start a provider-capacity hold - defers a turn to. Each begins the sticky destination and hybrid's hold - afresh, as before, but a ` !` asked for earlier in the turn still stands, - in explicit mode too, and in hybrid and locus mode the receipt at the + defers a turn to. A ` !` asked for earlier in the turn still stands, in + explicit mode too, and in hybrid and locus mode the receipt at the turn's end reports every stream's deliveries, including ones that land after a new stream began. Before, explicit mode dropped that ` !`, and the receipt counted only the deliveries that landed after the new start. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index 3f78c6ff4..0d8f949de 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -23,7 +23,9 @@ A prose segment's FIRST line may carry a routing prefix: - Body may start on the same line (`>>#ops deploy done`) or the next. - **Sticky within the turn:** the first resolved target applies to later - unprefixed segments of the SAME turn. Cross-turn, nothing is implicit. + unprefixed segments of the SAME turn, including those a later stream of it + writes, such as a framework retry's, a budget restart's or the compile a + provider-capacity hold defers the turn to. Cross-turn, nothing is implicit. - **Continuation modifier:** ` !` immediately after the target (`>>#ops !` / `>>skip_reply !`) requests an immediate re-wake after this turn ends, instead of the default pause-until-next-event. Gives prose the same diff --git a/docs/hybrid-prose-routing.md b/docs/hybrid-prose-routing.md index 0a1382089..d71ab06ca 100644 --- a/docs/hybrid-prose-routing.md +++ b/docs/hybrid-prose-routing.md @@ -24,6 +24,10 @@ At logical turn end, a `[delivered]` receipt names canonical destinations (or a bounce explains failure), so authored intent cannot impersonate delivery. Unprefixed prose after a successful envelope follows that explicit destination for the remainder of the turn; a fresh turn begins at its ordinary locus. +After `>>>skip_reply`, or an envelope that publishes nowhere, unprefixed prose +is held back until a later `>>>` names a destination. Both hold for the whole +logical turn, across its later streams, such as a framework retry's, a budget +restart's or the compile a provider-capacity hold defers the turn to. This is publication routing only. It creates no privacy meaning for `` or other prose tags, and grants no channel authority beyond the existing diff --git a/src/framework.ts b/src/framework.ts index dbb2e4033..c4b286957 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -516,53 +516,94 @@ interface AdmittedEnvelope { } /** - * 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. + * Whether a stream resumes its turn's request rather than starting one: a + * budget restart, or a tool-result-guard retry. Such a start takes in no new + * input, and keeps the turn's locus, trigger channel and tool-call count. Its + * routing state is the logical turn's, as for every start that continues the + * turn (TurnRouting). */ function resumesLogicalTurn(reason: string | undefined): boolean { return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry'; } /** - * One turn's routing and delivery receipts (AgentFramework.turnRouting). - * A fresh turn gets a new object; a queued delivery keeps its own turn's. + * One logical turn's routing state and receipt tallies + * (AgentFramework.turnRouting): everything the turn has decided about where + * its speech goes. Every stream of the turn shares it: the start that opens + * the turn, a resume (resumesLogicalTurn), and each start while the turn is + * open (AgentFramework.openLogical): a framework retry, the re-inference that + * consumes unstreamed tool results, a capacity-held turn's later compile. So + * none of them re-decides the locus, drops a sticky destination or a hold, or + * forgets a ` !`, and the receipt at the turn's end reports every stream's + * deliveries. A silent start has one of its own, so it neither takes nor + * disturbs an open turn's. A queued delivery keeps the object of the turn + * that queued it. */ interface TurnRouting { + /** + * The channel whose message triggered the turn (item-3 redux), which the + * locus is resolved from when the turn opens, and which the channel + * registry reports as the turn's default outgoing channel. A turn that + * channel_opens a channel moves it there. Kept across the turn's streams: + * a budget restart's trigger carries no channel, and dropping it there + * sent Sol's DM reply to a stale guild channel (2026-07-22). + */ + triggerChannel?: string; + /** + * Locus and hybrid mode: where the turn's unpinned plain prose lands. + * Frozen when the turn opens (home channel for a fork, else the trigger + * channel, else the global default), so ambient chatter, reactions and + * system markers can't move it (2026-07-21 Cairn lounge misroute). Since + * 2026-07-31 a re-pin moves it (repinTurnLocus): an addressed message, or a + * follow-up in a channel the turn engaged. The agent's own channel_open + * moves it too. Unset in explicit and disabled mode, and for a turn with + * nowhere to speak. + */ + locus?: string; + /** + * Channels the turn sent into with a send tool (SEND_ENGAGEMENT_TOOLS): a + * conversational follow-up in one of them is addressed by context, so it + * re-pins the locus (2026-07-31 n=7: q's #portables reply, with no + * mention, followed the stale pin into repligate's DM). + */ + engaged: Set; /** The sticky destination, set by each `>>` (hybrid: `>>>`) prefix that resolves. */ pin?: string; /** * Explicit mode: the destination the turn's delivered rounds have set, - * decided at each round boundary (roundDestination), where a stream that - * resumes the turn starts. + * decided at each round boundary (roundDestination), where each of the + * turn's streams starts. */ destination: string | null; - /** Hybrid: held back after a malformed or unresolved envelope, until a later `>>>` names a target. */ + /** + * Hybrid: held back after `>>>skip_reply`, or an envelope that is malformed, + * doesn't resolve or isn't delivered, until a later `>>>` names a target. + */ hybridHold: boolean; - /** What the logical turn keeps across a new stream (LogicalTurn). */ - logical: LogicalTurn; -} - -/** - * What every stream of one logical turn shares, from the fresh start that - * opens it until it settles (AgentFramework.openLogical): a framework retry, - * a re-inference after unstreamed tool results, a capacity-held turn's later - * compile. beginAgentTurn starts each with fresh routing, but a ` !` asked - * for in any of them stands, and the receipt at the turn's end reports every - * stream's deliveries, even ones that land after a new stream began. - */ -interface LogicalTurn { /** The turn asked for a ` !` continuation; taken once. */ continuation: boolean; /** Successful plain-prose destinations in delivery order; deduped by the receipt. */ deliveries: string[]; /** Plain-prose segments the turn suppressed, reported by its receipt. */ suppressed: number; + /** + * When the turn's first start began. A wake whose event comes later arrived + * during the turn: at a start that continues the turn it can re-pin the + * locus, as a mid-turn injection does (AgentFramework.repinTurnLocus). + */ + openedAt: number; } -function freshTurnRouting(): TurnRouting { - return { destination: null, hybridHold: false, logical: { continuation: false, deliveries: [], suppressed: 0 } }; +function freshTurnRouting(openedAt = Date.now()): TurnRouting { + return { + engaged: new Set(), destination: null, hybridHold: false, + continuation: false, deliveries: [], suppressed: 0, openedAt, + }; +} + +/** When a wake's event happened: a gate wake's event time, else when it was queued. */ +function wakeEventAt(request: InferenceRequest): number { + return request.wakeAt ?? request.timestamp; } /** How a round's prose is delivered: kept private, and under which turn's routing. */ @@ -1145,16 +1186,6 @@ export class AgentFramework { private inferencePolicy: InferencePolicy; private errorPolicy: ErrorPolicy; private pendingRequests: InferenceRequest[] = []; - /** - * Per-agent channel that triggered the agent's CURRENT inference turn, if any - * (item-3 redux). Read by the ChannelRegistry's `activeChannelResolver` to - * route a single-trunk agent's plain-text speech back to the channel it is - * answering, instead of the process-global most-recent-inbound locus that a - * concurrent message elsewhere can hijack. Set at turn start (or cleared for a - * heartbeat / no-trigger turn) in startAgentStream; overwritten by the next - * turn. Never read between turns (a given agent runs one turn at a time). - */ - private activeTriggerChannels: Map = new Map(); private running = false; private loopPromise: Promise | null = null; /** Quiesce/maintenance mode (issue #122). DELIBERATELY separate from @@ -1193,32 +1224,6 @@ export class AgentFramework { private processLoggingBroadcast: boolean; private activeStreams: Map> = new Map(); - /** Per-agent output locus FROZEN for the CURRENT logical turn. Resolved - * eagerly in startAgentStream (home → addressed trigger → global default) - * and never moved until the next turn: mid-turn injected messages — ambient - * chatter, reactions, system markers — must not hijack where the agent's - * plain prose lands (2026-07-21 Cairn lounge misroute). Lives here — not in - * driveStream locals — so the pin survives a context-budget stream restart, - * which continues the same logical turn in a fresh driveStream. Cleared or - * re-set at the start of every non-restart turn. Since 2026-07-31 the pin - * is no longer fully frozen: an ADDRESSED conversational injection — or a - * conversational follow-up in a channel the agent itself explicitly sent - * into this turn (turnEngagedChannels) — re-pins it at the boundary (see - * the addressed re-pin block in the tool-result handler). Ambient chatter - * elsewhere, reactions, and system markers still cannot move it. */ - private turnLocusPins: Map = new Map(); - /** Channels the agent EXPLICITLY sent into during the CURRENT turn - * (SEND_ENGAGEMENT_TOOLS, successful calls), per agent. Second re-pin - * signal: a conversational reply in a channel the agent just engaged is - * addressed by context even without a mention (2026-07-31 n=7: q's - * #portables follow-up — no @mention — was answered in trailing prose - * that followed the stale pin into repligate's DM). No author-kind - * filter: agent-residents are full participants, and the bot flag - * tracks nothing that matters (antra). Reactions/system markers are - * excluded by isConversationalInjection; the engaged-this-turn scope - * keeps unrelated channels from moving the pin. Cleared at every fresh - * turn's start; budget restarts keep it. */ - private turnEngagedChannels: Map> = new Map(); /** A tool boundary injected fresh CONVERSATIONAL input (a real message — * not a reaction or a system marker) into the live stream. Tells * driveStream to clear sticky explicit-send suppression before handling @@ -1234,12 +1239,13 @@ export class AgentFramework { /** Latest bounced (undelivered) prose per agent — the `{{unsent}}` source. */ private proseClipboards: Map = new Map(); /** - * Each agent's routing state for its current turn (TurnRouting): the - * sticky destination, the turn's destination for a resumed stream, - * hybrid's hold, a `!` continuation and receipt tallies. A fresh turn starts a new object - * instead of clearing the old one, and a stream that resumes the turn - * (resumesLogicalTurn) keeps it. A queued delivery holds the object of the - * turn that queued it, so prose a turn leaves queued when it ends (a + * Each agent's routing state for its current logical turn (TurnRouting): + * its trigger channel, locus and engaged channels, the sticky destination, + * the turn's destination, hybrid's hold, a ` !` continuation and receipt + * tallies. The start that opens a turn makes a + * new object instead of clearing the old one, and every later stream of + * the turn shares it (openLogical). A queued delivery holds the object of + * the turn that queued it, so prose a turn leaves queued when it ends (a * skip_reply ends it before its posts have run) still follows that turn's * destination, and its late writes can't reach the next turn. */ @@ -1251,25 +1257,26 @@ export class AgentFramework { */ private proseSpeechChains: Map> = new Map(); /** - * Each agent's open logical turn (LogicalTurn). It stays open from the - * fresh start that opens it until the turn settles (settleAgent: it - * completes, a tool ends it, or it fails or is abandoned), and every fresh - * start that routes prose while it is open continues it: a framework - * retry, the start that consumes tool results the non-streaming fallback - * left (whichever wake the scheduler starts it with), the later compile a - * provider-capacity hold defers a turn to. A silent start neither - * continues nor opens one. A stream that ends without settling needs to do - * nothing to carry its turn. Settling only detaches the turn from future - * starts: the settling stream keeps its own object, which its remaining - * deliveries, its receipt and its ` !` check go on using (a completion - * settles before it awaits its chain and writes its receipt). So every - * logical end must close inheritance once, before the next fresh start: - * a turn left open would hand its object, late writes and all, to an - * unrelated turn. A tool-ended turn settles before the next turn can - * start. Only the stream that holds the open turn closes it; disposal - * drops it, and stop() clears every agent's. + * Each agent's open logical turn: its routing state and receipt tallies + * (TurnRouting). It stays open from the start that opens it until the + * turn settles (settleAgent: it completes, a tool ends it, or it fails or + * is abandoned), and every start that routes prose while it is open + * continues it with that object: a framework retry, the start that + * consumes tool results the non-streaming fallback left (whichever wake + * the scheduler starts it with), the later compile a provider-capacity + * hold defers a turn to. A silent start neither continues nor opens one, + * and leaves the open turn's object untouched. A stream that ends without + * settling needs to do nothing to carry its turn. Settling only detaches + * the turn from future starts: the settling stream keeps its own object, + * which its remaining deliveries, its receipt and its ` !` check go on + * using (a completion settles before it awaits its chain and writes its + * receipt). So every logical end must close inheritance once, before the + * next fresh start: a turn left open would hand its object, late writes + * and all, to an unrelated turn. A tool-ended turn settles before the + * next turn can start. Only the stream that holds the open turn closes + * it; disposal drops it, and stop() clears every agent's. */ - private openLogical: Map = new Map(); + private openLogical: Map = new Map(); /** Consecutive bounce-wakes per agent; capped so an agent that keeps * emitting unprefixed prose can't wake-loop (notices still append). */ private proseBounceStreaks: Map = new Map(); @@ -4517,13 +4524,13 @@ export class AgentFramework { private settleAgent( agentName: string, outcome: { stopReason: AgentSettleResult['stopReason']; speech: string; error?: string }, - /** The settling stream's logical turn, when that stream still owns the - * agent: it ends here, so the agent's next fresh start opens a new one. - * A settle from a stream a newer one replaced closes nothing. */ - closes: LogicalTurn | undefined, + /** The settling stream's logical turn (TurnRouting), when that stream + * still owns the agent: it ends here, so the agent's next fresh start + * opens a new one. A stream that no longer owns the agent passes none. */ + closes: TurnRouting | undefined, ): void { - // This identity check is the only guard against a stale settle closing - // a newer turn (no test reaches one); keep it when editing call sites. + // The identity check keeps a settle that carries an earlier turn's + // object from closing a newer turn; keep it when editing call sites. if (closes && this.openLogical.get(agentName) === closes) this.openLogical.delete(agentName); const run = this.ephemeralRuns.get(agentName); if (!run) return; @@ -7154,7 +7161,7 @@ export class AgentFramework { // - chat:addressed — someone explicitly spoke TO the agent // (mention / reply / DM); // - a conversational follow-up in a channel the agent itself - // explicitly sent into THIS turn (turnEngagedChannels) — + // explicitly sent into THIS turn (TurnRouting.engaged) — // conversation the agent just engaged continues without // re-mentioning it (2026-07-31: q's #portables reply, no @, // answered in prose that followed the stale pin into @@ -7165,43 +7172,20 @@ export class AgentFramework { // Reactions and system markers are already excluded by // isConversationalInjection, and the engaged-this-turn scope // keeps unrelated channels from moving the pin. - const engaged = this.turnEngagedChannels.get(agent.name); + const routing = this.routingOf(agent.name); const lastQualifying = [...midTurnInjections].reverse().find((inj) => { const m = inj.metadata as Record | undefined; if (!isConversationalInjection(inj.metadata)) return false; if (typeof m?.channelId !== 'string') return false; const tags = m.tags as string[] | undefined; if (isAddressedMessage(tags, m)) return true; - return engaged?.has(m.channelId as string) === true; + return routing.engaged.has(m.channelId as string); }); const newLocus = (lastQualifying?.metadata as Record | undefined) ?.channelId as string | undefined; - if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) { - const prevPin = this.turnLocusPins.get(agent.name) ?? null; - this.turnLocusPins.set(agent.name, newLocus); - this.lastAnnouncedLocus.set(agent.name, newLocus); - const label = this.channelRegistry?.getDescriptor(newLocus)?.label; - const shown = label && label !== newLocus - ? `${label.startsWith('#') ? label : `#${label}`} (${newLocus})` - : newLocus; - const noticeContent: ContentBlock[] = [{ - type: 'text', - text: - `[routing] The conversation moved to ${shown} — your plain ` + - 'speech now lands there for the rest of this turn. Other ' + - 'channels need an explicit send tool.', - }]; - const noticeMeta = { system: true, kind: 'routing-notice' } as MessageMetadata; - try { - const id = agent.getContextManager().addMessage('user', noticeContent, noticeMeta); - this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' }); - midTurnInjections.push({ participant: 'user', content: noticeContent, metadata: noticeMeta }); - console.error( - `[routing] ${agent.name}: mid-turn addressed re-pin ${prevPin ?? '(none)'} -> ${newLocus}`, - ); - } catch (err) { - console.error('mid-turn locus re-pin: failed to record routing notice:', err); - } + if (newLocus) { + const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed'); + if (notice) midTurnInjections.push(notice); } } @@ -7220,7 +7204,7 @@ export class AgentFramework { // agent must be wakeable the moment it goes idle. (Idempotent; // driveStream's finally is the backstop.) this.eventGate?.onInferenceEnded(agent.name); - this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' }, this.turnRouting.get(agent.name)?.logical); + this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' }, this.turnRouting.get(agent.name)); this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name }); } else if (overBudget || overPhysical) { // Context budget exceeded: break the stream, let compile() compress. @@ -8818,7 +8802,7 @@ export class AgentFramework { // held in the gate while the agent was busy lands BEHIND a newer // addressed message's direct wake, and queue order would hand the turn // to the older one. Ties keep the later-queued request, as before. - const eventAt = (r: InferenceRequest): number => r.wakeAt ?? r.timestamp; + const eventAt = wakeEventAt; let ambientReq: InferenceRequest | undefined; let addressedReq: InferenceRequest | undefined; for (const r of requests) { @@ -8867,7 +8851,7 @@ export class AgentFramework { counterparty: budgetRestart ? undefined : provReq?.counterparty, wakeChannelId: budgetRestart ? undefined : provReq?.wakeChannelId, wakeAt: budgetRestart ? undefined : provReq?.wakeAt, - }); + }, 0, false, requests); } } @@ -8891,7 +8875,7 @@ export class AgentFramework { /** Record plain-prose segments this turn suppressed. */ private recordProseSuppression(routing: TurnRouting, count: number): void { if (count <= 0) return; - routing.logical.suppressed += count; + routing.suppressed += count; } /** Record a successful plain-prose delivery for this turn's receipt. */ @@ -8900,7 +8884,7 @@ export class AgentFramework { outcome: { delivered: boolean; channelId: string } | null | undefined, ): void { if (!outcome?.delivered) return; - routing.logical.deliveries.push(outcome.channelId); + routing.deliveries.push(outcome.channelId); } /** @@ -8917,11 +8901,11 @@ export class AgentFramework { * ([discord-send-failed]); this is the success half. */ private appendProseDeliveryReceipt(agent: Agent, routing: TurnRouting): void { - const list = routing.logical.deliveries; - const suppressed = routing.logical.suppressed; + const list = routing.deliveries; + const suppressed = routing.suppressed; if (list.length === 0 && suppressed === 0) return; - routing.logical.deliveries = []; - routing.logical.suppressed = 0; + routing.deliveries = []; + routing.suppressed = 0; const seen = new Set(); const shown: string[] = []; for (const id of list ?? []) { @@ -9009,6 +8993,49 @@ export class AgentFramework { } } + /** + * Move a turn's locus to the channel its conversation moved to: an + * addressed message, or a follow-up in a channel the turn engaged (the + * addressed re-pin). Ambient chatter never moves it. Leaves a durable + * `[routing]` notice in the window and updates lastAnnouncedLocus, so the + * next turn's announce-on-change diffs against what the agent was last + * told. Returns the notice as stored, for a live stream to inject with its + * boundary's other messages; undefined when the locus is already there or + * the notice couldn't be stored. + */ + private repinTurnLocus( + agent: Agent, + routing: TurnRouting, + channelId: string, + cause: string, + ): { participant: string; content: ContentBlock[]; metadata: MessageMetadata } | undefined { + if (routing.locus === channelId) return undefined; + const prevPin = routing.locus ?? null; + routing.locus = channelId; + this.lastAnnouncedLocus.set(agent.name, channelId); + const label = this.channelRegistry?.getDescriptor(channelId)?.label; + const shown = label && label !== channelId + ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})` + : channelId; + const content: ContentBlock[] = [{ + type: 'text', + text: + `[routing] The conversation moved to ${shown} — your plain ` + + 'speech now lands there for the rest of this turn. Other ' + + 'channels need an explicit send tool.', + }]; + const metadata = { system: true, kind: 'routing-notice' } as MessageMetadata; + try { + const id = agent.getContextManager().addMessage('user', content, metadata); + this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' }); + console.error(`[routing] ${agent.name}: ${cause} re-pin ${prevPin ?? '(none)'} -> ${channelId}`); + return { participant: 'user', content, metadata }; + } catch (err) { + console.error('turn locus re-pin: failed to record routing notice:', err); + return undefined; + } + } + /** * Durable window notice for a channel the agent did not ask to open — * subscription-policy admission, or an open forced by the agent's own @@ -9060,10 +9087,10 @@ export class AgentFramework { */ private requestProseContinuation(agent: Agent, routing: TurnRouting): void { // The flag may be shared with an earlier stream of the same logical - // turn (LogicalTurn); its deliveries are on the agent's chain ahead of + // turn (TurnRouting); its deliveries are on the agent's chain ahead of // this stream's, so by the time this check runs, they have run. - if (!routing.logical.continuation) return; - routing.logical.continuation = false; + if (!routing.continuation) return; + routing.continuation = false; // An explicit turn's check can run after its stream has returned. A // stopped framework, or an agent no longer registered under its name (a // disposed fork or ephemeral run), takes no new turn from it. @@ -9178,7 +9205,7 @@ export class AgentFramework { const normalized = envelope.replace(/^\s+(?=>>>)/, ''); const attempted = normalized.startsWith('>>>'); const parsed = parseHybridProsePrefix(normalized); - if (parsed.continueTurn) routing.logical.continuation = true; + if (parsed.continueTurn) routing.continuation = true; if (parsed.kind === 'private') { delete routing.pin; routing.hybridHold = true; @@ -9256,7 +9283,7 @@ export class AgentFramework { const parsed = parseProsePrefix(rawText); // A private round (one that called skip_reply) asks for no publication // or continuation, even when delivery runs after that turn has ended. - if (parsed.continueTurn && !round.keepPrivate) routing.logical.continuation = true; + if (parsed.continueTurn && !round.keepPrivate) routing.continuation = true; if (parsed.kind === 'private') { // The text already lives in the assistant message (window/chronicle); @@ -9425,6 +9452,8 @@ export class AgentFramework { trigger?: InferenceRequest, attempt = 0, providerGateAlreadyHeld = false, + /** The scheduler's requests this start consumes (beginAgentTurn). */ + batch: readonly InferenceRequest[] = [], ): Promise { const ownsProviderGate = !this.ephemeralRuns.has(agent.name) && !this.conversationAgentHomes.has(agent.name); @@ -9471,7 +9500,7 @@ export class AgentFramework { console.error(`[provider-admission] ${agent.name}: host quiesced while parked — wake requeued, not started`); return; } - await this.startAgentStream(agent, trigger, attempt, true); + await this.startAgentStream(agent, trigger, attempt, true, batch); }).catch((error) => { this.releasePrimaryProviderGate(agent.name); console.error(`[provider-cooldown] failed to resume primary for ${agent.name}:`, error); @@ -9504,7 +9533,7 @@ export class AgentFramework { this.activeTurnTriggers.set(agent.name, trigger); let tokenHandedOff = false; try { - tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate); + tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate, batch); } finally { if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) { this.activeTurnTokens.delete(agent.name); @@ -9525,8 +9554,11 @@ export class AgentFramework { attempt: number, turnToken: number, ownsProviderGate: boolean, + /** The scheduler's requests this start consumes, for a start that continues an open turn. */ + batch: readonly InferenceRequest[], ): Promise { - const continuingTurn = resumesLogicalTurn(trigger?.reason); + const startedAt = Date.now(); + const resumesTurn = 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 @@ -9544,7 +9576,7 @@ export class AgentFramework { // products — undoing the turn must not destroy them. if ( attempt === 0 && - !continuingTurn && + !resumesTurn && this.deferredMessages.length > 0 ) { { @@ -9575,7 +9607,7 @@ export class AgentFramework { // not yet run) and before the checkpoint (they are the turn's inputs). // Not on a context-budget restart: that continues the same logical turn. let assembly: AssemblyResult | undefined; - if (attempt === 0 && !continuingTurn && this.pushCoalescer?.pendingBatches()) { + if (attempt === 0 && !resumesTurn && this.pushCoalescer?.pendingBatches()) { try { assembly = await this.pushCoalescer.assemble(agent.name); } catch (err) { @@ -9622,45 +9654,48 @@ export class AgentFramework { this.redoStacks.delete(agent.name); // new work invalidates redo } - // Establish this turn's outbound routing locus (item-3 redux). Set it to the - // triggering channel for a channel/DM-triggered turn; clear it otherwise so - // a heartbeat / no-trigger turn doesn't inherit a previous turn's channel and - // instead falls back to the global default. A given agent runs one turn at a - // time, so a set here is only read during THIS turn's routeSpeech / - // buildChannelContext; retries re-run with the same trigger, re-setting it. - // A context-budget restart continues the SAME logical turn in a fresh - // stream — its trigger carries no channelId, and deleting the trigger - // channel here mid-logical-turn is exactly the hole that sent Sol's DM - // reply to a stale guild channel (2026-07-22): any live locus resolution - // after the restart fell through to the hours-old defaultPublishChannel. - // Keep the turn's trigger channel across restarts; only real new turns - // reset it. - if (!continuingTurn) { - if (trigger?.channelId) { - this.activeTriggerChannels.set(agent.name, trigger.channelId); + // This start's routing: its logical turn's (TurnRouting), which holds + // everything the turn has decided about where its speech goes. + // - A resume (a budget restart, a tool-result-guard retry) carries its + // turn's request on and keeps the turn's object as it is. + // - A silent start routes no prose. It gets an object of its own, with + // no locus, so it neither takes nor disturbs an open turn's. + // - Any other start continues the agent's open logical turn, if one is + // open (openLogical): a framework retry, the start that consumes tool + // results the non-streaming fallback left (whichever wake the + // scheduler starts it with), the later compile a provider-capacity + // hold defers a turn to. It takes the turn's object: the sticky + // destination, the destination and the hold, the ` !` and receipt + // tallies, the trigger channel, the locus with any re-pin, and the + // engaged channels. A delivery still queued from an earlier stream + // runs ahead of this one's on the agent's chain, so what it sets + // applies to this stream's prose, in the order the turn wrote them. + // With no turn open, the start opens one. + // A new object doesn't clear the previous turn's: prose that turn left + // queued still reads and writes its own. + let routing: TurnRouting; + let opensTurn = false; + if (resumesTurn) { + routing = this.routingOf(agent.name); + } else if (trigger?.suppressProse) { + routing = freshTurnRouting(startedAt); + opensTurn = true; + } else { + const open = this.openLogical.get(agent.name); + if (open) { + routing = open; } else { - this.activeTriggerChannels.delete(agent.name); + routing = freshTurnRouting(startedAt); + this.openLogical.set(agent.name, routing); + opensTurn = true; } } + this.turnRouting.set(agent.name, routing); - // FREEZE this turn's outbound locus now (turn-frozen routing). Resolved - // once — home channel for forks, else the triggering channel, else the - // global default — and never moved for the rest of the turn: mid-turn - // injections must not redirect the agent's plain prose (ambient chatter / - // reactions hijacked it before, 2026-07-21). A context-budget restart - // continues the same logical turn, so it keeps the existing pin. The - // eager snapshot also protects heartbeat turns from a moving - // defaultPublishChannel mid-turn. If the effective locus differs from the - // last one announced, drop a durable `[routing]` notice into the window - // BEFORE this turn compiles, so the agent always knows where its voice - // goes (announce-on-change only — no per-turn chatter, append-only for - // KV stability). - if (continuingTurn) { + if (resumesTurn) { const previousLogicalToolState = this.logicalTurnToolCalls.get(agent); this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 }); - } - - if (!continuingTurn) { + } else { if (attempt === 0) this.maybePrimeProseMode(agent); const previousLogicalToolState = this.logicalTurnToolCalls.get(agent); if (attempt === 0) { @@ -9670,69 +9705,63 @@ export class AgentFramework { // A framework retry is a new physical stream in the same logical turn. this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 }); } - // Fresh turn: forget the previous turn's explicit-send engagements. - // A restart continues the same logical turn and keeps them. - this.turnEngagedChannels.delete(agent.name); - // A new routing state for the new turn, in every mode: the sticky - // destination, the turn's destination, hybrid's hold and the receipt - // tallies start empty. The previous turn's object isn't cleared: prose - // it left queued still reads and writes its own. - const fresh = freshTurnRouting(); - // A start while the agent's logical turn is open continues it: it - // shares the earlier stream's ` !` and receipt tallies, which a - // delivery still queued there can write after this stream begins - // (openLogical). Otherwise this start opens one. A silent start does - // neither: it routes no prose, so it keeps a logical turn of its own, - // and an open turn waits for the next start that routes prose. - if (!trigger?.suppressProse) { - const open = this.openLogical.get(agent.name); - if (open) fresh.logical = open; - else this.openLogical.set(agent.name, fresh.logical); - } - this.turnRouting.set(agent.name, fresh); - 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.midTurnInputSignals.delete(agent.name); - this.turnLocusPins.delete(agent.name); - } else { + this.midTurnInputSignals.delete(agent.name); + } + + if (opensTurn) { + // The turn's trigger channel (item-3 redux): the channel of a + // channel/DM-triggered turn, and none for a heartbeat / no-trigger + // turn, which falls back to the global default rather than inheriting + // a previous turn's channel. + routing.triggerChannel = trigger?.channelId; + // FREEZE the turn's outbound locus now (turn-frozen routing): the home + // channel for forks, else the trigger channel, else the global default. + // Mid-turn injections must not redirect the agent's plain prose + // (ambient chatter and reactions hijacked it before, 2026-07-21), and + // the eager snapshot protects heartbeat turns from a moving + // defaultPublishChannel. Explicit mode uses a turn-scoped `>>` target + // and disabled mode has no prose target, so neither freezes or + // announces a locus. If the effective locus differs from the last one + // announced, a durable `[routing]` notice goes into the window BEFORE + // this turn compiles, so the agent always knows where its voice goes + // (announce-on-change only — no per-turn chatter, append-only for KV + // stability). + if (turnProseRouting !== 'explicit' && turnProseRouting !== 'disabled') { const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null; - if (locus !== null) this.turnLocusPins.set(agent.name, locus); - else this.turnLocusPins.delete(agent.name); - this.midTurnInputSignals.delete(agent.name); + if (locus !== null) routing.locus = locus; if (attempt === 0) this.announceLocusIfChanged(agent.name, locus); } + } else if (!resumesTurn && (turnProseRouting === 'locus' || turnProseRouting === 'hybrid')) { + // A start that continues the turn keeps its locus. Wakes that arrived + // after the turn opened can still move it, by the addressed re-pin a + // mid-turn boundary applies: that boundary sees only the messages it + // drains, and these were stored without one (during a capacity hold no + // turn is alive, so nothing defers them). The newest one that addressed + // the agent, or that came from a channel the turn engaged, moves the + // locus; ambient chatter and the turn's own held trigger don't. + let arrival: InferenceRequest | undefined; + for (const request of batch) { + if (!request.channelId || wakeEventAt(request) <= routing.openedAt) continue; + if (!request.addressed && !routing.engaged.has(request.channelId)) continue; + if (!arrival || wakeEventAt(request) >= wakeEventAt(arrival)) arrival = request; + } + if (arrival) this.repinTurnLocus(agent, routing, arrival.channelId!, 'continuing-start'); } - - // A context-budget restart normally preserves the turn locus, but a - // silent control-plane wake must remain locusless even if an explicit tool - // opened/pinned a channel mid-turn. - if (trigger?.suppressProse) { - this.turnLocusPins.delete(agent.name); - // Silent: no routing state. A continuing silent start keeps its turn's - // logical turn; a fresh one keeps the one of its own it was given. - const silent = freshTurnRouting(); - const logical = this.turnRouting.get(agent.name)?.logical; - if (logical) silent.logical = logical; - this.turnRouting.set(agent.name, silent); - } - // This start's own logical turn: a failure before its stream exists - // settles it, and no other start's. - const startLogical = this.turnRouting.get(agent.name)?.logical; + // A silent start, or a disabled agent's, has no locus to show, whatever a + // channel_open in a silent turn left on its object. + const turnLocus = turnProseRouting === 'disabled' ? undefined : routing.locus; this.touchEphemeralRun(agent.name, true); this.emitTrace({ type: 'inference:started', agentName: agent.name, - // The turn-frozen locus was pinned just above (kept across a - // context-budget restart, which skips the re-pin but re-emits this). - channelId: this.turnLocusPins.get(agent.name), + // The turn's locus, kept across a start that continues the turn. + channelId: turnLocus, ...(trigger?.suppressProse ? { silent: true } : {}), }); // A budget restart continues the same logical inference window. The // predecessor keeps EventGate liveness until its successor terminates. - if (!continuingTurn) { + if (!resumesTurn) { this.eventGate?.onInferenceStarted(agent.name); } this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() }); @@ -9749,7 +9778,7 @@ export class AgentFramework { const earlyTypingChannel = turnProseRouting === 'explicit' ? trigger?.channelId ?? null - : this.turnLocusPins.get(agent.name) ?? null; + : turnLocus ?? null; if (earlyTypingChannel) this.channelRegistry?.startTyping(earlyTypingChannel); try { @@ -9894,11 +9923,13 @@ export class AgentFramework { this.activeTurnTokens.delete(agent.name); this.activeTurnTriggers.delete(agent.name); } + // This start's own logical turn: a failure before its stream exists + // settles it, and no other start's. this.settleAgent(agent.name, { stopReason: 'exhausted', speech: '', error: err.message, - }, startLogical); + }, routing); this.emitTrace({ type: 'inference:exhausted', agentName: agent.name, @@ -9969,15 +10000,14 @@ export class AgentFramework { let generationLost = false; // ---- Present-while-acting turn state --------------------------------- - // Output locus for the WHOLE logical turn: frozen in startAgentStream - // (home → addressed trigger → global default) before this stream began, - // and never moved until the next turn. Mid-turn injections do not touch - // it — an ambient message or a reaction arriving at a tool boundary must - // not redirect the agent's task narration (2026-07-21 Cairn misroute). - // The pin lives in `turnLocusPins` so a context-budget restart (same - // logical turn, fresh driveStream) keeps it. - const resolveTurnLocus = (): string | null => - this.turnLocusPins.get(agent.name) ?? null; + // Output locus for the WHOLE logical turn: frozen when the turn opened + // (home → addressed trigger → global default), and moved only by a + // re-pin (an addressed message, or a follow-up in a channel the turn + // engaged) or the agent's own channel_open. Ambient messages and + // reactions arriving at a tool boundary must not redirect the agent's + // task narration (2026-07-21 Cairn misroute). The locus lives in the + // turn's routing state, so every stream of the turn shares it. + const resolveTurnLocus = (): string | null => streamRouting.locus ?? null; // Ordered delivery chain for live-routed prose. Links are enqueued // WITHOUT awaiting in the stream-event loop — an awaited network post @@ -9989,11 +10019,13 @@ export class AgentFramework { // // Every stream queues its deliveries behind the agent's latest link, so // the agent's posts go out in the order they were written, across a - // turn's streams (a budget restart, a tool-result-guard retry) and across - // turns. A turn that a tool ends (skip_reply) settles before its queued - // prose has posted; that prose keeps its own turn's routing state - // (turnRouting), and the next turn's posts wait behind it. - const resumesTurn = resumesLogicalTurn(trigger?.reason); + // turn's streams and across turns. So an earlier stream's deliveries, + // and the sticky destination or hold they set, come before this stream's + // own. A stream queues only while it is the agent's current stream: the + // event loop stops at the first event after a replacement. A turn that a + // tool ends (skip_reply) settles before its queued prose has posted; that + // prose keeps its own turn's routing state (turnRouting), and the next + // turn's posts wait behind it. let turnSpeechChain: Promise = this.proseSpeechChains.get(agent.name) ?? Promise.resolve(); this.proseSpeechChains.set(agent.name, turnSpeechChain); const chainSpeech = (link: () => Promise, failure: string): void => { @@ -10085,14 +10117,14 @@ export class AgentFramework { ? new ProseStreamRouter({ 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) 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. + // so a fresh turn's stream starts suppressed. A later stream of the + // turn (a resume, a retry, a re-inference, a capacity-deferred + // compile) 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 ? streamRouting.destination : null) + ? streamRouting.destination : typingChannel, resolve: (spec) => { const r = this.channelRegistry!.resolveProseTarget(spec); @@ -10672,7 +10704,7 @@ export class AgentFramework { .filter((block: ContentBlock): block is ContentBlock & { type: 'text' } => block.type === 'text') .map((block) => block.text) .join('\n'), - }, streamRouting.logical); + }, streamRouting); this.emitTrace({ type: 'inference:completed', @@ -11066,7 +11098,7 @@ export class AgentFramework { stopReason: 'exhausted', speech: '', error: 'Turn abandoned by operator quiesce', - }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting.logical : undefined); + }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting : undefined); this.emitTrace({ type: 'inference:aborted', agentName: agent.name, @@ -11095,7 +11127,7 @@ export class AgentFramework { stopReason: 'exhausted', speech: '', error: err.message, - }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting.logical : undefined); + }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting : undefined); this.emitTrace({ type: 'inference:exhausted', agentName: agent.name, @@ -11149,7 +11181,7 @@ export class AgentFramework { stopReason: 'exhausted', speech: '', error: 'Turn abandoned by operator quiesce', - }, streamRouting.logical); + }, streamRouting); } this.emitTrace({ type: 'inference:aborted', @@ -11193,7 +11225,7 @@ export class AgentFramework { stopReason: 'exhausted', speech: '', error: `Stream aborted: ${reason}`, - }, streamRouting.logical); + }, streamRouting); this.emitTrace({ type: 'inference:exhausted', agentName: agent.name, @@ -11339,7 +11371,7 @@ export class AgentFramework { stopReason: 'exhausted', speech: '', error: err.message, - }, streamRouting.logical); + }, streamRouting); this.emitTrace({ type: 'inference:exhausted', agentName: agent.name, @@ -13759,7 +13791,7 @@ export class AgentFramework { // a reply falls back to the process-global most-recent-inbound locus and // a concurrent message in another channel hijacks it. Empty for // heartbeat / no-trigger turns → correct global fallback. - activeChannelResolver: (agentName) => this.activeTriggerChannels.get(agentName), + activeChannelResolver: (agentName) => this.turnRouting.get(agentName)?.triggerChannel, // A text-only turn whose speech couldn't be delivered must not vanish // silently: record a `[discord-send-failed]` marker in chronicle so the // agent sees, on her next turn, that her reply never reached the human. @@ -15521,12 +15553,7 @@ export class AgentFramework { // the microtask queue, i.e. before the tool-result event // that evaluates injections. if (status !== 'unknown-channel' && status !== 'ambiguous') { - let engaged = this.turnEngagedChannels.get(agentName); - if (!engaged) { - engaged = new Set(); - this.turnEngagedChannels.set(agentName, engaged); - } - engaged.add(channelId ?? target); + this.routingOf(agentName).engaged.add(channelId ?? target); } if (status === 'opened') { console.error( @@ -15652,10 +15679,10 @@ export class AgentFramework { // A successful channel_open plants the agent's feet in the opened // channel. The agent's own deliberate open is the strongest "my next // words go here" signal in the system — stronger than any injection — - // so it MOVES the current turn's prose pin (turnLocusPins), not just - // the next turn's trigger state. (The original 2026-07-21 Aria fix - // set only activeTriggerChannels, which resolveLocus reads at the - // NEXT turn's freeze — the turn-frozen refactor had silently + // so it MOVES the current turn's locus (TurnRouting.locus), not just + // its trigger channel. (The original 2026-07-21 Aria fix set only + // the trigger channel, which resolveLocus reads at the NEXT turn's + // freeze — the turn-frozen refactor had silently // regressed the "reply right after opening" case; found while // mapping the routing logic 2026-07-31, antra-ratified fix.) The // announcement rides THIS TOOL RESULT — model-requested content, @@ -15667,10 +15694,11 @@ export class AgentFramework { (result.data as { channelId?: string } | undefined)?.channelId ?? (call.input as { channelId?: string } | undefined)?.channelId; if (opened) { - this.activeTriggerChannels.set(agentName, opened); + const openerRouting = this.routingOf(agentName); + openerRouting.triggerChannel = opened; const openerAgent = this.agents.get(agentName); if (openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) { - this.turnLocusPins.set(agentName, opened); + openerRouting.locus = opened; this.lastAnnouncedLocus.set(agentName, opened); result = { ...result, @@ -15953,7 +15981,7 @@ export class AgentFramework { console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`); } } else { - const locus = this.turnLocusPins.get(agentName) ?? null; + const locus = this.turnRouting.get(agentName)?.locus ?? null; this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => { console.error('[sleep] announce failed:', err instanceof Error ? err.message : err); }); @@ -16830,7 +16858,7 @@ export class AgentFramework { // Conversation identity = the agent (a trunk agent IS its own conversation; // forks get their own agent). The turn's channel LOCUS — the "proper // conversation tracking" this once flagged as a TODO — is now tracked - // per-agent in `activeTriggerChannels` and surfaced to the agent via + // as the turn's trigger channel (TurnRouting) and surfaced to the agent via // buildChannelContext (channels.defaultOutgoing) below, so the agent is // told the same channel its speech will route to (item-3 redux). conversationId: agent.name, diff --git a/src/mcpl/prose-stream-router.ts b/src/mcpl/prose-stream-router.ts index 1814b9071..0224334ca 100644 --- a/src/mcpl/prose-stream-router.ts +++ b/src/mcpl/prose-stream-router.ts @@ -21,8 +21,8 @@ * * 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). + * a later stream of the 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. @@ -54,7 +54,7 @@ export class ProseStreamRouter { mode: 'explicit' | 'hybrid' | 'locus'; /** 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. */ + * later stream of the turn. */ initialTarget: string | null; /** Resolve a `>>spec` to a channelId, or null (unresolved/ambiguous). */ resolve: (spec: string) => string | null; diff --git a/src/types/trace.ts b/src/types/trace.ts index 67b28a02b..2f19fc6e4 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -41,7 +41,7 @@ export type TraceEvent = type: 'inference:started'; agentName: string; /** - * The turn-frozen outbound locus (see turnLocusPins) — the channel this + * The turn-frozen outbound locus (TurnRouting.locus) — the channel this * turn's plain prose will be routed to. Omitted for turns with no locus * (heartbeats with no default channel). Observability only, like every * trace field; lets external taps (e.g. a TTS relay) tag the whole diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index b5b325520..74c68f46c 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -27,7 +27,8 @@ import type { 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'; +import { MembraneError } from '@animalabs/membrane'; +import type { ContentBlock, NormalizedRequest } from '@animalabs/membrane'; class ToolboxModule implements Module { readonly name = 'robot'; @@ -355,8 +356,14 @@ describe('explicit prose routing', () => { type RoutingState = { pin?: string; destination: string | null; hybridHold: boolean; - logical: { continuation: boolean; deliveries: string[]; suppressed: number }; + continuation: boolean; deliveries: string[]; suppressed: number; + triggerChannel?: string; locus?: string; engaged: Set; openedAt: number; }; + /** A turn's routing state, as the framework makes a fresh one. */ + const routingState = (fields: Partial = {}): RoutingState => ({ + destination: null, hybridHold: false, continuation: false, deliveries: [], suppressed: 0, + engaged: new Set(), openedAt: Date.now(), ...fields, + }); async function within(promise: Promise, label: string, ms = 5000): Promise { let timer: NodeJS.Timeout | undefined; @@ -512,8 +519,8 @@ describe('explicit prose routing', () => { appendProseDeliveryReceipt(agent: unknown, routing: RoutingState): void; }; const agent = framework.getAgent('assistant')!; - const earlier: RoutingState = { destination: null, hybridHold: false, logical: { continuation: false, deliveries: [], suppressed: 0 } }; - const next: RoutingState = { ...earlier, logical: { continuation: false, deliveries: [], suppressed: 0 } }; + const earlier: RoutingState = routingState(); + const next: RoutingState = routingState(); gateway.turnRouting.set('assistant', earlier); const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; const route = registry.routeSpeech as (agent: string, text: string, locus?: string | null) => Promise; @@ -568,15 +575,15 @@ describe('explicit prose routing', () => { await withQueuedTurns('explicit', '>>#alpha ! late', [ createMockResponse([{ type: 'text', text: '>>#cafe next' }]), ], async ({ earlier, next, turns, release, drain }) => { - assert.equal(earlier.logical.continuation, false, 'the continuation has not run before release'); + assert.equal(earlier.continuation, false, 'the continuation has not run before release'); release(); for (const end = Date.now() + 5000; turns() < 3 && Date.now() < end;) { await new Promise((resolve) => setTimeout(resolve, 5)); } await drain(); assert.equal(turns(), 3, 'the continuation starts its own turn, once'); - assert.equal(earlier.logical.continuation, false, "the earlier turn's request was taken from its own object"); - assert.equal(next.logical.continuation, false, 'the next turn never receives that continuation'); + assert.equal(earlier.continuation, false, "the earlier turn's request was taken from its own object"); + assert.equal(next.continuation, false, 'the next turn never receives that continuation'); }); }); @@ -693,7 +700,7 @@ describe('explicit prose routing', () => { const internals = framework as unknown as { turnRouting: Map; pendingRequests: InferenceRequest[]; proseSpeechChains: Map>; - openLogical: Map; + openLogical: Map; }; let release!: () => void; const held = new Promise((resolve) => { release = resolve; }); @@ -702,11 +709,11 @@ describe('explicit prose routing', () => { // its queued delivery runs; the turn goes on in a re-inference, which // starts a new stream with a fresh routing state while the earlier // stream's logical turn is still open. - const earlier: RoutingState = { destination: null, hybridHold: false, logical: { continuation: when === 'already set', deliveries: [], suppressed: 0 } }; + const earlier: RoutingState = routingState({ continuation: when === 'already set', locus: 'world:commons' }); internals.turnRouting.set('assistant', earlier); - internals.openLogical.set('assistant', earlier.logical); + internals.openLogical.set('assistant', earlier); if (when === 'set late') { - internals.proseSpeechChains.set('assistant', held.then(() => { earlier.logical.continuation = true; })); + internals.proseSpeechChains.set('assistant', held.then(() => { earlier.continuation = true; })); } internals.pendingRequests.push({ agentName: 'assistant', reason: 'tool_results_ready', source: 'framework', timestamp: Date.now() } as InferenceRequest); const running = framework.runUntilIdle(); @@ -715,7 +722,7 @@ describe('explicit prose routing', () => { await new Promise((resolve) => setTimeout(resolve, 5)); } assert.equal(turns, 1, 'the re-inference has started'); - assert.equal(earlier.logical.continuation, false, 'the earlier delivery has not run as the re-inference starts'); + assert.equal(earlier.continuation, false, 'the earlier delivery has not run as the re-inference starts'); release(); } await within(running, 'the re-inference to settle'); @@ -725,7 +732,7 @@ describe('explicit prose routing', () => { } assert.deepEqual(routed.map((r) => r.text.trim()), ['carrying on']); assert.equal(turns, 2, 'the re-inference completes, then the continuation it carried starts'); - assert.equal(earlier.logical.continuation, false, "the check took the earlier stream's flag"); + assert.equal(earlier.continuation, false, "the check took the earlier stream's flag"); } finally { release(); await framework.stop(); @@ -744,25 +751,26 @@ describe('explicit prose routing', () => { const internals = framework as unknown as { turnRouting: Map; pendingRequests: InferenceRequest[]; proseSpeechChains: Map>; - openLogical: Map; + openLogical: Map; }; let release!: () => void; const held = new Promise((resolve) => { release = resolve; }); try { // The earlier stream left its tool results pending, its logical turn // still open; its delivery lands only after the re-inference has - // begun, into the logical turn the two share. - const earlier: RoutingState = { destination: null, hybridHold: false, logical: { continuation: false, deliveries: [], suppressed: 0 } }; + // begun, into the logical turn the two share. The turn froze the + // registry's locus when it opened. + const earlier: RoutingState = routingState({ locus: 'world:commons' }); internals.turnRouting.set('assistant', earlier); - internals.openLogical.set('assistant', earlier.logical); - internals.proseSpeechChains.set('assistant', held.then(() => { earlier.logical.deliveries.push('chan-alpha'); })); + internals.openLogical.set('assistant', earlier); + internals.proseSpeechChains.set('assistant', held.then(() => { earlier.deliveries.push('chan-alpha'); })); internals.pendingRequests.push({ agentName: 'assistant', reason: 'tool_results_ready', source: 'framework', timestamp: Date.now() } as InferenceRequest); const running = framework.runUntilIdle(); for (const end = Date.now() + 5000; turns < 1 && Date.now() < end;) { await new Promise((resolve) => setTimeout(resolve, 5)); } assert.equal(turns, 1, 'the re-inference has started'); - assert.deepEqual(earlier.logical.deliveries, [], 'the earlier delivery has not landed yet'); + assert.deepEqual(earlier.deliveries, [], 'the earlier delivery has not landed yet'); release(); await within(running, 'the re-inference to settle'); assert.deepEqual(routed.map((r) => r.text.trim()), ['carrying on']); @@ -872,7 +880,7 @@ describe('explicit prose routing', () => { pendingRequests: InferenceRequest[]; handleProcessEvent(event: ProcessEvent): Promise; }; - const origin = internals.routingOf('assistant').logical; + const origin = internals.routingOf('assistant'); origin.continuation = true; origin.deliveries.push('chan-alpha'); // The turn that ran the tools is open, as its fresh start left it. @@ -901,11 +909,11 @@ describe('explicit prose routing', () => { it("a settle closes the open logical turn only when it is the settling stream's own, and stop() clears open turns", async () => { const framework = await createFramework('hybrid'); const internals = framework as unknown as { - openLogical: Map; - settleAgent(name: string, outcome: { stopReason: 'completed'; speech: string }, closes: RoutingState['logical'] | undefined): void; + openLogical: Map; + settleAgent(name: string, outcome: { stopReason: 'completed'; speech: string }, closes: RoutingState | undefined): void; }; - const open: RoutingState['logical'] = { continuation: true, deliveries: [], suppressed: 0 }; - const stale: RoutingState['logical'] = { continuation: false, deliveries: [], suppressed: 0 }; + const open: RoutingState = routingState({ continuation: true }); + const stale: RoutingState = routingState(); internals.openLogical.set('assistant', open); // A settle from a stream a newer one replaced passes its own logical // turn, or none; neither closes the open one. @@ -919,7 +927,7 @@ describe('explicit prose routing', () => { assert.equal(internals.openLogical.size, 0, 'stop() clears every open logical turn'); }); - it('a silent start leaves an open logical turn to the next start that routes prose', async () => { + it('a silent start neither takes nor disturbs an open logical turn, and leaves it to the next start that routes prose', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'quiet' }] as ContentBlock[])); const framework = await createFramework('hybrid'); stubRegistry(framework); @@ -929,19 +937,26 @@ describe('explicit prose routing', () => { }); const internals = framework as unknown as { turnRouting: Map; pendingRequests: InferenceRequest[]; - openLogical: Map; + openLogical: Map; }; try { - const open: RoutingState['logical'] = { continuation: true, deliveries: [], suppressed: 0 }; + // Everything the open turn has decided about where its speech goes. + const decided = (): RoutingState => routingState({ + continuation: true, pin: 'chan-alpha', hybridHold: true, triggerChannel: 'chan-alpha', + locus: 'world:commons', engaged: new Set(['discord:g:cafe']), openedAt: 1, + }); + const open = decided(); internals.openLogical.set('assistant', open); internals.pendingRequests.push({ agentName: 'assistant', reason: 'heartbeat', source: 'framework', timestamp: Date.now(), suppressProse: true } as InferenceRequest); // Bounded: a silent start that took the open turn's ' !' would start a // continuation with no response queued, and this would wait for good. await within(framework.runUntilIdle(), 'the silent turn to settle', 3000); assert.equal(turns, 1); - assert.notEqual(internals.turnRouting.get('assistant')?.logical, open, 'the silent start kept a logical turn of its own'); + const silent = internals.turnRouting.get('assistant'); + assert.notEqual(silent, open, 'the silent start kept a logical turn of its own'); + assert.equal(silent?.locus, undefined, 'with no locus'); assert.equal(internals.openLogical.get('assistant'), open, 'and neither took nor closed the open one'); - assert.equal(open.continuation, true, "the open turn's ' !' is untouched"); + assert.deepEqual(open, decided(), "everything the open turn decided is untouched, its ' !' included"); // The next start that routes prose continues it, and its ' !' runs. // (feedTurn counts starts from here: the prose turn is 1, the // continuation 2.) @@ -959,6 +974,33 @@ describe('explicit prose routing', () => { } }); + it("a silent turn's resume shows no locus, whatever a channel_open in that turn left on its routing state", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'quiet' }] as ContentBlock[])); + const framework = await createFramework('hybrid'); + stubRegistry(framework); + const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; + const typing: string[] = []; + registry.startTyping = (channelId: string) => { typing.push(channelId); }; + const started: Array = []; + framework.onTrace((event) => { + if (event.type === 'inference:started') started.push((event as { channelId?: string }).channelId); + }); + const internals = framework as unknown as { + turnRouting: Map; pendingRequests: InferenceRequest[]; + }; + try { + // A silent turn opened a channel, which moved its routing state's + // locus, and its stream then crossed the budget. + internals.turnRouting.set('assistant', routingState({ locus: 'discord:g:cafe' })); + internals.pendingRequests.push({ agentName: 'assistant', reason: 'context_budget_restart', source: 'framework', timestamp: Date.now(), suppressProse: true } as InferenceRequest); + await within(framework.runUntilIdle(), 'the silent restart', 3000); + assert.deepEqual(started, [undefined], 'the restart reports no locus'); + assert.deepEqual(typing, [], 'and shows no typing anywhere'); + } finally { + await framework.stop(); + } + }); + for (const mode of ['explicit', 'hybrid'] as const) { it(`${mode}: the wake that consumes unstreamed tool results continues the turn that scheduled them`, async () => { const p = mode === 'explicit' ? '>>' : '>>>'; @@ -972,15 +1014,15 @@ describe('explicit prose routing', () => { feedTurn(framework, 2, [{ type: 'text', text: `${p}skip_reply` }] as ContentBlock[]); const internals = framework as unknown as { turnRouting: Map; - openLogical: Map; + openLogical: Map; }; try { // The scheduling turn asked for a continuation and left its tool // results pending; the scheduler then starts the agent with another // wake, which consumes them. - const scheduling: RoutingState = { destination: null, hybridHold: false, logical: { continuation: true, deliveries: [], suppressed: 0 } }; - internals.openLogical.set('assistant', scheduling.logical); - internals.turnRouting.set('assistant', { destination: null, hybridHold: false, logical: { continuation: false, deliveries: [], suppressed: 0 } }); + const scheduling: RoutingState = routingState({ continuation: true }); + internals.openLogical.set('assistant', scheduling); + internals.turnRouting.set('assistant', routingState()); trigger(framework); await framework.runUntilIdle(); for (const end = Date.now() + 5000; turns < 2 && Date.now() < end;) { @@ -988,7 +1030,7 @@ describe('explicit prose routing', () => { await framework.runUntilIdle(); } assert.equal(turns, 2, "the consuming turn took the scheduling turn's ' !'"); - assert.equal(scheduling.logical.continuation, false); + assert.equal(scheduling.continuation, false); assert.equal(internals.openLogical.has('assistant'), false, 'and the turns it ran have settled'); } finally { await framework.stop(); @@ -1030,7 +1072,7 @@ describe('explicit prose routing', () => { await within(reached, 'the queued delivery to reach routing'); const chain = internals.proseSpeechChains.get('assistant')!; const routing = internals.turnRouting.get('assistant')!; - assert.equal(routing.logical.continuation, true, "the held delivery's ' !' is on its turn's object"); + assert.equal(routing.continuation, true, "the held delivery's ' !' is on its turn's object"); assert.equal(internals.pendingRequests.length, 0); if (boundary === 'stop') await framework.stop(); else internals.disposeConversationAgent('assistant'); @@ -1039,7 +1081,7 @@ describe('explicit prose routing', () => { await new Promise((resolve) => setImmediate(resolve)); assert.equal(internals.pendingRequests.filter((r) => r.reason === 'prose-continuation').length, 0, 'the late check adds no request'); - assert.equal(routing.logical.continuation, false, 'it took the flag and dropped it'); + assert.equal(routing.continuation, false, 'it took the flag and dropped it'); } finally { release(); await framework.stop(); @@ -1086,7 +1128,7 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); const internals = framework as unknown as { deliverProse(agent: unknown, text: string, round?: { keepPrivate?: boolean }): Promise; - turnRouting: Map; + turnRouting: Map; }; const agent = framework.getAgent('assistant')!; @@ -1094,12 +1136,12 @@ describe('explicit prose routing', () => { // 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.turnRouting.get('assistant')?.logical.continuation ?? false, false, 'and no continuation is asked for'); + assert.equal(internals.turnRouting.get('assistant')?.continuation ?? false, 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.turnRouting.get('assistant')?.logical.continuation, true); + assert.equal(internals.turnRouting.get('assistant')?.continuation, true); await framework.stop(); }); @@ -1201,8 +1243,7 @@ describe('explicit prose routing', () => { try { for (const segments of rounds) { for (const start of [null, 'chan-alpha']) { - internals.turnRouting.set('assistant', { pin: start ?? undefined, destination: null, hybridHold: false, - logical: { continuation: false, deliveries: [], suppressed: 0 } }); + internals.turnRouting.set('assistant', routingState({ pin: start ?? undefined })); for (const segment of segments) await internals.deliverProse(agent, segment); const admitted = segments.flatMap((segment) => internals.admitProse(segment)); assert.equal(internals.roundDestination(start, admitted), internals.turnRouting.get('assistant')?.pin ?? null, @@ -1228,9 +1269,8 @@ describe('explicit prose routing', () => { try { for (const text of cases) { routed.length = 0; - const queued: RoutingState = { destination: null, hybridHold: false, - logical: { continuation: false, deliveries: [], suppressed: 0 } }; - const live: RoutingState = { ...queued, logical: { continuation: false, deliveries: [], suppressed: 0 } }; + const queued: RoutingState = routingState(); + const live: RoutingState = routingState(); gateway.turnRouting.set('assistant', live); await gateway.deliverHybridProse(agent, text, 'chan-locus', true, queued); assert.equal(queued.hybridHold, true, 'the supplied turn receives the hold'); @@ -1257,17 +1297,16 @@ describe('explicit prose routing', () => { try { for (const prefix of ['>>>', '>>> ', '>>>\t']) { routed.length = 0; - const queued: RoutingState = { destination: null, hybridHold: false, - logical: { continuation: false, deliveries: [], suppressed: 0 } }; - const live: RoutingState = { ...queued, logical: { continuation: false, deliveries: [], suppressed: 0 } }; + const queued: RoutingState = routingState(); + const live: RoutingState = routingState(); gateway.turnRouting.set('assistant', live); await gateway.deliverHybridProse(agent, '>>>#alpha first', 'chan-locus', true, queued); await gateway.deliverHybridProse(agent, prefix, 'chan-locus', true, queued); await gateway.deliverHybridProse(agent, 'secret', 'chan-locus', true, queued); - assert.deepEqual(queued.logical.deliveries, ['chan-alpha'], 'the supplied turn records its delivery'); - assert.equal(queued.logical.suppressed, 1, 'the supplied turn records its suppression'); - assert.deepEqual(live.logical.deliveries, [], 'the live turn receives no delivery'); - assert.equal(live.logical.suppressed, 0, 'the live turn receives no suppression'); + assert.deepEqual(queued.deliveries, ['chan-alpha'], 'the supplied turn records its delivery'); + assert.equal(queued.suppressed, 1, 'the supplied turn records its suppression'); + assert.deepEqual(live.deliveries, [], 'the live turn receives no delivery'); + assert.equal(live.suppressed, 0, 'the live turn receives no suppression'); const router = new ProseStreamRouter({ mode: 'hybrid', initialTarget: 'chan-locus', resolve }); const streamed = [ ...router.feed('>>>#alpha first'), ...router.endSegment(), @@ -1581,4 +1620,249 @@ describe('explicit prose routing', () => { await framework.stop(); }); + // ---- A start that continues a logical turn keeps the turn's routing ---- + // A framework retry after a stream error, the compile a provider-capacity + // hold defers a turn to, and the re-inference that consumes unstreamed tool + // results each continue the turn: the sticky destination, explicit mode's + // turn destination and hybrid's hold carry into the new stream. + + /** An organization acceleration 429, which holds the turn for a later compile. */ + const accelerationLimit = (request: NormalizedRequest): MembraneError => new MembraneError({ + type: 'rate_limit', retryable: true, httpStatus: 429, + message: "This request would exceed your organization's maximum usage increase rate for input tokens per minute", + rawError: { status: 429 }, rawRequest: request, + }); + + /** + * A turn whose first stream writes `first` and calls a tool, and then + * fails instead of completing: with a transient error, which the framework + * retries, or with an acceleration 429, which holds the turn until a later + * compile. That later stream answers `carrying on` with no prefix, as a + * model continuing its turn would. + */ + async function continueTurn(how: 'retry' | 'capacity', mode: 'explicit' | 'hybrid', first: string) { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: first }, + { type: 'tool_use', id: 'continued-tool', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([] as ContentBlock[])); + const framework = await createFramework(mode); + const routed = stubRegistry(framework); + const { chunks } = captureStream(framework); + const internals = framework as unknown as { + providerAccelerationCooldowns: Map; + releaseProviderAccelerationCooldown(agentName: string): void; + }; + let starts = 0; + let laterChunks = 0; + framework.onTrace((event) => { + if (event.type !== 'inference:started') return; + starts++; + if (starts === 2) { + laterChunks = chunks.length; + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'carrying on' }])); + } + // A bounce wakes the resident; that turn stays quiet. + if (starts >= 3) membrane.pushResponse(createMockResponse([{ type: 'text', text: mode === 'explicit' ? '>>skip_reply' : '>>>skip_reply' }])); + }); + const makeStream = membrane.streamYielding.bind(membrane); + let streams = 0; + membrane.streamYielding = (request, options) => { + const stream = makeStream(request, options); + const index = ++streams; + const iterate = stream[Symbol.asyncIterator].bind(stream); + stream[Symbol.asyncIterator] = async function* () { + for await (const event of { [Symbol.asyncIterator]: iterate }) { + if (event.type === 'complete' && index === 1) { + yield { + type: 'error' as const, + error: how === 'retry' ? new Error('synthetic transient stream error') : accelerationLimit(request), + }; + return; + } + yield event; + } + }; + return stream; + }; + trigger(framework); + await within(framework.runUntilIdle(), 'the first stream', 10000); + if (how === 'capacity') { + assert.equal(starts, 1, 'the hold defers the turn rather than retrying it at once'); + assert.ok(internals.providerAccelerationCooldowns.has('assistant'), 'the turn is held'); + // What the cooldown's timer does when it fires. + internals.releaseProviderAccelerationCooldown('assistant'); + await within(framework.runUntilIdle(), 'the deferred compile', 10000); + } + assert.ok(starts >= 2, 'the turn went on in a later stream'); + return { framework, routed, bounces: context(framework).bounces, laterChunks: chunks.slice(laterChunks) }; + } + + for (const how of ['retry', 'capacity'] as const) { + const start = how === 'retry' ? 'an error retry' : 'the compile a capacity hold defers the turn to'; + for (const [label, mode, first, expected] of [ + ['hybrid >>>skip_reply keeps later unprefixed prose private', 'hybrid', '>>>skip_reply an aside', []], + ['hybrid >>>#alpha is sticky', 'hybrid', '>>>#alpha first', [{ text: 'first', locus: 'chan-alpha' }, { text: 'carrying on', locus: 'chan-alpha' }]], + ['explicit >>#alpha is sticky, and the stream starts at the turn\'s destination', 'explicit', '>>#alpha first', [{ text: 'first', locus: 'chan-alpha' }, { text: 'carrying on', locus: 'chan-alpha' }]], + ] as const) { + it(`across ${start}, ${label}`, async () => { + const { framework, routed, bounces, laterChunks } = await continueTurn(how, mode, first); + try { + // Publication as the registry stub saw it; bounce notices by their kind. + assert.deepEqual(routed.map((r) => ({ text: r.text.trim(), locus: r.locus })), expected); + assert.equal(bounces, 0, 'nothing bounced'); + if (mode === 'explicit') { + // The later stream streams its unprefixed text where delivery sends it. + assert.deepEqual(laterChunks.map((c) => c.channelId), laterChunks.map(() => 'chan-alpha')); + assert.equal(laterChunks.map((c) => c.delta).join(''), 'carrying on'); + } + } finally { + await framework.stop(); + } + }); + } + } + + for (const [label, mode, left, expected] of [ + ['hybrid: the hold keeps later unprefixed prose private', 'hybrid', { hybridHold: true, locus: 'world:commons' }, []], + ['hybrid: the sticky destination', 'hybrid', { pin: 'chan-alpha', locus: 'world:commons' }, [{ text: 'carrying on', locus: 'chan-alpha' }]], + ['explicit: the sticky destination, and the stream starts at the turn\'s destination', 'explicit', { pin: 'chan-alpha', destination: 'chan-alpha' }, [{ text: 'carrying on', locus: 'chan-alpha' }]], + ] as const) { + it(`a re-inference on unstreamed tool results keeps the turn's routing: ${label}`, async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'carrying on' }] as ContentBlock[])); + const framework = await createFramework(mode); + const routed = stubRegistry(framework); + const { chunks } = captureStream(framework); + // A bounce wakes the resident; that turn stays quiet. + feedTurn(framework, 2, [{ type: 'text', text: mode === 'explicit' ? '>>skip_reply' : '>>>skip_reply' }] as ContentBlock[]); + const internals = framework as unknown as { + turnRouting: Map; pendingRequests: InferenceRequest[]; + openLogical: Map; + }; + try { + // The turn's earlier stream left this routing state, and its tool + // results pending for the non-streaming fallback's re-inference. + const earlier = routingState(left); + internals.turnRouting.set('assistant', earlier); + internals.openLogical.set('assistant', earlier); + internals.pendingRequests.push({ agentName: 'assistant', reason: 'tool_results_ready', source: 'framework', timestamp: Date.now() } as InferenceRequest); + await within(framework.runUntilIdle(), 'the re-inference', 10000); + assert.deepEqual(routed.map((r) => ({ text: r.text.trim(), locus: r.locus })), expected); + assert.equal(context(framework).bounces, 0, 'nothing bounced'); + if (mode === 'explicit') { + assert.deepEqual(chunks.map((c) => c.channelId), chunks.map(() => 'chan-alpha')); + assert.equal(chunks.map((c) => c.delta).join(''), 'carrying on'); + } + } finally { + await framework.stop(); + } + }); + } + + for (const mode of ['explicit', 'hybrid'] as const) { + it(`${mode}: a retried turn's text waits for the replaced stream's late deliveries, and follows what they set`, async () => { + const p = mode === 'explicit' ? '>>' : '>>>'; + // The first stream's round one posts to #cafe, and the post is held. + // Round two sets where the turn goes next (explicit: #alpha; hybrid: a + // skip_reply hold); its delivery is queued behind the held post. Then + // the stream errors, and its retry starts while both are pending. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: `${p}#cafe zero` }, + { type: 'tool_use', id: 'late-one', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: mode === 'explicit' ? '>>#alpha first' : '>>>skip_reply an aside' }, + { type: 'tool_use', id: 'late-two', name: 'robot--move', input: { dir: 'south' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([] as ContentBlock[])); + const framework = await createFramework(mode); + const routed = stubRegistry(framework); + const { chunks } = captureStream(framework); + const internals = framework as unknown as { + turnRouting: Map; + channelRegistry: { routeSpeech: (agent: string, text: string, locus?: string | null) => Promise }; + }; + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + const route = internals.channelRegistry.routeSpeech; + internals.channelRegistry.routeSpeech = async (agent, text, locus) => { + const outcome = await route(agent, text, locus); + if (text.trim() === 'zero') await held; + return outcome; + }; + let starts = 0; + let retryChunks = 0; + framework.onTrace((event) => { + if (event.type !== 'inference:started') return; + starts++; + if (starts === 2) { + retryChunks = chunks.length; + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'carrying on' }])); + } + if (starts >= 3) membrane.pushResponse(createMockResponse([{ type: 'text', text: `${p}skip_reply` }])); + }); + let retryCompleted!: () => void; + const retryComplete = new Promise((resolve) => { retryCompleted = resolve; }); + const makeStream = membrane.streamYielding.bind(membrane); + let streams = 0; + membrane.streamYielding = (request, options) => { + const stream = makeStream(request, options); + const index = ++streams; + const iterate = stream[Symbol.asyncIterator].bind(stream); + stream[Symbol.asyncIterator] = async function* () { + for await (const event of { [Symbol.asyncIterator]: iterate }) { + if (event.type === 'complete' && index === 1) { + yield { type: 'error' as const, error: new Error('synthetic transient stream error') }; + return; + } + if (event.type === 'complete' && index === 2) retryCompleted(); + yield event; + } + }; + return stream; + }; + let running: Promise | undefined; + try { + trigger(framework); + running = framework.runUntilIdle(); + void running.catch(() => {}); + await within(retryComplete, 'the retry to finish its response', 10000); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(starts, 2, 'the retry has started'); + assert.deepEqual(routed.map((r) => r.text.trim()), ['zero'], "the retry's text waits behind the held post"); + const routing = internals.turnRouting.get('assistant')!; + if (mode === 'explicit') { + assert.equal(routing.pin, 'discord:g:cafe', "round two's delivery hasn't run yet"); + assert.equal(routing.destination, 'chan-alpha', 'though round two set the destination as it ended'); + } else { + assert.equal(routing.hybridHold, false, "round two's delivery hasn't run yet"); + } + release(); + await within(running, 'the retried turn to settle', 10000); + const posts = routed.map((r) => ({ text: r.text.trim(), locus: r.locus })); + if (mode === 'explicit') { + assert.deepEqual(posts, [ + { text: 'zero', locus: 'discord:g:cafe' }, + { text: 'first', locus: 'chan-alpha' }, + { text: 'carrying on', locus: 'chan-alpha' }, + ], "the replaced stream's posts go first, and the retry's text follows the destination they set"); + assert.deepEqual(chunks.slice(retryChunks).map((c) => c.channelId), chunks.slice(retryChunks).map(() => 'chan-alpha'), + "the retry's stream starts at the destination round two set"); + } else { + assert.deepEqual(posts, [{ text: 'zero', locus: 'discord:g:cafe' }], + "the retry's unprefixed text stays private under the hold the late delivery set"); + const receipt = context(framework).texts.filter((t) => t.startsWith('[delivered]')); + assert.equal(receipt.length, 1, `one receipt for the logical turn: ${receipt.join(' | ')}`); + assert.match(receipt[0]!, /discord:g:cafe/, "it reports the replaced stream's late post"); + assert.match(receipt[0]!, /1 plain-speech segment\(s\) suppressed/, "and the retry's held segment"); + } + assert.equal(context(framework).bounces, 0, 'nothing bounced'); + } finally { + release(); + if (running) await running; + await framework.stop(); + } + }); + } + }); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index c5c3157bc..07b3b497b 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -30,10 +30,12 @@ import type { ToolDefinition, ToolCall, ToolResult, + InferenceRequest, } from '../src/index.js'; import { AgentFramework } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; -import type { ContentBlock } from '@animalabs/membrane'; +import { MembraneError } from '@animalabs/membrane'; +import type { ContentBlock, NormalizedRequest } from '@animalabs/membrane'; // --------------------------------------------------------------------------- // Test module: tools that optionally emit a mid-turn external message @@ -413,13 +415,13 @@ describe('present while acting', () => { }; // Simulate an explicit send into #portables earlier in THIS turn (the // production record happens on the MCPL send path; the harness module's - // tools are not MCPL, so seed the turn-scoped set mid-turn — after the - // turn-start clear, before the injection boundary). + // tools are not MCPL, so seed the turn's engaged set mid-turn — after + // the turn opens, before the injection boundary). const origHandle = module.handleToolCall.bind(module); module.handleToolCall = async (call) => { (framework as unknown as { - turnEngagedChannels: Map>; - }).turnEngagedChannels.set('assistant', new Set(['discord:guild:portables'])); + turnRouting: Map }>; + }).turnRouting.get('assistant')!.engaged.add('discord:guild:portables'); return origHandle(call); }; @@ -457,8 +459,8 @@ describe('present while acting', () => { const origHandle = module.handleToolCall.bind(module); module.handleToolCall = async (call) => { (framework as unknown as { - turnEngagedChannels: Map>; - }).turnEngagedChannels.set('assistant', new Set(['discord:guild:hospital'])); + turnRouting: Map }>; + }).turnRouting.get('assistant')!.engaged.add('discord:guild:hospital'); return origHandle(call); }; @@ -533,6 +535,177 @@ describe('present while acting', () => { await framework.stop(); }); + // ------------------------------------------------------------------------- + // A start that continues the turn keeps its locus + // ------------------------------------------------------------------------- + // A framework retry, the compile a capacity hold defers a turn to, and the + // re-inference on unstreamed tool results each continue the logical turn: + // the locus stays where the turn decided, re-pins included. A wake that + // arrived after the turn opened can still move it by the mid-turn re-pin + // rule: addressed, or from a channel the turn engaged. + + /** Make stream `index` (1-based) end with `error` instead of completing. */ + function failStream(index: number, error: (request: NormalizedRequest) => Error): void { + const makeStream = membrane.streamYielding.bind(membrane); + let streams = 0; + membrane.streamYielding = (request, options) => { + const stream = makeStream(request, options); + const n = ++streams; + const iterate = stream[Symbol.asyncIterator].bind(stream); + stream[Symbol.asyncIterator] = async function* () { + for await (const event of { [Symbol.asyncIterator]: iterate }) { + if (event.type === 'complete' && n === index) { + yield { type: 'error' as const, error: error(request) }; + return; + } + yield event; + } + }; + return stream; + }; + } + + /** Feed a response to the Nth turn start (1-based) the moment it begins. */ + function feedStart(framework: AgentFramework, start: number, blocks: ContentBlock[]): void { + let starts = 0; + framework.onTrace((event) => { + if (event.type === 'inference:started' && ++starts === start) membrane.pushResponse(createMockResponse(blocks)); + }); + } + + const routingNotices = (framework: AgentFramework): string[] => + framework.getAgent('assistant')!.getContextManager().getAllMessages() + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'routing-notice') + .flatMap((m) => m.content.filter((b) => b.type === 'text').map((b) => (b as { text: string }).text)); + + it("a framework retry keeps the turn's locus, a mid-turn re-pin included", async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + module.toolDelayMs = 25; + module.interjection = 'quick question for you'; + module.interjectionMetadata = { channelId: 'discord:dm:antra', tags: ['chat:addressed'] }; + failStream(1, () => new Error('synthetic transient stream error')); + feedStart(framework, 2, [{ type: 'text', text: 'Answering after the retry.' }] as ContentBlock[]); + + trigger(framework); + await framework.runUntilIdle(); + + assert.equal(membrane.calls.length, 2, 'the error was retried'); + assert.deepEqual(routed, [ + { text: 'Answering after the retry.', locus: 'discord:dm:antra' }, + ], 'the retry speaks where the re-pin moved the turn, not at a re-resolved locus'); + + await framework.stop(); + }); + + for (const [label, arrival, expected] of [ + ['an addressed message from another channel moves the locus there, with the notice', + { channelId: 'discord:dm:antra', addressed: true }, 'discord:dm:antra'], + ['ambient traffic in another channel leaves the locus where it was', + { channelId: 'discord:guild:lounge', addressed: false }, 'chan-live-1'], + ] as const) { + it(`during a capacity hold, ${label}`, async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'On it.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + module.toolDelayMs = 25; + const internals = framework as unknown as { + pendingRequests: InferenceRequest[]; + providerAccelerationCooldowns: Map; + releaseProviderAccelerationCooldown(agentName: string): void; + }; + failStream(1, (request) => new MembraneError({ + type: 'rate_limit', retryable: true, httpStatus: 429, + message: "This request would exceed your organization's maximum usage increase rate for input tokens per minute", + rawError: { status: 429 }, rawRequest: request, + })); + feedStart(framework, 2, [{ type: 'text', text: 'Here is the rest.' }] as ContentBlock[]); + + trigger(framework); + await framework.runUntilIdle(); + assert.ok(internals.providerAccelerationCooldowns.has('assistant'), 'the turn is held'); + // A message arrives during the hold; its wake waits with the turn's. + internals.pendingRequests.push({ + agentName: 'assistant', reason: 'channel-message', source: 'test', timestamp: Date.now(), ...arrival, + } as InferenceRequest); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 1, 'the arrival waits in the hold'); + // What the cooldown's timer does when it fires. + internals.releaseProviderAccelerationCooldown('assistant'); + await framework.runUntilIdle(); + + assert.equal(membrane.calls.length, 2, 'one deferred compile'); + assert.deepEqual(routed, [ + { text: 'On it.', locus: 'chan-live-1' }, + { text: 'Here is the rest.', locus: expected }, + ]); + const moved = routingNotices(framework).filter((t) => t.includes('The conversation moved to')); + assert.deepEqual(moved, arrival.addressed + ? ['[routing] The conversation moved to discord:dm:antra — your plain speech now lands there for the rest of this turn. Other channels need an explicit send tool.'] + : [], arrival.addressed ? 'the re-pin tells the agent' : 'no re-pin, so no notice'); + + await framework.stop(); + }); + } + + for (const [label, arrivals, expected] of [ + ['keeps the turn\'s locus', [], 'chan-turn'], + ['moves it to the newest addressed arrival', [ + { channelId: 'discord:dm:antra', addressed: true, at: 1 }, + { channelId: 'discord:dm:q', addressed: true, at: 2 }, + ], 'discord:dm:q'], + ['moves it to a follow-up in a channel the turn engaged', [ + { channelId: 'discord:guild:portables', addressed: false, at: 1 }, + ], 'discord:guild:portables'], + ['leaves it for an addressed wake from before the turn opened', [ + { channelId: 'discord:dm:antra', addressed: true, at: -1 }, + ], 'chan-turn'], + ] as const) { + it(`a re-inference on unstreamed tool results ${label}`, async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Carrying on.' }] as ContentBlock[])); + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + type Routing = { locus?: string; engaged: Set; openedAt: number; [field: string]: unknown }; + const internals = framework as unknown as { + turnRouting: Map; openLogical: Map; pendingRequests: InferenceRequest[]; + }; + // The turn's earlier stream left its tool results pending for the + // non-streaming fallback's re-inference. The turn opened at `openedAt`, + // froze `chan-turn` as its locus, and sent into #portables. + const openedAt = Date.now() - 10_000; + const open: Routing = { + locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(['discord:guild:portables']), openedAt, + destination: null, hybridHold: false, continuation: false, deliveries: [], suppressed: 0, + }; + internals.turnRouting.set('assistant', open); + internals.openLogical.set('assistant', open); + internals.pendingRequests.push({ agentName: 'assistant', reason: 'tool_results_ready', source: 'framework', timestamp: Date.now() } as InferenceRequest); + for (const { at, ...wake } of arrivals) { + internals.pendingRequests.push({ + agentName: 'assistant', reason: 'channel-message', source: 'test', timestamp: openedAt + at * 1000, ...wake, + } as InferenceRequest); + } + + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Carrying on.', locus: expected }]); + const moved = routingNotices(framework).filter((t) => t.includes('The conversation moved to')); + assert.equal(moved.length, expected === 'chan-turn' ? 0 : 1, 'a re-pin leaves its notice'); + + await framework.stop(); + }); + } + it('a prose turn ends with a [delivered] receipt naming where the prose landed', async () => { // Explicit sends receipt themselves via tool_result; auto-routed prose // previously vanished with no in-window trace — the agent could never diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index f92bdb6a7..9a5ec5e08 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -899,12 +899,12 @@ test("explicit: a restarted turn whose last response is empty checks for its ' ! getDescriptor: () => undefined, getChannelTools: () => [], }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); const internals = h.framework as unknown as { - requestProseContinuation(agent: unknown, routing: { logical: { continuation: boolean } }): void; + requestProseContinuation(agent: unknown, routing: { continuation: boolean }): void; }; const check = internals.requestProseContinuation.bind(h.framework); const seen: boolean[] = []; internals.requestProseContinuation = (agent, routing) => { - seen.push(routing.logical.continuation); + seen.push(routing.continuation); check(agent, routing); }; try { @@ -982,10 +982,13 @@ test("a fresh turn starts new routing state in every mode, and leaves the earlie for (const mode of ['explicit', 'hybrid', 'locus'] as const) { const h = await harness([[answer()]], { proseRouting: mode }); type Routing = { pin?: string; destination: string | null; hybridHold: boolean; - logical: { continuation: boolean; deliveries: string[]; suppressed: number } }; + continuation: boolean; deliveries: string[]; suppressed: number; + triggerChannel?: string; locus?: string; engaged: Set; openedAt: number }; const internals = h.framework as unknown as { turnRouting: Map }; - const earlier: Routing = { pin: 'chan-earlier', destination: 'chan-earlier', hybridHold: true, - logical: { continuation: true, deliveries: ['chan-earlier'], suppressed: 2 } }; + const earlierFields = (): Routing => ({ pin: 'chan-earlier', destination: 'chan-earlier', hybridHold: true, + continuation: true, deliveries: ['chan-earlier'], suppressed: 2, + triggerChannel: 'chan-earlier', locus: 'chan-earlier', engaged: new Set(['chan-earlier']), openedAt: 1 }); + const earlier = earlierFields(); internals.turnRouting.set('assistant', earlier); try { await h.run(); @@ -994,12 +997,13 @@ test("a fresh turn starts new routing state in every mode, and leaves the earlie assert.equal(now?.pin, undefined, `${mode}: no sticky destination`); assert.equal(now?.destination, null, `${mode}: no turn destination`); assert.equal(now?.hybridHold, false, `${mode}: no hybrid hold`); - assert.equal(now?.logical.continuation, false, `${mode}: no continuation`); - assert.notEqual(now?.logical, earlier.logical, `${mode}: a fresh turn's logical state isn't shared with the earlier turn's`); - assert.deepEqual(now?.logical.deliveries, [], `${mode}: no deliveries carried into the new turn`); - assert.equal(now?.logical.suppressed, 0, `${mode}: no suppression carried into the new turn`); - assert.deepEqual(earlier, { pin: 'chan-earlier', destination: 'chan-earlier', hybridHold: true, - logical: { continuation: true, deliveries: ['chan-earlier'], suppressed: 2 } }, + assert.equal(now?.continuation, false, `${mode}: no continuation`); + assert.deepEqual(now?.deliveries, [], `${mode}: no deliveries carried into the new turn`); + assert.equal(now?.suppressed, 0, `${mode}: no suppression carried into the new turn`); + assert.equal(now?.triggerChannel, undefined, `${mode}: no trigger channel carried into the new turn`); + assert.notEqual(now?.locus, 'chan-earlier', `${mode}: no locus carried into the new turn`); + assert.deepEqual([...(now?.engaged ?? [])], [], `${mode}: no engaged channels carried into the new turn`); + assert.deepEqual(earlier, earlierFields(), `${mode}: all earlier-turn routing and receipt state stays with its queued prose`); } finally { await h.framework.stop(); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index dc61fc56c..1a85cbba1 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -25,10 +25,10 @@ import { AgentFramework } from '../src/index.js'; import type { ProcessEvent } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; -/** Reach the private per-turn triggering-channel map + the pure channel-deriver. */ +/** Reach the private per-turn routing state (its triggering channel) + the pure channel-deriver. */ function internals(framework: AgentFramework) { return framework as unknown as { - activeTriggerChannels: Map; + turnRouting: Map; pendingRequests: Array<{ agentName: string; reason: string; source: string; timestamp: number; channelId?: string }>; derivePushEventChannel( origin: Record | undefined, @@ -314,7 +314,7 @@ describe('Trunk channel routing (item-3 redux)', () => { assert.equal(membrane.calls.length, 1, 'the trunk should have run one turn'); assert.equal( - internals(framework).activeTriggerChannels.get('scout'), + internals(framework).turnRouting.get('scout')?.triggerChannel, 'discord:guild:chanA', 'the turn must be routed to the channel that triggered it', ); @@ -330,7 +330,7 @@ describe('Trunk channel routing (item-3 redux)', () => { assert.equal(membrane.calls.length, 1, 'the trunk should wake for the DM'); assert.equal( - internals(framework).activeTriggerChannels.get('scout'), + internals(framework).turnRouting.get('scout')?.triggerChannel, 'discord:dm:42', 'the DM reply must route to the DM channel, not the global locus', ); @@ -346,7 +346,7 @@ describe('Trunk channel routing (item-3 redux)', () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ans A' }])); framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); await framework.runUntilIdle(); - assert.equal(i.activeTriggerChannels.get('scout'), 'discord:guild:chanA'); + assert.equal(i.turnRouting.get('scout')?.triggerChannel, 'discord:guild:chanA'); // ...then a DM turn OVERWRITES it — the next turn's reply must never inherit // the previous turn's channel (the concurrency hazard this fix removes). @@ -355,9 +355,9 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.runUntilIdle(); assert.equal(membrane.calls.length, 2, 'both turns should have run'); assert.equal( - i.activeTriggerChannels.get('scout'), + i.turnRouting.get('scout')?.triggerChannel, 'discord:dm:42', - 'the map must reflect the CURRENT turn’s channel, not chanA', + 'the routing state must reflect the CURRENT turn’s channel, not chanA', ); await framework.stop(); }); @@ -371,7 +371,7 @@ describe('Trunk channel routing (item-3 redux)', () => { // turn (an InferenceRequest with no channelId). startAgentStream must clear // the entry up front so routeSpeech falls back to the global locus rather // than replaying the stale channel. - i.activeTriggerChannels.set('scout', 'discord:guild:stale'); + i.turnRouting.set('scout', { triggerChannel: 'discord:guild:stale' }); const scout = framework.getAgent('scout')!; await (framework as unknown as { startAgentStream(agent: unknown, trigger?: unknown): Promise; @@ -381,8 +381,8 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.runUntilIdle(); assert.equal( - i.activeTriggerChannels.has('scout'), - false, + i.turnRouting.get('scout')?.triggerChannel, + undefined, 'a no-trigger turn must clear any stale triggering channel', ); await framework.stop(); From 12db34942f12a7312d419ea1e916c75f9ed0d489 Mon Sep 17 00:00:00 2001 From: Nigel-2405 Date: Tue, 13 Oct 2026 09:38:27 -0400 Subject: [PATCH 02/17] fix(routing): a wake a tool boundary already weighed doesn't re-pin at a later start The arrival rule at a start that continues a logical turn compared each wake's event time with the time the turn opened. A wake that waited behind the turn while it was busy, whose message a mid-turn boundary had already drained and weighed, then counted as an arrival at the next continuing start. So after a later re-pin, for example to a follow-up in a channel the turn engaged, a capacity hold's release moved the locus back to the earlier channel. TurnRouting.openedAt becomes consideredUntil: when the turn began, then each tool boundary that weighs its injected messages for the mid-turn re-pin. A continuing start re-pins only on wakes newer than that. Tests: a wake whose message the first boundary re-pinned on doesn't take the locus back from the second boundary's re-pin at the capacity-deferred compile. The real channel registry resolves and reports the turn's trigger channel, which the framework's resolver now reads from the turn's routing state. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 32 ++++++----- test/explicit-prose-routing.test.ts | 6 +-- test/present-while-acting.test.ts | 83 ++++++++++++++++++++++++----- test/tool-result-guard.test.ts | 4 +- test/trunk-channel-routing.test.ts | 20 +++++++ 5 files changed, 115 insertions(+), 30 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index c4b286957..acd65dc53 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -587,17 +587,19 @@ interface TurnRouting { /** Plain-prose segments the turn suppressed, reported by its receipt. */ suppressed: number; /** - * When the turn's first start began. A wake whose event comes later arrived - * during the turn: at a start that continues the turn it can re-pin the - * locus, as a mid-turn injection does (AgentFramework.repinTurnLocus). + * The time up to which the turn's re-pin has weighed arriving input: when + * the turn began, then each tool boundary that weighed its injected + * messages. A wake whose event comes later arrived since: at a start that + * continues the turn, it can re-pin the locus as a mid-turn message does + * (repinTurnLocus). */ - openedAt: number; + consideredUntil: number; } -function freshTurnRouting(openedAt = Date.now()): TurnRouting { +function freshTurnRouting(consideredUntil = Date.now()): TurnRouting { return { engaged: new Set(), destination: null, hybridHold: false, - continuation: false, deliveries: [], suppressed: 0, openedAt, + continuation: false, deliveries: [], suppressed: 0, consideredUntil, }; } @@ -7187,6 +7189,8 @@ export class AgentFramework { const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed'); if (notice) midTurnInjections.push(notice); } + // This boundary has weighed every message that arrived before it. + routing.consideredUntil = Date.now(); } if (shouldEndTurn) { @@ -9733,15 +9737,17 @@ export class AgentFramework { } } else if (!resumesTurn && (turnProseRouting === 'locus' || turnProseRouting === 'hybrid')) { // A start that continues the turn keeps its locus. Wakes that arrived - // after the turn opened can still move it, by the addressed re-pin a - // mid-turn boundary applies: that boundary sees only the messages it - // drains, and these were stored without one (during a capacity hold no - // turn is alive, so nothing defers them). The newest one that addressed - // the agent, or that came from a channel the turn engaged, moves the - // locus; ambient chatter and the turn's own held trigger don't. + // since the turn last weighed its input can still move it, by the + // addressed re-pin a mid-turn boundary applies: that boundary sees only + // the messages it drains, and these were stored without one (during a + // capacity hold no turn is alive, so nothing defers them). The newest + // one that addressed the agent, or that came from a channel the turn + // engaged, moves the locus. Ambient chatter doesn't, and neither does a + // wake the turn has already weighed, such as its own held trigger or + // the wake of a message a boundary drained. let arrival: InferenceRequest | undefined; for (const request of batch) { - if (!request.channelId || wakeEventAt(request) <= routing.openedAt) continue; + if (!request.channelId || wakeEventAt(request) <= routing.consideredUntil) continue; if (!request.addressed && !routing.engaged.has(request.channelId)) continue; if (!arrival || wakeEventAt(request) >= wakeEventAt(arrival)) arrival = request; } diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 74c68f46c..0d5f209f6 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -357,12 +357,12 @@ describe('explicit prose routing', () => { type RoutingState = { pin?: string; destination: string | null; hybridHold: boolean; continuation: boolean; deliveries: string[]; suppressed: number; - triggerChannel?: string; locus?: string; engaged: Set; openedAt: number; + triggerChannel?: string; locus?: string; engaged: Set; consideredUntil: number; }; /** A turn's routing state, as the framework makes a fresh one. */ const routingState = (fields: Partial = {}): RoutingState => ({ destination: null, hybridHold: false, continuation: false, deliveries: [], suppressed: 0, - engaged: new Set(), openedAt: Date.now(), ...fields, + engaged: new Set(), consideredUntil: Date.now(), ...fields, }); async function within(promise: Promise, label: string, ms = 5000): Promise { @@ -943,7 +943,7 @@ describe('explicit prose routing', () => { // Everything the open turn has decided about where its speech goes. const decided = (): RoutingState => routingState({ continuation: true, pin: 'chan-alpha', hybridHold: true, triggerChannel: 'chan-alpha', - locus: 'world:commons', engaged: new Set(['discord:g:cafe']), openedAt: 1, + locus: 'world:commons', engaged: new Set(['discord:g:cafe']), consideredUntil: 1, }); const open = decided(); internals.openLogical.set('assistant', open); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 07b3b497b..eb87af474 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -573,6 +573,13 @@ describe('present while acting', () => { }); } + /** An organization acceleration 429, which holds the turn for a later compile. */ + const accelerationLimit = (request: NormalizedRequest): MembraneError => new MembraneError({ + type: 'rate_limit', retryable: true, httpStatus: 429, + message: "This request would exceed your organization's maximum usage increase rate for input tokens per minute", + rawError: { status: 429 }, rawRequest: request, + }); + const routingNotices = (framework: AgentFramework): string[] => framework.getAgent('assistant')!.getContextManager().getAllMessages() .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'routing-notice') @@ -624,11 +631,7 @@ describe('present while acting', () => { providerAccelerationCooldowns: Map; releaseProviderAccelerationCooldown(agentName: string): void; }; - failStream(1, (request) => new MembraneError({ - type: 'rate_limit', retryable: true, httpStatus: 429, - message: "This request would exceed your organization's maximum usage increase rate for input tokens per minute", - rawError: { status: 429 }, rawRequest: request, - })); + failStream(1, accelerationLimit); feedStart(framework, 2, [{ type: 'text', text: 'Here is the rest.' }] as ContentBlock[]); trigger(framework); @@ -658,6 +661,61 @@ describe('present while acting', () => { }); } + it("a wake whose message a tool boundary already weighed doesn't take the locus back at a later start", async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c2', name: 'robot--move', input: { dir: 'east' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + module.toolDelayMs = 25; + const internals = framework as unknown as { + pendingRequests: InferenceRequest[]; + turnRouting: Map }>; + providerAccelerationCooldowns: Map; + releaseProviderAccelerationCooldown(agentName: string): void; + }; + const origHandle = module.handleToolCall.bind(module); + module.handleToolCall = async (call) => { + if (call.id === 'c1') { + // Someone addresses the agent from a DM: the first boundary re-pins + // there, and the message's wake waits behind the turn. + module.interjection = 'quick question for you'; + module.interjectionMetadata = { channelId: 'discord:dm:antra', tags: ['chat:addressed'] }; + internals.pendingRequests.push({ + agentName: 'assistant', reason: 'channel-message', source: 'test', timestamp: Date.now(), + channelId: 'discord:dm:antra', addressed: true, + } as InferenceRequest); + } else { + // The turn sent into #portables, and a follow-up there re-pins the + // second boundary (seeded: this module's tools aren't MCPL sends). + internals.turnRouting.get('assistant')!.engaged.add('discord:guild:portables'); + module.interjection = 'replying on portables'; + module.interjectionMetadata = { channelId: 'discord:guild:portables', tags: ['chat:ambient', 'chat:from-human'] }; + } + return origHandle(call); + }; + failStream(1, accelerationLimit); + feedStart(framework, 2, [{ type: 'text', text: 'Back now.' }] as ContentBlock[]); + + trigger(framework); + await framework.runUntilIdle(); + assert.ok(internals.providerAccelerationCooldowns.has('assistant'), 'the turn is held'); + internals.releaseProviderAccelerationCooldown('assistant'); + await framework.runUntilIdle(); + + assert.equal(membrane.calls.length, 2, 'one deferred compile'); + assert.deepEqual(routed, [ + { text: 'Back now.', locus: 'discord:guild:portables' }, + ], "the DM's wake, weighed at the first boundary, doesn't move the locus back from #portables"); + + await framework.stop(); + }); + for (const [label, arrivals, expected] of [ ['keeps the turn\'s locus', [], 'chan-turn'], ['moves it to the newest addressed arrival', [ @@ -667,7 +725,7 @@ describe('present while acting', () => { ['moves it to a follow-up in a channel the turn engaged', [ { channelId: 'discord:guild:portables', addressed: false, at: 1 }, ], 'discord:guild:portables'], - ['leaves it for an addressed wake from before the turn opened', [ + ['leaves it for an addressed wake the turn has already weighed', [ { channelId: 'discord:dm:antra', addressed: true, at: -1 }, ], 'chan-turn'], ] as const) { @@ -675,16 +733,17 @@ describe('present while acting', () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Carrying on.' }] as ContentBlock[])); const framework = await createFramework(); const routed = stubChannelRegistry(framework); - type Routing = { locus?: string; engaged: Set; openedAt: number; [field: string]: unknown }; + type Routing = { locus?: string; engaged: Set; consideredUntil: number; [field: string]: unknown }; const internals = framework as unknown as { turnRouting: Map; openLogical: Map; pendingRequests: InferenceRequest[]; }; // The turn's earlier stream left its tool results pending for the - // non-streaming fallback's re-inference. The turn opened at `openedAt`, - // froze `chan-turn` as its locus, and sent into #portables. - const openedAt = Date.now() - 10_000; + // non-streaming fallback's re-inference. The turn froze `chan-turn` as + // its locus, sent into #portables, and last weighed arriving input at + // `consideredUntil`. + const consideredUntil = Date.now() - 10_000; const open: Routing = { - locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(['discord:guild:portables']), openedAt, + locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(['discord:guild:portables']), consideredUntil, destination: null, hybridHold: false, continuation: false, deliveries: [], suppressed: 0, }; internals.turnRouting.set('assistant', open); @@ -692,7 +751,7 @@ describe('present while acting', () => { internals.pendingRequests.push({ agentName: 'assistant', reason: 'tool_results_ready', source: 'framework', timestamp: Date.now() } as InferenceRequest); for (const { at, ...wake } of arrivals) { internals.pendingRequests.push({ - agentName: 'assistant', reason: 'channel-message', source: 'test', timestamp: openedAt + at * 1000, ...wake, + agentName: 'assistant', reason: 'channel-message', source: 'test', timestamp: consideredUntil + at * 1000, ...wake, } as InferenceRequest); } diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index 9a5ec5e08..159c5c792 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -983,11 +983,11 @@ test("a fresh turn starts new routing state in every mode, and leaves the earlie const h = await harness([[answer()]], { proseRouting: mode }); type Routing = { pin?: string; destination: string | null; hybridHold: boolean; continuation: boolean; deliveries: string[]; suppressed: number; - triggerChannel?: string; locus?: string; engaged: Set; openedAt: number }; + triggerChannel?: string; locus?: string; engaged: Set; consideredUntil: number }; const internals = h.framework as unknown as { turnRouting: Map }; const earlierFields = (): Routing => ({ pin: 'chan-earlier', destination: 'chan-earlier', hybridHold: true, continuation: true, deliveries: ['chan-earlier'], suppressed: 2, - triggerChannel: 'chan-earlier', locus: 'chan-earlier', engaged: new Set(['chan-earlier']), openedAt: 1 }); + triggerChannel: 'chan-earlier', locus: 'chan-earlier', engaged: new Set(['chan-earlier']), consideredUntil: 1 }); const earlier = earlierFields(); internals.turnRouting.set('assistant', earlier); try { diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 1a85cbba1..30f4a52e9 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -362,6 +362,26 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); + it("the channel registry resolves and reports the current turn's trigger channel", async () => { + const framework = await makeFramework(); + const i = framework as unknown as { + initializeMcpl(servers: unknown[]): Promise; + channelRegistry: { + resolveLocus(agentName: string): string | null; + buildChannelContext(agentName?: string): { defaultOutgoing?: { channelId: string } } | undefined; + }; + turnRouting: Map; + }; + // The real registry, with no servers: its resolver reads the framework's turn state. + await i.initializeMcpl([]); + i.turnRouting.set('scout', { triggerChannel: 'discord:guild:chanA' }); + assert.equal(i.channelRegistry.resolveLocus('scout'), 'discord:guild:chanA', + 'the locus a turn freezes comes from its trigger channel'); + assert.equal(i.channelRegistry.buildChannelContext('scout')?.defaultOutgoing?.channelId, 'discord:guild:chanA', + 'and the agent is told the same channel'); + await framework.stop(); + }); + it('startAgentStream clears the triggering channel for a no-channel (heartbeat) turn', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); const framework = await makeFramework(); From f4cf796853279c8febae577e72364c062c8971fb Mon Sep 17 00:00:00 2001 From: Nigel-2405 Date: Tue, 13 Oct 2026 10:15:53 -0400 Subject: [PATCH 03/17] fix(routing): weigh arrivals between a turn's streams as the messages they are The arrival rule at a start that continues a logical turn read the scheduler's wake requests as a stand-in for the messages the mid-turn re-pin weighs. Requests aren't messages, and an internal review found what that cost: - a wake whose message a tool boundary had already weighed was weighed again, and the held merged trigger of an earlier start, whose channel comes from its newest addressed request but whose time can come from another, re-applied an arrival a later re-pin had superseded; - requests carry no reaction or system flag, so a reaction wake could move the locus that a tool boundary would keep; - the gate puts a channel on a wake only when it is addressed, so an ambient follow-up in a channel the turn engaged never qualified; - the rule compared a gate's event time with the host's clock. The rule now weighs messages. A message stored for an agent whose turn is open while none of the turn's streams runs (a capacity hold, the wait for a re-inference, the flush at the end of a stream that didn't settle the turn, a start that fails before its stream), and one the next start's own flush or assembly lands, goes through the same predicate as a tool boundary's injections (repinChannel: conversational, and addressed or in an engaged channel). The newest that qualifies is the turn's arrival (TurnRouting.arrival). A tool boundary that can't re-pin, because it is over budget or has no live stream, leaves its injections as arrivals too. The turn's next start, a resume included, re-pins to the arrival with the same notice and clears it. TurnRouting.consideredUntil, wakeEventAt and the batch parameter of startAgentStream and beginAgentTurn are gone. Also in this round: - A start that fails before its stream exists lands the messages deferred during it, as a stream's teardown does. They used to wait for the agent's next start and land after newer messages. - The registry's activeChannelResolver returns the turn's locus once it has one, so a later start's channel context (defaultOutgoing) names the channel the turn's speech lands in after a re-pin. - A send's engaged channel and a channel_open's move of the locus are recorded on the turn that called the tool, captured at dispatch, not on whichever turn is live when the result or the open resolves. - stop() clears turnRouting as well as openLogical. - Comments say a silent start's object is the live routing while it runs; repinTurnLocus's doc says a failed notice still moves the locus; the trace type and the router no longer call the locus turn-frozen or cite an internal type. The explicit doc says the latest resolved target is sticky; the hybrid doc names every cause of the hold and says it governs publication. Tests: in locus mode, arrivals during a capacity hold (addressed moves, ambient doesn't), a message a boundary weighed, an arrival a start has used, a message deferred in a start that fails before its stream, a budget restart after an over-budget boundary, the start's own flush and an assembled delivery, and re-inference cases (newest addressed, engaged follow-up, a reaction that doesn't qualify). Dispatch-level tests for the late send engagement and the late channel_open. The registry wiring test covers the locus, and a trunk test checks that a capacity-deferred compile keeps the turn's trigger channel. The stop test covers turnRouting. Co-Authored-By: Claude Opus 5.5 --- .../continuing-starts-keep-routing.fixed.md | 25 +- docs/explicit-prose-routing.md | 2 +- docs/hybrid-prose-routing.md | 9 +- src/framework.ts | 186 +++++--- src/mcpl/prose-stream-router.ts | 6 +- src/types/trace.ts | 10 +- test/explicit-prose-routing.test.ts | 11 +- test/present-while-acting.test.ts | 411 ++++++++++++++---- test/tool-result-guard.test.ts | 5 +- test/trunk-channel-routing.test.ts | 62 ++- 10 files changed, 558 insertions(+), 169 deletions(-) diff --git a/changelog.d/continuing-starts-keep-routing.fixed.md b/changelog.d/continuing-starts-keep-routing.fixed.md index 7b925b649..7fced3c93 100644 --- a/changelog.d/continuing-starts-keep-routing.fixed.md +++ b/changelog.d/continuing-starts-keep-routing.fixed.md @@ -11,8 +11,23 @@ - the turn's locus was resolved again: a retry lost a mid-turn addressed re-pin, a re-inference took the locus of whichever wake started it, and a held turn's later compile let a newer ambient message move it. -- At a start that continues the turn, a wake that arrived after the turn - began moves the turn's locus only as a mid-turn message does: when it - addressed the agent, or came from a channel the turn sent into. The agent - gets the same `[routing] The conversation moved to …` notice. Ambient - traffic in another channel no longer moves a held turn's locus. +- A message that no tool boundary weighed now moves the turn's locus at the + turn's next start, by the rule a mid-turn message follows: a + conversational message that addressed the agent, or that follows up in a + channel the turn sent into, with the same + `[routing] The conversation moved to …` notice. That covers a message that + arrives during a capacity hold or before a re-inference, and one drained + at a budget restart's over-budget boundary, which before never moved the + locus. Ambient traffic, reactions and system markers don't move it. +- The channel context passed to `beforeInference` hooks + (`channels.defaultOutgoing`) names the turn's locus once the turn has one. + After a re-pin, a later start of the turn reports the channel its speech + lands in, not the channel that triggered the turn. +- A start that fails before its stream exists now lands the messages + deferred during it, as a stream's end does. Before, they waited for the + agent's next start and landed after any message stored in between. +- A send's engaged channel, and a `channel_open`'s move of the locus, are + recorded on the turn that called the tool, even when the tool's result, or + the open that follows a send, comes after that turn has ended. +- `stop()` clears every agent's routing state, so a resume after a restart + starts afresh. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index 0d8f949de..e5eaa090e 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -22,7 +22,7 @@ A prose segment's FIRST line may carry a routing prefix: ``` - Body may start on the same line (`>>#ops deploy done`) or the next. -- **Sticky within the turn:** the first resolved target applies to later +- **Sticky within the turn:** the latest resolved target applies to later unprefixed segments of the SAME turn, including those a later stream of it writes, such as a framework retry's, a budget restart's or the compile a provider-capacity hold defers the turn to. Cross-turn, nothing is implicit. diff --git a/docs/hybrid-prose-routing.md b/docs/hybrid-prose-routing.md index d71ab06ca..3ea280d1a 100644 --- a/docs/hybrid-prose-routing.md +++ b/docs/hybrid-prose-routing.md @@ -24,10 +24,11 @@ At logical turn end, a `[delivered]` receipt names canonical destinations (or a bounce explains failure), so authored intent cannot impersonate delivery. Unprefixed prose after a successful envelope follows that explicit destination for the remainder of the turn; a fresh turn begins at its ordinary locus. -After `>>>skip_reply`, or an envelope that publishes nowhere, unprefixed prose -is held back until a later `>>>` names a destination. Both hold for the whole -logical turn, across its later streams, such as a framework retry's, a budget -restart's or the compile a provider-capacity hold defers the turn to. +After `>>>skip_reply`, or a `>>>` envelope that is malformed, doesn't resolve +or isn't delivered, unprefixed prose isn't published until a later `>>>` names +a destination that resolves. The sticky destination and this hold last for +the whole logical turn, across its later streams, such as a framework retry's, +a budget restart's or the compile a provider-capacity hold defers the turn to. This is publication routing only. It creates no privacy meaning for `` or other prose tags, and grants no channel authority beyond the existing diff --git a/src/framework.ts b/src/framework.ts index acd65dc53..31dae1a00 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -587,27 +587,24 @@ interface TurnRouting { /** Plain-prose segments the turn suppressed, reported by its receipt. */ suppressed: number; /** - * The time up to which the turn's re-pin has weighed arriving input: when - * the turn began, then each tool boundary that weighed its injected - * messages. A wake whose event comes later arrived since: at a start that - * continues the turn, it can re-pin the locus as a mid-turn message does - * (repinTurnLocus). + * Locus and hybrid mode: the channel the newest message that arrived + * between the turn's streams would re-pin the locus to (repinChannel), + * if one did. No tool boundary weighs such a message, since none of the + * turn's streams was running: it was stored during a capacity hold or the + * wait for a re-inference, or flushed at the end of a stream that didn't + * settle the turn, or by the start that continues it (noteArrival). The + * turn's next start re-pins there and clears it, as a boundary would have. */ - consideredUntil: number; + arrival?: string; } -function freshTurnRouting(consideredUntil = Date.now()): TurnRouting { +function freshTurnRouting(): TurnRouting { return { engaged: new Set(), destination: null, hybridHold: false, - continuation: false, deliveries: [], suppressed: 0, consideredUntil, + continuation: false, deliveries: [], suppressed: 0, }; } -/** When a wake's event happened: a gate wake's event time, else when it was queued. */ -function wakeEventAt(request: InferenceRequest): number { - return request.wakeAt ?? request.timestamp; -} - /** How a round's prose is delivered: kept private, and under which turn's routing. */ interface ProseRound { keepPrivate?: boolean; @@ -1267,7 +1264,8 @@ export class AgentFramework { * consumes tool results the non-streaming fallback left (whichever wake * the scheduler starts it with), the later compile a provider-capacity * hold defers a turn to. A silent start neither continues nor opens one, - * and leaves the open turn's object untouched. A stream that ends without + * and leaves the open turn's object untouched (its own is the live routing + * while it runs: turnRouting). A stream that ends without * settling needs to do nothing to carry its turn. Settling only detaches * the turn from future starts: the settling stream keeps its own object, * which its remaining deliveries, its receipt and its ` !` check go on @@ -2203,10 +2201,11 @@ export class AgentFramework { // A stopped host must never hang behind its own cooldown. this.cancelProviderAdmission(); - // An open logical turn doesn't survive a stop: a start after a restart - // on this instance opens a new one rather than carrying its ` !` and - // receipt tallies across (openLogical). + // A turn doesn't survive a stop: a start after a restart on this + // instance, a resume included, gets new routing state rather than + // carrying the old turn's across (openLogical, turnRouting). this.openLogical.clear(); + this.turnRouting.clear(); // Cancel all active streams for (const agent of this.agents.values()) { @@ -7174,23 +7173,20 @@ export class AgentFramework { // Reactions and system markers are already excluded by // isConversationalInjection, and the engaged-this-turn scope // keeps unrelated channels from moving the pin. + // The last injection that qualifies wins (repinChannel). const routing = this.routingOf(agent.name); - const lastQualifying = [...midTurnInjections].reverse().find((inj) => { - const m = inj.metadata as Record | undefined; - if (!isConversationalInjection(inj.metadata)) return false; - if (typeof m?.channelId !== 'string') return false; - const tags = m.tags as string[] | undefined; - if (isAddressedMessage(tags, m)) return true; - return routing.engaged.has(m.channelId as string); - }); - const newLocus = (lastQualifying?.metadata as Record | undefined) - ?.channelId as string | undefined; + let newLocus: string | undefined; + for (const inj of midTurnInjections) newLocus = this.repinChannel(inj.metadata, routing) ?? newLocus; if (newLocus) { const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed'); if (notice) midTurnInjections.push(notice); } - // This boundary has weighed every message that arrived before it. - routing.consideredUntil = Date.now(); + } else if (!shouldEndTurn) { + // A boundary that can't re-pin now (over budget, or no live + // stream to tell) leaves its injections to the turn's next start, + // as arrivals: a budget restart, or a re-inference on these tool + // results. + for (const inj of midTurnInjections) this.noteArrival(agent.name, inj.metadata); } if (shouldEndTurn) { @@ -8806,7 +8802,7 @@ export class AgentFramework { // held in the gate while the agent was busy lands BEHIND a newer // addressed message's direct wake, and queue order would hand the turn // to the older one. Ties keep the later-queued request, as before. - const eventAt = wakeEventAt; + const eventAt = (r: InferenceRequest): number => r.wakeAt ?? r.timestamp; let ambientReq: InferenceRequest | undefined; let addressedReq: InferenceRequest | undefined; for (const r of requests) { @@ -8855,7 +8851,7 @@ export class AgentFramework { counterparty: budgetRestart ? undefined : provReq?.counterparty, wakeChannelId: budgetRestart ? undefined : provReq?.wakeChannelId, wakeAt: budgetRestart ? undefined : provReq?.wakeAt, - }, 0, false, requests); + }); } } @@ -8997,15 +8993,45 @@ export class AgentFramework { } } + /** + * The channel a message moves its turn's locus to, by the addressed + * re-pin rule (2026-07-31, n=6 and n=7): a conversational message (not a + * reaction or a system marker) that addressed the agent, or that follows + * up in a channel the turn sent into (TurnRouting.engaged). Undefined for + * any other message. A tool boundary weighs the messages it injects by it, + * and noteArrival the ones that arrive between the turn's streams. + */ + private repinChannel(metadata: MessageMetadata | undefined, routing: TurnRouting): string | undefined { + if (!isConversationalInjection(metadata)) return undefined; + const m = metadata as Record | undefined; + if (typeof m?.channelId !== 'string') return undefined; + if (isAddressedMessage(m.tags as string[] | undefined, m)) return m.channelId; + return routing.engaged.has(m.channelId) ? m.channelId : undefined; + } + + /** + * Weigh a message stored for an agent whose logical turn is open while + * none of the turn's streams is running, so that no tool boundary will + * weigh it: one that would re-pin the locus becomes the turn's arrival, + * the newest replacing any before it (TurnRouting.arrival). + */ + private noteArrival(agentName: string, metadata: MessageMetadata | undefined): void { + const open = this.openLogical.get(agentName); + if (!open) return; + const channelId = this.repinChannel(metadata, open); + if (channelId) open.arrival = channelId; + } + /** * Move a turn's locus to the channel its conversation moved to: an * addressed message, or a follow-up in a channel the turn engaged (the - * addressed re-pin). Ambient chatter never moves it. Leaves a durable - * `[routing]` notice in the window and updates lastAnnouncedLocus, so the - * next turn's announce-on-change diffs against what the agent was last - * told. Returns the notice as stored, for a live stream to inject with its - * boundary's other messages; undefined when the locus is already there or - * the notice couldn't be stored. + * addressed re-pin, repinChannel). Ambient chatter never moves it. Leaves + * a durable `[routing]` notice in the window and updates + * lastAnnouncedLocus, so the next turn's announce-on-change diffs against + * what the agent was last told. Returns the notice as stored, for a live + * stream to inject with its boundary's other messages. Returns undefined + * when the locus is already there, and when the notice couldn't be + * stored, though the locus has moved then too. */ private repinTurnLocus( agent: Agent, @@ -9456,8 +9482,6 @@ export class AgentFramework { trigger?: InferenceRequest, attempt = 0, providerGateAlreadyHeld = false, - /** The scheduler's requests this start consumes (beginAgentTurn). */ - batch: readonly InferenceRequest[] = [], ): Promise { const ownsProviderGate = !this.ephemeralRuns.has(agent.name) && !this.conversationAgentHomes.has(agent.name); @@ -9504,7 +9528,7 @@ export class AgentFramework { console.error(`[provider-admission] ${agent.name}: host quiesced while parked — wake requeued, not started`); return; } - await this.startAgentStream(agent, trigger, attempt, true, batch); + await this.startAgentStream(agent, trigger, attempt, true); }).catch((error) => { this.releasePrimaryProviderGate(agent.name); console.error(`[provider-cooldown] failed to resume primary for ${agent.name}:`, error); @@ -9537,13 +9561,26 @@ export class AgentFramework { this.activeTurnTriggers.set(agent.name, trigger); let tokenHandedOff = false; try { - tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate, batch); + tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate); } finally { if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) { this.activeTurnTokens.delete(agent.name); this.activeTurnTriggers.delete(agent.name); } if (!tokenHandedOff && ownsProviderGate) this.releasePrimaryProviderGate(agent.name); + // A start that ends before its stream exists lands what was deferred + // during it, as a stream's teardown does. Left queued, those messages + // would land only at the agent's next start, after any stored meanwhile. + if (!tokenHandedOff && !this.activeTurnTokens.has(agent.name) + && this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) { + for (const msg of this.drainDeferredFor(agent.name)) { + this.addMessage(msg.participant, msg.content, msg.metadata, { + deferredWriteId: msg.id, + ...(msg.forAgent ? { forAgent: msg.forAgent } : {}), + }); + } + this.ackDeferredWrites(); + } } } @@ -9558,8 +9595,6 @@ export class AgentFramework { attempt: number, turnToken: number, ownsProviderGate: boolean, - /** The scheduler's requests this start consumes, for a start that continues an open turn. */ - batch: readonly InferenceRequest[], ): Promise { const startedAt = Date.now(); const resumesTurn = resumesLogicalTurn(trigger?.reason); @@ -9593,6 +9628,7 @@ export class AgentFramework { const id = agent.getContextManager().addMessage( msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id), ); + this.noteArrival(agent.name, msg.metadata); this.emitTrace({ type: 'message:added', messageId: id, source: 'deferred-flush:turn-start' }); } catch (err) { console.error( @@ -9663,7 +9699,12 @@ export class AgentFramework { // - A resume (a budget restart, a tool-result-guard retry) carries its // turn's request on and keeps the turn's object as it is. // - A silent start routes no prose. It gets an object of its own, with - // no locus, so it neither takes nor disturbs an open turn's. + // no locus, so it neither takes nor disturbs an open turn's. Its + // object is the agent's live routing while it runs, and the open + // turn's next start makes the turn's object live again. (No silent + // start runs while a prose turn is open in production: a capacity + // hold holds silent wakes with the turn's, and a re-inference's wake + // is queued before its stream ends, so it batches with them.) // - Any other start continues the agent's open logical turn, if one is // open (openLogical): a framework retry, the start that consumes tool // results the non-streaming fallback left (whichever wake the @@ -9682,14 +9723,14 @@ export class AgentFramework { if (resumesTurn) { routing = this.routingOf(agent.name); } else if (trigger?.suppressProse) { - routing = freshTurnRouting(startedAt); + routing = freshTurnRouting(); opensTurn = true; } else { const open = this.openLogical.get(agent.name); if (open) { routing = open; } else { - routing = freshTurnRouting(startedAt); + routing = freshTurnRouting(); this.openLogical.set(agent.name, routing); opensTurn = true; } @@ -9735,23 +9776,16 @@ export class AgentFramework { if (locus !== null) routing.locus = locus; if (attempt === 0) this.announceLocusIfChanged(agent.name, locus); } - } else if (!resumesTurn && (turnProseRouting === 'locus' || turnProseRouting === 'hybrid')) { - // A start that continues the turn keeps its locus. Wakes that arrived - // since the turn last weighed its input can still move it, by the - // addressed re-pin a mid-turn boundary applies: that boundary sees only - // the messages it drains, and these were stored without one (during a - // capacity hold no turn is alive, so nothing defers them). The newest - // one that addressed the agent, or that came from a channel the turn - // engaged, moves the locus. Ambient chatter doesn't, and neither does a - // wake the turn has already weighed, such as its own held trigger or - // the wake of a message a boundary drained. - let arrival: InferenceRequest | undefined; - for (const request of batch) { - if (!request.channelId || wakeEventAt(request) <= routing.consideredUntil) continue; - if (!request.addressed && !routing.engaged.has(request.channelId)) continue; - if (!arrival || wakeEventAt(request) >= wakeEventAt(arrival)) arrival = request; + } else { + // Every other start of the turn keeps its locus: a resume, or a start + // that continues the open turn. A message that arrived while none of + // the turn's streams was running can still move it, as it would have + // at a tool boundary: no boundary drained it (TurnRouting.arrival). + const arrival = routing.arrival; + routing.arrival = undefined; + if (arrival && (turnProseRouting === 'locus' || turnProseRouting === 'hybrid')) { + this.repinTurnLocus(agent, routing, arrival, 'arrival'); } - if (arrival) this.repinTurnLocus(agent, routing, arrival.channelId!, 'continuing-start'); } // A silent start, or a disabled agent's, has no locus to show, whatever a // channel_open in a silent turn left on its object. @@ -13178,6 +13212,11 @@ export class AgentFramework { opts?.deferredWriteId ? withDeferredWriteId(metadata, opts.deferredWriteId) : metadata, ); if (opts?.placement) { opts.placement.agent = agent.name; opts.placement.messageId = stored; } + // Stored while none of the agent's turns is alive, or assembled for the + // start now beginning: no tool boundary will weigh it. + if (!this.activeTurnTokens.has(agent.name) || opts?.bypassDeferralFor === agent.name) { + this.noteArrival(agent.name, metadata); + } return stored; } @@ -13796,8 +13835,14 @@ export class AgentFramework { // agent as a trunk (it never exposes conversation forks), so without this // a reply falls back to the process-global most-recent-inbound locus and // a concurrent message in another channel hijacks it. Empty for - // heartbeat / no-trigger turns → correct global fallback. - activeChannelResolver: (agentName) => this.turnRouting.get(agentName)?.triggerChannel, + // heartbeat / no-trigger turns → correct global fallback. Once the + // turn has a locus, it's the locus, a re-pin's included: that's where + // the turn's plain speech lands, so the channel context the agent is + // told at a later start of the turn names it. + activeChannelResolver: (agentName) => { + const routing = this.turnRouting.get(agentName); + return routing?.locus ?? routing?.triggerChannel; + }, // A text-only turn whose speech couldn't be delivered must not vanish // silently: record a `[discord-send-failed]` marker in chronicle so the // agent sees, on her next turn, that her reply never reached the human. @@ -15460,6 +15505,9 @@ export class AgentFramework { this.emitTrace({ type: 'tool:started', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, input: call.input }); const startTime = Date.now(); const args = (call.input && typeof call.input === 'object') ? call.input as Record : {}; + // The turn that sends: a send's engaged channel is recorded on it, even + // when the send's result, or the open that follows, comes after the turn. + const sendingTurn = this.turnRouting.get(agentName); // Build state params for stateful tools (Step 8). // A tools/list attribution selects that exact stateful set. Untagged tools @@ -15559,7 +15607,7 @@ export class AgentFramework { // the microtask queue, i.e. before the tool-result event // that evaluates injections. if (status !== 'unknown-channel' && status !== 'ambiguous') { - this.routingOf(agentName).engaged.add(channelId ?? target); + sendingTurn?.engaged.add(channelId ?? target); } if (status === 'opened') { console.error( @@ -15677,6 +15725,9 @@ export class AgentFramework { this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input }); const startTime = Date.now(); + // The turn that called: a channel_open's result moves its locus, even + // if that result arrives after the turn has ended. + const callingTurn = this.routingOf(agentName); this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName }) .then((result) => { @@ -15700,11 +15751,10 @@ export class AgentFramework { (result.data as { channelId?: string } | undefined)?.channelId ?? (call.input as { channelId?: string } | undefined)?.channelId; if (opened) { - const openerRouting = this.routingOf(agentName); - openerRouting.triggerChannel = opened; + callingTurn.triggerChannel = opened; const openerAgent = this.agents.get(agentName); if (openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) { - openerRouting.locus = opened; + callingTurn.locus = opened; this.lastAnnouncedLocus.set(agentName, opened); result = { ...result, diff --git a/src/mcpl/prose-stream-router.ts b/src/mcpl/prose-stream-router.ts index 0224334ca..308f554ff 100644 --- a/src/mcpl/prose-stream-router.ts +++ b/src/mcpl/prose-stream-router.ts @@ -23,11 +23,11 @@ * - explicit: parse `>>` prefixes. A fresh turn's stream starts suppressed; * a later stream of the 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 + * - hybrid: parse `>>>` envelopes, with the turn's 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. + * everything streams to the turn's locus. */ export interface RoutedDelta { @@ -52,7 +52,7 @@ export class ProseStreamRouter { private opts: { /** explicit parses `>>`; hybrid parses `>>>` while retaining locus fallback. */ mode: 'explicit' | 'hybrid' | 'locus'; - /** Initial destination: the frozen turn locus (locus and hybrid mode); in + /** Initial destination: the turn's locus (locus and hybrid mode); in * explicit mode, null on a fresh turn, or the turn's destination on a * later stream of the turn. */ initialTarget: string | null; diff --git a/src/types/trace.ts b/src/types/trace.ts index 2f19fc6e4..093a17be4 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -41,9 +41,9 @@ export type TraceEvent = type: 'inference:started'; agentName: string; /** - * The turn-frozen outbound locus (TurnRouting.locus) — the channel this - * turn's plain prose will be routed to. Omitted for turns with no locus - * (heartbeats with no default channel). Observability only, like every + * The turn's outbound locus as this stream starts: the channel its + * plain prose is routed to, until a re-pin moves it. Omitted for turns + * with no locus (heartbeats with no default channel). Observability only, like every * trace field; lets external taps (e.g. a TTS relay) tag the whole * activation with its channel without re-deriving routing. */ @@ -95,7 +95,7 @@ export type TraceEvent = blockType: 'text' | 'thinking' | 'tool_call' | 'tool_result'; /** 0-indexed block position in the current assistant turn. */ blockIndex: number; - /** Turn-frozen outbound locus for this turn (see inference:started). */ + /** The turn's outbound locus as its stream started (see inference:started). */ channelId?: string; }) | (TraceEventBase & { @@ -110,7 +110,7 @@ export type TraceEvent = phase: 'block_start' | 'block_complete'; blockType: 'text' | 'thinking' | 'tool_call' | 'tool_result'; blockIndex: number; - /** Turn-frozen outbound locus for this turn (see inference:started). */ + /** The turn's outbound locus as its stream started (see inference:started). */ channelId?: string; }) | (TraceEventBase & { diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 0d5f209f6..3bae228b2 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -357,12 +357,12 @@ describe('explicit prose routing', () => { type RoutingState = { pin?: string; destination: string | null; hybridHold: boolean; continuation: boolean; deliveries: string[]; suppressed: number; - triggerChannel?: string; locus?: string; engaged: Set; consideredUntil: number; + triggerChannel?: string; locus?: string; engaged: Set; arrival?: string; }; /** A turn's routing state, as the framework makes a fresh one. */ const routingState = (fields: Partial = {}): RoutingState => ({ destination: null, hybridHold: false, continuation: false, deliveries: [], suppressed: 0, - engaged: new Set(), consideredUntil: Date.now(), ...fields, + engaged: new Set(), ...fields, }); async function within(promise: Promise, label: string, ms = 5000): Promise { @@ -906,10 +906,11 @@ describe('explicit prose routing', () => { } }); - it("a settle closes the open logical turn only when it is the settling stream's own, and stop() clears open turns", async () => { + it("a settle closes the open logical turn only when it is the settling stream's own, and stop() clears every turn's routing", async () => { const framework = await createFramework('hybrid'); const internals = framework as unknown as { openLogical: Map; + turnRouting: Map; settleAgent(name: string, outcome: { stopReason: 'completed'; speech: string }, closes: RoutingState | undefined): void; }; const open: RoutingState = routingState({ continuation: true }); @@ -923,8 +924,10 @@ describe('explicit prose routing', () => { internals.settleAgent('assistant', { stopReason: 'completed', speech: '' }, open); assert.equal(internals.openLogical.has('assistant'), false, "the turn's own settle closes it"); internals.openLogical.set('assistant', open); + internals.turnRouting.set('assistant', open); await framework.stop(); assert.equal(internals.openLogical.size, 0, 'stop() clears every open logical turn'); + assert.equal(internals.turnRouting.size, 0, 'and every live routing state, so a resume after a restart starts afresh'); }); it('a silent start neither takes nor disturbs an open logical turn, and leaves it to the next start that routes prose', async () => { @@ -943,7 +946,7 @@ describe('explicit prose routing', () => { // Everything the open turn has decided about where its speech goes. const decided = (): RoutingState => routingState({ continuation: true, pin: 'chan-alpha', hybridHold: true, triggerChannel: 'chan-alpha', - locus: 'world:commons', engaged: new Set(['discord:g:cafe']), consideredUntil: 1, + locus: 'world:commons', engaged: new Set(['discord:g:cafe']), arrival: 'discord:g:cafe', }); const open = decided(); internals.openLogical.set('assistant', open); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index eb87af474..86cc3a19e 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -32,6 +32,7 @@ import type { ToolResult, InferenceRequest, } from '../src/index.js'; +import type { ContextInjection } from '@animalabs/context-manager'; import { AgentFramework } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; import { MembraneError } from '@animalabs/membrane'; @@ -55,10 +56,20 @@ class RobotModule implements Module { interjectionMetadata: Record | null = null; /** Delay tool completion so live routing deterministically precedes it. */ toolDelayMs = 0; + /** Run once, during the next turn start's context gathering (the turn is + * alive and its stream doesn't exist yet). */ + startHook: (() => Promise) | null = null; async start(_ctx: ModuleContext): Promise {} async stop(): Promise {} + async gatherContext(): Promise { + const hook = this.startHook; + this.startHook = null; + if (hook) await hook(); + return []; + } + getTools(): ToolDefinition[] { return [ { @@ -176,12 +187,12 @@ describe('present while acting', () => { rmSync(tempDir, { recursive: true, force: true }); }); - async function createFramework(): Promise { + async function createFramework(agentOptions: { maxStreamTokens?: number } = {}): Promise { const framework = await AgentFramework.create({ storePath: join(tempDir, 'test.chronicle'), membrane: membrane.asMembrane(), agents: [ - { name: 'assistant', model: 'test-model', systemPrompt: 'You are a robot pilot.' }, + { name: 'assistant', model: 'test-model', systemPrompt: 'You are a robot pilot.', ...agentOptions }, ], modules: [module], }); @@ -536,16 +547,19 @@ describe('present while acting', () => { }); // ------------------------------------------------------------------------- - // A start that continues the turn keeps its locus + // Every later start of the turn keeps its locus // ------------------------------------------------------------------------- - // A framework retry, the compile a capacity hold defers a turn to, and the - // re-inference on unstreamed tool results each continue the logical turn: - // the locus stays where the turn decided, re-pins included. A wake that - // arrived after the turn opened can still move it by the mid-turn re-pin - // rule: addressed, or from a channel the turn engaged. - - /** Make stream `index` (1-based) end with `error` instead of completing. */ - function failStream(index: number, error: (request: NormalizedRequest) => Error): void { + // A framework retry, the compile a capacity hold defers a turn to, the + // re-inference on unstreamed tool results and a budget restart all keep + // the turn's locus, re-pins included. A message that no tool boundary + // weighed, because none of the turn's streams was running or its boundary + // couldn't re-pin, still moves it at the turn's next start, by the mid-turn + // rule: a conversational message that addressed the agent, or that follows + // up in a channel the turn sent into. + + /** Make each stream in `indices` (1-based) end with `error` instead of completing. */ + function failStream(indices: number | number[], error: (request: NormalizedRequest) => Error): void { + const failing = new Set(Array.isArray(indices) ? indices : [indices]); const makeStream = membrane.streamYielding.bind(membrane); let streams = 0; membrane.streamYielding = (request, options) => { @@ -554,7 +568,7 @@ describe('present while acting', () => { const iterate = stream[Symbol.asyncIterator].bind(stream); stream[Symbol.asyncIterator] = async function* () { for await (const event of { [Symbol.asyncIterator]: iterate }) { - if (event.type === 'complete' && n === index) { + if (event.type === 'complete' && failing.has(n)) { yield { type: 'error' as const, error: error(request) }; return; } @@ -566,10 +580,13 @@ describe('present while acting', () => { } /** Feed a response to the Nth turn start (1-based) the moment it begins. */ - function feedStart(framework: AgentFramework, start: number, blocks: ContentBlock[]): void { + function feedStart(framework: AgentFramework, start: number, ...responses: ContentBlock[][]): void { let starts = 0; framework.onTrace((event) => { - if (event.type === 'inference:started' && ++starts === start) membrane.pushResponse(createMockResponse(blocks)); + if (event.type !== 'inference:started' || ++starts !== start) return; + responses.forEach((blocks, i) => membrane.pushResponse(createMockResponse( + blocks, i < responses.length - 1 ? 'tool_use' : undefined, + ))); }); } @@ -580,10 +597,35 @@ describe('present while acting', () => { rawError: { status: 429 }, rawRequest: request, }); - const routingNotices = (framework: AgentFramework): string[] => + type Held = { + providerAccelerationCooldowns: Map; + releaseProviderAccelerationCooldown(agentName: string): void; + }; + + /** Release the agent's capacity hold, as its timer does, and run the deferred compile. */ + async function release(framework: AgentFramework): Promise { + const internals = framework as unknown as Held; + assert.ok(internals.providerAccelerationCooldowns.has('assistant'), 'the turn is held'); + internals.releaseProviderAccelerationCooldown('assistant'); + await framework.runUntilIdle(); + } + + /** A message someone sends, stored as the test module stores it. */ + async function say(framework: AgentFramework, text: string, metadata: Record): Promise { + framework.pushEvent({ type: 'external-message', source: 'test', content: text, metadata } as unknown as ProcessEvent); + await framework.runUntilIdle(); + } + + const addressed = (channelId: string) => ({ channelId, tags: ['chat:addressed'] }); + const ambient = (channelId: string) => ({ channelId, tags: ['chat:ambient', 'chat:from-human'] }); + + /** The `[routing] The conversation moved to …` notices, by channel. */ + const movedTo = (framework: AgentFramework): string[] => framework.getAgent('assistant')!.getContextManager().getAllMessages() .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'routing-notice') - .flatMap((m) => m.content.filter((b) => b.type === 'text').map((b) => (b as { text: string }).text)); + .flatMap((m) => m.content.filter((b) => b.type === 'text').map((b) => (b as { text: string }).text)) + .map((text) => /^\[routing\] The conversation moved to (\S+) /.exec(text)?.[1]) + .filter((channel): channel is string => channel !== undefined); it("a framework retry keeps the turn's locus, a mid-turn re-pin included", async () => { membrane.pushResponse(createMockResponse([ @@ -595,7 +637,7 @@ describe('present while acting', () => { const routed = stubChannelRegistry(framework); module.toolDelayMs = 25; module.interjection = 'quick question for you'; - module.interjectionMetadata = { channelId: 'discord:dm:antra', tags: ['chat:addressed'] }; + module.interjectionMetadata = addressed('discord:dm:antra'); failStream(1, () => new Error('synthetic transient stream error')); feedStart(framework, 2, [{ type: 'text', text: 'Answering after the retry.' }] as ContentBlock[]); @@ -610,11 +652,11 @@ describe('present while acting', () => { await framework.stop(); }); - for (const [label, arrival, expected] of [ + for (const [label, metadata, expected] of [ ['an addressed message from another channel moves the locus there, with the notice', - { channelId: 'discord:dm:antra', addressed: true }, 'discord:dm:antra'], + addressed('discord:dm:antra'), 'discord:dm:antra'], ['ambient traffic in another channel leaves the locus where it was', - { channelId: 'discord:guild:lounge', addressed: false }, 'chan-live-1'], + ambient('discord:guild:lounge'), 'chan-live-1'], ] as const) { it(`during a capacity hold, ${label}`, async () => { membrane.pushResponse(createMockResponse([ @@ -626,42 +668,29 @@ describe('present while acting', () => { const framework = await createFramework(); const routed = stubChannelRegistry(framework); module.toolDelayMs = 25; - const internals = framework as unknown as { - pendingRequests: InferenceRequest[]; - providerAccelerationCooldowns: Map; - releaseProviderAccelerationCooldown(agentName: string): void; - }; failStream(1, accelerationLimit); feedStart(framework, 2, [{ type: 'text', text: 'Here is the rest.' }] as ContentBlock[]); trigger(framework); await framework.runUntilIdle(); - assert.ok(internals.providerAccelerationCooldowns.has('assistant'), 'the turn is held'); - // A message arrives during the hold; its wake waits with the turn's. - internals.pendingRequests.push({ - agentName: 'assistant', reason: 'channel-message', source: 'test', timestamp: Date.now(), ...arrival, - } as InferenceRequest); - await framework.runUntilIdle(); - assert.equal(membrane.calls.length, 1, 'the arrival waits in the hold'); - // What the cooldown's timer does when it fires. - internals.releaseProviderAccelerationCooldown('assistant'); - await framework.runUntilIdle(); + // No turn is alive during the hold, so the message is stored at once. + await say(framework, 'a message during the hold', metadata); + assert.equal(membrane.calls.length, 1, 'the turn is still held'); + await release(framework); assert.equal(membrane.calls.length, 2, 'one deferred compile'); assert.deepEqual(routed, [ { text: 'On it.', locus: 'chan-live-1' }, { text: 'Here is the rest.', locus: expected }, ]); - const moved = routingNotices(framework).filter((t) => t.includes('The conversation moved to')); - assert.deepEqual(moved, arrival.addressed - ? ['[routing] The conversation moved to discord:dm:antra — your plain speech now lands there for the rest of this turn. Other channels need an explicit send tool.'] - : [], arrival.addressed ? 'the re-pin tells the agent' : 'no re-pin, so no notice'); + assert.deepEqual(movedTo(framework), expected === 'chan-live-1' ? [] : [expected], + 'a re-pin tells the agent, and nothing else does'); await framework.stop(); }); } - it("a wake whose message a tool boundary already weighed doesn't take the locus back at a later start", async () => { + it("a message a tool boundary already weighed doesn't take the locus back at a later start", async () => { membrane.pushResponse(createMockResponse([ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, ] as ContentBlock[], 'tool_use')); @@ -676,16 +705,15 @@ describe('present while acting', () => { const internals = framework as unknown as { pendingRequests: InferenceRequest[]; turnRouting: Map }>; - providerAccelerationCooldowns: Map; - releaseProviderAccelerationCooldown(agentName: string): void; }; const origHandle = module.handleToolCall.bind(module); module.handleToolCall = async (call) => { if (call.id === 'c1') { // Someone addresses the agent from a DM: the first boundary re-pins - // there, and the message's wake waits behind the turn. + // there. The message's wake waits behind the turn, as a real one + // would, and is released with the hold. module.interjection = 'quick question for you'; - module.interjectionMetadata = { channelId: 'discord:dm:antra', tags: ['chat:addressed'] }; + module.interjectionMetadata = addressed('discord:dm:antra'); internals.pendingRequests.push({ agentName: 'assistant', reason: 'channel-message', source: 'test', timestamp: Date.now(), channelId: 'discord:dm:antra', addressed: true, @@ -695,7 +723,7 @@ describe('present while acting', () => { // second boundary (seeded: this module's tools aren't MCPL sends). internals.turnRouting.get('assistant')!.engaged.add('discord:guild:portables'); module.interjection = 'replying on portables'; - module.interjectionMetadata = { channelId: 'discord:guild:portables', tags: ['chat:ambient', 'chat:from-human'] }; + module.interjectionMetadata = ambient('discord:guild:portables'); } return origHandle(call); }; @@ -704,67 +732,300 @@ describe('present while acting', () => { trigger(framework); await framework.runUntilIdle(); - assert.ok(internals.providerAccelerationCooldowns.has('assistant'), 'the turn is held'); - internals.releaseProviderAccelerationCooldown('assistant'); - await framework.runUntilIdle(); + await release(framework); assert.equal(membrane.calls.length, 2, 'one deferred compile'); assert.deepEqual(routed, [ { text: 'Back now.', locus: 'discord:guild:portables' }, - ], "the DM's wake, weighed at the first boundary, doesn't move the locus back from #portables"); + ], "the DM, weighed at the first boundary, doesn't move the locus back from #portables"); + assert.deepEqual(movedTo(framework), ['discord:dm:antra', 'discord:guild:portables']); + + await framework.stop(); + }); + + it("an arrival a start has used doesn't move the locus again after a later re-pin", async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + module.toolDelayMs = 25; + const origHandle = module.handleToolCall.bind(module); + module.handleToolCall = async (call) => { + if (call.id === 'c2') { + // In the second stream, someone else addresses the agent: its boundary re-pins. + module.interjection = 'over here now'; + module.interjectionMetadata = addressed('discord:dm:q'); + } + return origHandle(call); + }; + failStream([1, 2], accelerationLimit); + feedStart(framework, 2, + [{ type: 'tool_use', id: 'c2', name: 'robot--move', input: { dir: 'east' } }] as ContentBlock[], + [] as ContentBlock[]); + feedStart(framework, 3, [{ type: 'text', text: 'Back now.' }] as ContentBlock[]); + + trigger(framework); + await framework.runUntilIdle(); + await say(framework, 'quick question for you', addressed('discord:dm:antra')); + // The first release uses the DM; its stream re-pins to q, then is held again. + await release(framework); + assert.equal(membrane.calls.length, 2); + await release(framework); + + assert.equal(membrane.calls.length, 3, 'two deferred compiles'); + assert.deepEqual(movedTo(framework), ['discord:dm:antra', 'discord:dm:q']); + assert.deepEqual(routed, [ + { text: 'Back now.', locus: 'discord:dm:q' }, + ], 'the second release keeps the re-pin its stream made'); + + await framework.stop(); + }); + + it('a message deferred in a start that fails before its stream lands then, so a newer one during the hold wins', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + module.toolDelayMs = 25; + // Stream one is held at its end; the start after the first release is + // held before its stream exists; the third start answers. + const makeStream = membrane.streamYielding.bind(membrane); + let calls = 0; + membrane.streamYielding = (request, options) => { + const n = ++calls; + if (n === 2) throw accelerationLimit(request); + const stream = makeStream(request, options); + const iterate = stream[Symbol.asyncIterator].bind(stream); + stream[Symbol.asyncIterator] = async function* () { + for await (const event of { [Symbol.asyncIterator]: iterate }) { + if (event.type === 'complete' && n === 1) { + yield { type: 'error' as const, error: accelerationLimit(request) }; + return; + } + yield event; + } + }; + return stream; + }; + feedStart(framework, 2, [{ type: 'text', text: 'Back now.' }] as ContentBlock[]); + + trigger(framework); + await framework.runUntilIdle(); + // During the second start, before its stream exists, a DM arrives and is + // deferred: the start holds the turn. (Written directly: the run loop + // that would store an event's message is busy with this start.) + const store = framework as unknown as { + addMessage(participant: string, content: ContentBlock[], metadata: Record): string; + deferredMessages: unknown[]; + }; + let deferredInStart = -1; + module.startHook = async () => { + store.addMessage('Antra', [{ type: 'text', text: 'quick question for you' }] as ContentBlock[], addressed('discord:dm:antra')); + deferredInStart = store.deferredMessages.length; + }; + await release(framework); + assert.equal(deferredInStart, 1, 'the DM was deferred while the start held the turn'); + assert.equal(calls, 2, 'the second start failed before its stream'); + // During the second hold, someone else addresses the agent. + await say(framework, 'over here', addressed('discord:dm:q')); + await release(framework); + + assert.equal(calls, 3, 'the third start compiled'); + assert.deepEqual(routed, [ + { text: 'Back now.', locus: 'discord:dm:q' }, + ], 'the newer message wins: the deferred one landed when its start failed'); + + await framework.stop(); + }); + + it('a budget restart re-pins to an addressed message its over-budget boundary left unweighed', async () => { + const overBudget = createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use'); + overBudget.usage = { inputTokens: 100, outputTokens: 5 }; + membrane.pushResponse(overBudget); + + const framework = await createFramework({ maxStreamTokens: 20 }); + const routed = stubChannelRegistry(framework); + module.toolDelayMs = 25; + module.interjection = 'quick question for you'; + module.interjectionMetadata = addressed('discord:dm:antra'); + feedStart(framework, 2, [{ type: 'text', text: 'After the restart.' }] as ContentBlock[]); + + trigger(framework); + await framework.runUntilIdle(); + + assert.equal(membrane.calls.length, 2, 'the turn restarted for its budget'); + assert.deepEqual(routed, [ + { text: 'After the restart.', locus: 'discord:dm:antra' }, + ]); + assert.deepEqual(movedTo(framework), ['discord:dm:antra']); await framework.stop(); }); - for (const [label, arrivals, expected] of [ - ['keeps the turn\'s locus', [], 'chan-turn'], - ['moves it to the newest addressed arrival', [ - { channelId: 'discord:dm:antra', addressed: true, at: 1 }, - { channelId: 'discord:dm:q', addressed: true, at: 2 }, - ], 'discord:dm:q'], - ['moves it to a follow-up in a channel the turn engaged', [ - { channelId: 'discord:guild:portables', addressed: false, at: 1 }, - ], 'discord:guild:portables'], - ['leaves it for an addressed wake the turn has already weighed', [ - { channelId: 'discord:dm:antra', addressed: true, at: -1 }, - ], 'chan-turn'], + for (const [label, messages, expected] of [ + ["keeps the turn's locus", [], 'chan-turn'], + ['moves it to the newest addressed arrival', + [addressed('discord:dm:antra'), addressed('discord:dm:q')], 'discord:dm:q'], + ['moves it to a follow-up in a channel the turn engaged', + [ambient('discord:guild:portables')], 'discord:guild:portables'], + ['leaves it for a reaction in a channel the turn engaged', + [{ channelId: 'discord:guild:portables', tags: ['chat:reaction'] }], 'chan-turn'], ] as const) { it(`a re-inference on unstreamed tool results ${label}`, async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Carrying on.' }] as ContentBlock[])); const framework = await createFramework(); const routed = stubChannelRegistry(framework); - type Routing = { locus?: string; engaged: Set; consideredUntil: number; [field: string]: unknown }; + type Routing = { locus?: string; engaged: Set; [field: string]: unknown }; const internals = framework as unknown as { turnRouting: Map; openLogical: Map; pendingRequests: InferenceRequest[]; }; - // The turn's earlier stream left its tool results pending for the - // non-streaming fallback's re-inference. The turn froze `chan-turn` as - // its locus, sent into #portables, and last weighed arriving input at - // `consideredUntil`. - const consideredUntil = Date.now() - 10_000; + // The turn's earlier stream froze `chan-turn` as its locus, sent into + // #portables, and left its tool results for the non-streaming + // fallback's re-inference. Messages arrive before that starts. const open: Routing = { - locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(['discord:guild:portables']), consideredUntil, + locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(['discord:guild:portables']), destination: null, hybridHold: false, continuation: false, deliveries: [], suppressed: 0, }; internals.turnRouting.set('assistant', open); internals.openLogical.set('assistant', open); + for (const metadata of messages) await say(framework, 'a message', metadata); internals.pendingRequests.push({ agentName: 'assistant', reason: 'tool_results_ready', source: 'framework', timestamp: Date.now() } as InferenceRequest); - for (const { at, ...wake } of arrivals) { - internals.pendingRequests.push({ - agentName: 'assistant', reason: 'channel-message', source: 'test', timestamp: consideredUntil + at * 1000, ...wake, - } as InferenceRequest); - } await framework.runUntilIdle(); assert.deepEqual(routed, [{ text: 'Carrying on.', locus: expected }]); - const moved = routingNotices(framework).filter((t) => t.includes('The conversation moved to')); - assert.equal(moved.length, expected === 'chan-turn' ? 0 : 1, 'a re-pin leaves its notice'); + assert.deepEqual(movedTo(framework), expected === 'chan-turn' ? [] : [expected], 'a re-pin leaves its notice'); await framework.stop(); }); } + it('the start that continues the turn weighs a message its own flush lands, and a delivery assembled for it', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Carrying on.' }] as ContentBlock[])); + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + type Routing = { locus?: string; engaged: Set; arrival?: string; [field: string]: unknown }; + const internals = framework as unknown as { + turnRouting: Map; openLogical: Map; pendingRequests: InferenceRequest[]; + activeTurnTokens: Map; + deferredMessages: Array>; + addMessage(participant: string, content: ContentBlock[], metadata: Record, opts?: { bypassDeferralFor?: string }): string; + }; + const open: Routing = { + locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(), + destination: null, hybridHold: false, continuation: false, deliveries: [], suppressed: 0, + }; + internals.turnRouting.set('assistant', open); + internals.openLogical.set('assistant', open); + try { + // A delivery assembled while a start of the turn holds it (the + // coalescing assembly writes past the deferral) is an arrival too. + internals.activeTurnTokens.set('assistant', -1); + internals.addMessage('Antra', [{ type: 'text', text: 'batched' }] as ContentBlock[], addressed('discord:dm:q'), { bypassDeferralFor: 'assistant' }); + internals.activeTurnTokens.delete('assistant'); + assert.equal(open.arrival, 'discord:dm:q'); + // A message still deferred when the turn's next start begins lands in + // that start's own flush, and is the newest arrival. + internals.deferredMessages.push({ + id: 'deferred-1', seq: 1, participant: 'Antra', forAgent: 'assistant', + content: [{ type: 'text', text: 'quick question for you' }], metadata: addressed('discord:dm:antra'), + }); + internals.pendingRequests.push({ agentName: 'assistant', reason: 'tool_results_ready', source: 'framework', timestamp: Date.now() } as InferenceRequest); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Carrying on.', locus: 'discord:dm:antra' }]); + assert.deepEqual(movedTo(framework), ['discord:dm:antra']); + } finally { + await framework.stop(); + } + }); + + // A tool's late write lands in the turn that called it: a send's engaged + // channel, and a channel_open's move of the locus. (The dispatch functions + // run on a bare framework, as checkpoint-attribution.test.ts's harness does.) + function bareFramework(): Record & { [method: string]: any } { + const framework = Object.create(AgentFramework.prototype) as Record & { [method: string]: any }; + framework.emitTrace = () => {}; + framework.pushEvent = () => {}; + framework.conversationAgentHomes = new Map(); + framework.lastAnnouncedLocus = new Map(); + return framework; + } + const until = async (done: () => boolean, label: string): Promise => { + for (const end = Date.now() + 2000; !done();) { + if (Date.now() > end) throw new Error(`timed out waiting for ${label}`); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + }; + + it("a send's engaged channel is recorded on the turn that sent, however late its open resolves", async () => { + const sending = { engaged: new Set() }; + const next = { engaged: new Set() }; + let resolveOpen!: (outcome: { status: string; channelId: string }) => void; + const framework = bareFramework(); + framework.mcplServerRegistry = { + getServer: (id: string) => id === 'chat' ? { + id: 'chat', + sendToolsCall: async () => ({ content: [{ type: 'text', text: 'sent' }] }), + } : null, + }; + framework.mcplServerConfigs = new Map([['chat', { id: 'chat', toolPrefix: 'chat' }]]); + framework.mcplToolFeatureSets = new Map(); + framework.checkpointManager = null; + framework.channelRegistry = { + openIfClosedForSend: () => new Promise((resolve) => { resolveOpen = resolve; }), + }; + framework.turnRouting = new Map([['assistant', sending]]); + const results: unknown[] = []; + framework.pushEvent = (event: { type: string }) => { if (event.type === 'tool-result') results.push(event); }; + + framework.dispatchMcplToolCall('assistant', { + id: 'send-1', name: 'chat--send_message', input: { channelId: 'discord:guild:portables', text: 'hi' }, + }, 'chat', 'chat'); + await until(() => results.length > 0 && resolveOpen !== undefined, 'the send to finish'); + // The turn that sent has ended, and the next turn is the agent's live routing. + (framework.turnRouting as Map).set('assistant', next); + resolveOpen({ status: 'already-open', channelId: 'discord:guild:portables' }); + await until(() => sending.engaged.size > 0, 'the open to resolve'); + + assert.deepEqual([...sending.engaged], ['discord:guild:portables']); + assert.deepEqual([...next.engaged], [], "the next turn didn't send there"); + }); + + it("a channel_open moves the locus of the turn that called it, however late its result arrives", async () => { + const calling: { engaged: Set; triggerChannel?: string; locus?: string } = { engaged: new Set(), locus: 'chan-before' }; + const next: { engaged: Set; triggerChannel?: string; locus?: string } = { engaged: new Set(), locus: 'chan-next' }; + let resolveOpen!: (result: { success: boolean; data: { channelId: string } }) => void; + const framework = bareFramework(); + framework.agents = new Map([['assistant', { proseRouting: 'locus' }]]); + framework.channelRegistry = { + handleChannelToolCall: () => new Promise((resolve) => { resolveOpen = resolve; }), + }; + framework.turnRouting = new Map([['assistant', calling]]); + const results: unknown[] = []; + framework.pushEvent = (event: { type: string }) => { if (event.type === 'tool-result') results.push(event); }; + + framework.dispatchChannelToolCall('assistant', { + id: 'open-1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' }, + }); + await until(() => resolveOpen !== undefined, 'the open to start'); + (framework.turnRouting as Map).set('assistant', next); + resolveOpen({ success: true, data: { channelId: 'discord:guild:observatory' } }); + await until(() => results.length > 0, 'the tool result'); + + assert.equal(calling.triggerChannel, 'discord:guild:observatory'); + assert.equal(calling.locus, 'discord:guild:observatory'); + assert.deepEqual(next, { engaged: new Set(), locus: 'chan-next' }, 'the next turn is untouched'); + }); + it('a prose turn ends with a [delivered] receipt naming where the prose landed', async () => { // Explicit sends receipt themselves via tool_result; auto-routed prose // previously vanished with no in-window trace — the agent could never diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index 159c5c792..fc95926b1 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -983,11 +983,11 @@ test("a fresh turn starts new routing state in every mode, and leaves the earlie const h = await harness([[answer()]], { proseRouting: mode }); type Routing = { pin?: string; destination: string | null; hybridHold: boolean; continuation: boolean; deliveries: string[]; suppressed: number; - triggerChannel?: string; locus?: string; engaged: Set; consideredUntil: number }; + triggerChannel?: string; locus?: string; engaged: Set; arrival?: string }; const internals = h.framework as unknown as { turnRouting: Map }; const earlierFields = (): Routing => ({ pin: 'chan-earlier', destination: 'chan-earlier', hybridHold: true, continuation: true, deliveries: ['chan-earlier'], suppressed: 2, - triggerChannel: 'chan-earlier', locus: 'chan-earlier', engaged: new Set(['chan-earlier']), consideredUntil: 1 }); + triggerChannel: 'chan-earlier', locus: 'chan-earlier', engaged: new Set(['chan-earlier']), arrival: 'chan-earlier' }); const earlier = earlierFields(); internals.turnRouting.set('assistant', earlier); try { @@ -1003,6 +1003,7 @@ test("a fresh turn starts new routing state in every mode, and leaves the earlie assert.equal(now?.triggerChannel, undefined, `${mode}: no trigger channel carried into the new turn`); assert.notEqual(now?.locus, 'chan-earlier', `${mode}: no locus carried into the new turn`); assert.deepEqual([...(now?.engaged ?? [])], [], `${mode}: no engaged channels carried into the new turn`); + assert.equal(now?.arrival, undefined, `${mode}: no arrival carried into the new turn`); assert.deepEqual(earlier, earlierFields(), `${mode}: all earlier-turn routing and receipt state stays with its queued prose`); } finally { diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 30f4a52e9..96a0e5cd3 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -24,6 +24,7 @@ import { join } from 'node:path'; import { AgentFramework } from '../src/index.js'; import type { ProcessEvent } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import { MembraneError } from '@animalabs/membrane'; /** Reach the private per-turn routing state (its triggering channel) + the pure channel-deriver. */ function internals(framework: AgentFramework) { @@ -362,7 +363,7 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); - it("the channel registry resolves and reports the current turn's trigger channel", async () => { + it("the channel registry resolves and reports the current turn's trigger channel, then its locus", async () => { const framework = await makeFramework(); const i = framework as unknown as { initializeMcpl(servers: unknown[]): Promise; @@ -370,7 +371,7 @@ describe('Trunk channel routing (item-3 redux)', () => { resolveLocus(agentName: string): string | null; buildChannelContext(agentName?: string): { defaultOutgoing?: { channelId: string } } | undefined; }; - turnRouting: Map; + turnRouting: Map; }; // The real registry, with no servers: its resolver reads the framework's turn state. await i.initializeMcpl([]); @@ -379,6 +380,63 @@ describe('Trunk channel routing (item-3 redux)', () => { 'the locus a turn freezes comes from its trigger channel'); assert.equal(i.channelRegistry.buildChannelContext('scout')?.defaultOutgoing?.channelId, 'discord:guild:chanA', 'and the agent is told the same channel'); + // A re-pin moved the turn's locus: a later start of the turn tells the + // agent where its speech now lands. + i.turnRouting.set('scout', { triggerChannel: 'discord:guild:chanA', locus: 'discord:dm:42' }); + assert.equal(i.channelRegistry.buildChannelContext('scout')?.defaultOutgoing?.channelId, 'discord:dm:42'); + await framework.stop(); + }); + + it("the compile a capacity hold defers a turn to keeps the turn's trigger channel", async () => { + // The first stream ends with an organization acceleration 429, which + // holds the turn; a message from another channel arrives during the hold. + membrane.pushResponse(createMockResponse([] as never[])); + const framework = await makeFramework(); + const makeStream = membrane.streamYielding.bind(membrane); + let streams = 0; + membrane.streamYielding = (request, options) => { + const stream = makeStream(request, options); + const n = ++streams; + const iterate = stream[Symbol.asyncIterator].bind(stream); + stream[Symbol.asyncIterator] = async function* () { + for await (const event of { [Symbol.asyncIterator]: iterate }) { + if (event.type === 'complete' && n === 1) { + yield { + type: 'error' as const, + error: new MembraneError({ + type: 'rate_limit', retryable: true, httpStatus: 429, + message: "This request would exceed your organization's maximum usage increase rate for input tokens per minute", + rawError: { status: 429 }, rawRequest: request, + }), + }; + return; + } + yield event; + } + }; + return stream; + }; + const held = framework as unknown as { + providerAccelerationCooldowns: Map; + releaseProviderAccelerationCooldown(agentName: string): void; + }; + const triggerAtStart: Array = []; + framework.onTrace((event) => { + if (event.type !== 'inference:started') return; + triggerAtStart.push(internals(framework).turnRouting.get('scout')?.triggerChannel); + if (triggerAtStart.length === 2) membrane.pushResponse(createMockResponse([{ type: 'text', text: 'back' }])); + }); + + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: what time is it')); + await framework.runUntilIdle(); + assert.ok(held.providerAccelerationCooldowns.has('scout'), 'the turn is held'); + framework.pushEvent(channelIncoming('discord:guild:chanB', 'B: unrelated chatter')); + await framework.runUntilIdle(); + held.releaseProviderAccelerationCooldown('scout'); + await framework.runUntilIdle(); + + assert.deepEqual(triggerAtStart, ['discord:guild:chanA', 'discord:guild:chanA'], + "the deferred compile's batch names chanB, and the turn keeps chanA"); await framework.stop(); }); From 2374a60c5730fc94336ef59d64612b7d730c0908 Mon Sep 17 00:00:00 2001 From: Nigel-2405 Date: Tue, 13 Oct 2026 10:59:23 -0400 Subject: [PATCH 04/17] fix(routing): answer the second internal review of the turn-routing change - A start that throws outside its stream's own failure handling (locus resolution, the typing indicator) after opening its logical turn left the turn open, so the agent's next start took its whole routing object. startAgentStream now ends that turn when such a throw escapes, as beginAgentTurn's catch settles a failure before the stream exists. A silent start's throw leaves an open turn alone. - The flush at the end of a start that fails before its stream stores each message in its own try/catch, as the turn-start flush does, so one store failure neither drops the rest nor replaces the start's own error. - A tool boundary re-pins, or notes arrivals, only when the turn itself routes a locus: a silent turn's boundary, in an agent whose mode is locus or hybrid, no longer re-pins or stores a routing notice (as on main), and explicit and disabled turns note no arrivals. - repinTurnLocus moves the announce baseline only once its notice is stored, and a channel_open moves it only while the calling turn is still the agent's live turn. - stop() no longer clears turnRouting: a resume after a restart carries its own turn's request on, so it keeps that turn's routing, as on main. Only the open logical turn ends at stop(). - beginAgentTurn's unused startedAt is gone. - Comments and docs: the trace fields say what they carry in each mode; the registry's and the framework's resolver comments say it returns the locus, else the trigger channel; the hybrid doc says the hold lasts until a later envelope is delivered and governs publication only; the explicit doc says a round kept private sets no target. The changelog fragment names a trunk agent's defaultOutgoing (a fork keeps its home), says a hybrid stream's deltas still start at the locus, says which ambient traffic doesn't move the locus, and covers the silent boundary and the escaped throw. Tests: a start that throws before its stream ends its logical turn; a silent turn's tool boundary doesn't re-pin or tell the agent the conversation moved; in explicit mode an addressed arrival moves nothing. The pre-stream failure test now uses a host hold of a stream that can't be set up rather than a thrown 429, the trunk capacity test asserts that the second channel's wake waits in the hold, and the stop test no longer expects turnRouting cleared. Co-Authored-By: Claude Opus 5.5 --- .../continuing-starts-keep-routing.fixed.md | 29 +++-- docs/explicit-prose-routing.md | 4 +- docs/hybrid-prose-routing.md | 10 +- src/framework.ts | 102 +++++++++++------- src/mcpl/channel-registry.ts | 20 ++-- src/types/trace.ts | 21 ++-- test/explicit-prose-routing.test.ts | 35 +++++- test/present-while-acting.test.ts | 63 ++++++++++- test/trunk-channel-routing.test.ts | 2 + 9 files changed, 209 insertions(+), 77 deletions(-) diff --git a/changelog.d/continuing-starts-keep-routing.fixed.md b/changelog.d/continuing-starts-keep-routing.fixed.md index 7fced3c93..ddf8f8404 100644 --- a/changelog.d/continuing-starts-keep-routing.fixed.md +++ b/changelog.d/continuing-starts-keep-routing.fixed.md @@ -1,8 +1,8 @@ - A start that continues a logical turn now keeps what the turn has decided - about where its speech goes. Those starts are a framework retry after a - stream error, the re-inference that consumes unstreamed tool results, and - the compile a provider-capacity hold defers a turn to (#314). Before, each - began with fresh routing: + about where its speech is published. Those starts are a framework retry + after a stream error, the re-inference that consumes unstreamed tool + results, and the compile a provider-capacity hold defers a turn to (#314). + Before, each began with fresh routing: - in hybrid mode, the hold after `>>>skip_reply` was dropped, so the new stream's unprefixed prose was published to the turn's locus; - the sticky destination was dropped in both modes, so unprefixed prose @@ -11,6 +11,9 @@ - the turn's locus was resolved again: a retry lost a mid-turn addressed re-pin, a re-inference took the locus of whichever wake started it, and a held turn's later compile let a newer ambient message move it. + + A hybrid stream's live outgoing deltas still start at the turn's locus, + as before. - A message that no tool boundary weighed now moves the turn's locus at the turn's next start, by the rule a mid-turn message follows: a conversational message that addressed the agent, or that follows up in a @@ -18,16 +21,20 @@ `[routing] The conversation moved to …` notice. That covers a message that arrives during a capacity hold or before a re-inference, and one drained at a budget restart's over-budget boundary, which before never moved the - locus. Ambient traffic, reactions and system markers don't move it. -- The channel context passed to `beforeInference` hooks + locus. Ambient traffic in a channel the turn hasn't sent into, reactions + and system markers don't move it. +- A silent turn's tool boundary no longer re-pins its locus or adds a + `[routing]` notice to its stream. +- For a trunk agent, the channel context passed to `beforeInference` hooks (`channels.defaultOutgoing`) names the turn's locus once the turn has one. - After a re-pin, a later start of the turn reports the channel its speech - lands in, not the channel that triggered the turn. + After a re-pin, a later start of the turn reports the channel its + unprefixed speech is published in, not the channel that triggered the + turn. A conversation fork still reports its home channel. - A start that fails before its stream exists now lands the messages deferred during it, as a stream's end does. Before, they waited for the - agent's next start and landed after any message stored in between. + agent's next start and landed after any message stored in between. A + start that throws outside its stream's failure handling ends its logical + turn, so the agent's next start opens its own. - A send's engaged channel, and a `channel_open`'s move of the locus, are recorded on the turn that called the tool, even when the tool's result, or the open that follows a send, comes after that turn has ended. -- `stop()` clears every agent's routing state, so a resume after a restart - starts afresh. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index e5eaa090e..f237a61fe 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -25,7 +25,9 @@ A prose segment's FIRST line may carry a routing prefix: - **Sticky within the turn:** the latest resolved target applies to later unprefixed segments of the SAME turn, including those a later stream of it writes, such as a framework retry's, a budget restart's or the compile a - provider-capacity hold defers the turn to. Cross-turn, nothing is implicit. + provider-capacity hold defers the turn to. A round kept private (one that + calls `skip_reply`, or has a same-round private think) sets no target. + Cross-turn, nothing is implicit. - **Continuation modifier:** ` !` immediately after the target (`>>#ops !` / `>>skip_reply !`) requests an immediate re-wake after this turn ends, instead of the default pause-until-next-event. Gives prose the same diff --git a/docs/hybrid-prose-routing.md b/docs/hybrid-prose-routing.md index 3ea280d1a..e99b2c736 100644 --- a/docs/hybrid-prose-routing.md +++ b/docs/hybrid-prose-routing.md @@ -25,10 +25,12 @@ bounce explains failure), so authored intent cannot impersonate delivery. Unprefixed prose after a successful envelope follows that explicit destination for the remainder of the turn; a fresh turn begins at its ordinary locus. After `>>>skip_reply`, or a `>>>` envelope that is malformed, doesn't resolve -or isn't delivered, unprefixed prose isn't published until a later `>>>` names -a destination that resolves. The sticky destination and this hold last for -the whole logical turn, across its later streams, such as a framework retry's, -a budget restart's or the compile a provider-capacity hold defers the turn to. +or isn't delivered, unprefixed prose isn't published until a later `>>>` +envelope is delivered. The sticky destination and this hold last for the +whole logical turn, across its later streams, such as a framework retry's, a +budget restart's or the compile a provider-capacity hold defers the turn to. +They govern publication: a stream's live outgoing deltas still start at the +turn's locus. This is publication routing only. It creates no privacy meaning for `` or other prose tags, and grants no channel authority beyond the existing diff --git a/src/framework.ts b/src/framework.ts index 31dae1a00..7a8ddfddc 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2201,11 +2201,11 @@ export class AgentFramework { // A stopped host must never hang behind its own cooldown. this.cancelProviderAdmission(); - // A turn doesn't survive a stop: a start after a restart on this - // instance, a resume included, gets new routing state rather than - // carrying the old turn's across (openLogical, turnRouting). + // An open logical turn doesn't survive a stop: a start after a restart + // on this instance opens a new one rather than continuing the old turn's + // routing (openLogical). A resume still keeps its turn's routing state + // (turnRouting): it carries that turn's own request on. this.openLogical.clear(); - this.turnRouting.clear(); // Cancel all active streams for (const agent of this.agents.values()) { @@ -7154,10 +7154,11 @@ export class AgentFramework { // classifier-safe D-class from the 2026-07-24 ablation). It also // updates lastAnnouncedLocus so the next turn's announce-on-change // diffs against what the agent was actually last told. - if ( - (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid') && - !shouldEndTurn && !overBudget && currentState.stream - ) { + // The stream's own mode: a silent turn routes no prose, whatever + // the agent's mode, so its boundary neither re-pins nor notes. + const routesLocus = (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid') + && !this.activeTurnTriggers.get(agent.name)?.suppressProse; + if (routesLocus && !shouldEndTurn && !overBudget && currentState.stream) { // Two signals qualify an injection to move the pin (n=6 + n=7): // - chat:addressed — someone explicitly spoke TO the agent // (mention / reply / DM); @@ -7181,7 +7182,7 @@ export class AgentFramework { const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed'); if (notice) midTurnInjections.push(notice); } - } else if (!shouldEndTurn) { + } else if (routesLocus && !shouldEndTurn) { // A boundary that can't re-pin now (over budget, or no live // stream to tell) leaves its injections to the turn's next start, // as arrivals: a budget restart, or a re-inference on these tool @@ -9027,11 +9028,12 @@ export class AgentFramework { * addressed message, or a follow-up in a channel the turn engaged (the * addressed re-pin, repinChannel). Ambient chatter never moves it. Leaves * a durable `[routing]` notice in the window and updates - * lastAnnouncedLocus, so the next turn's announce-on-change diffs against - * what the agent was last told. Returns the notice as stored, for a live + * lastAnnouncedLocus once it's stored, so the next turn's announce-on-change + * diffs against what the agent was last told. Returns the notice as stored, for a live * stream to inject with its boundary's other messages. Returns undefined * when the locus is already there, and when the notice couldn't be - * stored, though the locus has moved then too. + * stored. The locus has moved then too, but the announce baseline hasn't, + * so the agent's next turn announces where its speech lands. */ private repinTurnLocus( agent: Agent, @@ -9042,7 +9044,6 @@ export class AgentFramework { if (routing.locus === channelId) return undefined; const prevPin = routing.locus ?? null; routing.locus = channelId; - this.lastAnnouncedLocus.set(agent.name, channelId); const label = this.channelRegistry?.getDescriptor(channelId)?.label; const shown = label && label !== channelId ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})` @@ -9057,6 +9058,7 @@ export class AgentFramework { const metadata = { system: true, kind: 'routing-notice' } as MessageMetadata; try { const id = agent.getContextManager().addMessage('user', content, metadata); + this.lastAnnouncedLocus.set(agent.name, channelId); this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' }); console.error(`[routing] ${agent.name}: ${cause} re-pin ${prevPin ?? '(none)'} -> ${channelId}`); return { participant: 'user', content, metadata }; @@ -9562,6 +9564,18 @@ export class AgentFramework { let tokenHandedOff = false; try { tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate); + } catch (error) { + // A throw outside the stream's own failure handling (the checkpoint, + // locus resolution, the typing indicator) drops this start's trigger, + // and nothing retries it. Its logical turn ends with it, as a failure + // in beginAgentTurn's own catch settles it, so the agent's next start + // opens a turn of its own rather than continuing this one's routing. + // A silent start has no part in an open turn. + const routing = this.turnRouting.get(agent.name); + if (!trigger?.suppressProse && routing && this.openLogical.get(agent.name) === routing) { + this.openLogical.delete(agent.name); + } + throw error; } finally { if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) { this.activeTurnTokens.delete(agent.name); @@ -9571,13 +9585,23 @@ export class AgentFramework { // A start that ends before its stream exists lands what was deferred // during it, as a stream's teardown does. Left queued, those messages // would land only at the agent's next start, after any stored meanwhile. + // Per message, as the turn-start flush does: one store failure must + // neither drop the messages behind it nor replace the start's own error. if (!tokenHandedOff && !this.activeTurnTokens.has(agent.name) && this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) { for (const msg of this.drainDeferredFor(agent.name)) { - this.addMessage(msg.participant, msg.content, msg.metadata, { - deferredWriteId: msg.id, - ...(msg.forAgent ? { forAgent: msg.forAgent } : {}), - }); + try { + this.addMessage(msg.participant, msg.content, msg.metadata, { + deferredWriteId: msg.id, + ...(msg.forAgent ? { forAgent: msg.forAgent } : {}), + }); + } catch (err) { + console.error( + `[deferred-flush] failed-start flush failed to store a message for ${agent.name} ` + + `(participant=${msg.participant}):`, + err, + ); + } } this.ackDeferredWrites(); } @@ -9596,7 +9620,6 @@ export class AgentFramework { turnToken: number, ownsProviderGate: boolean, ): Promise { - const startedAt = Date.now(); const resumesTurn = resumesLogicalTurn(trigger?.reason); const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting; // Flush messages deferred during the PREVIOUS turn — before the @@ -11045,13 +11068,11 @@ export class AgentFramework { this.recordProseSuppression(streamRouting, segments.length); } } else { - // Reuse the locus pinned at the turn's first live-routed - // segment (or resolve it now for a turn whose only prose is - // trailing). This dispatch runs AFTER the agent is idle (see - // PR #32 note below), so a queued inbound from another channel - // could otherwise overwrite the per-agent triggering channel - // between segments — and a turn that narrated live into one - // channel must not land its postscript in another. + // The turn's own locus, read from this stream's routing + // state. This dispatch runs AFTER the agent is idle (see PR + // #32 note below), so a next turn may already have started + // with routing of its own, and a turn that narrated live into + // one channel must not land its postscript in another. const locus = resolveTurnLocus(); console.error( `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${locus ?? '(default)'}`, @@ -13830,15 +13851,17 @@ export class AgentFramework { homeChannelResolver: (agentName) => this.conversationAgentHomes.get(agentName) ?? this.conversationRouter?.channelForAgent(agentName), - // Route a single TRUNK agent's plain-text speech to the channel that - // triggered its CURRENT turn (item-3 redux). connectome-host runs every - // agent as a trunk (it never exposes conversation forks), so without this - // a reply falls back to the process-global most-recent-inbound locus and - // a concurrent message in another channel hijacks it. Empty for - // heartbeat / no-trigger turns → correct global fallback. Once the - // turn has a locus, it's the locus, a re-pin's included: that's where - // the turn's plain speech lands, so the channel context the agent is - // told at a later start of the turn names it. + // A single TRUNK agent's channel for its CURRENT turn (item-3 redux): + // its locus once the turn has one, a re-pin's or a channel_open's + // included, else the channel that triggered the turn. connectome-host + // runs every agent as a trunk (it never exposes conversation forks), so + // without this a reply falls back to the process-global + // most-recent-inbound locus and a concurrent message in another + // channel hijacks it. An opening start resolves the turn's locus from + // it (the trigger channel then), and the channel context names it, so + // a later start of the turn is told where its unprefixed speech lands + // unless a hybrid `>>>` destination takes it. Empty for heartbeat / + // no-trigger turns → correct global fallback. activeChannelResolver: (agentName) => { const routing = this.turnRouting.get(agentName); return routing?.locus ?? routing?.triggerChannel; @@ -15755,7 +15778,9 @@ export class AgentFramework { const openerAgent = this.agents.get(agentName); if (openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) { callingTurn.locus = opened; - this.lastAnnouncedLocus.set(agentName, opened); + // The announce baseline is the agent's, across turns: it takes + // only a move this result tells the agent, while its turn runs. + if (this.turnRouting.get(agentName) === callingTurn) this.lastAnnouncedLocus.set(agentName, opened); result = { ...result, data: { @@ -16914,9 +16939,10 @@ export class AgentFramework { // Conversation identity = the agent (a trunk agent IS its own conversation; // forks get their own agent). The turn's channel LOCUS — the "proper // conversation tracking" this once flagged as a TODO — is now tracked - // as the turn's trigger channel (TurnRouting) and surfaced to the agent via - // buildChannelContext (channels.defaultOutgoing) below, so the agent is - // told the same channel its speech will route to (item-3 redux). + // on the turn's routing state (TurnRouting.locus, from its trigger + // channel) and surfaced to the agent via buildChannelContext + // (channels.defaultOutgoing) below, so the agent is told the same + // channel its speech will route to (item-3 redux). conversationId: agent.name, turnIndex: 0, // Simplified; needs per-conversation counter TODO userMessage: null, // Could extract from trigger context diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 64894a18f..a9d0c1786 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -512,17 +512,18 @@ interface ChannelRegistryOptions { */ homeChannelResolver?: (agentName: string) => string | undefined; /** - * Resolve the channel that triggered an agent's CURRENT inference turn, by - * agent name. This is the fix for single-TRUNK agents (the only mode + * Resolve an agent's channel for its CURRENT inference turn, by agent name: + * the turn's locus once it has one, else the channel that triggered the + * turn. This is the fix for single-TRUNK agents (the only mode * connectome-host runs — it never exposes conversation forks). A trunk agent * has no fork home, so without this its plain-text speech falls back to the * process-global `defaultPublishChannel`, which tracks the most-recent inbound * across ALL channels and misroutes a reply to whichever channel last spoke - * under concurrency (item-3 redux). The framework tracks the triggering - * channel of the live turn per-agent and exposes it here; resolves to - * undefined for a heartbeat / no-trigger turn, which correctly keeps the - * global fallback. Consulted AFTER `homeChannelResolver` (a fork's home always - * wins), BEFORE `defaultPublishChannel`. + * under concurrency (item-3 redux). The framework keeps both on the live + * turn's routing state and exposes them here; resolves to undefined for a + * heartbeat / no-trigger turn, which correctly keeps the global fallback. + * Consulted AFTER `homeChannelResolver` (a fork's home always wins), BEFORE + * `defaultPublishChannel`. */ activeChannelResolver?: (agentName: string) => string | undefined; } @@ -1361,8 +1362,9 @@ export class ChannelRegistry { // Resolve the outbound locus the SAME way routeSpeech does, so the agent is // told the channel its speech will actually land in: a conversation fork's - // home channel, else this turn's triggering channel (single trunk agent — - // item-3 redux), else the global default (heartbeats). Without this, a fork + // home channel, else this turn's channel (single trunk agent — item-3 + // redux: its locus, else its triggering channel), else the global default + // (heartbeats). Without this, a fork // was advertised the global locus but published somewhere else; and a trunk // agent was told the wrong channel under concurrency. const home = agentName ? this.homeChannelResolver?.(agentName) : undefined; diff --git a/src/types/trace.ts b/src/types/trace.ts index 093a17be4..bf7fdddc9 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -41,11 +41,14 @@ export type TraceEvent = type: 'inference:started'; agentName: string; /** - * The turn's outbound locus as this stream starts: the channel its - * plain prose is routed to, until a re-pin moves it. Omitted for turns - * with no locus (heartbeats with no default channel). Observability only, like every - * trace field; lets external taps (e.g. a TTS relay) tag the whole - * activation with its channel without re-deriving routing. + * The turn's locus as this stream starts (locus and hybrid mode): where + * its unprefixed plain prose is published, unless a hybrid `>>>` + * destination takes it, or a re-pin or a `channel_open` moves the locus + * later in the turn. Omitted for a turn with no locus: explicit and + * disabled mode, a silent turn, and a heartbeat with no default + * channel. Observability only, like every trace field; lets external + * taps (e.g. a TTS relay) tag the whole activation with its channel + * without re-deriving routing. */ channelId?: string; /** True for a control-plane wake that must not surface typing/prose. */ @@ -95,7 +98,9 @@ export type TraceEvent = blockType: 'text' | 'thinking' | 'tool_call' | 'tool_result'; /** 0-indexed block position in the current assistant turn. */ blockIndex: number; - /** The turn's outbound locus as its stream started (see inference:started). */ + /** The channel this stream shows typing in: the turn's locus as the + * stream started (see inference:started), or in explicit mode the + * channel the start's wake came from. */ channelId?: string; }) | (TraceEventBase & { @@ -110,7 +115,9 @@ export type TraceEvent = phase: 'block_start' | 'block_complete'; blockType: 'text' | 'thinking' | 'tool_call' | 'tool_result'; blockIndex: number; - /** The turn's outbound locus as its stream started (see inference:started). */ + /** The channel this stream shows typing in: the turn's locus as the + * stream started (see inference:started), or in explicit mode the + * channel the start's wake came from. */ channelId?: string; }) | (TraceEventBase & { diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 3bae228b2..c97c7e46b 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -906,11 +906,10 @@ describe('explicit prose routing', () => { } }); - it("a settle closes the open logical turn only when it is the settling stream's own, and stop() clears every turn's routing", async () => { + it("a settle closes the open logical turn only when it is the settling stream's own, and stop() clears open turns", async () => { const framework = await createFramework('hybrid'); const internals = framework as unknown as { openLogical: Map; - turnRouting: Map; settleAgent(name: string, outcome: { stopReason: 'completed'; speech: string }, closes: RoutingState | undefined): void; }; const open: RoutingState = routingState({ continuation: true }); @@ -924,10 +923,8 @@ describe('explicit prose routing', () => { internals.settleAgent('assistant', { stopReason: 'completed', speech: '' }, open); assert.equal(internals.openLogical.has('assistant'), false, "the turn's own settle closes it"); internals.openLogical.set('assistant', open); - internals.turnRouting.set('assistant', open); await framework.stop(); assert.equal(internals.openLogical.size, 0, 'stop() clears every open logical turn'); - assert.equal(internals.turnRouting.size, 0, 'and every live routing state, so a resume after a restart starts afresh'); }); it('a silent start neither takes nor disturbs an open logical turn, and leaves it to the next start that routes prose', async () => { @@ -1762,6 +1759,36 @@ describe('explicit prose routing', () => { }); } + it('explicit: an addressed message between the turn\'s streams moves nothing, since explicit mode has no locus', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'carrying on' }] as ContentBlock[])); + const framework = await createFramework('explicit'); + const routed = stubRegistry(framework); + feedTurn(framework, 2, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + const internals = framework as unknown as { + turnRouting: Map; pendingRequests: InferenceRequest[]; + openLogical: Map; + addMessage(participant: string, content: ContentBlock[], metadata: Record): string; + }; + try { + const earlier = routingState({ pin: 'chan-alpha', destination: 'chan-alpha' }); + internals.turnRouting.set('assistant', earlier); + internals.openLogical.set('assistant', earlier); + // Someone addresses the agent from another channel while the turn waits. + internals.addMessage('Antra', [{ type: 'text', text: 'quick question for you' }] as ContentBlock[], + { channelId: 'discord:g:cafe', tags: ['chat:addressed'] }); + assert.equal(earlier.arrival, 'discord:g:cafe', 'it is the turn\'s arrival'); + internals.pendingRequests.push({ agentName: 'assistant', reason: 'tool_results_ready', source: 'framework', timestamp: Date.now() } as InferenceRequest); + await within(framework.runUntilIdle(), 'the re-inference', 10000); + + assert.deepEqual(routed.map((r) => ({ text: r.text.trim(), locus: r.locus })), [{ text: 'carrying on', locus: 'chan-alpha' }]); + assert.equal(earlier.locus, undefined, 'no locus'); + assert.equal(earlier.arrival, undefined, 'the arrival is used up'); + assert.ok(!context(framework).texts.some((t) => t.includes('The conversation moved to')), 'and no routing notice'); + } finally { + await framework.stop(); + } + }); + for (const mode of ['explicit', 'hybrid'] as const) { it(`${mode}: a retried turn's text waits for the replaced stream's late deliveries, and follows what they set`, async () => { const p = mode === 'explicit' ? '>>' : '>>>'; diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 86cc3a19e..a2011d15b 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -793,13 +793,20 @@ describe('present while acting', () => { const framework = await createFramework(); const routed = stubChannelRegistry(framework); module.toolDelayMs = 25; - // Stream one is held at its end; the start after the first release is - // held before its stream exists; the third start answers. + // The host holds the agent once when its stream can't be set up. (Asked + // again at the release, it lets the hold go.) + let hostHolds = 0; + (framework as unknown as { providerHoldHook: (error: Error) => { holdMs: number; reason: string } | undefined }) + .providerHoldHook = (error) => error.message === 'stream setup failed' && hostHolds++ === 0 + ? { holdMs: 60_000, reason: 'host hold' } : undefined; + // Stream one is held at its end; the start after the first release + // fails before its stream exists, and the host holds it; the third start + // answers. const makeStream = membrane.streamYielding.bind(membrane); let calls = 0; membrane.streamYielding = (request, options) => { const n = ++calls; - if (n === 2) throw accelerationLimit(request); + if (n === 2) throw new Error('stream setup failed'); const stream = makeStream(request, options); const iterate = stream[Symbol.asyncIterator].bind(stream); stream[Symbol.asyncIterator] = async function* () { @@ -948,6 +955,56 @@ describe('present while acting', () => { } }); + it('a start that throws before its stream ends its logical turn, so the next start opens its own', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Hello there.' }] as ContentBlock[])); + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + // The registry is unavailable for the first start, which throws after it + // has opened its turn, outside the stream's own failure handling. + const registry = (framework as unknown as { channelRegistry: Record string> }).channelRegistry; + const resolve = registry.resolveLocus; + let failed = false; + registry.resolveLocus = (agent) => { + if (!failed) { failed = true; throw new Error('registry unavailable'); } + return resolve(agent); + }; + const internals = framework as unknown as { openLogical: Map }; + + trigger(framework); + await assert.rejects(framework.runUntilIdle(), /registry unavailable/); + assert.equal(internals.openLogical.has('assistant'), false, 'the failed start left no turn open'); + trigger(framework); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Hello there.', locus: 'chan-live-1' }], + 'the next start resolved a locus of its own'); + + await framework.stop(); + }); + + it("a silent turn's tool boundary doesn't re-pin its locus or tell it the conversation moved", async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'quiet' }] as ContentBlock[])); + const framework = await createFramework(); + stubChannelRegistry(framework); + module.toolDelayMs = 25; + module.interjection = 'quick question for you'; + module.interjectionMetadata = addressed('discord:dm:antra'); + const internals = framework as unknown as { pendingRequests: InferenceRequest[] }; + + internals.pendingRequests.push({ + agentName: 'assistant', reason: 'heartbeat', source: 'framework', timestamp: Date.now(), suppressProse: true, + } as InferenceRequest); + await framework.runUntilIdle(); + + assert.equal(module.interjection, null, 'the silent turn ran its tool round, and the DM arrived during it'); + assert.deepEqual(movedTo(framework), [], 'no routing notice in a turn that routes no prose'); + + await framework.stop(); + }); + // A tool's late write lands in the turn that called it: a send's engaged // channel, and a channel_open's move of the locus. (The dispatch functions // run on a bare framework, as checkpoint-attribution.test.ts's harness does.) diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 96a0e5cd3..e5708e4f3 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -432,6 +432,8 @@ describe('Trunk channel routing (item-3 redux)', () => { assert.ok(held.providerAccelerationCooldowns.has('scout'), 'the turn is held'); framework.pushEvent(channelIncoming('discord:guild:chanB', 'B: unrelated chatter')); await framework.runUntilIdle(); + const waiting = (held.providerAccelerationCooldowns.get('scout') as { heldRequests: Array<{ channelId?: string }> }).heldRequests; + assert.ok(waiting.some((r) => r.channelId === 'discord:guild:chanB'), "chanB's wake waits in the hold with the turn's"); held.releaseProviderAccelerationCooldown('scout'); await framework.runUntilIdle(); From 4a6ffe6bbee8610b72ea072b5d5f9fa738d4ef6b Mon Sep 17 00:00:00 2001 From: Nigel-2405 Date: Tue, 13 Oct 2026 11:50:03 -0400 Subject: [PATCH 05/17] fix(routing): answer the review of the second fix round - A start whose throw escapes beginAgentTurn ends the agent's open logical turn through settleAgent, not by comparing the live routing slot with openLogical. The open turn is the one the start was opening, continuing or resuming, so it ends whether or not the start had taken its object, and an ephemeral run's start fails the run with the error instead of leaving it to the watchdog. A silent start's throw leaves an open turn alone. - A resume opens its turn again when no turn is open, as after stop() ended every open turn, so the turn's later starts (a framework retry, a re-inference, a capacity-deferred compile) continue its routing instead of opening a fresh one. stop() still keeps the live routing. - announceLocusIfChanged moves the announce baseline only once its notice is stored, as repinTurnLocus does since the last round. - A silent turn's channel_open moves no locus and its result claims no move: a silent turn routes no prose. - The failed-start flush's log names the message id. - Comments: the escaped-throw catch, the boundary gate's reading of the turn token's trigger, and the resolver (undefined for a heartbeat only until its locus is resolved). The hybrid doc says the hold lifts when a later >>> names a destination that resolves and returns if its body isn't delivered; the changelog says a hybrid >>> destination can take unprefixed speech from the reported locus. - 2374a60c's message said explicit and disabled turns note no arrivals: that holds for the tool boundary, while a message stored with no stream running is noted for any open turn, and the next start clears it unused outside locus and hybrid mode. Tests: a continuing start that throws before it takes the turn's routing still ends the turn; a silent start's throw leaves an open turn alone; a resume after stop() continues its turn and its retry keeps the routing; a silent turn's over-budget boundary leaves no arrival on an open prose turn, and its restart stays silent; the failed-start flush stores the message behind a refused one; the announce baseline stays put when a re-pin's or a turn's notice can't be stored; a channel_open moves the baseline only while its turn runs, and a silent turn's moves nothing. The stop test asserts that the live routing is kept. Co-Authored-By: Claude Opus 5.5 --- .../continuing-starts-keep-routing.fixed.md | 7 +- docs/hybrid-prose-routing.md | 11 +- src/framework.ts | 52 ++-- src/mcpl/channel-registry.ts | 5 +- test/explicit-prose-routing.test.ts | 3 + test/present-while-acting.test.ts | 255 ++++++++++++++++++ 6 files changed, 305 insertions(+), 28 deletions(-) diff --git a/changelog.d/continuing-starts-keep-routing.fixed.md b/changelog.d/continuing-starts-keep-routing.fixed.md index ddf8f8404..143cb9514 100644 --- a/changelog.d/continuing-starts-keep-routing.fixed.md +++ b/changelog.d/continuing-starts-keep-routing.fixed.md @@ -27,9 +27,10 @@ `[routing]` notice to its stream. - For a trunk agent, the channel context passed to `beforeInference` hooks (`channels.defaultOutgoing`) names the turn's locus once the turn has one. - After a re-pin, a later start of the turn reports the channel its - unprefixed speech is published in, not the channel that triggered the - turn. A conversation fork still reports its home channel. + After a re-pin, a later start of the turn reports the locus it moved to, + where its unprefixed speech is published unless a hybrid `>>>` destination + takes it, not the channel that triggered the turn. A conversation fork + still reports its home channel. - A start that fails before its stream exists now lands the messages deferred during it, as a stream's end does. Before, they waited for the agent's next start and landed after any message stored in between. A diff --git a/docs/hybrid-prose-routing.md b/docs/hybrid-prose-routing.md index e99b2c736..14696c14d 100644 --- a/docs/hybrid-prose-routing.md +++ b/docs/hybrid-prose-routing.md @@ -26,11 +26,12 @@ Unprefixed prose after a successful envelope follows that explicit destination for the remainder of the turn; a fresh turn begins at its ordinary locus. After `>>>skip_reply`, or a `>>>` envelope that is malformed, doesn't resolve or isn't delivered, unprefixed prose isn't published until a later `>>>` -envelope is delivered. The sticky destination and this hold last for the -whole logical turn, across its later streams, such as a framework retry's, a -budget restart's or the compile a provider-capacity hold defers the turn to. -They govern publication: a stream's live outgoing deltas still start at the -turn's locus. +names a destination that resolves; if that envelope's body then isn't +delivered, the hold returns. The sticky destination and this hold last for +the whole logical turn, across its later streams, such as a framework +retry's, a budget restart's or the compile a provider-capacity hold defers +the turn to. They govern publication: a stream's live outgoing deltas still +start at the turn's locus. This is publication routing only. It creates no privacy meaning for `` or other prose tags, and grants no channel authority beyond the existing diff --git a/src/framework.ts b/src/framework.ts index 7a8ddfddc..5e31c8fda 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2203,8 +2203,9 @@ export class AgentFramework { this.cancelProviderAdmission(); // An open logical turn doesn't survive a stop: a start after a restart // on this instance opens a new one rather than continuing the old turn's - // routing (openLogical). A resume still keeps its turn's routing state - // (turnRouting): it carries that turn's own request on. + // routing (openLogical). A resume still continues its turn, with its + // routing state (turnRouting), since it carries that turn's own request + // on: beginAgentTurn opens the turn again for it. this.openLogical.clear(); // Cancel all active streams @@ -7154,8 +7155,10 @@ export class AgentFramework { // classifier-safe D-class from the 2026-07-24 ablation). It also // updates lastAnnouncedLocus so the next turn's announce-on-change // diffs against what the agent was actually last told. - // The stream's own mode: a silent turn routes no prose, whatever - // the agent's mode, so its boundary neither re-pins nor notes. + // The turn's own mode: a silent turn routes no prose, whatever the + // agent's mode, so its boundary neither re-pins nor notes. The + // trigger is this stream's: its start holds the agent's turn token + // while the agent waits on this round's tools. const routesLocus = (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid') && !this.activeTurnTriggers.get(agent.name)?.suppressProse; if (routesLocus && !shouldEndTurn && !overBudget && currentState.stream) { @@ -8958,7 +8961,6 @@ export class AgentFramework { if (!hasBaseline) this.lastAnnouncedLocus.set(agentName, locus); return; } - this.lastAnnouncedLocus.set(agentName, locus); const agent = this.agents.get(agentName); if (!agent) return; @@ -8985,6 +8987,9 @@ export class AgentFramework { [{ type: 'text', text }], { system: true, kind: 'routing-notice' }, ); + // The baseline is what the agent was told: it moves once the notice is + // stored, so a notice that fails is announced again at the next turn. + this.lastAnnouncedLocus.set(agentName, locus); this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' }); console.error( `[routing] ${agentName}: locus ${prev ?? '(none)'} -> ${locus ?? '(none)'} (announced in window)`, @@ -9567,13 +9572,16 @@ export class AgentFramework { } catch (error) { // A throw outside the stream's own failure handling (the checkpoint, // locus resolution, the typing indicator) drops this start's trigger, - // and nothing retries it. Its logical turn ends with it, as a failure - // in beginAgentTurn's own catch settles it, so the agent's next start - // opens a turn of its own rather than continuing this one's routing. - // A silent start has no part in an open turn. - const routing = this.turnRouting.get(agent.name); - if (!trigger?.suppressProse && routing && this.openLogical.get(agent.name) === routing) { - this.openLogical.delete(agent.name); + // and nothing retries or holds it. The agent's open logical turn is the + // one this start was opening, continuing or resuming, so it ends here, + // as a terminal failure in beginAgentTurn's own catch ends its turn: + // the next start opens a turn of its own rather than continuing this + // one's routing, and an ephemeral run fails with the error rather than + // waiting out its watchdog. A silent start has no part in an open turn. + if (!trigger?.suppressProse) { + this.settleAgent(agent.name, { + stopReason: 'exhausted', speech: '', error: error instanceof Error ? error.message : String(error), + }, this.openLogical.get(agent.name)); } throw error; } finally { @@ -9598,7 +9606,7 @@ export class AgentFramework { } catch (err) { console.error( `[deferred-flush] failed-start flush failed to store a message for ${agent.name} ` + - `(participant=${msg.participant}):`, + `(participant=${msg.participant}, id=${msg.id}):`, err, ); } @@ -9720,7 +9728,8 @@ export class AgentFramework { // This start's routing: its logical turn's (TurnRouting), which holds // everything the turn has decided about where its speech goes. // - A resume (a budget restart, a tool-result-guard retry) carries its - // turn's request on and keeps the turn's object as it is. + // turn's request on and keeps the turn's object as it is, and the + // turn stays, or after a stop() becomes again, the agent's open turn. // - A silent start routes no prose. It gets an object of its own, with // no locus, so it neither takes nor disturbs an open turn's. Its // object is the agent's live routing while it runs, and the open @@ -9745,6 +9754,9 @@ export class AgentFramework { let opensTurn = false; if (resumesTurn) { routing = this.routingOf(agent.name); + // The turn a resume continues is open, unless a stop() ended every + // open turn since: it opens again, so its later starts continue it. + if (!trigger?.suppressProse && !this.openLogical.has(agent.name)) this.openLogical.set(agent.name, routing); } else if (trigger?.suppressProse) { routing = freshTurnRouting(); opensTurn = true; @@ -13860,8 +13872,9 @@ export class AgentFramework { // channel hijacks it. An opening start resolves the turn's locus from // it (the trigger channel then), and the channel context names it, so // a later start of the turn is told where its unprefixed speech lands - // unless a hybrid `>>>` destination takes it. Empty for heartbeat / - // no-trigger turns → correct global fallback. + // unless a hybrid `>>>` destination takes it. Empty for a heartbeat / + // no-trigger turn until its locus is resolved, so that resolution + // takes the global fallback; the turn then keeps that channel. activeChannelResolver: (agentName) => { const routing = this.turnRouting.get(agentName); return routing?.locus ?? routing?.triggerChannel; @@ -15749,8 +15762,10 @@ export class AgentFramework { this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input }); const startTime = Date.now(); // The turn that called: a channel_open's result moves its locus, even - // if that result arrives after the turn has ended. + // if that result arrives after the turn has ended. A silent turn routes + // no prose, so its open moves no locus. const callingTurn = this.routingOf(agentName); + const callingSilent = this.activeTurnTriggers.get(agentName)?.suppressProse === true; this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName }) .then((result) => { @@ -15776,7 +15791,8 @@ export class AgentFramework { if (opened) { callingTurn.triggerChannel = opened; const openerAgent = this.agents.get(agentName); - if (openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) { + if (openerAgent && !callingSilent + && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) { callingTurn.locus = opened; // The announce baseline is the agent's, across turns: it takes // only a move this result tells the agent, while its turn runs. diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index a9d0c1786..6c2320521 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -520,8 +520,9 @@ interface ChannelRegistryOptions { * process-global `defaultPublishChannel`, which tracks the most-recent inbound * across ALL channels and misroutes a reply to whichever channel last spoke * under concurrency (item-3 redux). The framework keeps both on the live - * turn's routing state and exposes them here; resolves to undefined for a - * heartbeat / no-trigger turn, which correctly keeps the global fallback. + * turn's routing state and exposes them here. For a heartbeat / no-trigger + * turn it resolves to undefined until the turn's locus is resolved, so that + * resolution takes the global fallback, which the turn then keeps. * Consulted AFTER `homeChannelResolver` (a fork's home always wins), BEFORE * `defaultPublishChannel`. */ diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index c97c7e46b..d7409c7ad 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -910,6 +910,7 @@ describe('explicit prose routing', () => { const framework = await createFramework('hybrid'); const internals = framework as unknown as { openLogical: Map; + turnRouting: Map; settleAgent(name: string, outcome: { stopReason: 'completed'; speech: string }, closes: RoutingState | undefined): void; }; const open: RoutingState = routingState({ continuation: true }); @@ -923,8 +924,10 @@ describe('explicit prose routing', () => { internals.settleAgent('assistant', { stopReason: 'completed', speech: '' }, open); assert.equal(internals.openLogical.has('assistant'), false, "the turn's own settle closes it"); internals.openLogical.set('assistant', open); + internals.turnRouting.set('assistant', open); await framework.stop(); assert.equal(internals.openLogical.size, 0, 'stop() clears every open logical turn'); + assert.equal(internals.turnRouting.get('assistant'), open, 'and keeps the live routing state, for a resume that continues its turn'); }); it('a silent start neither takes nor disturbs an open logical turn, and leaves it to the next start that routes prose', async () => { diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index a2011d15b..2e1a50619 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -1005,6 +1005,229 @@ describe('present while acting', () => { await framework.stop(); }); + it("a start that continues the turn and throws before it takes the turn's routing still ends the turn", async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([] as ContentBlock[])); + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + module.toolDelayMs = 25; + failStream(1, accelerationLimit); + feedStart(framework, 2, [{ type: 'text', text: 'A new turn.' }] as ContentBlock[]); + const internals = framework as unknown as { + openLogical: Map; + recordTurnCheckpoint(agentName: string): void; + }; + + trigger(framework); + await framework.runUntilIdle(); + assert.equal(internals.openLogical.has('assistant'), true, 'the held turn is open'); + // The deferred compile's checkpoint fails before the start takes the turn. + const checkpoint = internals.recordTurnCheckpoint.bind(framework); + internals.recordTurnCheckpoint = () => { throw new Error('checkpoint failed'); }; + await assert.rejects(release(framework), /checkpoint failed/); + internals.recordTurnCheckpoint = checkpoint; + assert.equal(internals.openLogical.has('assistant'), false, 'the held turn ended with its failed compile'); + trigger(framework); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'A new turn.', locus: 'chan-live-2' }], + 'the next start opened its own turn and resolved its own locus'); + + await framework.stop(); + }); + + it("a silent start that throws leaves an open prose turn alone", async () => { + const framework = await createFramework(); + stubChannelRegistry(framework); + type Routing = { locus?: string; engaged: Set; [field: string]: unknown }; + const internals = framework as unknown as { + openLogical: Map; pendingRequests: InferenceRequest[]; + recordTurnCheckpoint(agentName: string): void; + }; + const open: Routing = { + locus: 'chan-turn', engaged: new Set(), destination: null, hybridHold: false, + continuation: false, deliveries: [], suppressed: 0, + }; + internals.openLogical.set('assistant', open); + internals.recordTurnCheckpoint = () => { throw new Error('checkpoint failed'); }; + internals.pendingRequests.push({ + agentName: 'assistant', reason: 'heartbeat', source: 'framework', timestamp: Date.now(), suppressProse: true, + } as InferenceRequest); + + await assert.rejects(framework.runUntilIdle(), /checkpoint failed/); + assert.equal(internals.openLogical.get('assistant'), open); + + await framework.stop(); + }); + + it("a resume after stop() continues its turn, and opens it again for the turn's later starts", async () => { + membrane.pushResponse(createMockResponse([] as ContentBlock[])); + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + failStream(1, () => new Error('synthetic transient stream error')); + feedStart(framework, 2, [{ type: 'text', text: 'After the retry.' }] as ContentBlock[]); + type Routing = { locus?: string; engaged: Set; [field: string]: unknown }; + const internals = framework as unknown as { + turnRouting: Map; openLogical: Map; pendingRequests: InferenceRequest[]; + }; + // A turn that crossed its budget before a stop(): its routing state is + // live, and stop() ended every open turn. Its budget restart now runs, + // and its stream fails and is retried. + const turn: Routing = { + locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(), destination: null, hybridHold: false, + continuation: false, deliveries: [], suppressed: 0, + }; + internals.turnRouting.set('assistant', turn); + internals.pendingRequests.push({ + agentName: 'assistant', reason: 'context_budget_restart', source: 'framework', timestamp: Date.now(), + } as InferenceRequest); + + await framework.runUntilIdle(); + + assert.equal(membrane.calls.length, 2, 'the restart was retried'); + assert.equal(internals.openLogical.get('assistant'), undefined, "the turn completed"); + assert.deepEqual(routed, [{ text: 'After the retry.', locus: 'chan-turn' }], + "the retry continued the resumed turn's routing"); + + await framework.stop(); + }); + + it("a silent turn's over-budget boundary leaves no arrival on an open prose turn", async () => { + const overBudget = createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use'); + overBudget.usage = { inputTokens: 100, outputTokens: 5 }; + membrane.pushResponse(overBudget); + const framework = await createFramework({ maxStreamTokens: 20 }); + const routed = stubChannelRegistry(framework); + module.toolDelayMs = 25; + module.interjection = 'quick question for you'; + module.interjectionMetadata = addressed('discord:dm:antra'); + feedStart(framework, 2, [{ type: 'text', text: 'quiet' }] as ContentBlock[]); + type Routing = { locus?: string; engaged: Set; arrival?: string; [field: string]: unknown }; + const internals = framework as unknown as { + openLogical: Map; pendingRequests: InferenceRequest[]; + }; + // Built by hand, as #312's silent-start test does: production runs no + // silent turn while a prose turn is open. + const open: Routing = { + locus: 'chan-turn', engaged: new Set(), destination: null, hybridHold: false, + continuation: false, deliveries: [], suppressed: 0, + }; + internals.openLogical.set('assistant', open); + internals.pendingRequests.push({ + agentName: 'assistant', reason: 'heartbeat', source: 'framework', timestamp: Date.now(), suppressProse: true, + } as InferenceRequest); + + await framework.runUntilIdle(); + + assert.equal(membrane.calls.length, 2, 'the silent turn crossed its budget and restarted'); + assert.deepEqual(routed, [], 'and its restart stayed silent'); + assert.equal(module.interjection, null, 'the DM arrived during its tool round'); + assert.equal(open.arrival, undefined, "the silent turn's injections are no arrival of the prose turn"); + + await framework.stop(); + }); + + it("a failed start's flush stores each deferred message on its own, so one failure loses no other", async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([] as ContentBlock[])); + const framework = await createFramework(); + stubChannelRegistry(framework); + module.toolDelayMs = 25; + let hostHolds = 0; + (framework as unknown as { providerHoldHook: (error: Error) => { holdMs: number; reason: string } | undefined }) + .providerHoldHook = (error) => error.message === 'stream setup failed' && hostHolds++ === 0 + ? { holdMs: 60_000, reason: 'host hold' } : undefined; + const makeStream = membrane.streamYielding.bind(membrane); + let calls = 0; + membrane.streamYielding = (request, options) => { + const n = ++calls; + if (n === 2) throw new Error('stream setup failed'); + const stream = makeStream(request, options); + const iterate = stream[Symbol.asyncIterator].bind(stream); + stream[Symbol.asyncIterator] = async function* () { + for await (const event of { [Symbol.asyncIterator]: iterate }) { + if (event.type === 'complete' && n === 1) { + yield { type: 'error' as const, error: accelerationLimit(request) }; + return; + } + yield event; + } + }; + return stream; + }; + const store = framework as unknown as { + addMessage(participant: string, content: ContentBlock[], metadata: Record, opts?: { deferredWriteId?: string }): string; + }; + const add = store.addMessage.bind(framework); + // The store refuses the first message when the failed start's flush lands it. + store.addMessage = (participant, content, metadata, opts) => { + if (opts?.deferredWriteId && (content[0] as { text?: string }).text === 'poison') throw new Error('store refused'); + return add(participant, content, metadata, opts); + }; + + trigger(framework); + await framework.runUntilIdle(); + module.startHook = async () => { + store.addMessage('Antra', [{ type: 'text', text: 'poison' }] as ContentBlock[], {}); + store.addMessage('Antra', [{ type: 'text', text: 'second' }] as ContentBlock[], {}); + }; + await release(framework); + + assert.equal(calls, 2, 'the second start failed before its stream'); + const texts = framework.getAgent('assistant')!.getContextManager().getAllMessages() + .flatMap((m) => m.content.filter((b) => b.type === 'text').map((b) => (b as { text: string }).text)); + assert.ok(texts.includes('second'), 'the message behind the refused one was stored'); + assert.ok(!texts.includes('poison')); + + await framework.stop(); + }); + + for (const [label, notice] of [ + ['a re-pin', 'The conversation moved to'], + ["a turn's announcement", 'Your plain speech now lands in'], + ] as const) { + it(`the announce baseline moves only once ${label}'s notice is stored`, async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done.' }] as ContentBlock[])); + const framework = await createFramework(); + stubChannelRegistry(framework); + module.toolDelayMs = 25; + if (notice === 'The conversation moved to') { + // Someone addresses the agent mid-turn from a DM: the turn re-pins. + module.interjection = 'quick question for you'; + module.interjectionMetadata = addressed('discord:dm:antra'); + } + const cm = framework.getAgent('assistant')!.getContextManager() as unknown as { + addMessage(participant: string, content: ContentBlock[], metadata?: unknown): string; + }; + const add = cm.addMessage.bind(cm); + cm.addMessage = (participant, content, metadata) => { + if ((content[0] as { text?: string })?.text?.includes(notice)) throw new Error('store refused'); + return add(participant, content, metadata); + }; + const baseline = (framework as unknown as { lastAnnouncedLocus: Map }).lastAnnouncedLocus; + + trigger(framework); + await framework.runUntilIdle(); + + if (notice === 'The conversation moved to') { + assert.equal(baseline.get('assistant'), 'chan-live-1', 'the agent was last told of the locus the turn opened at'); + } else { + assert.equal(baseline.has('assistant'), false, 'the agent was never told a locus'); + } + + await framework.stop(); + }); + } + // A tool's late write lands in the turn that called it: a send's engaged // channel, and a channel_open's move of the locus. (The dispatch functions // run on a bare framework, as checkpoint-attribution.test.ts's harness does.) @@ -1014,6 +1237,7 @@ describe('present while acting', () => { framework.pushEvent = () => {}; framework.conversationAgentHomes = new Map(); framework.lastAnnouncedLocus = new Map(); + framework.activeTurnTriggers = new Map(); return framework; } const until = async (done: () => boolean, label: string): Promise => { @@ -1081,8 +1305,39 @@ describe('present while acting', () => { assert.equal(calling.triggerChannel, 'discord:guild:observatory'); assert.equal(calling.locus, 'discord:guild:observatory'); assert.deepEqual(next, { engaged: new Set(), locus: 'chan-next' }, 'the next turn is untouched'); + assert.equal((framework.lastAnnouncedLocus as Map).has('assistant'), false, + "the agent's announce baseline moves only for a result its turn is told"); }); + for (const [label, silent, expected] of [ + ['while its turn runs moves the locus and the announce baseline, and says so', false, 'discord:guild:observatory'], + ['in a silent turn moves no locus, and claims none', true, 'chan-before'], + ] as const) { + it(`a channel_open ${label}`, async () => { + const calling: { engaged: Set; triggerChannel?: string; locus?: string } = { engaged: new Set(), locus: 'chan-before' }; + const framework = bareFramework(); + framework.agents = new Map([['assistant', { proseRouting: 'locus' }]]); + framework.channelRegistry = { + handleChannelToolCall: async () => ({ success: true, data: { channelId: 'discord:guild:observatory' } }), + }; + framework.turnRouting = new Map([['assistant', calling]]); + (framework.activeTurnTriggers as Map).set('assistant', silent ? { suppressProse: true } : {}); + const results: Array<{ result: { data?: { routing?: string } } }> = []; + framework.pushEvent = (event: { type: string }) => { + if (event.type === 'tool-result') results.push(event as unknown as { result: { data?: { routing?: string } } }); + }; + + framework.dispatchChannelToolCall('assistant', { + id: 'open-1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' }, + }); + await until(() => results.length > 0, 'the tool result'); + + assert.equal(calling.locus, expected); + assert.equal((framework.lastAnnouncedLocus as Map).get('assistant'), silent ? undefined : 'discord:guild:observatory'); + assert.equal(results[0]!.result.data?.routing !== undefined, !silent, 'the result tells the agent only of a move it made'); + }); + } + it('a prose turn ends with a [delivered] receipt naming where the prose landed', async () => { // Explicit sends receipt themselves via tool_result; auto-routed prose // previously vanished with no in-window trace — the agent could never From 26eeafea73f9f4251912beea089bd28355c092de Mon Sep 17 00:00:00 2001 From: Nigel-2405 Date: Tue, 13 Oct 2026 12:02:36 -0400 Subject: [PATCH 06/17] fix(routing): look up the sending turn only for a send tool dispatchMcplToolCall captured the agent's live routing for every MCPL tool call, though only a send (SEND_ENGAGEMENT_TOOLS) records an engaged channel. checkpoint-attribution.test.ts dispatches through a bare framework that holds no routing state, and its eight dispatch tests threw on the lookup in the full run at 0cff2330. Only a send looks its turn up now, so other tools touch no routing state. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/framework.ts b/src/framework.ts index 4131d3ddf..f8f0c26ac 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -15547,7 +15547,7 @@ export class AgentFramework { const args = (call.input && typeof call.input === 'object') ? call.input as Record : {}; // The turn that sends: a send's engaged channel is recorded on it, even // when the send's result, or the open that follows, comes after the turn. - const sendingTurn = this.turnRouting.get(agentName); + const sendingTurn = SEND_ENGAGEMENT_TOOLS.has(toolName) ? this.turnRouting.get(agentName) : undefined; // Build state params for stateful tools (Step 8). // A tools/list attribution selects that exact stateful set. Untagged tools From 3340391695f869474b746f035b2691ebd48efb5f Mon Sep 17 00:00:00 2001 From: Nigel-2405 Date: Tue, 13 Oct 2026 22:12:04 -0400 Subject: [PATCH 07/17] fix(routing): a start dropped before its stream sends anything ends the turn it was running Main's #281 drops a start before its stream sends anything once stop() has closed admission (before the compile) or a restart has begun a new lifecycle (after it), besides the agent's replacement it already dropped. A drop doesn't settle, so it left the agent's open logical turn (openLogical) as it was. stop() and an agent's disposal end every open turn, but a start can reach its routing after either and open or reopen its turn then: - a fresh start awaiting its deferred batches' assembly while stop() runs; - a start of an agent that takes no primary provider gate (an ephemeral run or a conversation fork), whose startAgentStream doesn't check admission, such as a framework retry whose delay outlasts the stop(). Dropped, that turn stayed open, and the agent's next start, after a restart too, continued its routing: the dropped start's locus and trigger channel, or for a reopened resume the old turn's hold and sticky destination. endDroppedTurn() ends the turn in both drop branches when it is still this start's routing object, so another turn is never closed. A disposal that happened after the start's routing has already deleted the entry, and a stop cleared it; the identity check covers both. Tests (present-while-acting): a primary agent's fresh start waits in a fake coalescer's assembly while stop() runs, then is dropped before its compile (admission closed) or, with start() also run, after it (the lifecycle changed). Each asserts the drop's precondition (no compile; main's drop line), that no turn is left open, and that the next start resolves its own locus (chan-live-2) rather than continuing the dropped turn's (chan-live-1). --- src/framework.ts | 18 +++++++ test/present-while-acting.test.ts | 85 +++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/src/framework.ts b/src/framework.ts index 9589bc304..9b43da143 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -4660,6 +4660,22 @@ export class AgentFramework { run.lastActivity = Date.now(); } + /** + * A start dropped before its stream sends anything (its agent was replaced, + * or stop() closed admission or ended the lifecycle the start began in) + * ends the logical turn it was running. stop() and an agent's disposal end + * every open turn (openLogical), but a start can reach its routing after + * either: one that awaited its batches' assembly across a stop(), or a + * start of an agent that takes no primary provider gate, which doesn't + * check admission before its turn begins, such as a retry after its delay. + * The turn it opened or reopened then would stay open, and the agent's next + * start, after a restart too, would continue the dropped turn's routing. + * The identity check leaves any other turn open. + */ + private endDroppedTurn(agentName: string, routing: TurnRouting): void { + if (this.openLogical.get(agentName) === routing) this.openLogical.delete(agentName); + } + /** A logical turn's end, for every agent: it closes the agent's open * logical turn when it is the settling stream's own (openLogical), and * settles an active ephemeral run. The run's tool-call count comes from @@ -10061,6 +10077,7 @@ export class AgentFramework { // and stop() may close provider admission. Do not create a provider // stream after its generation lost ownership, or once admission closed. if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed) { + this.endDroppedTurn(agent.name, routing); this.channelRegistry?.stopTyping(); this.eventGate?.onInferenceEnded(agent.name); return false; @@ -10104,6 +10121,7 @@ export class AgentFramework { this.activeStreams.delete(agent.name); this.pendingAssistantBlocks.delete(agent.name); } + this.endDroppedTurn(agent.name, routing); this.channelRegistry?.stopTyping(); this.eventGate?.onInferenceEnded(agent.name); return false; diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 2e1a50619..691e1d9cd 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -36,6 +36,7 @@ import type { ContextInjection } from '@animalabs/context-manager'; import { AgentFramework } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; import { MembraneError } from '@animalabs/membrane'; +import { JsStore } from '@animalabs/chronicle'; import type { ContentBlock, NormalizedRequest } from '@animalabs/membrane'; // --------------------------------------------------------------------------- @@ -1247,6 +1248,90 @@ describe('present while acting', () => { } }; + // stop() and an agent's disposal end every open turn, but a start can reach + // its routing after either, opening or reopening its turn, and then be + // dropped before its stream sends anything: before its compile, once stop() + // has closed admission, or after it, once a restart has begun a lifecycle + // of its own. Here the start waits for a deferred batch's assembly while + // the stop runs. + for (const restartFirst of [false, true]) { + const name = restartFirst + ? "a start whose compile spans a stop() and restart is dropped and leaves no turn open, so the next start opens its own" + : "a start that stop() drops before its compile leaves no turn open, so the first start after a restart opens its own"; + it(name, async () => { + // A store the framework doesn't own, so the same instance can start again. + const store = JsStore.openOrCreate({ path: join(tempDir, 'restart.chronicle') }); + const framework = await AgentFramework.create({ + store, + membrane: membrane.asMembrane(), + agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'You are a robot pilot.' }], + modules: [module], + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }); + module.framework = framework; + const routed = stubChannelRegistry(framework); + const internals = framework as unknown as { + pushCoalescer: unknown; + openLogical: Map; + startAgentStream(agent: unknown, trigger?: InferenceRequest): Promise; + }; + const agent = framework.getAgent('assistant')!; + const wake = (): InferenceRequest => ({ + agentName: 'assistant', reason: 'external-message', source: 'test', timestamp: Date.now(), + } as InferenceRequest); + let entered!: () => void; + const assembling = new Promise((resolve) => { entered = resolve; }); + let release!: () => void; + const released = new Promise((resolve) => { release = resolve; }); + internals.pushCoalescer = { + pendingBatches: () => true, + assemble: async () => { + entered(); + await released; + return { settled: new Set(), materialized: new Set() }; + }, + suspend: () => {}, + isRendering: () => false, + }; + const lines: string[] = []; + const error = console.error; + try { + console.error = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); }; + try { + const dropped = internals.startAgentStream(agent, wake()); + await assembling; + await framework.stop(); + if (restartFirst) framework.start(); + release(); + await dropped; + } finally { console.error = error; } + if (restartFirst) { + assert.ok(lines.some((line) => /stopped and started again/.test(line) && /dropped before it sends anything/.test(line)), + lines.join('\n')); + } else { + assert.equal(membrane.calls.length, 0, 'the start was dropped before its compile'); + } + assert.deepEqual(routed, [], 'the dropped start routed nothing'); + assert.equal(internals.openLogical.has('assistant'), false, 'the dropped start left no turn open'); + + if (!restartFirst) framework.start(); + internals.pushCoalescer = undefined; + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'After the restart.' }] as ContentBlock[])); + await internals.startAgentStream(agent, wake()); + await until(() => routed.length > 0, "the next turn's speech"); + // The dropped start resolved chan-live-1. Continuing its turn would + // have kept that locus, without resolving one. + assert.deepEqual(routed, [{ text: 'After the restart.', locus: 'chan-live-2' }], + 'the next start resolved a locus of its own'); + } finally { + release(); + await framework.stop(); + store.close(); + } + }); + } + it("a send's engaged channel is recorded on the turn that sent, however late its open resolves", async () => { const sending = { engaged: new Set() }; const next = { engaged: new Set() }; From 139aca6cab86891922463d96a88c39bf224a11e9 Mon Sep 17 00:00:00 2001 From: Nigel-2405 Date: Tue, 13 Oct 2026 23:04:19 -0400 Subject: [PATCH 08/17] test(routing): pin a resume's reopened turn and a failed start's ephemeral run; the changelog says what such a run's callers see Naomi-2422's read of 33403916 found two pieces without a test that fails when they're removed, and one caller-visible change the changelog left out. - A resume after stop() opens its turn again (4a6ffe6b). The test named for it retried the resume's stream, but a resume's retry is a resume too and takes the turn's routing state whatever is open, so removing the reopen left every test passing. The test now records, at each start, what openLogical holds: the resumed turn's object, while each of its streams runs. That's what lets the turn's non-resume later starts, such as a re-inference on unstreamed tool results, continue it. - A start whose throw escapes beginAgentTurn settles through settleAgent (4a6ffe6b), which also fails an ephemeral run with the start's error. Before, the run waited for its watchdog. A new test, adapted from her probe, runs an ephemeral agent whose checkpoint throws and expects the run to reject with that error. Ending only the open turn, without settleAgent, lets the run wait out its 3 s test watchdog instead. - The changelog's escaped-throw sentence now says an ephemeral run (runEphemeralToCompletion) whose start throws that way fails at once, where it waited for its watchdog: 30 s by default before inference started, or 15 minutes once it had. No source change. --- .../continuing-starts-keep-routing.fixed.md | 5 ++- test/present-while-acting.test.ts | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/changelog.d/continuing-starts-keep-routing.fixed.md b/changelog.d/continuing-starts-keep-routing.fixed.md index 143cb9514..291d40dfb 100644 --- a/changelog.d/continuing-starts-keep-routing.fixed.md +++ b/changelog.d/continuing-starts-keep-routing.fixed.md @@ -35,7 +35,10 @@ deferred during it, as a stream's end does. Before, they waited for the agent's next start and landed after any message stored in between. A start that throws outside its stream's failure handling ends its logical - turn, so the agent's next start opens its own. + turn, so the agent's next start opens its own, and an ephemeral run + (`runEphemeralToCompletion`) whose start throws that way fails at once + with the start's error. Before, the run waited for its watchdog: 30 s by + default before its inference started, or 15 minutes once it had. - A send's engaged channel, and a `channel_open`'s move of the locus, are recorded on the turn that called the tool, even when the tool's result, or the open that follows a send, comes after that turn has ended. diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 691e1d9cd..de182488a 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -1039,6 +1039,38 @@ describe('present while acting', () => { await framework.stop(); }); + it("an ephemeral run whose start throws outside its stream fails with that error, not by its watchdog", async () => { + // Adapted from a reviewer's probe. On main the run waited for its startup + // watchdog (30 s by default), which fails it as a zombie. + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'ephemeral.chronicle'), + membrane: membrane.asMembrane(), + agents: [], + modules: [], + syncIntervalMs: 0, + }); + const internals = framework as unknown as { recordTurnCheckpoint(agentName: string): void }; + const checkpoint = internals.recordTurnCheckpoint.bind(framework); + internals.recordTurnCheckpoint = (agentName) => { + if (agentName === 'worker') throw new Error('checkpoint store unavailable'); + checkpoint(agentName); + }; + const error = console.error; + console.error = () => {}; + try { + const { agent, contextManager } = await framework.createEphemeralAgent({ + name: 'worker', model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all', + }); + contextManager.addMessage('user', [{ type: 'text', text: 'Run once.' }]); + const run = framework.runEphemeralToCompletion(agent, contextManager, { startupTimeoutMs: 3_000 }); + framework.start(); + await assert.rejects(run, /checkpoint store unavailable/, "the run fails with its start's error"); + } finally { + console.error = error; + await framework.stop(); + } + }); + it("a silent start that throws leaves an open prose turn alone", async () => { const framework = await createFramework(); stubChannelRegistry(framework); @@ -1084,10 +1116,20 @@ describe('present while acting', () => { internals.pendingRequests.push({ agentName: 'assistant', reason: 'context_budget_restart', source: 'framework', timestamp: Date.now(), } as InferenceRequest); + // The retry of a resume is a resume too, and takes the turn's routing + // state whatever is open. The turn must be open while its streams run, so + // that its other later starts, such as a re-inference on unstreamed tool + // results, continue it rather than open a new one. + const openAtStart: unknown[] = []; + framework.onTrace((event) => { + if (event.type === 'inference:started') openAtStart.push(internals.openLogical.get('assistant')); + }); await framework.runUntilIdle(); assert.equal(membrane.calls.length, 2, 'the restart was retried'); + assert.deepEqual(openAtStart.map((open) => open === turn), [true, true], + 'the resumed turn was open again while each of its streams ran'); assert.equal(internals.openLogical.get('assistant'), undefined, "the turn completed"); assert.deepEqual(routed, [{ text: 'After the retry.', locus: 'chan-turn' }], "the retry continued the resumed turn's routing"); From 984f2fa95cf72dc15436dee265370c7c1a436eb4 Mon Sep 17 00:00:00 2001 From: Nigel-2405 Date: Tue, 13 Oct 2026 23:58:47 -0400 Subject: [PATCH 09/17] fix(routing): an arrival re-pins only while its message remains; a late channel_open's announcement needs its turn open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sebastian-2380's read of 139aca6c found two defects, each with a probe. 1. A withdrawn arrival misrouted the turn (a regression against main). During a capacity hold, an addressed coalesced message in another channel was stored and became the turn's arrival. Its server then retracted it while unread: removePlacement removed the message from the agent's store and withdrew its wake. The held turn's deferred compile still re-pinned to that channel, with "The conversation moved to …", and published there. On main 7cd7af5 the same probe publishes at the turn's own channel (his J7415, J7419). TurnRouting.arrival, the channel of the newest qualifying message, is now TurnRouting.arrivals: each qualifying message's id and the channel it re-pins to, in the order they were stored. noteArrival takes the message's id at all three sites (addMessage, the turn-start flush, and the boundary that can't re-pin, which now keeps each injected message's stored id). The turn's next start re-pins to the newest one whose message is still in the agent's store (remainingArrival, through ContextManager.getMessage) and clears them all. A message removed meanwhile moves nothing, an earlier one that remains still does, and with none left the locus stays. Ada-1017 confirmed the reading (#90067): the store is the test, since a retraction removes a message and compression doesn't, and the last compile can't be, since an arrival between the turn's streams isn't in it yet. 2. A channel_open whose result lands after its turn settled, before any later start, moved the announce baseline: the turn's routing state was still the agent's live one, so the identity check passed, while the tool-result handler dropped the result for the idle agent. The agent was never told, and its next turn's announcement was suppressed. The check now asks whether the calling turn is still open (openLogical), which a settle ends (his J7416 shows the guard red and green). Tests (present-while-acting): during a capacity hold, an addressed message withdrawn before the deferred compile moves nothing, and when a newer one is withdrawn the newest that remains moves the locus, with its notice; a dispatch-level test where the result lands after the turn settled with no later start, and the announce baseline stays. Tests that build routing state by hand give it its arrivals; the existing late channel_open tests open the calling turn. The changelog says a message removed before the turn's next start doesn't move its locus. --- .../continuing-starts-keep-routing.fixed.md | 4 +- src/framework.ts | 79 +++++++++++---- test/explicit-prose-routing.test.ts | 10 +- test/present-while-acting.test.ts | 99 +++++++++++++++++-- test/tool-result-guard.test.ts | 6 +- 5 files changed, 159 insertions(+), 39 deletions(-) diff --git a/changelog.d/continuing-starts-keep-routing.fixed.md b/changelog.d/continuing-starts-keep-routing.fixed.md index 291d40dfb..34a6e4ed8 100644 --- a/changelog.d/continuing-starts-keep-routing.fixed.md +++ b/changelog.d/continuing-starts-keep-routing.fixed.md @@ -22,7 +22,9 @@ arrives during a capacity hold or before a re-inference, and one drained at a budget restart's over-budget boundary, which before never moved the locus. Ambient traffic in a channel the turn hasn't sent into, reactions - and system markers don't move it. + and system markers don't move it, and neither does a message removed + before that start, such as an unread coalesced message its server + retracted: the newest qualifying message that remains decides. - A silent turn's tool boundary no longer re-pins its locus or adds a `[routing]` notice to its stream. - For a trunk agent, the channel context passed to `beforeInference` hooks diff --git a/src/framework.ts b/src/framework.ts index 9b43da143..42b089b9a 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -589,21 +589,31 @@ interface TurnRouting { /** Plain-prose segments the turn suppressed, reported by its receipt. */ suppressed: number; /** - * Locus and hybrid mode: the channel the newest message that arrived - * between the turn's streams would re-pin the locus to (repinChannel), - * if one did. No tool boundary weighs such a message, since none of the + * Locus and hybrid mode: the messages that arrived between the turn's + * streams and would re-pin the locus (repinChannel), in the order they + * were stored. No tool boundary weighs such a message, since none of the * turn's streams was running: it was stored during a capacity hold or the * wait for a re-inference, or flushed at the end of a stream that didn't * settle the turn, or by the start that continues it (noteArrival). The - * turn's next start re-pins there and clears it, as a boundary would have. + * turn's next start re-pins to the newest one still in the agent's store + * and clears them all, as a boundary would have weighed its injections + * (remainingArrival). One removed meanwhile, such as an unread coalesced + * message its server retracted, isn't there for the agent to answer. */ - arrival?: string; + arrivals: Arrival[]; +} + +/** A message that would re-pin its turn's locus (TurnRouting.arrivals). */ +interface Arrival { + messageId: MessageId; + /** The channel it re-pins the locus to. */ + channelId: string; } function freshTurnRouting(): TurnRouting { return { engaged: new Set(), destination: null, hybridHold: false, - continuation: false, deliveries: [], suppressed: 0, + continuation: false, deliveries: [], suppressed: 0, arrivals: [], }; } @@ -7216,11 +7226,14 @@ export class AgentFramework { // queued, they flush at the target's own next boundary — with // injection — or in driveStream's finally when its turn ends. const midTurnInjections: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = []; + // The injected messages' ids, for a boundary that leaves them to the + // turn's next start as arrivals. + const injectedIds = new Map(); { const deferred = this.drainDeferredFor(agent.name); if (deferred.length > 0) { for (const msg of deferred) { - agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)); + const id = agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)); // Injection guards: tool blocks would corrupt the tool-cycle // structure the membrane enforces, and a message named as the // agent itself would render as an ASSISTANT turn on the wire @@ -7230,11 +7243,13 @@ export class AgentFramework { (b) => b.type === 'tool_use' || b.type === 'tool_result' ); if (!hasToolBlocks && msg.participant !== agent.name) { - midTurnInjections.push({ + const injection = { participant: msg.participant, content: msg.content, ...(msg.metadata ? { metadata: msg.metadata } : {}), - }); + }; + midTurnInjections.push(injection); + injectedIds.set(injection, id); } } } @@ -7348,7 +7363,7 @@ export class AgentFramework { // stream to tell) leaves its injections to the turn's next start, // as arrivals: a budget restart, or a re-inference on these tool // results. - for (const inj of midTurnInjections) this.noteArrival(agent.name, inj.metadata); + for (const inj of midTurnInjections) this.noteArrival(agent.name, inj.metadata, injectedIds.get(inj)!); } if (shouldEndTurn) { @@ -9180,14 +9195,33 @@ export class AgentFramework { /** * Weigh a message stored for an agent whose logical turn is open while * none of the turn's streams is running, so that no tool boundary will - * weigh it: one that would re-pin the locus becomes the turn's arrival, - * the newest replacing any before it (TurnRouting.arrival). + * weigh it: one that would re-pin the locus becomes one of the turn's + * arrivals (TurnRouting.arrivals). */ - private noteArrival(agentName: string, metadata: MessageMetadata | undefined): void { + private noteArrival(agentName: string, metadata: MessageMetadata | undefined, messageId: MessageId): void { const open = this.openLogical.get(agentName); if (!open) return; const channelId = this.repinChannel(metadata, open); - if (channelId) open.arrival = channelId; + if (channelId) open.arrivals.push({ messageId, channelId }); + } + + /** + * The channel of the turn's newest arrival whose message is still in the + * agent's store (TurnRouting.arrivals). A message removed since it was + * stored, such as an unread coalesced message its server retracted, moves + * nothing, and an earlier one that remains still does. Compression keeps a + * message in the store; the last compile can't be the test, since an + * arrival between the turn's streams isn't in it yet. + */ + private remainingArrival(agent: Agent, routing: TurnRouting): string | undefined { + const cm = agent.getContextManager(); + for (let i = routing.arrivals.length - 1; i >= 0; i--) { + const { messageId, channelId } = routing.arrivals[i]!; + let stored: StoredMessage | null; + try { stored = cm.getMessage(messageId); } catch { stored = null; } + if (stored) return channelId; + } + return undefined; } /** @@ -9822,7 +9856,7 @@ export class AgentFramework { const id = agent.getContextManager().addMessage( msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id), ); - this.noteArrival(agent.name, msg.metadata); + this.noteArrival(agent.name, msg.metadata, id); this.emitTrace({ type: 'message:added', messageId: id, source: 'deferred-flush:turn-start' }); } catch (err) { console.error( @@ -9978,9 +10012,9 @@ export class AgentFramework { // Every other start of the turn keeps its locus: a resume, or a start // that continues the open turn. A message that arrived while none of // the turn's streams was running can still move it, as it would have - // at a tool boundary: no boundary drained it (TurnRouting.arrival). - const arrival = routing.arrival; - routing.arrival = undefined; + // at a tool boundary: no boundary drained it (TurnRouting.arrivals). + const arrival = this.remainingArrival(agent, routing); + routing.arrivals = []; if (arrival && (turnProseRouting === 'locus' || turnProseRouting === 'hybrid')) { this.repinTurnLocus(agent, routing, arrival, 'arrival'); } @@ -13540,7 +13574,7 @@ export class AgentFramework { // Stored while none of the agent's turns is alive, or assembled for the // start now beginning: no tool boundary will weigh it. if (!this.activeTurnTokens.has(agent.name) || opts?.bypassDeferralFor === agent.name) { - this.noteArrival(agent.name, metadata); + this.noteArrival(agent.name, metadata, stored); } return stored; } @@ -16098,8 +16132,11 @@ export class AgentFramework { && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) { callingTurn.locus = opened; // The announce baseline is the agent's, across turns: it takes - // only a move this result tells the agent, while its turn runs. - if (this.turnRouting.get(agentName) === callingTurn) this.lastAnnouncedLocus.set(agentName, opened); + // only a move this result tells the agent, which needs its turn + // still open. A result that lands after the turn settled is + // dropped, so the agent never reads it, whether or not a later + // start has replaced the turn's routing state yet. + if (this.openLogical.get(agentName) === callingTurn) this.lastAnnouncedLocus.set(agentName, opened); result = { ...result, data: { diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 026617c97..c34cb6e73 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -357,12 +357,12 @@ describe('explicit prose routing', () => { type RoutingState = { pin?: string; destination: string | null; hybridHold: boolean; continuation: boolean; deliveries: string[]; suppressed: number; - triggerChannel?: string; locus?: string; engaged: Set; arrival?: string; + triggerChannel?: string; locus?: string; engaged: Set; arrivals: Array<{ messageId: string; channelId: string }>; }; /** A turn's routing state, as the framework makes a fresh one. */ const routingState = (fields: Partial = {}): RoutingState => ({ destination: null, hybridHold: false, continuation: false, deliveries: [], suppressed: 0, - engaged: new Set(), ...fields, + engaged: new Set(), arrivals: [], ...fields, }); async function within(promise: Promise, label: string, ms = 5000): Promise { @@ -944,7 +944,7 @@ describe('explicit prose routing', () => { // Everything the open turn has decided about where its speech goes. const decided = (): RoutingState => routingState({ continuation: true, pin: 'chan-alpha', hybridHold: true, triggerChannel: 'chan-alpha', - locus: 'world:commons', engaged: new Set(['discord:g:cafe']), arrival: 'discord:g:cafe', + locus: 'world:commons', engaged: new Set(['discord:g:cafe']), arrivals: [{ messageId: 'cafe-message', channelId: 'discord:g:cafe' }], }); const open = decided(); internals.openLogical.set('assistant', open); @@ -1777,13 +1777,13 @@ describe('explicit prose routing', () => { // Someone addresses the agent from another channel while the turn waits. internals.addMessage('Antra', [{ type: 'text', text: 'quick question for you' }] as ContentBlock[], { channelId: 'discord:g:cafe', tags: ['chat:addressed'] }); - assert.equal(earlier.arrival, 'discord:g:cafe', 'it is the turn\'s arrival'); + assert.deepEqual(earlier.arrivals.map((a) => a.channelId), ['discord:g:cafe'], 'it is the turn\'s arrival'); internals.pendingRequests.push({ agentName: 'assistant', reason: 'tool_results_ready', source: 'framework', timestamp: Date.now() } as InferenceRequest); await within(framework.runUntilIdle(), 'the re-inference', 10000); assert.deepEqual(routed.map((r) => ({ text: r.text.trim(), locus: r.locus })), [{ text: 'carrying on', locus: 'chan-alpha' }]); assert.equal(earlier.locus, undefined, 'no locus'); - assert.equal(earlier.arrival, undefined, 'the arrival is used up'); + assert.deepEqual(earlier.arrivals, [], 'the arrival is used up'); assert.ok(!context(framework).texts.some((t) => t.includes('The conversation moved to')), 'and no routing notice'); } finally { await framework.stop(); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index de182488a..17480e34d 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -617,6 +617,15 @@ describe('present while acting', () => { await framework.runUntilIdle(); } + /** Remove a stored message, as the retraction of an unread coalesced message does (removePlacement). */ + function withdraw(framework: AgentFramework, text: string): void { + const cm = framework.getAgent('assistant')!.getContextManager(); + const message = cm.getAllMessages().find((m) => + m.content.some((b) => b.type === 'text' && (b as { text: string }).text === text)); + assert.ok(message, `"${text}" is stored`); + cm.removeMessage(message.id); + } + const addressed = (channelId: string) => ({ channelId, tags: ['chat:addressed'] }); const ambient = (channelId: string) => ({ channelId, tags: ['chat:ambient', 'chat:from-human'] }); @@ -691,6 +700,45 @@ describe('present while acting', () => { }); } + for (const [label, messages, expected] of [ + ['an addressed message withdrawn before the deferred compile moves nothing', + [['soon withdrawn', 'discord:dm:antra', true]], 'chan-live-1'], + ['the newest addressed message that remains moves the locus, when a newer one is withdrawn', + [['still there', 'discord:dm:antra', false], ['soon withdrawn', 'discord:dm:q', true]], 'discord:dm:antra'], + ] as const) { + it(`during a capacity hold, ${label}`, async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'On it.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + module.toolDelayMs = 25; + failStream(1, accelerationLimit); + feedStart(framework, 2, [{ type: 'text', text: 'Here is the rest.' }] as ContentBlock[]); + + trigger(framework); + await framework.runUntilIdle(); + for (const [text, channelId] of messages) await say(framework, text, addressed(channelId)); + // Each withdrawn message leaves the agent's store before the turn's + // next start, so the agent never sees it there. + for (const [text, , withdrawn] of messages) if (withdrawn) withdraw(framework, text); + await release(framework); + + assert.equal(membrane.calls.length, 2, 'one deferred compile'); + assert.deepEqual(routed, [ + { text: 'On it.', locus: 'chan-live-1' }, + { text: 'Here is the rest.', locus: expected }, + ]); + assert.deepEqual(movedTo(framework), expected === 'chan-live-1' ? [] : [expected], + 'a re-pin tells the agent, and nothing else does'); + + await framework.stop(); + }); + } + it("a message a tool boundary already weighed doesn't take the locus back at a later start", async () => { membrane.pushResponse(createMockResponse([ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, @@ -900,7 +948,7 @@ describe('present while acting', () => { // fallback's re-inference. Messages arrive before that starts. const open: Routing = { locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(['discord:guild:portables']), - destination: null, hybridHold: false, continuation: false, deliveries: [], suppressed: 0, + destination: null, hybridHold: false, continuation: false, deliveries: [], suppressed: 0, arrivals: [], }; internals.turnRouting.set('assistant', open); internals.openLogical.set('assistant', open); @@ -920,7 +968,7 @@ describe('present while acting', () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Carrying on.' }] as ContentBlock[])); const framework = await createFramework(); const routed = stubChannelRegistry(framework); - type Routing = { locus?: string; engaged: Set; arrival?: string; [field: string]: unknown }; + type Routing = { locus?: string; engaged: Set; arrivals: Array<{ messageId: string; channelId: string }>; [field: string]: unknown }; const internals = framework as unknown as { turnRouting: Map; openLogical: Map; pendingRequests: InferenceRequest[]; activeTurnTokens: Map; @@ -929,7 +977,7 @@ describe('present while acting', () => { }; const open: Routing = { locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(), - destination: null, hybridHold: false, continuation: false, deliveries: [], suppressed: 0, + destination: null, hybridHold: false, continuation: false, deliveries: [], suppressed: 0, arrivals: [], }; internals.turnRouting.set('assistant', open); internals.openLogical.set('assistant', open); @@ -939,7 +987,7 @@ describe('present while acting', () => { internals.activeTurnTokens.set('assistant', -1); internals.addMessage('Antra', [{ type: 'text', text: 'batched' }] as ContentBlock[], addressed('discord:dm:q'), { bypassDeferralFor: 'assistant' }); internals.activeTurnTokens.delete('assistant'); - assert.equal(open.arrival, 'discord:dm:q'); + assert.deepEqual(open.arrivals.map((a) => a.channelId), ['discord:dm:q']); // A message still deferred when the turn's next start begins lands in // that start's own flush, and is the newest arrival. internals.deferredMessages.push({ @@ -1081,7 +1129,7 @@ describe('present while acting', () => { }; const open: Routing = { locus: 'chan-turn', engaged: new Set(), destination: null, hybridHold: false, - continuation: false, deliveries: [], suppressed: 0, + continuation: false, deliveries: [], suppressed: 0, arrivals: [], }; internals.openLogical.set('assistant', open); internals.recordTurnCheckpoint = () => { throw new Error('checkpoint failed'); }; @@ -1110,7 +1158,7 @@ describe('present while acting', () => { // and its stream fails and is retried. const turn: Routing = { locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(), destination: null, hybridHold: false, - continuation: false, deliveries: [], suppressed: 0, + continuation: false, deliveries: [], suppressed: 0, arrivals: [], }; internals.turnRouting.set('assistant', turn); internals.pendingRequests.push({ @@ -1149,7 +1197,7 @@ describe('present while acting', () => { module.interjection = 'quick question for you'; module.interjectionMetadata = addressed('discord:dm:antra'); feedStart(framework, 2, [{ type: 'text', text: 'quiet' }] as ContentBlock[]); - type Routing = { locus?: string; engaged: Set; arrival?: string; [field: string]: unknown }; + type Routing = { locus?: string; engaged: Set; arrivals: Array<{ messageId: string; channelId: string }>; [field: string]: unknown }; const internals = framework as unknown as { openLogical: Map; pendingRequests: InferenceRequest[]; }; @@ -1157,7 +1205,7 @@ describe('present while acting', () => { // silent turn while a prose turn is open. const open: Routing = { locus: 'chan-turn', engaged: new Set(), destination: null, hybridHold: false, - continuation: false, deliveries: [], suppressed: 0, + continuation: false, deliveries: [], suppressed: 0, arrivals: [], }; internals.openLogical.set('assistant', open); internals.pendingRequests.push({ @@ -1169,7 +1217,7 @@ describe('present while acting', () => { assert.equal(membrane.calls.length, 2, 'the silent turn crossed its budget and restarted'); assert.deepEqual(routed, [], 'and its restart stayed silent'); assert.equal(module.interjection, null, 'the DM arrived during its tool round'); - assert.equal(open.arrival, undefined, "the silent turn's injections are no arrival of the prose turn"); + assert.deepEqual(open.arrivals, [], "the silent turn's injections are no arrival of the prose turn"); await framework.stop(); }); @@ -1281,6 +1329,7 @@ describe('present while acting', () => { framework.conversationAgentHomes = new Map(); framework.lastAnnouncedLocus = new Map(); framework.activeTurnTriggers = new Map(); + framework.openLogical = new Map(); return framework; } const until = async (done: () => boolean, label: string): Promise => { @@ -1418,6 +1467,7 @@ describe('present while acting', () => { handleChannelToolCall: () => new Promise((resolve) => { resolveOpen = resolve; }), }; framework.turnRouting = new Map([['assistant', calling]]); + framework.openLogical = new Map([['assistant', calling]]); const results: unknown[] = []; framework.pushEvent = (event: { type: string }) => { if (event.type === 'tool-result') results.push(event); }; @@ -1426,6 +1476,7 @@ describe('present while acting', () => { }); await until(() => resolveOpen !== undefined, 'the open to start'); (framework.turnRouting as Map).set('assistant', next); + (framework.openLogical as Map).set('assistant', next); resolveOpen({ success: true, data: { channelId: 'discord:guild:observatory' } }); await until(() => results.length > 0, 'the tool result'); @@ -1436,6 +1487,35 @@ describe('present while acting', () => { "the agent's announce baseline moves only for a result its turn is told"); }); + it("a channel_open whose result lands after its turn settled, before any later start, leaves the announce baseline", async () => { + // The turn's routing state is still the agent's live one, since no later + // start has replaced it, but the turn is no longer open: the tool-result + // handler drops the result, so the agent never reads that it moved. + const calling: { engaged: Set; triggerChannel?: string; locus?: string } = { engaged: new Set(), locus: 'chan-before' }; + let resolveOpen!: (result: { success: boolean; data: { channelId: string } }) => void; + const framework = bareFramework(); + framework.agents = new Map([['assistant', { proseRouting: 'locus' }]]); + framework.channelRegistry = { + handleChannelToolCall: () => new Promise((resolve) => { resolveOpen = resolve; }), + }; + framework.turnRouting = new Map([['assistant', calling]]); + framework.openLogical = new Map([['assistant', calling]]); + const results: unknown[] = []; + framework.pushEvent = (event: { type: string }) => { if (event.type === 'tool-result') results.push(event); }; + + framework.dispatchChannelToolCall('assistant', { + id: 'open-1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' }, + }); + await until(() => resolveOpen !== undefined, 'the open to start'); + (framework.openLogical as Map).delete('assistant'); + resolveOpen({ success: true, data: { channelId: 'discord:guild:observatory' } }); + await until(() => results.length > 0, 'the tool result'); + + assert.equal(framework.turnRouting.get('assistant'), calling, "no later start replaced the turn's routing state"); + assert.equal((framework.lastAnnouncedLocus as Map).has('assistant'), false, + "so the agent's next turn still announces where its speech lands"); + }); + for (const [label, silent, expected] of [ ['while its turn runs moves the locus and the announce baseline, and says so', false, 'discord:guild:observatory'], ['in a silent turn moves no locus, and claims none', true, 'chan-before'], @@ -1448,6 +1528,7 @@ describe('present while acting', () => { handleChannelToolCall: async () => ({ success: true, data: { channelId: 'discord:guild:observatory' } }), }; framework.turnRouting = new Map([['assistant', calling]]); + if (!silent) framework.openLogical = new Map([['assistant', calling]]); (framework.activeTurnTriggers as Map).set('assistant', silent ? { suppressProse: true } : {}); const results: Array<{ result: { data?: { routing?: string } } }> = []; framework.pushEvent = (event: { type: string }) => { diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index bad3b60b4..8cc2886ca 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -998,11 +998,11 @@ test("a fresh turn starts new routing state in every mode, and leaves the earlie const h = await harness([[answer()]], { proseRouting: mode }); type Routing = { pin?: string; destination: string | null; hybridHold: boolean; continuation: boolean; deliveries: string[]; suppressed: number; - triggerChannel?: string; locus?: string; engaged: Set; arrival?: string }; + triggerChannel?: string; locus?: string; engaged: Set; arrivals: Array<{ messageId: string; channelId: string }> }; const internals = h.framework as unknown as { turnRouting: Map }; const earlierFields = (): Routing => ({ pin: 'chan-earlier', destination: 'chan-earlier', hybridHold: true, continuation: true, deliveries: ['chan-earlier'], suppressed: 2, - triggerChannel: 'chan-earlier', locus: 'chan-earlier', engaged: new Set(['chan-earlier']), arrival: 'chan-earlier' }); + triggerChannel: 'chan-earlier', locus: 'chan-earlier', engaged: new Set(['chan-earlier']), arrivals: [{ messageId: 'earlier-message', channelId: 'chan-earlier' }] }); const earlier = earlierFields(); internals.turnRouting.set('assistant', earlier); try { @@ -1018,7 +1018,7 @@ test("a fresh turn starts new routing state in every mode, and leaves the earlie assert.equal(now?.triggerChannel, undefined, `${mode}: no trigger channel carried into the new turn`); assert.notEqual(now?.locus, 'chan-earlier', `${mode}: no locus carried into the new turn`); assert.deepEqual([...(now?.engaged ?? [])], [], `${mode}: no engaged channels carried into the new turn`); - assert.equal(now?.arrival, undefined, `${mode}: no arrival carried into the new turn`); + assert.deepEqual(now?.arrivals, [], `${mode}: no arrival carried into the new turn`); assert.deepEqual(earlier, earlierFields(), `${mode}: all earlier-turn routing and receipt state stays with its queued prose`); } finally { From 7b02c83ff25898418afd8efbaefe39fdc84d9960 Mon Sep 17 00:00:00 2001 From: Nigel-2405 Date: Wed, 14 Oct 2026 00:14:45 -0400 Subject: [PATCH 10/17] fix(routing): a store that can't be read at a start fails it, rather than read its arrivals as withdrawn Sebastian-2380's read of 984f2fa9: remainingArrival caught any error from ContextManager.getMessage and read it as the message's absence, so a read that failed discarded a candidate that was still there. His probe (J7447) fails one read of a present addressed arrival during a capacity hold: the deferred compile then includes the message, yet publishes at the turn's old channel, with no routing notice and no report of the failure. getMessage already answers null for an id the store doesn't hold, so the check needs no catch. A read that throws now escapes the start like any other store failure there, and startAgentStream's catch ends the turn. Test (present-while-acting, adapted from his probe): during a capacity hold, an addressed message arrives, and the store's read of it throws at the deferred compile. The release rejects with that error, the compile never runs, and the turn speaks nowhere else. --- src/framework.ts | 7 +++--- test/present-while-acting.test.ts | 41 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 42b089b9a..203cac1f4 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9217,9 +9217,10 @@ export class AgentFramework { const cm = agent.getContextManager(); for (let i = routing.arrivals.length - 1; i >= 0; i--) { const { messageId, channelId } = routing.arrivals[i]!; - let stored: StoredMessage | null; - try { stored = cm.getMessage(messageId); } catch { stored = null; } - if (stored) return channelId; + // The store answers null for a message that isn't there. A store that + // can't be read throws here, as anywhere else in the start, rather + // than have its candidates read as withdrawn. + if (cm.getMessage(messageId)) return channelId; } return undefined; } diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 17480e34d..0790882dc 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -739,6 +739,47 @@ describe('present while acting', () => { }); } + it("during a capacity hold, a store that can't be read at the deferred compile fails the start rather than drop the arrival", async () => { + // Adapted from a reviewer's probe: a read that fails says nothing about + // whether the message is there, so it can't count as a withdrawal. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'On it.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + module.toolDelayMs = 25; + failStream(1, accelerationLimit); + // Were the failed read taken as a withdrawal, the compile would run and + // answer here, at the turn's old channel. + feedStart(framework, 2, [{ type: 'text', text: 'Here is the rest.' }] as ContentBlock[]); + + trigger(framework); + await framework.runUntilIdle(); + await say(framework, 'a question for you', addressed('discord:dm:antra')); + const cm = framework.getAgent('assistant')!.getContextManager(); + const arrival = cm.getAllMessages().find((m) => + m.content.some((b) => b.type === 'text' && (b as { text: string }).text === 'a question for you'))!; + const getMessage = cm.getMessage.bind(cm); + cm.getMessage = (id) => { + if (id === arrival.id) throw new Error('store unavailable'); + return getMessage(id); + }; + try { + await assert.rejects(release(framework), /store unavailable/); + } finally { + cm.getMessage = getMessage; + } + + assert.equal(membrane.calls.length, 1, 'the deferred compile never ran'); + assert.deepEqual(routed, [{ text: 'On it.', locus: 'chan-live-1' }], 'and the turn spoke nowhere else'); + assert.deepEqual(movedTo(framework), [], 'nor told the agent of a move'); + + await framework.stop(); + }); + it("a message a tool boundary already weighed doesn't take the locus back at a later start", async () => { membrane.pushResponse(createMockResponse([ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, From eab875595e09b089e2e5436fbf247899f8b45d34 Mon Sep 17 00:00:00 2001 From: Nigel-2405 Date: Wed, 14 Oct 2026 00:53:30 -0400 Subject: [PATCH 11/17] test(routing): bring a capacity hold to its deadline before a test releases it The tests that release a capacity hold call releaseProviderAccelerationCooldown directly, as the hold's timer would. #251 (main ab5f1183) makes that release re-arm its timer and return while the hold's `until` is still ahead; provider-waits.test.ts checks this for a wait's hold. The 429 these tests inject holds the turn for about 67 s, so on this branch merged with ab5f1183 the release let nothing go. Beth-2389 found it in her trial merge: 14 tests failed (J7490), every present-while-acting test that calls release() (10), the three explicit-prose-routing cases across a capacity-deferred compile, and trunk-channel-routing's held-compile test. In the three she traced (J7497) the deferred compile never ran. Setting `until` to now first made the nine files she ran pass there (J7499). Each of the three places now sets the hold's `until` to Date.now() before the release, which is the state the timer finds when it fires. This branch's release doesn't read `until`, so before the merge the change alters no outcome. Checks on the VPS, with typecheck and build clean in each (receipts under ~/.local/state/nigel-2405/chk/): - this commit's tree (d9904821), J7503: explicit-prose-routing 63/63, present-while-acting 53/53, trunk-channel-routing 13/13, tool-result-guard 114/114 and checkpoint-attribution 10/10, on Node 24.21.0 and 20.20.2 (focus-5facb314.txt); - a trial merge of it with main ab5f1183 (30a6c3ac, tree af4f7854, the three settleAgent conflicts resolved as before: main's error text, this branch's routing argument), J7504: those five, and provider-waits 83/83, provider-acceleration-cooldown 10/10, inference-exhaustion-rewind 11/11 and inference-failure-bounds 30/30, on both Nodes (focus-30a6c3ac.txt). --- test/explicit-prose-routing.test.ts | 8 +++++--- test/present-while-acting.test.ts | 9 ++++++--- test/trunk-channel-routing.test.ts | 8 +++++--- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index c34cb6e73..7089b9fc4 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -1651,7 +1651,7 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); const { chunks } = captureStream(framework); const internals = framework as unknown as { - providerAccelerationCooldowns: Map; + providerAccelerationCooldowns: Map; releaseProviderAccelerationCooldown(agentName: string): void; }; let starts = 0; @@ -1690,8 +1690,10 @@ describe('explicit prose routing', () => { await within(framework.runUntilIdle(), 'the first stream', 10000); if (how === 'capacity') { assert.equal(starts, 1, 'the hold defers the turn rather than retrying it at once'); - assert.ok(internals.providerAccelerationCooldowns.has('assistant'), 'the turn is held'); - // What the cooldown's timer does when it fires. + const hold = internals.providerAccelerationCooldowns.get('assistant'); + assert.ok(hold, 'the turn is held'); + // What the cooldown's timer does when it fires, at the hold's deadline. + hold.until = Date.now(); internals.releaseProviderAccelerationCooldown('assistant'); await within(framework.runUntilIdle(), 'the deferred compile', 10000); } diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 0790882dc..d08ef77f6 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -599,14 +599,17 @@ describe('present while acting', () => { }); type Held = { - providerAccelerationCooldowns: Map; + providerAccelerationCooldowns: Map; releaseProviderAccelerationCooldown(agentName: string): void; }; - /** Release the agent's capacity hold, as its timer does, and run the deferred compile. */ + /** Bring the agent's capacity hold to its deadline and release it, as its + * timer does then, and run the deferred compile. */ async function release(framework: AgentFramework): Promise { const internals = framework as unknown as Held; - assert.ok(internals.providerAccelerationCooldowns.has('assistant'), 'the turn is held'); + const hold = internals.providerAccelerationCooldowns.get('assistant'); + assert.ok(hold, 'the turn is held'); + hold.until = Date.now(); internals.releaseProviderAccelerationCooldown('assistant'); await framework.runUntilIdle(); } diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index e5708e4f3..e947a818c 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -417,7 +417,7 @@ describe('Trunk channel routing (item-3 redux)', () => { return stream; }; const held = framework as unknown as { - providerAccelerationCooldowns: Map; + providerAccelerationCooldowns: Map }>; releaseProviderAccelerationCooldown(agentName: string): void; }; const triggerAtStart: Array = []; @@ -432,8 +432,10 @@ describe('Trunk channel routing (item-3 redux)', () => { assert.ok(held.providerAccelerationCooldowns.has('scout'), 'the turn is held'); framework.pushEvent(channelIncoming('discord:guild:chanB', 'B: unrelated chatter')); await framework.runUntilIdle(); - const waiting = (held.providerAccelerationCooldowns.get('scout') as { heldRequests: Array<{ channelId?: string }> }).heldRequests; - assert.ok(waiting.some((r) => r.channelId === 'discord:guild:chanB'), "chanB's wake waits in the hold with the turn's"); + const hold = held.providerAccelerationCooldowns.get('scout')!; + assert.ok(hold.heldRequests.some((r) => r.channelId === 'discord:guild:chanB'), "chanB's wake waits in the hold with the turn's"); + // What the hold's timer does when it fires, at the hold's deadline. + hold.until = Date.now(); held.releaseProviderAccelerationCooldown('scout'); await framework.runUntilIdle(); From a679e9658fb420582faacd74e8d130dc440ba77e Mon Sep 17 00:00:00 2001 From: Nigel-2405 Date: Wed, 14 Oct 2026 05:45:44 -0400 Subject: [PATCH 12/17] fix(routing): answer sw-af314d; tune-out diversions and modes without a locus leave arrivals alone, and a late channel_open moves no locus Answers the internal review sw-af314d (wf-1047, the round 139aca6c..eab87559), whose sheet raised 14 issues. - A message tune-out diverts (metadata.tuneOut) is stored, but a resident's compiled view leaves it out (createAgent's viewFilter), so the agent never sees it. noteArrival weighed it anyway, and the turn's next start re-pinned the locus to the tuned-out channel with a "[routing] The conversation moved to" notice: during a capacity hold, an addressed message diverted from another channel moved the turn there (sw-af314d I2). noteArrival now skips it, as the design's rule says: an arrival re-pins only when the agent will see the message. A tool boundary still weighs the messages it injects into the live stream, diverted ones included, as on main: the agent hears those. - Only a locus or hybrid turn's start reads its arrivals' messages (I8). Since 7b02c83f a store read that throws fails the start, and an explicit or disabled turn read the store for an arrival it could never use, so a failed read failed a start that main lets run. The arrivals are still cleared at every later start. - A channel_open moves the locus of the turn that called it only while that turn is open, the condition the announce baseline already had (I3). A result that lands after the turn settled is dropped unread, so moving that turn's locus left it somewhere the agent was never told, for a resume that opens the turn again after stop(). The trigger channel is still recorded on the turn that called, however late. The result's routing note goes with the move. Tests: - present-while-acting, the capacity-hold rows: an addressed message that tune-out diverts leaves the locus, and so does an addressed system marker from another channel (I14; the system exclusion had no test). - present-while-acting: a budget restart moves nothing for an addressed message its over-budget boundary drained and that leaves the store before the restart starts (I6), beside the existing row where it remains and re-pins. - present-while-acting: the late channel_open tests become three rows: its turn still open while a silent start's routing is live (moves that turn's locus and the baseline, and the result says so); its turn ended and the next opened; its turn settled with no later start (neither moves the locus or the baseline, nor adds the note; the trigger channel is recorded). - explicit-prose-routing: an explicit turn whose arrival's message can't be read still starts and delivers (I8), beside the existing row. Text: TurnRouting.arrivals and noteArrival's comments name the tune-out rule and that only a locus or hybrid start re-pins; the channel_open comments say what a late result does; the changelog fragment says a message the agent won't see moves nothing, and what a late channel_open records. --- .../continuing-starts-keep-routing.fixed.md | 15 +- src/framework.ts | 60 ++++--- test/explicit-prose-routing.test.ts | 71 +++++--- test/present-while-acting.test.ts | 160 +++++++++--------- 4 files changed, 171 insertions(+), 135 deletions(-) diff --git a/changelog.d/continuing-starts-keep-routing.fixed.md b/changelog.d/continuing-starts-keep-routing.fixed.md index 34a6e4ed8..4d4190b13 100644 --- a/changelog.d/continuing-starts-keep-routing.fixed.md +++ b/changelog.d/continuing-starts-keep-routing.fixed.md @@ -22,9 +22,10 @@ arrives during a capacity hold or before a re-inference, and one drained at a budget restart's over-budget boundary, which before never moved the locus. Ambient traffic in a channel the turn hasn't sent into, reactions - and system markers don't move it, and neither does a message removed - before that start, such as an unread coalesced message its server - retracted: the newest qualifying message that remains decides. + and system markers don't move it. Neither does a message the agent won't + see: one that tune-out diverts, or one removed before that start, such as + an unread coalesced message its server retracted. The newest qualifying + message that remains decides. - A silent turn's tool boundary no longer re-pins its locus or adds a `[routing]` notice to its stream. - For a trunk agent, the channel context passed to `beforeInference` hooks @@ -41,6 +42,8 @@ (`runEphemeralToCompletion`) whose start throws that way fails at once with the start's error. Before, the run waited for its watchdog: 30 s by default before its inference started, or 15 minutes once it had. -- A send's engaged channel, and a `channel_open`'s move of the locus, are - recorded on the turn that called the tool, even when the tool's result, or - the open that follows a send, comes after that turn has ended. +- A send's engaged channel is recorded on the turn that called the tool, + even when the open that follows the send comes after that turn has ended. + A `channel_open` moves the locus of the turn that called it while that + turn is open. Its result tells the agent of the move, so one that comes + after the turn ended moves nothing: the result is dropped unread. diff --git a/src/framework.ts b/src/framework.ts index 203cac1f4..f80fe45dd 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -589,16 +589,18 @@ interface TurnRouting { /** Plain-prose segments the turn suppressed, reported by its receipt. */ suppressed: number; /** - * Locus and hybrid mode: the messages that arrived between the turn's - * streams and would re-pin the locus (repinChannel), in the order they - * were stored. No tool boundary weighs such a message, since none of the - * turn's streams was running: it was stored during a capacity hold or the - * wait for a re-inference, or flushed at the end of a stream that didn't - * settle the turn, or by the start that continues it (noteArrival). The - * turn's next start re-pins to the newest one still in the agent's store - * and clears them all, as a boundary would have weighed its injections - * (remainingArrival). One removed meanwhile, such as an unread coalesced - * message its server retracted, isn't there for the agent to answer. + * The messages that arrived between the turn's streams and would re-pin + * the locus (repinChannel), in the order they were stored. No tool + * boundary weighs such a message, since none of the turn's streams was + * running: it was stored during a capacity hold or the wait for a + * re-inference, or flushed at the end of a stream that didn't settle the + * turn, or by the start that continues it (noteArrival). A message + * tune-out diverted isn't one, since a resident's compiled view leaves it + * out. The turn's next start clears them all, and in locus or hybrid mode + * first re-pins to the newest one still in the agent's store, as a + * boundary would have weighed its injections (remainingArrival). One + * removed meanwhile, such as an unread coalesced message its server + * retracted, isn't there for the agent to answer. */ arrivals: Arrival[]; } @@ -9196,11 +9198,14 @@ export class AgentFramework { * Weigh a message stored for an agent whose logical turn is open while * none of the turn's streams is running, so that no tool boundary will * weigh it: one that would re-pin the locus becomes one of the turn's - * arrivals (TurnRouting.arrivals). + * arrivals (TurnRouting.arrivals). A message tune-out diverted + * (metadata.tuneOut) never enters a resident's compiled view, so the agent + * won't see it, and it moves nothing. */ private noteArrival(agentName: string, metadata: MessageMetadata | undefined, messageId: MessageId): void { const open = this.openLogical.get(agentName); if (!open) return; + if ((metadata as { tuneOut?: unknown } | undefined)?.tuneOut) return; const channelId = this.repinChannel(metadata, open); if (channelId) open.arrivals.push({ messageId, channelId }); } @@ -10014,11 +10019,12 @@ export class AgentFramework { // that continues the open turn. A message that arrived while none of // the turn's streams was running can still move it, as it would have // at a tool boundary: no boundary drained it (TurnRouting.arrivals). - const arrival = this.remainingArrival(agent, routing); + // Only a turn that has a locus reads its arrivals' messages. + const arrival = turnProseRouting === 'locus' || turnProseRouting === 'hybrid' + ? this.remainingArrival(agent, routing) + : undefined; routing.arrivals = []; - if (arrival && (turnProseRouting === 'locus' || turnProseRouting === 'hybrid')) { - this.repinTurnLocus(agent, routing, arrival, 'arrival'); - } + if (arrival) this.repinTurnLocus(agent, routing, arrival, 'arrival'); } // A silent start, or a disabled agent's, has no locus to show, whatever a // channel_open in a silent turn left on its object. @@ -16099,9 +16105,10 @@ export class AgentFramework { this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input }); const startTime = Date.now(); - // The turn that called: a channel_open's result moves its locus, even - // if that result arrives after the turn has ended. A silent turn routes - // no prose, so its open moves no locus. + // The turn that called: a channel_open's result records its trigger + // channel there, however late it arrives, and moves its locus while the + // turn is still open, even once another start's routing is the agent's + // live one. A silent turn routes no prose, so its open moves no locus. const callingTurn = this.routingOf(agentName); const callingSilent = this.activeTurnTriggers.get(agentName)?.suppressProse === true; @@ -16129,15 +16136,18 @@ export class AgentFramework { if (opened) { callingTurn.triggerChannel = opened; const openerAgent = this.agents.get(agentName); + // The move is one this result tells the agent, so it needs the + // turn that called still open. A result that lands after the turn + // settled is dropped, so the agent never reads it, whether or not + // a later start has replaced the turn's routing state yet: the + // turn's locus stays where the agent was last told, for a resume + // that opens the turn again, and so does the announce baseline, + // which is the agent's across turns. if (openerAgent && !callingSilent - && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) { + && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid') + && this.openLogical.get(agentName) === callingTurn) { callingTurn.locus = opened; - // The announce baseline is the agent's, across turns: it takes - // only a move this result tells the agent, which needs its turn - // still open. A result that lands after the turn settled is - // dropped, so the agent never reads it, whether or not a later - // start has replaced the turn's routing state yet. - if (this.openLogical.get(agentName) === callingTurn) this.lastAnnouncedLocus.set(agentName, opened); + this.lastAnnouncedLocus.set(agentName, opened); result = { ...result, data: { diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 7089b9fc4..5159458e4 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -1762,35 +1762,50 @@ describe('explicit prose routing', () => { }); } - it('explicit: an addressed message between the turn\'s streams moves nothing, since explicit mode has no locus', async () => { - membrane.pushResponse(createMockResponse([{ type: 'text', text: 'carrying on' }] as ContentBlock[])); - const framework = await createFramework('explicit'); - const routed = stubRegistry(framework); - feedTurn(framework, 2, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); - const internals = framework as unknown as { - turnRouting: Map; pendingRequests: InferenceRequest[]; - openLogical: Map; - addMessage(participant: string, content: ContentBlock[], metadata: Record): string; - }; - try { - const earlier = routingState({ pin: 'chan-alpha', destination: 'chan-alpha' }); - internals.turnRouting.set('assistant', earlier); - internals.openLogical.set('assistant', earlier); - // Someone addresses the agent from another channel while the turn waits. - internals.addMessage('Antra', [{ type: 'text', text: 'quick question for you' }] as ContentBlock[], - { channelId: 'discord:g:cafe', tags: ['chat:addressed'] }); - assert.deepEqual(earlier.arrivals.map((a) => a.channelId), ['discord:g:cafe'], 'it is the turn\'s arrival'); - internals.pendingRequests.push({ agentName: 'assistant', reason: 'tool_results_ready', source: 'framework', timestamp: Date.now() } as InferenceRequest); - await within(framework.runUntilIdle(), 'the re-inference', 10000); + for (const unreadable of [false, true]) { + const name = unreadable + ? "explicit: a turn with no locus doesn't read its arrival's message, so a store that can't be read leaves its next start alone" + : "explicit: an addressed message between the turn's streams moves nothing, since explicit mode has no locus"; + it(name, async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'carrying on' }] as ContentBlock[])); + const framework = await createFramework('explicit'); + const routed = stubRegistry(framework); + feedTurn(framework, 2, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + const internals = framework as unknown as { + turnRouting: Map; pendingRequests: InferenceRequest[]; + openLogical: Map; + addMessage(participant: string, content: ContentBlock[], metadata: Record): string; + }; + const cm = framework.getAgent('assistant')!.getContextManager(); + const getMessage = cm.getMessage.bind(cm); + try { + const earlier = routingState({ pin: 'chan-alpha', destination: 'chan-alpha' }); + internals.turnRouting.set('assistant', earlier); + internals.openLogical.set('assistant', earlier); + // Someone addresses the agent from another channel while the turn waits. + internals.addMessage('Antra', [{ type: 'text', text: 'quick question for you' }] as ContentBlock[], + { channelId: 'discord:g:cafe', tags: ['chat:addressed'] }); + assert.deepEqual(earlier.arrivals.map((a) => a.channelId), ['discord:g:cafe'], 'it is the turn\'s arrival'); + if (unreadable) { + const arrival = earlier.arrivals[0]!.messageId; + cm.getMessage = (id) => { + if (id === arrival) throw new Error('store unavailable'); + return getMessage(id); + }; + } + internals.pendingRequests.push({ agentName: 'assistant', reason: 'tool_results_ready', source: 'framework', timestamp: Date.now() } as InferenceRequest); + await within(framework.runUntilIdle(), 'the re-inference', 10000); - assert.deepEqual(routed.map((r) => ({ text: r.text.trim(), locus: r.locus })), [{ text: 'carrying on', locus: 'chan-alpha' }]); - assert.equal(earlier.locus, undefined, 'no locus'); - assert.deepEqual(earlier.arrivals, [], 'the arrival is used up'); - assert.ok(!context(framework).texts.some((t) => t.includes('The conversation moved to')), 'and no routing notice'); - } finally { - await framework.stop(); - } - }); + assert.deepEqual(routed.map((r) => ({ text: r.text.trim(), locus: r.locus })), [{ text: 'carrying on', locus: 'chan-alpha' }]); + assert.equal(earlier.locus, undefined, 'no locus'); + assert.deepEqual(earlier.arrivals, [], 'the arrival is used up'); + assert.ok(!context(framework).texts.some((t) => t.includes('The conversation moved to')), 'and no routing notice'); + } finally { + cm.getMessage = getMessage; + await framework.stop(); + } + }); + } for (const mode of ['explicit', 'hybrid'] as const) { it(`${mode}: a retried turn's text waits for the replaced stream's late deliveries, and follows what they set`, async () => { diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index d08ef77f6..7ab2e7343 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -670,6 +670,12 @@ describe('present while acting', () => { addressed('discord:dm:antra'), 'discord:dm:antra'], ['ambient traffic in another channel leaves the locus where it was', ambient('discord:guild:lounge'), 'chan-live-1'], + // Tune-out stamps a message it diverts, and a resident's compiled view + // leaves it out, so the agent never sees it. + ['an addressed message that tune-out diverts leaves the locus where it was', + { ...addressed('discord:dm:antra'), tuneOut: { epochId: 'epoch-1' } }, 'chan-live-1'], + ['an addressed system marker from another channel leaves the locus where it was', + { ...addressed('discord:dm:antra'), system: true }, 'chan-live-1'], ] as const) { it(`during a capacity hold, ${label}`, async () => { membrane.pushResponse(createMockResponse([ @@ -944,31 +950,44 @@ describe('present while acting', () => { await framework.stop(); }); - it('a budget restart re-pins to an addressed message its over-budget boundary left unweighed', async () => { - const overBudget = createMockResponse([ - { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, - ] as ContentBlock[], 'tool_use'); - overBudget.usage = { inputTokens: 100, outputTokens: 5 }; - membrane.pushResponse(overBudget); + for (const [label, withdrawn, expected] of [ + ['re-pins to an addressed message its over-budget boundary left unweighed', false, 'discord:dm:antra'], + ['moves nothing for such a message withdrawn before it starts', true, 'chan-live-1'], + ] as const) { + it(`a budget restart ${label}`, async () => { + const overBudget = createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use'); + overBudget.usage = { inputTokens: 100, outputTokens: 5 }; + membrane.pushResponse(overBudget); - const framework = await createFramework({ maxStreamTokens: 20 }); - const routed = stubChannelRegistry(framework); - module.toolDelayMs = 25; - module.interjection = 'quick question for you'; - module.interjectionMetadata = addressed('discord:dm:antra'); - feedStart(framework, 2, [{ type: 'text', text: 'After the restart.' }] as ContentBlock[]); + const framework = await createFramework({ maxStreamTokens: 20 }); + const routed = stubChannelRegistry(framework); + module.toolDelayMs = 25; + module.interjection = 'quick question for you'; + module.interjectionMetadata = addressed('discord:dm:antra'); + feedStart(framework, 2, [{ type: 'text', text: 'After the restart.' }] as ContentBlock[]); + if (withdrawn) { + // The boundary has stored the message and left it to the restart as + // an arrival, by the id it was stored under; it leaves the store + // before the restart starts. + framework.onTrace((event) => { + if (event.type === 'inference:stream_restarted') withdraw(framework, 'quick question for you'); + }); + } - trigger(framework); - await framework.runUntilIdle(); + trigger(framework); + await framework.runUntilIdle(); - assert.equal(membrane.calls.length, 2, 'the turn restarted for its budget'); - assert.deepEqual(routed, [ - { text: 'After the restart.', locus: 'discord:dm:antra' }, - ]); - assert.deepEqual(movedTo(framework), ['discord:dm:antra']); + assert.equal(membrane.calls.length, 2, 'the turn restarted for its budget'); + assert.deepEqual(routed, [ + { text: 'After the restart.', locus: expected }, + ]); + assert.deepEqual(movedTo(framework), withdrawn ? [] : [expected]); - await framework.stop(); - }); + await framework.stop(); + }); + } for (const [label, messages, expected] of [ ["keeps the turn's locus", [], 'chan-turn'], @@ -1501,64 +1520,53 @@ describe('present while acting', () => { assert.deepEqual([...next.engaged], [], "the next turn didn't send there"); }); - it("a channel_open moves the locus of the turn that called it, however late its result arrives", async () => { - const calling: { engaged: Set; triggerChannel?: string; locus?: string } = { engaged: new Set(), locus: 'chan-before' }; - const next: { engaged: Set; triggerChannel?: string; locus?: string } = { engaged: new Set(), locus: 'chan-next' }; - let resolveOpen!: (result: { success: boolean; data: { channelId: string } }) => void; - const framework = bareFramework(); - framework.agents = new Map([['assistant', { proseRouting: 'locus' }]]); - framework.channelRegistry = { - handleChannelToolCall: () => new Promise((resolve) => { resolveOpen = resolve; }), - }; - framework.turnRouting = new Map([['assistant', calling]]); - framework.openLogical = new Map([['assistant', calling]]); - const results: unknown[] = []; - framework.pushEvent = (event: { type: string }) => { if (event.type === 'tool-result') results.push(event); }; - - framework.dispatchChannelToolCall('assistant', { - id: 'open-1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' }, - }); - await until(() => resolveOpen !== undefined, 'the open to start'); - (framework.turnRouting as Map).set('assistant', next); - (framework.openLogical as Map).set('assistant', next); - resolveOpen({ success: true, data: { channelId: 'discord:guild:observatory' } }); - await until(() => results.length > 0, 'the tool result'); - - assert.equal(calling.triggerChannel, 'discord:guild:observatory'); - assert.equal(calling.locus, 'discord:guild:observatory'); - assert.deepEqual(next, { engaged: new Set(), locus: 'chan-next' }, 'the next turn is untouched'); - assert.equal((framework.lastAnnouncedLocus as Map).has('assistant'), false, - "the agent's announce baseline moves only for a result its turn is told"); - }); + // A channel_open's result can land after a later start has made another + // object the agent's live routing. While the turn that called is open, the + // result moves that turn's locus and tells the agent. Once the turn has + // ended, the tool-result handler drops the result, so the agent never reads + // it, and nothing moves, whether or not a later start has come yet. + for (const [label, after, moves] of [ + ['moves the locus of its turn, still open, when a silent start\'s routing is the live one', 'silent start', true], + ['moves no locus once its turn has ended and the next turn has opened', 'next turn', false], + ['moves no locus once its turn has settled, before any later start', 'settled', false], + ] as const) { + it(`a channel_open whose result lands late ${label}`, async () => { + type Routing = { engaged: Set; triggerChannel?: string; locus?: string }; + const calling: Routing = { engaged: new Set(), locus: 'chan-before' }; + const other: Routing = { engaged: new Set(), locus: 'chan-next' }; + let resolveOpen!: (result: { success: boolean; data: { channelId: string } }) => void; + const framework = bareFramework(); + framework.agents = new Map([['assistant', { proseRouting: 'locus' }]]); + framework.channelRegistry = { + handleChannelToolCall: () => new Promise((resolve) => { resolveOpen = resolve; }), + }; + framework.turnRouting = new Map([['assistant', calling]]); + framework.openLogical = new Map([['assistant', calling]]); + const results: Array<{ result: { data?: { routing?: string } } }> = []; + framework.pushEvent = (event: { type: string }) => { + if (event.type === 'tool-result') results.push(event as unknown as { result: { data?: { routing?: string } } }); + }; - it("a channel_open whose result lands after its turn settled, before any later start, leaves the announce baseline", async () => { - // The turn's routing state is still the agent's live one, since no later - // start has replaced it, but the turn is no longer open: the tool-result - // handler drops the result, so the agent never reads that it moved. - const calling: { engaged: Set; triggerChannel?: string; locus?: string } = { engaged: new Set(), locus: 'chan-before' }; - let resolveOpen!: (result: { success: boolean; data: { channelId: string } }) => void; - const framework = bareFramework(); - framework.agents = new Map([['assistant', { proseRouting: 'locus' }]]); - framework.channelRegistry = { - handleChannelToolCall: () => new Promise((resolve) => { resolveOpen = resolve; }), - }; - framework.turnRouting = new Map([['assistant', calling]]); - framework.openLogical = new Map([['assistant', calling]]); - const results: unknown[] = []; - framework.pushEvent = (event: { type: string }) => { if (event.type === 'tool-result') results.push(event); }; + framework.dispatchChannelToolCall('assistant', { + id: 'open-1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' }, + }); + await until(() => resolveOpen !== undefined, 'the open to start'); + const turnRouting = framework.turnRouting as Map; + const openLogical = framework.openLogical as Map; + if (after === 'silent start') turnRouting.set('assistant', other); + if (after === 'next turn') { turnRouting.set('assistant', other); openLogical.set('assistant', other); } + if (after === 'settled') openLogical.delete('assistant'); + resolveOpen({ success: true, data: { channelId: 'discord:guild:observatory' } }); + await until(() => results.length > 0, 'the tool result'); - framework.dispatchChannelToolCall('assistant', { - id: 'open-1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' }, + assert.equal(calling.triggerChannel, 'discord:guild:observatory', 'the turn that called records the channel it opened'); + assert.equal(calling.locus, moves ? 'discord:guild:observatory' : 'chan-before'); + assert.deepEqual(other, { engaged: new Set(), locus: 'chan-next' }, 'the other routing state is untouched'); + assert.equal((framework.lastAnnouncedLocus as Map).get('assistant'), + moves ? 'discord:guild:observatory' : undefined, "the announce baseline moves with the locus, for a move the agent is told"); + assert.equal(results[0]!.result.data?.routing !== undefined, moves, 'and the result says so only then'); }); - await until(() => resolveOpen !== undefined, 'the open to start'); - (framework.openLogical as Map).delete('assistant'); - resolveOpen({ success: true, data: { channelId: 'discord:guild:observatory' } }); - await until(() => results.length > 0, 'the tool result'); - - assert.equal(framework.turnRouting.get('assistant'), calling, "no later start replaced the turn's routing state"); - assert.equal((framework.lastAnnouncedLocus as Map).has('assistant'), false, - "so the agent's next turn still announces where its speech lands"); - }); + } for (const [label, silent, expected] of [ ['while its turn runs moves the locus and the announce baseline, and says so', false, 'discord:guild:observatory'], From 88ffb8a48f66b66187cc2f4e75a826f453bb0de3 Mon Sep 17 00:00:00 2001 From: Nigel-2405 Date: Wed, 14 Oct 2026 06:01:34 -0400 Subject: [PATCH 13/17] docs(routing): a dropped start reaches its routing after stop() only through its assembly, now that #251 is in endDroppedTurn's comment, and the dropped-start tests' comment, gave a second way a start reaches its routing after stop() or an agent's disposal: a retry whose delay outlasts the stop(), for an agent that takes no primary provider gate, since startAgentStream checked admission only for one that does. With main's #251, merged in b534aaf4, startAgentStream refuses a start at its entry once admission has closed, for every agent, and stop() ending a retry's wait cancels the retry, so that path no longer reaches routing (Beth-2389 found it, room-203 #90601). A start of an agent disposed while its retry waited does reach routing, but not the drop: it fails at its first lookup of the agent by name ("Unknown agent"), and that failure path settles the turn it opened (probe J7515, ~/.local/state/nigel-2405/probe/; room-203 #90703). So both comments now name the one way left: a fresh start that awaits its batches' assembly across a stop(), which the two dropped-start tests hold in a stand-in's assemble(). Comments only. --- src/framework.ts | 14 ++++++-------- test/present-while-acting.test.ts | 11 +++++------ 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 3d86bc70f..51e2879fe 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -5757,14 +5757,12 @@ export class AgentFramework { /** * A start dropped before its stream sends anything (its agent was replaced, * or stop() closed admission or ended the lifecycle the start began in) - * ends the logical turn it was running. stop() and an agent's disposal end - * every open turn (openLogical), but a start can reach its routing after - * either: one that awaited its batches' assembly across a stop(), or a - * start of an agent that takes no primary provider gate, which doesn't - * check admission before its turn begins, such as a retry after its delay. - * The turn it opened or reopened then would stay open, and the agent's next - * start, after a restart too, would continue the dropped turn's routing. - * The identity check leaves any other turn open. + * ends the logical turn it was running. stop() ends every open turn + * (openLogical), but a fresh start that awaited its batches' assembly + * across a stop() reaches its routing after it and opens its turn. That + * turn would then stay open, and the agent's first start after a restart + * would continue the dropped turn's routing. The identity check leaves any + * other turn open. */ private endDroppedTurn(agentName: string, routing: TurnRouting): void { if (this.openLogical.get(agentName) === routing) this.openLogical.delete(agentName); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 7ab2e7343..0d965d9e6 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -1402,12 +1402,11 @@ describe('present while acting', () => { } }; - // stop() and an agent's disposal end every open turn, but a start can reach - // its routing after either, opening or reopening its turn, and then be - // dropped before its stream sends anything: before its compile, once stop() - // has closed admission, or after it, once a restart has begun a lifecycle - // of its own. Here the start waits for a deferred batch's assembly while - // the stop runs. + // stop() ends every open turn, but a fresh start that awaits a deferred + // batch's assembly while the stop runs reaches its routing after it, opens + // its turn, and is then dropped before its stream sends anything: before + // its compile, once stop() has closed admission, or after it, once a + // restart has begun a lifecycle of its own. for (const restartFirst of [false, true]) { const name = restartFirst ? "a start whose compile spans a stop() and restart is dropped and leaves no turn open, so the next start opens its own" From 4b2957083ab5f7b9639d8156a6a240bcd469ef5e Mon Sep 17 00:00:00 2001 From: Nigel-2405 Date: Wed, 14 Oct 2026 06:20:55 -0400 Subject: [PATCH 14/17] refactor(routing): a channel_open's move needs only its open turn, which a silent turn never is Since a679e965, a channel_open moves the calling turn's locus only while that turn is the agent's open one (openLogical). A silent start's routing state is its own and never registered as the open turn, nor is a silent resume's, so that check already leaves a silent turn's locus alone, and the separate silent check (callingSilent, the call's trigger's suppressProse) decided nothing. a679e965's removal run showed it: removing the silent check (M31) failed none of the five covering files' tests, where at 984f2fa9 it failed one (rm/round-a679e965.txt, J7833). So the condition is the open turn alone, and the comment says why a silent turn's open moves no locus. "a channel_open in a silent turn moves no locus, and claims none" still holds: its turn isn't the open one. --- src/framework.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 51e2879fe..ea262ad2f 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -17968,9 +17968,9 @@ export class AgentFramework { // The turn that called: a channel_open's result records its trigger // channel there, however late it arrives, and moves its locus while the // turn is still open, even once another start's routing is the agent's - // live one. A silent turn routes no prose, so its open moves no locus. + // live one. A silent turn routes no prose, and its routing state is never + // the open turn's (openLogical), so its open moves no locus. const callingTurn = this.routingOf(agentName); - const callingSilent = this.activeTurnTriggers.get(agentName)?.suppressProse === true; this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName }) .then((result) => { @@ -18003,7 +18003,7 @@ export class AgentFramework { // turn's locus stays where the agent was last told, for a resume // that opens the turn again, and so does the announce baseline, // which is the agent's across turns. - if (openerAgent && !callingSilent + if (openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid') && this.openLogical.get(agentName) === callingTurn) { callingTurn.locus = opened; From f8f2ec1713ec5d2a9202d644277c20e9a9589755 Mon Sep 17 00:00:00 2001 From: Nigel-2405 Date: Wed, 14 Oct 2026 06:44:38 -0400 Subject: [PATCH 15/17] docs(routing): a silent turn's routing state can become the open turn's, but not while an open it called is out 4b295708's comment said a silent turn's routing state is never the open turn's. Beth-2389 found where it is (room-203 #92933): the scheduler keeps a batch's suppressProse only when every request in it is silent (runScheduledInference's silentOnly), so a silent turn's budget restart batched with an ordinary wake starts as a non-silent resume, and beginAgentTurn registers the resumed routing state, the silent turn's, in openLogical. Her conclusion still holds, so the code doesn't change: the restart is queued at the over-budget boundary, which runs once the agent has every result of the round, after each channel_open's result callback has run. No open the silent stream called is still out once its routing state can be the open turn's, and every open the resumed stream calls is a non-silent turn's. The comment now says that. Comment only. --- src/framework.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index ea262ad2f..b2dd1dd24 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -17968,8 +17968,11 @@ export class AgentFramework { // The turn that called: a channel_open's result records its trigger // channel there, however late it arrives, and moves its locus while the // turn is still open, even once another start's routing is the agent's - // live one. A silent turn routes no prose, and its routing state is never - // the open turn's (openLogical), so its open moves no locus. + // live one. A silent turn's open moves no locus: no silent start or + // resume registers its routing state as the open turn (openLogical), and + // a non-silent resume that does, a budget restart batched with an + // ordinary wake, starts only once its over-budget boundary has every + // result of the round. const callingTurn = this.routingOf(agentName); this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName }) From f07c6eac9a81d3c053988fcf15ad6f7282f94912 Mon Sep 17 00:00:00 2001 From: Nigel-2405 Date: Wed, 14 Oct 2026 07:14:52 -0400 Subject: [PATCH 16/17] fix(routing): every resident's open turn weighs what lands in the shared message slot Greptile's review of #333 (f8f2ec17, its P1 on src/framework.ts) found that addMessage noted an arrival only on the agent it routed the message to: the primary, unless the message was targeted. Every ordinary resident reads one message slot, though, so a second resident's open turn got no arrivals. A probe with two residents (~/.local/state/nigel-2405/probe/ second-resident-hold.test.ts, J7953/J7954) held each in turn with a capacity hold and addressed it from a DM during the hold. On main cd036e0d both deferred compiles spoke in the DM, by re-resolving the locus from the batch's addressed wake. At f8f2ec17 the primary did, through its arrival, and the second resident kept its old locus (sr-main-cd036e0d.txt, sr-f8f2ec17.txt). Recorded as sw-af314d's miss. Ada-1017's call (room-203 #93301): one rule for every resident. Each open turn weighs what lands in the slot by its own rule (addressed, or a follow-up in a channel it engaged), whichever agent the message was stored for. A tool boundary weighs only the messages drained for its own agent, so no boundary of another resident's turn ever weighs such a message, whether or not that turn's stream runs. So a second resident's mid-stream messages now count at its turn's next start, where main never weighed them, and a chat:addressed message moves any resident's open turn, since it addresses the account the residents share, whichever resident a server's wake picks. noteForOtherResidents does it for the three paths that store conversational messages into the slot: addMessage's store, a tool boundary's drain, and a start's own flush. The subconscious reads the slot through a merged view but its own store is isolated, so a message stored there moves no resident; forks and ephemeral agents read their own slots alone. TurnRouting.arrivals' and noteArrival's comments say which messages count now, and the changelog fragment says what changes for a second resident. Tests (trunk-channel-routing, "Residents read one message slot", two residents scout and shade with a registry stand-in that resolves a fresh locus to the turn's trigger channel): - the probe's table as the expectation: the primary and a second resident, each held by a capacity hold and addressed from a DM, speak in the DM; - a second resident held during ambient traffic elsewhere keeps its locus; - a second resident's held turn weighs a message the primary's own start stores (deferred while scout's turn was alive), and one the primary's tool boundary drains (arriving while scout's tool ran); - a second resident's turn weighs a message stored while it runs (during its start's context gathering, with the primary idle and no tool cycle open) at the turn's next start, the compile its capacity hold defers it to. A message that comes while any agent is mid tool cycle waits in the primary's queue instead (addMessage's tool-cycle check is global) and lands at the primary's boundary or start: the other two paths. --- .../continuing-starts-keep-routing.fixed.md | 6 +- src/framework.ts | 38 ++- test/trunk-channel-routing.test.ts | 280 +++++++++++++++++- 3 files changed, 313 insertions(+), 11 deletions(-) diff --git a/changelog.d/continuing-starts-keep-routing.fixed.md b/changelog.d/continuing-starts-keep-routing.fixed.md index 4d4190b13..36b3ae5db 100644 --- a/changelog.d/continuing-starts-keep-routing.fixed.md +++ b/changelog.d/continuing-starts-keep-routing.fixed.md @@ -25,7 +25,11 @@ and system markers don't move it. Neither does a message the agent won't see: one that tune-out diverts, or one removed before that start, such as an unread coalesced message its server retracted. The newest qualifying - message that remains decides. + message that remains decides. Residents read one message slot, so each + resident's open turn weighs such a message by its own rule, whichever + agent it was stored for. A second resident's locus also moves at its + turn's next start for a message stored while that turn ran, which no tool + boundary of its own weighs. - A silent turn's tool boundary no longer re-pins its locus or adds a `[routing]` notice to its stream. - For a trunk agent, the channel context passed to `beforeInference` hooks diff --git a/src/framework.ts b/src/framework.ts index b2dd1dd24..de6403a9c 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -594,14 +594,16 @@ interface TurnRouting { /** Plain-prose segments the turn suppressed, reported by its receipt. */ suppressed: number; /** - * The messages that arrived between the turn's streams and would re-pin - * the locus (repinChannel), in the order they were stored. No tool - * boundary weighs such a message, since none of the turn's streams was + * The messages that would re-pin the locus (repinChannel) that no tool + * boundary of the turn weighs, in the order they were stored. One stored + * for this agent is such a message when none of the turn's streams was * running: it was stored during a capacity hold or the wait for a * re-inference, or flushed at the end of a stream that didn't settle the - * turn, or by the start that continues it (noteArrival). A message - * tune-out diverted isn't one, since a resident's compiled view leaves it - * out. The turn's next start clears them all, and in locus or hybrid mode + * turn, or by the start that continues it (noteArrival). One stored for + * another resident always is, since residents read one message slot and a + * boundary weighs only the messages drained for its own agent + * (noteForOtherResidents). A message tune-out diverted isn't one, since a + * resident's compiled view leaves it out. The turn's next start clears them all, and in locus or hybrid mode * first re-pins to the newest one still in the agent's store, as a * boundary would have weighed its injections (remainingArrival). One * removed meanwhile, such as an unread coalesced message its server @@ -8595,6 +8597,8 @@ export class AgentFramework { if (deferred.length > 0) { for (const msg of deferred) { const id = agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)); + // This boundary weighs it for this agent; the other residents read it too. + this.noteForOtherResidents(agent, msg.metadata, id); // Injection guards: tool blocks would corrupt the tool-cycle // structure the membrane enforces, and a message named as the // agent itself would render as an ASSISTANT turn on the wire @@ -10630,9 +10634,8 @@ export class AgentFramework { } /** - * Weigh a message stored for an agent whose logical turn is open while - * none of the turn's streams is running, so that no tool boundary will - * weigh it: one that would re-pin the locus becomes one of the turn's + * Weigh a message that no tool boundary of the agent's open logical turn + * will weigh: one that would re-pin the locus becomes one of the turn's * arrivals (TurnRouting.arrivals). A message tune-out diverted * (metadata.tuneOut) never enters a resident's compiled view, so the agent * won't see it, and it moves nothing. @@ -10645,6 +10648,21 @@ export class AgentFramework { if (channelId) open.arrivals.push({ messageId, channelId }); } + /** + * Weigh a message just stored in `agent`'s store for every other resident's + * open turn, running or not. Residents read one message slot + * (sharedSlotAgents), and a tool boundary weighs only the messages drained + * for its own agent, so no boundary of another resident's turn weighs it. + * The subconscious reads that slot through a merged view, but its own + * store is isolated, as a fork's or an ephemeral agent's is. + */ + private noteForOtherResidents(agent: Agent, metadata: MessageMetadata | undefined, messageId: MessageId): void { + if (!this.sharedSlotAgents.has(agent) || agent.name === this.subconsciousAgentName) return; + for (const reader of this.sharedSlotAgents) { + if (reader !== agent && this.agents.get(reader.name) === reader) this.noteArrival(reader.name, metadata, messageId); + } + } + /** * The channel of the turn's newest arrival whose message is still in the * agent's store (TurnRouting.arrivals). A message removed since it was @@ -11333,6 +11351,7 @@ export class AgentFramework { msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id), ); this.noteArrival(agent.name, msg.metadata, id); + this.noteForOtherResidents(agent, msg.metadata, id); this.emitTrace({ type: 'message:added', messageId: id, source: 'deferred-flush:turn-start' }); } catch (err) { console.error( @@ -15134,6 +15153,7 @@ export class AgentFramework { if (!this.activeTurnTokens.has(agent.name) || opts?.bypassDeferralFor === agent.name) { this.noteArrival(agent.name, metadata, stored); } + this.noteForOtherResidents(agent, metadata, stored); return stored; } diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index e947a818c..28cb96f75 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -22,9 +22,19 @@ import { tmpdir } from 'node:os'; import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { AgentFramework } from '../src/index.js'; -import type { ProcessEvent } from '../src/index.js'; +import type { + ProcessEvent, + Module, + ModuleContext, + ToolDefinition, + ToolCall, + ToolResult, + EventResponse, +} from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; import { MembraneError } from '@animalabs/membrane'; +import type { ContentBlock } from '@animalabs/membrane'; +import type { ContextInjection } from '@animalabs/context-manager'; /** Reach the private per-turn routing state (its triggering channel) + the pure channel-deriver. */ function internals(framework: AgentFramework) { @@ -470,3 +480,271 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); }); + +// --------------------------------------------------------------------------- +// Residents read one message slot +// --------------------------------------------------------------------------- +// Every ordinary resident reads the same message slot, and a tool boundary +// weighs only the messages drained for its own agent. So each resident's open +// turn weighs what lands in the slot by its own rule (an addressed message, +// or a follow-up in a channel the turn engaged), whichever agent it was +// stored for: by addMessage, by a boundary's drain, or by a start's own flush. + +/** A channel message as a channel server forwards it, waking only `target`. */ +function residentIncoming(channelId: string, text: string, target: string, addressed: boolean): ProcessEvent { + return { + type: 'mcpl:channel-incoming', + serverId: 'discord', + channelId, + messageId: `m-${Math.random().toString(36).slice(2)}`, + author: { id: 'U1', name: 'antra' }, + content: [{ type: 'text', text }], + timestamp: new Date().toISOString(), + metadata: addressed ? { mentioned: true } : {}, + tags: addressed ? ['chat:addressed'] : ['chat:ambient', 'chat:from-human'], + triggerInference: true, + targetAgents: [target], + } as unknown as ProcessEvent; +} + +/** A module whose `pause` tool runs `during` before it returns, and whose + * context gathering runs `startHook` once, in the named agent's next start. */ +class PauseModule implements Module { + readonly name = 'pause'; + during: (() => Promise) | null = null; + startHook: { agent: string; run: () => void } | null = null; + async start(_ctx: ModuleContext): Promise {} + async stop(): Promise {} + async gatherContext(agentName: string): Promise { + const hook = this.startHook; + if (hook?.agent === agentName) { + this.startHook = null; + hook.run(); + } + return []; + } + getTools(): ToolDefinition[] { + return [{ name: 'pause', description: 'Pause briefly', inputSchema: { type: 'object', properties: {} } }]; + } + async handleToolCall(_call: ToolCall): Promise { + const during = this.during; + this.during = null; + if (during) await during(); + return { success: true, data: { ok: true } }; + } + async onProcess(): Promise { + return {}; + } +} + +describe('Residents read one message slot', () => { + let tempDir: string; + let membrane: MockMembrane; + let pause: PauseModule; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'resident-slot-test-')); + membrane = new MockMembrane(); + pause = new PauseModule(); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + type Internals = { + addMessage(participant: string, content: ContentBlock[], metadata?: Record): string; + turnRouting: Map; + activeTurnTokens: Map; + deferredMessages: unknown[]; + channelRegistry: unknown; + providerAccelerationCooldowns: Map; + releaseProviderAccelerationCooldown(agentName: string): void; + }; + + /** Two residents, scout the primary and shade. A registry stand-in + * resolves a fresh locus to the turn's trigger channel and records where + * each agent's speech is routed. `held`'s first stream ends with an + * organization acceleration 429, which holds its turn for a later + * compile. `first` is that stream's rounds, and `responses[n]` feeds the + * nth start (1-based) as it begins. */ + async function residents( + held: 'scout' | 'shade', + responses: Record, + first: ContentBlock[][] = [[]], + ) { + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: membrane.asMembrane(), + agents: [ + { name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }, + { name: 'shade', model: 'test-model', systemPrompt: 'You are shade.' }, + ], + modules: [pause], + }); + const i = framework as unknown as Internals; + const routed: Array<{ agent: string; text: string; locus: string | null }> = []; + const registry: Record = { + resolveLocus: (name: string) => i.turnRouting.get(name)?.triggerChannel ?? 'chan-default', + routeSpeech: async (agent: string, text: string, locus?: string | null) => { + routed.push({ agent, text, locus: locus ?? null }); + return locus ? { delivered: true, channelId: locus } : null; + }, + getDefaultPublishChannel: () => null, + isChannelOpen: () => true, + getDescriptor: () => undefined, + getChannelTools: () => [], + }; + i.channelRegistry = new Proxy(registry, { + get: (target, prop: string) => (prop in target ? target[prop] : () => undefined), + }); + + first.forEach((blocks, n) => membrane.pushResponse(createMockResponse(blocks, n < first.length - 1 ? 'tool_use' : undefined))); + const makeStream = membrane.streamYielding.bind(membrane); + const firstStreamOf = new Set(); + membrane.streamYielding = (request, options) => { + const stream = makeStream(request, options); + const system = JSON.stringify((request as { system?: unknown }).system ?? ''); + const agent = system.includes('You are shade.') ? 'shade' : 'scout'; + const first = !firstStreamOf.has(agent); + firstStreamOf.add(agent); + const iterate = stream[Symbol.asyncIterator].bind(stream); + stream[Symbol.asyncIterator] = async function* () { + for await (const event of { [Symbol.asyncIterator]: iterate }) { + if (event.type === 'complete' && first && agent === held) { + yield { + type: 'error' as const, + error: new MembraneError({ + type: 'rate_limit', retryable: true, httpStatus: 429, + message: "This request would exceed your organization's maximum usage increase rate for input tokens per minute", + rawError: { status: 429 }, rawRequest: request, + }), + }; + return; + } + yield event; + } + }; + return stream; + }; + let starts = 0; + framework.onTrace((event) => { + if (event.type !== 'inference:started') return; + const feed = responses[++starts]; + feed?.forEach((blocks, n) => membrane.pushResponse(createMockResponse(blocks, n < feed.length - 1 ? 'tool_use' : undefined))); + }); + + /** Bring `name`'s hold to its deadline and release it, as its timer does then. */ + const release = async (name: string): Promise => { + const hold = i.providerAccelerationCooldowns.get(name); + assert.ok(hold, `${name}'s turn is held`); + hold.until = Date.now(); + i.releaseProviderAccelerationCooldown(name); + await framework.runUntilIdle(); + }; + return { framework, i, routed, release }; + } + + const hereIAm = [[{ type: 'text', text: 'Here I am.' }] as ContentBlock[]]; + + // Each row is also what main does, by re-resolving the held turn's locus + // from its batch's addressed wake. + for (const [held, role] of [['scout', 'the primary'], ['shade', 'a second resident']] as const) { + it(`${role}, held by a capacity hold, moves its locus to an addressed message from another channel`, async () => { + const { framework, i, routed, release } = await residents(held, { 2: hereIAm }); + framework.pushEvent(residentIncoming('discord:guild:chanA', `${held}, take a look`, held, false)); + await framework.runUntilIdle(); + assert.ok(i.providerAccelerationCooldowns.has(held), 'the turn is held'); + framework.pushEvent(residentIncoming('discord:dm:antra', `${held}, over here please`, held, true)); + await framework.runUntilIdle(); + await release(held); + + assert.deepEqual(routed, [{ agent: held, text: 'Here I am.', locus: 'discord:dm:antra' }]); + await framework.stop(); + }); + } + + it('a second resident, held by a capacity hold, keeps its locus for ambient traffic in another channel', async () => { + const { framework, routed, release } = await residents('shade', { 2: hereIAm }); + framework.pushEvent(residentIncoming('discord:guild:chanA', 'shade, take a look', 'shade', false)); + await framework.runUntilIdle(); + framework.pushEvent(residentIncoming('discord:guild:lounge', 'unrelated chatter', 'shade', false)); + await framework.runUntilIdle(); + await release('shade'); + + assert.deepEqual(routed, [{ agent: 'shade', text: 'Here I am.', locus: 'discord:guild:chanA' }]); + await framework.stop(); + }); + + it("a second resident's turn weighs a message stored while it runs, at the turn's next start", async () => { + const { framework, i, routed, release } = await residents('shade', { 2: hereIAm }); + // While shade's start gathers its context, its turn is alive and no tool + // cycle is open, so an addressed message for the idle primary is stored + // at once. Shade's start has weighed its arrivals by then, and no + // boundary of shade's turn weighs it. + pause.startHook = { + agent: 'shade', + run: () => { + i.addMessage('Antra', [{ type: 'text', text: 'shade, over here please' }], + { channelId: 'discord:dm:antra', tags: ['chat:addressed'] }); + }, + }; + framework.pushEvent(residentIncoming('discord:guild:chanA', 'shade, take a look', 'shade', false)); + await framework.runUntilIdle(); + assert.equal(pause.startHook, null, "shade's start stored the message"); + await release('shade'); + + assert.deepEqual(routed, [{ agent: 'shade', text: 'Here I am.', locus: 'discord:dm:antra' }]); + await framework.stop(); + }); + + it("a second resident's held turn weighs a message the primary's own start stores", async () => { + const { framework, i, routed, release } = await residents('shade', { + 2: [[{ type: 'text', text: 'scout here' }] as ContentBlock[]], + 3: hereIAm, + }); + framework.pushEvent(residentIncoming('discord:guild:chanA', 'shade, take a look', 'shade', false)); + await framework.runUntilIdle(); + // While scout's turn is alive, a message for the primary waits in its + // queue, and scout's next start stores it. + i.activeTurnTokens.set('scout', 999); + framework.pushEvent(residentIncoming('discord:dm:antra', 'shade, over here please', 'shade', true)); + await framework.runUntilIdle(); + assert.equal(i.deferredMessages.length, 1, 'the message waits for scout'); + i.activeTurnTokens.delete('scout'); + framework.pushEvent(residentIncoming('discord:guild:chanC', 'scout, a word', 'scout', false)); + await framework.runUntilIdle(); + assert.equal(i.deferredMessages.length, 0, "scout's start stored it"); + await release('shade'); + + assert.deepEqual(routed.filter((r) => r.agent === 'shade'), + [{ agent: 'shade', text: 'Here I am.', locus: 'discord:dm:antra' }]); + await framework.stop(); + }); + + it("a second resident's held turn weighs a message the primary's tool boundary drains", async () => { + const { framework, routed, release } = await residents('shade', { + 2: [ + [{ type: 'tool_use', id: 'p1', name: 'pause--pause', input: {} }] as ContentBlock[], + [{ type: 'text', text: 'scout done' }] as ContentBlock[], + ], + 3: hereIAm, + }); + framework.pushEvent(residentIncoming('discord:guild:chanA', 'shade, take a look', 'shade', false)); + await framework.runUntilIdle(); + // The message comes while scout's tool runs, so it waits in scout's queue + // until scout's boundary stores it. + pause.during = async () => { + framework.pushEvent(residentIncoming('discord:dm:antra', 'shade, over here please', 'shade', true)); + await new Promise((resolve) => setTimeout(resolve, 30)); + }; + framework.pushEvent(residentIncoming('discord:guild:chanC', 'scout, a word', 'scout', false)); + await framework.runUntilIdle(); + assert.equal(pause.during, null, "scout's tool ran"); + await release('shade'); + + assert.deepEqual(routed.filter((r) => r.agent === 'shade'), + [{ agent: 'shade', text: 'Here I am.', locus: 'discord:dm:antra' }]); + await framework.stop(); + }); +}); From 0dfdbd5cb644b8849b0fcac195a8fa2e2b07646b Mon Sep 17 00:00:00 2001 From: Nigel-2405 Date: Wed, 14 Oct 2026 08:11:20 -0400 Subject: [PATCH 17/17] fix(routing): answer sw-af314e; a boundary that re-pins clears older arrivals, and only ordinary residents take another's sw-af314e (wf-1084) read f8f2ec17..f07c6eac: 39 findings in 17 issues, its audit clean. This answers the fix-now ones. - I8: since f07c6eac a turn can take an arrival while its stream runs, from a message stored for another resident. A tool boundary that then re-pins left that arrival in place, so the turn's next start moved the locus back to it, though what the boundary had just stored is newer in the store's order, which is the window's. The boundary now clears the turn's arrivals when it finds a message to re-pin to. A new test holds such an arrival from shade's store while scout's tool runs, has scout's boundary re-pin, and checks scout's next start keeps the boundary's channel. - I2: noteForOtherResidents noted arrivals on the subconscious's open turn too, since it reads the shared slot through its merged view, though its doc and Ada-1017's rule are about ordinary residents. Its explicit starts cleared them unread. The readers are now the ordinary residents, by the same predicate as the writer check (isOrdinaryResident). - I1: the boundary drain's comment said the boundary weighs everything it stores for its own agent. It weighs what it injects; what it keeps window-only is the agent's own words or tool blocks, which never move its own turn. The comment says so now. - I9, I10, I14: TurnRouting.arrivals' comment names the boundary that couldn't re-pin, says another resident's message counts whenever it lands (within those that would re-pin), and that a boundary's re-pin clears them; repinChannel's and the later start's comments say noteArrival weighs what no boundary of the turn weighed. - I4, I13, I17: the changelog says a message stored for another resident counts whenever it lands, including one that resident's boundary drained, and that a message tagged as addressed addresses the account the residents share, so it can move any resident's turn. - I5: the boundary-drain test stored its message with a 30 ms timer and checked only that the tool ran. It now stores it directly during the tool and asserts it waited in scout's queue and that scout's boundary stored it. - I15: the ambient test asserts the lounge message is in shade's store before the release. --- .../continuing-starts-keep-routing.fixed.md | 11 ++-- src/framework.ts | 63 ++++++++++++------- test/trunk-channel-routing.test.ts | 40 ++++++++++-- 3 files changed, 81 insertions(+), 33 deletions(-) diff --git a/changelog.d/continuing-starts-keep-routing.fixed.md b/changelog.d/continuing-starts-keep-routing.fixed.md index 36b3ae5db..e0b995ab8 100644 --- a/changelog.d/continuing-starts-keep-routing.fixed.md +++ b/changelog.d/continuing-starts-keep-routing.fixed.md @@ -25,11 +25,12 @@ and system markers don't move it. Neither does a message the agent won't see: one that tune-out diverts, or one removed before that start, such as an unread coalesced message its server retracted. The newest qualifying - message that remains decides. Residents read one message slot, so each - resident's open turn weighs such a message by its own rule, whichever - agent it was stored for. A second resident's locus also moves at its - turn's next start for a message stored while that turn ran, which no tool - boundary of its own weighs. + message that remains decides. Residents read one message slot, and a + tool boundary weighs only the messages drained for its own agent, so each + resident's open turn also takes, by the same rule, a message stored for + another resident whenever it lands, including one that resident's + boundary drained. A message tagged as addressed addresses the account the + residents share, so it can move any resident's turn. - A silent turn's tool boundary no longer re-pins its locus or adds a `[routing]` notice to its stream. - For a trunk agent, the channel context passed to `beforeInference` hooks diff --git a/src/framework.ts b/src/framework.ts index de6403a9c..63fde5caf 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -595,16 +595,19 @@ interface TurnRouting { suppressed: number; /** * The messages that would re-pin the locus (repinChannel) that no tool - * boundary of the turn weighs, in the order they were stored. One stored - * for this agent is such a message when none of the turn's streams was - * running: it was stored during a capacity hold or the wait for a - * re-inference, or flushed at the end of a stream that didn't settle the - * turn, or by the start that continues it (noteArrival). One stored for - * another resident always is, since residents read one message slot and a - * boundary weighs only the messages drained for its own agent - * (noteForOtherResidents). A message tune-out diverted isn't one, since a - * resident's compiled view leaves it out. The turn's next start clears them all, and in locus or hybrid mode - * first re-pins to the newest one still in the agent's store, as a + * boundary of the turn weighed, in the order they were stored + * (noteArrival). A message stored for this agent is one when none of the + * turn's streams was running: stored during a capacity hold or the wait + * for a re-inference, flushed at the end of a stream that didn't settle + * the turn, or stored by the start that continues it. So is one a boundary + * drained but couldn't re-pin for, being over budget or without a live + * stream. A message stored for another ordinary resident is one whenever + * it lands, since residents read one message slot and a boundary weighs + * only the messages drained for its own agent (noteForOtherResidents). A + * message tune-out diverted isn't one, since a resident's compiled view + * leaves it out. A boundary that re-pins clears them, since what it injects + * is newer. The turn's next start clears them all, and in locus or hybrid + * mode first re-pins to the newest one still in the agent's store, as a * boundary would have weighed its injections (remainingArrival). One * removed meanwhile, such as an unread coalesced message its server * retracted, isn't there for the agent to answer. @@ -8597,7 +8600,8 @@ export class AgentFramework { if (deferred.length > 0) { for (const msg of deferred) { const id = agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)); - // This boundary weighs it for this agent; the other residents read it too. + // Every other resident reads what this boundary stores; for its + // own agent, the boundary weighs what it injects below. this.noteForOtherResidents(agent, msg.metadata, id); // Injection guards: tool blocks would corrupt the tool-cycle // structure the membrane enforces, and a message named as the @@ -8720,6 +8724,9 @@ export class AgentFramework { let newLocus: string | undefined; for (const inj of midTurnInjections) newLocus = this.repinChannel(inj.metadata, routing) ?? newLocus; if (newLocus) { + // What this boundary just stored is newer than any arrival the + // turn holds, so the arrivals can't move the locus back. + routing.arrivals = []; const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed'); if (notice) midTurnInjections.push(notice); } @@ -10623,7 +10630,7 @@ export class AgentFramework { * reaction or a system marker) that addressed the agent, or that follows * up in a channel the turn sent into (TurnRouting.engaged). Undefined for * any other message. A tool boundary weighs the messages it injects by it, - * and noteArrival the ones that arrive between the turn's streams. + * and noteArrival the ones no boundary of the turn weighs. */ private repinChannel(metadata: MessageMetadata | undefined, routing: TurnRouting): string | undefined { if (!isConversationalInjection(metadata)) return undefined; @@ -10649,17 +10656,27 @@ export class AgentFramework { } /** - * Weigh a message just stored in `agent`'s store for every other resident's - * open turn, running or not. Residents read one message slot - * (sharedSlotAgents), and a tool boundary weighs only the messages drained - * for its own agent, so no boundary of another resident's turn weighs it. - * The subconscious reads that slot through a merged view, but its own - * store is isolated, as a fork's or an ephemeral agent's is. + * Whether `agent`'s store is the residents' one message slot, which every + * ordinary resident reads (sharedSlotAgents). The subconscious reads that + * slot through a merged view, but its own store is isolated, as a fork's + * or an ephemeral agent's is. + */ + private isOrdinaryResident(agent: Agent): boolean { + return this.sharedSlotAgents.has(agent) && agent.name !== this.subconsciousAgentName; + } + + /** + * Weigh a message just stored in an ordinary resident's store for every + * other ordinary resident's open turn, running or not: residents read one + * message slot, and a tool boundary weighs only the messages drained for + * its own agent, so no boundary of another resident's turn weighs it. */ private noteForOtherResidents(agent: Agent, metadata: MessageMetadata | undefined, messageId: MessageId): void { - if (!this.sharedSlotAgents.has(agent) || agent.name === this.subconsciousAgentName) return; + if (!this.isOrdinaryResident(agent)) return; for (const reader of this.sharedSlotAgents) { - if (reader !== agent && this.agents.get(reader.name) === reader) this.noteArrival(reader.name, metadata, messageId); + if (reader !== agent && this.agents.get(reader.name) === reader && this.isOrdinaryResident(reader)) { + this.noteArrival(reader.name, metadata, messageId); + } } } @@ -11505,9 +11522,9 @@ export class AgentFramework { } } else { // Every other start of the turn keeps its locus: a resume, or a start - // that continues the open turn. A message that arrived while none of - // the turn's streams was running can still move it, as it would have - // at a tool boundary: no boundary drained it (TurnRouting.arrivals). + // that continues the open turn. A message no tool boundary of the turn + // weighed can still move it, as it would have at a boundary + // (TurnRouting.arrivals). // Only a turn that has a locus reads its arrivals' messages. const arrival = turnProseRouting === 'locus' || turnProseRouting === 'hybrid' ? this.remainingArrival(agent, routing) diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 28cb96f75..e12e55a3d 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -553,7 +553,7 @@ describe('Residents read one message slot', () => { }); type Internals = { - addMessage(participant: string, content: ContentBlock[], metadata?: Record): string; + addMessage(participant: string, content: ContentBlock[], metadata?: Record, opts?: { forAgent?: string }): string; turnRouting: Map; activeTurnTokens: Map; deferredMessages: unknown[]; @@ -670,6 +670,9 @@ describe('Residents read one message slot', () => { await framework.runUntilIdle(); framework.pushEvent(residentIncoming('discord:guild:lounge', 'unrelated chatter', 'shade', false)); await framework.runUntilIdle(); + assert.ok(framework.getAgent('shade')!.getContextManager().getAllMessages() + .some((m) => m.content.some((b) => b.type === 'text' && (b as { text: string }).text === 'unrelated chatter')), + 'shade reads the ambient message in the slot'); await release('shade'); assert.deepEqual(routed, [{ agent: 'shade', text: 'Here I am.', locus: 'discord:guild:chanA' }]); @@ -722,8 +725,32 @@ describe('Residents read one message slot', () => { await framework.stop(); }); + it("a boundary that re-pins supersedes an arrival from another resident's store", async () => { + const { framework, i, routed, release } = await residents('scout', { 2: hereIAm }, [ + [{ type: 'tool_use', id: 'p1', name: 'pause--pause', input: {} }] as ContentBlock[], + [] as ContentBlock[], + ]); + // While scout's tool runs, a message for shade is stored at once, an + // arrival for scout's turn. Then one for scout waits in its queue, and + // scout's boundary stores it after, re-pinning the turn there. + pause.during = async () => { + i.addMessage('Antra', [{ type: 'text', text: 'shade, a word' }], + { channelId: 'discord:dm:xenia', tags: ['chat:addressed'] }, { forAgent: 'shade' }); + i.addMessage('Antra', [{ type: 'text', text: 'scout, over here please' }], + { channelId: 'discord:dm:antra', tags: ['chat:addressed'] }); + }; + framework.pushEvent(residentIncoming('discord:guild:chanA', 'scout, take a look', 'scout', false)); + await framework.runUntilIdle(); + assert.equal(pause.during, null, "scout's tool ran"); + await release('scout'); + + assert.deepEqual(routed, [{ agent: 'scout', text: 'Here I am.', locus: 'discord:dm:antra' }], + "the boundary's re-pin stands at the turn's next start"); + await framework.stop(); + }); + it("a second resident's held turn weighs a message the primary's tool boundary drains", async () => { - const { framework, routed, release } = await residents('shade', { + const { framework, i, routed, release } = await residents('shade', { 2: [ [{ type: 'tool_use', id: 'p1', name: 'pause--pause', input: {} }] as ContentBlock[], [{ type: 'text', text: 'scout done' }] as ContentBlock[], @@ -734,13 +761,16 @@ describe('Residents read one message slot', () => { await framework.runUntilIdle(); // The message comes while scout's tool runs, so it waits in scout's queue // until scout's boundary stores it. + let queued = -1; pause.during = async () => { - framework.pushEvent(residentIncoming('discord:dm:antra', 'shade, over here please', 'shade', true)); - await new Promise((resolve) => setTimeout(resolve, 30)); + i.addMessage('Antra', [{ type: 'text', text: 'shade, over here please' }], + { channelId: 'discord:dm:antra', tags: ['chat:addressed'] }); + queued = i.deferredMessages.length; }; framework.pushEvent(residentIncoming('discord:guild:chanC', 'scout, a word', 'scout', false)); await framework.runUntilIdle(); - assert.equal(pause.during, null, "scout's tool ran"); + assert.equal(queued, 1, "the message waited in scout's queue while its tool ran"); + assert.equal(i.deferredMessages.length, 0, "scout's boundary stored it"); await release('shade'); assert.deepEqual(routed.filter((r) => r.agent === 'shade'),