Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions changelog.d/failed-speech-open-puts-back.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
- When an open that speech or a reply made fails, the channel's decision goes
back to what it was. So an agent told the channel is closed and the open
failed isn't later put into it by that open's decision. Before, the
decision to open stayed: the agent's next speech there, or reconciliation
when the server announced the channel again, opened it without the notice
that says it's open and how to close it. A decision made while the open
was out stands. Since the failed request may have reached the server, as
when its answer is lost to a timeout, the host also closes the channel on
the server. Where the decision put back means nobody had decided, the
channel then follows its server's latest announcement and the
subscription policy, which a re-announcement during the open couldn't
apply: if they select it for opening, the host tries the open again, and
the failure says why. Where the host can't record putting the decision
back, the decision stays, no close is sent, and the failure says the
channel stays selected for opening. `channel_open`'s own decision still
outlives a failed open, as its failure says.
- A reply into a closed channel whose open fails now tells the agent that
the reply went out and opening the channel failed, and that
`channel_open` tries again, where the agent can call it: a conversation
fork can't, and an agent's `allowedTools` can leave it out. The notice
comes where the host directs the server's lifecycle
(`channels.lifecycle`), and only when no decision about the channel, such
as the agent closing it, was made while the open was out. A reply whose
open only reconfirms a channel the agent already has open gets none.
Where the channel is selected for opening after the failure, the notice
says so and why, as speech's failure does, and that `channel_close`
withdraws that, where the agent can call it: where the host couldn't
record putting the channel's decision back, in `channel_open`'s own
failure's words, a later delivery or reconnect tries again; where the
server's latest announcement or the policy selected it again, the host
tries again itself. Where the channel is selected for opening and the
replying agent has ended, the primary is told by name instead, if one is
registered; where the replying agent can't close the channel, as a fork
away from its home can't, the primary is told by name too, if it can.
Before, the failure reached only the logs.
- On a server without `channels.lifecycle`, an open by speech or a reply,
which fails there before any request is sent, no longer records a decision.
- `channel_open` on a channel that's already open, and `channel_close` on one
that's already closed, now record the agent's decision, and so does
`channel_decline` on a channel closed by default. So a module's
housekeeping close is refused for a channel the agent opened itself, even
where speech, a reply, the server or a policy had opened it first. And a
channel the agent closed or declined isn't opened later because its server
registers it as initially open, or a policy admits it.
- `channels/close` now has the deadline `channels/open` has when
`requestTimeoutMs` is 0. A close the server never answers no longer holds,
for good, the deliveries that wait for the channel's lifecycle work.
144 changes: 134 additions & 10 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from './mcpl/channel-registry.js';
import { ChannelRegistry, connectorText, 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';
Expand Down Expand Up @@ -15045,6 +15045,114 @@ export class AgentFramework {
}
}

/**
* 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
* asks for failed. Without it the agent would wait for a conversation it
* won't see. It goes to the agent whose reply it was, or, when no agent
* is named, where addMessage delivers an unrouted message (deliveryAgent:
* the primary, else the first registered agent), as the opened notice
* does, and its advice is judged for that agent. Where the channel stays
* closed, an agent that has ended is told nothing, and nor is anyone in
* its place: nothing follows from the failure. Where it's selected for
* 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
* (dispatchChannelToolCall), and an agent's allowedTools can leave it out.
* 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, in channel_open's own
* failure's words, a later delivery or reconnect tries again; where the
* server's latest announcement or the subscription policy lifted the
* 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.
*/
private recordReplyOpenFailedNotice(
agentName: string | undefined,
channel: { channelId: string; label?: string },
selectedForOpening?: SelectedForOpening,
): void {
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;
}
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;
}
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 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 }],
(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);
}
// 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;
if (
!ended && selectedForOpening !== undefined && who !== undefined && !canClose &&
primary !== undefined && primary !== who && this.agents.has(primary) &&
(!primaryHome || primaryHome === channel.channelId) && this.canAgentUseTool(primary, 'channel_close')
) {
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);
}
}
}

/**
* A `!` continuation the turn asked for (`>>#x !`, `>>>#x !`): start another
* turn now instead of pausing until the next external event. It reads the
Expand Down Expand Up @@ -25688,12 +25796,13 @@ export class AgentFramework {
}
}

// INVARIANT: engaging a channel opens it. A successful explicit send
// into a channel the registry knows as closed drives the same open
// flow as channel_open (durable desired state, the server's
// channels/open), so there is no such thing as posting into a channel
// that stays half-alive (no typing, no reactions, no inbound
// forwarding).
// Engaging a channel opens it. A successful explicit send into a
// channel the registry knows as closed drives the open flow
// channel_open uses (the server's channels/open), so posting there
// doesn't leave the channel half-alive (no typing, no reactions, no
// inbound forwarding). If that open fails, the channel stays as it
// was, and where the host directs the server's lifecycle the agent
// is told, with how to try again.
// Fire-and-forget: the send already succeeded; the open must not
// delay or fail the tool result.
if (
Expand All @@ -25705,9 +25814,14 @@ export class AgentFramework {
extractChannelIdFromToolResult(result.content) ??
(typeof args.channelId === 'string' ? (args.channelId as string) : undefined);
if (target) {
// Whether the host directs the server's lifecycle is read before
// the open, not after its failure: a transport that drops while the
// open is out clears the grant before the failure arrives, and the
// notice below is for that failure too.
const directs = this.channelRegistry.directsLifecycle(serverId);
this.channelRegistry
.openIfClosedForSend(target, serverId)
.then(({ status, channelId, label }) => {
.then(({ status, channelId, label, opening, selectedForOpening }) => {
// Turn-scoped engagement record (mid-turn re-pin, 2026-07-31
// n=7): a human follow-up in a channel the agent explicitly
// sent into this turn counts as conversationally addressed.
Expand All @@ -25732,10 +25846,20 @@ export class AgentFramework {
'opened-by-reply',
);
} else if (status === 'open-failed') {
// A reply's open that would have opened a channel not
// selected open, and failed, is told to the agent, with how
// to try again, where the host directs the server's
// lifecycle. The opened notice would have told it of the
// open. A failed reconfirm of a channel already selected
// open changes nothing the agent was told. Without
// channels.lifecycle the server opens and closes its own
// channels, and channel_open can't open one either.
const tell = opening === true && directs;
console.error(
`[channel] ${agentName}: ${toolName} delivered to ${target} but the open-on-send failed; ` +
'a later registration or delivery tries the open again',
`[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);
}
})
.catch((err) => console.error('[channel] open-on-send error:', err));
Expand Down
Loading
Loading