Skip to content
Draft
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
10 changes: 10 additions & 0 deletions changelog.d/speech-none-not-a-locus.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
- A channel whose descriptor carries `metadata.speech: 'none'` (terminal-sessions marks
its session channels so) is never the automatic destination of an agent's prose: a turn
that such a channel woke routes bare prose to the agent's home or default channel
instead, the agent is told that channel, and the terminal channel is not opened by
delivery. Mid-turn, a message from such a channel doesn't move where the agent's
prose lands, and neither does the agent's `channel_open` of one, which still opens
it. Its traffic doesn't make it the default publish channel, so `channel_publish`
without a `channelId`, whose description now says so, and the reply context servers
receive (the channel context's `incoming`) stay with the conversation. Explicit sends
to it still work.
22 changes: 16 additions & 6 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,8 +571,10 @@ interface TurnRouting {
* system markers can't move it (2026-07-21 Cairn lounge misroute). Since
* 2026-07-31 a re-pin moves it (repinTurnLocus): an addressed message, or a
* follow-up in a channel the turn engaged. The agent's own channel_open
* moves it too. Unset in explicit and disabled mode, and for a turn with
* nowhere to speak.
* moves it too. None of these sets or moves it to a channel that refuses
* speech (ChannelRegistry.refusesSpeech), as of when it's set: a channel
* marked later keeps a locus it already holds until the turn ends. Unset
* in explicit and disabled mode, and for a turn with nowhere to speak.
*/
locus?: string;
/**
Expand Down Expand Up @@ -10918,13 +10920,16 @@ export class AgentFramework {
* re-pin rule (2026-07-31, n=6 and n=7): a conversational message (not a
* reaction or a system marker) that addressed the agent, or that follows
* up in a channel the turn sent into (TurnRouting.engaged). Undefined for
* any other message. A tool boundary weighs the messages it injects by it,
* and noteArrival the ones no boundary of the turn weighs.
* any other message, and for one from a channel that refuses speech
* (ChannelRegistry.refusesSpeech), which names no conversation. A tool
* boundary weighs the messages it injects by it, and noteArrival the ones
* no boundary of the turn weighs.
*/
private repinChannel(metadata: MessageMetadata | undefined, routing: TurnRouting): string | undefined {
if (!isConversationalInjection(metadata)) return undefined;
const m = metadata as Record<string, unknown> | undefined;
if (typeof m?.channelId !== 'string') return undefined;
if (this.channelRegistry?.refusesSpeech(m.channelId)) return undefined;
if (isAddressedMessage(m.tags as string[] | undefined, m)) return m.channelId;
return routing.engaged.has(m.channelId) ? m.channelId : undefined;
}
Expand Down Expand Up @@ -11815,7 +11820,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,
// skipping a channel that refuses speech (ChannelRegistry.refusesSpeech).
// 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 @@ -18394,7 +18400,11 @@ export class AgentFramework {
const opened =
(result.data as { channelId?: string } | undefined)?.channelId
?? (call.input as { channelId?: string } | undefined)?.channelId;
if (opened) {
// A channel that refuses speech (ChannelRegistry.refusesSpeech)
// names no conversation: opening it is for listening, so it moves
// neither the turn's trigger channel nor its locus, and nothing is
// announced.
if (opened && !this.channelRegistry?.refusesSpeech(opened)) {
callingTurn.triggerChannel = opened;
const openerAgent = this.agents.get(agentName);
// The move is one this result tells the agent, so it needs the
Expand Down
90 changes: 70 additions & 20 deletions src/mcpl/channel-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,11 +390,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 most recent incoming channel, skipping any marked as not a conversation, such as a terminal session.',
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 most recent incoming channel, skipping any marked as not a conversation)' },
content: { type: 'string', description: 'Text content to publish' },
text: { type: 'string', description: 'Alias for content' },
},
Expand Down Expand Up @@ -521,7 +521,7 @@ interface ChannelRegistryOptions {
* forks are spawned bound to a single channel (the framework tracks this in
* `conversationAgentHomes` / `ConversationRouter.channelForAgent`); their
* plain-text speech must route THERE. The process-global `defaultPublishChannel`
* tracks only the most-recent inbound across ALL channels, so with one fork per
* tracks only the most-recent inbound across every channel that takes speech, so with one fork per
* channel running concurrently it misroutes a fork's reply to whichever channel
* last spoke (item 3). Returns undefined for the trunk/primary agent, which has
* no home and correctly falls back to the global locus (heartbeats, etc.).
Expand All @@ -534,7 +534,7 @@ interface ChannelRegistryOptions {
* connectome-host runs — it never exposes conversation forks). A trunk agent
* has no fork home, so without this its plain-text speech falls back to the
* process-global `defaultPublishChannel`, which tracks the most-recent inbound
* across ALL channels and misroutes a reply to whichever channel last spoke
* across every channel that takes speech and misroutes a reply to whichever channel last spoke
* under concurrency (item-3 redux). The framework keeps both on the live
* turn's routing state and exposes them here. For a heartbeat / no-trigger
* turn it resolves to undefined until the turn's locus is resolved, so that
Expand Down Expand Up @@ -649,10 +649,10 @@ export class ChannelRegistry {
*/
private dmClassified = new Map<string, true>();

/** Most recent incoming channel ID — used for speech routing / default publish. */
/** Most recent incoming channel ID, from a channel that takes speech (refusesSpeech): the fallback locus, and channel_publish's channel when the call names none. */
private defaultPublishChannel: string | null = null;

/** Most recent incoming message metadata, used for buildChannelContext. */
/** That incoming message's id and thread, used for buildChannelContext. */
private defaultPublishMessageId: string | null = null;
private defaultPublishThreadId: string | undefined = undefined;

Expand Down Expand Up @@ -1044,9 +1044,15 @@ export class ChannelRegistry {
// A coalesced item is "accepted" only once the coalescer admits it, so
// for those this runs after the hook (below).
const markAccepted = () => {
this.defaultPublishChannel = message.channelId;
this.defaultPublishMessageId = message.messageId;
this.defaultPublishThreadId = message.threadId;
// A channel marked `speech: 'none'` names no conversation, so its
// traffic doesn't make it the default publish channel: the fallback
// for a wake that names none, and channel_publish's channel when the
// call names none. Asked by id, as delivery resolves the default.
if (!this.refusesSpeech(message.channelId)) {
this.defaultPublishChannel = message.channelId;
this.defaultPublishMessageId = message.messageId;
this.defaultPublishThreadId = message.threadId;
}
// A server sending channels/incoming is authoritative evidence that
// the transport is actually open. This repairs transient status only;
// durable desired state still changes exclusively through lifecycle
Expand Down Expand Up @@ -1318,7 +1324,8 @@ export class ChannelRegistry {
// ==========================================================================

/**
* Get the default publish channel ID (most recent incoming channel).
* Get the default publish channel ID: the most recent incoming channel that
* took speech when its traffic was accepted.
*/
getDefaultPublishChannel(): string | null {
return this.defaultPublishChannel;
Expand Down Expand Up @@ -1462,15 +1469,16 @@ export class ChannelRegistry {
// 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;
const outgoing = this.firstSpeakable(home, active, this.defaultPublishChannel);

if (openChannels.length === 0 && !outgoing) {
return undefined;
}

const context: ChannelContext = {};

// Incoming: the most-recent inbound message (what the agent is replying to).
// Incoming: the most-recent inbound message from a channel that takes
// speech (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) {
Expand Down Expand Up @@ -2984,7 +2992,9 @@ export class ChannelRegistry {
/**
* Host-owned output routing (see forking-knowledge-miner LOCUS-ROUTING-DESIGN).
* Publish the agent's plain-text speech to the current conversational locus
* (the most recent incoming channel, tracked cross-surface here in the host).
* (the turn's locus, which resolveLocus resolves from a fork's home, the
* turn's trigger channel or the default publish channel, skipping a channel
* that refuses speech).
* Called by the framework on a text-only turn — replaces the per-surface
* sticky auto-post that used to live in discord-mcpl. Returns null when there
* is no locus / the channel or its server can't be resolved (in which case
Expand Down Expand Up @@ -3052,8 +3062,46 @@ export class ChannelRegistry {
}

resolveLocus(conversationId: string): string | null {
const home = this.homeChannelResolver?.(conversationId);
return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null;
return this.firstSpeakable(
this.homeChannelResolver?.(conversationId),
this.activeChannelResolver?.(conversationId),
this.defaultPublishChannel,
);
Comment on lines 3064 to +3069

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Later replies ignore speech opt-out

The new filter only protects the destination chosen at turn start. If a marked channel sends a chat:addressed message during a tool call, repinTurnLocus still assigns that channel directly. A successful channel_open does the same. Later bare prose then reaches the channel despite metadata.speech: 'none', and routeSpeech opens it if closed.

Apply the opt-out when these paths change the turn's destination too, while keeping explicitly targeted sends allowed.

Knowledge Base Used:

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

Comment:
**Later replies ignore speech opt-out**

The new filter only protects the destination chosen at turn start. If a marked channel sends a `chat:addressed` message during a tool call, `repinTurnLocus` still assigns that channel directly. A successful `channel_open` does the same. Later bare prose then reaches the channel despite `metadata.speech: 'none'`, and `routeSpeech` opens it if closed.

Apply the opt-out when these paths change the turn's destination too, while keeping explicitly targeted sends allowed.

**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)
- [Streaming prose routing](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/streaming-prose-routing.md)

---

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

}

/**
* The first candidate locus, in precedence order, that may carry an
* agent's ordinary prose, skipping a channel that refuses speech
* (refusesSpeech). A wake from such a channel is a notification, not an
* invitation to reply there, so bare prose routes past it to the next
* candidate, and the channel is not opened by delivery. An explicit send
* to it still goes (routeSpeech takes the pinned target as given); only
* the automatic destination skips it.
*/
private firstSpeakable(...candidates: Array<string | null | undefined>): string | null {
for (const candidate of candidates) {
if (candidate && !this.refusesSpeech(candidate)) return candidate;
}
return null;
}

/**
* Whether a channel's descriptor carries `metadata.speech: 'none'`: the
* channel is not a conversation, but something its server reports on
* (terminal-sessions marks every session channel so). It is never where an
* agent's ordinary prose lands on its own: not the locus a wake from it
* resolves to (firstSpeakable), not where a message from it re-pins the
* turn or the agent's channel_open of it moves it (the framework asks this
* at both), and not channel_publish's channel when the call names none
* (its traffic doesn't set the default, and a default marked later is
* skipped). A send that names it still reaches it. The channel is looked up
* by id, as delivery looks it up, so the mark read is the one on the
* channel prose would reach. The mark is read from the descriptor its
* server registered: a channel the host knows only from a push that named
* it, before its server registers it, isn't marked.
*/
refusesSpeech(channelId: string): boolean {
return this.findChannelEntry(channelId)?.descriptor.metadata?.speech === 'none';
}

async routeSpeech(
Expand Down Expand Up @@ -3093,9 +3141,9 @@ export class ChannelRegistry {
const channelId = locusChannelId;
if (!channelId) {
// 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.
return fail(null, 'turn has no locus (no home/trigger channel; nothing to deliver into)');
// global inbound ever seen, or each of them refuses speech) — the agent
// was told its prose stays in the archive; honor that.
return fail(null, 'turn has no locus (no home, trigger or default channel that takes plain speech; nothing to deliver into)');
}

const entry = this.findChannelEntry(channelId);
Expand Down Expand Up @@ -3192,8 +3240,10 @@ 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
// is no default once it refuses speech (a mark that arrived after its
// traffic made it the default).
const channelId = input.channelId ?? this.firstSpeakable(this.defaultPublishChannel);
if (!channelId) {
return {
success: false,
Expand Down
9 changes: 8 additions & 1 deletion src/mcpl/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -967,7 +967,14 @@ export interface ChannelDescriptor {
/** Platform-specific address (e.g., { guild: "acme", channel: "#general" }) */
address?: Record<string, unknown>;

/** Arbitrary metadata */
/**
* Arbitrary metadata. One key the host reads: `speech: 'none'` marks a
* channel that is not a conversation (a terminal session, a feed), so an
* agent's ordinary prose is never routed to it automatically. A wake from
* it, a message from it, and the agent's own channel_open of it don't make
* it the turn's locus, and a publish that names no channel doesn't go
* there. A send that names it still reaches it.
*/
metadata?: Record<string, unknown>;

/**
Expand Down
Loading
Loading