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
23 changes: 23 additions & 0 deletions changelog.d/551-ephemeral-no-global-fallback.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
- An ephemeral run (a subagent) no longer posts its plain prose to whichever
channel last received a message (#551). That channel, the process-global
fallback, was the locus of any `locus` or `hybrid` run with no channel of its
own, so delegated work, possibly drawn from a DM, could land in an unrelated,
possibly public channel. No ephemeral run takes the global fallback now,
whatever its prose routing:
- a `locus` or `hybrid` run's plain prose with no channel of its own is
returned to its caller as before and published nowhere; a sleep
announcement it makes is published nowhere too. Neither counts as a failed
delivery, so neither writes a `[send-undeliverable]` notice, an
`mcpl:speech-route-failed` trace or a log line;
- `channel_publish` without a `channelId` is refused, with words saying to
name the channel;
- its channel context has no `incoming` message, and no default outgoing
channel unless it opened one itself;
- a refusal of its inference adds no reaction anywhere, and its
`failures.log` record names no `locus`.

A channel the run opens itself, and a destination it names in its prose
(`hybrid`'s `>>>`, `explicit`'s `>>`), still route as before. Residents and
conversation forks keep the fallback. `channel_publish`'s description now
says where an omitted `channelId` goes: the conversation's bound channel,
else the most recent incoming one, and nowhere for a subagent.
41 changes: 28 additions & 13 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2160,8 +2160,10 @@ export class AgentFramework {
private ephemeralRuns: Map<string, EphemeralRun> = new Map();
/** Ephemeral namespaces/names are single-generation for this framework
* lifetime. Reusing one while an old stream drains would let name-keyed
* liveness/settlement state cross generations. */
private usedEphemeralAgentNames: Set<string> = new Set();
* liveness/settlement state cross generations. Each name records what
* reserved it: an ephemeral run (createEphemeralAgent), which takes no
* global fallback even after its release, or a conversation fork. */
private usedEphemeralAgentNames: Map<string, 'run' | 'conversation'> = new Map();
/** One-shot generation tickets minted by createEphemeralAgent. */
private ephemeralCandidates: WeakMap<Agent, ContextManager> = new WeakMap();
/** Warn only once per Agent instance after utilities are registered. */
Expand Down Expand Up @@ -7684,7 +7686,7 @@ export class AgentFramework {
if (this.usedEphemeralAgentNames.has(config.name) || this.agents.has(config.name)) {
throw new Error(`Ephemeral agent name \"${config.name}\" is already registered or has been used in this framework`);
}
this.usedEphemeralAgentNames.add(config.name);
this.usedEphemeralAgentNames.set(config.name, 'run');
try {
const namespace = `subagent/${config.name}`;

Expand Down Expand Up @@ -8459,13 +8461,17 @@ export class AgentFramework {

/**
* Mark an inference refusal visibly: react on the message that holds the
* conversational locus (the most recent incoming channel message) with a
* category-specific emoji. Best-effort — failures are logged, never thrown,
* and non-Discord loci are silently skipped.
* refused agent's conversational locus (the most recent incoming channel
* message; an ephemeral run has none, so a subagent's refusal marks no
* message) with a category-specific emoji. Best-effort — failures are
* logged, never thrown, and non-Discord loci are silently skipped.
*/
private async reactToRefusal(agentName: string, category: string): Promise<void> {
try {
const incoming = this.channelRegistry?.buildChannelContext()?.incoming;
// The refused agent's own view of the latest inbound message: an
// ephemeral run has none (takesGlobalFallback), so a subagent's refusal
// marks no one else's channel.
const incoming = this.channelRegistry?.buildChannelContext(agentName)?.incoming;
if (!incoming) return;
// incoming.channelId is the MCPL composite id ("discord:<guild>:<channel>");
// the reaction tool wants the raw Discord channel (or thread) id — the
Expand Down Expand Up @@ -11601,7 +11607,7 @@ export class AgentFramework {
if (this.usedEphemeralAgentNames.has(name) || this.agents.has(name)) {
throw new Error(`Conversation agent name "${name}" is already registered or reserved`);
}
this.usedEphemeralAgentNames.add(name);
this.usedEphemeralAgentNames.set(name, 'conversation');
try {

const contextManager = await ContextManager.open({
Expand Down Expand Up @@ -13184,7 +13190,8 @@ export class AgentFramework {
// Ambient chatter must not outrank an addressed message just by being
// newest (2026-07-21 Cairn lounge misroute, turn-start variant).
// Non-channel wakes (heartbeats, module events, reactions — which never
// carry channelId) leave both undefined → global fallback.
// carry channelId) leave both undefined → global fallback, which an
// ephemeral run never takes (takesGlobalFallback).
// Track the winning REQUEST, not just its channel: channel, addressed
// and counterparty must come from the same message, or a batch of
// "ambient from A, then addressed from B" would report B's channel
Expand Down Expand Up @@ -15604,7 +15611,8 @@ export class AgentFramework {
// a previous turn's channel.
routing.triggerChannel = trigger?.channelId;
// FREEZE the turn's outbound locus now (turn-frozen routing): the home
// channel for forks, else the trigger channel, else the global default.
// channel for forks, else the trigger channel, else the global default,
// which an ephemeral run never takes (takesGlobalFallback).
// Mid-turn injections must not redirect the agent's plain prose
// (ambient chatter and reactions hijacked it before, 2026-07-21), and
// the eager snapshot protects heartbeat turns from a moving
Expand Down Expand Up @@ -17320,7 +17328,7 @@ export class AgentFramework {
// one channel must not land its postscript in another.
const locus = resolveTurnLocus();
console.error(
`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${locus ?? '(default)'}`,
`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${locus ?? '(none)'}`,
);
// Deliver sequentially (await each) so the segments land in order.
for (const seg of segments) {
Expand Down Expand Up @@ -21031,7 +21039,8 @@ export class AgentFramework {
kind: 'refusal',
category,
streak,
locus: this.channelRegistry?.buildChannelContext()?.incoming?.channelId ?? null,
// The refused agent's own view: a subagent's refusal names no channel.
locus: this.channelRegistry?.buildChannelContext(agentName)?.incoming?.channelId ?? null,
tokens: tokens ?? null,
});
if (streak >= 2) {
Expand Down Expand Up @@ -21492,11 +21501,17 @@ export class AgentFramework {
// a later start of the turn is told where its unprefixed speech lands
// unless a hybrid `>>>` destination takes it. Empty for a heartbeat /
// no-trigger turn until its locus is resolved, so that resolution
// takes the global fallback; the turn then keeps that channel.
// takes the global fallback (none for an ephemeral run); the turn then
// keeps that channel.
activeChannelResolver: (agentName) => {
const routing = this.turnRouting.get(agentName);
return routing?.locus ?? routing?.triggerChannel;
},
// An ephemeral run (a subagent) never takes the process-global
// fallback: its prose is its caller's result, and the latest inbound
// message's channel is nobody's choice for it (#551). By name, so a
// speech dispatch that comes after the run's release still counts.
takesGlobalFallback: (conversationId) => this.usedEphemeralAgentNames.get(conversationId) !== 'run',
eventGatePresent: this.eventGate !== null,
// A text-only turn whose speech couldn't be delivered must not vanish
// silently: record a `[discord-send-failed]` marker in chronicle so the
Expand Down
79 changes: 62 additions & 17 deletions src/mcpl/channel-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,11 +370,11 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
},
{
name: 'channel_publish',
description: 'Publish a message to a channel. If channelId is omitted, publishes to the most recent incoming channel.',
description: 'Publish a message to a channel. If channelId is omitted, publishes to the channel this conversation is bound to, if any, else to the most recent incoming channel. A subagent has neither, and must name one.',
inputSchema: {
type: 'object' as const,
properties: {
channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to the most recent incoming channel)' },
channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to the conversation\'s bound channel, else the most recent incoming channel; required of a subagent)' },
content: { type: 'string', description: 'Text content to publish' },
text: { type: 'string', description: 'Alias for content' },
},
Expand Down Expand Up @@ -591,8 +591,10 @@ interface ChannelRegistryOptions {
/**
* Called when a text-only turn's speech could NOT be delivered to its
* conversational locus — no locus, an unregistered channel, a missing
* server, or the server reporting `delivered: false`. The host wires this
* to drop a `[discord-send-failed]` marker into chronicle so the failure is
* server, or the server reporting `delivered: false`. Not called for an
* ephemeral run's speech with no locus, which is its caller's result
* (`takesGlobalFallback`). The host wires this to drop a
* `[discord-send-failed]` marker into chronicle so the failure is
* visible to the agent (and operator) rather than silently lost. Must not
* itself trigger inference (avoid wake loops).
*/
Expand Down Expand Up @@ -652,6 +654,18 @@ interface ChannelRegistryOptions {
* `defaultPublishChannel`.
*/
activeChannelResolver?: (agentName: string) => string | undefined;
/**
* Whether a conversation takes the process-global fallback, the channel of
* the most recent delivered inbound message: as where its plain speech lands
* when it has no home and no channel of its own this turn, as its channel
* context's `incoming` and default outgoing channel, and as
* `channel_publish`'s target when it names none. False for an ephemeral run
* (a subagent): its prose is its caller's result, so a channel that only the
* latest inbound message picked is never its destination, and its plain
* speech with no channel of its own is undelivered rather than a failed
* delivery. Absent, every conversation takes it.
*/
takesGlobalFallback?: (conversationId: string) => boolean;
/** Whether this framework has EventGate and can honor skip_reply self-wakes. */
eventGatePresent?: boolean;
}
Expand Down Expand Up @@ -696,6 +710,7 @@ export class ChannelRegistry {
}) => void;
private homeChannelResolver?: (agentName: string) => string | undefined;
private activeChannelResolver?: (agentName: string) => string | undefined;
private takesGlobalFallback?: (conversationId: string) => boolean;
private store?: JsStore;

/** Registered channels, keyed by `{serverId}:{channelId}`. */
Expand Down Expand Up @@ -872,6 +887,7 @@ export class ChannelRegistry {
this.onChannelAutoOpened = options?.onChannelAutoOpened;
this.homeChannelResolver = options?.homeChannelResolver;
this.activeChannelResolver = options?.activeChannelResolver;
this.takesGlobalFallback = options?.takesGlobalFallback;
this.store = options?.store;
this.initializeLifecycleStore();
this.initializeLabelHistoryStore();
Expand Down Expand Up @@ -1631,7 +1647,7 @@ export class ChannelRegistry {
});

case 'channel_publish':
return this.handleToolPublish(input as { channelId: string; content: string });
return this.handleToolPublish(input as { channelId: string; content: string }, origin);

case 'think':
return this.handleToolThink(input as { content?: string });
Expand Down Expand Up @@ -1666,9 +1682,15 @@ export class ChannelRegistry {
// (heartbeats). Without this, a fork
// was advertised the global locus but published somewhere else; and a trunk
// agent was told the wrong channel under concurrency.
const home = agentName ? this.homeChannelResolver?.(agentName) : undefined;
const active = agentName ? this.activeChannelResolver?.(agentName) : undefined;
const outgoing = home ?? active ?? this.defaultPublishChannel;
// Each lookup is keyed on whether a name was given, not its truthiness:
// '' is a name an ephemeral run can have, and resolveLocus reads it too.
const home = agentName !== undefined ? this.homeChannelResolver?.(agentName) : undefined;
const active = agentName !== undefined ? this.activeChannelResolver?.(agentName) : undefined;
// An ephemeral run takes no global fallback (takesGlobalFallback): neither
// the latest inbound message's channel as its outgoing one, nor that
// message as the one it is replying to.
const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
const outgoing = home ?? active ?? fallback;
Comment on lines +1692 to +1693

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Opened channel disappears from context

For the supported name: '' case, buildChannelContext drops even a channel the subagent opened. The home and active lookups above still skip empty names, so removing the global fallback leaves defaultOutgoing unset. Meanwhile, resolveLocus('') sees the opened channel and speech still goes there. Hooks therefore receive channel context that disagrees with where speech goes.

Use agentName !== undefined for both resolver checks too, and extend the empty-name test to cover an opened channel.

Knowledge Base Used: MCPL conversation and inference routing

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/channel-registry.ts
Line: 1692-1693

Comment:
**Opened channel disappears from context**

For the supported `name: ''` case, `buildChannelContext` drops even a channel the subagent opened. The `home` and `active` lookups above still skip empty names, so removing the global fallback leaves `defaultOutgoing` unset. Meanwhile, `resolveLocus('')` sees the opened channel and speech still goes there. Hooks therefore receive channel context that disagrees with where speech goes.

Use `agentName !== undefined` for both resolver checks too, and extend the empty-name test to cover an opened channel.

**Knowledge Base Used:** [MCPL conversation and inference routing](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/mcpl-conversation-and-inference.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in cba2011f: buildChannelContext's home and active lookups now key on whether a name was given, as its fallback already did, so a subagent named '' is told a channel it opened, where its speech goes. A new test pins it, and it fails at e6e4a1fd.

Written by Milo-2653.

— Lottie-2661


if (openChannels.length === 0 && !outgoing) {
return undefined;
Expand All @@ -1679,9 +1701,9 @@ export class ChannelRegistry {
// Incoming: the most-recent inbound message (what the agent is replying to).
// Left process-global — per-channel inbound tracking (the right messageId for
// a fork's own channel) is a separate concern from the outbound routing fix.
if (this.defaultPublishChannel && this.defaultPublishMessageId) {
if (fallback && this.defaultPublishMessageId) {
context.incoming = {
channelId: this.defaultPublishChannel,
channelId: fallback,
messageId: this.defaultPublishMessageId,
threadId: this.defaultPublishThreadId,
};
Expand Down Expand Up @@ -3512,7 +3534,12 @@ export class ChannelRegistry {

resolveLocus(conversationId: string): string | null {
const home = this.homeChannelResolver?.(conversationId);
return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null;
return home ?? this.activeChannelResolver?.(conversationId) ?? this.globalFallbackFor(conversationId);
}

/** The process-global fallback channel for this conversation, or null when it takes none (takesGlobalFallback). */
private globalFallbackFor(conversationId: string): string | null {
return this.takesGlobalFallback?.(conversationId) === false ? null : this.defaultPublishChannel;
}

/**
Expand Down Expand Up @@ -3716,7 +3743,9 @@ export class ChannelRegistry {
* Plain speech for one turn segment: publish `text` to the turn's frozen
* locus and report the PublishOutcome. A failure (or an uncertain outcome)
* is surfaced as a trace and through onRouteFailure, so the agent learns
* its words did not (or may not have) reached anyone.
* its words did not (or may not have) reached anyone. An ephemeral run's
* speech with no locus is the exception: it is its caller's result, so it
* returns as not delivered and surfaces nothing.
*/
async deliverSpeech(
conversationId: string,
Expand All @@ -3727,7 +3756,9 @@ export class ChannelRegistry {
* dispatch resolving live can read the NEXT turn's trigger state or a
* stale global default and land the reply in the wrong channel (item-3,
* PR #32, and the 2026-07-22 Sol DM misroute). Explicit `null` means
* "this turn is pinned to no locus": fail loudly rather than guess. */
* "this turn is pinned to no locus": fail loudly rather than guess,
* except for an ephemeral run, whose speech is then its caller's
* result. */
locusChannelId: string | null,
/** `heldOnFailure`: the caller holds a `failed` delivery's words as a
* draft with its own notice, so no failure marker is written for it.
Expand Down Expand Up @@ -3762,6 +3793,12 @@ export class ChannelRegistry {
return fail(null, reason, { status: 'failed', reason, at: Date.now() });
}
if (!locusChannelId) {
// An ephemeral run with no channel of its own: its plain speech is its
// caller's result (takesGlobalFallback). It returns as not delivered,
// with its own reason, and writes no line, trace or notice.
if (this.takesGlobalFallback?.(conversationId) === false) {
return { status: 'failed', reason: "an ephemeral run's plain speech with no channel of its own is its caller's result", at: Date.now() };
}
// The turn froze with no locus (no home, no triggering channel, no
// global inbound ever seen) — the agent was told its prose stays in
// the archive; honor that.
Expand Down Expand Up @@ -3807,7 +3844,10 @@ export class ChannelRegistry {
};
}

private async handleToolPublish(input: { channelId?: string; content?: string; text?: string }): Promise<ToolResult> {
private async handleToolPublish(
input: { channelId?: string; content?: string; text?: string },
origin?: ChannelToolOrigin,
): Promise<ToolResult> {
// Resolve content: accept both `content` and `text` (backward compat)
const messageText = input.content ?? input.text;
if (!messageText) {
Expand All @@ -3818,12 +3858,17 @@ export class ChannelRegistry {
};
}

// Resolve channelId: default to most recent incoming channel
const channelId = input.channelId ?? this.defaultPublishChannel;
// Resolve channelId: default to the most recent incoming channel, which an
// ephemeral run never takes (takesGlobalFallback), so it names one.
const caller = origin?.kind === 'agent' ? origin.agentName : undefined;
const channelId = input.channelId ?? (caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel);
if (!channelId) {
return {
success: false,
error: 'No channelId specified and no default channel available',
error: caller !== undefined && this.takesGlobalFallback?.(caller) === false
? 'Nothing was published: name the channel with channelId. A subagent has no default channel, since its plain '
+ 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
: 'No channelId specified and no default channel available',
isError: true,
};
}
Expand Down
3 changes: 2 additions & 1 deletion src/types/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,8 @@ export interface InferenceRequest {
* replies in the channel it is answering — not the process-global most-recent-
* inbound locus, which a concurrent message elsewhere can hijack (item-3
* redux). Undefined for non-channel wakes (heartbeat, timers, module events),
* which correctly fall back to the global default channel.
* which correctly fall back to the global default channel, except an
* ephemeral run's (a subagent's), which takes no global fallback.
*/
channelId?: string;
/**
Expand Down
Loading
Loading