Skip to content
21 changes: 21 additions & 0 deletions changelog.d/feat-disabled-mode-typing.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
- Agents with `proseRouting: 'disabled'` now show the typing indicator during
a turn, on the channel the trigger came from, the same as explicit mode. A
batched gate wake names no `channelId`, so its `wakeChannelId` is used
instead. Before, disabled-mode turns showed no indicator at all, so a
resident that answers only through send tools looked idle while it worked.
Never on a tuned-out channel: the agent turned its attention off there, yet
an ambient push or a gate wake can still name the channel. Silent wakes
(`suppressProse`, such as a silent heartbeat) still show none. Explicit and
locus modes pick their typing channel as before.
- Entering tune-out on a channel now stops a typing indicator already running
there. Before, its 7 s refresh continued until the turn ended. A
disabled-mode turn does not restart it; an explicit or locus turn can still
restart it at stream start or on a retry, as before.
- With conversation routing, a disabled-mode gate wake shows typing only
where the channel's messages reach the agent: a fork on its home channel,
any other agent never on a fork-bound channel.
- A silent request batched with ordinary ones no longer picks the turn's
channel or wake provenance; the ordinary requests decide, in every
prose-routing mode. A batch of only silent requests is unchanged.
- A typing `stop` now honors the server's `channels.typing` grant, like the
start: a server without the grant no longer receives an unpaired stop.
53 changes: 44 additions & 9 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10443,9 +10443,14 @@ export class AgentFramework {
// addressed message's direct wake, and queue order would hand the turn
// to the older one. Ties keep the later-queued request, as before.
const eventAt = (r: InferenceRequest): number => r.wakeAt ?? r.timestamp;
// A silent tick never suppresses a genuine coalesced wake. If any
// ordinary request shares this batch, the ordinary turn semantics win,
// including where the turn is: a silent request names no channel then.
const silentOnly = requests.every((r) => r.suppressProse === true);
const routable = silentOnly ? requests : requests.filter((r) => r.suppressProse !== true);
let ambientReq: InferenceRequest | undefined;
let addressedReq: InferenceRequest | undefined;
for (const r of requests) {
for (const r of routable) {
if (!r.channelId) continue;
if (!ambientReq || eventAt(r) >= eventAt(ambientReq)) ambientReq = r;
if (r.addressed && (!addressedReq || eventAt(r) >= eventAt(addressedReq))) addressedReq = r;
Expand All @@ -10461,14 +10466,11 @@ export class AgentFramework {
let provReq: InferenceRequest | undefined = addressedReq
?? (channelReq?.counterparty || channelReq?.wakeChannelId ? channelReq : undefined);
if (!provReq) {
for (const r of requests) {
for (const r of routable) {
if (!r.counterparty && !r.wakeChannelId) continue;
if (!provReq || (r.wakeAt ?? r.timestamp) >= (provReq.wakeAt ?? provReq.timestamp)) provReq = r;
}
}
// A silent tick never suppresses a genuine coalesced wake. If any
// ordinary request shares this batch, the ordinary turn semantics win.
const silentOnly = requests.every((r) => r.suppressProse === true);
// RFC-006 §5: when every cause is a deferred batch, the turn's content
// exists only once assembly renders it. Recorded for the whole batch
// (not inherited from requests[0]), so a requeued trigger stays honest.
Expand Down Expand Up @@ -11334,6 +11336,36 @@ export class AgentFramework {
}
}

/**
* Where a `proseRouting: 'disabled'` turn shows typing: the trigger
* channel, as in explicit mode, else where a batched gate wake came from
* (`wakeChannelId`; the gate deliberately names no `channelId`) when that
* channel's messages reach this agent. Never a
* tuned-out channel: its incoming traffic, addressed included, is diverted
* away from the resident, yet an ambient push or a gate wake can still name
* the channel, and typing there would show attendance the agent turned off
* (`enterTuneOut` also stops typing already running there). A silent wake
* (`suppressProse`, which also runs as disabled) shows none.
*/
private disabledTypingChannel(agentName: string, trigger: InferenceRequest | undefined): string | null {
if (!trigger || trigger.suppressProse) return null;
let channelId = trigger.channelId;
if (!channelId && trigger.wakeChannelId) {
// The gate wakes every agent, but with conversation routing a channel's
// messages reach only the fork bound to it: a fork types only on its
// home channel, and no other agent types on a fork-bound channel.
const home = this.conversationAgentHomes.get(agentName);
const reachesAgent = home !== undefined
? trigger.wakeChannelId === home
: ![...this.conversationAgentHomes.values()].includes(trigger.wakeChannelId);
if (reachesAgent) channelId = trigger.wakeChannelId;
}
if (!channelId) return null;
const serverId = this.channelRegistry?.getChannelServerId(channelId);
if (serverId && this.channelRegistry?.getTuneOutState(serverId, channelId)) return null;
Comment on lines +11364 to +11365

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 Duplicate channel IDs confuse typing If two servers register the same channel ID, this lookup checks tune-out state on the first server rather than necessarily on the server that supplied the wake. A wake from the second server’s tuned-out channel can therefore start typing on the first server’s channel, giving a misleading presence signal.

Knowledge Base Used: MCPL transport and server registration

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 8884-8885

Comment:
**Duplicate channel IDs confuse typing** If two servers register the same channel ID, this lookup checks tune-out state on the first server rather than necessarily on the server that supplied the wake. A wake from the second server’s tuned-out channel can therefore start typing on the first server’s channel, giving a misleading presence signal.

**Knowledge Base Used:** [MCPL transport and server registration](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/mcpl-transport-and-registration.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.

Valid, but this predates the PR, and fixing only the check would make it disagree with the send: startTyping resolves the channel through the same first-match findChannelEntry, so with duplicate ids the indicator already went to the first server before this change. As it stands, the tune-out check and the send always use the same server. Listed under "Not covered" in the description; resolving the owning server from the trigger's source would need to change both the check and startTyping.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

That’s a fair distinction. Since disabledTypingChannel and startTyping both resolve duplicate IDs through the same first-match lookup, this PR does not introduce a new mismatch between the tune-out check and the indicator destination. Correct ownership would require changing both paths and knowing the wake’s source server, so this is appropriately documented as pre-existing scope rather than an actionable finding here. I’m withdrawing the comment.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

return channelId;
}

/**
* Body of startAgentStream, split out so the caller's try/finally owns the
* turn token unconditionally. Returns true iff the token was handed off to
Expand Down Expand Up @@ -11572,9 +11604,11 @@ export class AgentFramework {
// idempotent per channel, and owns the 7s refresh); the catch below stops
// it on the no-driveStream failure paths (e.g. a compile refusal).
const earlyTypingChannel =
turnProseRouting === 'explicit'
? trigger?.channelId ?? null
: turnLocus ?? null;
turnProseRouting === 'disabled'
? this.disabledTypingChannel(agent.name, trigger)
: turnProseRouting === 'explicit'
? trigger?.channelId ?? null
: turnLocus ?? null;
if (earlyTypingChannel) this.channelRegistry?.startTyping(earlyTypingChannel);

try {
Expand Down Expand Up @@ -11926,9 +11960,10 @@ export class AgentFramework {
// 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.
// - disabled mode: see disabledTypingChannel.
const typingChannel =
turnProseRouting === 'disabled'
? null
? this.disabledTypingChannel(agent.name, trigger)
: turnProseRouting === 'explicit'
? trigger?.channelId ?? null
: resolveTurnLocus();
Expand Down
25 changes: 17 additions & 8 deletions src/mcpl/channel-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1291,9 +1291,7 @@ export class ChannelRegistry {
// global-clear branch's semantics, and keeps defensive stopTyping(ch)
// calls from spamming stops at a server that never saw a start.
const entry = this.findChannelEntry(channelId);
if (entry && this.sendTypingFn) {
this.sendTypingFn(entry.serverId, channelId, this.typingMetadata.get(channelId), 'stop');
}
if (entry) this.sendTypingStop(entry.serverId, channelId);
}
this.typingMetadata.delete(channelId);
} else {
Expand All @@ -1303,11 +1301,9 @@ export class ChannelRegistry {
clearInterval(interval);
}
this.typingIntervals.clear();
if (this.sendTypingFn) {
for (const id of channels) {
const entry = this.findChannelEntry(id);
if (entry) this.sendTypingFn(entry.serverId, id, this.typingMetadata.get(id), 'stop');
}
for (const id of channels) {
const entry = this.findChannelEntry(id);
if (entry) this.sendTypingStop(entry.serverId, id);
}
this.typingMetadata.clear();
}
Expand Down Expand Up @@ -1988,6 +1984,9 @@ export class ChannelRegistry {
source,
timestamp: new Date().toISOString(),
});
// A tuned-out channel shows no attendance: stop a typing run already
// going there, or its 7s refresh would continue until the turn ends.
this.stopTyping(channelId);

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 Tune-out sends ungranted typing startTyping installs a timer even when the server lacks the channels.typing grant and the initial notification is skipped. If tune-out then stops that timer, stopTyping sends a stop notification without checking the grant, so the server receives an unpaired typing notification it was not authorized to receive.

Knowledge Base Used: MCPL transport and server registration

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

Comment:
**Tune-out sends ungranted typing** `startTyping` installs a timer even when the server lacks the `channels.typing` grant and the initial notification is skipped. If tune-out then stops that timer, `stopTyping` sends a stop notification without checking the grant, so the server receives an unpaired typing notification it was not authorized to receive.

**Knowledge Base Used:** [MCPL transport and server registration](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/mcpl-transport-and-registration.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.

Valid, and it applied to the existing turn-end stop too. Fixed in 7166b0b at the shared point: both stopTyping branches now send through sendTypingStop, which applies the same channels.typing grant check as the start. Test in channel-registry-typing.test.ts: an ungranted server receives neither a start nor a stop from either branch; it fails without the check.

}

/**
Expand Down Expand Up @@ -2271,6 +2270,16 @@ export class ChannelRegistry {
// Private: Typing notification
// ==========================================================================

/**
* Send a typing 'stop'. The same §14.1 grant gate as the start: a timer can
* exist for a server whose start was skipped for lack of `channels.typing`,
* and it must not receive an unpaired stop either.
*/
private sendTypingStop(serverId: string, channelId: string): void {
if (!CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.typing')) return;
this.sendTypingFn?.(serverId, channelId, this.typingMetadata.get(channelId), 'stop');
}

/**
* Send a typing notification for a channel.
*
Expand Down
21 changes: 21 additions & 0 deletions test/channel-registry-typing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,24 @@ test('stopTyping() global branch only dispatches stops for channels with interva
assert.equal(stops.length, 1);
assert.equal(stops[0].channelId, 'ch1');
});

test('a server without the channels.typing grant gets no stop, from either stopTyping branch', () => {
const calls: TypingCall[] = [];
const registry = new ChannelRegistry(
{ getServer: () => ({ grant: new CapabilityGrant(new Set(), []) }) } as unknown as McplServerRegistry,
{} as FeatureSetManager,
() => {},
() => {},
{ sendTypingFn: (serverId, channelId, metadata, op) => { calls.push({ serverId, channelId, metadata, op }); } },
);
(registry as unknown as {
channels: Map<string, { serverId: string; descriptor: { id: string }; open: boolean }>;
}).channels.set('srv:ch1', { serverId: 'srv', descriptor: { id: 'ch1' }, open: true });

registry.startTyping('ch1'); // the timer exists; the start was skipped for lack of the grant
registry.stopTyping('ch1');
registry.startTyping('ch1');
registry.stopTyping();

assert.deepEqual(calls, [], 'an ungranted server receives neither start nor stop');
});
Loading