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
11 changes: 11 additions & 0 deletions changelog.d/555-repin-traces-follow-locus.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
- After an addressed re-pin, or the agent's own `channel_open`, moves a
`locus` or `hybrid` turn's locus between rounds, the `inference:tokens` and
`inference:content_block` traces now name the new locus, and the typing
indicator moves there, from the next round on (#555). Before, both stayed
on the channel the stream started in for the rest of the stream, while the
round's prose was delivered to the new locus, and after a budget restart
or a guard retry the old channel's indicator kept running beside the new
one until the turn ended. Both name the conversation the turn is
attending: in `hybrid` mode they follow the locus whether or not a `>>>`
envelope sends the text elsewhere. An `explicit` turn's typing and traces
still show its trigger channel.
17 changes: 17 additions & 0 deletions changelog.d/555-trace-channel-follows-locus.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
- **Hosts that read the `channelId` of `inference:tokens` or
`inference:content_block` traces:** in `locus` and `hybrid` mode it now
follows the turn's locus. It was documented as "the turn's locus as the
stream started", so after an addressed re-pin, or the agent's own
`channel_open`, moved the locus between rounds, it kept naming the old
channel for the rest of the stream. It now names the new locus from the
next round on: from the model's first content in that round, or from its
retry after a refusal. It is still the channel the stream shows typing in,
and in `hybrid` mode still the locus whether or not a `>>>` envelope sends
the text elsewhere.
- **Migration:** a consumer that keeps the first `channelId` it sees for an
activation, as a voice relay may, works as before, and stays on the old
channel after a re-pin; to follow the move, read each trace's
`channelId`. A consumer that groups one stream's chunks by `channelId`
now finds a stream whose locus moved under two channels.
- **Unchanged:** `explicit` mode's traces, which name the trigger channel,
and the live outgoing stream (`channels/outgoing/chunk` and `complete`).
91 changes: 70 additions & 21 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,14 @@ interface TurnRouting {
* nowhere to speak.
*/
locus?: string;
/**
* The channel the turn's typing indicator was last shown in, by any of its
* streams (showTurnTyping). A stream that hands the turn to a successor (a
* budget restart, a guard retry) leaves its indicator running, so a later
* start, or a move, that shows it elsewhere stops this one first, as after
* a re-pin.
*/
typing?: string;
/**
* Channels the turn sent into with a send tool (SEND_ENGAGEMENT_TOOLS): a
* conversational follow-up in one of them is addressed by context, so it
Expand Down Expand Up @@ -14312,6 +14320,17 @@ export class AgentFramework {
}
}

/**
* Show the turn's typing indicator in `channelId`, stopping the one it last
* showed elsewhere: this stream's, or one an earlier stream of the turn
* left running when it handed the turn on.
*/
private showTurnTyping(routing: TurnRouting, channelId: string): void {
if (routing.typing !== undefined && routing.typing !== channelId) this.channelRegistry?.stopTyping(routing.typing);
this.channelRegistry?.startTyping(channelId);
routing.typing = channelId;
}

/**
* The channel of the turn's newest arrival whose message is still in the
* agent's store (TurnRouting.arrivals). A message removed since it was
Expand Down Expand Up @@ -15673,14 +15692,16 @@ export class AgentFramework {
// turn, which falls back to the global default rather than inheriting
// a previous turn's channel.
routing.triggerChannel = trigger?.channelId;
// FREEZE the turn's outbound locus now (turn-frozen routing): the home
// channel for forks, else the trigger channel, else the global default,
// 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
// Resolve the turn's outbound locus now: the home channel for forks,
// else the trigger channel, else the global default, which an
// ephemeral run never takes (takesGlobalFallback). Ambient mid-turn
// injections must not redirect the agent's plain prose (ambient
// chatter and reactions hijacked it before, 2026-07-21); only an
// addressed message, a follow-up in a channel the turn engaged
// (repinTurnLocus) or the agent's own channel_open moves it, between
// rounds. The eager snapshot protects heartbeat turns from a moving
// defaultPublishChannel. Explicit mode uses a turn-scoped `>>` target
// and disabled mode has no prose target, so neither freezes or
// and disabled mode has no prose target, so neither resolves or
// announces a locus. If the effective locus differs from the last one
// announced, a durable `[routing]` notice goes into the window BEFORE
// this turn compiles, so the agent always knows where its voice goes
Expand Down Expand Up @@ -15743,7 +15764,7 @@ export class AgentFramework {
turnProseRouting === 'explicit'
? trigger?.channelId ?? null
: turnLocus ?? null;
if (earlyTypingChannel) this.channelRegistry?.startTyping(earlyTypingChannel);
if (earlyTypingChannel) this.showTurnTyping(routing, earlyTypingChannel);

try {
const requestSnapshot = this.captureInferenceToolSnapshot(agent);
Expand Down Expand Up @@ -16294,22 +16315,40 @@ export class AgentFramework {
let silenceBeforeRefusal: 'send' | 'private' | null | undefined;

// Typing indicator: show "<agent> is typing…" for the whole duration of
// this turn. Started here (paired with the finally below, so it can never
// leak) and refreshed on a 7s interval by the ChannelRegistry until
// stopped on any exit path. Never moves mid-turn.
// - locus mode: the frozen routing pin (where bare prose will land).
// this turn. Started at the turn's start, before the compile, and here,
// and refreshed on a 7s interval by the ChannelRegistry until the finally
// below stops it on an exit path that ends the turn. A stream that hands
// the turn to a successor leaves it running, and the successor's start
// stops it first if it shows elsewhere (showTurnTyping). Typing is
// presence, not delivery: it says "attending to the conversation here",
// whichever channel a `>>>` or `>>` envelope then sends the words to.
// - locus and hybrid mode: the turn's locus, the conversation the turn
// is attending. An addressed re-pin or the agent's own channel_open
// can move it between rounds, and followTurnLocus (below) moves the
// indicator with it at the next round's first content, or at its
// retry after a refusal.
// - explicit mode: there is no locus — indicate on the TRIGGER channel
// (where the wake came from). Typing is presence, not delivery, so
// this doesn't violate never-guess: it says "attending to what you
// sent here", which is true regardless of where the reply goes.
// Heartbeat/no-trigger explicit turns show no indicator.
const typingChannel =
// (where the wake came from). This doesn't violate never-guess: it
// says "attending to what you sent here", which is true regardless
// of where the reply goes. Heartbeat/no-trigger explicit turns show
// no indicator.
let typingChannel =
turnProseRouting === 'disabled'
? null
: turnProseRouting === 'explicit'
? trigger?.channelId ?? null
: resolveTurnLocus();
if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
if (typingChannel) this.showTurnTyping(streamRouting, typingChannel);
// Locus and hybrid mode: move the indicator to the turn's locus when it
// has moved since the indicator last showed. Once a turn has a locus it
// always has one.
const followTurnLocus = (): void => {
if (turnProseRouting !== 'locus' && turnProseRouting !== 'hybrid') return;
const locus = resolveTurnLocus();
if (!locus || locus === typingChannel) return;
this.showTurnTyping(streamRouting, locus);
typingChannel = locus;
};

// MCPL Spec 14.3 outgoing streaming: route text deltas to their
// destination server AS THEY GENERATE (voice synthesis, live message
Expand Down Expand Up @@ -16501,6 +16540,16 @@ export class AgentFramework {
break;
}
this.touchEphemeralRun(agent.name, true);
// The model's content in a round, or its retry after a refusal, comes
// after the boundary that may have moved the turn's locus: show the
// round where it is, before its traces name the channel. The results
// a tool boundary reports (XML tool mode's, or a result with an
// image) belong to the round before, and a response with nothing in
// it moves nothing, so a channel_open just before an empty final
// response doesn't start typing in a turn that is ending.
const modelContent = (event.type === 'tokens' && event.meta.type !== 'tool_result')
|| (event.type === 'block' && event.event.block.type !== 'tool_result');
if (modelContent || event.type === 'retrying') followTurnLocus();
switch (event.type) {
case 'tokens': {
const trace: Parameters<AgentFramework['emitTrace']>[0] = {
Expand All @@ -16509,9 +16558,9 @@ export class AgentFramework {
content: event.content,
blockType: event.meta.type,
blockIndex: event.meta.blockIndex,
// Turn-frozen locus (same value as the typing indicator above):
// lets trace consumers tag every chunk with the channel this
// turn's prose is bound for, without re-deriving routing.
// The channel the typing indicator above shows, the conversation
// the turn is attending: lets trace consumers tag every chunk
// with it, without re-deriving routing.
channelId: typingChannel ?? undefined,
};
// Delivery joins a round's text blocks with a newline
Expand Down
24 changes: 18 additions & 6 deletions src/types/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,15 @@ export type TraceEvent =
blockType: 'text' | 'thinking' | 'tool_call' | 'tool_result';
/** 0-indexed block position in the current assistant turn. */
blockIndex: number;
/** The channel this stream shows typing in: the turn's locus as the
* stream started (see inference:started), or in explicit mode the
* channel the start's wake came from. */
/** The channel this stream shows typing in, the conversation the turn
* is attending. Locus and hybrid mode: the turn's locus, which an
* addressed re-pin or the agent's own `channel_open` can move between
* rounds, so it can change within one stream; the indicator and these
* traces follow it from the next round on, at the model's first
* content in it or its retry after a refusal, and a tool result the
* boundary reports carries the channel of the round it answers. In
* hybrid mode a `>>>` envelope can send the text itself elsewhere.
* Explicit mode: the channel the start's wake came from. */
channelId?: string;
})
| (TraceEventBase & {
Expand All @@ -141,9 +147,15 @@ export type TraceEvent =
phase: 'block_start' | 'block_complete';
blockType: 'text' | 'thinking' | 'tool_call' | 'tool_result';
blockIndex: number;
/** The channel this stream shows typing in: the turn's locus as the
* stream started (see inference:started), or in explicit mode the
* channel the start's wake came from. */
/** The channel this stream shows typing in, the conversation the turn
* is attending. Locus and hybrid mode: the turn's locus, which an
* addressed re-pin or the agent's own `channel_open` can move between
* rounds, so it can change within one stream; the indicator and these
* traces follow it from the next round on, at the model's first
* content in it or its retry after a refusal, and a tool result the
* boundary reports carries the channel of the round it answers. In
* hybrid mode a `>>>` envelope can send the text itself elsewhere.
* Explicit mode: the channel the start's wake came from. */
channelId?: string;
})
| (TraceEventBase & {
Expand Down
42 changes: 42 additions & 0 deletions test/explicit-prose-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1637,6 +1637,48 @@ describe('explicit prose routing', () => {
await framework.stop();
});

it("explicit mode's typing and traces stay on its trigger channel across the turn's rounds", async () => {
// Explicit mode has no locus: its typing and traces show where the wake
// came from, and only a locus or hybrid turn's follow a moved locus.
membrane.pushResponse(createMockResponse([
{ type: 'tool_use', id: 't1', name: 'robot--move', input: { dir: 'north' } },
] as ContentBlock[], 'tool_use'));
membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>#alpha done' }] as ContentBlock[]));

const framework = await createFramework();
const routed = stubRegistry(framework);
const registry = (framework as unknown as { channelRegistry: Record<string, unknown> }).channelRegistry;
const typing: string[] = [];
registry.startTyping = (channelId: string) => { typing.push(`start ${channelId}`); };
registry.stopTyping = (channelId?: string) => { typing.push(`stop ${channelId ?? '(all)'}`); };
const traced = new Set<string | undefined>();
framework.onTrace((event) => {
const e = event as { type: string; blockType?: string; channelId?: string };
if ((e.type === 'inference:tokens' || e.type === 'inference:content_block') && e.blockType === 'text') traced.add(e.channelId);
});
const internals = framework as unknown as {
pendingRequests: InferenceRequest[];
processInferenceRequests(): Promise<void>;
};
try {
internals.pendingRequests.push({
agentName: 'assistant', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(),
channelId: 'chan-alpha', addressed: true,
});
await internals.processInferenceRequests();
await framework.runUntilIdle();

assert.equal(module.calls.length, 1, 'control: round 1 ran its tool');
assert.deepEqual(routed, [{ text: 'done', locus: 'chan-alpha' }], 'control: round 2 ran and was delivered');
assert.ok(typing.includes('start chan-alpha'), `typing showed on the trigger channel: ${JSON.stringify(typing)}`);
assert.equal(typing.includes('stop chan-alpha'), false, `and stayed there until the turn ended: ${JSON.stringify(typing)}`);
assert.ok(typing.includes('stop (all)'), 'control: the turn\'s end stopped it');
assert.deepEqual([...traced], ['chan-alpha'], 'round 2 is traced in the trigger channel');
} finally {
await framework.stop();
}
});

it('explicit mode streams nothing before a destination: unprefixed text never reaches the trigger channel', async () => {
membrane.pushResponse(createMockResponse([
{ type: 'text', text: 'Unprefixed musing.\n>>#alpha streamed after the prefix' },
Expand Down
Loading
Loading