@animalabs/agent-framework 0.22.5 → 0.22.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/framework.ts CHANGED
@@ -754,6 +754,31 @@ interface TurnRouting {
754
754
  * sent Sol's DM reply to a stale guild channel (2026-07-22).
755
755
  */
756
756
  triggerChannel?: string;
757
+ /**
758
+ * The trigger's channel with the server whose message it was, frozen when
759
+ * the turn opens and never moved: a channel_open moves triggerChannel and
760
+ * a re-pin moves the locus, but neither carries this server with it. Every
761
+ * send of the turn whose destination id is this channel resolves with this
762
+ * server (triggerServerFor): speech, a channel_publish naming it or taking
763
+ * it as its default, a drafts resend, and the refusal reaction. So a
764
+ * message one server sent can't have its reply go out through another
765
+ * server that registers the same id, whatever the registry holds by then;
766
+ * the reply is refused where its server no longer registers the channel.
767
+ * Unset for a turn with no channel trigger, and where the requests batched
768
+ * into the trigger name its channel from more than one server: a bare id
769
+ * then resolves only where exactly one server registers it.
770
+ */
771
+ triggerOrigin?: { channelId: string; serverId: string };
772
+ /**
773
+ * The locus with the server of the message that put it there, where that
774
+ * isn't the trigger: the latest accepted message's (the global fallback a
775
+ * turn with no trigger channel takes), an arrival's or a mid-turn
776
+ * message's (a re-pin), or the server a channel_open opened it on. Set
777
+ * with the locus (setTurnLocus), and cleared when the locus moves to a
778
+ * channel no one server put there. A send into this channel resolves with
779
+ * this server, as one into the trigger's channel does with the trigger's.
780
+ */
781
+ locusOrigin?: { channelId: string; serverId: string };
757
782
  /**
758
783
  * Locus and hybrid mode: where the turn's unpinned plain prose lands.
759
784
  * Frozen when the turn opens (home channel for a fork, else the trigger
@@ -830,11 +855,60 @@ interface Arrival {
830
855
  messageId: MessageId;
831
856
  /** The channel it re-pins the locus to. */
832
857
  channelId: string;
858
+ /** The server whose message it is. */
859
+ serverId?: string;
860
+ }
861
+
862
+ /** The server a stored channel message came from, as the host wrote it. */
863
+ function messageServerId(metadata: MessageMetadata | undefined): string | undefined {
864
+ const serverId = (metadata as { serverId?: unknown } | undefined)?.serverId;
865
+ return typeof serverId === 'string' && serverId ? serverId : undefined;
833
866
  }
834
867
 
835
868
  /** The last TurnRouting.turnId given out: ids increase, and are unique within the process. */
836
869
  let lastTurnId = 0;
837
870
 
871
+ /**
872
+ * The server every request naming `channelId` came from, or undefined where
873
+ * one names no server or they name more than one (TurnRouting.triggerOrigin).
874
+ */
875
+ function onlyServerFor(requests: readonly InferenceRequest[], channelId: string): string | undefined {
876
+ let serverId: string | undefined;
877
+ for (const r of requests) {
878
+ if (r.channelId !== channelId) continue;
879
+ if (!r.serverId || (serverId !== undefined && r.serverId !== serverId)) return undefined;
880
+ serverId = r.serverId;
881
+ }
882
+ return serverId;
883
+ }
884
+
885
+ /**
886
+ * The server a send in `routing`'s turn resolves `channelId` with: the
887
+ * trigger's, where `channelId` is the trigger's channel
888
+ * (TurnRouting.triggerOrigin), else the locus's, where it's the channel the
889
+ * locus's server put there (TurnRouting.locusOrigin).
890
+ */
891
+ function triggerServerFor(routing: TurnRouting | undefined, channelId: string | null | undefined): string | undefined {
892
+ if (routing === undefined || !channelId) return undefined;
893
+ if (routing.triggerOrigin?.channelId === channelId) return routing.triggerOrigin.serverId;
894
+ if (routing.locusOrigin?.channelId === channelId) return routing.locusOrigin.serverId;
895
+ return undefined;
896
+ }
897
+
898
+ /** Move a turn's locus, with the server of the message that put it there
899
+ * (TurnRouting.locusOrigin), or none. */
900
+ function setTurnLocus(routing: TurnRouting, channelId: string, serverId: string | undefined): void {
901
+ routing.locus = channelId;
902
+ routing.locusOrigin = serverId !== undefined ? { channelId, serverId } : undefined;
903
+ }
904
+
905
+ /** routeSpeech's options for speech into `channelId` in `routing`'s turn:
906
+ * the trigger's server, where it's the trigger's channel (triggerServerFor). */
907
+ function speechServerOpts(routing: TurnRouting | undefined, channelId: string | null | undefined): { locusServerId: string } | undefined {
908
+ const serverId = triggerServerFor(routing, channelId);
909
+ return serverId !== undefined ? { locusServerId: serverId } : undefined;
910
+ }
911
+
838
912
  function freshTurnRouting(): TurnRouting {
839
913
  return {
840
914
  turnId: ++lastTurnId,
@@ -2727,8 +2801,8 @@ export class AgentFramework {
2727
2801
  // route applies to a fork only when it IS that home, and never to
2728
2802
  // another agent when a fork owns that channel. Otherwise a fork
2729
2803
  // bound elsewhere would show typing where it isn't replying.
2730
- ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId)
2731
- ? { channelId: provenance.routeChannelId, addressed: true }
2804
+ ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId, provenance.routeServerId)
2805
+ ? { channelId: provenance.routeChannelId, serverId: provenance.routeServerId, addressed: true }
2732
2806
  : {}),
