From cdba921d17268bd150dbe53ebb71fe5c134927c3 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 19 Oct 2026 16:06:33 -0400 Subject: [PATCH 01/12] fix(channels): a channel notice reaches whoever can close the channel, and a released agent's unread ones reach the primary - The opened notice (recordChannelAutoOpenNotice) names channel_close only to a reader that can call it for every channel it names (canCloseChannels: its allowedTools hold channel_close, and a conversation fork only for its home channel). Where an agent's own send opened a channel it can't close, the primary is told by name too, if it's registered, isn't that agent and can close it. The ended branch judges its advice for the primary, and a policy admission's for the agent addMessage delivers to. Before, a fork whose reply opened a channel away from its home was told to use channel_close, which dispatchChannelToolCall refuses there, and no one who could close the channel was told. - The reply notice (recordReplyOpenFailedNotice) takes the same canCloseChannels, in place of its two inline copies of the rule. Neither notice checks any more that the primary isn't the reader: where the reader can't close and the primary can, they're different agents, so the check could change nothing (its removal failed no test). - Comments on the release paths name a channel notice its reader could act on beside failed replies. - Both notices are built by builders (channelOpenedNotices, replyOpenFailedNotices) that return what to write. A notice addressed to the agent whose send opened the channel, or whose reply it was, carries its inputs (passOn) where that agent was told it can act on it; a policy admission's, addressed to no agent of its own, carries none. When a fork or an ephemeral run is released with such a notice unread, stored or still queued, or restored at boot for an agent gone, the primary is told in its place: the builder is called again for the agent now gone, giving the ended branch's words judged for the primary (channelNoticeForPrimary, through noticeForPrimary beside failed replies). A notice whose reader couldn't act carries no inputs, since the primary was told then. - The release helpers are renamed for what they now pass on: unreadNoticesForPrimary, passNoticeToPrimary, queuedNoticeForPrimary. Tests: the opened notice's rule as a table (fork away from home and at it, allowedTools, the primary's own conditions, several channels, a policy admission, an ended agent); a real fork away from its home, with its refused channel_close; a fork released with a notice it could act on, and one it couldn't; a channel notice still queued when a run ends; restored channel notices for a gone agent at boot; and the inputs read back, malformed ones included. Co-Authored-By: Claude Opus 5.5 --- ...annel-notices-reach-who-can-close.fixed.md | 14 + src/framework.ts | 486 ++++++++++++------ test/channel-lifecycle-order.test.ts | 126 ++++- test/route-failure-targeting.test.ts | 159 +++++- test/stop-step-throws.test.ts | 6 +- 5 files changed, 619 insertions(+), 172 deletions(-) create mode 100644 changelog.d/channel-notices-reach-who-can-close.fixed.md diff --git a/changelog.d/channel-notices-reach-who-can-close.fixed.md b/changelog.d/channel-notices-reach-who-can-close.fixed.md new file mode 100644 index 000000000..920551ab2 --- /dev/null +++ b/changelog.d/channel-notices-reach-who-can-close.fixed.md @@ -0,0 +1,14 @@ +- The notice that a channel opened without the agent asking (an open its + own send or reply caused, or a subscription-policy admission) names + `channel_close` only to an agent that can call it there for every channel + it names. A conversation fork can close only its home channel, and an + agent's `allowedTools` can leave `channel_close` out. Before, such an agent + was told to use `channel_close`, and couldn't call it, or its call was + refused. Where an agent's + own send opened a channel it can't close, the primary is now told by name + too, if it can close it. +- When a conversation fork or an ephemeral run is released without reading a + channel notice it could act on (a channel its own send opened, or a + channel its reply couldn't open that is selected for opening), the primary + is told by name in its place, as for a reply whose delivery failed. Before, + the notice stayed unread in the released agent's window. diff --git a/src/framework.ts b/src/framework.ts index 47ae0b67d..6c0a5fa20 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2166,6 +2166,72 @@ type McpRestartClaim = { readonly restarting: string; endedBy?: string }; * agent and stream, before the cancel, for the stream's terminal to read. */ type FrameworkCancelKind = 'turn_ended' | 'budget_restart' | 'quiesce_abandoned' | 'shutdown'; +/** What opened a channel the agent didn't ask to open + * (recordChannelAutoOpenNotice). */ +type ChannelOpenedCause = 'subscription-policy' | 'opened-by-delivery' | 'opened-by-reply'; + +/** A channel notice as its builder returns it: its text, its metadata, whom + * it's addressed to (none: where addMessage delivers an unrouted message), + * and what its log line says. */ +interface ChannelNotice { + text: string; + metadata: MessageMetadata; + target?: { forAgent: string }; + told: string; + shown: string; +} + +/** + * A channel notice's inputs, kept in its metadata (`passOn`) where its reader + * was told it could act: a release that finds the notice unread tells the + * primary in its place, by calling the notice's builder again for the reader + * then gone (channelNoticeForPrimary). A notice whose reader couldn't act + * carries none: the primary was told then, where it could act. + */ +type ChannelNoticePassOn = + | { notice: 'channel-opened'; cause: ChannelOpenedCause; channels: Array<{ channelId: string; label?: string }> } + | { notice: 'reply-open-failed'; channel: { channelId: string; label?: string }; selectedForOpening: SelectedForOpening }; + +/** A channel as a notice's inputs carry it, read back from stored metadata. */ +function noticeChannel(value: unknown): { channelId: string; label?: string } | null { + const channel = value as { channelId?: unknown; label?: unknown } | null | undefined; + if (typeof channel?.channelId !== 'string' || channel.channelId === '') return null; + return typeof channel.label === 'string' ? { channelId: channel.channelId, label: channel.label } : { channelId: channel.channelId }; +} + +/** A channel notice's inputs (`passOn`), read back from its metadata, which + * a store or a queue may have kept from another build: null when it + * carries none, or none this build reads. */ +function channelNoticePassOn(metadata: unknown): ChannelNoticePassOn | null { + const meta = metadata as { kind?: unknown; passOn?: unknown } | null | undefined; + if (meta?.kind !== 'channel-notice') return null; + const passOn = meta.passOn as { + notice?: unknown; cause?: unknown; channels?: unknown; channel?: unknown; selectedForOpening?: unknown; + } | null | undefined; + if (passOn?.notice === 'channel-opened') { + const causes: readonly unknown[] = ['subscription-policy', 'opened-by-delivery', 'opened-by-reply']; + if (!causes.includes(passOn.cause) || !Array.isArray(passOn.channels) || passOn.channels.length === 0) return null; + const channels = passOn.channels.map(noticeChannel); + if (channels.some((c) => c === null)) return null; + return { notice: 'channel-opened', cause: passOn.cause as ChannelOpenedCause, channels: channels as Array<{ channelId: string; label?: string }> }; + } + if (passOn?.notice === 'reply-open-failed') { + const selected: readonly unknown[] = ['unrecorded', 'server-bootstrap', 'subscription-policy']; + const channel = noticeChannel(passOn.channel); + if (!channel || !selected.includes(passOn.selectedForOpening)) return null; + return { notice: 'reply-open-failed', channel, selectedForOpening: passOn.selectedForOpening as SelectedForOpening }; + } + return null; +} + +/** Whether a notice an agent never read goes to the primary in its place + * when the agent ends: a failed reply's, or a channel notice that carries + * its inputs, since its reader was told it could act (ChannelNoticePassOn). */ +function passesToPrimary(metadata: unknown): boolean { + return (metadata as { kind?: unknown } | null | undefined)?.kind === 'discord-send-failed' || + channelNoticePassOn(metadata) !== null; +} + export class AgentFramework { private toolPresentations = new Map(); private presentationPreviews = new WeakMap(); @@ -3957,7 +4023,8 @@ export class AgentFramework { // still loses it at exit. An entry for a target no longer registered (a // disposed fork) is left: dropped with a memory-only queue at exit, and // from a persisted one, by the boot or resume flush that meets it, passed - // to the primary if it's a failed reply, and otherwise dropped, logged. + // to the primary if it's a failed reply, or a channel notice its reader + // could act on, and otherwise dropped, logged. // // A registered agent's stream that outlasted its wait, or whose cancel // threw, may still write, so that agent's entries stay queued, said once @@ -3968,15 +4035,17 @@ export class AgentFramework { // later stop() takes what that leaves. Until then a persisted queue keeps // them for boot, and a memory-only one loses them at exit. // A window release the quiesce postponed (releasesAfterResume) never runs - // once the host stops, so its unread pass runs here: each failed reply - // stored unread in that window goes to the primary now, deferred while + // once the host stops, so its unread pass runs here: each notice stored + // unread in that window that the primary takes in its place (a failed + // reply, or a channel notice its reader could act on) goes to the primary + // now, deferred while // quiesced like any write, so it waits in the queue with the rest. What // was queued for that agent stays queued, as a gone agent's. Each // release's pass is a step of its own: one that throws fails the stop() // and leaves the others, and the flush below, to run. for (const agent of this.releasesAfterResume.values()) { step(() => { - for (const metadata of this.unreadFailedReplies(agent)) this.passFailedReplyToPrimary(agent.name, metadata); + for (const metadata of this.unreadNoticesForPrimary(agent)) this.passNoticeToPrimary(agent.name, metadata); }); } this.releasesAfterResume.clear(); @@ -6412,7 +6481,8 @@ export class AgentFramework { try { // The windows of forks and runs deregistered during the window are // released first, each landing its queued writes and passing on its - // unread failed replies, as at any release. Then flush context writes + // unread failed replies, and the channel notices it could act on, as at + // any release. Then flush context writes // deferred by the quiesce window (module events, api message.send) — // with no turn alive there is no other flush point. The batch is // stored in queue order and acknowledged once, after the chronicle @@ -6484,15 +6554,15 @@ export class AgentFramework { this.handOffDeferredWrites(flush); // A target no longer registered (a fork or a run gone before a crash // that left this write to boot recovery) has no window to land in. A - // failed reply it never read becomes the primary's by-name notice, under - // the entry's own id and place in the queue (queuedFailedReplyForPrimary), - // and is written with the rest; any other write is dropped, logged, by - // addMessage. + // failed reply it never read, or a channel notice it could have acted on, + // becomes the primary's by-name notice, under the entry's own id and + // place in the queue (queuedNoticeForPrimary), and is written with the + // rest; any other write is dropped, logged, by addMessage. let passed = 0; const batch = flush.flatMap((msg) => { if (msg.forAgent === undefined || this.agents.has(msg.forAgent)) return [msg]; - if ((msg.metadata as { kind?: unknown } | undefined)?.kind !== 'discord-send-failed') return [msg]; - const forPrimary = this.queuedFailedReplyForPrimary(msg.forAgent, msg); + if (!passesToPrimary(msg.metadata)) return [msg]; + const forPrimary = this.queuedNoticeForPrimary(msg.forAgent, msg); if (!forPrimary) return []; passed++; return [forPrimary]; @@ -6503,7 +6573,7 @@ export class AgentFramework { this.ackDeferredWrites(); console.error( `[host-mode] ${label}: flushed ${stored}/${flush.length} deferred context write(s)` + - (passed > 0 ? `, ${passed} failed repl${passed === 1 ? 'y' : 'ies'} of a released agent passed to the primary` : '') + + (passed > 0 ? `, ${passed} notice${passed === 1 ? '' : 's'} of a released agent passed to the primary` : '') + (keep.length > 0 ? `, ${keep.length} kept for a still-alive turn` : ''), ); } @@ -14989,60 +15059,127 @@ export class AgentFramework { * transport RPC (a failed open keeps the decision, and channel_list shows * transport state), or an open that the agent's own delivery or reply * caused (`opened-by-delivery`, `opened-by-reply`), once its transport is - * confirmed. Either way it names `channel_close` as the opt-out (their - * decision outranks policy). What an open channel delivers is its server's - * to say (#406), so the notice doesn't promise its traffic. System message: - * never triggers inference, never conversational, never locus-eligible. + * confirmed. It names `channel_close` as the opt-out (their decision + * outranks policy) only to a reader that can call it for every channel it + * names (canCloseChannels): a conversation fork may close only its home + * channel, and an agent's allowedTools can leave channel_close out. Where an + * agent's own send opened a channel it can't close, the primary is told by + * name too, if it's registered and can close it. What an open channel + * delivers is its server's to say (#406), so the notice doesn't promise its + * traffic. System message: never triggers inference, never conversational, + * never locus-eligible. */ private recordChannelAutoOpenNotice( agentName: string | undefined, channels: Array<{ channelId: string; label?: string }>, - cause: 'subscription-policy' | 'opened-by-delivery' | 'opened-by-reply', + cause: ChannelOpenedCause, ): void { if (channels.length === 0) return; + for (const notice of this.channelOpenedNotices(agentName, channels, cause)) { + try { + // Through framework.addMessage for mid-turn safety (deferral + + // hear-while-acting injection) — a delivery-forced open happens inside + // a live turn, and a direct context-manager append there would store + // the notice out of order. + this.addMessage('user', [{ type: 'text', text: notice.text }], notice.metadata, notice.target); + console.error(`[channel] auto-open notice (${cause}) -> ${notice.told}: ${notice.shown}`); + } catch (err) { + console.error('recordChannelAutoOpenNotice failed:', err); + } + } + } + + /** + * The notices recordChannelAutoOpenNotice writes, in order: the reader's, + * and the primary's by name where the reader can't close the channels. + * None, logged, for an agent that has ended when there's no primary to tell. + * The reader's carries its inputs (`passOn`) where it's addressed to the + * agent whose send opened the channels and that agent was told it can close + * them, so a release that finds it unread can tell the primary in its place + * (channelNoticeForPrimary), which calls this again for the agent then gone. + * A policy admission's goes where addMessage delivers it, to no agent of its + * own, and carries none. + */ + private channelOpenedNotices( + agentName: string | undefined, + channels: Array<{ channelId: string; label?: string }>, + cause: ChannelOpenedCause, + ): ChannelNotice[] { const shown = channels .map((c) => (c.label && c.label !== c.channelId ? `${c.label} (${c.channelId})` : c.channelId)) .join(', '); + const ids = channels.map((c) => c.channelId); const plural = channels.length > 1; - const optOut = ' What an open channel delivers to you depends on its server. ' + - `If you don't want ${plural ? 'one of them' : 'it'}, use channel_close; your choice outranks policy.`; + const optOut = (canClose: boolean) => ' What an open channel delivers to you depends on its server.' + + (canClose ? ` If you don't want ${plural ? 'one of them' : 'it'}, use channel_close; your choice outranks policy.` : ''); // An open caused by an agent's own send is that agent's to hear about, in // the window it reads; a policy admission with no agent behind it goes to // the primary. An agent that has ended (a run whose final reply is routed - // after its release) can close nothing, and no channel opens without - // someone being told who can close it: the primary, the one left, is told - // by name, as for a failed reply. The default path would fall back to the - // first registered agent when there is no primary. - let text: string; - let metadata = { system: true, kind: 'channel-notice' } as MessageMetadata; - let target: { forAgent: string } | undefined; - let told: string; - if (agentName && !this.agents.has(agentName)) { - if (!this.primaryAgentName || !this.agents.has(this.primaryAgentName)) { + // after its release) can close nothing, so the primary, the one left, is + // told by name, as for a failed reply, whether or not it can close the + // channels: its advice is judged for it. The default path would fall + // back to the first registered agent when there is no primary. + const primary = this.primaryAgentName ?? undefined; + const ended = !!agentName && !this.agents.has(agentName); + if (ended) { + if (!primary || !this.agents.has(primary)) { console.error(`[channel] ${agentName} has ended, and there is no primary to tell that ${shown} opened`); - return; + return []; } - text = `[channels] Now open: ${shown}: opened by ${agentName}'s reply; ${agentName} has ended.` + optOut; - metadata = { system: true, kind: 'channel-notice', speaker: agentName } as MessageMetadata; - told = `the primary, by name (${agentName} has ended)`; - } else { - text = (cause === 'subscription-policy' + return [{ + text: `[channels] Now open: ${shown}: opened by ${agentName}'s reply; ${agentName} has ended.` + + optOut(this.canCloseChannels(primary, ids)), + metadata: { system: true, kind: 'channel-notice', speaker: agentName } as MessageMetadata, + told: `the primary, by name (${agentName} has ended)`, + shown, + }]; + } + // The reader: the agent whose send opened the channels, or for a policy + // admission, the agent addMessage delivers to (deliveryAgent). + const who = agentName || this.deliveryAgent()?.name; + const canClose = this.canCloseChannels(who, ids); + const notices: ChannelNotice[] = [{ + text: (cause === 'subscription-policy' ? `[channels] Selected for opening by subscription policy: ${shown}. ` + 'This records the admission decision; channel_list shows current transport state.' - : `[channels] Now open: ${shown}: your own message into the closed channel opened it.`) + optOut; - if (agentName) target = { forAgent: agentName }; - told = agentName ?? 'primary'; - } - try { - // Through framework.addMessage for mid-turn safety (deferral + - // hear-while-acting injection) — a delivery-forced open happens inside - // a live turn, and a direct context-manager append there would store - // the notice out of order. - this.addMessage('user', [{ type: 'text', text }], metadata, target); - console.error(`[channel] auto-open notice (${cause}) -> ${told}: ${shown}`); - } catch (err) { - console.error('recordChannelAutoOpenNotice failed:', err); + : `[channels] Now open: ${shown}: your own message into the closed channel opened it.`) + optOut(canClose), + metadata: { + system: true, kind: 'channel-notice', + ...(agentName && canClose ? { passOn: { notice: 'channel-opened', cause, channels } satisfies ChannelNoticePassOn } : {}), + } as MessageMetadata, + ...(agentName ? { target: { forAgent: agentName } } : {}), + told: agentName ?? 'primary', + shown, + }]; + // Where the agent whose send opened them can't close them, as a fork away + // from its home can't, or an agent whose allowedTools leave channel_close + // out, the primary is told by name too, where it's registered and can + // close them, so an agent that can close the channels hears they opened. + // (The primary can't be that agent: it can close them, and that agent + // can't.) + if (agentName && !canClose && primary !== undefined && this.agents.has(primary) && + this.canCloseChannels(primary, ids)) { + notices.push({ + text: `[channels] Now open: ${shown}: opened by ${agentName}'s reply; ${agentName} can't close ${plural ? 'them' : 'it'}.` + + optOut(true), + metadata: { system: true, kind: 'channel-notice', speaker: agentName } as MessageMetadata, + told: `the primary, by name (${agentName} can't close ${plural ? 'them' : 'it'})`, + shown, + }); } + return notices; + } + + /** + * Whether `who` can call channel_close for every one of `channelIds` here: + * its allowedTools hold it (canAgentUseTool), and a conversation fork, + * whose channel_close dispatchChannelToolCall refuses for any channel but + * its home, only for its home. + */ + private canCloseChannels(who: string | undefined, channelIds: readonly string[]): boolean { + if (who === undefined) return false; + const home = this.conversationAgentHomes.get(who); + return channelIds.every((id) => !home || home === id) && this.canAgentUseTool(who, 'channel_close'); } /** @@ -15069,88 +15206,102 @@ export class AgentFramework { * default put back, the host tries again itself, so channel_open isn't * named. Either way channel_close withdraws that, named only to an agent * that can call it here (a fork may close only its home channel). Where - * the reader can't, the primary is told by name too, if it's registered, - * isn't the reader and can close it. System message, as the opened notice - * is. + * the reader can't, the primary is told by name too, if it's registered + * and can close it. System message, as the opened notice is. */ private recordReplyOpenFailedNotice( agentName: string | undefined, channel: { channelId: string; label?: string }, selectedForOpening?: SelectedForOpening, ): void { + for (const notice of this.replyOpenFailedNotices(agentName, channel, selectedForOpening)) { + try { + this.addMessage('user', [{ type: 'text', text: notice.text }], notice.metadata, notice.target); + console.error(`[channel] reply open-failed notice -> ${notice.told}: ${notice.shown}`); + } catch (err) { + console.error('recordReplyOpenFailedNotice failed:', err); + } + } + } + + /** + * The notices recordReplyOpenFailedNotice writes, in order: the reader's, + * and the primary's by name where the reader can't close a channel selected + * for opening. None, logged, where the agent has ended and the channel + * stays closed, or there's no primary to tell. The reader's carries its + * inputs (`passOn`) where it's addressed to the agent whose reply it was, + * the channel is selected for opening and that agent was told it can close + * it, so a release that finds it unread can tell the primary in its place + * (channelNoticeForPrimary). + */ + private replyOpenFailedNotices( + agentName: string | undefined, + channel: { channelId: string; label?: string }, + selectedForOpening?: SelectedForOpening, + ): ChannelNotice[] { const shown = channel.label && channel.label !== channel.channelId ? `${channel.label} (${channel.channelId})` : channel.channelId; const ended = !!agentName && !this.agents.has(agentName); if (ended && !selectedForOpening) { console.error(`[channel] ${agentName} has ended; not telling it that ${shown} couldn't be opened`); - return; + return []; } const primary = this.primaryAgentName ?? undefined; if (ended && (!primary || !this.agents.has(primary))) { console.error(`[channel] ${agentName} has ended, and there is no primary to tell that ${shown} couldn't be opened and is selected for opening`); - return; + return []; } const who = ended ? primary : agentName || this.deliveryAgent()?.name; const canOpen = who !== undefined && !this.conversationAgentHomes.has(who) && this.canAgentUseTool(who, 'channel_open'); - // A fork's channel_close is refused for any channel but its home, as - // dispatchChannelToolCall reads it. - const home = who !== undefined ? this.conversationAgentHomes.get(who) : undefined; - const canClose = who !== undefined && (!home || home === channel.channelId) && - this.canAgentUseTool(who, 'channel_close'); + const canClose = this.canCloseChannels(who, [channel.channelId]); const selected = selectedForOpening === 'unrecorded' ? " It stays selected for opening, as the host couldn't record undoing that: a later delivery or reconnect tries again" : selectedForOpening !== undefined ? ` It's selected for opening again, as ${selectedForOpening === 'server-bootstrap' ? 'its server announces it open' : 'the subscription policy admits it'}: the host tries again` : ''; const hostRetries = selectedForOpening === 'server-bootstrap' || selectedForOpening === 'subscription-policy'; - const text = `[channels] Not open: ${shown}. ` + - (ended - ? `${agentName}'s reply there went out, but opening the channel failed; ${agentName} has ended.` - : 'Your reply there went out, but opening the channel failed.') + - (selected ? selected + (canClose ? ', and channel_close withdraws that.' : '.') : '') + - (canOpen && !hostRetries ? ' channel_open tries again.' : ''); - try { - this.addMessage( - 'user', - [{ type: 'text', text }], + const notices: ChannelNotice[] = [{ + text: `[channels] Not open: ${shown}. ` + (ended - ? { system: true, kind: 'channel-notice', speaker: agentName } - : { system: true, kind: 'channel-notice' }) as MessageMetadata, - agentName && !ended ? { forAgent: agentName } : undefined, - ); - console.error( - `[channel] reply open-failed notice -> ${ended ? `the primary, by name (${agentName} has ended)` : who ?? 'no agent'}: ${shown}`, - ); - } catch (err) { - console.error('recordReplyOpenFailedNotice failed:', err); - } + ? `${agentName}'s reply there went out, but opening the channel failed; ${agentName} has ended.` + : 'Your reply there went out, but opening the channel failed.') + + (selected ? selected + (canClose ? ', and channel_close withdraws that.' : '.') : '') + + (canOpen && !hostRetries ? ' channel_open tries again.' : ''), + metadata: (ended + ? { system: true, kind: 'channel-notice', speaker: agentName } + : { + system: true, kind: 'channel-notice', + ...(agentName && selectedForOpening !== undefined && canClose + ? { passOn: { notice: 'reply-open-failed', channel, selectedForOpening } satisfies ChannelNoticePassOn } + : {}), + }) as MessageMetadata, + ...(agentName && !ended ? { target: { forAgent: agentName } } : {}), + told: ended ? `the primary, by name (${agentName} has ended)` : who ?? 'no agent', + shown, + }]; // Where the reader can't close a channel selected for opening, as a fork // away from its home can't, or an agent whose allowedTools leave // channel_close out, the primary is told by name too, where it's // registered and can close it, so an agent that could close the channel - // hears it may open. - const primaryHome = primary !== undefined ? this.conversationAgentHomes.get(primary) : undefined; + // hears it may open. (The primary can't be the reader: it can close it, + // and the reader can't.) if ( !ended && selectedForOpening !== undefined && who !== undefined && !canClose && - primary !== undefined && primary !== who && this.agents.has(primary) && - (!primaryHome || primaryHome === channel.channelId) && this.canAgentUseTool(primary, 'channel_close') + primary !== undefined && this.agents.has(primary) && + this.canCloseChannels(primary, [channel.channelId]) ) { const primaryCanOpen = !this.conversationAgentHomes.has(primary) && this.canAgentUseTool(primary, 'channel_open'); - const told = `[channels] Not open: ${shown}. ${who}'s reply there went out, but opening the channel failed, and ${who} can't close it.` + - selected + ', and channel_close withdraws that.' + - (primaryCanOpen && !hostRetries ? ' channel_open tries again.' : ''); - try { - this.addMessage( - 'user', - [{ type: 'text', text: told }], - { system: true, kind: 'channel-notice', speaker: who } as MessageMetadata, - ); - console.error(`[channel] reply open-failed notice -> the primary, by name (${who} can't close it): ${shown}`); - } catch (err) { - console.error('recordReplyOpenFailedNotice failed:', err); - } + notices.push({ + text: `[channels] Not open: ${shown}. ${who}'s reply there went out, but opening the channel failed, and ${who} can't close it.` + + selected + ', and channel_close withdraws that.' + + (primaryCanOpen && !hostRetries ? ' channel_open tries again.' : ''), + metadata: { system: true, kind: 'channel-notice', speaker: who } as MessageMetadata, + told: `the primary, by name (${who} can't close it)`, + shown, + }); } + return notices; } /** @@ -21377,9 +21528,10 @@ export class AgentFramework { * null when the agent is no longer registered (a fork or a run already * released): there is no window to write, so the caller writes nothing, * traces nothing and requests no wake. The drop is logged here, once. A - * failed reply is passed on by name instead (passFailedReplyToPrimary). The - * notice of a channel an agent's own send opened follows the same rule in - * recordChannelAutoOpenNotice, which passes it on by name too. + * failed reply is passed on by name instead (passNoticeToPrimary). The + * notices of a channel an agent's own send opened, or a reply couldn't + * open, follow the same rule in their builders (channelOpenedNotices, + * replyOpenFailedNotices), which pass them on by name too. */ private noticeTargetFor(agentName: string, notice: string): { forAgent: string } | null { if (this.agents.has(agentName)) return { forAgent: agentName }; @@ -21393,10 +21545,11 @@ export class AgentFramework { * (releaseWindowNowOrAfterResume), since a write could land mid-surgery. * * The agent never reads its window again, so every failed-reply notice it - * never read is passed to the primary, by name, once: each one stored in - * its window above its consumed watermark (flushed there at its turn's end, - * or stored while it was idle) and not heard at a tool boundary, and each - * one still queued for it. Writes still + * never read, and every channel notice it could act on and never read, is + * passed to the primary, by name, once (passesToPrimary): each one stored + * in its window above its consumed watermark (flushed there at its turn's + * end, or stored while it was idle) and not heard at a tool boundary, and + * each one still queued for it. Writes still * queued would wait for a boundary it never reaches, and the flush that * finds it gone would drop them, so they land in its own window now, which * Chronicle keeps for investigation. Its stream is over or cancelled, so it @@ -21408,7 +21561,7 @@ export class AgentFramework { * prepared. */ private releaseWindow(agent: Agent): void { - for (const metadata of this.unreadFailedReplies(agent)) this.passFailedReplyToPrimary(agent.name, metadata); + for (const metadata of this.unreadNoticesForPrimary(agent)) this.passNoticeToPrimary(agent.name, metadata); const queued = this.drainDeferredFor(agent.name); if (queued.length === 0) return; const cm = agent.getContextManager(); @@ -21423,11 +21576,12 @@ export class AgentFramework { err, ); } - // A failed reply, now in a window no one reads: the entry itself goes - // on to the primary as its by-name notice, under its own id and seq, so - // a crash before the ack below replays it once, not twice. - if ((msg.metadata as { kind?: unknown } | undefined)?.kind !== 'discord-send-failed') continue; - const passed = this.queuedFailedReplyForPrimary(agent.name, msg); + // A failed reply, or a channel notice its reader could act on, now in a + // window no one reads: the entry itself goes on to the primary as its + // by-name notice, under its own id and seq, so a crash before the ack + // below replays it once, not twice. + if (!passesToPrimary(msg.metadata)) continue; + const passed = this.queuedNoticeForPrimary(agent.name, msg); if (passed) forPrimary.push(passed); } if (forPrimary.length > 0) this.readdDrained(forPrimary, `release of ${agent.name}`); @@ -21435,17 +21589,18 @@ export class AgentFramework { } /** - * The failed-reply notices stored in `agent`'s window that it never read: - * those above the lower of its consumed watermark and the point where the - * watermark's branch and the current one part (branchesPartAt), or every - * one, when it never compiled. The watermark moves at a compile, and at a - * tool boundary whose + * The notices stored in `agent`'s window that it never read and the primary + * takes in its place (passesToPrimary): its failed replies, and the channel + * notices it was told it could act on. Those are the ones above the lower + * of its consumed watermark and the point where the watermark's branch and + * the current one part (branchesPartAt), or every one, when it never + * compiled. The watermark moves at a compile, and at a tool boundary whose * stream declares it carries the messages injected there * (markContextConsumed), so a notice the live stream heard is under it, and * one a boundary stored without sending (a turn a tool ended, a budget * restart, a stream that may drop injections) is above it. */ - private unreadFailedReplies(agent: Agent): MessageMetadata[] { + private unreadNoticesForPrimary(agent: Agent): MessageMetadata[] { try { const cm = agent.getContextManager(); const watermark = agent.getConsumedWatermark(); @@ -21453,14 +21608,11 @@ export class AgentFramework { ? Math.min(watermark.sequence, this.branchesPartAt(watermark.branch, cm.currentBranch().name)) : Number.NEGATIVE_INFINITY; return cm.getAllMessages() - .filter((m) => { - const metadata = m.metadata as { kind?: unknown } | undefined; - return m.sequence > floor && metadata?.kind === 'discord-send-failed'; - }) + .filter((m) => m.sequence > floor && passesToPrimary(m.metadata)) .map((m) => m.metadata as MessageMetadata); } catch (err) { // A release must complete whatever its window's read meets. - console.error(`[route-failure] ${agent.name}: could not read its window for unread failed replies:`, err); + console.error(`[route-failure] ${agent.name}: could not read its window for unread notices the primary would take:`, err); return []; } } @@ -21505,7 +21657,8 @@ export class AgentFramework { * deregistered, so its stream can't dispatch anything more and resume * doesn't weigh its context, and a write addressed to it meanwhile is * treated as for any agent no longer registered (dropped, or passed to the - * primary if it's a failed reply). + * primary if it's a failed reply, or a channel notice its reader could act + * on). */ private releaseWindowNowOrAfterResume(agent: Agent): void { if (this.quiesced) this.releasesAfterResume.set(agent.name, agent); @@ -21526,47 +21679,82 @@ export class AgentFramework { } /** - * A fork or an ephemeral run ended before reading that its reply's delivery - * to a channel failed: it was released with the notice stored in its window - * unread, or its delivery failed after release. (A notice still queued for - * it, at release or restored at boot, goes on as the queue entry itself: - * queuedFailedReplyForPrimary.) Tell the primary instead, naming the - * speaker, so an agent who can still act learns of it and can tell the - * human. It gives the reply's length, not its words, which stay in the - * speaker's window. Its wording follows the speaker's own notice - * (onRouteFailure): a delivery that wasn't confirmed may have been posted, - * and neither text says no one saw the reply, since only this channel's - * routing failed. Only for a delivery to a channel; a reply that had no - * channel to go to (no locus) had no human waiting for it. Never throws: - * it runs inside a release, which must complete whatever this write meets. + * A fork or an ephemeral run ended before reading a notice the primary + * takes in its place (passesToPrimary): it was released with the notice + * stored in its window unread, or, for a failed reply, its delivery failed + * after release. (A notice still queued for it, at release or restored at + * boot, goes on as the queue entry itself: queuedNoticeForPrimary.) Tell + * the primary instead, naming the speaker, so an agent who can still act + * learns of it (noticeForPrimary). Never throws: it runs inside a release, + * which must complete whatever this write meets. */ - private passFailedReplyToPrimary(speaker: string, metadata: MessageMetadata | undefined): void { - const notice = this.failedReplyNoticeForPrimary(speaker, metadata); + private passNoticeToPrimary(speaker: string, metadata: MessageMetadata | undefined): void { + const notice = this.noticeForPrimary(speaker, metadata); if (!notice) return; try { this.addMessage('user', notice.content, notice.metadata); } catch (err) { - console.error(`[route-failure] failed to tell the primary that ${speaker}'s reply to ${String(notice.metadata.channelId)} failed:`, err); + console.error(`[route-failure] failed to tell the primary ${notice.about}:`, err); } } /** - * A queued failed-reply entry for `speaker`, who is gone, as the primary's - * by-name notice (passFailedReplyToPrimary's words): the same entry, under - * its own id and seq, now addressed to the primary. Written through the - * drain, it lands stamped with that id, or waits in the queue under it, so - * boot's exactly-once holds for it: a crash before the original's ack - * replays one entry, never the original and a separate notice. null when no - * one is told (logged): a reply that had no channel to reach, or no primary. + * A queued entry for `speaker`, who is gone, that the primary takes in its + * place, as the primary's by-name notice (noticeForPrimary): the same + * entry, under its own id and seq, now addressed to the primary. Written + * through the drain, it lands stamped with that id, or waits in the queue + * under it, so boot's exactly-once holds for it: a crash before the + * original's ack replays one entry, never the original and a separate + * notice. null when no one is told (logged). */ - private queuedFailedReplyForPrimary(speaker: string, msg: DeferredWrite): DeferredWrite | null { - const notice = this.failedReplyNoticeForPrimary(speaker, msg.metadata); + private queuedNoticeForPrimary(speaker: string, msg: DeferredWrite): DeferredWrite | null { + const notice = this.noticeForPrimary(speaker, msg.metadata); if (!notice) return null; return { id: msg.id, seq: msg.seq, participant: 'user', content: notice.content, metadata: notice.metadata, heldAt: msg.heldAt }; } + /** + * The primary's by-name notice in place of one `speaker` never read, and + * what it's about, as a log line says it: a failed reply's + * (failedReplyNoticeForPrimary) or a channel notice's + * (channelNoticeForPrimary). null when no one is told, which is logged + * where it's decided. + */ + private noticeForPrimary( + speaker: string, + metadata: MessageMetadata | undefined, + ): { content: ContentBlock[]; metadata: MessageMetadata; about: string } | null { + const kind = (metadata as { kind?: unknown } | undefined)?.kind; + if (kind === 'discord-send-failed') { + const notice = this.failedReplyNoticeForPrimary(speaker, metadata); + return notice && { ...notice, about: `that ${speaker}'s reply to ${String(notice.metadata.channelId)} failed` }; + } + const passOn = channelNoticePassOn(metadata); + if (!passOn) return null; + const notice = this.channelNoticeForPrimary(speaker, passOn); + return notice && { ...notice, about: `of ${speaker}'s channel notice` }; + } + + /** + * The primary's by-name notice in place of a channel notice `speaker` was + * told it could act on and never read: its builder, called again with the + * same inputs now that `speaker` has ended, gives the words it gives an + * agent that has ended, judged for the primary, or nothing, logged, with no + * primary to tell. They speak of the open or the failure as it was, as the + * notice did. + */ + private channelNoticeForPrimary( + speaker: string, + passOn: ChannelNoticePassOn, + ): { content: ContentBlock[]; metadata: MessageMetadata } | null { + const [notice] = passOn.notice === 'channel-opened' + ? this.channelOpenedNotices(speaker, passOn.channels, passOn.cause) + : this.replyOpenFailedNotices(speaker, passOn.channel, passOn.selectedForOpening); + return notice ? { content: [{ type: 'text', text: notice.text }], metadata: notice.metadata } : null; + } + /** The primary's by-name notice of `speaker`'s failed reply, or null when no - * one is told, which is logged here (see passFailedReplyToPrimary). */ + * one is told, which is logged here (see passNoticeToPrimary). */ private failedReplyNoticeForPrimary( speaker: string, metadata: MessageMetadata | undefined, @@ -21644,8 +21832,8 @@ export class AgentFramework { * slot (sharedSlotAgents). A write whose target is gone isn't among them: * no module reads that agent's store, and the write waits for its window's * release (drainDeferredFor) or, at its flush, is dropped (addMessage), a - * failed reply passing to the primary as its notice - * (queuedFailedReplyForPrimary). + * failed reply, or a channel notice its reader could act on, passing to the + * primary as its notice (queuedNoticeForPrimary). */ private heldWritesFor(reader: Agent): DeferredWrite[] { return this.deferredMessages @@ -22563,7 +22751,7 @@ export class AgentFramework { }; if (!this.agents.has(conversationId)) { // Released before its delivery failed: no window left to read it. - this.passFailedReplyToPrimary(conversationId, metadata); + this.passNoticeToPrimary(conversationId, metadata); return; } // No-locus failures (headless / WebUI turns with no home or diff --git a/test/channel-lifecycle-order.test.ts b/test/channel-lifecycle-order.test.ts index 81b624266..39359def5 100644 --- a/test/channel-lifecycle-order.test.ts +++ b/test/channel-lifecycle-order.test.ts @@ -2340,7 +2340,9 @@ test("where a reply's reader can't close a channel selected for opening, the pri ] }, { why: 'a reader who can close it, beside a primary that can too', name: 'helper', agents: [['helper', []], ['primary', []]], primary: 'primary', selectedForOpening: 'server-bootstrap', - told: [{ text: facts + again + ', and channel_close withdraws that.', metadata: { system: true, kind: 'channel-notice' }, forAgent: 'helper' }] }, + // Its inputs, so a release that finds it unread tells the primary. + told: [{ text: facts + again + ', and channel_close withdraws that.', forAgent: 'helper', metadata: { system: true, kind: 'channel-notice', + passOn: { notice: 'reply-open-failed', channel: { channelId: 'x', label: '#x' }, selectedForOpening: 'server-bootstrap' } } }] }, { why: "a primary that can close it but not open it, the put-back unrecorded", name: 'fork', agents: [['fork', []], ['primary', ['channel_open']]], homes: [['fork', 'y']], primary: 'primary', selectedForOpening: 'unrecorded', told: [ @@ -2384,6 +2386,128 @@ test("where a reply's reader can't close a channel selected for opening, the pri } }); +// The opened notice follows the reply notice's rule: channel_close is named +// only to a reader that can call it for every channel named, and where an +// agent's send opened a channel it can't close, the primary is told by name +// too, if it can close it. The reader's notice carries its inputs only where +// it was told it can close the channels, so a release passes on only that. +test('the opened notice names channel_close only to a reader that can call it, and tells the primary by name where the agent whose send opened it can\'t', () => { + const optOut = ' What an open channel delivers to you depends on its server.'; + const close = " If you don't want it, use channel_close; your choice outranks policy."; + const closeMany = " If you don't want one of them, use channel_close; your choice outranks policy."; + const own = '[channels] Now open: #x (x): your own message into the closed channel opened it.'; + const opened = (passOn: unknown) => ({ system: true, kind: 'channel-notice', passOn }); + const fromReply = { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: 'x', label: '#x' }] }; + const cases: Array<{ + why: string; name?: string; cause?: 'opened-by-reply' | 'subscription-policy'; channels?: Array<{ channelId: string; label?: string }>; + agents: Array<[string, string[]]>; homes?: Array<[string, string]>; primary: string | null; + told: Array<{ text: string; metadata: unknown; forAgent?: string }>; + }> = [ + { why: 'a reader that can close it', name: 'helper', agents: [['helper', []], ['primary', []]], primary: 'primary', + told: [{ text: own + optOut + close, metadata: opened(fromReply), forAgent: 'helper' }] }, + { why: 'a fork away from its home', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'y']], primary: 'primary', + told: [ + { text: own + optOut, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, + { text: "[channels] Now open: #x (x): opened by fork's reply; fork can't close it." + optOut + close, + metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, + ] }, + { why: 'a fork at its home', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'x']], primary: 'primary', + told: [{ text: own + optOut + close, metadata: opened(fromReply), forAgent: 'fork' }] }, + { why: 'an agent whose allowedTools leave channel_close out', name: 'helper', agents: [['helper', ['channel_close']], ['primary', []]], primary: 'primary', + told: [ + { text: own + optOut, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'helper' }, + { text: "[channels] Now open: #x (x): opened by helper's reply; helper can't close it." + optOut + close, + metadata: { system: true, kind: 'channel-notice', speaker: 'helper' } }, + ] }, + { why: "a primary that can't close it either", name: 'fork', agents: [['fork', []], ['primary', ['channel_close']]], homes: [['fork', 'y']], primary: 'primary', + told: [{ text: own + optOut, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }] }, + { why: 'a primary named but not registered', name: 'fork', agents: [['fork', []], ['other', []]], homes: [['fork', 'y']], primary: 'gone', + told: [{ text: own + optOut, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }] }, + { why: 'the primary itself, without channel_close', name: 'primary', agents: [['primary', ['channel_close']]], primary: 'primary', + told: [{ text: own + optOut, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'primary' }] }, + { why: 'several channels, one away from a fork\'s home', name: 'fork', channels: [{ channelId: 'y', label: '#y' }, { channelId: 'x', label: '#x' }], + agents: [['fork', []], ['primary', []]], homes: [['fork', 'y']], primary: 'primary', + told: [ + { text: '[channels] Now open: #y (y), #x (x): your own message into the closed channel opened it.' + optOut, + metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, + { text: "[channels] Now open: #y (y), #x (x): opened by fork's reply; fork can't close them." + optOut + closeMany, + metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, + ] }, + { why: 'a policy admission, to the primary that can close it', cause: 'subscription-policy', agents: [['primary', []]], primary: 'primary', + // Addressed to no agent of its own, so it carries no inputs to pass on. + told: [{ text: '[channels] Selected for opening by subscription policy: #x (x). This records the admission decision; channel_list shows current transport state.' + optOut + close, + metadata: { system: true, kind: 'channel-notice' } }] }, + { why: "a policy admission, to a primary that can't close it", cause: 'subscription-policy', agents: [['primary', ['channel_close']]], primary: 'primary', + told: [{ text: '[channels] Selected for opening by subscription policy: #x (x). This records the admission decision; channel_list shows current transport state.' + optOut, + metadata: { system: true, kind: 'channel-notice' } }] }, + { why: "an agent that has ended, with a primary that can't close it", name: 'gone', agents: [['primary', ['channel_close']]], primary: 'primary', + told: [{ text: "[channels] Now open: #x (x): opened by gone's reply; gone has ended." + optOut, + metadata: { system: true, kind: 'channel-notice', speaker: 'gone' } }] }, + ]; + for (const c of cases) { + const told: Array<{ text: string; metadata: unknown; forAgent?: string }> = []; + const logged: string[] = []; + const framework = Object.create(AgentFramework.prototype) as any; + framework.agents = new Map(c.agents.map(([name, denied]) => [name, { name, canUseTool: (tool: string) => !denied.includes(tool) }])); + framework.primaryAgentName = c.primary; + framework.usedEphemeralAgentNames = new Set(); + framework.conversationAgentHomes = new Map(c.homes ?? []); + framework.addMessage = (_participant: string, content: Array<{ text: string }>, metadata: unknown, routed?: { forAgent?: string }) => { + told.push({ text: content[0]!.text, metadata, ...(routed ? { forAgent: routed.forAgent } : {}) }); + return 'm'; + }; + const error = console.error; + console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; + try { + framework.recordChannelAutoOpenNotice(c.name, c.channels ?? [{ channelId: 'x', label: '#x' }], c.cause ?? 'opened-by-reply'); + } finally { + console.error = error; + } + assert.deepEqual(told, c.told, c.why); + if (c.told.length === 2) { + assert.ok(logged.some((l) => l.startsWith(`[channel] auto-open notice (opened-by-reply) -> the primary, by name (${c.name} can't close`)), `${c.why}: the log says why`); + } + } +}); + +// A release finds a channel notice its reader could act on unread: the +// primary is told in its place, in the words the notice's builder gives an +// agent that has ended, judged for the primary. A notice whose inputs a store +// kept from another build, or that carries none, isn't passed on. +test("a channel notice's inputs give the primary's by-name notice once its reader has ended, and malformed ones give none", () => { + const framework = Object.create(AgentFramework.prototype) as any; + framework.agents = new Map([['primary', { name: 'primary', canUseTool: (tool: string) => tool !== 'channel_open' }]]); + framework.primaryAgentName = 'primary'; + framework.usedEphemeralAgentNames = new Set(); + framework.conversationAgentHomes = new Map(); + const error = console.error; + console.error = () => {}; + try { + const reply = framework.noticeForPrimary('gone', { system: true, kind: 'channel-notice', + passOn: { notice: 'reply-open-failed', channel: { channelId: 'x', label: '#x' }, selectedForOpening: 'unrecorded' } }); + assert.deepEqual(reply?.content, [{ type: 'text', text: + "[channels] Not open: #x (x). gone's reply there went out, but opening the channel failed; gone has ended. " + + "It stays selected for opening, as the host couldn't record undoing that: a later delivery or reconnect tries again, and channel_close withdraws that." }], + "judged for the primary, which can close it and can't call channel_open"); + assert.deepEqual(reply?.metadata, { system: true, kind: 'channel-notice', speaker: 'gone' }); + for (const passOn of [ + undefined, + { notice: 'channel-opened', cause: 'opened-by-reply', channels: [] }, + { notice: 'channel-opened', cause: 'opened-by-whim', channels: [{ channelId: 'x' }] }, + { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: '' }] }, + { notice: 'reply-open-failed', channel: { channelId: 'x' } }, + { notice: 'reply-open-failed', channel: { channelId: 'x' }, selectedForOpening: 'maybe' }, + { notice: 'something-else' }, + ]) { + assert.equal(framework.noticeForPrimary('gone', { system: true, kind: 'channel-notice', ...(passOn ? { passOn } : {}) }), null, JSON.stringify(passOn)); + } + assert.equal(framework.noticeForPrimary('gone', { system: true, kind: 'routing-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: 'x' }] } }), null, + 'only a channel notice'); + } finally { + console.error = error; + } +}); + test("a reply's open that only reconfirms a tuned-out channel's transport, and fails, isn't one that would have opened it", async () => { const f = fixture(); await f.registry.handleChanged('test', { added: [descriptor('x', true, 'A')] }); diff --git a/test/route-failure-targeting.test.ts b/test/route-failure-targeting.test.ts index d84be3ea5..7aefe75e4 100644 --- a/test/route-failure-targeting.test.ts +++ b/test/route-failure-targeting.test.ts @@ -637,16 +637,82 @@ describe('notices about an agent\'s own action', () => { const primary = t.mock.method(framework.getAgent('primary')!.getContextManager(), 'addMessage'); const forkCm = t.mock.method(fork.getContextManager(), 'addMessage'); - state.recordChannelAutoOpenNotice(fork.name, [{ channelId: 'c-1', label: '#one' }], 'opened-by-reply'); + // The fork's own home channel, which it can close. + state.recordChannelAutoOpenNotice(fork.name, [{ channelId: 'missing-channel', label: '#home' }], 'opened-by-reply'); assert.equal(forkCm.mock.callCount(), 1); assert.equal(primary.mock.callCount(), 0); - assert.match(JSON.stringify(forkCm.mock.calls[0]!.arguments[1]), /Now open: #one \(c-1\): your own message into the closed channel opened it/); + assert.match(JSON.stringify(forkCm.mock.calls[0]!.arguments[1]), + /Now open: #home \(missing-channel\): your own message into the closed channel opened it\. .*use channel_close/); state.recordChannelAutoOpenNotice(undefined, [{ channelId: 'c-2' }], 'subscription-policy'); assert.equal(primary.mock.callCount(), 1, 'no agent caused a policy open'); assert.equal(forkCm.mock.callCount(), 1); }); + it("tells a fork whose send opened a channel away from its home that it opened, without channel_close, which it can't call there, and tells the primary by name, which can", async (t) => { + const fork = await spawnFork(); + const state = internals(framework); + const primary = t.mock.method(framework.getAgent('primary')!.getContextManager(), 'addMessage'); + const forkCm = t.mock.method(fork.getContextManager(), 'addMessage'); + + state.recordChannelAutoOpenNotice(fork.name, [{ channelId: 'c-1', label: '#one' }], 'opened-by-reply'); + + assert.equal(forkCm.mock.callCount(), 1); + assert.equal(JSON.stringify(forkCm.mock.calls[0]!.arguments[1]), JSON.stringify([{ type: 'text', text: + '[channels] Now open: #one (c-1): your own message into the closed channel opened it. What an open channel delivers to you depends on its server.' }])); + assert.equal(primary.mock.callCount(), 1, 'the primary is told too'); + assert.equal(JSON.stringify(primary.mock.calls[0]!.arguments[1]), JSON.stringify([{ type: 'text', text: + `[channels] Now open: #one (c-1): opened by ${fork.name}'s reply; ${fork.name} can't close it. ` + + "What an open channel delivers to you depends on its server. If you don't want it, use channel_close; your choice outranks policy." }])); + assert.equal((primary.mock.calls[0]!.arguments[2] as { speaker?: unknown } | undefined)?.speaker, fork.name); + + // The advice left out is advice the fork can't take: its channel_close + // there is refused. + const results: Array<{ success?: boolean; error?: string }> = []; + const push = framework.pushEvent.bind(framework); + (framework as unknown as { pushEvent: (e: { type: string; result?: { success?: boolean; error?: string } }) => void }).pushEvent = (e) => { + if (e.type === 'tool-result') results.push(e.result!); else push(e as never); + }; + try { + (framework as unknown as { dispatchChannelToolCall(agent: string, call: { id: string; name: string; input: Record }): void }) + .dispatchChannelToolCall(fork.name, { id: 'close-away', name: 'channel_close', input: { channelId: 'c-1' } }); + for (let i = 0; i < 50 && results.length === 0; i++) await new Promise((resolve) => setImmediate(resolve)); + } finally { + (framework as unknown as { pushEvent: unknown }).pushEvent = push; + } + assert.equal(results[0]?.success, false); + assert.match(results[0]?.error ?? '', /closing c-1 is not allowed/); + }); + + it('tells the primary, by name, of a channel notice a fork could act on and never read, when the fork is released', async () => { + const fork = await spawnFork(); + const state = internals(framework); + state.recordChannelAutoOpenNotice(fork.name, [{ channelId: 'missing-channel', label: '#home' }], 'opened-by-reply'); + + reap(fork.name); + + const told = framework.getAgent('primary')!.getContextManager().getAllMessages() + .filter((m) => JSON.stringify(m.content).includes('#home')); + assert.equal(told.length, 1, 'the primary is told once'); + assert.equal((told[0]!.content[0] as { text: string }).text, + `[channels] Now open: #home (missing-channel): opened by ${fork.name}'s reply; ${fork.name} has ended. ` + + "What an open channel delivers to you depends on its server. If you don't want it, use channel_close; your choice outranks policy."); + assert.equal((told[0]!.metadata as { speaker?: unknown } | undefined)?.speaker, fork.name); + }); + + it("passes on no channel notice its reader couldn't act on: the primary was told then, once", async () => { + const fork = await spawnFork(); + const state = internals(framework); + state.recordChannelAutoOpenNotice(fork.name, [{ channelId: 'c-1', label: '#one' }], 'opened-by-reply'); + + reap(fork.name); + + const told = framework.getAgent('primary')!.getContextManager().getAllMessages() + .filter((m) => JSON.stringify(m.content).includes('#one')); + assert.equal(told.length, 1, 'only the notice of the time'); + assert.match(JSON.stringify(told[0]!.content), new RegExp(`${fork.name} can't close it`)); + }); + it('tells the primary, by name, of a channel an ended agent\'s send opened, and the primary\'s channel_close closes it', async (t) => { const state = internals(framework); const channels = (state.channelRegistry as unknown as { channels: Map }).channels; @@ -736,6 +802,47 @@ describe('notices about an agent\'s own action', () => { assert.equal(state.deferredMessages.length, 0); }); + it('passes a restored channel notice its gone reader could act on to the primary, by name, under its own id; one it couldn\'t is dropped, logged', async (t) => { + const state = internals(framework); + const errors: string[] = []; + t.mock.method(console, 'error', (...args: unknown[]) => { errors.push(args.map(String).join(' ')); }); + (state.deferredMessages as unknown as Array>).push( + { + id: 'w-4', seq: 4, participant: 'user', forAgent: 'conversation-gone', + content: [{ type: 'text', text: '[channels] Now open: #four (c-4): your own message into the closed channel opened it.' }], + metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: 'c-4', label: '#four' }] } }, + }, + { + id: 'w-5', seq: 5, participant: 'user', forAgent: 'conversation-gone', + content: [{ type: 'text', text: '[channels] Not open: #five (c-5). Your reply there went out, but opening the channel failed.' }], + metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'reply-open-failed', channel: { channelId: 'c-5', label: '#five' }, selectedForOpening: 'server-bootstrap' } }, + }, + { + // Its reader couldn't act on it, or an earlier build stored it: no inputs. + id: 'w-6', seq: 6, participant: 'user', forAgent: 'conversation-gone', + content: [{ type: 'text', text: '[channels] Now open: #six (c-6): your own message into the closed channel opened it.' }], + metadata: { system: true, kind: 'channel-notice' }, + }, + ); + + await state.flushDeferredWrites('boot-recovery'); + + const told = framework.getAgent('primary')!.getContextManager().getAllMessages() + .filter((m) => JSON.stringify(m.content).includes('conversation-gone')); + assert.deepEqual(told.map((m) => (m.content[0] as { text: string }).text), [ + "[channels] Now open: #four (c-4): opened by conversation-gone's reply; conversation-gone has ended. " + + "What an open channel delivers to you depends on its server. If you don't want it, use channel_close; your choice outranks policy.", + "[channels] Not open: #five (c-5). conversation-gone's reply there went out, but opening the channel failed; conversation-gone has ended. " + + "It's selected for opening again, as its server announces it open: the host tries again, and channel_close withdraws that.", + ]); + assert.deepEqual(told.map((m) => (m.metadata as { deferredWriteId?: unknown; speaker?: unknown }).deferredWriteId), ['w-4', 'w-5']); + assert.deepEqual(told.map((m) => (m.metadata as { speaker?: unknown }).speaker), ['conversation-gone', 'conversation-gone']); + const all = framework.getAgent('primary')!.getContextManager().getAllMessages().map((m) => JSON.stringify(m.content)); + assert.equal(all.filter((c) => c.includes('#six')).length, 0); + assert.ok(errors.some((e) => /"conversation-gone" is not a registered agent — message dropped \(participant=user\)/.test(e))); + assert.equal(state.deferredMessages.length, 0); + }); + it('passes a restored failure notice on as the same queue entry while the primary is mid-turn: it waits under its own id', async () => { const state = internals(framework); (state.deferredMessages as unknown as Array>).push({ @@ -756,13 +863,13 @@ describe('notices about an agent\'s own action', () => { }); it('counts as read, across branches, only what an agent\'s window shares with the branch it read up to the point they part', async () => { - const state = internals(framework) as unknown as { unreadFailedReplies(agent: unknown): Array<{ tag?: unknown }> }; + const state = internals(framework) as unknown as { unreadNoticesForPrimary(agent: unknown): Array<{ tag?: unknown }> }; const agent = framework.getAgent('secondary')!; const cm = agent.getContextManager(); const store = framework.getStore(); const notice = (tag: string) => cm.getMessage(cm.addMessage('user', [{ type: 'text', text: `[discord-send-failed] ${tag}` }], { system: true, kind: 'discord-send-failed', channelId: 'missing-channel', tag } as never))!; - const tags = () => state.unreadFailedReplies(agent).map((m) => m.tag); + const tags = () => state.unreadNoticesForPrimary(agent).map((m) => m.tag); const read = notice('read'); agent.markContextConsumed(); @@ -782,13 +889,13 @@ describe('notices about an agent\'s own action', () => { }); it('counts as unread a notice stored on a branch after the agent read past the point where it parts from another', async () => { - const state = internals(framework) as unknown as { unreadFailedReplies(agent: unknown): Array<{ tag?: unknown }> }; + const state = internals(framework) as unknown as { unreadNoticesForPrimary(agent: unknown): Array<{ tag?: unknown }> }; const agent = framework.getAgent('secondary')!; const cm = agent.getContextManager(); const store = framework.getStore(); const notice = (tag: string) => cm.getMessage(cm.addMessage('user', [{ type: 'text', text: `[discord-send-failed] ${tag}` }], { system: true, kind: 'discord-send-failed', channelId: 'missing-channel', tag } as never))!; - const tags = () => state.unreadFailedReplies(agent).map((m) => m.tag); + const tags = () => state.unreadNoticesForPrimary(agent).map((m) => m.tag); notice('before the branch'); const home = store.currentBranch().name; @@ -806,13 +913,13 @@ describe('notices about an agent\'s own action', () => { }); it('counts as unread what the current branch has from a branch between it and the one the agent read on', async () => { - const state = internals(framework) as unknown as { unreadFailedReplies(agent: unknown): Array<{ tag?: unknown }> }; + const state = internals(framework) as unknown as { unreadNoticesForPrimary(agent: unknown): Array<{ tag?: unknown }> }; const agent = framework.getAgent('secondary')!; const cm = agent.getContextManager(); const store = framework.getStore(); const notice = (tag: string) => cm.getMessage(cm.addMessage('user', [{ type: 'text', text: `[discord-send-failed] ${tag}` }], { system: true, kind: 'discord-send-failed', channelId: 'missing-channel', tag } as never))!; - const tags = () => state.unreadFailedReplies(agent).map((m) => m.tag); + const tags = () => state.unreadNoticesForPrimary(agent).map((m) => m.tag); notice('read at home'); const home = store.currentBranch().name; @@ -833,13 +940,13 @@ describe('notices about an agent\'s own action', () => { }); it('counts as unread, across a branch made after the agent read, what was stored after its watermark', async () => { - const state = internals(framework) as unknown as { unreadFailedReplies(agent: unknown): Array<{ tag?: unknown }> }; + const state = internals(framework) as unknown as { unreadNoticesForPrimary(agent: unknown): Array<{ tag?: unknown }> }; const agent = framework.getAgent('secondary')!; const cm = agent.getContextManager(); const store = framework.getStore(); const notice = (tag: string) => cm.getMessage(cm.addMessage('user', [{ type: 'text', text: `[discord-send-failed] ${tag}` }], { system: true, kind: 'discord-send-failed', channelId: 'missing-channel', tag } as never))!; - const tags = () => state.unreadFailedReplies(agent).map((m) => m.tag); + const tags = () => state.unreadNoticesForPrimary(agent).map((m) => m.tag); notice('read'); agent.markContextConsumed(); @@ -1099,7 +1206,23 @@ describe('notices about an agent\'s own action', () => { assert.equal(count(messages, 'A reply by worker'), 1, 'and, still queued at the release, it goes on to the primary by name'); }); - it('passes a failed reply still queued when a run ends unquiesced on to the primary as the same queue entry', async () => { + const queuedForTheRun = [ + { + what: 'a failed reply', + content: [{ type: 'text', text: `${SEND_FAILED} Your previous reply (5 chars) could not be delivered to #x (missing-channel).` }], + metadata: { system: true, kind: 'discord-send-failed', channelId: 'missing-channel', reason: 'no registered channel', textLen: 5 }, + inRun: SEND_FAILED, + toPrimary: 'A reply by worker', + }, + { + what: 'a channel notice the run could act on', + content: [{ type: 'text', text: '[channels] Now open: #seven (c-7): your own message into the closed channel opened it.' }], + metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-delivery', channels: [{ channelId: 'c-7', label: '#seven' }] } }, + inRun: 'Now open: #seven', + toPrimary: "opened by worker's reply; worker has ended", + }, + ]; + for (const queued of queuedForTheRun) it(`passes ${queued.what} still queued when a run ends unquiesced on to the primary as the same queue entry`, async () => { let release!: () => void; const released = new Promise((resolve) => { release = resolve; }); runMembrane.streamYielding = (request) => { @@ -1115,12 +1238,10 @@ describe('notices about an agent\'s own action', () => { const done = runFramework.runEphemeralToCompletion(created.agent, created.contextManager, { idleTimeoutMs: 300, idlePollMs: 50 }); for (let i = 0; i < 400 && !state.activeStreams.has('worker'); i++) await new Promise((resolve) => setTimeout(resolve, 5)); assert.ok(state.activeStreams.has('worker'), 'the run\'s stream started'); - // A failed reply queued for the run's turn end, which the run's stall - // then ends first. + // A notice queued for the run's turn end, which the run's stall then + // ends first. (state.deferredMessages as unknown as Array>).push({ - id: 'w-late', seq: 1_000_000, participant: 'user', forAgent: 'worker', - content: [{ type: 'text', text: `${SEND_FAILED} Your previous reply (5 chars) could not be delivered to #x (missing-channel).` }], - metadata: { system: true, kind: 'discord-send-failed', channelId: 'missing-channel', reason: 'no registered channel', textLen: 5 }, + id: 'w-late', seq: 1_000_000, participant: 'user', forAgent: 'worker', content: queued.content, metadata: queued.metadata, }); await assert.rejects(done, /stalled/); release(); @@ -1131,11 +1252,11 @@ describe('notices about an agent\'s own action', () => { const archive = await ContextManager.open({ store: runFramework.getStore(), namespace: 'subagent/worker', isolate: true, strategy: new PassthroughStrategy(), }); - const record = archive.getAllMessages().filter((m) => JSON.stringify(m.content).includes(SEND_FAILED)); + const record = archive.getAllMessages().filter((m) => JSON.stringify(m.content).includes(queued.inRun)); assert.equal(record.length, 1, 'it landed in the run\'s window'); assert.equal(idOf(record[0]), 'w-late'); const told = runFramework.getAgent('primary')!.getContextManager().getAllMessages() - .filter((m) => JSON.stringify(m.content).includes('A reply by worker')); + .filter((m) => JSON.stringify(m.content).includes(queued.toPrimary)); assert.equal(told.length, 1, 'the primary is told once'); assert.equal(idOf(told[0]), 'w-late', 'as the same entry, under its id'); }); @@ -1213,7 +1334,7 @@ describe('notices about an agent\'s own action', () => { assert.equal(state.deferredMessages.length, 0); assert.equal(count(await archived(), 'Queued for the run.'), 1, 'what was queued landed in its window'); - assert.ok(errors.some((e) => e.includes('[route-failure] worker: could not read its window for unread failed replies'))); + assert.ok(errors.some((e) => e.includes('[route-failure] worker: could not read its window for unread notices the primary would take'))); }); it('resumes past a run that ended while the host was quiesced: resume doesn\'t weigh its context', async (t) => { diff --git a/test/stop-step-throws.test.ts b/test/stop-step-throws.test.ts index 2860d67b5..c2abe9c81 100644 --- a/test/stop-step-throws.test.ts +++ b/test/stop-step-throws.test.ts @@ -178,14 +178,14 @@ const ROWS: Row[] = [ // Other agents, such as the run whose end releases its window, read // their unread replies as usual. const owner = fw as unknown as Record; - const usual = (owner.unreadFailedReplies as (agent: unknown) => unknown[]).bind(fw); - owner.unreadFailedReplies = (agent: { name: string }) => { + const usual = (owner.unreadNoticesForPrimary as (agent: unknown) => unknown[]).bind(fw); + owner.unreadNoticesForPrimary = (agent: { name: string }) => { if (agent.name === 'first') throw e; if (agent.name !== 'second') return usual(agent); unreadPassed.push(agent.name); return []; }; - return () => { delete owner.unreadFailedReplies; }; + return () => { delete owner.unreadNoticesForPrimary; }; }, check: (fw) => { assert.deepEqual(unreadPassed, ['second'], "the other release's unread pass still ran"); From 95186372f04709a25e9dd399c2e04dce242947d8 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 19 Oct 2026 16:58:20 -0400 Subject: [PATCH 02/12] fix(channels): name the agent's own message in the primary's by-name notices, and pass a sharded notice on once - The two notices that tell the primary of another agent's send now say what that agent's own notice says, whatever opened the channel: "X's own message into the closed channel opened it", then ", and X can't close it" where X can't close it, or "; X has ended" where X has ended. Both said "opened by X's reply", which was false where X's speech opened it. - The release's unread scan (unreadNoticesForPrimary) takes one copy of each body. Where the context strategy shards a body at ingress (the autobiographical strategy's chunkIngressMessage), context-manager stores it as several messages, each carrying the same metadata and one bodyGroupId, so a sharded notice, a failed reply included, reached the primary once per copy. - channelNoticePassOn refuses a subscription-policy cause. Nothing writes one, since a policy admission's notice carries no inputs, and one read back would be re-rendered as an agent's message having opened the channel. - Comments: onChannelAutoOpened's names the opt-out only to a reader that can use it, and the ephemeral teardown's names a channel notice the run could act on beside a failed reply. Tests: stop()'s postponed release passes on a channel notice the run could act on, as it does a failed reply; the scan takes one copy of each sharded body; a reader's notice that can't be written leaves the primary's written, for both notices; a policy admission with no primary, delivered to an agent that can't close; a reply notice's inputs with the put-back unrecorded; more malformed inputs read back, and an unlabeled channel named by its id; and both release tests check the fork was released before reading what the primary was told. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 32 +++++++---- test/channel-lifecycle-order.test.ts | 80 +++++++++++++++++++++++++--- test/route-failure-targeting.test.ts | 34 ++++++++---- 3 files changed, 121 insertions(+), 25 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 6c0a5fa20..35bbb3cb7 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2209,7 +2209,10 @@ function channelNoticePassOn(metadata: unknown): ChannelNoticePassOn | null { notice?: unknown; cause?: unknown; channels?: unknown; channel?: unknown; selectedForOpening?: unknown; } | null | undefined; if (passOn?.notice === 'channel-opened') { - const causes: readonly unknown[] = ['subscription-policy', 'opened-by-delivery', 'opened-by-reply']; + // A policy admission's notice is addressed to no agent of its own and + // carries no inputs (channelOpenedNotices), so only an agent's send reads + // back. + const causes: readonly unknown[] = ['opened-by-delivery', 'opened-by-reply']; if (!causes.includes(passOn.cause) || !Array.isArray(passOn.channels) || passOn.channels.length === 0) return null; const channels = passOn.channels.map(noticeChannel); if (channels.some((c) => c === null)) return null; @@ -8330,9 +8333,10 @@ export class AgentFramework { // turn's end, is left to its window's release below // (releaseWindowNowOrAfterResume), as for a disposed fork: it lands // there in the run's store, which keeps the run's namespace for - // investigation, now or, while quiesced, at resume. A failed reply - // among it goes on to the primary as the same queue entry, so no ack - // comes between the entry and the primary's notice. + // investigation, now or, while quiesced, at resume. A failed reply, or + // a channel notice the run could act on, among it goes on to the + // primary as the same queue entry, so no ack comes between the entry + // and the primary's notice. // Exact-generation disposal owns these name-keyed entries. Remove them // before deregistration; the late driveStream finally will see the // generation mismatch and leave any future owner untouched. @@ -15127,7 +15131,7 @@ export class AgentFramework { return []; } return [{ - text: `[channels] Now open: ${shown}: opened by ${agentName}'s reply; ${agentName} has ended.` + + text: `[channels] Now open: ${shown}: ${agentName}'s own message into the closed channel opened it; ${agentName} has ended.` + optOut(this.canCloseChannels(primary, ids)), metadata: { system: true, kind: 'channel-notice', speaker: agentName } as MessageMetadata, told: `the primary, by name (${agentName} has ended)`, @@ -15160,7 +15164,7 @@ export class AgentFramework { if (agentName && !canClose && primary !== undefined && this.agents.has(primary) && this.canCloseChannels(primary, ids)) { notices.push({ - text: `[channels] Now open: ${shown}: opened by ${agentName}'s reply; ${agentName} can't close ${plural ? 'them' : 'it'}.` + + text: `[channels] Now open: ${shown}: ${agentName}'s own message into the closed channel opened it, and ${agentName} can't close ${plural ? 'them' : 'it'}.` + optOut(true), metadata: { system: true, kind: 'channel-notice', speaker: agentName } as MessageMetadata, told: `the primary, by name (${agentName} can't close ${plural ? 'them' : 'it'})`, @@ -21607,8 +21611,17 @@ export class AgentFramework { const floor = watermark ? Math.min(watermark.sequence, this.branchesPartAt(watermark.branch, cm.currentBranch().name)) : Number.NEGATIVE_INFINITY; + // A body the store took as more than one message (a sharded body) + // carries the same metadata on each of its copies, and is one notice. + const bodies = new Set(); return cm.getAllMessages() - .filter((m) => m.sequence > floor && passesToPrimary(m.metadata)) + .filter((m) => { + if (m.sequence <= floor || !passesToPrimary(m.metadata)) return false; + if (m.bodyGroupId === undefined) return true; + if (bodies.has(m.bodyGroupId)) return false; + bodies.add(m.bodyGroupId); + return true; + }) .map((m) => m.metadata as MessageMetadata); } catch (err) { // A release must complete whatever its window's read meets. @@ -22783,8 +22796,9 @@ export class AgentFramework { }, // A channel opened without the agent asking (policy admission or an // open forced by delivery) must be announced in the window, with the - // opt-out named. Routed here so registry-driven opens and framework- - // driven opens produce the same durable notice. + // opt-out named to a reader that can use it. Routed here so + // registry-driven opens and framework-driven opens produce the same + // durable notice. onChannelAutoOpened: ({ conversationId, source, channels }) => { this.recordChannelAutoOpenNotice(conversationId, channels, source); }, diff --git a/test/channel-lifecycle-order.test.ts b/test/channel-lifecycle-order.test.ts index 39359def5..e6bf3a735 100644 --- a/test/channel-lifecycle-order.test.ts +++ b/test/channel-lifecycle-order.test.ts @@ -2343,6 +2343,10 @@ test("where a reply's reader can't close a channel selected for opening, the pri // Its inputs, so a release that finds it unread tells the primary. told: [{ text: facts + again + ', and channel_close withdraws that.', forAgent: 'helper', metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'reply-open-failed', channel: { channelId: 'x', label: '#x' }, selectedForOpening: 'server-bootstrap' } } }] }, + { why: 'a reader who can close it, the put-back unrecorded', name: 'helper', agents: [['helper', []], ['primary', []]], + primary: 'primary', selectedForOpening: 'unrecorded', + told: [{ text: facts + stays + ', and channel_close withdraws that. channel_open tries again.', forAgent: 'helper', metadata: { system: true, kind: 'channel-notice', + passOn: { notice: 'reply-open-failed', channel: { channelId: 'x', label: '#x' }, selectedForOpening: 'unrecorded' } } }] }, { why: "a primary that can close it but not open it, the put-back unrecorded", name: 'fork', agents: [['fork', []], ['primary', ['channel_open']]], homes: [['fork', 'y']], primary: 'primary', selectedForOpening: 'unrecorded', told: [ @@ -2408,7 +2412,7 @@ test('the opened notice names channel_close only to a reader that can call it, a { why: 'a fork away from its home', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'y']], primary: 'primary', told: [ { text: own + optOut, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, - { text: "[channels] Now open: #x (x): opened by fork's reply; fork can't close it." + optOut + close, + { text: "[channels] Now open: #x (x): fork's own message into the closed channel opened it, and fork can't close it." + optOut + close, metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, ] }, { why: 'a fork at its home', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'x']], primary: 'primary', @@ -2416,7 +2420,7 @@ test('the opened notice names channel_close only to a reader that can call it, a { why: 'an agent whose allowedTools leave channel_close out', name: 'helper', agents: [['helper', ['channel_close']], ['primary', []]], primary: 'primary', told: [ { text: own + optOut, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'helper' }, - { text: "[channels] Now open: #x (x): opened by helper's reply; helper can't close it." + optOut + close, + { text: "[channels] Now open: #x (x): helper's own message into the closed channel opened it, and helper can't close it." + optOut + close, metadata: { system: true, kind: 'channel-notice', speaker: 'helper' } }, ] }, { why: "a primary that can't close it either", name: 'fork', agents: [['fork', []], ['primary', ['channel_close']]], homes: [['fork', 'y']], primary: 'primary', @@ -2430,7 +2434,7 @@ test('the opened notice names channel_close only to a reader that can call it, a told: [ { text: '[channels] Now open: #y (y), #x (x): your own message into the closed channel opened it.' + optOut, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, - { text: "[channels] Now open: #y (y), #x (x): opened by fork's reply; fork can't close them." + optOut + closeMany, + { text: "[channels] Now open: #y (y), #x (x): fork's own message into the closed channel opened it, and fork can't close them." + optOut + closeMany, metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, ] }, { why: 'a policy admission, to the primary that can close it', cause: 'subscription-policy', agents: [['primary', []]], primary: 'primary', @@ -2440,8 +2444,11 @@ test('the opened notice names channel_close only to a reader that can call it, a { why: "a policy admission, to a primary that can't close it", cause: 'subscription-policy', agents: [['primary', ['channel_close']]], primary: 'primary', told: [{ text: '[channels] Selected for opening by subscription policy: #x (x). This records the admission decision; channel_list shows current transport state.' + optOut, metadata: { system: true, kind: 'channel-notice' } }] }, + { why: "a policy admission with no primary, to the first agent, which can't close it", cause: 'subscription-policy', agents: [['worker', ['channel_close']]], primary: null, + told: [{ text: '[channels] Selected for opening by subscription policy: #x (x). This records the admission decision; channel_list shows current transport state.' + optOut, + metadata: { system: true, kind: 'channel-notice' } }] }, { why: "an agent that has ended, with a primary that can't close it", name: 'gone', agents: [['primary', ['channel_close']]], primary: 'primary', - told: [{ text: "[channels] Now open: #x (x): opened by gone's reply; gone has ended." + optOut, + told: [{ text: "[channels] Now open: #x (x): gone's own message into the closed channel opened it; gone has ended." + optOut, metadata: { system: true, kind: 'channel-notice', speaker: 'gone' } }] }, ]; for (const c of cases) { @@ -2470,10 +2477,63 @@ test('the opened notice names channel_close only to a reader that can call it, a } }); +// A body the store took as more than one message (a sharded body) carries +// the same metadata on each of its copies, and is one notice to pass on. +test("a sharded notice is passed on once: the release's scan takes one copy of each body", () => { + const framework = Object.create(AgentFramework.prototype) as any; + const passOn = { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: 'x', label: '#x' }] }; + const stored = [ + { sequence: 1, metadata: { system: true, kind: 'channel-notice', passOn }, bodyGroupId: 'g-1' }, + { sequence: 2, metadata: { system: true, kind: 'channel-notice', passOn }, bodyGroupId: 'g-1' }, + { sequence: 3, metadata: { system: true, kind: 'channel-notice', passOn } }, + { sequence: 4, metadata: { system: true, kind: 'discord-send-failed', channelId: 'x' }, bodyGroupId: 'g-2' }, + { sequence: 5, metadata: { system: true, kind: 'discord-send-failed', channelId: 'x' }, bodyGroupId: 'g-2' }, + ]; + const agent = { + name: 'fork', + getConsumedWatermark: () => undefined, + getContextManager: () => ({ getAllMessages: () => stored }), + }; + const unread = framework.unreadNoticesForPrimary(agent) as Array<{ kind?: string }>; + assert.deepEqual(unread.map((m) => m.kind), ['channel-notice', 'channel-notice', 'discord-send-failed'], + 'one per body: the two shards of g-1 once, the unsharded notice, and g-2 once'); +}); + +// Each notice is written on its own: a reader's notice that can't be +// written leaves the primary's to be written still. +test("a reader's notice that can't be written still leaves the primary's by-name notice written, for both notices", () => { + for (const write of ['opened', 'reply'] as const) { + const told: Array<{ text: string; forAgent?: string }> = []; + const logged: string[] = []; + const framework = Object.create(AgentFramework.prototype) as any; + framework.agents = new Map([['fork', { name: 'fork', canUseTool: () => true }], ['primary', { name: 'primary', canUseTool: () => true }]]); + framework.primaryAgentName = 'primary'; + framework.usedEphemeralAgentNames = new Set(); + framework.conversationAgentHomes = new Map([['fork', 'y']]); + framework.addMessage = (_participant: string, content: Array<{ text: string }>, _metadata: unknown, routed?: { forAgent?: string }) => { + if (routed?.forAgent) throw new Error('store refused the write'); + told.push({ text: content[0]!.text }); + return 'm'; + }; + const error = console.error; + console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; + try { + if (write === 'opened') framework.recordChannelAutoOpenNotice('fork', [{ channelId: 'x', label: '#x' }], 'opened-by-reply'); + else framework.recordReplyOpenFailedNotice('fork', { channelId: 'x', label: '#x' }, 'server-bootstrap'); + } finally { + console.error = error; + } + assert.equal(told.length, 1, `${write}: the primary's notice is written`); + assert.match(told[0]!.text, /fork can't close it/, write); + assert.ok(logged.some((l) => l.startsWith(write === 'opened' ? 'recordChannelAutoOpenNotice failed:' : 'recordReplyOpenFailedNotice failed:')), + `${write}: the reader's failure is logged`); + } +}); + // A release finds a channel notice its reader could act on unread: the // primary is told in its place, in the words the notice's builder gives an -// agent that has ended, judged for the primary. A notice whose inputs a store -// kept from another build, or that carries none, isn't passed on. +// agent that has ended, judged for the primary. A notice whose inputs don't +// read back as this build writes them, or that carries none, isn't passed on. test("a channel notice's inputs give the primary's by-name notice once its reader has ended, and malformed ones give none", () => { const framework = Object.create(AgentFramework.prototype) as any; framework.agents = new Map([['primary', { name: 'primary', canUseTool: (tool: string) => tool !== 'channel_open' }]]); @@ -2497,12 +2557,20 @@ test("a channel notice's inputs give the primary's by-name notice once its reade { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: '' }] }, { notice: 'reply-open-failed', channel: { channelId: 'x' } }, { notice: 'reply-open-failed', channel: { channelId: 'x' }, selectedForOpening: 'maybe' }, + { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: 7 }] }, + { notice: 'channel-opened', cause: 'opened-by-reply', channels: 'x' }, + { notice: 'channel-opened', cause: 'subscription-policy', channels: [{ channelId: 'x' }] }, + { notice: 'reply-open-failed', channel: null, selectedForOpening: 'unrecorded' }, { notice: 'something-else' }, ]) { assert.equal(framework.noticeForPrimary('gone', { system: true, kind: 'channel-notice', ...(passOn ? { passOn } : {}) }), null, JSON.stringify(passOn)); } assert.equal(framework.noticeForPrimary('gone', { system: true, kind: 'routing-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: 'x' }] } }), null, 'only a channel notice'); + // A channel with no label, or one whose label isn't text, is named by its id. + const unlabeled = framework.noticeForPrimary('gone', { system: true, kind: 'channel-notice', + passOn: { notice: 'channel-opened', cause: 'opened-by-delivery', channels: [{ channelId: 'x', label: 9 }] } }); + assert.match(unlabeled?.content[0]?.text ?? '', /^\[channels\] Now open: x: gone's own message into the closed channel opened it; gone has ended\./); } finally { console.error = error; } diff --git a/test/route-failure-targeting.test.ts b/test/route-failure-targeting.test.ts index 7aefe75e4..8c557f754 100644 --- a/test/route-failure-targeting.test.ts +++ b/test/route-failure-targeting.test.ts @@ -662,7 +662,7 @@ describe('notices about an agent\'s own action', () => { '[channels] Now open: #one (c-1): your own message into the closed channel opened it. What an open channel delivers to you depends on its server.' }])); assert.equal(primary.mock.callCount(), 1, 'the primary is told too'); assert.equal(JSON.stringify(primary.mock.calls[0]!.arguments[1]), JSON.stringify([{ type: 'text', text: - `[channels] Now open: #one (c-1): opened by ${fork.name}'s reply; ${fork.name} can't close it. ` + + `[channels] Now open: #one (c-1): ${fork.name}'s own message into the closed channel opened it, and ${fork.name} can't close it. ` + "What an open channel delivers to you depends on its server. If you don't want it, use channel_close; your choice outranks policy." }])); assert.equal((primary.mock.calls[0]!.arguments[2] as { speaker?: unknown } | undefined)?.speaker, fork.name); @@ -691,11 +691,12 @@ describe('notices about an agent\'s own action', () => { reap(fork.name); + assert.equal(framework.getAgent(fork.name), null, 'released'); const told = framework.getAgent('primary')!.getContextManager().getAllMessages() .filter((m) => JSON.stringify(m.content).includes('#home')); assert.equal(told.length, 1, 'the primary is told once'); assert.equal((told[0]!.content[0] as { text: string }).text, - `[channels] Now open: #home (missing-channel): opened by ${fork.name}'s reply; ${fork.name} has ended. ` + + `[channels] Now open: #home (missing-channel): ${fork.name}'s own message into the closed channel opened it; ${fork.name} has ended. ` + "What an open channel delivers to you depends on its server. If you don't want it, use channel_close; your choice outranks policy."); assert.equal((told[0]!.metadata as { speaker?: unknown } | undefined)?.speaker, fork.name); }); @@ -707,6 +708,7 @@ describe('notices about an agent\'s own action', () => { reap(fork.name); + assert.equal(framework.getAgent(fork.name), null, 'released'); const told = framework.getAgent('primary')!.getContextManager().getAllMessages() .filter((m) => JSON.stringify(m.content).includes('#one')); assert.equal(told.length, 1, 'only the notice of the time'); @@ -731,7 +733,7 @@ describe('notices about an agent\'s own action', () => { const told = framework.getAgent('primary')!.getContextManager().getAllMessages() .filter((m) => JSON.stringify(m.content).includes('Now open: #opened (opened-1)')); assert.equal(told.length, 1, 'the primary is told once'); - assert.match(JSON.stringify(told[0]!.content), /opened by released-run's reply; released-run has ended\. .*use channel_close/); + assert.match(JSON.stringify(told[0]!.content), /released-run's own message into the closed channel opened it; released-run has ended\. .*use channel_close/); assert.equal((told[0]!.metadata as { speaker?: unknown } | undefined)?.speaker, 'released-run'); assert.equal(state.deferredMessages.length, 0); @@ -830,7 +832,7 @@ describe('notices about an agent\'s own action', () => { const told = framework.getAgent('primary')!.getContextManager().getAllMessages() .filter((m) => JSON.stringify(m.content).includes('conversation-gone')); assert.deepEqual(told.map((m) => (m.content[0] as { text: string }).text), [ - "[channels] Now open: #four (c-4): opened by conversation-gone's reply; conversation-gone has ended. " + + "[channels] Now open: #four (c-4): conversation-gone's own message into the closed channel opened it; conversation-gone has ended. " + "What an open channel delivers to you depends on its server. If you don't want it, use channel_close; your choice outranks policy.", "[channels] Not open: #five (c-5). conversation-gone's reply there went out, but opening the channel failed; conversation-gone has ended. " + "It's selected for opening again, as its server announces it open: the host tries again, and channel_close withdraws that.", @@ -1219,7 +1221,7 @@ describe('notices about an agent\'s own action', () => { content: [{ type: 'text', text: '[channels] Now open: #seven (c-7): your own message into the closed channel opened it.' }], metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-delivery', channels: [{ channelId: 'c-7', label: '#seven' }] } }, inRun: 'Now open: #seven', - toPrimary: "opened by worker's reply; worker has ended", + toPrimary: "worker's own message into the closed channel opened it; worker has ended", }, ]; for (const queued of queuedForTheRun) it(`passes ${queued.what} still queued when a run ends unquiesced on to the primary as the same queue entry`, async () => { @@ -1288,15 +1290,27 @@ describe('notices about an agent\'s own action', () => { assert.equal(count(await archived(), 'Written for the run while the host was quiesced.'), 1, 'and lands in its window at resume'); }); - it('passes a failed reply a run stored unread on to the primary\'s queue when the host stops before the release it waited for', async () => { + const storedUnread = [ + { + what: 'a failed reply', + content: [{ type: 'text' as const, text: `${SEND_FAILED} Your previous reply (5 chars) could not be delivered to #x (missing-channel).` }], + metadata: { system: true, kind: 'discord-send-failed', channelId: 'missing-channel', reason: 'no registered channel', textLen: 5 }, + toPrimary: 'A reply by worker', + }, + { + what: 'a channel notice the run could act on', + content: [{ type: 'text' as const, text: '[channels] Now open: #eight (c-8): your own message into the closed channel opened it.' }], + metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: 'c-8', label: '#eight' }] } }, + toPrimary: "worker's own message into the closed channel opened it; worker has ended", + }, + ]; + for (const unread of storedUnread) it(`passes ${unread.what} a run stored unread on to the primary\'s queue when the host stops before the release it waited for`, async () => { const created = await runFramework.createEphemeralAgent({ name: 'worker', model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all', proseRouting: 'explicit', }); created.contextManager.addMessage('user', [{ type: 'text', text: 'Run once.' }]); // Stored in its window, and never read: the run never compiled. - created.contextManager.addMessage('user', - [{ type: 'text', text: `${SEND_FAILED} Your previous reply (5 chars) could not be delivered to #x (missing-channel).` }], - { system: true, kind: 'discord-send-failed', channelId: 'missing-channel', reason: 'no registered channel', textLen: 5 } as never); + created.contextManager.addMessage('user', unread.content, unread.metadata as never); const state = internals(runFramework) as unknown as ReturnType & { releasesAfterResume: Map }; // Not started, so its startup watchdog ends it, while the host is quiesced. const done = runFramework.runEphemeralToCompletion(created.agent, created.contextManager, { startupTimeoutMs: 150 }); @@ -1307,7 +1321,7 @@ describe('notices about an agent\'s own action', () => { await runFramework.stop(); const passed = (state.deferredMessages as unknown as Array<{ forAgent?: string; content: ContentBlock[]; metadata?: Record }>) - .filter((m) => JSON.stringify(m.content).includes('A reply by worker')); + .filter((m) => JSON.stringify(m.content).includes(unread.toPrimary)); assert.equal(passed.length, 1, 'queued for the primary, for resume or boot'); assert.equal(passed[0]!.forAgent, undefined); assert.equal(passed[0]!.metadata?.speaker, 'worker'); From 8251d0ea8763d1fc8b3a2da1964db497be9d4a2b Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 19 Oct 2026 17:23:15 -0400 Subject: [PATCH 03/12] fix(channels): read a sharded notice as one body by context-manager's rule, not by its group id - The release's unread scan (unreadNoticesForPrimary) skips a message that continues the body before it, by context-manager's continuesBody in store order, where it kept the first message of each bodyGroupId. A group id names the text (the chunker hashes it), so the same notice stored twice is two bodies under one id, and the scan dropped the second. - Comments: the release's doc and the scan's say which notices count as unread, including one injected into a stream that doesn't declare it carries injections; the channel registry's says the opened notice tells how to close the channel to a reader that can; stop()'s paragraph on a postponed release is reflowed. Tests: the sharded-notice test now stores real shards through the context manager, with the strategy's ingress chunking stubbed to shard by block and name the text, and checks that the same text stored twice is two notices. It replaces a test over hand-written records that gave each body its own group id. The ended-send test's comment and message say send, not reply. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 41 ++++++++++++++-------------- src/mcpl/channel-registry.ts | 3 +- test/channel-lifecycle-order.test.ts | 22 --------------- test/route-failure-targeting.test.ts | 33 ++++++++++++++++++++-- 4 files changed, 53 insertions(+), 46 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 35bbb3cb7..eedab1bd7 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9,7 +9,7 @@ import { import { JsStore } from '@animalabs/chronicle'; import type { Membrane, ContentBlock, NormalizedRequest, NormalizedResponse, YieldingStream, DetailedUsage, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane'; import { MembraneError } from '@animalabs/membrane'; -import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError, stripUnpairedToolBlocks } from '@animalabs/context-manager'; +import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError, stripUnpairedToolBlocks, continuesBody } from '@animalabs/context-manager'; import type { CacheWireReceipt } from './kv-unified-wire.js'; import { SILENT_HEARTBEAT_SEPARATOR, type RequestOnlyPrompt, type RequestOnlyStamps } from './silent-heartbeat.js'; import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, type SubconsciousConfig } from './tune-out/tools.js'; @@ -4041,11 +4041,10 @@ export class AgentFramework { // once the host stops, so its unread pass runs here: each notice stored // unread in that window that the primary takes in its place (a failed // reply, or a channel notice its reader could act on) goes to the primary - // now, deferred while - // quiesced like any write, so it waits in the queue with the rest. What - // was queued for that agent stays queued, as a gone agent's. Each - // release's pass is a step of its own: one that throws fails the stop() - // and leaves the others, and the flush below, to run. + // now, deferred while quiesced like any write, so it waits in the queue + // with the rest. What was queued for that agent stays queued, as a gone + // agent's. Each release's pass is a step of its own: one that throws fails + // the stop() and leaves the others, and the flush below, to run. for (const agent of this.releasesAfterResume.values()) { step(() => { for (const metadata of this.unreadNoticesForPrimary(agent)) this.passNoticeToPrimary(agent.name, metadata); @@ -21551,8 +21550,7 @@ export class AgentFramework { * The agent never reads its window again, so every failed-reply notice it * never read, and every channel notice it could act on and never read, is * passed to the primary, by name, once (passesToPrimary): each one stored - * in its window above its consumed watermark (flushed there at its turn's - * end, or stored while it was idle) and not heard at a tool boundary, and + * in its window above its consumed watermark (unreadNoticesForPrimary), and * each one still queued for it. Writes still * queued would wait for a boundary it never reaches, and the flush that * finds it gone would drop them, so they land in its own window now, which @@ -21600,9 +21598,12 @@ export class AgentFramework { * the current one part (branchesPartAt), or every one, when it never * compiled. The watermark moves at a compile, and at a tool boundary whose * stream declares it carries the messages injected there - * (markContextConsumed), so a notice the live stream heard is under it, and - * one a boundary stored without sending (a turn a tool ended, a budget - * restart, a stream that may drop injections) is above it. + * (markContextConsumed). So a notice such a stream carried is under it. + * One a boundary stored without sending (a turn a tool ended, a budget + * restart) is above it, and so is one injected into a stream that doesn't + * declare it carries injections, which may have dropped it: counted as + * unread, it goes to the primary even if the agent heard it, rather than to + * no one where the stream dropped it. */ private unreadNoticesForPrimary(agent: Agent): MessageMetadata[] { try { @@ -21612,16 +21613,14 @@ export class AgentFramework { ? Math.min(watermark.sequence, this.branchesPartAt(watermark.branch, cm.currentBranch().name)) : Number.NEGATIVE_INFINITY; // A body the store took as more than one message (a sharded body) - // carries the same metadata on each of its copies, and is one notice. - const bodies = new Set(); - return cm.getAllMessages() - .filter((m) => { - if (m.sequence <= floor || !passesToPrimary(m.metadata)) return false; - if (m.bodyGroupId === undefined) return true; - if (bodies.has(m.bodyGroupId)) return false; - bodies.add(m.bodyGroupId); - return true; - }) + // carries the same metadata on each of its shards, and is one notice, + // so a shard that continues the body before it isn't taken again. Which + // messages make up a body is context-manager's rule (continuesBody, + // pairwise in store order): a group id names the text, so the same text + // stored twice is two bodies under one id. + const messages = cm.getAllMessages(); + return messages + .filter((m, i) => m.sequence > floor && passesToPrimary(m.metadata) && !continuesBody(messages[i - 1], m)) .map((m) => m.metadata as MessageMetadata); } catch (err) { // A release must complete whatever its window's read meets. diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 6840609d5..1b3713b64 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -4066,7 +4066,8 @@ export class ChannelRegistry { * after an open for a declaration leave the channel open, and the agent * gets the failure for the words. An open by delivery (`openSource` * 'opened-by-delivery', the default) that turned a closed channel open - * also gives the notice that it's open, which says how to close it. + * also gives the notice that it's open, which says how to close it to a + * reader that can. * One that only reconfirmed the transport of a channel already decided * open or tuned out changes no decision, and gives none. When the * registration is gone, the failure says the channel was removed. diff --git a/test/channel-lifecycle-order.test.ts b/test/channel-lifecycle-order.test.ts index e6bf3a735..87eba2d82 100644 --- a/test/channel-lifecycle-order.test.ts +++ b/test/channel-lifecycle-order.test.ts @@ -2477,28 +2477,6 @@ test('the opened notice names channel_close only to a reader that can call it, a } }); -// A body the store took as more than one message (a sharded body) carries -// the same metadata on each of its copies, and is one notice to pass on. -test("a sharded notice is passed on once: the release's scan takes one copy of each body", () => { - const framework = Object.create(AgentFramework.prototype) as any; - const passOn = { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: 'x', label: '#x' }] }; - const stored = [ - { sequence: 1, metadata: { system: true, kind: 'channel-notice', passOn }, bodyGroupId: 'g-1' }, - { sequence: 2, metadata: { system: true, kind: 'channel-notice', passOn }, bodyGroupId: 'g-1' }, - { sequence: 3, metadata: { system: true, kind: 'channel-notice', passOn } }, - { sequence: 4, metadata: { system: true, kind: 'discord-send-failed', channelId: 'x' }, bodyGroupId: 'g-2' }, - { sequence: 5, metadata: { system: true, kind: 'discord-send-failed', channelId: 'x' }, bodyGroupId: 'g-2' }, - ]; - const agent = { - name: 'fork', - getConsumedWatermark: () => undefined, - getContextManager: () => ({ getAllMessages: () => stored }), - }; - const unread = framework.unreadNoticesForPrimary(agent) as Array<{ kind?: string }>; - assert.deepEqual(unread.map((m) => m.kind), ['channel-notice', 'channel-notice', 'discord-send-failed'], - 'one per body: the two shards of g-1 once, the unsharded notice, and g-2 once'); -}); - // Each notice is written on its own: a reader's notice that can't be // written leaves the primary's to be written still. test("a reader's notice that can't be written still leaves the primary's by-name notice written, for both notices", () => { diff --git a/test/route-failure-targeting.test.ts b/test/route-failure-targeting.test.ts index 8c557f754..941d21fe4 100644 --- a/test/route-failure-targeting.test.ts +++ b/test/route-failure-targeting.test.ts @@ -718,7 +718,7 @@ describe('notices about an agent\'s own action', () => { it('tells the primary, by name, of a channel an ended agent\'s send opened, and the primary\'s channel_close closes it', async (t) => { const state = internals(framework); const channels = (state.channelRegistry as unknown as { channels: Map }).channels; - // The channel the ended run's reply opened, on a server that lets it close. + // The channel the ended run's send opened, on a server that lets it close. channels.set('srv:opened-1', { serverId: 'srv', descriptor: { id: 'opened-1', type: 'srv', label: '#opened' }, open: true } as never); const closed: unknown[] = []; const server = { @@ -742,7 +742,7 @@ describe('notices about an agent\'s own action', () => { .dispatchChannelToolCall('primary', { id: 'close-1', name: 'channel_close', input: { channelId: 'opened-1' } }); for (let i = 0; i < 50 && channels.get('srv:opened-1')!.open; i++) await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(closed, [{ channelId: 'opened-1' }]); - assert.equal(channels.get('srv:opened-1')!.open, false, 'the channel the reply opened is closed'); + assert.equal(channels.get('srv:opened-1')!.open, false, 'the channel the send opened is closed'); }); it('tells no one, logged, of a channel an ended agent\'s send opened when there is no primary', async (t) => { @@ -864,6 +864,35 @@ describe('notices about an agent\'s own action', () => { assert.match(JSON.stringify(entry.content), /A reply by conversation-gone \(12 chars\) could not be delivered/); }); + it('counts a body the store shards as one notice, and the same text stored again as a notice of its own', async () => { + const state = internals(framework) as unknown as { unreadNoticesForPrimary(agent: unknown): Array<{ tag?: unknown }> }; + const agent = framework.getAgent('secondary')!; + const cm = agent.getContextManager(); + // Shard every notice into one message per block, as a strategy's ingress + // chunking does. The group id names the text, as context-manager's + // chunker's does, so the same text shards under the same id each time + // it's stored. + (cm as unknown as { strategy: Record }).strategy.chunkIngressMessage = (_participant: string, content: ContentBlock[]) => + content.length > 1 + ? { + bodyGroupId: `bg-${content.map((block) => (block as { text?: string }).text).join('')}`, + shards: content.map((block, shardIndex) => ({ content: [block], shardIndex })), + } + : null; + const notice = (tag: string, words: string) => cm.addMessage('user', + [{ type: 'text', text: `[discord-send-failed] ${words}` }, { type: 'text', text: ' and the rest of it' }], + { system: true, kind: 'discord-send-failed', channelId: 'missing-channel', tag } as never); + + notice('first', 'the same words'); + notice('second', 'the same words'); + notice('third', 'other words'); + + const stored = cm.getAllMessages().filter((m) => (m.metadata as { kind?: unknown } | undefined)?.kind === 'discord-send-failed'); + assert.deepEqual(stored.map((m) => m.shardIndex), [0, 1, 0, 1, 0, 1], 'each body is stored as two shards'); + assert.equal(stored[2]!.bodyGroupId, stored[0]!.bodyGroupId, 'the same text is stored again under the same group id'); + assert.deepEqual(state.unreadNoticesForPrimary(agent).map((m) => m.tag), ['first', 'second', 'third']); + }); + it('counts as read, across branches, only what an agent\'s window shares with the branch it read up to the point they part', async () => { const state = internals(framework) as unknown as { unreadNoticesForPrimary(agent: unknown): Array<{ tag?: unknown }> }; const agent = framework.getAgent('secondary')!; From 89bccd9cd1568b0191b0a79a527d592dc81713ff Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 19 Oct 2026 17:50:31 -0400 Subject: [PATCH 04/12] docs(channels): say when the release's scan has no floor, and reflow the release's doc - unreadNoticesForPrimary's doc said every notice counts as unread when the agent never compiled. Every agent's consumed watermark is set when it's created (markContextConsumed), so the floor is absent only when the agent has no watermark at all. The doc now says so, and that the watermark is set at creation. - releaseWindow's doc points to unreadNoticesForPrimary for which notices in the window count as unread, and its paragraph is reflowed. Comments only. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index eedab1bd7..9ab3aec8f 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -21550,17 +21550,16 @@ export class AgentFramework { * The agent never reads its window again, so every failed-reply notice it * never read, and every channel notice it could act on and never read, is * passed to the primary, by name, once (passesToPrimary): each one stored - * in its window above its consumed watermark (unreadNoticesForPrimary), and - * each one still queued for it. Writes still - * queued would wait for a boundary it never reaches, and the flush that - * finds it gone would drop them, so they land in its own window now, which - * Chronicle keeps for investigation. Its stream is over or cancelled, so it - * compiles nothing more. One path still stores after these writes: a - * `complete` that arrived while the agent still waited for tools awaits - * the spill of its last tool results, and stores them once that resolves, - * even if a watchdog has ended the run meanwhile. A tool boundary stores - * nothing for a round whose turn was stopped while its results were - * prepared. + * in its window that it hasn't read, as unreadNoticesForPrimary counts + * them, and each one still queued for it. Writes still queued would wait + * for a boundary it never reaches, and the flush that finds it gone would + * drop them, so they land in its own window now, which Chronicle keeps for + * investigation. Its stream is over or cancelled, so it compiles nothing + * more. One path still stores after these writes: a `complete` that arrived + * while the agent still waited for tools awaits the spill of its last tool + * results, and stores them once that resolves, even if a watchdog has ended + * the run meanwhile. A tool boundary stores nothing for a round whose turn + * was stopped while its results were prepared. */ private releaseWindow(agent: Agent): void { for (const metadata of this.unreadNoticesForPrimary(agent)) this.passNoticeToPrimary(agent.name, metadata); @@ -21595,10 +21594,11 @@ export class AgentFramework { * takes in its place (passesToPrimary): its failed replies, and the channel * notices it was told it could act on. Those are the ones above the lower * of its consumed watermark and the point where the watermark's branch and - * the current one part (branchesPartAt), or every one, when it never - * compiled. The watermark moves at a compile, and at a tool boundary whose - * stream declares it carries the messages injected there - * (markContextConsumed). So a notice such a stream carried is under it. + * the current one part (branchesPartAt), or every one, when it has no + * watermark. The watermark is set when the agent is created, and moves at + * a compile, and at a tool boundary whose stream declares it carries the + * messages injected there (markContextConsumed). So a notice such a stream + * carried is under it. * One a boundary stored without sending (a turn a tool ended, a budget * restart) is above it, and so is one injected into a stream that doesn't * declare it carries injections, which may have dropped it: counted as From 6594165ff0e91eee3f678a165823704859c7465d Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 19 Oct 2026 18:48:49 -0400 Subject: [PATCH 05/12] fix(channels): say what a channel delivers only to who holds its traffic, tell a reader who can't close who can, and name a fork by its channel - "What an open channel delivers to you depends on its server" is now said only to an agent that holds the channel's traffic (holdsChannelTraffic). Where conversations are routed, a fork holds only its home's, and the primary, the template forks start from, holds none; elsewhere incoming traffic is stored in deliveryAgent()'s window. A fork bound to another channel was told it as if the channel's messages might now reach it. - An agent that can't close a channel its send opened, or one its reply left selected for opening, is told who was told: "Your primary has been told and can close it." to a conversation fork, whose prompt may give the primary's name as its own, and the primary's name to any other agent. - The primary's by-name notices name a fork by the channel it's bound to, as notices name channels, with its own name once in parentheses, then "that fork": "a message from your fork bound to #dm (dm-1) (conversation-dm-1-g1-...) into the closed channel opened it, and that fork can't close it." Other agents keep their names: "a message from worker ...", "A reply from worker went out there ...". - "It's selected for opening again, as its server announces it open" read as contradicting "Not open". The clauses now say "because", and the server's case "because its server lists it as open by default", in the reply notice and in the channel registry's lifecycle errors, which reach speech's failure notice. Tests: the opened notice's table now covers who holds a channel's traffic (a fork at and away from its home, the primary where conversations are routed and where they aren't, the first agent when there's no primary), a fork whose home the registry labels, and the told clause; the reply notice's table covers the told clause; the rest take the new words. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 115 ++++++++++++++++++++------- src/mcpl/channel-registry.ts | 6 +- test/channel-lifecycle-order.test.ts | 104 ++++++++++++++---------- test/route-failure-targeting.test.ts | 26 +++--- 4 files changed, 163 insertions(+), 88 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 1b9135c41..c95c47a9c 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -15123,7 +15123,11 @@ export class AgentFramework { .join(', '); const ids = channels.map((c) => c.channelId); const plural = channels.length > 1; - const optOut = (canClose: boolean) => ' What an open channel delivers to you depends on its server.' + + // What the channels deliver is said only to an agent that holds their + // traffic (holdsChannelTraffic), and channel_close is named only to one + // that can call it for every one of them. + const optOut = (holds: boolean, canClose: boolean) => + (holds ? ' What an open channel delivers to you depends on its server.' : '') + (canClose ? ` If you don't want ${plural ? 'one of them' : 'it'}, use channel_close; your choice outranks policy.` : ''); // An open caused by an agent's own send is that agent's to hear about, in // the window it reads; a policy admission with no agent behind it goes to @@ -15139,9 +15143,10 @@ export class AgentFramework { console.error(`[channel] ${agentName} has ended, and there is no primary to tell that ${shown} opened`); return []; } + const sender = this.agentForPrimary(agentName); return [{ - text: `[channels] Now open: ${shown}: ${agentName}'s own message into the closed channel opened it; ${agentName} has ended.` + - optOut(this.canCloseChannels(primary, ids)), + text: `[channels] Now open: ${shown}: a message from ${sender.first} into the closed channel opened it; ${sender.again} has ended.` + + optOut(this.holdsChannelTraffic(primary, ids), this.canCloseChannels(primary, ids)), metadata: { system: true, kind: 'channel-notice', speaker: agentName } as MessageMetadata, told: `the primary, by name (${agentName} has ended)`, shown, @@ -15151,11 +15156,21 @@ export class AgentFramework { // admission, the agent addMessage delivers to (deliveryAgent). const who = agentName || this.deliveryAgent()?.name; const canClose = this.canCloseChannels(who, ids); + // Where the agent whose send opened them can't close them, as a fork away + // from its home can't, or an agent whose allowedTools leave channel_close + // out, the primary is told by name too, where it's registered and can + // close them, so an agent that can close the channels hears they opened, + // and the agent hears who was told. (The primary can't be that agent: it + // can close them, and that agent can't.) + const tellPrimary = !!agentName && !canClose && primary !== undefined && this.agents.has(primary) && + this.canCloseChannels(primary, ids); const notices: ChannelNotice[] = [{ text: (cause === 'subscription-policy' ? `[channels] Selected for opening by subscription policy: ${shown}. ` + 'This records the admission decision; channel_list shows current transport state.' - : `[channels] Now open: ${shown}: your own message into the closed channel opened it.`) + optOut(canClose), + : `[channels] Now open: ${shown}: your own message into the closed channel opened it.`) + + optOut(this.holdsChannelTraffic(who, ids), canClose) + + (tellPrimary ? ` ${this.primaryForReader(agentName, primary)} has been told and can close ${plural ? 'them' : 'it'}.` : ''), metadata: { system: true, kind: 'channel-notice', ...(agentName && canClose ? { passOn: { notice: 'channel-opened', cause, channels } satisfies ChannelNoticePassOn } : {}), @@ -15164,17 +15179,11 @@ export class AgentFramework { told: agentName ?? 'primary', shown, }]; - // Where the agent whose send opened them can't close them, as a fork away - // from its home can't, or an agent whose allowedTools leave channel_close - // out, the primary is told by name too, where it's registered and can - // close them, so an agent that can close the channels hears they opened. - // (The primary can't be that agent: it can close them, and that agent - // can't.) - if (agentName && !canClose && primary !== undefined && this.agents.has(primary) && - this.canCloseChannels(primary, ids)) { + if (tellPrimary) { + const sender = this.agentForPrimary(agentName); notices.push({ - text: `[channels] Now open: ${shown}: ${agentName}'s own message into the closed channel opened it, and ${agentName} can't close ${plural ? 'them' : 'it'}.` + - optOut(true), + text: `[channels] Now open: ${shown}: a message from ${sender.first} into the closed channel opened it, and ${sender.again} can't close ${plural ? 'them' : 'it'}.` + + optOut(this.holdsChannelTraffic(primary, ids), true), metadata: { system: true, kind: 'channel-notice', speaker: agentName } as MessageMetadata, told: `the primary, by name (${agentName} can't close ${plural ? 'them' : 'it'})`, shown, @@ -15195,6 +15204,50 @@ export class AgentFramework { return channelIds.every((id) => !home || home === id) && this.canAgentUseTool(who, 'channel_close'); } + /** + * Whether `who` holds the traffic of every one of `channelIds`, so that + * what an open channel delivers is stored in its window. A conversation + * fork holds only its home's: where conversations are routed, an incoming + * message goes to the fork bound to its channel or nowhere, and never to + * the primary (handleMcplChannelIncoming). Elsewhere an incoming message is + * stored with no forAgent, in deliveryAgent()'s window. (A push names its + * channel and lands in the primary's window either way, but it comes + * whether or not the channel is open, so it isn't what opening one + * delivers.) + */ + private holdsChannelTraffic(who: string | undefined, channelIds: readonly string[]): boolean { + if (who === undefined) return false; + const home = this.conversationAgentHomes.get(who); + if (home !== undefined) return channelIds.every((id) => id === home); + if (this.conversationRouter) return false; + return who === this.deliveryAgent()?.name; + } + + /** + * How a notice to the primary names `agentName`: a conversation fork by the + * channel it's bound to, as notices name channels, with its own name once + * in parentheses, and as "that fork" after; any other agent by its name. + * A fork's home is known while it's registered and through its release; a + * notice passed on later, at resume or at boot, names it by its name. + */ + private agentForPrimary(agentName: string): { first: string; again: string } { + const home = this.conversationAgentHomes.get(agentName); + if (home === undefined) return { first: agentName, again: agentName }; + const label = this.channelRegistry?.getDescriptor(home)?.label; + const bound = label && label !== home ? `${label} (${home})` : home; + return { first: `your fork bound to ${bound} (${agentName})`, again: 'that fork' }; + } + + /** + * How a notice to `reader` names the primary it says was told: "Your + * primary" to a conversation fork, whose configuration is the template's, + * the primary's, with only its name changed, so its prompt may give the + * primary's name as its own; the primary's name to any other agent. + */ + private primaryForReader(reader: string | undefined, primary: string | undefined): string { + return reader !== undefined && this.conversationAgentHomes.has(reader) ? 'Your primary' : primary ?? 'The primary'; + } + /** * Window notice for a reply whose channel couldn't be opened: the reply * went out through its send tool, but the open that engaging the channel @@ -15269,17 +15322,28 @@ export class AgentFramework { const canOpen = who !== undefined && !this.conversationAgentHomes.has(who) && this.canAgentUseTool(who, 'channel_open'); const canClose = this.canCloseChannels(who, [channel.channelId]); const selected = selectedForOpening === 'unrecorded' - ? " It stays selected for opening, as the host couldn't record undoing that: a later delivery or reconnect tries again" + ? " It stays selected for opening, because the host couldn't record undoing that: a later delivery or reconnect tries again" : selectedForOpening !== undefined - ? ` It's selected for opening again, as ${selectedForOpening === 'server-bootstrap' ? 'its server announces it open' : 'the subscription policy admits it'}: the host tries again` + ? ` It's selected for opening again, because ${selectedForOpening === 'server-bootstrap' ? 'its server lists it as open by default' : 'the subscription policy admits it'}: the host tries again` : ''; const hostRetries = selectedForOpening === 'server-bootstrap' || selectedForOpening === 'subscription-policy'; + // Where the reader can't close a channel selected for opening, as a fork + // away from its home can't, or an agent whose allowedTools leave + // channel_close out, the primary is told by name too, where it's + // registered and can close it, so an agent that could close the channel + // hears it may open, and the reader hears who was told. (The primary + // can't be the reader: it can close it, and the reader can't.) + const tellPrimary = !ended && selectedForOpening !== undefined && who !== undefined && !canClose && + primary !== undefined && this.agents.has(primary) && + this.canCloseChannels(primary, [channel.channelId]); + const sender = ended ? this.agentForPrimary(agentName!) : undefined; const notices: ChannelNotice[] = [{ text: `[channels] Not open: ${shown}. ` + (ended - ? `${agentName}'s reply there went out, but opening the channel failed; ${agentName} has ended.` + ? `A reply from ${sender!.first} went out there, but opening the channel failed; ${sender!.again} has ended.` : 'Your reply there went out, but opening the channel failed.') + (selected ? selected + (canClose ? ', and channel_close withdraws that.' : '.') : '') + + (tellPrimary ? ` ${this.primaryForReader(who, primary)} has been told, and can withdraw that.` : '') + (canOpen && !hostRetries ? ' channel_open tries again.' : ''), metadata: (ended ? { system: true, kind: 'channel-notice', speaker: agentName } @@ -15293,20 +15357,11 @@ export class AgentFramework { told: ended ? `the primary, by name (${agentName} has ended)` : who ?? 'no agent', shown, }]; - // Where the reader can't close a channel selected for opening, as a fork - // away from its home can't, or an agent whose allowedTools leave - // channel_close out, the primary is told by name too, where it's - // registered and can close it, so an agent that could close the channel - // hears it may open. (The primary can't be the reader: it can close it, - // and the reader can't.) - if ( - !ended && selectedForOpening !== undefined && who !== undefined && !canClose && - primary !== undefined && this.agents.has(primary) && - this.canCloseChannels(primary, [channel.channelId]) - ) { - const primaryCanOpen = !this.conversationAgentHomes.has(primary) && this.canAgentUseTool(primary, 'channel_open'); + if (tellPrimary) { + const primaryCanOpen = !this.conversationAgentHomes.has(primary!) && this.canAgentUseTool(primary!, 'channel_open'); + const reader = this.agentForPrimary(who!); notices.push({ - text: `[channels] Not open: ${shown}. ${who}'s reply there went out, but opening the channel failed, and ${who} can't close it.` + + text: `[channels] Not open: ${shown}. A reply from ${reader.first} went out there, but opening the channel failed, and ${reader.again} can't close it.` + selected + ', and channel_close withdraws that.' + (primaryCanOpen && !hostRetries ? ' channel_open tries again.' : ''), metadata: { system: true, kind: 'channel-notice', speaker: who } as MessageMetadata, diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 8f6ee3100..217abd187 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -3485,7 +3485,7 @@ export class ChannelRegistry { // The caller reports both: the channel isn't open, but it stays // selected for opening, so reconciliation may open it later. throw new ChannelLifecycleError( - afterClause(lifecycleClause(err), 'the channel stays selected for opening, as this host couldn\'t record ' + + afterClause(lifecycleClause(err), 'the channel stays selected for opening, because this host couldn\'t record ' + `undoing that: ${requestErrorText(putBackErr)}`), 'put-back-unrecorded', undefined, @@ -3532,8 +3532,8 @@ export class ChannelRegistry { } if (selectedAgain) { throw new ChannelLifecycleError( - afterClause(lifecycleClause(err), 'the channel is selected for opening again, as ' + - (selectedAgain === 'server-bootstrap' ? 'its server announces it open' : 'the subscription policy admits it')), + afterClause(lifecycleClause(err), 'the channel is selected for opening again, because ' + + (selectedAgain === 'server-bootstrap' ? 'its server lists it as open by default' : 'the subscription policy admits it')), 'selected-again', undefined, { by: selectedAgain, cause: err }, diff --git a/test/channel-lifecycle-order.test.ts b/test/channel-lifecycle-order.test.ts index 36596effb..2da119646 100644 --- a/test/channel-lifecycle-order.test.ts +++ b/test/channel-lifecycle-order.test.ts @@ -1923,14 +1923,14 @@ test("when an open by speech or a reply fails while the server announces the cha const { outcome } = await speechWhoseOpenFails(f, reannounce); assert.equal(outcome.status, 'failed'); assert.equal(outcome.reason, - "opening the channel for these words didn't complete: fixture lifecycle failure; the channel is selected for opening again, as its server announces it open"); + "opening the channel for these words didn't complete: fixture lifecycle failure; the channel is selected for opening again, because its server lists it as open by default"); // A server's own words keep their stop, so what follows is a sentence. const g = fixture(); await g.registry.handleChanged('test', { added: [descriptor('x', false)] }); const answered = await speechWhoseOpenFails(g, () => [g.registry.handleChanged('test', { added: [descriptor('x', true)] })], new McplRequestError('Opening x is not allowed here.', 'error-response')); assert.equal(answered.outcome.reason, "opening the channel for these words didn't complete: Opening x is not allowed here. " + - 'The channel is selected for opening again, as its server announces it open'); + 'The channel is selected for opening again, because its server lists it as open by default'); } else { const { answer } = await replyWhoseOpenFails(f, reannounce); assert.deepEqual(answer, { status: 'open-failed', channelId: 'x', label: 'x', opening: true, selectedForOpening: 'server-bootstrap' }); @@ -1951,7 +1951,7 @@ test("when a reply's open fails after the subscription policy came to admit the const { answer } = await replyWhoseOpenFails(f, () => { f.registry.setSubscriptionPolicy('test', 'auto'); return []; }); assert.deepEqual(answer, { status: 'open-failed', channelId: 'x', label: 'x', opening: true, selectedForOpening: 'subscription-policy' }); assert.deepEqual(f.traces.filter((t) => t.type === 'mcpl:channel-open-failed').map((t) => t.error), - ['fixture lifecycle failure; the channel is selected for opening again, as the subscription policy admits it']); + ['fixture lifecycle failure; the channel is selected for opening again, because the subscription policy admits it']); await tick(); await tick(); assert.deepEqual(decision(f.registry), { state: 'open', source: 'subscription-policy' }); assert.equal(f.isOpen(), true); @@ -2226,7 +2226,7 @@ test("when putting back the decision a failed open replaced can't be recorded, s const closes = f.calls.filter((c) => c.kind === 'close').length; const { outcome } = await speechWhoseOpenFails(f); assert.equal(outcome.status, 'failed'); - assert.match(outcome.reason ?? '', /^opening the channel for these words didn't complete: fixture lifecycle failure; the channel stays selected for opening, as this host couldn't record undoing that: ENOSPC/); + assert.match(outcome.reason ?? '', /^opening the channel for these words didn't complete: fixture lifecycle failure; the channel stays selected for opening, because this host couldn't record undoing that: ENOSPC/); assert.deepEqual(decision(f.registry), { state: 'open', source: 'opened-by-delivery' }); await tick(); await tick(); assert.equal(f.calls.filter((c) => c.kind === 'close').length, closes, 'no close is sent: the decision stays open'); @@ -2330,19 +2330,19 @@ test("a reply whose open would have opened the channel, and failed, tells the ag '[channels] Not open: #x (x). Your reply there went out, but opening the channel failed. channel_open tries again.', ], "a transport that drops while the open is out clears the grant before the failure arrives: whether the host directs the lifecycle is read before the open"); assert.deepEqual((await replyOpenFailedNotices({ opening: true, directs: true, selectedForOpening: 'unrecorded' })).told.map((t) => t.text), [ - "[channels] Not open: #x (x). Your reply there went out, but opening the channel failed. It stays selected for opening, as the host couldn't record undoing that: a later delivery or reconnect tries again, and channel_close withdraws that. channel_open tries again.", + "[channels] Not open: #x (x). Your reply there went out, but opening the channel failed. It stays selected for opening, because the host couldn't record undoing that: a later delivery or reconnect tries again, and channel_close withdraws that. channel_open tries again.", ], "where the put-back couldn't be recorded, as speech's failure says, in channel_open's own failure's words"); assert.deepEqual((await replyOpenFailedNotices({ opening: true, directs: true, selectedForOpening: 'server-bootstrap' })).told.map((t) => t.text), [ - "[channels] Not open: #x (x). Your reply there went out, but opening the channel failed. It's selected for opening again, as its server announces it open: the host tries again, and channel_close withdraws that.", + "[channels] Not open: #x (x). Your reply there went out, but opening the channel failed. It's selected for opening again, because its server lists it as open by default: the host tries again, and channel_close withdraws that.", ], "where the server's announcement lifted the default put back, the host tries again itself, so channel_open isn't named"); assert.deepEqual((await replyOpenFailedNotices({ opening: true, directs: true, selectedForOpening: 'subscription-policy' })).told.map((t) => t.text), [ - "[channels] Not open: #x (x). Your reply there went out, but opening the channel failed. It's selected for opening again, as the subscription policy admits it: the host tries again, and channel_close withdraws that.", + "[channels] Not open: #x (x). Your reply there went out, but opening the channel failed. It's selected for opening again, because the subscription policy admits it: the host tries again, and channel_close withdraws that.", ], 'where the subscription policy lifted it'); }); test("the reply notice names channel_open and channel_close only to an agent that can call them; where the replying agent has ended, no one is told unless the channel is selected for opening, and then the primary is, by name", async () => { const facts = '[channels] Not open: #x (x). Your reply there went out, but opening the channel failed.'; - const stays = " It stays selected for opening, as the host couldn't record undoing that: a later delivery or reconnect tries again"; + const stays = " It stays selected for opening, because the host couldn't record undoing that: a later delivery or reconnect tries again"; const fork = await replyOpenFailedNotices({ opening: true, directs: true, homes: [['agent', 'x']] }); assert.deepEqual(fork.told.map((t) => t.text), [facts], "a conversation fork's channel_open is refused outright"); @@ -2362,7 +2362,7 @@ test("the reply notice names channel_open and channel_close only to an agent tha assert.deepEqual(noClose.told.map((t) => t.text), [facts + stays + '. channel_open tries again.'], 'an agent whose allowedTools leave channel_close out'); - const again = " It's selected for opening again, as its server announces it open: the host tries again"; + const again = " It's selected for opening again, because its server lists it as open by default: the host tries again"; const forkAgain = await replyOpenFailedNotices({ opening: true, directs: true, homes: [['agent', 'x']], selectedForOpening: 'server-bootstrap' }); assert.deepEqual(forkAgain.told.map((t) => t.text), [facts + again + ', and channel_close withdraws that.'], 'a fork told the server lifted its home channel may close it'); @@ -2371,7 +2371,7 @@ test("the reply notice names channel_open and channel_close only to an agent tha assert.deepEqual(ended.told, [], 'the channel stays closed, so nothing follows from the failure'); assert.ok(ended.logged.some((l) => l.includes("agent has ended; not telling it that #x (x) couldn't be opened"))); - const endedFacts = "[channels] Not open: #x (x). agent's reply there went out, but opening the channel failed; agent has ended."; + const endedFacts = "[channels] Not open: #x (x). A reply from agent went out there, but opening the channel failed; agent has ended."; const endedAgain = await replyOpenFailedNotices({ opening: true, directs: true, live: ['primary'], primary: 'primary', selectedForOpening: 'server-bootstrap' }); assert.deepEqual(endedAgain.told, [{ text: endedFacts + again + ', and channel_close withdraws that.', @@ -2395,7 +2395,7 @@ test("the reply notice names channel_open and channel_close only to an agent tha test("a reply notice with no agent named, or an empty name, goes out unrouted, with its advice judged for, and its log naming, the agent addMessage delivers to", () => { const facts = '[channels] Not open: #x (x). Your reply there went out, but opening the channel failed.'; - const stays = " It stays selected for opening, as the host couldn't record undoing that: a later delivery or reconnect tries again"; + const stays = " It stays selected for opening, because the host couldn't record undoing that: a later delivery or reconnect tries again"; const cases: Array<{ why: string; name?: string; agents: Array<[string, string[]]>; primary: string | null; selectedForOpening?: 'unrecorded' | 'server-bootstrap' | 'subscription-policy'; @@ -2410,7 +2410,7 @@ test("a reply notice with no agent named, or an empty name, goes out unrouted, w reader: 'primary', text: facts + stays + '. channel_open tries again.' }, { why: 'selected again by the server, a primary that can open and close', agents: [['primary', []]], primary: 'primary', selectedForOpening: 'server-bootstrap', reader: 'primary', - text: facts + " It's selected for opening again, as its server announces it open: the host tries again, and channel_close withdraws that." }, + text: facts + " It's selected for opening again, because its server lists it as open by default: the host tries again, and channel_close withdraws that." }, { why: 'no primary: the first registered agent', agents: [['first', ['channel_open']], ['second', []]], primary: null, reader: 'first', text: facts }, { why: 'a primary that is not the first registered agent', agents: [['first', ['channel_open']], ['second', []]], primary: 'second', @@ -2442,9 +2442,11 @@ test("a reply notice with no agent named, or an empty name, goes out unrouted, w test("where a reply's reader can't close a channel selected for opening, the primary is told by name too, if it can close it", () => { const facts = '[channels] Not open: #x (x). Your reply there went out, but opening the channel failed.'; - const stays = " It stays selected for opening, as the host couldn't record undoing that: a later delivery or reconnect tries again"; - const again = " It's selected for opening again, as its server announces it open: the host tries again"; - const byName = (who: string) => `[channels] Not open: #x (x). ${who}'s reply there went out, but opening the channel failed, and ${who} can't close it.`; + const stays = " It stays selected for opening, because the host couldn't record undoing that: a later delivery or reconnect tries again"; + const again = " It's selected for opening again, because its server lists it as open by default: the host tries again"; + const byName = (first: string, again: string) => `[channels] Not open: #x (x). A reply from ${first} went out there, but opening the channel failed, and ${again} can't close it.`; + const forkByName = byName('your fork bound to y (fork)', 'that fork'); + const told = (primary: string) => ` ${primary} has been told, and can withdraw that.`; const cases: Array<{ why: string; name: string; agents: Array<[string, string[]]>; homes?: Array<[string, string]>; primary: string | null; selectedForOpening?: 'unrecorded' | 'server-bootstrap'; @@ -2453,14 +2455,14 @@ test("where a reply's reader can't close a channel selected for opening, the pri { why: 'a fork away from its home, selected again by its server', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'y']], primary: 'primary', selectedForOpening: 'server-bootstrap', told: [ - { text: facts + again + '.', metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, - { text: byName('fork') + again + ', and channel_close withdraws that.', metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, + { text: facts + again + '.' + told('Your primary'), metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, + { text: forkByName + again + ', and channel_close withdraws that.', metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, ] }, { why: "an agent whose allowedTools leave channel_close out, the put-back unrecorded", name: 'helper', agents: [['helper', ['channel_close']], ['primary', []]], primary: 'primary', selectedForOpening: 'unrecorded', told: [ - { text: facts + stays + '. channel_open tries again.', metadata: { system: true, kind: 'channel-notice' }, forAgent: 'helper' }, - { text: byName('helper') + stays + ', and channel_close withdraws that. channel_open tries again.', metadata: { system: true, kind: 'channel-notice', speaker: 'helper' } }, + { text: facts + stays + '.' + told('primary') + ' channel_open tries again.', metadata: { system: true, kind: 'channel-notice' }, forAgent: 'helper' }, + { text: byName('helper', 'helper') + stays + ', and channel_close withdraws that. channel_open tries again.', metadata: { system: true, kind: 'channel-notice', speaker: 'helper' } }, ] }, { why: 'a reader who can close it, beside a primary that can too', name: 'helper', agents: [['helper', []], ['primary', []]], primary: 'primary', selectedForOpening: 'server-bootstrap', @@ -2474,8 +2476,8 @@ test("where a reply's reader can't close a channel selected for opening, the pri { why: "a primary that can close it but not open it, the put-back unrecorded", name: 'fork', agents: [['fork', []], ['primary', ['channel_open']]], homes: [['fork', 'y']], primary: 'primary', selectedForOpening: 'unrecorded', told: [ - { text: facts + stays + '.', metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, - { text: byName('fork') + stays + ', and channel_close withdraws that.', metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, + { text: facts + stays + '.' + told('Your primary'), metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, + { text: forkByName + stays + ', and channel_close withdraws that.', metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, ] }, { why: 'a fork away from its home, the channel left closed', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'y']], primary: 'primary', @@ -2519,7 +2521,10 @@ test("where a reply's reader can't close a channel selected for opening, the pri // agent's send opened a channel it can't close, the primary is told by name // too, if it can close it. The reader's notice carries its inputs only where // it was told it can close the channels, so a release passes on only that. -test('the opened notice names channel_close only to a reader that can call it, and tells the primary by name where the agent whose send opened it can\'t', () => { +test('the opened notice names channel_close only to a reader that can call it, says what the channel delivers only to the agent that holds its traffic, and tells the primary by name where the agent whose send opened it can\'t close it', () => { + // What the channel delivers: said to the agent whose window holds its + // traffic. Where conversations are routed (router), that's a fork for its + // home alone; elsewhere, the agent addMessage delivers to. const optOut = ' What an open channel delivers to you depends on its server.'; const close = " If you don't want it, use channel_close; your choice outranks policy."; const closeMany = " If you don't want one of them, use channel_close; your choice outranks policy."; @@ -2528,43 +2533,52 @@ test('the opened notice names channel_close only to a reader that can call it, a const fromReply = { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: 'x', label: '#x' }] }; const cases: Array<{ why: string; name?: string; cause?: 'opened-by-reply' | 'subscription-policy'; channels?: Array<{ channelId: string; label?: string }>; - agents: Array<[string, string[]]>; homes?: Array<[string, string]>; primary: string | null; + agents: Array<[string, string[]]>; homes?: Array<[string, string]>; labels?: Array<[string, string]>; router?: boolean; primary: string | null; told: Array<{ text: string; metadata: unknown; forAgent?: string }>; }> = [ - { why: 'a reader that can close it', name: 'helper', agents: [['helper', []], ['primary', []]], primary: 'primary', - told: [{ text: own + optOut + close, metadata: opened(fromReply), forAgent: 'helper' }] }, - { why: 'a fork away from its home', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'y']], primary: 'primary', + { why: 'a reader that can close it, whose window holds no channel traffic', name: 'helper', agents: [['helper', []], ['primary', []]], primary: 'primary', + told: [{ text: own + close, metadata: opened(fromReply), forAgent: 'helper' }] }, + { why: 'a fork away from its home', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'y']], router: true, primary: 'primary', told: [ - { text: own + optOut, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, - { text: "[channels] Now open: #x (x): fork's own message into the closed channel opened it, and fork can't close it." + optOut + close, + { text: own + ' Your primary has been told and can close it.', metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, + { text: '[channels] Now open: #x (x): a message from your fork bound to y (fork) into the closed channel opened it, and that fork can\'t close it.' + close, metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, ] }, - { why: 'a fork at its home', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'x']], primary: 'primary', + { why: 'a fork away from a home the registry labels', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'y']], labels: [['y', '#dm-y']], router: true, primary: 'primary', + told: [ + { text: own + ' Your primary has been told and can close it.', metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, + { text: '[channels] Now open: #x (x): a message from your fork bound to #dm-y (y) (fork) into the closed channel opened it, and that fork can\'t close it.' + close, + metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, + ] }, + { why: 'a fork at its home', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'x']], router: true, primary: 'primary', told: [{ text: own + optOut + close, metadata: opened(fromReply), forAgent: 'fork' }] }, { why: 'an agent whose allowedTools leave channel_close out', name: 'helper', agents: [['helper', ['channel_close']], ['primary', []]], primary: 'primary', told: [ - { text: own + optOut, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'helper' }, - { text: "[channels] Now open: #x (x): helper's own message into the closed channel opened it, and helper can't close it." + optOut + close, + { text: own + ' primary has been told and can close it.', metadata: { system: true, kind: 'channel-notice' }, forAgent: 'helper' }, + { text: "[channels] Now open: #x (x): a message from helper into the closed channel opened it, and helper can't close it." + optOut + close, metadata: { system: true, kind: 'channel-notice', speaker: 'helper' } }, ] }, - { why: "a primary that can't close it either", name: 'fork', agents: [['fork', []], ['primary', ['channel_close']]], homes: [['fork', 'y']], primary: 'primary', - told: [{ text: own + optOut, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }] }, - { why: 'a primary named but not registered', name: 'fork', agents: [['fork', []], ['other', []]], homes: [['fork', 'y']], primary: 'gone', - told: [{ text: own + optOut, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }] }, + { why: "a primary that can't close it either", name: 'fork', agents: [['fork', []], ['primary', ['channel_close']]], homes: [['fork', 'y']], router: true, primary: 'primary', + told: [{ text: own, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }] }, + { why: 'a primary named but not registered', name: 'fork', agents: [['fork', []], ['other', []]], homes: [['fork', 'y']], router: true, primary: 'gone', + told: [{ text: own, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }] }, { why: 'the primary itself, without channel_close', name: 'primary', agents: [['primary', ['channel_close']]], primary: 'primary', told: [{ text: own + optOut, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'primary' }] }, { why: 'several channels, one away from a fork\'s home', name: 'fork', channels: [{ channelId: 'y', label: '#y' }, { channelId: 'x', label: '#x' }], - agents: [['fork', []], ['primary', []]], homes: [['fork', 'y']], primary: 'primary', + agents: [['fork', []], ['primary', []]], homes: [['fork', 'y']], router: true, primary: 'primary', told: [ - { text: '[channels] Now open: #y (y), #x (x): your own message into the closed channel opened it.' + optOut, + { text: '[channels] Now open: #y (y), #x (x): your own message into the closed channel opened it. Your primary has been told and can close them.', metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, - { text: "[channels] Now open: #y (y), #x (x): fork's own message into the closed channel opened it, and fork can't close them." + optOut + closeMany, + { text: "[channels] Now open: #y (y), #x (x): a message from your fork bound to y (fork) into the closed channel opened it, and that fork can't close them." + closeMany, metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, ] }, { why: 'a policy admission, to the primary that can close it', cause: 'subscription-policy', agents: [['primary', []]], primary: 'primary', // Addressed to no agent of its own, so it carries no inputs to pass on. told: [{ text: '[channels] Selected for opening by subscription policy: #x (x). This records the admission decision; channel_list shows current transport state.' + optOut + close, metadata: { system: true, kind: 'channel-notice' } }] }, + { why: 'a policy admission where conversations are routed, to the primary, whose window holds no channel traffic', cause: 'subscription-policy', agents: [['primary', []]], router: true, primary: 'primary', + told: [{ text: '[channels] Selected for opening by subscription policy: #x (x). This records the admission decision; channel_list shows current transport state.' + close, + metadata: { system: true, kind: 'channel-notice' } }] }, { why: "a policy admission, to a primary that can't close it", cause: 'subscription-policy', agents: [['primary', ['channel_close']]], primary: 'primary', told: [{ text: '[channels] Selected for opening by subscription policy: #x (x). This records the admission decision; channel_list shows current transport state.' + optOut, metadata: { system: true, kind: 'channel-notice' } }] }, @@ -2572,7 +2586,10 @@ test('the opened notice names channel_close only to a reader that can call it, a told: [{ text: '[channels] Selected for opening by subscription policy: #x (x). This records the admission decision; channel_list shows current transport state.' + optOut, metadata: { system: true, kind: 'channel-notice' } }] }, { why: "an agent that has ended, with a primary that can't close it", name: 'gone', agents: [['primary', ['channel_close']]], primary: 'primary', - told: [{ text: "[channels] Now open: #x (x): gone's own message into the closed channel opened it; gone has ended." + optOut, + told: [{ text: '[channels] Now open: #x (x): a message from gone into the closed channel opened it; gone has ended.' + optOut, + metadata: { system: true, kind: 'channel-notice', speaker: 'gone' } }] }, + { why: 'an agent that has ended, where conversations are routed', name: 'gone', agents: [['primary', []]], router: true, primary: 'primary', + told: [{ text: '[channels] Now open: #x (x): a message from gone into the closed channel opened it; gone has ended.' + close, metadata: { system: true, kind: 'channel-notice', speaker: 'gone' } }] }, ]; for (const c of cases) { @@ -2583,6 +2600,9 @@ test('the opened notice names channel_close only to a reader that can call it, a framework.primaryAgentName = c.primary; framework.usedEphemeralAgentNames = new Set(); framework.conversationAgentHomes = new Map(c.homes ?? []); + framework.conversationRouter = c.router ? {} : undefined; + const labels = new Map(c.labels ?? []); + framework.channelRegistry = { getDescriptor: (id: string) => (labels.has(id) ? { id, label: labels.get(id) } : undefined) }; framework.addMessage = (_participant: string, content: Array<{ text: string }>, metadata: unknown, routed?: { forAgent?: string }) => { told.push({ text: content[0]!.text, metadata, ...(routed ? { forAgent: routed.forAgent } : {}) }); return 'm'; @@ -2648,8 +2668,8 @@ test("a channel notice's inputs give the primary's by-name notice once its reade const reply = framework.noticeForPrimary('gone', { system: true, kind: 'channel-notice', passOn: { notice: 'reply-open-failed', channel: { channelId: 'x', label: '#x' }, selectedForOpening: 'unrecorded' } }); assert.deepEqual(reply?.content, [{ type: 'text', text: - "[channels] Not open: #x (x). gone's reply there went out, but opening the channel failed; gone has ended. " + - "It stays selected for opening, as the host couldn't record undoing that: a later delivery or reconnect tries again, and channel_close withdraws that." }], + "[channels] Not open: #x (x). A reply from gone went out there, but opening the channel failed; gone has ended. " + + "It stays selected for opening, because the host couldn't record undoing that: a later delivery or reconnect tries again, and channel_close withdraws that." }], "judged for the primary, which can close it and can't call channel_open"); assert.deepEqual(reply?.metadata, { system: true, kind: 'channel-notice', speaker: 'gone' }); for (const passOn of [ @@ -2672,7 +2692,7 @@ test("a channel notice's inputs give the primary's by-name notice once its reade // A channel with no label, or one whose label isn't text, is named by its id. const unlabeled = framework.noticeForPrimary('gone', { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-delivery', channels: [{ channelId: 'x', label: 9 }] } }); - assert.match(unlabeled?.content[0]?.text ?? '', /^\[channels\] Now open: x: gone's own message into the closed channel opened it; gone has ended\./); + assert.match(unlabeled?.content[0]?.text ?? '', /^\[channels\] Now open: x: a message from gone into the closed channel opened it; gone has ended\./); } finally { console.error = error; } diff --git a/test/route-failure-targeting.test.ts b/test/route-failure-targeting.test.ts index f6202d203..250ae9d8e 100644 --- a/test/route-failure-targeting.test.ts +++ b/test/route-failure-targeting.test.ts @@ -660,11 +660,11 @@ describe('notices about an agent\'s own action', () => { assert.equal(forkCm.mock.callCount(), 1); assert.equal(JSON.stringify(forkCm.mock.calls[0]!.arguments[1]), JSON.stringify([{ type: 'text', text: - '[channels] Now open: #one (c-1): your own message into the closed channel opened it. What an open channel delivers to you depends on its server.' }])); + '[channels] Now open: #one (c-1): your own message into the closed channel opened it. Your primary has been told and can close it.' }])); assert.equal(primary.mock.callCount(), 1, 'the primary is told too'); assert.equal(JSON.stringify(primary.mock.calls[0]!.arguments[1]), JSON.stringify([{ type: 'text', text: - `[channels] Now open: #one (c-1): ${fork.name}'s own message into the closed channel opened it, and ${fork.name} can't close it. ` + - "What an open channel delivers to you depends on its server. If you don't want it, use channel_close; your choice outranks policy." }])); + `[channels] Now open: #one (c-1): a message from your fork bound to missing-channel (${fork.name}) into the closed channel opened it, and that fork can't close it. ` + + "If you don't want it, use channel_close; your choice outranks policy." }])); assert.equal((primary.mock.calls[0]!.arguments[2] as { speaker?: unknown } | undefined)?.speaker, fork.name); // The advice left out is advice the fork can't take: its channel_close @@ -697,8 +697,8 @@ describe('notices about an agent\'s own action', () => { .filter((m) => JSON.stringify(m.content).includes('#home')); assert.equal(told.length, 1, 'the primary is told once'); assert.equal((told[0]!.content[0] as { text: string }).text, - `[channels] Now open: #home (missing-channel): ${fork.name}'s own message into the closed channel opened it; ${fork.name} has ended. ` + - "What an open channel delivers to you depends on its server. If you don't want it, use channel_close; your choice outranks policy."); + `[channels] Now open: #home (missing-channel): a message from your fork bound to missing-channel (${fork.name}) into the closed channel opened it; that fork has ended. ` + + "If you don't want it, use channel_close; your choice outranks policy."); assert.equal((told[0]!.metadata as { speaker?: unknown } | undefined)?.speaker, fork.name); }); @@ -713,7 +713,7 @@ describe('notices about an agent\'s own action', () => { const told = framework.getAgent('primary')!.getContextManager().getAllMessages() .filter((m) => JSON.stringify(m.content).includes('#one')); assert.equal(told.length, 1, 'only the notice of the time'); - assert.match(JSON.stringify(told[0]!.content), new RegExp(`${fork.name} can't close it`)); + assert.match(JSON.stringify(told[0]!.content), new RegExp(`your fork bound to missing-channel \\(${fork.name}\\) into the closed channel opened it, and that fork can't close it`)); }); it('tells the primary, by name, of a channel an ended agent\'s send opened, and the primary\'s channel_close closes it', async (t) => { @@ -734,7 +734,7 @@ describe('notices about an agent\'s own action', () => { const told = framework.getAgent('primary')!.getContextManager().getAllMessages() .filter((m) => JSON.stringify(m.content).includes('Now open: #opened (opened-1)')); assert.equal(told.length, 1, 'the primary is told once'); - assert.match(JSON.stringify(told[0]!.content), /released-run's own message into the closed channel opened it; released-run has ended\. .*use channel_close/); + assert.match(JSON.stringify(told[0]!.content), /a message from released-run into the closed channel opened it; released-run has ended\. .*use channel_close/); assert.equal((told[0]!.metadata as { speaker?: unknown } | undefined)?.speaker, 'released-run'); assert.equal(state.deferredMessages.length, 0); @@ -833,10 +833,10 @@ describe('notices about an agent\'s own action', () => { const told = framework.getAgent('primary')!.getContextManager().getAllMessages() .filter((m) => JSON.stringify(m.content).includes('conversation-gone')); assert.deepEqual(told.map((m) => (m.content[0] as { text: string }).text), [ - "[channels] Now open: #four (c-4): conversation-gone's own message into the closed channel opened it; conversation-gone has ended. " + - "What an open channel delivers to you depends on its server. If you don't want it, use channel_close; your choice outranks policy.", - "[channels] Not open: #five (c-5). conversation-gone's reply there went out, but opening the channel failed; conversation-gone has ended. " + - "It's selected for opening again, as its server announces it open: the host tries again, and channel_close withdraws that.", + "[channels] Now open: #four (c-4): a message from conversation-gone into the closed channel opened it; conversation-gone has ended. " + + "If you don't want it, use channel_close; your choice outranks policy.", + "[channels] Not open: #five (c-5). A reply from conversation-gone went out there, but opening the channel failed; conversation-gone has ended. " + + "It's selected for opening again, because its server lists it as open by default: the host tries again, and channel_close withdraws that.", ]); assert.deepEqual(told.map((m) => (m.metadata as { deferredWriteId?: unknown; speaker?: unknown }).deferredWriteId), ['w-4', 'w-5']); assert.deepEqual(told.map((m) => (m.metadata as { speaker?: unknown }).speaker), ['conversation-gone', 'conversation-gone']); @@ -1251,7 +1251,7 @@ describe('notices about an agent\'s own action', () => { content: [{ type: 'text', text: '[channels] Now open: #seven (c-7): your own message into the closed channel opened it.' }], metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-delivery', channels: [{ channelId: 'c-7', label: '#seven' }] } }, inRun: 'Now open: #seven', - toPrimary: "worker's own message into the closed channel opened it; worker has ended", + toPrimary: "a message from worker into the closed channel opened it; worker has ended", }, ]; for (const queued of queuedForTheRun) it(`passes ${queued.what} still queued when a run ends unquiesced on to the primary as the same queue entry`, async () => { @@ -1333,7 +1333,7 @@ describe('notices about an agent\'s own action', () => { what: 'a channel notice the run could act on', content: [{ type: 'text' as const, text: '[channels] Now open: #eight (c-8): your own message into the closed channel opened it.' }], metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: 'c-8', label: '#eight' }] } }, - toPrimary: "worker's own message into the closed channel opened it; worker has ended", + toPrimary: "a message from worker into the closed channel opened it; worker has ended", }, ]; for (const unread of storedUnread) it(`passes ${unread.what} a run stored unread on to the primary\'s queue when the host stops before the release it waited for`, async () => { From bd235c41a80b29100e6bf52a2b2b497036d9fc7b Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 19 Oct 2026 19:10:33 -0400 Subject: [PATCH 06/12] fix(channels): residents share channel traffic, say the primary was told only once it was, and name a fork plainly - holdsChannelTraffic counts every ordinary resident where conversations aren't routed. An incoming message is stored with no forAgent, in deliveryAgent()'s store, and residents read one shared message slot, so a second resident was wrongly left out of "What an open channel delivers to you". A run's store is its own, so it holds none unless it's the delivery agent itself, as in a host with no primary. - The told clause ("Your primary has been told and can close it.") can no longer outrun the write it reports. Each builder lists the primary's by-name notice first, and the reader's carries its words without the clause (untold). One writer, writeChannelNotices, now serves both notices: it writes them in order, each on its own, and writes the reader's untold words where the primary's write failed. - The primary's notices name a fork as "your fork in #dm (dm), named conversation-dm-...," rather than with two parentheses in a row, and "your fork" and "Your primary" apply only where the router's template is the primary (forkOfPrimary). A fork made from another template is "the fork", and is told the primary's name. - The changelog fragment covers the notices' new words. Tests: the opened notice's table covers a second resident (which holds the traffic), a run with its own store (which doesn't), a run that is the delivery agent with no primary, and a fork made from another template; both tables list the primary's notice first; the write test covers a failed write of either notice, and the reader's untold words. Co-Authored-By: Claude Opus 5.5 --- ...annel-notices-reach-who-can-close.fixed.md | 16 +- src/framework.ts | 153 +++++++++++------- test/channel-lifecycle-order.test.ts | 117 +++++++++----- test/route-failure-targeting.test.ts | 6 +- 4 files changed, 186 insertions(+), 106 deletions(-) diff --git a/changelog.d/channel-notices-reach-who-can-close.fixed.md b/changelog.d/channel-notices-reach-who-can-close.fixed.md index 920551ab2..a8202bbb8 100644 --- a/changelog.d/channel-notices-reach-who-can-close.fixed.md +++ b/changelog.d/channel-notices-reach-who-can-close.fixed.md @@ -4,9 +4,19 @@ it names. A conversation fork can close only its home channel, and an agent's `allowedTools` can leave `channel_close` out. Before, such an agent was told to use `channel_close`, and couldn't call it, or its call was - refused. Where an agent's - own send opened a channel it can't close, the primary is now told by name - too, if it can close it. + refused. Where an agent's own send opened a channel it can't close, the + primary is now told by name too, if it can close it, and the agent is told + that it was. +- The opened notice says "What an open channel delivers to you depends on + its server" only to an agent whose window holds the channel's traffic. + Before, a conversation fork was told it of a channel other than its home, + whose messages never reach it. +- The primary's notices name a conversation fork by the channel it's bound + to, then by its generated name. The notice of an open by an agent that has + ended says "a message from" it, where it said "opened by X's reply" whether + a reply or speech had opened the channel. The reply notice and speech's + failure notice say why a channel is selected for opening again with + "because", where they said "as". - When a conversation fork or an ephemeral run is released without reading a channel notice it could act on (a channel its own send opened, or a channel its reply couldn't open that is selected for opening), the primary diff --git a/src/framework.ts b/src/framework.ts index c95c47a9c..2e2c4dead 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2172,9 +2172,13 @@ type ChannelOpenedCause = 'subscription-policy' | 'opened-by-delivery' | 'opened /** A channel notice as its builder returns it: its text, its metadata, whom * it's addressed to (none: where addMessage delivers an unrouted message), - * and what its log line says. */ + * and what its log line says. A reader's notice that says the primary was + * told carries `untold`, its words without saying so, which + * writeChannelNotices writes instead where the primary's notice, written + * before it, didn't land. */ interface ChannelNotice { text: string; + untold?: string; metadata: MessageMetadata; target?: { forAgent: string }; told: string; @@ -15077,10 +15081,12 @@ export class AgentFramework { * names (canCloseChannels): a conversation fork may close only its home * channel, and an agent's allowedTools can leave channel_close out. Where an * agent's own send opened a channel it can't close, the primary is told by - * name too, if it's registered and can close it. What an open channel - * delivers is its server's to say (#406), so the notice doesn't promise its - * traffic. System message: never triggers inference, never conversational, - * never locus-eligible. + * name too, if it's registered and can close it, and the agent is told + * that it was. What an open channel delivers is its server's to say (#406), + * so the notice doesn't promise its traffic, and it says even that only to + * an agent whose window holds the channel's traffic (holdsChannelTraffic). + * System message: never triggers inference, never conversational, never + * locus-eligible. */ private recordChannelAutoOpenNotice( agentName: string | undefined, @@ -15088,24 +15094,39 @@ export class AgentFramework { cause: ChannelOpenedCause, ): void { if (channels.length === 0) return; - for (const notice of this.channelOpenedNotices(agentName, channels, cause)) { + this.writeChannelNotices(this.channelOpenedNotices(agentName, channels, cause), `auto-open notice (${cause})`, 'recordChannelAutoOpenNotice'); + } + + /** + * Write a builder's channel notices in order, each on its own: one that + * can't be written is logged, and the rest are still written. A reader's + * notice that says the primary was told comes after the primary's, and is + * written in its `untold` words where the primary's didn't land, so it + * never says the primary was told of what its window doesn't hold. Through + * framework.addMessage for mid-turn safety (deferral + hear-while-acting + * injection): an open a delivery forces happens inside a live turn, and a + * direct context-manager append there would store the notice out of order. + */ + private writeChannelNotices(notices: readonly ChannelNotice[], what: string, writer: string): void { + let allWritten = true; + for (const notice of notices) { + const text = allWritten || notice.untold === undefined ? notice.text : notice.untold; try { - // Through framework.addMessage for mid-turn safety (deferral + - // hear-while-acting injection) — a delivery-forced open happens inside - // a live turn, and a direct context-manager append there would store - // the notice out of order. - this.addMessage('user', [{ type: 'text', text: notice.text }], notice.metadata, notice.target); - console.error(`[channel] auto-open notice (${cause}) -> ${notice.told}: ${notice.shown}`); + this.addMessage('user', [{ type: 'text', text }], notice.metadata, notice.target); + console.error(`[channel] ${what} -> ${notice.told}: ${notice.shown}`); } catch (err) { - console.error('recordChannelAutoOpenNotice failed:', err); + allWritten = false; + console.error(`${writer} failed:`, err); } } } /** - * The notices recordChannelAutoOpenNotice writes, in order: the reader's, - * and the primary's by name where the reader can't close the channels. - * None, logged, for an agent that has ended when there's no primary to tell. + * The notices recordChannelAutoOpenNotice writes, in order: the primary's + * by name where the reader can't close the channels, then the reader's, + * which then says the primary was told (`untold`: its words where that + * didn't land). None, logged, for an agent that has ended when there's no + * primary to tell. * The reader's carries its inputs (`passOn`) where it's addressed to the * agent whose send opened the channels and that agent was told it can close * them, so a release that finds it unread can tell the primary in its place @@ -15164,13 +15185,14 @@ export class AgentFramework { // can close them, and that agent can't.) const tellPrimary = !!agentName && !canClose && primary !== undefined && this.agents.has(primary) && this.canCloseChannels(primary, ids); - const notices: ChannelNotice[] = [{ - text: (cause === 'subscription-policy' - ? `[channels] Selected for opening by subscription policy: ${shown}. ` + - 'This records the admission decision; channel_list shows current transport state.' - : `[channels] Now open: ${shown}: your own message into the closed channel opened it.`) + - optOut(this.holdsChannelTraffic(who, ids), canClose) + - (tellPrimary ? ` ${this.primaryForReader(agentName, primary)} has been told and can close ${plural ? 'them' : 'it'}.` : ''), + const readerText = (told: string) => (cause === 'subscription-policy' + ? `[channels] Selected for opening by subscription policy: ${shown}. ` + + 'This records the admission decision; channel_list shows current transport state.' + : `[channels] Now open: ${shown}: your own message into the closed channel opened it.`) + + optOut(this.holdsChannelTraffic(who, ids), canClose) + told; + const reader: ChannelNotice = { + text: readerText(tellPrimary ? ` ${this.primaryForReader(agentName, primary)} has been told and can close ${plural ? 'them' : 'it'}.` : ''), + ...(tellPrimary ? { untold: readerText('') } : {}), metadata: { system: true, kind: 'channel-notice', ...(agentName && canClose ? { passOn: { notice: 'channel-opened', cause, channels } satisfies ChannelNoticePassOn } : {}), @@ -15178,7 +15200,8 @@ export class AgentFramework { ...(agentName ? { target: { forAgent: agentName } } : {}), told: agentName ?? 'primary', shown, - }]; + }; + const notices: ChannelNotice[] = []; if (tellPrimary) { const sender = this.agentForPrimary(agentName); notices.push({ @@ -15189,6 +15212,7 @@ export class AgentFramework { shown, }); } + notices.push(reader); return notices; } @@ -15206,11 +15230,13 @@ export class AgentFramework { /** * Whether `who` holds the traffic of every one of `channelIds`, so that - * what an open channel delivers is stored in its window. A conversation - * fork holds only its home's: where conversations are routed, an incoming + * what an open channel delivers is in its window. A conversation fork + * holds only its home's: where conversations are routed, an incoming * message goes to the fork bound to its channel or nowhere, and never to * the primary (handleMcplChannelIncoming). Elsewhere an incoming message is - * stored with no forAgent, in deliveryAgent()'s window. (A push names its + * stored with no forAgent, in deliveryAgent()'s store, and where that's the + * residents' shared slot, every ordinary resident reads it + * (isOrdinaryResident); a run's store is its own. (A push names its * channel and lands in the primary's window either way, but it comes * whether or not the channel is open, so it isn't what opening one * delivers.) @@ -15220,14 +15246,19 @@ export class AgentFramework { const home = this.conversationAgentHomes.get(who); if (home !== undefined) return channelIds.every((id) => id === home); if (this.conversationRouter) return false; - return who === this.deliveryAgent()?.name; + const delivery = this.deliveryAgent(); + const agent = this.agents.get(who); + if (delivery === undefined || agent === undefined) return false; + return agent === delivery || (this.isOrdinaryResident(agent) && this.isOrdinaryResident(delivery)); } /** * How a notice to the primary names `agentName`: a conversation fork by the - * channel it's bound to, as notices name channels, with its own name once - * in parentheses, and as "that fork" after; any other agent by its name. - * A fork's home is known while it's registered and through its release; a + * channel it's bound to, as notices name channels, with its own name once, + * after "named", and as "that fork" after that ("your fork" where the + * primary is the template forks are made from); any other agent by its + * name. `first` is said mid-sentence, so a fork's ends with a comma. A + * fork's home is known while it's registered and through its release; a * notice passed on later, at resume or at boot, names it by its name. */ private agentForPrimary(agentName: string): { first: string; again: string } { @@ -15235,17 +15266,26 @@ export class AgentFramework { if (home === undefined) return { first: agentName, again: agentName }; const label = this.channelRegistry?.getDescriptor(home)?.label; const bound = label && label !== home ? `${label} (${home})` : home; - return { first: `your fork bound to ${bound} (${agentName})`, again: 'that fork' }; + const fork = this.forkOfPrimary(agentName) ? 'your fork' : 'the fork'; + return { first: `${fork} in ${bound}, named ${agentName},`, again: 'that fork' }; } /** * How a notice to `reader` names the primary it says was told: "Your - * primary" to a conversation fork, whose configuration is the template's, - * the primary's, with only its name changed, so its prompt may give the - * primary's name as its own; the primary's name to any other agent. + * primary" to a conversation fork made from the primary, whose + * configuration is the primary's with only its name changed, so its prompt + * may give the primary's name as its own; the primary's name to any other + * agent, a fork made from another template included. */ private primaryForReader(reader: string | undefined, primary: string | undefined): string { - return reader !== undefined && this.conversationAgentHomes.has(reader) ? 'Your primary' : primary ?? 'The primary'; + return reader !== undefined && this.forkOfPrimary(reader) ? 'Your primary' : primary ?? 'The primary'; + } + + /** Whether `agentName` is a conversation fork made from the primary: the + * router's template is the primary (connectome-host's is). */ + private forkOfPrimary(agentName: string): boolean { + return this.conversationAgentHomes.has(agentName) && this.primaryAgentName !== null && + this.conversationRouter?.templateAgent === this.primaryAgentName; } /** @@ -15273,27 +15313,22 @@ export class AgentFramework { * named. Either way channel_close withdraws that, named only to an agent * that can call it here (a fork may close only its home channel). Where * the reader can't, the primary is told by name too, if it's registered - * and can close it. System message, as the opened notice is. + * and can close it, and the reader is told that it was. System message, as + * the opened notice is. */ private recordReplyOpenFailedNotice( agentName: string | undefined, channel: { channelId: string; label?: string }, selectedForOpening?: SelectedForOpening, ): void { - for (const notice of this.replyOpenFailedNotices(agentName, channel, selectedForOpening)) { - try { - this.addMessage('user', [{ type: 'text', text: notice.text }], notice.metadata, notice.target); - console.error(`[channel] reply open-failed notice -> ${notice.told}: ${notice.shown}`); - } catch (err) { - console.error('recordReplyOpenFailedNotice failed:', err); - } - } + this.writeChannelNotices(this.replyOpenFailedNotices(agentName, channel, selectedForOpening), 'reply open-failed notice', 'recordReplyOpenFailedNotice'); } /** - * The notices recordReplyOpenFailedNotice writes, in order: the reader's, - * and the primary's by name where the reader can't close a channel selected - * for opening. None, logged, where the agent has ended and the channel + * The notices recordReplyOpenFailedNotice writes, in order: the primary's + * by name where the reader can't close a channel selected for opening, + * then the reader's, which then says the primary was told (`untold`: its + * words where that didn't land). None, logged, where the agent has ended and the channel * stays closed, or there's no primary to tell. The reader's carries its * inputs (`passOn`) where it's addressed to the agent whose reply it was, * the channel is selected for opening and that agent was told it can close @@ -15337,14 +15372,16 @@ export class AgentFramework { primary !== undefined && this.agents.has(primary) && this.canCloseChannels(primary, [channel.channelId]); const sender = ended ? this.agentForPrimary(agentName!) : undefined; - const notices: ChannelNotice[] = [{ - text: `[channels] Not open: ${shown}. ` + - (ended - ? `A reply from ${sender!.first} went out there, but opening the channel failed; ${sender!.again} has ended.` - : 'Your reply there went out, but opening the channel failed.') + - (selected ? selected + (canClose ? ', and channel_close withdraws that.' : '.') : '') + - (tellPrimary ? ` ${this.primaryForReader(who, primary)} has been told, and can withdraw that.` : '') + - (canOpen && !hostRetries ? ' channel_open tries again.' : ''), + const readerText = (told: string) => `[channels] Not open: ${shown}. ` + + (ended + ? `A reply from ${sender!.first} went out there, but opening the channel failed; ${sender!.again} has ended.` + : 'Your reply there went out, but opening the channel failed.') + + (selected ? selected + (canClose ? ', and channel_close withdraws that.' : '.') : '') + + told + + (canOpen && !hostRetries ? ' channel_open tries again.' : ''); + const notice: ChannelNotice = { + text: readerText(tellPrimary ? ` ${this.primaryForReader(who, primary)} has been told, and can withdraw that.` : ''), + ...(tellPrimary ? { untold: readerText('') } : {}), metadata: (ended ? { system: true, kind: 'channel-notice', speaker: agentName } : { @@ -15356,7 +15393,8 @@ export class AgentFramework { ...(agentName && !ended ? { target: { forAgent: agentName } } : {}), told: ended ? `the primary, by name (${agentName} has ended)` : who ?? 'no agent', shown, - }]; + }; + const notices: ChannelNotice[] = []; if (tellPrimary) { const primaryCanOpen = !this.conversationAgentHomes.has(primary!) && this.canAgentUseTool(primary!, 'channel_open'); const reader = this.agentForPrimary(who!); @@ -15369,6 +15407,7 @@ export class AgentFramework { shown, }); } + notices.push(notice); return notices; } diff --git a/test/channel-lifecycle-order.test.ts b/test/channel-lifecycle-order.test.ts index 2da119646..af9595fb9 100644 --- a/test/channel-lifecycle-order.test.ts +++ b/test/channel-lifecycle-order.test.ts @@ -2445,7 +2445,7 @@ test("where a reply's reader can't close a channel selected for opening, the pri const stays = " It stays selected for opening, because the host couldn't record undoing that: a later delivery or reconnect tries again"; const again = " It's selected for opening again, because its server lists it as open by default: the host tries again"; const byName = (first: string, again: string) => `[channels] Not open: #x (x). A reply from ${first} went out there, but opening the channel failed, and ${again} can't close it.`; - const forkByName = byName('your fork bound to y (fork)', 'that fork'); + const forkByName = byName('your fork in y, named fork,', 'that fork'); const told = (primary: string) => ` ${primary} has been told, and can withdraw that.`; const cases: Array<{ why: string; name: string; agents: Array<[string, string[]]>; homes?: Array<[string, string]>; primary: string | null; @@ -2455,14 +2455,14 @@ test("where a reply's reader can't close a channel selected for opening, the pri { why: 'a fork away from its home, selected again by its server', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'y']], primary: 'primary', selectedForOpening: 'server-bootstrap', told: [ - { text: facts + again + '.' + told('Your primary'), metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, { text: forkByName + again + ', and channel_close withdraws that.', metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, + { text: facts + again + '.' + told('Your primary'), metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, ] }, { why: "an agent whose allowedTools leave channel_close out, the put-back unrecorded", name: 'helper', agents: [['helper', ['channel_close']], ['primary', []]], primary: 'primary', selectedForOpening: 'unrecorded', told: [ - { text: facts + stays + '.' + told('primary') + ' channel_open tries again.', metadata: { system: true, kind: 'channel-notice' }, forAgent: 'helper' }, { text: byName('helper', 'helper') + stays + ', and channel_close withdraws that. channel_open tries again.', metadata: { system: true, kind: 'channel-notice', speaker: 'helper' } }, + { text: facts + stays + '.' + told('primary') + ' channel_open tries again.', metadata: { system: true, kind: 'channel-notice' }, forAgent: 'helper' }, ] }, { why: 'a reader who can close it, beside a primary that can too', name: 'helper', agents: [['helper', []], ['primary', []]], primary: 'primary', selectedForOpening: 'server-bootstrap', @@ -2476,8 +2476,8 @@ test("where a reply's reader can't close a channel selected for opening, the pri { why: "a primary that can close it but not open it, the put-back unrecorded", name: 'fork', agents: [['fork', []], ['primary', ['channel_open']]], homes: [['fork', 'y']], primary: 'primary', selectedForOpening: 'unrecorded', told: [ - { text: facts + stays + '.' + told('Your primary'), metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, { text: forkByName + stays + ', and channel_close withdraws that.', metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, + { text: facts + stays + '.' + told('Your primary'), metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, ] }, { why: 'a fork away from its home, the channel left closed', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'y']], primary: 'primary', @@ -2498,6 +2498,8 @@ test("where a reply's reader can't close a channel selected for opening, the pri framework.primaryAgentName = c.primary; framework.usedEphemeralAgentNames = new Set(); framework.conversationAgentHomes = new Map(c.homes ?? []); + // Forks are made where conversations are routed, here from the primary. + framework.conversationRouter = c.homes ? { templateAgent: 'primary' } : undefined; framework.addMessage = (_participant: string, content: Array<{ text: string }>, metadata: unknown, routed?: { forAgent?: string }) => { told.push({ text: content[0]!.text, metadata, ...(routed ? { forAgent: routed.forAgent } : {}) }); return 'm'; @@ -2533,30 +2535,40 @@ test('the opened notice names channel_close only to a reader that can call it, s const fromReply = { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: 'x', label: '#x' }] }; const cases: Array<{ why: string; name?: string; cause?: 'opened-by-reply' | 'subscription-policy'; channels?: Array<{ channelId: string; label?: string }>; - agents: Array<[string, string[]]>; homes?: Array<[string, string]>; labels?: Array<[string, string]>; router?: boolean; primary: string | null; + agents: Array<[string, string[]]>; homes?: Array<[string, string]>; labels?: Array<[string, string]>; isolated?: string[]; + router?: boolean; template?: string; primary: string | null; told: Array<{ text: string; metadata: unknown; forAgent?: string }>; }> = [ - { why: 'a reader that can close it, whose window holds no channel traffic', name: 'helper', agents: [['helper', []], ['primary', []]], primary: 'primary', - told: [{ text: own + close, metadata: opened(fromReply), forAgent: 'helper' }] }, + { why: "a second resident that can close it, which reads the residents' shared slot", name: 'helper', agents: [['helper', []], ['primary', []]], primary: 'primary', + told: [{ text: own + optOut + close, metadata: opened(fromReply), forAgent: 'helper' }] }, + { why: 'a run that can close it, whose store is its own and holds no channel traffic', name: 'run', agents: [['run', []], ['primary', []]], isolated: ['run'], primary: 'primary', + told: [{ text: own + close, metadata: opened(fromReply), forAgent: 'run' }] }, { why: 'a fork away from its home', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'y']], router: true, primary: 'primary', told: [ - { text: own + ' Your primary has been told and can close it.', metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, - { text: '[channels] Now open: #x (x): a message from your fork bound to y (fork) into the closed channel opened it, and that fork can\'t close it.' + close, + { text: "[channels] Now open: #x (x): a message from your fork in y, named fork, into the closed channel opened it, and that fork can't close it." + close, metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, + { text: own + ' Your primary has been told and can close it.', metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, ] }, { why: 'a fork away from a home the registry labels', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'y']], labels: [['y', '#dm-y']], router: true, primary: 'primary', told: [ + { text: "[channels] Now open: #x (x): a message from your fork in #dm-y (y), named fork, into the closed channel opened it, and that fork can't close it." + close, + metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, { text: own + ' Your primary has been told and can close it.', metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, - { text: '[channels] Now open: #x (x): a message from your fork bound to #dm-y (y) (fork) into the closed channel opened it, and that fork can\'t close it.' + close, + ] }, + { why: "a fork made from a template that isn't the primary", name: 'fork', agents: [['fork', []], ['primary', []], ['other', []]], homes: [['fork', 'y']], + router: true, template: 'other', primary: 'primary', + told: [ + { text: "[channels] Now open: #x (x): a message from the fork in y, named fork, into the closed channel opened it, and that fork can't close it." + close, metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, + { text: own + ' primary has been told and can close it.', metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, ] }, { why: 'a fork at its home', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'x']], router: true, primary: 'primary', told: [{ text: own + optOut + close, metadata: opened(fromReply), forAgent: 'fork' }] }, - { why: 'an agent whose allowedTools leave channel_close out', name: 'helper', agents: [['helper', ['channel_close']], ['primary', []]], primary: 'primary', + { why: 'a second resident whose allowedTools leave channel_close out', name: 'helper', agents: [['helper', ['channel_close']], ['primary', []]], primary: 'primary', told: [ - { text: own + ' primary has been told and can close it.', metadata: { system: true, kind: 'channel-notice' }, forAgent: 'helper' }, { text: "[channels] Now open: #x (x): a message from helper into the closed channel opened it, and helper can't close it." + optOut + close, metadata: { system: true, kind: 'channel-notice', speaker: 'helper' } }, + { text: own + optOut + ' primary has been told and can close it.', metadata: { system: true, kind: 'channel-notice' }, forAgent: 'helper' }, ] }, { why: "a primary that can't close it either", name: 'fork', agents: [['fork', []], ['primary', ['channel_close']]], homes: [['fork', 'y']], router: true, primary: 'primary', told: [{ text: own, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }] }, @@ -2564,13 +2576,13 @@ test('the opened notice names channel_close only to a reader that can call it, s told: [{ text: own, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }] }, { why: 'the primary itself, without channel_close', name: 'primary', agents: [['primary', ['channel_close']]], primary: 'primary', told: [{ text: own + optOut, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'primary' }] }, - { why: 'several channels, one away from a fork\'s home', name: 'fork', channels: [{ channelId: 'y', label: '#y' }, { channelId: 'x', label: '#x' }], + { why: "several channels, one away from a fork's home", name: 'fork', channels: [{ channelId: 'y', label: '#y' }, { channelId: 'x', label: '#x' }], agents: [['fork', []], ['primary', []]], homes: [['fork', 'y']], router: true, primary: 'primary', told: [ + { text: "[channels] Now open: #y (y), #x (x): a message from your fork in y, named fork, into the closed channel opened it, and that fork can't close them." + closeMany, + metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, { text: '[channels] Now open: #y (y), #x (x): your own message into the closed channel opened it. Your primary has been told and can close them.', metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, - { text: "[channels] Now open: #y (y), #x (x): a message from your fork bound to y (fork) into the closed channel opened it, and that fork can't close them." + closeMany, - metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, ] }, { why: 'a policy admission, to the primary that can close it', cause: 'subscription-policy', agents: [['primary', []]], primary: 'primary', // Addressed to no agent of its own, so it carries no inputs to pass on. @@ -2582,7 +2594,8 @@ test('the opened notice names channel_close only to a reader that can call it, s { why: "a policy admission, to a primary that can't close it", cause: 'subscription-policy', agents: [['primary', ['channel_close']]], primary: 'primary', told: [{ text: '[channels] Selected for opening by subscription policy: #x (x). This records the admission decision; channel_list shows current transport state.' + optOut, metadata: { system: true, kind: 'channel-notice' } }] }, - { why: "a policy admission with no primary, to the first agent, which can't close it", cause: 'subscription-policy', agents: [['worker', ['channel_close']]], primary: null, + { why: "a policy admission with no primary, to the first agent, a run whose store is its own, which can't close it", cause: 'subscription-policy', + agents: [['worker', ['channel_close']]], isolated: ['worker'], primary: null, told: [{ text: '[channels] Selected for opening by subscription policy: #x (x). This records the admission decision; channel_list shows current transport state.' + optOut, metadata: { system: true, kind: 'channel-notice' } }] }, { why: "an agent that has ended, with a primary that can't close it", name: 'gone', agents: [['primary', ['channel_close']]], primary: 'primary', @@ -2600,7 +2613,10 @@ test('the opened notice names channel_close only to a reader that can call it, s framework.primaryAgentName = c.primary; framework.usedEphemeralAgentNames = new Set(); framework.conversationAgentHomes = new Map(c.homes ?? []); - framework.conversationRouter = c.router ? {} : undefined; + framework.conversationRouter = c.router ? { templateAgent: c.template ?? 'primary' } : undefined; + // Residents read one shared slot; a fork's store, or a run's, is its own. + framework.sharedSlotAgents = new Set([...framework.agents.values()] + .filter((a: { name: string }) => !framework.conversationAgentHomes.has(a.name) && !(c.isolated ?? []).includes(a.name))); const labels = new Map(c.labels ?? []); framework.channelRegistry = { getDescriptor: (id: string) => (labels.has(id) ? { id, label: labels.get(id) } : undefined) }; framework.addMessage = (_participant: string, content: Array<{ text: string }>, metadata: unknown, routed?: { forAgent?: string }) => { @@ -2621,34 +2637,49 @@ test('the opened notice names channel_close only to a reader that can call it, s } }); -// Each notice is written on its own: a reader's notice that can't be -// written leaves the primary's to be written still. -test("a reader's notice that can't be written still leaves the primary's by-name notice written, for both notices", () => { +// Each notice is written on its own: one that can't be written leaves the +// other to be written still. The primary's is written first, and the +// reader's says the primary was told only where the primary's landed. +test("each channel notice is written on its own, and the reader's says the primary was told only where the primary's landed, for both notices", () => { for (const write of ['opened', 'reply'] as const) { - const told: Array<{ text: string; forAgent?: string }> = []; - const logged: string[] = []; - const framework = Object.create(AgentFramework.prototype) as any; - framework.agents = new Map([['fork', { name: 'fork', canUseTool: () => true }], ['primary', { name: 'primary', canUseTool: () => true }]]); - framework.primaryAgentName = 'primary'; - framework.usedEphemeralAgentNames = new Set(); - framework.conversationAgentHomes = new Map([['fork', 'y']]); - framework.addMessage = (_participant: string, content: Array<{ text: string }>, _metadata: unknown, routed?: { forAgent?: string }) => { - if (routed?.forAgent) throw new Error('store refused the write'); - told.push({ text: content[0]!.text }); - return 'm'; - }; - const error = console.error; - console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; - try { - if (write === 'opened') framework.recordChannelAutoOpenNotice('fork', [{ channelId: 'x', label: '#x' }], 'opened-by-reply'); - else framework.recordReplyOpenFailedNotice('fork', { channelId: 'x', label: '#x' }, 'server-bootstrap'); - } finally { - console.error = error; + for (const refused of ['reader', 'primary'] as const) { + const told: Array<{ text: string; forAgent?: string }> = []; + const logged: string[] = []; + const framework = Object.create(AgentFramework.prototype) as any; + framework.agents = new Map([['fork', { name: 'fork', canUseTool: () => true }], ['primary', { name: 'primary', canUseTool: () => true }]]); + framework.primaryAgentName = 'primary'; + framework.usedEphemeralAgentNames = new Set(); + framework.conversationAgentHomes = new Map([['fork', 'y']]); + framework.conversationRouter = { templateAgent: 'primary' }; + framework.addMessage = (_participant: string, content: Array<{ text: string }>, _metadata: unknown, routed?: { forAgent?: string }) => { + // The reader's notice is addressed to the fork; the primary's to no one. + if ((refused === 'reader') === !!routed?.forAgent) throw new Error('store refused the write'); + told.push({ text: content[0]!.text, ...(routed?.forAgent ? { forAgent: routed.forAgent } : {}) }); + return 'm'; + }; + const error = console.error; + console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; + try { + if (write === 'opened') framework.recordChannelAutoOpenNotice('fork', [{ channelId: 'x', label: '#x' }], 'opened-by-reply'); + else framework.recordReplyOpenFailedNotice('fork', { channelId: 'x', label: '#x' }, 'server-bootstrap'); + } finally { + console.error = error; + } + const why = `${write}, the ${refused}'s write refused`; + assert.equal(told.length, 1, `${why}: the other notice is written`); + if (refused === 'reader') { + assert.equal(told[0]!.forAgent, undefined, why); + assert.match(told[0]!.text, /that fork can't close it/, why); + } else { + assert.equal(told[0]!.forAgent, 'fork', why); + assert.equal(told[0]!.text, write === 'opened' + ? '[channels] Now open: #x (x): your own message into the closed channel opened it.' + : "[channels] Not open: #x (x). Your reply there went out, but opening the channel failed. It's selected for opening again, because its server lists it as open by default: the host tries again.", + `${why}: the reader isn't told the primary was`); + } + assert.ok(logged.some((l) => l.startsWith(write === 'opened' ? 'recordChannelAutoOpenNotice failed:' : 'recordReplyOpenFailedNotice failed:')), + `${why}: the failure is logged`); } - assert.equal(told.length, 1, `${write}: the primary's notice is written`); - assert.match(told[0]!.text, /fork can't close it/, write); - assert.ok(logged.some((l) => l.startsWith(write === 'opened' ? 'recordChannelAutoOpenNotice failed:' : 'recordReplyOpenFailedNotice failed:')), - `${write}: the reader's failure is logged`); } }); diff --git a/test/route-failure-targeting.test.ts b/test/route-failure-targeting.test.ts index 250ae9d8e..55891f5fe 100644 --- a/test/route-failure-targeting.test.ts +++ b/test/route-failure-targeting.test.ts @@ -663,7 +663,7 @@ describe('notices about an agent\'s own action', () => { '[channels] Now open: #one (c-1): your own message into the closed channel opened it. Your primary has been told and can close it.' }])); assert.equal(primary.mock.callCount(), 1, 'the primary is told too'); assert.equal(JSON.stringify(primary.mock.calls[0]!.arguments[1]), JSON.stringify([{ type: 'text', text: - `[channels] Now open: #one (c-1): a message from your fork bound to missing-channel (${fork.name}) into the closed channel opened it, and that fork can't close it. ` + + `[channels] Now open: #one (c-1): a message from your fork in missing-channel, named ${fork.name}, into the closed channel opened it, and that fork can't close it. ` + "If you don't want it, use channel_close; your choice outranks policy." }])); assert.equal((primary.mock.calls[0]!.arguments[2] as { speaker?: unknown } | undefined)?.speaker, fork.name); @@ -697,7 +697,7 @@ describe('notices about an agent\'s own action', () => { .filter((m) => JSON.stringify(m.content).includes('#home')); assert.equal(told.length, 1, 'the primary is told once'); assert.equal((told[0]!.content[0] as { text: string }).text, - `[channels] Now open: #home (missing-channel): a message from your fork bound to missing-channel (${fork.name}) into the closed channel opened it; that fork has ended. ` + + `[channels] Now open: #home (missing-channel): a message from your fork in missing-channel, named ${fork.name}, into the closed channel opened it; that fork has ended. ` + "If you don't want it, use channel_close; your choice outranks policy."); assert.equal((told[0]!.metadata as { speaker?: unknown } | undefined)?.speaker, fork.name); }); @@ -713,7 +713,7 @@ describe('notices about an agent\'s own action', () => { const told = framework.getAgent('primary')!.getContextManager().getAllMessages() .filter((m) => JSON.stringify(m.content).includes('#one')); assert.equal(told.length, 1, 'only the notice of the time'); - assert.match(JSON.stringify(told[0]!.content), new RegExp(`your fork bound to missing-channel \\(${fork.name}\\) into the closed channel opened it, and that fork can't close it`)); + assert.match(JSON.stringify(told[0]!.content), new RegExp(`your fork in missing-channel, named ${fork.name}, into the closed channel opened it, and that fork can't close it`)); }); it('tells the primary, by name, of a channel an ended agent\'s send opened, and the primary\'s channel_close closes it', async (t) => { From 9718958c2055c5ca9e2c64d200978ccd7a21b67d Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 19 Oct 2026 19:31:09 -0400 Subject: [PATCH 07/12] fix(channels): a fork holds its home's traffic only while bound to it, and the shared slot's readers hold theirs - holdsChannelTraffic asks the router whether a fork's home is still bound to it. After an idle-TTL expiry the router routes the home's traffic to a newer fork or nowhere, so a closing fork holds none, and isn't told what the channel delivers to it. - Where conversations aren't routed, every agent that reads the residents' shared slot holds the traffic stored there (sharedSlotAgents), the subconscious included through its merged view, rather than the ordinary residents alone. - primaryForReader takes the primary's name as a string: both callers run only where the primary is known, so its fallback was unreachable. - writeChannelNotices' doc and ChannelNotice's say what makes the reader's notice leave out that the primary was told: a failed write of the primary's notice. A write deferred to the primary's next boundary counts as written, as it does for any notice. - The changelog fragment says "told this about" where it said "told it of". Tests: the opened notice's table covers a closing fork at its home and the subconscious; the reply notice's covers a fork that has ended while its home is still known, which the primary hears of by the channel it was in. Co-Authored-By: Claude Opus 5.5 --- ...annel-notices-reach-who-can-close.fixed.md | 4 +- src/framework.ts | 41 +++++++++++-------- test/channel-lifecycle-order.test.ts | 21 ++++++++-- 3 files changed, 44 insertions(+), 22 deletions(-) diff --git a/changelog.d/channel-notices-reach-who-can-close.fixed.md b/changelog.d/channel-notices-reach-who-can-close.fixed.md index a8202bbb8..21e5ae79c 100644 --- a/changelog.d/channel-notices-reach-who-can-close.fixed.md +++ b/changelog.d/channel-notices-reach-who-can-close.fixed.md @@ -9,8 +9,8 @@ that it was. - The opened notice says "What an open channel delivers to you depends on its server" only to an agent whose window holds the channel's traffic. - Before, a conversation fork was told it of a channel other than its home, - whose messages never reach it. + Before, a conversation fork was told this about a channel other than its + home, whose messages never reach it. - The primary's notices name a conversation fork by the channel it's bound to, then by its generated name. The notice of an open by an agent that has ended says "a message from" it, where it said "opened by X's reply" whether diff --git a/src/framework.ts b/src/framework.ts index 2e2c4dead..b35c3d4aa 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2174,8 +2174,8 @@ type ChannelOpenedCause = 'subscription-policy' | 'opened-by-delivery' | 'opened * it's addressed to (none: where addMessage delivers an unrouted message), * and what its log line says. A reader's notice that says the primary was * told carries `untold`, its words without saying so, which - * writeChannelNotices writes instead where the primary's notice, written - * before it, didn't land. */ + * writeChannelNotices writes instead where the write of the primary's + * notice, before it, failed. */ interface ChannelNotice { text: string; untold?: string; @@ -15101,8 +15101,11 @@ export class AgentFramework { * Write a builder's channel notices in order, each on its own: one that * can't be written is logged, and the rest are still written. A reader's * notice that says the primary was told comes after the primary's, and is - * written in its `untold` words where the primary's didn't land, so it - * never says the primary was told of what its window doesn't hold. Through + * written in its `untold` words where the primary's write failed, so it + * never says the primary was told of a notice that wasn't written. A write + * addMessage defers to the primary's next boundary, as it does any write + * to an agent mid-turn, counts as written: a memory-only queue loses it at + * exit, as it loses any deferred write. Through * framework.addMessage for mid-turn safety (deferral + hear-while-acting * injection): an open a delivery forces happens inside a live turn, and a * direct context-manager append there would store the notice out of order. @@ -15230,26 +15233,30 @@ export class AgentFramework { /** * Whether `who` holds the traffic of every one of `channelIds`, so that - * what an open channel delivers is in its window. A conversation fork - * holds only its home's: where conversations are routed, an incoming - * message goes to the fork bound to its channel or nowhere, and never to - * the primary (handleMcplChannelIncoming). Elsewhere an incoming message is - * stored with no forAgent, in deliveryAgent()'s store, and where that's the - * residents' shared slot, every ordinary resident reads it - * (isOrdinaryResident); a run's store is its own. (A push names its - * channel and lands in the primary's window either way, but it comes - * whether or not the channel is open, so it isn't what opening one + * what an open channel delivers is in its window. Where conversations are + * routed, an incoming message goes to the fork its channel is bound to, or + * nowhere, and never to the primary (handleMcplChannelIncoming), so a fork + * holds only its home's, and only while the router binds its home to it: a + * fork whose binding has expired, closing, holds none. Elsewhere an + * incoming message is stored with no forAgent, in deliveryAgent()'s store, + * and where that's the residents' shared slot, every agent that reads the + * slot holds it (sharedSlotAgents: the ordinary residents, and the + * subconscious through its merged view); a run's store is its own. (A push + * names its channel and lands in the primary's window either way, but it + * comes whether or not the channel is open, so it isn't what opening one * delivers.) */ private holdsChannelTraffic(who: string | undefined, channelIds: readonly string[]): boolean { if (who === undefined) return false; const home = this.conversationAgentHomes.get(who); - if (home !== undefined) return channelIds.every((id) => id === home); + if (home !== undefined) { + return channelIds.every((id) => id === home && this.conversationRouter?.getBinding(id)?.agentName === who); + } if (this.conversationRouter) return false; const delivery = this.deliveryAgent(); const agent = this.agents.get(who); if (delivery === undefined || agent === undefined) return false; - return agent === delivery || (this.isOrdinaryResident(agent) && this.isOrdinaryResident(delivery)); + return agent === delivery || (this.sharedSlotAgents.has(agent) && this.isOrdinaryResident(delivery)); } /** @@ -15277,8 +15284,8 @@ export class AgentFramework { * may give the primary's name as its own; the primary's name to any other * agent, a fork made from another template included. */ - private primaryForReader(reader: string | undefined, primary: string | undefined): string { - return reader !== undefined && this.forkOfPrimary(reader) ? 'Your primary' : primary ?? 'The primary'; + private primaryForReader(reader: string | undefined, primary: string): string { + return reader !== undefined && this.forkOfPrimary(reader) ? 'Your primary' : primary; } /** Whether `agentName` is a conversation fork made from the primary: the diff --git a/test/channel-lifecycle-order.test.ts b/test/channel-lifecycle-order.test.ts index af9595fb9..6cafc3884 100644 --- a/test/channel-lifecycle-order.test.ts +++ b/test/channel-lifecycle-order.test.ts @@ -2479,6 +2479,10 @@ test("where a reply's reader can't close a channel selected for opening, the pri { text: forkByName + stays + ', and channel_close withdraws that.', metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }, { text: facts + stays + '.' + told('Your primary'), metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }, ] }, + { why: 'a fork that has ended, its home still known while it is released, the channel selected again', name: 'fork', agents: [['primary', []]], + homes: [['fork', 'y']], primary: 'primary', selectedForOpening: 'server-bootstrap', + told: [{ text: '[channels] Not open: #x (x). A reply from your fork in y, named fork, went out there, but opening the channel failed; that fork has ended.' + + again + ', and channel_close withdraws that.', metadata: { system: true, kind: 'channel-notice', speaker: 'fork' } }] }, { why: 'a fork away from its home, the channel left closed', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'y']], primary: 'primary', told: [{ text: facts, metadata: { system: true, kind: 'channel-notice' }, forAgent: 'fork' }] }, @@ -2536,7 +2540,7 @@ test('the opened notice names channel_close only to a reader that can call it, s const cases: Array<{ why: string; name?: string; cause?: 'opened-by-reply' | 'subscription-policy'; channels?: Array<{ channelId: string; label?: string }>; agents: Array<[string, string[]]>; homes?: Array<[string, string]>; labels?: Array<[string, string]>; isolated?: string[]; - router?: boolean; template?: string; primary: string | null; + router?: boolean; template?: string; expired?: string[]; subconscious?: string; primary: string | null; told: Array<{ text: string; metadata: unknown; forAgent?: string }>; }> = [ { why: "a second resident that can close it, which reads the residents' shared slot", name: 'helper', agents: [['helper', []], ['primary', []]], primary: 'primary', @@ -2564,6 +2568,11 @@ test('the opened notice names channel_close only to a reader that can call it, s ] }, { why: 'a fork at its home', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'x']], router: true, primary: 'primary', told: [{ text: own + optOut + close, metadata: opened(fromReply), forAgent: 'fork' }] }, + { why: 'a fork at its home whose binding has expired, closing: its home routes to it no more', name: 'fork', agents: [['fork', []], ['primary', []]], + homes: [['fork', 'x']], router: true, expired: ['fork'], primary: 'primary', + told: [{ text: own + close, metadata: opened(fromReply), forAgent: 'fork' }] }, + { why: 'the subconscious, which reads the shared slot through its merged view', name: 'sub', agents: [['sub', []], ['primary', []]], subconscious: 'sub', primary: 'primary', + told: [{ text: own + optOut + close, metadata: opened(fromReply), forAgent: 'sub' }] }, { why: 'a second resident whose allowedTools leave channel_close out', name: 'helper', agents: [['helper', ['channel_close']], ['primary', []]], primary: 'primary', told: [ { text: "[channels] Now open: #x (x): a message from helper into the closed channel opened it, and helper can't close it." + optOut + close, @@ -2613,8 +2622,14 @@ test('the opened notice names channel_close only to a reader that can call it, s framework.primaryAgentName = c.primary; framework.usedEphemeralAgentNames = new Set(); framework.conversationAgentHomes = new Map(c.homes ?? []); - framework.conversationRouter = c.router ? { templateAgent: c.template ?? 'primary' } : undefined; - // Residents read one shared slot; a fork's store, or a run's, is its own. + // Each fork is bound to its home, unless its binding has expired. + const bound = new Map((c.homes ?? []).filter(([fork]) => !(c.expired ?? []).includes(fork)).map(([fork, home]) => [home, fork])); + framework.conversationRouter = c.router + ? { templateAgent: c.template ?? 'primary', getBinding: (id: string) => (bound.has(id) ? { channelId: id, agentName: bound.get(id) } : undefined) } + : undefined; + framework.subconsciousAgentName = c.subconscious; + // Residents, and the subconscious through its merged view, read one shared + // slot; a fork's store, or a run's, is its own. framework.sharedSlotAgents = new Set([...framework.agents.values()] .filter((a: { name: string }) => !framework.conversationAgentHomes.has(a.name) && !(c.isolated ?? []).includes(a.name))); const labels = new Map(c.labels ?? []); From 9fd914b2e2db1c18861c7ba32e540b0864657aff Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 19 Oct 2026 19:34:39 -0400 Subject: [PATCH 08/12] docs(router): expired() says the framework unbinds before the closure turn ConversationRouter.expired()'s doc said the framework runs a fork's closure turn first and unbinds after. sweepExpiredConversations unbinds each expired binding first, so the channel's next message goes to a newer fork, or nowhere, and then queues the old fork's closure turn. holdsChannelTraffic relies on that order: during its closure turn a fork no longer holds its home's traffic. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/conversation-router.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/mcpl/conversation-router.ts b/src/mcpl/conversation-router.ts index 371c1d253..88eef6c4f 100644 --- a/src/mcpl/conversation-router.ts +++ b/src/mcpl/conversation-router.ts @@ -264,8 +264,10 @@ export class ConversationRouter { return Array.from(this.bindings.values()); } - /** Bindings whose idle TTL has elapsed. Does not unbind — the framework - * runs the closure turn first, then calls unbind(). */ + /** Bindings whose idle TTL has elapsed. Does not unbind: the framework + * unbinds each one first, so the channel's next message goes to a newer + * fork, or nowhere, and then runs the old fork's closure turn + * (sweepExpiredConversations). */ expired(now = Date.now()): ConversationBinding[] { const ttl = this.idleTtlMs; return this.getBindings().filter((b) => now - b.lastActivity >= ttl); From d9055853020aa9fd51675627031f86252237ba01 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 20 Oct 2026 01:45:14 -0400 Subject: [PATCH 09/12] fix(channels): pass on a released agent's channel notice only as far as it still holds A channel notice its reader could act on and never read goes to the primary when the reader is released, rebuilt from the inputs it was written with. Those said what was so then: a channel since closed was still "Now open", and one no longer waiting to open still "selected for opening". channelNoticeForPrimary now checks each channel against its durable desired state before rebuilding: - the opened notice keeps the channels still open, a tuned-out one included (a tune-out leaves the transport open, its traffic held); - the reply notice is passed on only while its channel is still selected for opening (desired open) and not open since; - with nothing left, no one is told, logged. The state comes from the registry, by the channel's id alone (desiredStateOf: the registered channel's server's decision, else the one server's on record), since a notice's inputs carry no server id. A serving boot lands a stranded queue before initializeMcpl builds the registry, so there the state comes from the lifecycle log itself (desiredStateInLog). Both read the log by one rule, desiredStateRecord, which the registry's replay now uses too. shownChannel names how a notice shows a channel; the two builders and the new log lines share it. The fragment now qualifies the rule on channel_close for a shared resident slot (#291) and forks spawned from the primary afterwards (#235), and says the primary is told only what still holds. Tests: - route-failure-targeting: a fork's unread opened notice whose channel closed before its release tells the primary nothing, logged; a restored queue passes on only the channels still open (a tuned-out one included) and drops failed notices whose channel is open since or no longer selected, each logged; a serving boot reads the lifecycle log before there's a registry. The existing pass-on tests, the fork's and the boot's and the run's two channel-notice rows, record the decision an open makes. - channel-lifecycle-order: the inputs test's framework stub gets a registry whose channels are as the notice says. - channel-lifecycle-provenance: desiredStateOf by id (registered server first, one server on record, two, none); desiredStateInLog reads the last decision, and agrees with a registry replayed from the same store. Co-Authored-By: Claude Opus 5.5 --- ...annel-notices-reach-who-can-close.fixed.md | 12 +- src/framework.ts | 55 ++++++-- src/mcpl/channel-registry.ts | 71 ++++++++-- test/channel-lifecycle-order.test.ts | 2 + test/channel-lifecycle-provenance.test.ts | 44 +++++- test/route-failure-targeting.test.ts | 132 +++++++++++++++++- 6 files changed, 281 insertions(+), 35 deletions(-) diff --git a/changelog.d/channel-notices-reach-who-can-close.fixed.md b/changelog.d/channel-notices-reach-who-can-close.fixed.md index 21e5ae79c..70a1f2bb4 100644 --- a/changelog.d/channel-notices-reach-who-can-close.fixed.md +++ b/changelog.d/channel-notices-reach-who-can-close.fixed.md @@ -1,8 +1,10 @@ - The notice that a channel opened without the agent asking (an open its own send or reply caused, or a subscription-policy admission) names `channel_close` only to an agent that can call it there for every channel - it names. A conversation fork can close only its home channel, and an - agent's `allowedTools` can leave `channel_close` out. Before, such an agent + it names, except where several residents share one slot (#291), and in + forks spawned from the primary afterwards (#235). A conversation fork can + close only its home channel, and an agent's `allowedTools` can leave + `channel_close` out. Before, such an agent was told to use `channel_close`, and couldn't call it, or its call was refused. Where an agent's own send opened a channel it can't close, the primary is now told by name too, if it can close it, and the agent is told @@ -20,5 +22,7 @@ - When a conversation fork or an ephemeral run is released without reading a channel notice it could act on (a channel its own send opened, or a channel its reply couldn't open that is selected for opening), the primary - is told by name in its place, as for a reply whose delivery failed. Before, - the notice stayed unread in the released agent's window. + is told by name in its place, as for a reply whose delivery failed, of what + still holds then: a channel closed since is left out, and so is one no + longer selected for opening, or open since. Before, the notice stayed + unread in the released agent's window. diff --git a/src/framework.ts b/src/framework.ts index b35c3d4aa..ddc96c397 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -102,7 +102,7 @@ import { isSilentHeartbeatMarker, isVisiblyEmptyContent, NON_STRING_TEXT_PLACEHO import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js'; import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js'; import { InferenceRouter } from './mcpl/inference-router.js'; -import { ChannelRegistry, connectorText, destinationKey, journalToolDescription, type ChannelToolOrigin, type PublishDestination, type PublishOutcome, type SelectedForOpening } from './mcpl/channel-registry.js'; +import { ChannelRegistry, connectorText, desiredStateInLog, destinationKey, journalToolDescription, type ChannelToolOrigin, type PublishDestination, type PublishOutcome, type SelectedForOpening } from './mcpl/channel-registry.js'; import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftAttempt, type DraftReason, type DraftState, type InheritedRisk } from './prose-drafts.js'; import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; import { INBOUND_SOURCE_KEY, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; @@ -2196,6 +2196,11 @@ type ChannelNoticePassOn = | { notice: 'channel-opened'; cause: ChannelOpenedCause; channels: Array<{ channelId: string; label?: string }> } | { notice: 'reply-open-failed'; channel: { channelId: string; label?: string }; selectedForOpening: SelectedForOpening }; +/** A channel as a notice shows it: its label beside its id, or its id alone. */ +function shownChannel(channel: { channelId: string; label?: string }): string { + return channel.label && channel.label !== channel.channelId ? `${channel.label} (${channel.channelId})` : channel.channelId; +} + /** A channel as a notice's inputs carry it, read back from stored metadata. */ function noticeChannel(value: unknown): { channelId: string; label?: string } | null { const channel = value as { channelId?: unknown; label?: unknown } | null | undefined; @@ -15142,9 +15147,7 @@ export class AgentFramework { channels: Array<{ channelId: string; label?: string }>, cause: ChannelOpenedCause, ): ChannelNotice[] { - const shown = channels - .map((c) => (c.label && c.label !== c.channelId ? `${c.label} (${c.channelId})` : c.channelId)) - .join(', '); + const shown = channels.map(shownChannel).join(', '); const ids = channels.map((c) => c.channelId); const plural = channels.length > 1; // What the channels deliver is said only to an agent that holds their @@ -15347,9 +15350,7 @@ export class AgentFramework { channel: { channelId: string; label?: string }, selectedForOpening?: SelectedForOpening, ): ChannelNotice[] { - const shown = channel.label && channel.label !== channel.channelId - ? `${channel.label} (${channel.channelId})` - : channel.channelId; + const shown = shownChannel(channel); const ended = !!agentName && !this.agents.has(agentName); if (ended && !selectedForOpening) { console.error(`[channel] ${agentName} has ended; not telling it that ${shown} couldn't be opened`); @@ -21868,19 +21869,43 @@ export class AgentFramework { /** * The primary's by-name notice in place of a channel notice `speaker` was - * told it could act on and never read: its builder, called again with the - * same inputs now that `speaker` has ended, gives the words it gives an - * agent that has ended, judged for the primary, or nothing, logged, with no - * primary to tell. They speak of the open or the failure as it was, as the - * notice did. + * told it could act on and never read: its builder, called again now that + * `speaker` has ended, gives the words it gives an agent that has ended, + * judged for the primary, or nothing, logged, with no primary to tell. + * They speak of the open or the failure as it was, as the notice did, and + * only of what still holds by each channel's durable desired state: the + * registry's (desiredStateOf), or before there is one, as at a serving + * boot's flush, the lifecycle log's (desiredStateInLog). An opened channel + * since closed is left out, and so is a failed one no longer selected for + * opening, or open since. With nothing left, no one is told, logged here. */ private channelNoticeForPrimary( speaker: string, passOn: ChannelNoticePassOn, ): { content: ContentBlock[]; metadata: MessageMetadata } | null { - const [notice] = passOn.notice === 'channel-opened' - ? this.channelOpenedNotices(speaker, passOn.channels, passOn.cause) - : this.replyOpenFailedNotices(speaker, passOn.channel, passOn.selectedForOpening); + const registry = this.channelRegistry; + const desiredState = (channelId: string) => + (registry ? registry.desiredStateOf(channelId) : desiredStateInLog(this.store, channelId)); + let notice: ChannelNotice | undefined; + if (passOn.notice === 'channel-opened') { + // A tune-out leaves a channel open, its traffic held. + const channels = passOn.channels.filter((c) => { + const desired = desiredState(c.channelId); + return desired === 'open' || desired === 'tuned-out'; + }); + if (channels.length === 0) { + console.error(`[channel] ${speaker} has ended, and what its notice said opened is closed now (${passOn.channels.map(shownChannel).join(', ')}): no one is told`); + return null; + } + [notice] = this.channelOpenedNotices(speaker, channels, passOn.cause); + } else { + const { channelId } = passOn.channel; + if (desiredState(channelId) !== 'open' || registry?.isChannelOpen(channelId)) { + console.error(`[channel] ${speaker} has ended, and ${shownChannel(passOn.channel)} is no longer waiting to open, as its notice said: no one is told`); + return null; + } + [notice] = this.replyOpenFailedNotices(speaker, passOn.channel, passOn.selectedForOpening); + } return notice ? { content: [{ type: 'text', text: notice.text }], metadata: notice.metadata } : null; } diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 217abd187..72a90bf51 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -238,6 +238,44 @@ interface ChannelLifecycleEvent { acknowledgment?: string; } +/** A lifecycle-log item as a desired-state decision, or null when it isn't + * one this build reads: the one rule the registry's replay + * (initializeLifecycleStore) and desiredStateInLog read the log by. */ +function desiredStateRecord(item: unknown): { + serverId: string; channelId: string; state: DesiredChannelState; source: string; tuneOut?: TuneOutParams; +} | null { + const event = item as Partial | null | undefined; + if (event?.kind !== 'desired-state' || typeof event.serverId !== 'string' || typeof event.channelId !== 'string') return null; + if (!(event.desired === 'open' || event.desired === 'closed' || (event.desired === 'tuned-out' && event.tuneOut))) return null; + return { + serverId: event.serverId, + channelId: event.channelId, + state: event.desired, + source: typeof event.source === 'string' ? event.source : 'unknown', + ...(event.desired === 'tuned-out' ? { tuneOut: event.tuneOut } : {}), + }; +} + +/** + * The desired state `store`'s lifecycle log records for `channelId`, found + * by its id alone, for a reader with no registry yet, as a serving boot's + * deferred-write flush is, before initializeMcpl: the last decision wins, as + * the registry's replay reads it (desiredStateOf answers once there is a + * registry). undefined when no decision names it, or decisions on more than + * one server do. + */ +export function desiredStateInLog(store: JsStore, channelId: string): DesiredChannelState | undefined { + ensureStateRegistered(store, { id: CHANNEL_LIFECYCLE_LOG_ID, strategy: 'append_log' }); + const raw = store.getStateJson(CHANNEL_LIFECYCLE_LOG_ID); + if (!Array.isArray(raw)) return undefined; + const byServer = new Map(); + for (const item of raw) { + const decision = desiredStateRecord(item); + if (decision?.channelId === channelId) byServer.set(decision.serverId, decision.state); + } + return byServer.size === 1 ? [...byServer.values()][0] : undefined; +} + /** One append-log record in `CHANNEL_LABEL_HISTORY_LOG_ID`: "at `ts`, this * channelId's label was observed to be `label`". See `labelHistory`. */ interface ChannelLabelSightingEvent { @@ -2022,6 +2060,22 @@ export class ChannelRegistry { return this.desiredStates.get(this.lifecycleKey(serverId, channelId))?.state; } + /** + * The durable desired state of `channelId`, for a reader that knows only + * the channel: its server's record where the channel is registered, and + * otherwise the lifecycle log's record for that id, which is all boot has + * before the servers reconnect. undefined when no record names it, or + * records on more than one server do. + */ + desiredStateOf(channelId: string): DesiredChannelState | undefined { + const serverId = this.getChannelServerId(channelId); + if (serverId !== null) return this.getDesiredState(serverId, channelId); + const states = [...this.desiredStates] + .filter(([key]) => key.slice(key.indexOf('\u0000') + 1) === channelId) + .map(([, record]) => record.state); + return states.length === 1 ? states[0] : undefined; + } + /** * Get all open channels. */ @@ -2250,20 +2304,11 @@ export class ChannelRegistry { if (!item || typeof item !== 'object') continue; const event = item as Partial; if (typeof event.serverId !== 'string') continue; - if ( - event.kind === 'desired-state' && - typeof event.channelId === 'string' && - (event.desired === 'open' || event.desired === 'closed' || - (event.desired === 'tuned-out' && event.tuneOut)) - ) { + const decision = desiredStateRecord(item); + if (decision) { this.desiredStates.set( - this.lifecycleKey(event.serverId, event.channelId), - { - state: event.desired, - source: typeof event.source === "string" ? event.source : "unknown", - tuneOut: event.desired === 'tuned-out' ? event.tuneOut : undefined, - wakeCount: 0, - }, + this.lifecycleKey(decision.serverId, decision.channelId), + { state: decision.state, source: decision.source, tuneOut: decision.tuneOut, wakeCount: 0 }, ); } else if ( event.kind === 'tune-out-wake' && diff --git a/test/channel-lifecycle-order.test.ts b/test/channel-lifecycle-order.test.ts index 6cafc3884..28ec909c2 100644 --- a/test/channel-lifecycle-order.test.ts +++ b/test/channel-lifecycle-order.test.ts @@ -2708,6 +2708,8 @@ test("a channel notice's inputs give the primary's by-name notice once its reade framework.primaryAgentName = 'primary'; framework.usedEphemeralAgentNames = new Set(); framework.conversationAgentHomes = new Map(); + // Every channel still as the notice says: open, or still waiting to open. + framework.channelRegistry = { desiredStateOf: () => 'open', isChannelOpen: () => false }; const error = console.error; console.error = () => {}; try { diff --git a/test/channel-lifecycle-provenance.test.ts b/test/channel-lifecycle-provenance.test.ts index d27a776f2..c4b2f5150 100644 --- a/test/channel-lifecycle-provenance.test.ts +++ b/test/channel-lifecycle-provenance.test.ts @@ -1,6 +1,10 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { JsStore } from '@animalabs/chronicle'; +import { ChannelRegistry, desiredStateInLog } from '../src/mcpl/channel-registry.js'; import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; @@ -13,7 +17,7 @@ import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; * predated the server declaring its bootstrap preference. */ -function makeRegistry() { +function makeRegistry(store?: JsStore) { const serverRegistry = { getServer: (_id: string) => ({ sendChannelsPublish: async () => ({ delivered: true }) }), } as unknown as McplServerRegistry; @@ -22,6 +26,7 @@ function makeRegistry() { {} as FeatureSetManager, () => {}, () => {}, + store ? { store } : undefined, ); const r = registry as unknown as { ensureInitialDesiredState(serverId: string, ch: { id: string; type: string; label: string; direction: string; initiallyOpen?: boolean }): void; @@ -61,3 +66,38 @@ test('initiallyOpen at first sight registers open with server-bootstrap provenan r.ensureInitialDesiredState('eidoverse', ch(true)); assert.equal(r.getDesiredState('eidoverse', 'world:commons'), 'open'); }); + +test("desiredStateOf finds a channel's decision by its id alone: its registered server's, else the one server's on record", () => { + const { registry, r } = makeRegistry(); + r.setDesiredState('a', 'c-1', 'open', 'opened-by-reply'); + assert.equal(registry.desiredStateOf('c-1'), 'open', 'the one decision on record'); + r.setDesiredState('b', 'c-1', 'closed', 'agent-tool'); + assert.equal(registry.desiredStateOf('c-1'), undefined, 'decisions on two servers, and neither registers it'); + (registry as unknown as { channels: Map }).channels + .set('a:c-1', { serverId: 'a', descriptor: { id: 'c-1', type: 'a', label: '#one' }, open: false }); + assert.equal(registry.desiredStateOf('c-1'), 'open', 'the registered server\'s'); + assert.equal(registry.desiredStateOf('c-2'), undefined, 'none on record'); +}); + +test("desiredStateInLog reads a channel's last decision from a store's lifecycle log, as a registry replayed from it does", () => { + const dir = mkdtempSync(join(tmpdir(), 'desired-state-in-log-')); + const store = JsStore.openOrCreate({ path: join(dir, 'store') }); + try { + assert.equal(desiredStateInLog(store, 'c-1'), undefined, 'a store no registry has written to'); + const { r } = makeRegistry(store); + r.setDesiredState('a', 'c-1', 'open', 'opened-by-reply'); + r.setDesiredState('a', 'c-1', 'closed', 'agent-tool'); + r.setDesiredState('a', 'c-2', 'open', 'opened-by-reply'); + r.setDesiredState('a', 'c-3', 'open', 'opened-by-reply'); + r.setDesiredState('b', 'c-3', 'closed', 'agent-tool'); + assert.equal(desiredStateInLog(store, 'c-1'), 'closed', 'the last decision wins'); + assert.equal(desiredStateInLog(store, 'c-2'), 'open'); + assert.equal(desiredStateInLog(store, 'c-3'), undefined, 'decisions on two servers'); + assert.equal(desiredStateInLog(store, 'c-4'), undefined, 'none on record'); + const replayed = makeRegistry(store).registry; + for (const id of ['c-1', 'c-2', 'c-3', 'c-4']) assert.equal(replayed.desiredStateOf(id), desiredStateInLog(store, id), id); + } finally { + store.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/route-failure-targeting.test.ts b/test/route-failure-targeting.test.ts index 55891f5fe..8d68d478a 100644 --- a/test/route-failure-targeting.test.ts +++ b/test/route-failure-targeting.test.ts @@ -1,6 +1,6 @@ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { AgentFramework } from '../src/index.js'; @@ -140,6 +140,13 @@ describe('notices about an agent\'s own action', () => { return framework.getAgent(binding.agentName)!; } + /** A durable decision for `channel`, as an open or a close records it in + * the registry's lifecycle log. */ + function decide(channel: string, desired: 'open' | 'closed', source = 'opened-by-reply', on = framework) { + (internals(on).channelRegistry as unknown as { setDesiredState(serverId: string, channelId: string, desired: string, source: string): void }) + .setDesiredState('srv', channel, desired, source); + } + async function runEphemeral(proseRouting: 'locus' | 'explicit', speech: string) { const created = await framework.createEphemeralAgent({ name: 'worker', model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all', proseRouting, @@ -688,6 +695,7 @@ describe('notices about an agent\'s own action', () => { it('tells the primary, by name, of a channel notice a fork could act on and never read, when the fork is released', async () => { const fork = await spawnFork(); const state = internals(framework); + decide('missing-channel', 'open'); state.recordChannelAutoOpenNotice(fork.name, [{ channelId: 'missing-channel', label: '#home' }], 'opened-by-reply'); reap(fork.name); @@ -702,6 +710,24 @@ describe('notices about an agent\'s own action', () => { assert.equal((told[0]!.metadata as { speaker?: unknown } | undefined)?.speaker, fork.name); }); + it('tells the primary nothing, logged, of a channel a fork\'s unread notice said opened, once the channel has closed', async (t) => { + const errors: string[] = []; + t.mock.method(console, 'error', (...args: unknown[]) => { errors.push(args.map(String).join(' ')); }); + const fork = await spawnFork(); + decide('missing-channel', 'open'); + internals(framework).recordChannelAutoOpenNotice(fork.name, [{ channelId: 'missing-channel', label: '#home' }], 'opened-by-reply'); + decide('missing-channel', 'closed', 'agent-tool'); + + reap(fork.name); + + assert.equal(framework.getAgent(fork.name), null, 'released'); + const told = framework.getAgent('primary')!.getContextManager().getAllMessages() + .filter((m) => JSON.stringify(m.content).includes('#home')); + assert.equal(told.length, 0, 'the primary is told nothing'); + assert.ok(errors.includes( + `[channel] ${fork.name} has ended, and what its notice said opened is closed now (#home (missing-channel)): no one is told`)); + }); + it("passes on no channel notice its reader couldn't act on: the primary was told then, once", async () => { const fork = await spawnFork(); const state = internals(framework); @@ -809,6 +835,8 @@ describe('notices about an agent\'s own action', () => { const state = internals(framework); const errors: string[] = []; t.mock.method(console, 'error', (...args: unknown[]) => { errors.push(args.map(String).join(' ')); }); + decide('c-4', 'open'); + decide('c-5', 'open', 'server-bootstrap'); (state.deferredMessages as unknown as Array>).push( { id: 'w-4', seq: 4, participant: 'user', forAgent: 'conversation-gone', @@ -846,6 +874,102 @@ describe('notices about an agent\'s own action', () => { assert.equal(state.deferredMessages.length, 0); }); + it('passes on, of a restored channel notice, only what still holds: the channels still open, and a failed one still waiting to open', async (t) => { + const state = internals(framework); + const errors: string[] = []; + t.mock.method(console, 'error', (...args: unknown[]) => { errors.push(args.map(String).join(' ')); }); + decide('c-7', 'open'); + decide('c-8', 'open'); + decide('c-8', 'closed', 'agent-tool'); + // Selected for opening, and open since. + decide('c-9', 'open', 'server-bootstrap'); + (state.channelRegistry as unknown as { channels: Map }).channels + .set('srv:c-9', { serverId: 'srv', descriptor: { id: 'c-9', type: 'srv', label: '#nine' }, open: true }); + // Selected for opening, and closed since. + decide('c-10', 'open', 'server-bootstrap'); + decide('c-10', 'closed', 'agent-tool'); + // Opened, and tuned out since: still open, its traffic held. + decide('c-13', 'open'); + state.channelRegistry.enterTuneOut('srv', 'c-13', + { epochId: 'e-13', cadenceSeconds: 60, backlogCap: 10, maxWakes: 3, startedAtSequence: 0 }, 'agent-tool'); + const opened = (id: string, channels: Array<{ channelId: string; label: string }>) => ({ + id, seq: Number(id.slice(2)), participant: 'user', forAgent: 'conversation-gone', + content: [{ type: 'text', text: '[channels] Now open: your own message into the closed channel opened it.' }], + metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-delivery', channels } }, + }); + const failed = (id: string, channel: { channelId: string; label: string }) => ({ + id, seq: Number(id.slice(2)), participant: 'user', forAgent: 'conversation-gone', + content: [{ type: 'text', text: '[channels] Not open. Your reply there went out, but opening the channel failed.' }], + metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'reply-open-failed', channel, selectedForOpening: 'server-bootstrap' } }, + }); + (state.deferredMessages as unknown as Array>).push( + opened('w-7', [{ channelId: 'c-7', label: '#seven' }, { channelId: 'c-8', label: '#eight' }]), + opened('w-8', [{ channelId: 'c-8', label: '#eight' }]), + failed('w-9', { channelId: 'c-9', label: '#nine' }), + failed('w-10', { channelId: 'c-10', label: '#ten' }), + opened('w-13', [{ channelId: 'c-13', label: '#thirteen' }]), + ); + + await state.flushDeferredWrites('boot-recovery'); + + const told = framework.getAgent('primary')!.getContextManager().getAllMessages() + .filter((m) => JSON.stringify(m.content).includes('conversation-gone')); + assert.deepEqual(told.map((m) => (m.content[0] as { text: string }).text), [ + '[channels] Now open: #seven (c-7): a message from conversation-gone into the closed channel opened it; conversation-gone has ended. ' + + "If you don't want it, use channel_close; your choice outranks policy.", + '[channels] Now open: #thirteen (c-13): a message from conversation-gone into the closed channel opened it; conversation-gone has ended. ' + + "If you don't want it, use channel_close; your choice outranks policy.", + ]); + assert.deepEqual(told.map((m) => (m.metadata as { deferredWriteId?: unknown }).deferredWriteId), ['w-7', 'w-13']); + for (const line of [ + '[channel] conversation-gone has ended, and what its notice said opened is closed now (#eight (c-8)): no one is told', + '[channel] conversation-gone has ended, and #nine (c-9) is no longer waiting to open, as its notice said: no one is told', + '[channel] conversation-gone has ended, and #ten (c-10) is no longer waiting to open, as its notice said: no one is told', + ]) assert.ok(errors.includes(line), line); + assert.equal(state.deferredMessages.length, 0); + }); + + it('reads a stranded channel notice\'s channels from the lifecycle log at a serving boot, before there is a registry', async (t) => { + decide('c-11', 'open'); + decide('c-12', 'open'); + decide('c-12', 'closed', 'agent-tool'); + await framework.stop(); + const storePath = join(tempDir, 'test.chronicle'); + const notice = (id: string, channelId: string, label: string) => ({ + id, seq: Number(id.slice(2)), participant: 'user', forAgent: 'conversation-gone', + content: [{ type: 'text', text: `[channels] Now open: ${label} (${channelId}): your own message into the closed channel opened it.` }], + metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId, label }] } }, + }); + // A resume that didn't finish its flush leaves the flag cleared and the + // queue behind, which a serving boot lands before MCPL is brought up. + mkdirSync(join(storePath, 'recovery'), { recursive: true }); + writeFileSync(join(storePath, 'recovery', 'host-mode.json'), JSON.stringify({ version: 1, quiesced: false })); + writeFileSync(join(storePath, 'recovery', 'deferred-writes.json'), JSON.stringify({ + version: 1, pending: [notice('w-11', 'c-11', '#eleven'), notice('w-12', 'c-12', '#twelve')], + })); + const errors: string[] = []; + t.mock.method(console, 'error', (...args: unknown[]) => { errors.push(args.map(String).join(' ')); }); + + framework = await AgentFramework.create({ + storePath, + membrane: membrane.asMembrane(), + agents: [ + { name: 'primary', model: 'test-model', systemPrompt: 'Primary.', proseRouting: 'locus' }, + { name: 'secondary', model: 'test-model', systemPrompt: 'Secondary.' }, + ], + modules: [], + conversations: { templateAgent: 'primary' }, + }); + + assert.equal(internals(framework).channelRegistry, null, 'the boot landed them with no registry'); + const told = framework.getAgent('primary')!.getContextManager().getAllMessages() + .map((m) => JSON.stringify(m.content)) + .filter((c) => c.includes('conversation-gone')); + assert.equal(told.length, 1); + assert.match(told[0]!, /Now open: #eleven \(c-11\): a message from conversation-gone into the closed channel opened it; conversation-gone has ended\./); + assert.ok(errors.includes('[channel] conversation-gone has ended, and what its notice said opened is closed now (#twelve (c-12)): no one is told')); + }); + it('passes a restored failure notice on as the same queue entry while the primary is mid-turn: it waits under its own id', async () => { const state = internals(framework); (state.deferredMessages as unknown as Array>).push({ @@ -1245,6 +1369,7 @@ describe('notices about an agent\'s own action', () => { metadata: { system: true, kind: 'discord-send-failed', channelId: 'missing-channel', reason: 'no registered channel', textLen: 5 }, inRun: SEND_FAILED, toPrimary: 'A reply by worker', + opened: null, }, { what: 'a channel notice the run could act on', @@ -1252,9 +1377,11 @@ describe('notices about an agent\'s own action', () => { metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-delivery', channels: [{ channelId: 'c-7', label: '#seven' }] } }, inRun: 'Now open: #seven', toPrimary: "a message from worker into the closed channel opened it; worker has ended", + opened: 'c-7', }, ]; for (const queued of queuedForTheRun) it(`passes ${queued.what} still queued when a run ends unquiesced on to the primary as the same queue entry`, async () => { + if (queued.opened) decide(queued.opened, 'open', 'opened-by-delivery', runFramework); let release!: () => void; const released = new Promise((resolve) => { release = resolve; }); runMembrane.streamYielding = (request) => { @@ -1328,15 +1455,18 @@ describe('notices about an agent\'s own action', () => { content: [{ type: 'text' as const, text: `${SEND_FAILED} Your previous reply (5 chars) could not be delivered to #x (missing-channel).` }], metadata: { system: true, kind: 'discord-send-failed', channelId: 'missing-channel', reason: 'no registered channel', textLen: 5 }, toPrimary: 'A reply by worker', + opened: null, }, { what: 'a channel notice the run could act on', content: [{ type: 'text' as const, text: '[channels] Now open: #eight (c-8): your own message into the closed channel opened it.' }], metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: 'c-8', label: '#eight' }] } }, toPrimary: "a message from worker into the closed channel opened it; worker has ended", + opened: 'c-8', }, ]; for (const unread of storedUnread) it(`passes ${unread.what} a run stored unread on to the primary\'s queue when the host stops before the release it waited for`, async () => { + if (unread.opened) decide(unread.opened, 'open', 'opened-by-reply', runFramework); const created = await runFramework.createEphemeralAgent({ name: 'worker', model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all', proseRouting: 'explicit', }); From 6e82cb023862fcac8a60dfb0b911b7e8f1030396 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 20 Oct 2026 03:30:03 -0400 Subject: [PATCH 10/12] fix(channels): check a passed-on notice's channels on their own server A notice's inputs (passOn) carried a channel's id and label, not its server, so channelNoticeForPrimary found each channel by its id alone: on the first server that registered it, or the one server with a decision on record, or none where two servers held one. Two servers can share a channel id, as two bots on one guild do. - passOn carries serverId. The registry's onChannelAutoOpened gives it, and the send tool's dispatch has it; the notice builders take it. - channelNoticeForPrimary reads that server's decision: getDesiredState with a registry, and desiredStateInLog(store, serverId, channelId) at a serving boot. desiredStateOf and both one-server rules are gone. - The reply notice's transport check reads that server's channel: isChannelOpen takes an optional server, as inboundOnlyServer does. - A tune-out keeps a channel selected for opening, since reconcile opens a tuned-out channel's transport. So the reply notice is passed on for a channel tuned out since, as for one decided open, while its transport isn't open. - The log lines say what the record shows. - Docs: the check is the decision, not the transport at that moment; a binding stands until the sweep unbinds it; the notice writers name channel_close and channel_open by what the agent a notice is addressed to can call, and others may read it too (#291, #235); shownChannel is the channel notices' rule. Tests: route-failure-targeting (one channel id on two servers, each notice checked by its own server's decision, with a registry and at a serving boot; a failed notice whose channel is tuned out since; another server's open transport; the boot reads a failed notice and a tune-out from the log); channel-lifecycle-provenance (desiredStateInLog by server, records the rule doesn't read, a decision with no source, agreement with a replayed registry); channel-lifecycle-order (a fork whose home is bound to a newer fork, a label equal to its id, inputs that name no server); the router test's comment on when the framework unbinds. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 127 +++++++++++++--------- src/mcpl/channel-registry.ts | 41 +++---- test/channel-lifecycle-order.test.ts | 81 ++++++++------ test/channel-lifecycle-provenance.test.ts | 50 +++++---- test/conversation-router.test.ts | 2 +- test/route-failure-targeting.test.ts | 120 ++++++++++++++------ test/trunk-channel-routing.test.ts | 5 +- 7 files changed, 258 insertions(+), 168 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index ddc96c397..13e76bb5e 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2187,16 +2187,19 @@ interface ChannelNotice { /** * A channel notice's inputs, kept in its metadata (`passOn`) where its reader - * was told it could act: a release that finds the notice unread tells the + * was told it could act: the server its channels are on, and what its + * builder was given. A release that finds the notice unread tells the * primary in its place, by calling the notice's builder again for the reader - * then gone (channelNoticeForPrimary). A notice whose reader couldn't act - * carries none: the primary was told then, where it could act. + * then gone, and checks each channel against that server's decision + * (channelNoticeForPrimary). A notice whose reader couldn't act carries none: + * the primary was told then, where it could act. */ type ChannelNoticePassOn = - | { notice: 'channel-opened'; cause: ChannelOpenedCause; channels: Array<{ channelId: string; label?: string }> } - | { notice: 'reply-open-failed'; channel: { channelId: string; label?: string }; selectedForOpening: SelectedForOpening }; + | { notice: 'channel-opened'; serverId: string; cause: ChannelOpenedCause; channels: Array<{ channelId: string; label?: string }> } + | { notice: 'reply-open-failed'; serverId: string; channel: { channelId: string; label?: string }; selectedForOpening: SelectedForOpening }; -/** A channel as a notice shows it: its label beside its id, or its id alone. */ +/** A channel as the channel notices show it (the opened and reply notices, + * and the lines that log them): its label beside its id, or its id alone. */ function shownChannel(channel: { channelId: string; label?: string }): string { return channel.label && channel.label !== channel.channelId ? `${channel.label} (${channel.channelId})` : channel.channelId; } @@ -2215,9 +2218,11 @@ function channelNoticePassOn(metadata: unknown): ChannelNoticePassOn | null { const meta = metadata as { kind?: unknown; passOn?: unknown } | null | undefined; if (meta?.kind !== 'channel-notice') return null; const passOn = meta.passOn as { - notice?: unknown; cause?: unknown; channels?: unknown; channel?: unknown; selectedForOpening?: unknown; + notice?: unknown; serverId?: unknown; cause?: unknown; channels?: unknown; channel?: unknown; selectedForOpening?: unknown; } | null | undefined; - if (passOn?.notice === 'channel-opened') { + if (typeof passOn?.serverId !== 'string' || passOn.serverId === '') return null; + const serverId = passOn.serverId; + if (passOn.notice === 'channel-opened') { // A policy admission's notice is addressed to no agent of its own and // carries no inputs (channelOpenedNotices), so only an agent's send reads // back. @@ -2225,13 +2230,13 @@ function channelNoticePassOn(metadata: unknown): ChannelNoticePassOn | null { if (!causes.includes(passOn.cause) || !Array.isArray(passOn.channels) || passOn.channels.length === 0) return null; const channels = passOn.channels.map(noticeChannel); if (channels.some((c) => c === null)) return null; - return { notice: 'channel-opened', cause: passOn.cause as ChannelOpenedCause, channels: channels as Array<{ channelId: string; label?: string }> }; + return { notice: 'channel-opened', serverId, cause: passOn.cause as ChannelOpenedCause, channels: channels as Array<{ channelId: string; label?: string }> }; } - if (passOn?.notice === 'reply-open-failed') { + if (passOn.notice === 'reply-open-failed') { const selected: readonly unknown[] = ['unrecorded', 'server-bootstrap', 'subscription-policy']; const channel = noticeChannel(passOn.channel); if (!channel || !selected.includes(passOn.selectedForOpening)) return null; - return { notice: 'reply-open-failed', channel, selectedForOpening: passOn.selectedForOpening as SelectedForOpening }; + return { notice: 'reply-open-failed', serverId, channel, selectedForOpening: passOn.selectedForOpening as SelectedForOpening }; } return null; } @@ -15082,9 +15087,12 @@ export class AgentFramework { * transport state), or an open that the agent's own delivery or reply * caused (`opened-by-delivery`, `opened-by-reply`), once its transport is * confirmed. It names `channel_close` as the opt-out (their decision - * outranks policy) only to a reader that can call it for every channel it - * names (canCloseChannels): a conversation fork may close only its home - * channel, and an agent's allowedTools can leave channel_close out. Where an + * outranks policy) only where the agent it's addressed to can call it for + * every channel it names (canCloseChannels): a conversation fork may close + * only its home channel, and an agent's allowedTools can leave + * channel_close out. Others can read it too: the other residents, where + * several share one slot (#291), and forks spawned from the primary + * afterwards, from its window (#235). Where an * agent's own send opened a channel it can't close, the primary is told by * name too, if it's registered and can close it, and the agent is told * that it was. What an open channel delivers is its server's to say (#406), @@ -15095,11 +15103,12 @@ export class AgentFramework { */ private recordChannelAutoOpenNotice( agentName: string | undefined, + serverId: string, channels: Array<{ channelId: string; label?: string }>, cause: ChannelOpenedCause, ): void { if (channels.length === 0) return; - this.writeChannelNotices(this.channelOpenedNotices(agentName, channels, cause), `auto-open notice (${cause})`, 'recordChannelAutoOpenNotice'); + this.writeChannelNotices(this.channelOpenedNotices(agentName, serverId, channels, cause), `auto-open notice (${cause})`, 'recordChannelAutoOpenNotice'); } /** @@ -15135,15 +15144,16 @@ export class AgentFramework { * which then says the primary was told (`untold`: its words where that * didn't land). None, logged, for an agent that has ended when there's no * primary to tell. - * The reader's carries its inputs (`passOn`) where it's addressed to the - * agent whose send opened the channels and that agent was told it can close - * them, so a release that finds it unread can tell the primary in its place - * (channelNoticeForPrimary), which calls this again for the agent then gone. - * A policy admission's goes where addMessage delivers it, to no agent of its - * own, and carries none. + * The reader's carries its inputs (`passOn`), the channels' server among + * them, where it's addressed to the agent whose send opened the channels + * and that agent was told it can close them, so a release that finds it + * unread can tell the primary in its place (channelNoticeForPrimary), which + * calls this again for the agent then gone. A policy admission's goes where + * addMessage delivers it, to no agent of its own, and carries none. */ private channelOpenedNotices( agentName: string | undefined, + serverId: string, channels: Array<{ channelId: string; label?: string }>, cause: ChannelOpenedCause, ): ChannelNotice[] { @@ -15201,7 +15211,7 @@ export class AgentFramework { ...(tellPrimary ? { untold: readerText('') } : {}), metadata: { system: true, kind: 'channel-notice', - ...(agentName && canClose ? { passOn: { notice: 'channel-opened', cause, channels } satisfies ChannelNoticePassOn } : {}), + ...(agentName && canClose ? { passOn: { notice: 'channel-opened', serverId, cause, channels } satisfies ChannelNoticePassOn } : {}), } as MessageMetadata, ...(agentName ? { target: { forAgent: agentName } } : {}), told: agentName ?? 'primary', @@ -15239,8 +15249,10 @@ export class AgentFramework { * what an open channel delivers is in its window. Where conversations are * routed, an incoming message goes to the fork its channel is bound to, or * nowhere, and never to the primary (handleMcplChannelIncoming), so a fork - * holds only its home's, and only while the router binds its home to it: a - * fork whose binding has expired, closing, holds none. Elsewhere an + * holds only its home's, and only while the router binds its home to it. + * An idle expiry's sweep unbinds the home before the fork's closure turn + * (ConversationRouter.expired), and from then the closing fork holds none; + * a newer fork the home is bound to holds it instead. Elsewhere an * incoming message is stored with no forAgent, in deliveryAgent()'s store, * and where that's the residents' shared slot, every agent that reads the * slot holds it (sharedSlotAgents: the ordinary residents, and the @@ -15311,9 +15323,12 @@ export class AgentFramework { * opening, the primary is told by name in its place, if one is registered, * as the opened notice tells it of an open by an agent that has ended, so * an agent that could close the channel hears it may open. It names - * channel_open as the way to try again only to an agent that can call it: - * a conversation fork's channel_open is refused outright + * channel_open as the way to try again only where the agent it's addressed + * to can call it: a conversation fork's channel_open is refused outright * (dispatchChannelToolCall), and an agent's allowedTools can leave it out. + * Others can read it too: the other residents, where several share one + * slot (#291), and forks spawned from the primary afterwards, from its + * window (#235). * Where the channel is selected for opening, it says so and why, as * speech's failure does, and what that means: where the host couldn't * record putting the channel's decision back, a later delivery or @@ -15328,10 +15343,11 @@ export class AgentFramework { */ private recordReplyOpenFailedNotice( agentName: string | undefined, + serverId: string, channel: { channelId: string; label?: string }, selectedForOpening?: SelectedForOpening, ): void { - this.writeChannelNotices(this.replyOpenFailedNotices(agentName, channel, selectedForOpening), 'reply open-failed notice', 'recordReplyOpenFailedNotice'); + this.writeChannelNotices(this.replyOpenFailedNotices(agentName, serverId, channel, selectedForOpening), 'reply open-failed notice', 'recordReplyOpenFailedNotice'); } /** @@ -15340,13 +15356,14 @@ export class AgentFramework { * then the reader's, which then says the primary was told (`untold`: its * words where that didn't land). None, logged, where the agent has ended and the channel * stays closed, or there's no primary to tell. The reader's carries its - * inputs (`passOn`) where it's addressed to the agent whose reply it was, - * the channel is selected for opening and that agent was told it can close - * it, so a release that finds it unread can tell the primary in its place - * (channelNoticeForPrimary). + * inputs (`passOn`), the channel's server among them, where it's addressed + * to the agent whose reply it was, the channel is selected for opening and + * that agent was told it can close it, so a release that finds it unread + * can tell the primary in its place (channelNoticeForPrimary). */ private replyOpenFailedNotices( agentName: string | undefined, + serverId: string, channel: { channelId: string; label?: string }, selectedForOpening?: SelectedForOpening, ): ChannelNotice[] { @@ -15395,7 +15412,7 @@ export class AgentFramework { : { system: true, kind: 'channel-notice', ...(agentName && selectedForOpening !== undefined && canClose - ? { passOn: { notice: 'reply-open-failed', channel, selectedForOpening } satisfies ChannelNoticePassOn } + ? { passOn: { notice: 'reply-open-failed', serverId, channel, selectedForOpening } satisfies ChannelNoticePassOn } : {}), }) as MessageMetadata, ...(agentName && !ended ? { target: { forAgent: agentName } } : {}), @@ -21873,38 +21890,43 @@ export class AgentFramework { * `speaker` has ended, gives the words it gives an agent that has ended, * judged for the primary, or nothing, logged, with no primary to tell. * They speak of the open or the failure as it was, as the notice did, and - * only of what still holds by each channel's durable desired state: the - * registry's (desiredStateOf), or before there is one, as at a serving - * boot's flush, the lifecycle log's (desiredStateInLog). An opened channel - * since closed is left out, and so is a failed one no longer selected for - * opening, or open since. With nothing left, no one is told, logged here. + * only of what still holds by each channel's durable decision on its own + * server: the registry's (getDesiredState), or before there is one, as at + * a serving boot's flush, the lifecycle log's (desiredStateInLog). That's + * the decision, not the transport at that moment: it's what channel_close + * changes and what the host carries out once the transport is back, and at + * a serving boot no transport is up yet. An opened channel no longer + * decided open or tuned out is left out, and so is a failed one no longer + * selected for opening, or open since. With nothing left, no one is told, + * logged here. */ private channelNoticeForPrimary( speaker: string, passOn: ChannelNoticePassOn, ): { content: ContentBlock[]; metadata: MessageMetadata } | null { const registry = this.channelRegistry; - const desiredState = (channelId: string) => - (registry ? registry.desiredStateOf(channelId) : desiredStateInLog(this.store, channelId)); + const { serverId } = passOn; + // A tune-out leaves a channel open, its traffic held, and keeps a channel + // not open yet selected for opening (reconcile opens it). + const decidedOpen = (channelId: string) => { + const desired = registry ? registry.getDesiredState(serverId, channelId) : desiredStateInLog(this.store, serverId, channelId); + return desired === 'open' || desired === 'tuned-out'; + }; let notice: ChannelNotice | undefined; if (passOn.notice === 'channel-opened') { - // A tune-out leaves a channel open, its traffic held. - const channels = passOn.channels.filter((c) => { - const desired = desiredState(c.channelId); - return desired === 'open' || desired === 'tuned-out'; - }); + const channels = passOn.channels.filter((c) => decidedOpen(c.channelId)); if (channels.length === 0) { - console.error(`[channel] ${speaker} has ended, and what its notice said opened is closed now (${passOn.channels.map(shownChannel).join(', ')}): no one is told`); + console.error(`[channel] ${speaker} has ended, and no channel its notice said opened is decided open or tuned out now (${passOn.channels.map(shownChannel).join(', ')}): no one is told`); return null; } - [notice] = this.channelOpenedNotices(speaker, channels, passOn.cause); + [notice] = this.channelOpenedNotices(speaker, serverId, channels, passOn.cause); } else { const { channelId } = passOn.channel; - if (desiredState(channelId) !== 'open' || registry?.isChannelOpen(channelId)) { - console.error(`[channel] ${speaker} has ended, and ${shownChannel(passOn.channel)} is no longer waiting to open, as its notice said: no one is told`); + if (!decidedOpen(channelId) || registry?.isChannelOpen(channelId, serverId)) { + console.error(`[channel] ${speaker} has ended, and ${shownChannel(passOn.channel)}, which its notice said was not open and selected for opening, is open now or no longer selected: no one is told`); return null; } - [notice] = this.replyOpenFailedNotices(speaker, passOn.channel, passOn.selectedForOpening); + [notice] = this.replyOpenFailedNotices(speaker, serverId, passOn.channel, passOn.selectedForOpening); } return notice ? { content: [{ type: 'text', text: notice.text }], metadata: notice.metadata } : null; } @@ -22955,8 +22977,8 @@ export class AgentFramework { // opt-out named to a reader that can use it. Routed here so // registry-driven opens and framework-driven opens produce the same // durable notice. - onChannelAutoOpened: ({ conversationId, source, channels }) => { - this.recordChannelAutoOpenNotice(conversationId, channels, source); + onChannelAutoOpened: ({ conversationId, serverId, source, channels }) => { + this.recordChannelAutoOpenNotice(conversationId, serverId, channels, source); }, }, ); @@ -26206,6 +26228,7 @@ export class AgentFramework { ); this.recordChannelAutoOpenNotice( agentName, + serverId, [{ channelId: channelId ?? target, label }], 'opened-by-reply', ); @@ -26223,7 +26246,7 @@ export class AgentFramework { `[channel] ${agentName}: ${toolName} delivered to ${target} but the open-on-send failed` + (tell ? '; the agent is told' : ''), ); - if (tell) this.recordReplyOpenFailedNotice(agentName, { channelId: channelId ?? target, label }, selectedForOpening); + if (tell) this.recordReplyOpenFailedNotice(agentName, serverId, { channelId: channelId ?? target, label }, selectedForOpening); } }) .catch((err) => console.error('[channel] open-on-send error:', err)); diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 72a90bf51..970b47ab4 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -257,23 +257,22 @@ function desiredStateRecord(item: unknown): { } /** - * The desired state `store`'s lifecycle log records for `channelId`, found - * by its id alone, for a reader with no registry yet, as a serving boot's + * The desired state `store`'s lifecycle log records for `channelId` on + * `serverId`, for a reader with no registry yet, as a serving boot's * deferred-write flush is, before initializeMcpl: the last decision wins, as - * the registry's replay reads it (desiredStateOf answers once there is a - * registry). undefined when no decision names it, or decisions on more than - * one server do. + * the registry's replay reads it (getDesiredState answers once there is a + * registry). undefined when no decision names it. */ -export function desiredStateInLog(store: JsStore, channelId: string): DesiredChannelState | undefined { +export function desiredStateInLog(store: JsStore, serverId: string, channelId: string): DesiredChannelState | undefined { ensureStateRegistered(store, { id: CHANNEL_LIFECYCLE_LOG_ID, strategy: 'append_log' }); const raw = store.getStateJson(CHANNEL_LIFECYCLE_LOG_ID); if (!Array.isArray(raw)) return undefined; - const byServer = new Map(); + let state: DesiredChannelState | undefined; for (const item of raw) { const decision = desiredStateRecord(item); - if (decision?.channelId === channelId) byServer.set(decision.serverId, decision.state); + if (decision?.serverId === serverId && decision.channelId === channelId) state = decision.state; } - return byServer.size === 1 ? [...byServer.values()][0] : undefined; + return state; } /** One append-log record in `CHANNEL_LABEL_HISTORY_LOG_ID`: "at `ts`, this @@ -2052,30 +2051,18 @@ export class ChannelRegistry { return typeof label === 'string' && label.length > 0 ? label : undefined; } - isChannelOpen(channelId: string): boolean { - return this.findChannelEntry(channelId)?.open === true; + /** Whether `channelId`'s transport is open: on `serverId` where the caller + * knows the channel's server, and otherwise on the first server found to + * register that id (findChannelEntry). */ + isChannelOpen(channelId: string, serverId?: string): boolean { + const entry = serverId ? this.channels.get(`${serverId}:${channelId}`) : this.findChannelEntry(channelId); + return entry?.open === true; } getDesiredState(serverId: string, channelId: string): DesiredChannelState | undefined { return this.desiredStates.get(this.lifecycleKey(serverId, channelId))?.state; } - /** - * The durable desired state of `channelId`, for a reader that knows only - * the channel: its server's record where the channel is registered, and - * otherwise the lifecycle log's record for that id, which is all boot has - * before the servers reconnect. undefined when no record names it, or - * records on more than one server do. - */ - desiredStateOf(channelId: string): DesiredChannelState | undefined { - const serverId = this.getChannelServerId(channelId); - if (serverId !== null) return this.getDesiredState(serverId, channelId); - const states = [...this.desiredStates] - .filter(([key]) => key.slice(key.indexOf('\u0000') + 1) === channelId) - .map(([, record]) => record.state); - return states.length === 1 ? states[0] : undefined; - } - /** * Get all open channels. */ diff --git a/test/channel-lifecycle-order.test.ts b/test/channel-lifecycle-order.test.ts index 28ec909c2..57e02d6d1 100644 --- a/test/channel-lifecycle-order.test.ts +++ b/test/channel-lifecycle-order.test.ts @@ -769,9 +769,9 @@ test('failed policy opening records intent once, with truthful resident text thr }); try { const recordNotice = (framework as unknown as { - recordChannelAutoOpenNotice(agent: undefined, channels: Array<{ channelId: string; label?: string }>, cause: 'subscription-policy' | 'opened-by-delivery'): void; + recordChannelAutoOpenNotice(agent: undefined, serverId: string, channels: Array<{ channelId: string; label?: string }>, cause: 'subscription-policy' | 'opened-by-delivery'): void; }).recordChannelAutoOpenNotice.bind(framework); - const onNotice: NoticeHandler = (info) => recordNotice(undefined, info.channels, info.source); + const onNotice: NoticeHandler = (info) => recordNotice(undefined, info.serverId, info.channels, info.source); const f = fixture(onNotice, framework.getStore()); f.registry.setSubscriptionPolicy('test', 'auto'); f.held.add('x'); @@ -1556,13 +1556,14 @@ test('the notices say what happened, name the opt-out, and state no rule about l const recordNotice = (framework as unknown as { recordChannelAutoOpenNotice( agent: undefined, + serverId: string, channels: Array<{ channelId: string; label?: string }>, cause: 'subscription-policy' | 'opened-by-delivery' | 'opened-by-reply', ): void; }).recordChannelAutoOpenNotice.bind(framework); - recordNotice(undefined, [{ channelId: 'discord:g1:c1', label: '#general' }], 'opened-by-reply'); - recordNotice(undefined, [{ channelId: 'c2' }], 'opened-by-delivery'); - recordNotice(undefined, [{ channelId: 'c3' }, { channelId: 'c4', label: 'ops' }], 'subscription-policy'); + recordNotice(undefined, 'srv', [{ channelId: 'discord:g1:c1', label: '#general' }], 'opened-by-reply'); + recordNotice(undefined, 'srv', [{ channelId: 'c2' }], 'opened-by-delivery'); + recordNotice(undefined, 'srv', [{ channelId: 'c3' }, { channelId: 'c4', label: 'ops' }], 'subscription-policy'); const texts = framework.getAgent('resident')!.getContextManager().getAllMessages() .flatMap((m) => m.content) .filter((b) => b.type === 'text' && b.text.startsWith('[channels]')) @@ -2340,7 +2341,7 @@ test("a reply whose open would have opened the channel, and failed, tells the ag ], 'where the subscription policy lifted it'); }); -test("the reply notice names channel_open and channel_close only to an agent that can call them; where the replying agent has ended, no one is told unless the channel is selected for opening, and then the primary is, by name", async () => { +test("the reply notice names channel_open and channel_close only where the agent it's addressed to can call them; where the replying agent has ended, no one is told unless the channel is selected for opening, and then the primary is, by name", async () => { const facts = '[channels] Not open: #x (x). Your reply there went out, but opening the channel failed.'; const stays = " It stays selected for opening, because the host couldn't record undoing that: a later delivery or reconnect tries again"; const fork = await replyOpenFailedNotices({ opening: true, directs: true, homes: [['agent', 'x']] }); @@ -2431,7 +2432,7 @@ test("a reply notice with no agent named, or an empty name, goes out unrouted, w const error = console.error; console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; try { - framework.recordReplyOpenFailedNotice(c.name, { channelId: 'x', label: '#x' }, c.selectedForOpening); + framework.recordReplyOpenFailedNotice(c.name, 'srv', { channelId: 'x', label: '#x' }, c.selectedForOpening); } finally { console.error = error; } @@ -2468,11 +2469,11 @@ test("where a reply's reader can't close a channel selected for opening, the pri primary: 'primary', selectedForOpening: 'server-bootstrap', // Its inputs, so a release that finds it unread tells the primary. told: [{ text: facts + again + ', and channel_close withdraws that.', forAgent: 'helper', metadata: { system: true, kind: 'channel-notice', - passOn: { notice: 'reply-open-failed', channel: { channelId: 'x', label: '#x' }, selectedForOpening: 'server-bootstrap' } } }] }, + passOn: { notice: 'reply-open-failed', serverId: 'srv', channel: { channelId: 'x', label: '#x' }, selectedForOpening: 'server-bootstrap' } } }] }, { why: 'a reader who can close it, the put-back unrecorded', name: 'helper', agents: [['helper', []], ['primary', []]], primary: 'primary', selectedForOpening: 'unrecorded', told: [{ text: facts + stays + ', and channel_close withdraws that. channel_open tries again.', forAgent: 'helper', metadata: { system: true, kind: 'channel-notice', - passOn: { notice: 'reply-open-failed', channel: { channelId: 'x', label: '#x' }, selectedForOpening: 'unrecorded' } } }] }, + passOn: { notice: 'reply-open-failed', serverId: 'srv', channel: { channelId: 'x', label: '#x' }, selectedForOpening: 'unrecorded' } } }] }, { why: "a primary that can close it but not open it, the put-back unrecorded", name: 'fork', agents: [['fork', []], ['primary', ['channel_open']]], homes: [['fork', 'y']], primary: 'primary', selectedForOpening: 'unrecorded', told: [ @@ -2511,7 +2512,7 @@ test("where a reply's reader can't close a channel selected for opening, the pri const error = console.error; console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; try { - framework.recordReplyOpenFailedNotice(c.name, { channelId: 'x', label: '#x' }, c.selectedForOpening); + framework.recordReplyOpenFailedNotice(c.name, 'srv', { channelId: 'x', label: '#x' }, c.selectedForOpening); } finally { console.error = error; } @@ -2523,11 +2524,13 @@ test("where a reply's reader can't close a channel selected for opening, the pri }); // The opened notice follows the reply notice's rule: channel_close is named -// only to a reader that can call it for every channel named, and where an +// only where the agent it's addressed to can call it for every channel named +// (other residents of one slot, and forks spawned from the primary later, may +// read it too: #291, #235), and where an // agent's send opened a channel it can't close, the primary is told by name // too, if it can close it. The reader's notice carries its inputs only where // it was told it can close the channels, so a release passes on only that. -test('the opened notice names channel_close only to a reader that can call it, says what the channel delivers only to the agent that holds its traffic, and tells the primary by name where the agent whose send opened it can\'t close it', () => { +test('the opened notice names channel_close only where the agent it\'s addressed to can call it, says what the channel delivers only to the agent that holds its traffic, and tells the primary by name where the agent whose send opened it can\'t close it', () => { // What the channel delivers: said to the agent whose window holds its // traffic. Where conversations are routed (router), that's a fork for its // home alone; elsewhere, the agent addMessage delivers to. @@ -2536,7 +2539,7 @@ test('the opened notice names channel_close only to a reader that can call it, s const closeMany = " If you don't want one of them, use channel_close; your choice outranks policy."; const own = '[channels] Now open: #x (x): your own message into the closed channel opened it.'; const opened = (passOn: unknown) => ({ system: true, kind: 'channel-notice', passOn }); - const fromReply = { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: 'x', label: '#x' }] }; + const fromReply = { notice: 'channel-opened', serverId: 'srv', cause: 'opened-by-reply', channels: [{ channelId: 'x', label: '#x' }] }; const cases: Array<{ why: string; name?: string; cause?: 'opened-by-reply' | 'subscription-policy'; channels?: Array<{ channelId: string; label?: string }>; agents: Array<[string, string[]]>; homes?: Array<[string, string]>; labels?: Array<[string, string]>; isolated?: string[]; @@ -2568,9 +2571,12 @@ test('the opened notice names channel_close only to a reader that can call it, s ] }, { why: 'a fork at its home', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'x']], router: true, primary: 'primary', told: [{ text: own + optOut + close, metadata: opened(fromReply), forAgent: 'fork' }] }, - { why: 'a fork at its home whose binding has expired, closing: its home routes to it no more', name: 'fork', agents: [['fork', []], ['primary', []]], + { why: 'a fork at its home whose binding the sweep has ended, closing: its home routes to it no more', name: 'fork', agents: [['fork', []], ['primary', []]], homes: [['fork', 'x']], router: true, expired: ['fork'], primary: 'primary', told: [{ text: own + close, metadata: opened(fromReply), forAgent: 'fork' }] }, + { why: 'a fork at its home, which the router has bound to a newer fork since: its home routes to that one', name: 'fork', + agents: [['fork', []], ['newer', []], ['primary', []]], homes: [['fork', 'x'], ['newer', 'x']], router: true, primary: 'primary', + told: [{ text: own + close, metadata: opened(fromReply), forAgent: 'fork' }] }, { why: 'the subconscious, which reads the shared slot through its merged view', name: 'sub', agents: [['sub', []], ['primary', []]], subconscious: 'sub', primary: 'primary', told: [{ text: own + optOut + close, metadata: opened(fromReply), forAgent: 'sub' }] }, { why: 'a second resident whose allowedTools leave channel_close out', name: 'helper', agents: [['helper', ['channel_close']], ['primary', []]], primary: 'primary', @@ -2622,7 +2628,8 @@ test('the opened notice names channel_close only to a reader that can call it, s framework.primaryAgentName = c.primary; framework.usedEphemeralAgentNames = new Set(); framework.conversationAgentHomes = new Map(c.homes ?? []); - // Each fork is bound to its home, unless its binding has expired. + // Each fork is bound to its home, unless the sweep has ended its binding; + // of two forks with one home, the router binds the later. const bound = new Map((c.homes ?? []).filter(([fork]) => !(c.expired ?? []).includes(fork)).map(([fork, home]) => [home, fork])); framework.conversationRouter = c.router ? { templateAgent: c.template ?? 'primary', getBinding: (id: string) => (bound.has(id) ? { channelId: id, agentName: bound.get(id) } : undefined) } @@ -2641,7 +2648,7 @@ test('the opened notice names channel_close only to a reader that can call it, s const error = console.error; console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; try { - framework.recordChannelAutoOpenNotice(c.name, c.channels ?? [{ channelId: 'x', label: '#x' }], c.cause ?? 'opened-by-reply'); + framework.recordChannelAutoOpenNotice(c.name, 'srv', c.channels ?? [{ channelId: 'x', label: '#x' }], c.cause ?? 'opened-by-reply'); } finally { console.error = error; } @@ -2675,8 +2682,8 @@ test("each channel notice is written on its own, and the reader's says the prima const error = console.error; console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; try { - if (write === 'opened') framework.recordChannelAutoOpenNotice('fork', [{ channelId: 'x', label: '#x' }], 'opened-by-reply'); - else framework.recordReplyOpenFailedNotice('fork', { channelId: 'x', label: '#x' }, 'server-bootstrap'); + if (write === 'opened') framework.recordChannelAutoOpenNotice('fork', 'srv', [{ channelId: 'x', label: '#x' }], 'opened-by-reply'); + else framework.recordReplyOpenFailedNotice('fork', 'srv', { channelId: 'x', label: '#x' }, 'server-bootstrap'); } finally { console.error = error; } @@ -2709,12 +2716,12 @@ test("a channel notice's inputs give the primary's by-name notice once its reade framework.usedEphemeralAgentNames = new Set(); framework.conversationAgentHomes = new Map(); // Every channel still as the notice says: open, or still waiting to open. - framework.channelRegistry = { desiredStateOf: () => 'open', isChannelOpen: () => false }; + framework.channelRegistry = { getDesiredState: () => 'open', isChannelOpen: () => false }; const error = console.error; console.error = () => {}; try { const reply = framework.noticeForPrimary('gone', { system: true, kind: 'channel-notice', - passOn: { notice: 'reply-open-failed', channel: { channelId: 'x', label: '#x' }, selectedForOpening: 'unrecorded' } }); + passOn: { notice: 'reply-open-failed', serverId: 'srv', channel: { channelId: 'x', label: '#x' }, selectedForOpening: 'unrecorded' } }); assert.deepEqual(reply?.content, [{ type: 'text', text: "[channels] Not open: #x (x). A reply from gone went out there, but opening the channel failed; gone has ended. " + "It stays selected for opening, because the host couldn't record undoing that: a later delivery or reconnect tries again, and channel_close withdraws that." }], @@ -2722,25 +2729,33 @@ test("a channel notice's inputs give the primary's by-name notice once its reade assert.deepEqual(reply?.metadata, { system: true, kind: 'channel-notice', speaker: 'gone' }); for (const passOn of [ undefined, - { notice: 'channel-opened', cause: 'opened-by-reply', channels: [] }, - { notice: 'channel-opened', cause: 'opened-by-whim', channels: [{ channelId: 'x' }] }, - { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: '' }] }, - { notice: 'reply-open-failed', channel: { channelId: 'x' } }, - { notice: 'reply-open-failed', channel: { channelId: 'x' }, selectedForOpening: 'maybe' }, - { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: 7 }] }, - { notice: 'channel-opened', cause: 'opened-by-reply', channels: 'x' }, - { notice: 'channel-opened', cause: 'subscription-policy', channels: [{ channelId: 'x' }] }, - { notice: 'reply-open-failed', channel: null, selectedForOpening: 'unrecorded' }, - { notice: 'something-else' }, + { notice: 'channel-opened', serverId: 'srv', cause: 'opened-by-reply', channels: [] }, + { notice: 'channel-opened', serverId: 'srv', cause: 'opened-by-whim', channels: [{ channelId: 'x' }] }, + { notice: 'channel-opened', serverId: 'srv', cause: 'opened-by-reply', channels: [{ channelId: '' }] }, + { notice: 'reply-open-failed', serverId: 'srv', channel: { channelId: 'x' } }, + { notice: 'reply-open-failed', serverId: 'srv', channel: { channelId: 'x' }, selectedForOpening: 'maybe' }, + { notice: 'channel-opened', serverId: 'srv', cause: 'opened-by-reply', channels: [{ channelId: 7 }] }, + { notice: 'channel-opened', serverId: 'srv', cause: 'opened-by-reply', channels: 'x' }, + { notice: 'channel-opened', serverId: 'srv', cause: 'subscription-policy', channels: [{ channelId: 'x' }] }, + { notice: 'reply-open-failed', serverId: 'srv', channel: null, selectedForOpening: 'unrecorded' }, + { notice: 'something-else', serverId: 'srv' }, + // Each channel is checked on its own server, so a notice must name one. + { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: 'x' }] }, + { notice: 'channel-opened', serverId: '', cause: 'opened-by-reply', channels: [{ channelId: 'x' }] }, + { notice: 'reply-open-failed', serverId: 7, channel: { channelId: 'x' }, selectedForOpening: 'unrecorded' }, ]) { assert.equal(framework.noticeForPrimary('gone', { system: true, kind: 'channel-notice', ...(passOn ? { passOn } : {}) }), null, JSON.stringify(passOn)); } - assert.equal(framework.noticeForPrimary('gone', { system: true, kind: 'routing-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: 'x' }] } }), null, + assert.equal(framework.noticeForPrimary('gone', { system: true, kind: 'routing-notice', passOn: { notice: 'channel-opened', serverId: 'srv', cause: 'opened-by-reply', channels: [{ channelId: 'x' }] } }), null, 'only a channel notice'); - // A channel with no label, or one whose label isn't text, is named by its id. + // A channel with no label, or one whose label isn't text, is named by its + // id, and so is one whose label is its id. const unlabeled = framework.noticeForPrimary('gone', { system: true, kind: 'channel-notice', - passOn: { notice: 'channel-opened', cause: 'opened-by-delivery', channels: [{ channelId: 'x', label: 9 }] } }); + passOn: { notice: 'channel-opened', serverId: 'srv', cause: 'opened-by-delivery', channels: [{ channelId: 'x', label: 9 }] } }); assert.match(unlabeled?.content[0]?.text ?? '', /^\[channels\] Now open: x: a message from gone into the closed channel opened it; gone has ended\./); + const labelIsId = framework.noticeForPrimary('gone', { system: true, kind: 'channel-notice', + passOn: { notice: 'channel-opened', serverId: 'srv', cause: 'opened-by-delivery', channels: [{ channelId: 'x', label: 'x' }] } }); + assert.match(labelIsId?.content[0]?.text ?? '', /^\[channels\] Now open: x: a message from gone into the closed channel opened it; gone has ended\./); } finally { console.error = error; } diff --git a/test/channel-lifecycle-provenance.test.ts b/test/channel-lifecycle-provenance.test.ts index c4b2f5150..cadb086f5 100644 --- a/test/channel-lifecycle-provenance.test.ts +++ b/test/channel-lifecycle-provenance.test.ts @@ -67,35 +67,45 @@ test('initiallyOpen at first sight registers open with server-bootstrap provenan assert.equal(r.getDesiredState('eidoverse', 'world:commons'), 'open'); }); -test("desiredStateOf finds a channel's decision by its id alone: its registered server's, else the one server's on record", () => { - const { registry, r } = makeRegistry(); - r.setDesiredState('a', 'c-1', 'open', 'opened-by-reply'); - assert.equal(registry.desiredStateOf('c-1'), 'open', 'the one decision on record'); - r.setDesiredState('b', 'c-1', 'closed', 'agent-tool'); - assert.equal(registry.desiredStateOf('c-1'), undefined, 'decisions on two servers, and neither registers it'); - (registry as unknown as { channels: Map }).channels - .set('a:c-1', { serverId: 'a', descriptor: { id: 'c-1', type: 'a', label: '#one' }, open: false }); - assert.equal(registry.desiredStateOf('c-1'), 'open', 'the registered server\'s'); - assert.equal(registry.desiredStateOf('c-2'), undefined, 'none on record'); -}); - -test("desiredStateInLog reads a channel's last decision from a store's lifecycle log, as a registry replayed from it does", () => { +test("desiredStateInLog reads a server's channel's last decision from a store's lifecycle log, by the rule a registry replayed from it reads", () => { const dir = mkdtempSync(join(tmpdir(), 'desired-state-in-log-')); const store = JsStore.openOrCreate({ path: join(dir, 'store') }); try { - assert.equal(desiredStateInLog(store, 'c-1'), undefined, 'a store no registry has written to'); - const { r } = makeRegistry(store); + assert.equal(desiredStateInLog(store, 'a', 'c-1'), undefined, 'a store no registry has written to'); + const { registry, r } = makeRegistry(store); r.setDesiredState('a', 'c-1', 'open', 'opened-by-reply'); r.setDesiredState('a', 'c-1', 'closed', 'agent-tool'); r.setDesiredState('a', 'c-2', 'open', 'opened-by-reply'); + // One channel id on two servers, each with its own decision. r.setDesiredState('a', 'c-3', 'open', 'opened-by-reply'); r.setDesiredState('b', 'c-3', 'closed', 'agent-tool'); - assert.equal(desiredStateInLog(store, 'c-1'), 'closed', 'the last decision wins'); - assert.equal(desiredStateInLog(store, 'c-2'), 'open'); - assert.equal(desiredStateInLog(store, 'c-3'), undefined, 'decisions on two servers'); - assert.equal(desiredStateInLog(store, 'c-4'), undefined, 'none on record'); + registry.enterTuneOut('a', 'c-9', { epochId: 'e-9', cadenceSeconds: 60, backlogCap: 10, maxWakes: 3, startedAtSequence: 0 }, 'agent-tool'); + // Records the rule doesn't read as a decision: another kind, a channel id + // that isn't text, a tune-out with no params, a state no build writes. + // A decision with no source is still a decision. + for (const item of [ + { kind: 'channel-renamed', serverId: 'a', channelId: 'c-5', desired: 'open', source: 'agent-tool' }, + { kind: 'desired-state', serverId: 'a', channelId: 6, desired: 'open', source: 'agent-tool' }, + { kind: 'desired-state', serverId: 'a', channelId: 'c-7', desired: 'tuned-out', source: 'agent-tool' }, + { kind: 'desired-state', serverId: 'a', channelId: 'c-8', desired: 'maybe', source: 'agent-tool' }, + { kind: 'desired-state', serverId: 'a', channelId: 'c-10', desired: 'open' }, + ]) store.appendToStateJson('mcpl/channel-lifecycle', { ...item, timestamp: new Date().toISOString() } as never); + assert.equal(desiredStateInLog(store, 'a', 'c-1'), 'closed', 'the last decision wins'); + assert.equal(desiredStateInLog(store, 'a', 'c-2'), 'open'); + assert.equal(desiredStateInLog(store, 'a', 'c-3'), 'open', "a's own decision"); + assert.equal(desiredStateInLog(store, 'b', 'c-3'), 'closed', "b's own decision"); + assert.equal(desiredStateInLog(store, 'b', 'c-2'), undefined, "another server's channel"); + assert.equal(desiredStateInLog(store, 'a', 'c-9'), 'tuned-out'); + for (const [server, id] of [['a', 'c-5'], ['a', '6'], ['a', 'c-7'], ['a', 'c-8']]) { + assert.equal(desiredStateInLog(store, server!, id!), undefined, `${server}:${id} isn't a decision`); + } + assert.equal(desiredStateInLog(store, 'a', 'c-10'), 'open', 'a decision with no source'); + assert.equal(desiredStateInLog(store, 'a', 'c-4'), undefined, 'none on record'); const replayed = makeRegistry(store).registry; - for (const id of ['c-1', 'c-2', 'c-3', 'c-4']) assert.equal(replayed.desiredStateOf(id), desiredStateInLog(store, id), id); + for (const [server, id] of [['a', 'c-1'], ['a', 'c-2'], ['a', 'c-3'], ['b', 'c-3'], ['b', 'c-2'], ['a', 'c-9'], + ['a', 'c-5'], ['a', '6'], ['a', 'c-7'], ['a', 'c-8'], ['a', 'c-10'], ['a', 'c-4']]) { + assert.equal(replayed.getDesiredState(server!, id!), desiredStateInLog(store, server!, id!), `${server}:${id}`); + } } finally { store.close(); rmSync(dir, { recursive: true, force: true }); diff --git a/test/conversation-router.test.ts b/test/conversation-router.test.ts index d0bd6eb08..00d073924 100644 --- a/test/conversation-router.test.ts +++ b/test/conversation-router.test.ts @@ -125,7 +125,7 @@ test('rebind after expiry gets a fresh generation and agent name', () => { if (d1.kind !== 'spawn') assert.fail('expected spawn'); router.bind('slack:C1', d1.agentName, d1.generation, T0); - router.unbind('slack:C1'); // framework does this after the closure turn + router.unbind('slack:C1'); // the framework does this before the old fork's closure turn (sweepExpiredConversations) const d2 = router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel' }); assert.equal(d2.kind, 'spawn'); diff --git a/test/route-failure-targeting.test.ts b/test/route-failure-targeting.test.ts index 8d68d478a..a3e737c15 100644 --- a/test/route-failure-targeting.test.ts +++ b/test/route-failure-targeting.test.ts @@ -32,7 +32,7 @@ function internals(framework: AgentFramework) { injectScriptWake(record: { id: string; agentName: string }, envelope: string): void; closingConversationAgents: Set; activeStreams: Map>; - recordChannelAutoOpenNotice(agentName: string | undefined, channels: Array<{ channelId: string; label?: string }>, cause: 'subscription-policy' | 'opened-by-delivery' | 'opened-by-reply'): void; + recordChannelAutoOpenNotice(agentName: string | undefined, serverId: string, channels: Array<{ channelId: string; label?: string }>, cause: 'subscription-policy' | 'opened-by-delivery' | 'opened-by-reply'): void; addMessage(participant: string, content: ContentBlock[], metadata?: Record, opts?: { forAgent?: string }): string; }; } @@ -140,11 +140,11 @@ describe('notices about an agent\'s own action', () => { return framework.getAgent(binding.agentName)!; } - /** A durable decision for `channel`, as an open or a close records it in - * the registry's lifecycle log. */ - function decide(channel: string, desired: 'open' | 'closed', source = 'opened-by-reply', on = framework) { + /** A durable decision for `channel` on `server`, as an open or a close + * records it in the registry's lifecycle log. */ + function decide(channel: string, desired: 'open' | 'closed', source = 'opened-by-reply', on = framework, server = 'srv') { (internals(on).channelRegistry as unknown as { setDesiredState(serverId: string, channelId: string, desired: string, source: string): void }) - .setDesiredState('srv', channel, desired, source); + .setDesiredState(server, channel, desired, source); } async function runEphemeral(proseRouting: 'locus' | 'explicit', speech: string) { @@ -646,13 +646,13 @@ describe('notices about an agent\'s own action', () => { const forkCm = t.mock.method(fork.getContextManager(), 'addMessage'); // The fork's own home channel, which it can close. - state.recordChannelAutoOpenNotice(fork.name, [{ channelId: 'missing-channel', label: '#home' }], 'opened-by-reply'); + state.recordChannelAutoOpenNotice(fork.name, 'srv', [{ channelId: 'missing-channel', label: '#home' }], 'opened-by-reply'); assert.equal(forkCm.mock.callCount(), 1); assert.equal(primary.mock.callCount(), 0); assert.match(JSON.stringify(forkCm.mock.calls[0]!.arguments[1]), /Now open: #home \(missing-channel\): your own message into the closed channel opened it\. .*use channel_close/); - state.recordChannelAutoOpenNotice(undefined, [{ channelId: 'c-2' }], 'subscription-policy'); + state.recordChannelAutoOpenNotice(undefined, 'srv', [{ channelId: 'c-2' }], 'subscription-policy'); assert.equal(primary.mock.callCount(), 1, 'no agent caused a policy open'); assert.equal(forkCm.mock.callCount(), 1); }); @@ -663,7 +663,7 @@ describe('notices about an agent\'s own action', () => { const primary = t.mock.method(framework.getAgent('primary')!.getContextManager(), 'addMessage'); const forkCm = t.mock.method(fork.getContextManager(), 'addMessage'); - state.recordChannelAutoOpenNotice(fork.name, [{ channelId: 'c-1', label: '#one' }], 'opened-by-reply'); + state.recordChannelAutoOpenNotice(fork.name, 'srv', [{ channelId: 'c-1', label: '#one' }], 'opened-by-reply'); assert.equal(forkCm.mock.callCount(), 1); assert.equal(JSON.stringify(forkCm.mock.calls[0]!.arguments[1]), JSON.stringify([{ type: 'text', text: @@ -696,7 +696,7 @@ describe('notices about an agent\'s own action', () => { const fork = await spawnFork(); const state = internals(framework); decide('missing-channel', 'open'); - state.recordChannelAutoOpenNotice(fork.name, [{ channelId: 'missing-channel', label: '#home' }], 'opened-by-reply'); + state.recordChannelAutoOpenNotice(fork.name, 'srv', [{ channelId: 'missing-channel', label: '#home' }], 'opened-by-reply'); reap(fork.name); @@ -715,7 +715,7 @@ describe('notices about an agent\'s own action', () => { t.mock.method(console, 'error', (...args: unknown[]) => { errors.push(args.map(String).join(' ')); }); const fork = await spawnFork(); decide('missing-channel', 'open'); - internals(framework).recordChannelAutoOpenNotice(fork.name, [{ channelId: 'missing-channel', label: '#home' }], 'opened-by-reply'); + internals(framework).recordChannelAutoOpenNotice(fork.name, 'srv', [{ channelId: 'missing-channel', label: '#home' }], 'opened-by-reply'); decide('missing-channel', 'closed', 'agent-tool'); reap(fork.name); @@ -725,13 +725,13 @@ describe('notices about an agent\'s own action', () => { .filter((m) => JSON.stringify(m.content).includes('#home')); assert.equal(told.length, 0, 'the primary is told nothing'); assert.ok(errors.includes( - `[channel] ${fork.name} has ended, and what its notice said opened is closed now (#home (missing-channel)): no one is told`)); + `[channel] ${fork.name} has ended, and no channel its notice said opened is decided open or tuned out now (#home (missing-channel)): no one is told`)); }); it("passes on no channel notice its reader couldn't act on: the primary was told then, once", async () => { const fork = await spawnFork(); const state = internals(framework); - state.recordChannelAutoOpenNotice(fork.name, [{ channelId: 'c-1', label: '#one' }], 'opened-by-reply'); + state.recordChannelAutoOpenNotice(fork.name, 'srv', [{ channelId: 'c-1', label: '#one' }], 'opened-by-reply'); reap(fork.name); @@ -755,7 +755,7 @@ describe('notices about an agent\'s own action', () => { }; t.mock.method(state.mcplServerRegistry, 'getServer', () => server as never); - state.recordChannelAutoOpenNotice('released-run', [{ channelId: 'opened-1', label: '#opened' }], 'opened-by-delivery'); + state.recordChannelAutoOpenNotice('released-run', 'srv', [{ channelId: 'opened-1', label: '#opened' }], 'opened-by-delivery'); const told = framework.getAgent('primary')!.getContextManager().getAllMessages() .filter((m) => JSON.stringify(m.content).includes('Now open: #opened (opened-1)')); @@ -780,7 +780,7 @@ describe('notices about an agent\'s own action', () => { const name = state.primaryAgentName; state.primaryAgentName = null; try { - state.recordChannelAutoOpenNotice('released-run', [{ channelId: 'opened-2' }], 'opened-by-delivery'); + state.recordChannelAutoOpenNotice('released-run', 'srv', [{ channelId: 'opened-2' }], 'opened-by-delivery'); } finally { state.primaryAgentName = name; } @@ -841,12 +841,12 @@ describe('notices about an agent\'s own action', () => { { id: 'w-4', seq: 4, participant: 'user', forAgent: 'conversation-gone', content: [{ type: 'text', text: '[channels] Now open: #four (c-4): your own message into the closed channel opened it.' }], - metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: 'c-4', label: '#four' }] } }, + metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', serverId: 'srv', cause: 'opened-by-reply', channels: [{ channelId: 'c-4', label: '#four' }] } }, }, { id: 'w-5', seq: 5, participant: 'user', forAgent: 'conversation-gone', content: [{ type: 'text', text: '[channels] Not open: #five (c-5). Your reply there went out, but opening the channel failed.' }], - metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'reply-open-failed', channel: { channelId: 'c-5', label: '#five' }, selectedForOpening: 'server-bootstrap' } }, + metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'reply-open-failed', serverId: 'srv', channel: { channelId: 'c-5', label: '#five' }, selectedForOpening: 'server-bootstrap' } }, }, { // Its reader couldn't act on it, or an earlier build stored it: no inputs. @@ -885,6 +885,11 @@ describe('notices about an agent\'s own action', () => { decide('c-9', 'open', 'server-bootstrap'); (state.channelRegistry as unknown as { channels: Map }).channels .set('srv:c-9', { serverId: 'srv', descriptor: { id: 'c-9', type: 'srv', label: '#nine' }, open: true }); + // Selected for opening on its own server, where it isn't open, while + // another server's channel with the same id is. + decide('c-22', 'open', 'server-bootstrap'); + (state.channelRegistry as unknown as { channels: Map }).channels + .set('other:c-22', { serverId: 'other', descriptor: { id: 'c-22', type: 'other', label: '#twenty-two' }, open: true }); // Selected for opening, and closed since. decide('c-10', 'open', 'server-bootstrap'); decide('c-10', 'closed', 'agent-tool'); @@ -892,15 +897,24 @@ describe('notices about an agent\'s own action', () => { decide('c-13', 'open'); state.channelRegistry.enterTuneOut('srv', 'c-13', { epochId: 'e-13', cadenceSeconds: 60, backlogCap: 10, maxWakes: 3, startedAtSequence: 0 }, 'agent-tool'); - const opened = (id: string, channels: Array<{ channelId: string; label: string }>) => ({ + // One channel id on two servers: each notice is checked by its own + // server's decision. + decide('c-14', 'open'); + decide('c-14', 'closed', 'agent-tool', framework, 'other'); + // Selected for opening, and tuned out since, not open yet: a tune-out + // keeps it selected for opening. + decide('c-15', 'open', 'server-bootstrap'); + state.channelRegistry.enterTuneOut('srv', 'c-15', + { epochId: 'e-15', cadenceSeconds: 60, backlogCap: 10, maxWakes: 3, startedAtSequence: 0 }, 'agent-tool'); + const opened = (id: string, channels: Array<{ channelId: string; label: string }>, serverId = 'srv') => ({ id, seq: Number(id.slice(2)), participant: 'user', forAgent: 'conversation-gone', content: [{ type: 'text', text: '[channels] Now open: your own message into the closed channel opened it.' }], - metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-delivery', channels } }, + metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', serverId, cause: 'opened-by-delivery', channels } }, }); const failed = (id: string, channel: { channelId: string; label: string }) => ({ id, seq: Number(id.slice(2)), participant: 'user', forAgent: 'conversation-gone', content: [{ type: 'text', text: '[channels] Not open. Your reply there went out, but opening the channel failed.' }], - metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'reply-open-failed', channel, selectedForOpening: 'server-bootstrap' } }, + metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'reply-open-failed', serverId: 'srv', channel, selectedForOpening: 'server-bootstrap' } }, }); (state.deferredMessages as unknown as Array>).push( opened('w-7', [{ channelId: 'c-7', label: '#seven' }, { channelId: 'c-8', label: '#eight' }]), @@ -908,6 +922,10 @@ describe('notices about an agent\'s own action', () => { failed('w-9', { channelId: 'c-9', label: '#nine' }), failed('w-10', { channelId: 'c-10', label: '#ten' }), opened('w-13', [{ channelId: 'c-13', label: '#thirteen' }]), + opened('w-14', [{ channelId: 'c-14', label: '#fourteen' }]), + failed('w-15', { channelId: 'c-15', label: '#fifteen' }), + opened('w-16', [{ channelId: 'c-14', label: '#fourteen' }], 'other'), + failed('w-22', { channelId: 'c-22', label: '#twenty-two' }), ); await state.flushDeferredWrites('boot-recovery'); @@ -919,12 +937,19 @@ describe('notices about an agent\'s own action', () => { "If you don't want it, use channel_close; your choice outranks policy.", '[channels] Now open: #thirteen (c-13): a message from conversation-gone into the closed channel opened it; conversation-gone has ended. ' + "If you don't want it, use channel_close; your choice outranks policy.", + '[channels] Now open: #fourteen (c-14): a message from conversation-gone into the closed channel opened it; conversation-gone has ended. ' + + "If you don't want it, use channel_close; your choice outranks policy.", + "[channels] Not open: #fifteen (c-15). A reply from conversation-gone went out there, but opening the channel failed; conversation-gone has ended. " + + "It's selected for opening again, because its server lists it as open by default: the host tries again, and channel_close withdraws that.", + "[channels] Not open: #twenty-two (c-22). A reply from conversation-gone went out there, but opening the channel failed; conversation-gone has ended. " + + "It's selected for opening again, because its server lists it as open by default: the host tries again, and channel_close withdraws that.", ]); - assert.deepEqual(told.map((m) => (m.metadata as { deferredWriteId?: unknown }).deferredWriteId), ['w-7', 'w-13']); + assert.deepEqual(told.map((m) => (m.metadata as { deferredWriteId?: unknown }).deferredWriteId), ['w-7', 'w-13', 'w-14', 'w-15', 'w-22']); for (const line of [ - '[channel] conversation-gone has ended, and what its notice said opened is closed now (#eight (c-8)): no one is told', - '[channel] conversation-gone has ended, and #nine (c-9) is no longer waiting to open, as its notice said: no one is told', - '[channel] conversation-gone has ended, and #ten (c-10) is no longer waiting to open, as its notice said: no one is told', + '[channel] conversation-gone has ended, and no channel its notice said opened is decided open or tuned out now (#eight (c-8)): no one is told', + '[channel] conversation-gone has ended, and #nine (c-9), which its notice said was not open and selected for opening, is open now or no longer selected: no one is told', + '[channel] conversation-gone has ended, and #ten (c-10), which its notice said was not open and selected for opening, is open now or no longer selected: no one is told', + '[channel] conversation-gone has ended, and no channel its notice said opened is decided open or tuned out now (#fourteen (c-14)): no one is told', ]) assert.ok(errors.includes(line), line); assert.equal(state.deferredMessages.length, 0); }); @@ -933,19 +958,41 @@ describe('notices about an agent\'s own action', () => { decide('c-11', 'open'); decide('c-12', 'open'); decide('c-12', 'closed', 'agent-tool'); + // A failed reply's channel, selected for opening still, and one closed + // since. + decide('c-17', 'open', 'server-bootstrap'); + decide('c-18', 'open', 'server-bootstrap'); + decide('c-18', 'closed', 'agent-tool'); + // Opened, and tuned out since, which the log records with its params. + decide('c-19', 'open'); + internals(framework).channelRegistry.enterTuneOut('srv', 'c-19', + { epochId: 'e-19', cadenceSeconds: 60, backlogCap: 10, maxWakes: 3, startedAtSequence: 0 }, 'agent-tool'); + // One channel id on two servers, each with its own decision. + decide('c-20', 'open'); + decide('c-20', 'closed', 'agent-tool', framework, 'other'); await framework.stop(); const storePath = join(tempDir, 'test.chronicle'); - const notice = (id: string, channelId: string, label: string) => ({ + const notice = (id: string, channelId: string, label: string, serverId = 'srv') => ({ id, seq: Number(id.slice(2)), participant: 'user', forAgent: 'conversation-gone', content: [{ type: 'text', text: `[channels] Now open: ${label} (${channelId}): your own message into the closed channel opened it.` }], - metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId, label }] } }, + metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', serverId, cause: 'opened-by-reply', channels: [{ channelId, label }] } }, + }); + const failed = (id: string, channelId: string, label: string) => ({ + id, seq: Number(id.slice(2)), participant: 'user', forAgent: 'conversation-gone', + content: [{ type: 'text', text: `[channels] Not open: ${label} (${channelId}). Your reply there went out, but opening the channel failed.` }], + metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'reply-open-failed', serverId: 'srv', channel: { channelId, label }, selectedForOpening: 'server-bootstrap' } }, }); // A resume that didn't finish its flush leaves the flag cleared and the // queue behind, which a serving boot lands before MCPL is brought up. mkdirSync(join(storePath, 'recovery'), { recursive: true }); writeFileSync(join(storePath, 'recovery', 'host-mode.json'), JSON.stringify({ version: 1, quiesced: false })); writeFileSync(join(storePath, 'recovery', 'deferred-writes.json'), JSON.stringify({ - version: 1, pending: [notice('w-11', 'c-11', '#eleven'), notice('w-12', 'c-12', '#twelve')], + version: 1, pending: [ + notice('w-11', 'c-11', '#eleven'), notice('w-12', 'c-12', '#twelve'), + failed('w-17', 'c-17', '#seventeen'), failed('w-18', 'c-18', '#eighteen'), + notice('w-19', 'c-19', '#nineteen'), + notice('w-20', 'c-20', '#twenty'), notice('w-21', 'c-20', '#twenty', 'other'), + ], })); const errors: string[] = []; t.mock.method(console, 'error', (...args: unknown[]) => { errors.push(args.map(String).join(' ')); }); @@ -963,11 +1010,18 @@ describe('notices about an agent\'s own action', () => { assert.equal(internals(framework).channelRegistry, null, 'the boot landed them with no registry'); const told = framework.getAgent('primary')!.getContextManager().getAllMessages() - .map((m) => JSON.stringify(m.content)) - .filter((c) => c.includes('conversation-gone')); - assert.equal(told.length, 1); - assert.match(told[0]!, /Now open: #eleven \(c-11\): a message from conversation-gone into the closed channel opened it; conversation-gone has ended\./); - assert.ok(errors.includes('[channel] conversation-gone has ended, and what its notice said opened is closed now (#twelve (c-12)): no one is told')); + .filter((m) => JSON.stringify(m.content).includes('conversation-gone')); + assert.deepEqual(told.map((m) => (m.metadata as { deferredWriteId?: unknown }).deferredWriteId), ['w-11', 'w-17', 'w-19', 'w-20']); + const texts = told.map((m) => (m.content[0] as { text: string }).text); + assert.match(texts[0]!, /^\[channels\] Now open: #eleven \(c-11\): a message from conversation-gone into the closed channel opened it; conversation-gone has ended\./); + assert.match(texts[1]!, /^\[channels\] Not open: #seventeen \(c-17\)\. A reply from conversation-gone went out there, but opening the channel failed; conversation-gone has ended\. It's selected for opening again/); + assert.match(texts[2]!, /^\[channels\] Now open: #nineteen \(c-19\): a message from conversation-gone/); + assert.match(texts[3]!, /^\[channels\] Now open: #twenty \(c-20\): a message from conversation-gone/); + for (const line of [ + '[channel] conversation-gone has ended, and no channel its notice said opened is decided open or tuned out now (#twelve (c-12)): no one is told', + '[channel] conversation-gone has ended, and #eighteen (c-18), which its notice said was not open and selected for opening, is open now or no longer selected: no one is told', + '[channel] conversation-gone has ended, and no channel its notice said opened is decided open or tuned out now (#twenty (c-20)): no one is told', + ]) assert.ok(errors.includes(line), line); }); it('passes a restored failure notice on as the same queue entry while the primary is mid-turn: it waits under its own id', async () => { @@ -1374,7 +1428,7 @@ describe('notices about an agent\'s own action', () => { { what: 'a channel notice the run could act on', content: [{ type: 'text', text: '[channels] Now open: #seven (c-7): your own message into the closed channel opened it.' }], - metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-delivery', channels: [{ channelId: 'c-7', label: '#seven' }] } }, + metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', serverId: 'srv', cause: 'opened-by-delivery', channels: [{ channelId: 'c-7', label: '#seven' }] } }, inRun: 'Now open: #seven', toPrimary: "a message from worker into the closed channel opened it; worker has ended", opened: 'c-7', @@ -1460,7 +1514,7 @@ describe('notices about an agent\'s own action', () => { { what: 'a channel notice the run could act on', content: [{ type: 'text' as const, text: '[channels] Now open: #eight (c-8): your own message into the closed channel opened it.' }], - metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', cause: 'opened-by-reply', channels: [{ channelId: 'c-8', label: '#eight' }] } }, + metadata: { system: true, kind: 'channel-notice', passOn: { notice: 'channel-opened', serverId: 'srv', cause: 'opened-by-reply', channels: [{ channelId: 'c-8', label: '#eight' }] } }, toPrimary: "a message from worker into the closed channel opened it; worker has ended", opened: 'c-8', }, diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 26fc8fa3d..05e6f86e2 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -456,6 +456,7 @@ describe('Trunk channel routing (item-3 redux)', () => { const notice = internals(framework) as unknown as { recordChannelAutoOpenNotice( agentName: string | undefined, + serverId: string, channels: Array<{ channelId: string; label?: string }>, cause: 'subscription-policy' | 'opened-by-delivery' | 'opened-by-reply', ): void; @@ -468,7 +469,7 @@ describe('Trunk channel routing (item-3 redux)', () => { // Each form an agent can read: a delivery or a send opens one channel, and // only the subscription policy opens several at once. - notice.recordChannelAutoOpenNotice('scout', [{ channelId: 'slack:C1', label: '#general (acme)' }], 'opened-by-delivery'); + notice.recordChannelAutoOpenNotice('scout', 'slack', [{ channelId: 'slack:C1', label: '#general (acme)' }], 'opened-by-delivery'); const one = lastText(); assert.match( one, @@ -476,7 +477,7 @@ describe('Trunk channel routing (item-3 redux)', () => { ); assert.doesNotMatch(one, /will reach you/); - notice.recordChannelAutoOpenNotice('scout', [{ channelId: 'slack:C1' }, { channelId: 'slack:C2' }], 'subscription-policy'); + notice.recordChannelAutoOpenNotice('scout', 'slack', [{ channelId: 'slack:C1' }, { channelId: 'slack:C2' }], 'subscription-policy'); const two = lastText(); assert.match( two, From 3af57e1ccca19945d3a762bf67c1bc57e128479f Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 20 Oct 2026 04:04:25 -0400 Subject: [PATCH 11/12] fix(channels): leave a failed notice out once its channel is tuned out, and read only a server's own entry - A failed notice is passed on only while its channel is still decided open and its transport isn't open. A tune-out since is a newer decision, made by an agent that knows the channel, and channel_open refuses a tuned-out channel, so the notice's reason and its channel_open advice would misstate it. The opened notice still keeps a tuned-out channel, which a tune-out leaves open. - isChannelOpen, given a server, reads that server's entry only: the registry's key joins the two ids with a colon, so another server's entry can sit under it, as streamingServerAt already guards. - The docs say what the check reads: an opened channel's decision whatever its transport is doing, and a failed one's decision and transport. Tests: route-failure-targeting (a failed notice tuned out since is left out, logged; a delivery-forced open through the framework's own callback keeps the registry's server; the unactionable-notice test records its channel's decision, so only the missing inputs keep it back); channel-lifecycle-order (the send tool's opened and failed notices name the send's server, which isn't its tool prefix); channel-lifecycle-provenance (isChannelOpen with a server reads only that server's entry). Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 32 +++++++++++++---------- src/mcpl/channel-registry.ts | 9 ++++--- test/channel-lifecycle-order.test.ts | 24 ++++++++++++----- test/channel-lifecycle-provenance.test.ts | 10 +++++++ test/route-failure-targeting.test.ts | 30 ++++++++++++++------- 5 files changed, 73 insertions(+), 32 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 13e76bb5e..8c33ea9c8 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -21892,12 +21892,16 @@ export class AgentFramework { * They speak of the open or the failure as it was, as the notice did, and * only of what still holds by each channel's durable decision on its own * server: the registry's (getDesiredState), or before there is one, as at - * a serving boot's flush, the lifecycle log's (desiredStateInLog). That's - * the decision, not the transport at that moment: it's what channel_close + * a serving boot's flush, the lifecycle log's (desiredStateInLog). An + * opened channel is kept while it's decided open or tuned out, whatever its + * transport is doing at that moment: the decision is what channel_close * changes and what the host carries out once the transport is back, and at - * a serving boot no transport is up yet. An opened channel no longer - * decided open or tuned out is left out, and so is a failed one no longer - * selected for opening, or open since. With nothing left, no one is told, + * a serving boot no transport is up yet. A failed one is kept only while + * it's still decided open and its transport isn't open, since "Not open" + * would misstate an open one. One tuned out since is left out too: the + * tune-out is a newer decision, by an agent that knows the channel, and + * the notice's reason and its channel_open advice, which a tuned-out + * channel refuses, would misstate it. With nothing left, no one is told, * logged here. */ private channelNoticeForPrimary( @@ -21906,15 +21910,15 @@ export class AgentFramework { ): { content: ContentBlock[]; metadata: MessageMetadata } | null { const registry = this.channelRegistry; const { serverId } = passOn; - // A tune-out leaves a channel open, its traffic held, and keeps a channel - // not open yet selected for opening (reconcile opens it). - const decidedOpen = (channelId: string) => { - const desired = registry ? registry.getDesiredState(serverId, channelId) : desiredStateInLog(this.store, serverId, channelId); - return desired === 'open' || desired === 'tuned-out'; - }; + const desiredState = (channelId: string) => + (registry ? registry.getDesiredState(serverId, channelId) : desiredStateInLog(this.store, serverId, channelId)); let notice: ChannelNotice | undefined; if (passOn.notice === 'channel-opened') { - const channels = passOn.channels.filter((c) => decidedOpen(c.channelId)); + // A tune-out leaves a channel open, its traffic held. + const channels = passOn.channels.filter((c) => { + const desired = desiredState(c.channelId); + return desired === 'open' || desired === 'tuned-out'; + }); if (channels.length === 0) { console.error(`[channel] ${speaker} has ended, and no channel its notice said opened is decided open or tuned out now (${passOn.channels.map(shownChannel).join(', ')}): no one is told`); return null; @@ -21922,8 +21926,8 @@ export class AgentFramework { [notice] = this.channelOpenedNotices(speaker, serverId, channels, passOn.cause); } else { const { channelId } = passOn.channel; - if (!decidedOpen(channelId) || registry?.isChannelOpen(channelId, serverId)) { - console.error(`[channel] ${speaker} has ended, and ${shownChannel(passOn.channel)}, which its notice said was not open and selected for opening, is open now or no longer selected: no one is told`); + if (desiredState(channelId) !== 'open' || registry?.isChannelOpen(channelId, serverId)) { + console.error(`[channel] ${speaker} has ended, and ${shownChannel(passOn.channel)}, which its notice said was not open and selected for opening, is open now or no longer decided open: no one is told`); return null; } [notice] = this.replyOpenFailedNotices(speaker, serverId, passOn.channel, passOn.selectedForOpening); diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 970b47ab4..cdf044ca8 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -2053,10 +2053,13 @@ export class ChannelRegistry { /** Whether `channelId`'s transport is open: on `serverId` where the caller * knows the channel's server, and otherwise on the first server found to - * register that id (findChannelEntry). */ + * register that id (findChannelEntry). The map's key joins the two ids + * with a colon, so another server's entry can sit under a key: it has to + * be this server's (as in streamingServerAt). */ isChannelOpen(channelId: string, serverId?: string): boolean { - const entry = serverId ? this.channels.get(`${serverId}:${channelId}`) : this.findChannelEntry(channelId); - return entry?.open === true; + if (serverId === undefined) return this.findChannelEntry(channelId)?.open === true; + const entry = this.channels.get(`${serverId}:${channelId}`); + return entry?.serverId === serverId && entry.open === true; } getDesiredState(serverId: string, channelId: string): DesiredChannelState | undefined { diff --git a/test/channel-lifecycle-order.test.ts b/test/channel-lifecycle-order.test.ts index 57e02d6d1..6072184c9 100644 --- a/test/channel-lifecycle-order.test.ts +++ b/test/channel-lifecycle-order.test.ts @@ -2260,12 +2260,15 @@ test("channel_open and channel_close on a channel already so say what failed whe async function replyOpenFailedNotices(opts: { opening: boolean; directs: boolean; agentName?: string; live?: string[]; homes?: Array<[string, string]>; denied?: string[]; selectedForOpening?: 'unrecorded' | 'server-bootstrap' | 'subscription-policy'; primary?: string; dropsDuring?: true; + opened?: true; }) { const agentName = opts.agentName ?? 'agent'; const told: Array<{ text: string; metadata: unknown; forAgent?: string }> = []; const logged: string[] = []; + // The server's id isn't its tool prefix, so the notice's inputs can't take + // one for the other. const server = { - id: 'srv', + id: 'chat-server', isConnected: true, async sendToolsList() { return { tools: [{ name: 'send_message', inputSchema: { type: 'object' } }] }; }, async sendToolsCall() { return { content: [{ type: 'text', text: 'sent' }] }; }, @@ -2273,8 +2276,8 @@ async function replyOpenFailedNotices(opts: { const framework = Object.create(AgentFramework.prototype) as any; framework.mcplTools = []; framework.mcplToolFeatureSets = new Map(); - framework.mcplServerConfigs = new Map([['srv', { id: 'srv', toolPrefix: 'srv' }]]); - framework.mcplServerRegistry = { getAllServers: () => [server], getServer: (id: string) => (id === 'srv' ? server : null) }; + framework.mcplServerConfigs = new Map([['chat-server', { id: 'chat-server', toolPrefix: 'srv' }]]); + framework.mcplServerRegistry = { getAllServers: () => [server], getServer: (id: string) => (id === 'chat-server' ? server : null) }; framework.checkpointManager = null; framework.turnRouting = new Map(); framework.agents = new Map((opts.live ?? [agentName]).map((name) => [name, { canUseTool: (tool: string) => !(opts.denied ?? []).includes(tool) }])); @@ -2292,11 +2295,13 @@ async function replyOpenFailedNotices(opts: { framework.channelRegistry = { openIfClosedForSend: async () => { opened = true; + if (opts.opened) return { status: 'opened', channelId: 'x', label: '#x' }; return { status: 'open-failed', channelId: 'x', label: '#x', opening: opts.opening, ...(opts.selectedForOpening ? { selectedForOpening: opts.selectedForOpening } : {}) }; }, directsLifecycle: () => opts.directs && !(opts.dropsDuring && opened), }; + const handled = (l: string) => l.includes('open-on-send failed') || l.includes('opened (send implies engagement)'); const error = console.error; console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; try { @@ -2305,15 +2310,15 @@ async function replyOpenFailedNotices(opts: { framework.pushEvent = (event: { type: string; result: { success?: boolean; error?: string } }) => { if (event.type === 'tool-result') resolve(event.result); }; - framework.dispatchMcplToolCall(agentName, { id: 'c1', name: 'srv--send_message', input: { channelId: 'x' } }, 'srv', 'srv'); + framework.dispatchMcplToolCall(agentName, { id: 'c1', name: 'srv--send_message', input: { channelId: 'x' } }, 'chat-server', 'srv'); }); assert.equal(sent.success, true, `the send went out: ${sent.error ?? ''}`); // The open's answer is handled once its console line is written. - for (let i = 0; i < 50 && !logged.some((l) => l.includes('open-on-send failed')); i++) await tick(); + for (let i = 0; i < 50 && !logged.some(handled); i++) await tick(); } finally { console.error = error; } - assert.ok(logged.some((l) => l.includes('open-on-send failed')), 'the failed open was handled'); + assert.ok(logged.some(handled), 'the open was handled'); return { told, logged }; } @@ -2353,6 +2358,13 @@ test("the reply notice names channel_open and channel_close only where the agent const forkHome = await replyOpenFailedNotices({ opening: true, directs: true, homes: [['agent', 'x']], selectedForOpening: 'unrecorded' }); assert.deepEqual(forkHome.told.map((t) => t.text), [facts + stays + ', and channel_close withdraws that.'], 'a fork may close its home channel, though not open it'); + assert.equal((forkHome.told[0]!.metadata as { passOn?: { serverId?: unknown } }).passOn?.serverId, 'chat-server', + "its inputs name the send's server, not its tool prefix"); + // A send that opens its channel tells the agent, and its inputs name the + // send's server too. + const sendOpened = await replyOpenFailedNotices({ opening: true, directs: true, homes: [['agent', 'x']], opened: true }); + assert.match(sendOpened.told[0]?.text ?? '', /^\[channels\] Now open: #x \(x\): your own message into the closed channel opened it\./); + assert.equal((sendOpened.told[0]!.metadata as { passOn?: { serverId?: unknown } }).passOn?.serverId, 'chat-server'); const forkElsewhere = await replyOpenFailedNotices({ opening: true, directs: true, homes: [['agent', 'y']], selectedForOpening: 'unrecorded' }); assert.deepEqual(forkElsewhere.told.map((t) => t.text), [facts + stays + '.'], "a fork's channel_close is refused for a channel that isn't its home"); diff --git a/test/channel-lifecycle-provenance.test.ts b/test/channel-lifecycle-provenance.test.ts index cadb086f5..ecb46dd52 100644 --- a/test/channel-lifecycle-provenance.test.ts +++ b/test/channel-lifecycle-provenance.test.ts @@ -67,6 +67,16 @@ test('initiallyOpen at first sight registers open with server-bootstrap provenan assert.equal(r.getDesiredState('eidoverse', 'world:commons'), 'open'); }); +test("isChannelOpen, given a server, reads only that server's channel, though the registry's key joins the two ids with a colon", () => { + const { registry } = makeRegistry(); + // Server "a" registers channel "b:c": its key is "a:b:c", which is also + // what server "a:b" and channel "c" would make. + (registry as unknown as { channels: Map }).channels + .set('a:b:c', { serverId: 'a', descriptor: { id: 'b:c', type: 'a', label: '#c' }, open: true }); + assert.equal(registry.isChannelOpen('b:c', 'a'), true, "a's own channel"); + assert.equal(registry.isChannelOpen('c', 'a:b'), false, "another server's entry under the same key"); +}); + test("desiredStateInLog reads a server's channel's last decision from a store's lifecycle log, by the rule a registry replayed from it reads", () => { const dir = mkdtempSync(join(tmpdir(), 'desired-state-in-log-')); const store = JsStore.openOrCreate({ path: join(dir, 'store') }); diff --git a/test/route-failure-targeting.test.ts b/test/route-failure-targeting.test.ts index a3e737c15..5b097e1ce 100644 --- a/test/route-failure-targeting.test.ts +++ b/test/route-failure-targeting.test.ts @@ -657,6 +657,17 @@ describe('notices about an agent\'s own action', () => { assert.equal(forkCm.mock.callCount(), 1); }); + it("keeps the server the registry names for an open a delivery forced, as the framework's callback writes the notice's inputs", async () => { + const fork = await spawnFork(); + const registry = internals(framework).channelRegistry as unknown as { + onChannelAutoOpened(info: { conversationId?: string; serverId: string; source: string; channels: Array<{ channelId: string; label?: string }> }): void; + }; + registry.onChannelAutoOpened({ conversationId: fork.name, serverId: 'other-server', source: 'opened-by-delivery', channels: [{ channelId: 'missing-channel', label: '#home' }] }); + const notice = fork.getContextManager().getAllMessages() + .find((m) => (m.metadata as { kind?: unknown } | undefined)?.kind === 'channel-notice'); + assert.equal((notice?.metadata as { passOn?: { serverId?: unknown } } | undefined)?.passOn?.serverId, 'other-server'); + }); + it("tells a fork whose send opened a channel away from its home that it opened, without channel_close, which it can't call there, and tells the primary by name, which can", async (t) => { const fork = await spawnFork(); const state = internals(framework); @@ -731,6 +742,8 @@ describe('notices about an agent\'s own action', () => { it("passes on no channel notice its reader couldn't act on: the primary was told then, once", async () => { const fork = await spawnFork(); const state = internals(framework); + // Decided open, so only the notice's missing inputs keep it back. + decide('c-1', 'open'); state.recordChannelAutoOpenNotice(fork.name, 'srv', [{ channelId: 'c-1', label: '#one' }], 'opened-by-reply'); reap(fork.name); @@ -874,7 +887,7 @@ describe('notices about an agent\'s own action', () => { assert.equal(state.deferredMessages.length, 0); }); - it('passes on, of a restored channel notice, only what still holds: the channels still open, and a failed one still waiting to open', async (t) => { + it('passes on, of a restored channel notice, only what still holds: the channels still decided open or tuned out, and a failed one still decided open and not open', async (t) => { const state = internals(framework); const errors: string[] = []; t.mock.method(console, 'error', (...args: unknown[]) => { errors.push(args.map(String).join(' ')); }); @@ -901,8 +914,8 @@ describe('notices about an agent\'s own action', () => { // server's decision. decide('c-14', 'open'); decide('c-14', 'closed', 'agent-tool', framework, 'other'); - // Selected for opening, and tuned out since, not open yet: a tune-out - // keeps it selected for opening. + // Selected for opening, and tuned out since: a newer decision, by the + // agent that tuned it out, so its failed notice is left out. decide('c-15', 'open', 'server-bootstrap'); state.channelRegistry.enterTuneOut('srv', 'c-15', { epochId: 'e-15', cadenceSeconds: 60, backlogCap: 10, maxWakes: 3, startedAtSequence: 0 }, 'agent-tool'); @@ -939,16 +952,15 @@ describe('notices about an agent\'s own action', () => { "If you don't want it, use channel_close; your choice outranks policy.", '[channels] Now open: #fourteen (c-14): a message from conversation-gone into the closed channel opened it; conversation-gone has ended. ' + "If you don't want it, use channel_close; your choice outranks policy.", - "[channels] Not open: #fifteen (c-15). A reply from conversation-gone went out there, but opening the channel failed; conversation-gone has ended. " + - "It's selected for opening again, because its server lists it as open by default: the host tries again, and channel_close withdraws that.", "[channels] Not open: #twenty-two (c-22). A reply from conversation-gone went out there, but opening the channel failed; conversation-gone has ended. " + "It's selected for opening again, because its server lists it as open by default: the host tries again, and channel_close withdraws that.", ]); - assert.deepEqual(told.map((m) => (m.metadata as { deferredWriteId?: unknown }).deferredWriteId), ['w-7', 'w-13', 'w-14', 'w-15', 'w-22']); + assert.deepEqual(told.map((m) => (m.metadata as { deferredWriteId?: unknown }).deferredWriteId), ['w-7', 'w-13', 'w-14', 'w-22']); for (const line of [ '[channel] conversation-gone has ended, and no channel its notice said opened is decided open or tuned out now (#eight (c-8)): no one is told', - '[channel] conversation-gone has ended, and #nine (c-9), which its notice said was not open and selected for opening, is open now or no longer selected: no one is told', - '[channel] conversation-gone has ended, and #ten (c-10), which its notice said was not open and selected for opening, is open now or no longer selected: no one is told', + '[channel] conversation-gone has ended, and #nine (c-9), which its notice said was not open and selected for opening, is open now or no longer decided open: no one is told', + '[channel] conversation-gone has ended, and #ten (c-10), which its notice said was not open and selected for opening, is open now or no longer decided open: no one is told', + '[channel] conversation-gone has ended, and #fifteen (c-15), which its notice said was not open and selected for opening, is open now or no longer decided open: no one is told', '[channel] conversation-gone has ended, and no channel its notice said opened is decided open or tuned out now (#fourteen (c-14)): no one is told', ]) assert.ok(errors.includes(line), line); assert.equal(state.deferredMessages.length, 0); @@ -1019,7 +1031,7 @@ describe('notices about an agent\'s own action', () => { assert.match(texts[3]!, /^\[channels\] Now open: #twenty \(c-20\): a message from conversation-gone/); for (const line of [ '[channel] conversation-gone has ended, and no channel its notice said opened is decided open or tuned out now (#twelve (c-12)): no one is told', - '[channel] conversation-gone has ended, and #eighteen (c-18), which its notice said was not open and selected for opening, is open now or no longer selected: no one is told', + '[channel] conversation-gone has ended, and #eighteen (c-18), which its notice said was not open and selected for opening, is open now or no longer decided open: no one is told', '[channel] conversation-gone has ended, and no channel its notice said opened is decided open or tuned out now (#twenty (c-20)): no one is told', ]) assert.ok(errors.includes(line), line); }); From 7bd9b7db07a8c41df9fcca434929cf586ca4488a Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 20 Oct 2026 04:12:23 -0400 Subject: [PATCH 12/12] docs(channels): a passed-on failed notice gives its selection's reason as the failure had it channelNoticeForPrimary's doc now says that a failed notice's reason for its selection is the failure's, which an agent's channel_open since replaces in the record and not in the words. The notice's advice still holds then; only its reason is the earlier one. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/framework.ts b/src/framework.ts index 8c33ea9c8..129931337 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -21889,7 +21889,9 @@ export class AgentFramework { * told it could act on and never read: its builder, called again now that * `speaker` has ended, gives the words it gives an agent that has ended, * judged for the primary, or nothing, logged, with no primary to tell. - * They speak of the open or the failure as it was, as the notice did, and + * They speak of the open or the failure as it was, as the notice did, a + * failed one's reason for its selection included, which an agent's + * channel_open since replaces in the record and not in the words, and * only of what still holds by each channel's durable decision on its own * server: the registry's (getDesiredState), or before there is one, as at * a serving boot's flush, the lifecycle log's (desiredStateInLog). An