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..e0b995ab8 --- /dev/null +++ b/changelog.d/continuing-starts-keep-routing.fixed.md @@ -0,0 +1,54 @@ +- A start that continues a logical turn now keeps what the turn has decided + 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 + 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. + + 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 + 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 in a channel the turn hasn't sent into, reactions + 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, 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 + (`channels.defaultOutgoing`) names the turn's locus once the turn has one. + 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 + start that throws outside its stream's failure handling ends its logical + 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 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/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index 3f78c6ff4..f237a61fe 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -22,8 +22,12 @@ 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. +- **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. 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 0a1382089..14696c14d 100644 --- a/docs/hybrid-prose-routing.md +++ b/docs/hybrid-prose-routing.md @@ -24,6 +24,14 @@ 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 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; 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 9877d24c3..63fde5caf 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -523,53 +523,110 @@ 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; + /** + * The messages that would re-pin the locus (repinChannel) that no tool + * 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. + */ + 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 { destination: null, hybridHold: false, logical: { continuation: false, deliveries: [], suppressed: 0 } }; + return { + engaged: new Set(), destination: null, hybridHold: false, + continuation: false, deliveries: [], suppressed: 0, arrivals: [], + }; } /** How a round's prose is delivered: kept private, and under which turn's routing. */ @@ -1563,16 +1620,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 @@ -1617,32 +1664,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 @@ -1658,12 +1679,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. */ @@ -1675,25 +1697,27 @@ 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 (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 + * 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(); @@ -2701,8 +2725,10 @@ 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). + // on this instance opens a new one rather than continuing the old turn's + // 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 @@ -5733,6 +5759,20 @@ 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() 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); + } + /** 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 @@ -5741,13 +5781,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; @@ -8552,11 +8592,17 @@ 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)); + // 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 // agent itself would render as an ASSISTANT turn on the wire @@ -8566,11 +8612,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); } } } @@ -8649,15 +8697,18 @@ 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 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) { // Two signals qualify an injection to move the pin (n=6 + n=7): // - 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 @@ -8668,44 +8719,23 @@ 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 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; - }); - 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); - } + // The last injection that qualifies wins (repinChannel). + const routing = this.routingOf(agent.name); + 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); } + } 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 + // results. + for (const inj of midTurnInjections) this.noteArrival(agent.name, inj.metadata, injectedIds.get(inj)!); } if (shouldEndTurn) { @@ -8723,7 +8753,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. @@ -10474,7 +10504,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. */ @@ -10483,7 +10513,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); } /** @@ -10500,11 +10530,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 ?? []) { @@ -10556,7 +10586,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; @@ -10583,6 +10612,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)`, @@ -10592,6 +10624,127 @@ 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 no boundary of the turn weighs. + */ + 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 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. + */ + 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 }); + } + + /** + * 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.isOrdinaryResident(agent)) return; + for (const reader of this.sharedSlotAgents) { + if (reader !== agent && this.agents.get(reader.name) === reader && this.isOrdinaryResident(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 + * 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]!; + // 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; + } + + /** + * 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, repinChannel). Ambient chatter never moves it. Leaves + * a durable `[routing]` notice in the window and updates + * 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. 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, + 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; + 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.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 }; + } 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 @@ -10643,10 +10796,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. @@ -10761,7 +10914,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; @@ -10839,7 +10992,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); @@ -11123,12 +11276,50 @@ 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 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 { 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. + // 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)) { + 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}, id=${msg.id}):`, + err, + ); + } + } + this.ackDeferredWrites(); + } } } @@ -11145,7 +11336,7 @@ export class AgentFramework { ownsProviderGate: boolean, ): Promise { const lifecycle = this.lifecycleGeneration; - const continuingTurn = resumesLogicalTurn(trigger?.reason); + 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 @@ -11163,7 +11354,7 @@ export class AgentFramework { // products — undoing the turn must not destroy them. if ( attempt === 0 && - !continuingTurn && + !resumesTurn && this.deferredMessages.length > 0 ) { { @@ -11176,6 +11367,8 @@ export class AgentFramework { const id = agent.getContextManager().addMessage( 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( @@ -11194,7 +11387,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) { @@ -11241,45 +11434,57 @@ 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, 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 + // 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 + // 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); + // 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; + } else { + const open = this.openLogical.get(agent.name); + if (open) { + routing = open; } else { - this.activeTriggerChannels.delete(agent.name); + routing = freshTurnRouting(); + 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) { @@ -11289,69 +11494,59 @@ 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 { + // Every other start of the turn keeps its locus: a resume, or a start + // 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) + : undefined; + routing.arrivals = []; + if (arrival) this.repinTurnLocus(agent, routing, arrival, 'arrival'); } - - // 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() }); @@ -11368,7 +11563,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 { @@ -11429,6 +11624,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; @@ -11472,6 +11668,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; @@ -11570,11 +11767,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: statedWait === 'fail' ? this.statedWaitNotSlept(err) : err.message, - }, startLogical); + }, routing); this.emitTrace({ type: 'inference:exhausted', agentName: agent.name, @@ -11647,15 +11846,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 @@ -11667,11 +11865,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 => { @@ -11763,14 +11963,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); @@ -12350,7 +12550,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', @@ -12651,13 +12851,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)'}`, @@ -12744,7 +12942,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, @@ -12782,7 +12980,7 @@ export class AgentFramework { stopReason: 'exhausted', speech: '', error: statedWait === 'fail' ? this.statedWaitNotSlept(err) : 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, @@ -12839,7 +13037,7 @@ export class AgentFramework { stopReason: 'exhausted', speech: '', error: 'Turn abandoned by operator quiesce', - }, streamRouting.logical); + }, streamRouting); } this.emitTrace({ type: 'inference:aborted', @@ -12883,7 +13081,7 @@ export class AgentFramework { stopReason: 'exhausted', speech: '', error: `Stream aborted: ${reason}`, - }, streamRouting.logical); + }, streamRouting); this.emitTrace({ type: 'inference:exhausted', agentName: agent.name, @@ -13043,7 +13241,7 @@ export class AgentFramework { stopReason: 'exhausted', speech: '', error: statedWait === 'fail' ? this.statedWaitNotSlept(err) : err.message, - }, streamRouting.logical); + }, streamRouting); this.emitTrace({ type: 'inference:exhausted', agentName: agent.name, @@ -14967,6 +15165,12 @@ 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, stored); + } + this.noteForOtherResidents(agent, metadata, stored); return stored; } @@ -15703,13 +15907,22 @@ 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. - activeChannelResolver: (agentName) => this.activeTriggerChannels.get(agentName), + // 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 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; + }, // 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. @@ -17569,6 +17782,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 = 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 @@ -17668,12 +17884,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); + sendingTurn?.engaged.add(channelId ?? target); } if (status === 'opened') { console.error( @@ -17791,6 +18002,15 @@ 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 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'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 }) .then((result) => { @@ -17799,10 +18019,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, @@ -17814,10 +18034,19 @@ export class AgentFramework { (result.data as { channelId?: string } | undefined)?.channelId ?? (call.input as { channelId?: string } | undefined)?.channelId; if (opened) { - this.activeTriggerChannels.set(agentName, opened); + callingTurn.triggerChannel = opened; const openerAgent = this.agents.get(agentName); - if (openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) { - this.turnLocusPins.set(agentName, opened); + // 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 + && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid') + && this.openLogical.get(agentName) === callingTurn) { + callingTurn.locus = opened; this.lastAnnouncedLocus.set(agentName, opened); result = { ...result, @@ -18100,7 +18329,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); }); @@ -19009,9 +19238,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 - // per-agent in `activeTriggerChannels` 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 41da16ead..082e6ed9b 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -528,17 +528,19 @@ 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. 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`. */ activeChannelResolver?: (agentName: string) => string | undefined; } @@ -1453,8 +1455,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/mcpl/prose-stream-router.ts b/src/mcpl/prose-stream-router.ts index 1814b9071..308f554ff 100644 --- a/src/mcpl/prose-stream-router.ts +++ b/src/mcpl/prose-stream-router.ts @@ -21,13 +21,13 @@ * * Three modes, mirroring proseRouting: * - explicit: parse `>>` prefixes. A fresh turn's stream starts suppressed; - * one that resumes a turn starts at the turn's destination, and - * each round's end returns the stream to it (retarget). - * - hybrid: parse `>>>` envelopes, with the frozen turn locus as the + * 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 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,9 +52,9 @@ export class ProseStreamRouter { private opts: { /** explicit parses `>>`; hybrid parses `>>>` while retaining locus fallback. */ mode: 'explicit' | 'hybrid' | 'locus'; - /** Initial destination: the frozen turn locus (locus 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 - * 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 c96a9a579..b1526bbcb 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-frozen outbound locus (see turnLocusPins) — 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 - * 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. */ @@ -104,7 +107,9 @@ 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 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 & { @@ -119,7 +124,9 @@ 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 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 648f32534..5159458e4 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; 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(), arrivals: [], ...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'); }); }); @@ -691,7 +698,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; }); @@ -700,11 +707,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(); @@ -713,7 +720,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'); @@ -723,7 +730,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(); @@ -742,25 +749,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']); @@ -870,7 +878,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. @@ -899,11 +907,12 @@ 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; + turnRouting: 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. @@ -913,11 +922,13 @@ 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 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); @@ -927,19 +938,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']), arrivals: [{ messageId: 'cafe-message', channelId: 'discord:g:cafe' }], + }); + 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.) @@ -957,6 +975,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' ? '>>' : '>>>'; @@ -970,15 +1015,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;) { @@ -986,7 +1031,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(); @@ -1028,7 +1073,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'); @@ -1037,7 +1082,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(); @@ -1084,7 +1129,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')!; @@ -1092,12 +1137,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(); }); @@ -1199,8 +1244,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, @@ -1226,9 +1270,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'); @@ -1255,17 +1298,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(), @@ -1579,4 +1621,296 @@ 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'); + 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); + } + 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 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 { + 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 () => { + 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..0d965d9e6 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -30,10 +30,14 @@ import type { ToolDefinition, ToolCall, 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 type { ContentBlock } from '@animalabs/membrane'; +import { MembraneError } from '@animalabs/membrane'; +import { JsStore } from '@animalabs/chronicle'; +import type { ContentBlock, NormalizedRequest } from '@animalabs/membrane'; // --------------------------------------------------------------------------- // Test module: tools that optionally emit a mid-turn external message @@ -53,10 +57,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 [ { @@ -174,12 +188,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], }); @@ -413,13 +427,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 +471,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 +547,1056 @@ describe('present while acting', () => { await framework.stop(); }); + // ------------------------------------------------------------------------- + // Every later start of the turn keeps its locus + // ------------------------------------------------------------------------- + // 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) => { + 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' && failing.has(n)) { + 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, ...responses: ContentBlock[][]): void { + let starts = 0; + framework.onTrace((event) => { + if (event.type !== 'inference:started' || ++starts !== start) return; + responses.forEach((blocks, i) => membrane.pushResponse(createMockResponse( + blocks, i < responses.length - 1 ? 'tool_use' : undefined, + ))); + }); + } + + /** 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, + }); + + type Held = { + providerAccelerationCooldowns: Map; + releaseProviderAccelerationCooldown(agentName: string): void; + }; + + /** 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; + const hold = internals.providerAccelerationCooldowns.get('assistant'); + assert.ok(hold, 'the turn is held'); + hold.until = Date.now(); + 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(); + } + + /** 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'] }); + + /** 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)) + .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([ + { 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 = addressed('discord:dm:antra'); + 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, metadata, expected] of [ + ['an addressed message from another channel moves the locus there, with the notice', + 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([ + { 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(); + // 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 }, + ]); + assert.deepEqual(movedTo(framework), expected === 'chan-live-1' ? [] : [expected], + 'a re-pin tells the agent, and nothing else does'); + + await framework.stop(); + }); + } + + 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("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' } }, + ] 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 }>; + }; + 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. 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 = addressed('discord:dm:antra'); + 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 = ambient('discord:guild:portables'); + } + return origHandle(call); + }; + failStream(1, accelerationLimit); + feedStart(framework, 2, [{ type: 'text', text: 'Back now.' }] as ContentBlock[]); + + trigger(framework); + 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, 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; + // 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 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; + }; + 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(); + }); + + 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[]); + 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(); + + 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(); + }); + } + + 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; [field: string]: unknown }; + const internals = framework as unknown as { + turnRouting: Map; openLogical: Map; pendingRequests: InferenceRequest[]; + }; + // 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']), + destination: null, hybridHold: false, continuation: false, deliveries: [], suppressed: 0, arrivals: [], + }; + 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); + + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Carrying on.', locus: expected }]); + 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; arrivals: Array<{ messageId: string; channelId: 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, arrivals: [], + }; + 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.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({ + 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(); + } + }); + + 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(); + }); + + 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("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); + 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, arrivals: [], + }; + 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, arrivals: [], + }; + internals.turnRouting.set('assistant', turn); + 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"); + + 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; arrivals: Array<{ messageId: string; channelId: 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, arrivals: [], + }; + 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.deepEqual(open.arrivals, [], "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.) + 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(); + framework.activeTurnTriggers = new Map(); + framework.openLogical = 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)); + } + }; + + // 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" + : "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() }; + 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"); + }); + + // 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 } } }); + }; + + 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'); + + 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'); + }); + } + + 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]]); + 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 }) => { + 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 diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index ba1a56d75..8cc2886ca 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -914,12 +914,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 { @@ -997,10 +997,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; arrivals: Array<{ messageId: string; channelId: string }> }; 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']), arrivals: [{ messageId: 'earlier-message', channelId: 'chan-earlier' }] }); + const earlier = earlierFields(); internals.turnRouting.set('assistant', earlier); try { await h.run(); @@ -1009,12 +1012,14 @@ 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(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 { await h.framework.stop(); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index dc61fc56c..e12e55a3d 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -22,13 +22,24 @@ 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 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 +325,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 +341,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 +357,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,13 +366,94 @@ 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(); }); + 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; + 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'); + // 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(); + 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(); + + assert.deepEqual(triggerAtStart, ['discord:guild:chanA', 'discord:guild:chanA'], + "the deferred compile's batch names chanB, and the turn keeps chanA"); + 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(); @@ -371,7 +463,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,10 +473,308 @@ 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(); }); }); + +// --------------------------------------------------------------------------- +// 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, opts?: { forAgent?: string }): 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(); + 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' }]); + 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 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, 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[], + ], + 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. + let queued = -1; + pause.during = async () => { + 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(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'), + [{ agent: 'shade', text: 'Here I am.', locus: 'discord:dm:antra' }]); + await framework.stop(); + }); +});