2733
2807
  });
2734
2808
  },
@@ -7960,7 +8034,9 @@ export class AgentFramework {
7960
8034
  // tools/call directly on its connection (bare tool name — no prefix
7961
8035
  // games), bypassing the agent event queue so no synthetic tool-result
7962
8036
  // is injected into a turn the agent never took.
7963
- const serverId = this.channelRegistry?.getChannelServerId(incoming.channelId);
8037
+ // Through the server whose message it marks: the latest accepted
8038
+ // message's, kept beside its channel and id (fallbackServerFor).
8039
+ const serverId = this.channelRegistry?.fallbackServerFor(incoming.channelId);
7964
8040
  const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
7965
8041
  if (!server) {
7966
8042
  console.error(
@@ -8133,7 +8209,9 @@ export class AgentFramework {
8133
8209
  const parts = incoming.channelId.split(':');
8134
8210
  if (parts[0] !== 'discord') return;
8135
8211
  const channelId = parts[parts.length - 1];
8136
- const serverId = this.channelRegistry?.getChannelServerId(incoming.channelId);
8212
+ // As reactToRefusal resolves it: through the server whose message the
8213
+ // latest inbound is (fallbackServerFor).
8214
+ const serverId = this.channelRegistry?.fallbackServerFor(incoming.channelId);
8137
8215
  const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
8138
8216
  if (!server) return;
8139
8217
  await server.sendToolsCall('send_message', {
@@ -10529,12 +10607,19 @@ export class AgentFramework {
10529
10607
  // The last injection that qualifies wins (repinChannel).
10530
10608
  const routing = this.routingOf(agent.name);
10531
10609
  let newLocus: string | undefined;
10532
- for (const inj of midTurnInjections) newLocus = this.repinChannel(inj.metadata, routing) ?? newLocus;
10610
+ let newLocusServer: string | undefined;
10611
+ for (const inj of midTurnInjections) {
10612
+ const channelId = this.repinChannel(inj.metadata, routing);
10613
+ if (channelId) {
10614
+ newLocus = channelId;
10615
+ newLocusServer = messageServerId(inj.metadata);
10616
+ }
10617
+ }
10533
10618
  if (newLocus) {
10534
10619
  // What this boundary just stored is newer than any arrival the
10535
10620
  // turn holds, so the arrivals can't move the locus back.
10536
10621
  routing.arrivals = [];
10537
- const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed');
10622
+ const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed', newLocusServer);
10538
10623
  if (notice) midTurnInjections.push(notice);
10539
10624
  }
10540
10625
  } else if (routesLocus && !shouldEndTurn) {
@@ -10917,14 +11002,15 @@ export class AgentFramework {
10917
11002
  const id = target.getContextManager().addMessage('user', event.content, metadata);
10918
11003
  // A correction may target an older engagement. Refresh only the
10919
11004
  // binding whose agent actually received the message.
10920
- if (this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) {
10921
- this.conversationRouter.touch(event.channelId);
11005
+ const channel = { serverId: event.serverId, channelId: event.channelId };
11006
+ if (this.conversationRouter.getBinding(channel)?.agentName === target.name) {
11007
+ this.conversationRouter.touch(channel);
10922
11008
  }
10923
11009
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
10924
11010
  if (event.triggerInference) {
10925
11011
  this.pendingRequests.push({
10926
11012
  agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
10927
- channelId: event.channelId,
11013
+ channelId: event.channelId, serverId: event.serverId,
10928
11014
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
10929
11015
  addressed: isAddressedMessage(event.tags, event.metadata),
10930
11016
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
@@ -11003,8 +11089,10 @@ export class AgentFramework {
11003
11089
  source: event.serverId,
11004
11090
  timestamp: Date.now(),
11005
11091
  // Route this turn's auto-published speech back to THIS channel, not
11006
- // the global most-recent-inbound locus (item-3 redux, trunk agents).
11092
+ // the global most-recent-inbound locus (item-3 redux, trunk agents),
11093
+ // through the server that sent it.
11007
11094
  channelId: event.channelId,
11095
+ serverId: event.serverId,
11008
11096
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
11009
11097
  // Addressed messages outrank ambient chatter when a batched wake
11010
11098
  // picks the turn's frozen speech locus.
@@ -11082,7 +11170,7 @@ export class AgentFramework {
11082
11170
  if (!agent) {
11083
11171
  // Bound agent vanished (e.g. external reset) — unbind so the next
11084
11172
  // qualifying message respawns a fresh fork.
11085
- router.unbind(event.channelId);
11173
+ router.unbind({ serverId: event.serverId, channelId: event.channelId });
11086
11174
  this.emitTrace({
11087
11175
  type: 'mcpl:conversation-binding-orphaned',
11088
11176
  channelId: event.channelId,
@@ -11098,7 +11186,7 @@ export class AgentFramework {
11098
11186
  messageMetadata.triggered = trigger;
11099
11187
 
11100
11188
  const id = agent.getContextManager().addMessage('user', event.content, messageMetadata);
11101
- router.touch(event.channelId);
11189
+ router.touch({ serverId: event.serverId, channelId: event.channelId });
11102
11190
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
11103
11191
 
11104
11192
  if (trigger) {
@@ -11110,6 +11198,7 @@ export class AgentFramework {
11110
11198
  // A fork's home channel wins in routeSpeech regardless, but carry the
11111
11199
  // triggering channel too so the trunk/active path stays consistent.
11112
11200
  channelId: event.channelId,
11201
+ serverId: event.serverId,
11113
11202
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
11114
11203
  addressed: isAddressedMessage(event.tags, event.metadata),
11115
11204
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
@@ -11254,7 +11343,7 @@ export class AgentFramework {
11254
11343
  }
11255
11344
  const decision = this.conversationRouter!.route(facts);
11256
11345
  if (decision.kind !== 'spawn') return decision;
11257
- return { ...decision, spawned: this.spawnConversationAgent(facts.channelId, decision) };
11346
+ return { ...decision, spawned: this.spawnConversationAgent(facts, decision) };
11258
11347
  }
11259
11348
  }
11260
11349
 
@@ -11270,11 +11359,12 @@ export class AgentFramework {
11270
11359
  * so the entry it removes is its own. It's removed before anyone waiting
11271
11360
  * on it resumes, so a waiter that decides again doesn't find it.
11272
11361
  */
11273
- private spawnConversationAgent(channelId: string, decision: { agentName: string; generation: number }): Promise<Agent> {
11362
+ private spawnConversationAgent(facts: IncomingMessageFacts, decision: { agentName: string; generation: number }): Promise<Agent> {
11363
+ const channelId = facts.channelId;
11274
11364
  const spawn = (async () => {
11275
11365
  this.storeConversationGeneration(channelId, decision.generation);
11276
11366
  const agent = await this.createConversationAgent(decision.agentName, channelId);
11277
- this.conversationRouter!.bind(channelId, decision.agentName, decision.generation);
11367
+ this.conversationRouter!.bind({ serverId: facts.serverId, channelId }, decision.agentName, decision.generation);
11278
11368
  return agent;
11279
11369
  })();
11280
11370
  const done = (): void => { this.conversationSpawns.delete(channelId); };
@@ -11403,7 +11493,7 @@ export class AgentFramework {
11403
11493
  this.lastConversationSweep = now;
11404
11494
 
11405
11495
  for (const binding of this.conversationRouter.expired(now)) {
11406
- this.conversationRouter.unbind(binding.channelId);
11496
+ this.conversationRouter.unbind(binding);
11407
11497
  this.emitTrace({
11408
11498
  type: 'mcpl:conversation-closed',
11409
11499
  channelId: binding.channelId,
@@ -12072,7 +12162,7 @@ export class AgentFramework {
12072
12162
  if (explicit) return explicit.filter((n) => this.agents.has(n));
12073
12163
  if (occ.scope.kind === 'channel' && this.conversationRouter) {
12074
12164
  const router = this.conversationRouter;
12075
- const bound = router.getBinding(occ.scope.id)?.agentName;
12165
+ const bound = router.getBinding({ serverId: occ.serverId, channelId: occ.scope.id })?.agentName;
12076
12166
  if (bound) return this.agents.has(bound) ? [bound] : [];
12077
12167
  // No fork yet: the same bind decision an incoming message would get. A
12078
12168
  // batch that qualifies spawns its fork now so it has someone to wake.
@@ -12180,6 +12270,7 @@ export class AgentFramework {
12180
12270
  source: occ.serverId,
12181
12271
  timestamp: Date.now(),
12182
12272
  channelId,
12273
+ serverId: channelId !== undefined ? occ.serverId : undefined,
12183
12274
  counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
12184
12275
  addressed: isAddressedMessage(occ.tags, origin),
12185
12276
  coalescingSubject: subject,
@@ -12394,6 +12485,7 @@ export class AgentFramework {
12394
12485
  source: event.serverId,
12395
12486
  timestamp: Date.now(),
12396
12487
  channelId: triggerChannel?.channelId,
12488
+ serverId: triggerChannel ? event.serverId : undefined,
12397
12489
  // DMs and addressed-while-closed messages arrive as push events;
12398
12490
  // they must outrank ambient chatter in a batched wake's locus
12399
12491
  // selection just like their channels/incoming counterparts.
@@ -12416,17 +12508,49 @@ export class AgentFramework {
12416
12508
  * Whether a batched gate wake's addressed route may become `agentName`'s
12417
12509
  * speech locus. The gate broadcasts to every agent, conversation forks
12418
12510
  * included. A fork speaks only in its home channel, and a channel bound to
12419
- * a fork is that fork's conversation, so no other agent takes it.
12511
+ * a fork is that fork's conversation, so no other agent takes it. Two
12512
+ * servers' channels with one id are two conversations, each bound to its
12513
+ * own fork with that id as its home, so a fork takes the route only where
12514
+ * the router binds the route's channel, its server and id, to it; a route
12515
+ * with no server names a fork only where one fork is bound to that id.
12420
12516
  */
12421
- private mayTakeGateRoute(agentName: string, routeChannelId: string): boolean {
12517
+ private mayTakeGateRoute(agentName: string, routeChannelId: string, routeServerId?: string): boolean {
12422
12518
  const home = this.conversationAgentHomes.get(agentName);
12423
- if (home !== undefined) return home === routeChannelId;
12519
+ if (home !== undefined) {
12520
+ return home === routeChannelId
12521
+ && this.conversationRouter?.getBinding({ serverId: routeServerId, channelId: routeChannelId })?.agentName === agentName;
12522
+ }
12424
12523
  for (const boundChannel of this.conversationAgentHomes.values()) {
12425
12524
  if (boundChannel === routeChannelId) return false;
12426
12525
  }
12427
12526
  return true;
12428
12527
  }
12429
12528
 
12529
+ /**
12530
+ * The channel a push names, and a server that registers it, when another
12531
+ * server registers that channel id and the pushing server doesn't. A
12532
+ * channel's id is the agent's locus on its own, so the reply to such a
12533
+ * push would go out through the server that registers it: the push
12534
+ * boundary refuses it (PushHandler). A push naming a channel no server
12535
+ * registers isn't refused, and registers it for the pushing server
12536
+ * (ensureChannelRegistered).
12537
+ */
12538
+ private channelOfAnotherServer(
12539
+ serverId: string,
12540
+ origin: Record<string, unknown> | undefined,
12541
+ ): { channelId: string; holder: string } | undefined {
12542
+ if (!this.channelRegistry) return undefined;
12543
+ const channel = this.derivePushEventChannel(origin);
12544
+ if (!channel) return undefined;
12545
+ let holder: string | undefined;
12546
+ for (const entry of this.channelRegistry.listChannelsRaw()) {
12547
+ if (entry.descriptor.id !== channel.channelId) continue;
12548
+ if (entry.serverId === serverId) return undefined;
12549
+ holder ??= entry.serverId;
12550
+ }
12551
+ return holder === undefined ? undefined : { channelId: channel.channelId, holder };
12552
+ }
12553
+
12430
12554
  /**
12431
12555
  * Derive the MCPL composite channel id (the outbound routing locus) for a
12432
12556
  * push event from its server-defined `origin`, if it carries one.
@@ -12751,6 +12875,7 @@ export class AgentFramework {
12751
12875
  ephemeralSystemPrompt: silentOnly ? trigger?.ephemeralSystemPrompt : undefined,
12752
12876
  silentHeartbeat: silentOnly ? trigger?.silentHeartbeat : undefined,
12753
12877
  channelId: channelReq?.channelId,
12878
+ serverId: channelReq?.channelId !== undefined ? onlyServerFor(requests, channelReq.channelId) : undefined,
12754
12879
  addressed: addressedReq !== undefined,
12755
12880
  // A context-budget restart continues the same logical turn: it keeps
12756
12881
  // the channel for routing but names no author — the restart is its
@@ -13197,8 +13322,8 @@ export class AgentFramework {
13197
13322
  /** The drafts tool's answer to a resident's call, from the model's own
13198
13323
  * dispatch or an agent-origin call through executeToolCall (a puppet's):
13199
13324
  * a failure the handler throws is answered as one. */
13200
- private draftsToolResult(agentName: string, rawInput: unknown): Promise<ToolResult> {
13201
- return this.handleDraftsTool(agentName, rawInput).catch((err): ToolResult => ({
13325
+ private draftsToolResult(agentName: string, rawInput: unknown, callerTurnId?: number): Promise<ToolResult> {
13326
+ return this.handleDraftsTool(agentName, rawInput, callerTurnId).catch((err): ToolResult => ({
13202
13327
  success: false,
13203
13328
  error: `drafts failed: ${thrownText(err)}`,
13204
13329
  isError: true,
@@ -13211,7 +13336,7 @@ export class AgentFramework {
13211
13336
  * each draft is published verbatim, in order, through the normal publish
13212
13337
  * executor, its attempt journaled durably before the request leaves.
13213
13338
  */
13214
- private async handleDraftsTool(agentName: string, rawInput: unknown): Promise<ToolResult> {
13339
+ private async handleDraftsTool(agentName: string, rawInput: unknown, callerTurnId?: number): Promise<ToolResult> {
13215
13340
  const input = (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? rawInput : {}) as Record<string, unknown>;
13216
13341
  // The turn that sends: a resent draft's channel is recorded as engaged on
13217
13342
  // it, as a send tool's is, even when the result comes after the turn.
@@ -13378,7 +13503,17 @@ export class AgentFramework {
13378
13503
  const cand = resolved.candidates?.length ? ` It could mean: ${resolved.candidates.join(', ')}.` : '';
13379
13504
  return refuse(`Destination "${spec}" did not resolve: ${resolved.error}.${cand} Nothing was sent.`);
13380
13505
  }
13381
- const target = registry.resolveDestination({ channelId: resolved.channelId });
13506
+ // A resend into a channel the calling turn recorded a server for goes
13507
+ // through that server (triggerServerFor), where the call was made inside
13508
+ // that turn; a background script's call carries no turn.
13509
+ const callingRouting = this.turnRouting.get(agentName);
13510
+ const triggerServer = callerTurnId !== undefined && callingRouting?.turnId === callerTurnId
13511
+ ? triggerServerFor(callingRouting, resolved.channelId)
13512
+ : undefined;
13513
+ const target = registry.resolveDestination({
13514
+ ...(triggerServer !== undefined ? { serverId: triggerServer } : {}),
13515
+ channelId: resolved.channelId,
13516
+ });
13382
13517
  if ('error' in target) return refuse(`Destination "${spec}": ${target.error}. Nothing was sent.`);
13383
13518
  const destination = target.destination;
13384
13519
  if (home && destination.channelId !== home) {
@@ -13724,7 +13859,10 @@ export class AgentFramework {
13724
13859
  if (!open) return;
13725
13860
  if ((metadata as { tuneOut?: unknown } | undefined)?.tuneOut) return;
13726
13861
  const channelId = this.repinChannel(metadata, open);
13727
- if (channelId) open.arrivals.push({ messageId, channelId });
13862
+ if (channelId) {
13863
+ const serverId = messageServerId(metadata);
13864
+ open.arrivals.push({ messageId, channelId, ...(serverId !== undefined ? { serverId } : {}) });
13865
+ }
13728
13866
  }
13729
13867
 
13730
13868
  /**
@@ -13760,14 +13898,14 @@ export class AgentFramework {
13760
13898
  * message in the store; the last compile can't be the test, since an
13761
13899
  * arrival between the turn's streams isn't in it yet.
13762
13900
  */
13763
- private remainingArrival(agent: Agent, routing: TurnRouting): string | undefined {
13901
+ private remainingArrival(agent: Agent, routing: TurnRouting): Arrival | undefined {
13764
13902
  const cm = agent.getContextManager();
13765
13903
  for (let i = routing.arrivals.length - 1; i >= 0; i--) {
13766
- const { messageId, channelId } = routing.arrivals[i]!;
13904
+ const arrival = routing.arrivals[i]!;
13767
13905
  // The store answers null for a message that isn't there. A store that
13768
13906
  // can't be read throws here, as anywhere else in the start, rather
13769
13907
  // than have its candidates read as withdrawn.
13770
- if (cm.getMessage(messageId)) return channelId;
13908
+ if (cm.getMessage(arrival.messageId)) return arrival;
13771
13909
  }
13772
13910
  return undefined;
13773
13911
  }
@@ -13789,10 +13927,12 @@ export class AgentFramework {
13789
13927
  routing: TurnRouting,
13790
13928
  channelId: string,
13791
13929
  cause: string,
13930
+ /** The server of the message that moved it. */
13931
+ serverId?: string,
13792
13932
  ): { participant: string; content: ContentBlock[]; metadata: MessageMetadata } | undefined {
13793
13933
  if (routing.locus === channelId) return undefined;
13794
13934
  const prevPin = routing.locus ?? null;
13795
- routing.locus = channelId;
13935
+ setTurnLocus(routing, channelId, serverId);
13796
13936
  const label = this.channelRegistry?.getDescriptor(channelId)?.label;
13797
13937
  const shown = label && label !== channelId
13798
13938
  ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})`
@@ -14054,11 +14194,13 @@ export class AgentFramework {
14054
14194
  body: string,
14055
14195
  channelId: string,
14056
14196
  unsent: Draft | undefined,
14197
+ /** The server the channel resolves with (triggerServerFor). */
14198
+ serverId: string | undefined,
14057
14199
  ): Promise<PublishOutcome> {
14058
14200
  const registry = this.channelRegistry!;
14059
14201
  let attemptId: string | undefined;
14060
14202
  if (unsent) {
14061
- const target = registry.resolveDestination({ channelId });
14203
+ const target = registry.resolveDestination({ ...(serverId !== undefined ? { serverId } : {}), channelId });
14062
14204
  if (!('error' in target)) {
14063
14205
  try {
14064
14206
  attemptId = this.proseDrafts.beginAttempt(agent.name, unsent.id, target.destination, 'unsent-token', false);
@@ -14079,7 +14221,8 @@ export class AgentFramework {
14079
14221
  try {
14080
14222
  // Both callers hold a failed delivery's words as a bounce draft, whose
14081
14223
  // notice says what happened, so the registry writes no second marker.
14082
- outcome = await registry.deliverSpeech(agent.name, body, channelId, { heldOnFailure: true });
14224
+ outcome = await registry.deliverSpeech(agent.name, body, channelId,
14225
+ { heldOnFailure: true, ...(serverId !== undefined ? { locusServerId: serverId } : {}) });
14083
14226
  } finally {
14084
14227
  if (flight) {
14085
14228
  this.draftsInFlight.delete(flight);
@@ -14177,7 +14320,7 @@ export class AgentFramework {
14177
14320
  continue;
14178
14321
  }
14179
14322
  try {
14180
- const outcome = await this.deliverWithUnsent(agent, body, resolved.channelId, unsent.draft);
14323
+ const outcome = await this.deliverWithUnsent(agent, body, resolved.channelId, unsent.draft, triggerServerFor(routing, resolved.channelId));
14181
14324
  this.recordProseDelivery(routing, outcome.status === 'delivered' ? { delivered: true, channelId: outcome.destination!.channelId } : null);
14182
14325
  if (outcome.status === 'delivered') {
14183
14326
  this.proseBounceStreaks.delete(agent.name);
@@ -14219,7 +14362,7 @@ export class AgentFramework {
14219
14362
  }
14220
14363
  const target = sticky ?? locus;
14221
14364
  try {
14222
- const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, target);
14365
+ const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, target, speechServerOpts(routing, target));
14223
14366
  this.recordProseDelivery(routing, outcome);
14224
14367
  } catch (err) {
14225
14368
  console.error('hybrid locus delivery failed:', err);
@@ -14340,7 +14483,7 @@ export class AgentFramework {
14340
14483
  }
14341
14484
 
14342
14485
  try {
14343
- const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft);
14486
+ const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft, triggerServerFor(routing, targetChannel));
14344
14487
  if (outcome.status === 'delivered') {
14345
14488
  this.proseBounceStreaks.delete(name);
14346
14489
  } else if (outcome.status === 'failed') {
@@ -14931,6 +15074,9 @@ export class AgentFramework {
14931
15074
  // turn, which falls back to the global default rather than inheriting
14932
15075
  // a previous turn's channel.
14933
15076
  routing.triggerChannel = trigger?.channelId;
15077
+ routing.triggerOrigin = trigger?.channelId && trigger.serverId
15078
+ ? { channelId: trigger.channelId, serverId: trigger.serverId }
15079
+ : undefined;
14934
15080
  // FREEZE the turn's outbound locus now (turn-frozen routing): the home
14935
15081
  // channel for forks, else the trigger channel, else the global default.
14936
15082
  // Mid-turn injections must not redirect the agent's plain prose
@@ -14945,7 +15091,14 @@ export class AgentFramework {
14945
15091
  // stability).
14946
15092
  if (turnProseRouting !== 'explicit' && turnProseRouting !== 'disabled') {
14947
15093
  const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null;
14948
- if (locus !== null) routing.locus = locus;
15094
+ // A locus that is neither the trigger's channel nor the agent's home
15095
+ // is the global fallback: its server is the latest accepted
15096
+ // message's, taken now, with the locus.
15097
+ if (locus !== null) {
15098
+ setTurnLocus(routing, locus, locus !== routing.triggerChannel && !this.conversationAgentHomes.has(agent.name)
15099
+ ? this.channelRegistry?.fallbackServerFor(locus)
15100
+ : undefined);
15101
+ }
14949
15102
  if (attempt === 0) this.announceLocusIfChanged(agent.name, locus);
14950
15103
  }
14951
15104
  } else {
@@ -14958,7 +15111,7 @@ export class AgentFramework {
14958
15111
  ? this.remainingArrival(agent, routing)
14959
15112
  : undefined;
14960
15113
  routing.arrivals = [];
14961
- if (arrival) this.repinTurnLocus(agent, routing, arrival, 'arrival');
15114
+ if (arrival) this.repinTurnLocus(agent, routing, arrival.channelId, 'arrival', arrival.serverId);
14962
15115
  }
14963
15116
  // A silent start, or a disabled agent's, has no locus to show, whatever a
14964
15117
  // channel_open in a silent turn left on its object.
@@ -15438,7 +15591,7 @@ export class AgentFramework {
15438
15591
  };
15439
15592
  const enqueueSpeech = (text: string, locus: string | null): void => {
15440
15593
  chainSpeech(async () => {
15441
- const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus);
15594
+ const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus, speechServerOpts(streamRouting, locus));
15442
15595
  this.recordProseDelivery(streamRouting, outcome);
15443
15596
  }, 'mid-turn speech routing failed:');
15444
15597
  };
@@ -15558,6 +15711,7 @@ export class AgentFramework {
15558
15711
  for (const rd of deltas) {
15559
15712
  this.channelRegistry!.sendOutgoingChunk(
15560
15713
  rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta,
15714
+ triggerServerFor(streamRouting, rd.channelId),
15561
15715
  );
15562
15716
  }
15563
15717
  };
@@ -16363,7 +16517,7 @@ export class AgentFramework {
16363
16517
  );
16364
16518
  for (const segment of speechSegments) {
16365
16519
  try {
16366
- const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus);
16520
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus, speechServerOpts(streamRouting, locus));
16367
16521
  this.recordProseDelivery(streamRouting, outcome);
16368
16522
  } catch (err) {
16369
16523
  console.error('speech routing failed:', err);
@@ -16459,7 +16613,7 @@ export class AgentFramework {
16459
16613
  // Deliver sequentially (await each) so the segments land in order.
16460
16614
  for (const seg of segments) {
16461
16615
  try {
16462
- const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
16616
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus, speechServerOpts(streamRouting, locus));
16463
16617
  this.recordProseDelivery(streamRouting, outcome);
16464
16618
  } catch (err) {
16465
16619
  console.error('speech routing failed:', err);
@@ -17103,7 +17257,7 @@ export class AgentFramework {
17103
17257
  if (frameReachedTerminal && proseStream) {
17104
17258
  emitOutgoing(proseStream.finish());
17105
17259
  for (const [channelId, text] of proseStream.byChannel()) {
17106
- this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
17260
+ this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text, triggerServerFor(streamRouting, channelId));
17107
17261
  }
17108
17262
  }
17109
17263
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
@@ -17395,7 +17549,7 @@ export class AgentFramework {
17395
17549
  // agent origin and acts as that resident, as the model's own call does
17396
17550
  // through dispatchToolCall. A module holds no drafts.
17397
17551
  if (call.name === 'drafts' && this.channelRegistry && origin.kind === 'agent') {
17398
- return this.draftsToolResult(origin.agentName, call.input);
17552
+ return this.draftsToolResult(origin.agentName, call.input, call.callerTurnId);
17399
17553
  }
17400
17554
 
17401
17555
  // The utils meta-tool — programmatic callers (code_execution scripts,
@@ -18865,7 +19019,7 @@ export class AgentFramework {
18865
19019
  if (enrichedCall.name === 'drafts' && this.channelRegistry) {
18866
19020
  const startedAt = Date.now();
18867
19021
  this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'drafts', callId: enrichedCall.id, input: enrichedCall.input });
18868
- void this.draftsToolResult(agentName, enrichedCall.input)
19022
+ void this.draftsToolResult(agentName, enrichedCall.input, enrichedCall.callerTurnId)
18869
19023
  .then((result) => {
18870
19024
  this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'drafts', callId: enrichedCall.id, durationMs: Date.now() - startedAt });
18871
19025
  this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'framework', result });
@@ -20017,6 +20171,7 @@ export class AgentFramework {
20017
20171
  this.noteInboundAccepted(source);
20018
20172
  return source;
20019
20173
  },
20174
+ (serverId, params) => this.channelOfAnotherServer(serverId, params.origin),
20020
20175
  );
20021
20176
 
20022
20177
  // Server-initiated inference router (Step 6)
@@ -22475,8 +22630,17 @@ export class AgentFramework {
22475
22630
  // ordinary wake, starts only once its over-budget boundary has every
22476
22631
  // result of the round.
22477
22632
  const callingTurn = this.routingOf(agentName);
22633
+ const inTurn = call.callerTurnId !== undefined && call.callerTurnId === callingTurn.turnId;
22478
22634
 
22479
- this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName })
22635
+ this.channelRegistry!.handleChannelToolCall(call.name, call.input, {
22636
+ kind: 'agent',
22637
+ agentName,
22638
+ // The turn's recorded servers go only with a call made inside that
22639
+ // turn: a background script's call carries no turn, and takes a bare
22640
+ // id only where exactly one server registers it.
22641
+ ...(inTurn && callingTurn.triggerOrigin !== undefined ? { trigger: callingTurn.triggerOrigin } : {}),
22642
+ ...(inTurn && callingTurn.locusOrigin !== undefined ? { locus: callingTurn.locusOrigin } : {}),
22643
+ })
22480
22644
  .then((result) => {
22481
22645
  const durationMs = Date.now() - startTime;
22482
22646
  this.emitTrace({
@@ -22514,7 +22678,13 @@ export class AgentFramework {
22514
22678
  if (openerAgent
22515
22679
  && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')
22516
22680
  && this.openLogical.get(agentName) === callingTurn) {
22517
- callingTurn.locus = opened;
22681
+ const openedOn = this.channelRegistry!.resolveDestination({
22682
+ ...(typeof (call.input as { serverId?: unknown } | undefined)?.serverId === 'string'
22683
+ ? { serverId: (call.input as { serverId: string }).serverId }
22684
+ : {}),
22685
+ channelId: opened,
22686
+ });
22687
+ setTurnLocus(callingTurn, opened, 'error' in openedOn ? undefined : openedOn.destination.serverId);
22518
22688
  this.lastAnnouncedLocus.set(agentName, opened);
22519
22689
  result = {
22520
22690
  ...result,
@@ -22788,17 +22958,21 @@ export class AgentFramework {
22788
22958
  } else if (agent?.proseRouting === 'explicit') {
22789
22959
  // Explicit mode: announce to the turn's sticky prose target if the
22790
22960
  // model has set one; otherwise stay quiet — never guess a channel.
22791
- const target = this.turnRouting.get(agentName)?.pin;
22961
+ const routing = this.turnRouting.get(agentName);
22962
+ const target = routing?.pin;
22792
22963
  if (target) {
22793
- this.channelRegistry.routeSpeech(agentName, text, target).catch((err) => {
22964
+ this.channelRegistry.routeSpeech(agentName, text, target,
22965
+ speechServerOpts(call.callerTurnId === routing?.turnId ? routing : undefined, target)).catch((err) => {
22794
22966
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
22795
22967
  });
22796
22968
  } else {
22797
22969
  console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`);
22798
22970
  }
22799
22971
  } else {
22800
- const locus = this.turnRouting.get(agentName)?.locus ?? null;
22801
- this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => {
22972
+ const routing = this.turnRouting.get(agentName);
22973
+ const locus = routing?.locus ?? null;
22974
+ this.channelRegistry.routeSpeech(agentName, text, locus,
22975
+ speechServerOpts(call.callerTurnId === routing?.turnId ? routing : undefined, locus)).catch((err) => {
22802
22976
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
22803
22977
  });
22804
22978
  }
@@ -121,6 +121,9 @@ export interface WakeProvenance {
121
121
  * (2026-09-30: a DM reply published into a guild channel). Ambient-only
122
122
  * batches still set nothing and keep the legacy fallback. */
123
123
  routeChannelId?: string;
124
+ /** The server whose event `routeChannelId` came from: the turn's replies
125
+ * to that channel go out through this server only. */
126
+ routeServerId?: string;
124
127
  /** Timestamp (ms) of the chosen event, so a consumer coalescing several
125
128
  * requests can order by event recency, not by flush order. */
126
129
  at?: number;
@@ -226,7 +229,9 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin
226
229
  ...(channelId ? { channelId } : {}),
227
230
  ...(pick.authorId ? { counterparty: `${ns}:user:${pick.authorId}` } : {}),
228
231
  ...(pick.addressed ? { addressed: true } : {}),
229
- ...(pick.addressed && pick.routeChannelId ? { routeChannelId: pick.routeChannelId } : {}),
232
+ ...(pick.addressed && pick.routeChannelId
233
+ ? { routeChannelId: pick.routeChannelId, ...(pick.serverId ? { routeServerId: pick.serverId } : {}) }
234
+ : {}),
230
235
  at: pick.timestamp,
231
236
  };
232
237
  }