@animalabs/agent-framework 0.23.2 → 0.23.3

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
@@ -801,6 +801,31 @@ interface TurnRouting {
801
801
  * sent Sol's DM reply to a stale guild channel (2026-07-22).
802
802
  */
803
803
  triggerChannel?: string;
804
+ /**
805
+ * The trigger's channel with the server whose message it was, frozen when
806
+ * the turn opens and never moved: a channel_open moves triggerChannel and
807
+ * a re-pin moves the locus, but neither carries this server with it. Every
808
+ * send of the turn whose destination id is this channel resolves with this
809
+ * server (triggerServerFor): speech, a channel_publish naming it or taking
810
+ * it as its default, a drafts resend, and the refusal reaction. So a
811
+ * message one server sent can't have its reply go out through another
812
+ * server that registers the same id, whatever the registry holds by then;
813
+ * the reply is refused where its server no longer registers the channel.
814
+ * Unset for a turn with no channel trigger, and where the requests batched
815
+ * into the trigger name its channel from more than one server: a bare id
816
+ * then resolves only where exactly one server registers it.
817
+ */
818
+ triggerOrigin?: { channelId: string; serverId: string };
819
+ /**
820
+ * The locus with the server of the message that put it there, where that
821
+ * isn't the trigger: the latest accepted message's (the global fallback a
822
+ * turn with no trigger channel takes), an arrival's or a mid-turn
823
+ * message's (a re-pin), or the server a channel_open opened it on. Set
824
+ * with the locus (setTurnLocus), and cleared when the locus moves to a
825
+ * channel no one server put there. A send into this channel resolves with
826
+ * this server, as one into the trigger's channel does with the trigger's.
827
+ */
828
+ locusOrigin?: { channelId: string; serverId: string };
804
829
  /**
805
830
  * Locus and hybrid mode: where the turn's unpinned plain prose lands.
806
831
  * Frozen when the turn opens (home channel for a fork, else the trigger
@@ -877,11 +902,60 @@ interface Arrival {
877
902
  messageId: MessageId;
878
903
  /** The channel it re-pins the locus to. */
879
904
  channelId: string;
905
+ /** The server whose message it is. */
906
+ serverId?: string;
907
+ }
908
+
909
+ /** The server a stored channel message came from, as the host wrote it. */
910
+ function messageServerId(metadata: MessageMetadata | undefined): string | undefined {
911
+ const serverId = (metadata as { serverId?: unknown } | undefined)?.serverId;
912
+ return typeof serverId === 'string' && serverId ? serverId : undefined;
880
913
  }
881
914
 
882
915
  /** The last TurnRouting.turnId given out: ids increase, and are unique within the process. */
883
916
  let lastTurnId = 0;
884
917
 
918
+ /**
919
+ * The server every request naming `channelId` came from, or undefined where
920
+ * one names no server or they name more than one (TurnRouting.triggerOrigin).
921
+ */
922
+ function onlyServerFor(requests: readonly InferenceRequest[], channelId: string): string | undefined {
923
+ let serverId: string | undefined;
924
+ for (const r of requests) {
925
+ if (r.channelId !== channelId) continue;
926
+ if (!r.serverId || (serverId !== undefined && r.serverId !== serverId)) return undefined;
927
+ serverId = r.serverId;
928
+ }
929
+ return serverId;
930
+ }
931
+
932
+ /**
933
+ * The server a send in `routing`'s turn resolves `channelId` with: the
934
+ * trigger's, where `channelId` is the trigger's channel
935
+ * (TurnRouting.triggerOrigin), else the locus's, where it's the channel the
936
+ * locus's server put there (TurnRouting.locusOrigin).
937
+ */
938
+ function triggerServerFor(routing: TurnRouting | undefined, channelId: string | null | undefined): string | undefined {
939
+ if (routing === undefined || !channelId) return undefined;
940
+ if (routing.triggerOrigin?.channelId === channelId) return routing.triggerOrigin.serverId;
941
+ if (routing.locusOrigin?.channelId === channelId) return routing.locusOrigin.serverId;
942
+ return undefined;
943
+ }
944
+
945
+ /** Move a turn's locus, with the server of the message that put it there
946
+ * (TurnRouting.locusOrigin), or none. */
947
+ function setTurnLocus(routing: TurnRouting, channelId: string, serverId: string | undefined): void {
948
+ routing.locus = channelId;
949
+ routing.locusOrigin = serverId !== undefined ? { channelId, serverId } : undefined;
950
+ }
951
+
952
+ /** routeSpeech's options for speech into `channelId` in `routing`'s turn:
953
+ * the trigger's server, where it's the trigger's channel (triggerServerFor). */
954
+ function speechServerOpts(routing: TurnRouting | undefined, channelId: string | null | undefined): { locusServerId: string } | undefined {
955
+ const serverId = triggerServerFor(routing, channelId);
956
+ return serverId !== undefined ? { locusServerId: serverId } : undefined;
957
+ }
958
+
885
959
  function freshTurnRouting(): TurnRouting {
886
960
  return {
887
961
  turnId: ++lastTurnId,
@@ -2951,8 +3025,8 @@ export class AgentFramework {
2951
3025
  // route applies to a fork only when it IS that home, and never to
2952
3026
  // another agent when a fork owns that channel. Otherwise a fork
2953
3027
  // bound elsewhere would show typing where it isn't replying.
2954
- ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId)
2955
- ? { channelId: provenance.routeChannelId, addressed: true }
3028
+ ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId, provenance.routeServerId)
3029
+ ? { channelId: provenance.routeChannelId, serverId: provenance.routeServerId, addressed: true }
2956
3030
  : {}),
2957
3031
  });
2958
3032
  },
@@ -8508,7 +8582,9 @@ export class AgentFramework {
8508
8582
  // tools/call directly on its connection (bare tool name — no prefix
8509
8583
  // games), bypassing the agent event queue so no synthetic tool-result
8510
8584
  // is injected into a turn the agent never took.
8511
- const serverId = this.channelRegistry?.getChannelServerId(incoming.channelId);
8585
+ // Through the server whose message it marks: the latest accepted
8586
+ // message's, kept beside its channel and id (fallbackServerFor).
8587
+ const serverId = this.channelRegistry?.fallbackServerFor(incoming.channelId);
8512
8588
  const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
8513
8589
  if (!server) {
8514
8590
  console.error(
@@ -11050,12 +11126,19 @@ export class AgentFramework {
11050
11126
  // The last injection that qualifies wins (repinChannel).
11051
11127
  const routing = this.routingOf(agent.name);
11052
11128
  let newLocus: string | undefined;
11053
- for (const inj of midTurnInjections) newLocus = this.repinChannel(inj.metadata, routing) ?? newLocus;
11129
+ let newLocusServer: string | undefined;
11130
+ for (const inj of midTurnInjections) {
11131
+ const channelId = this.repinChannel(inj.metadata, routing);
11132
+ if (channelId) {
11133
+ newLocus = channelId;
11134
+ newLocusServer = messageServerId(inj.metadata);
11135
+ }
11136
+ }
11054
11137
  if (newLocus) {
11055
11138
  // What this boundary just stored is newer than any arrival the
11056
11139
  // turn holds, so the arrivals can't move the locus back.
11057
11140
  routing.arrivals = [];
11058
- const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed');
11141
+ const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed', newLocusServer);
11059
11142
  if (notice) midTurnInjections.push(notice);
11060
11143
  }
11061
11144
  } else if (routesLocus && !shouldEndTurn) {
@@ -11440,14 +11523,15 @@ export class AgentFramework {
11440
11523
  const id = target.getContextManager().addMessage('user', event.content, metadata);
11441
11524
  // A correction may target an older engagement. Refresh only the
11442
11525
  // binding whose agent actually received the message.
11443
- if (this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) {
11444
- this.conversationRouter.touch(event.channelId);
11526
+ const channel = { serverId: event.serverId, channelId: event.channelId };
11527
+ if (this.conversationRouter.getBinding(channel)?.agentName === target.name) {
11528
+ this.conversationRouter.touch(channel);
11445
11529
  }
11446
11530
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
11447
11531
  if (event.triggerInference) {
11448
11532
  this.pendingRequests.push({
11449
11533
  agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
11450
- channelId: event.channelId,
11534
+ channelId: event.channelId, serverId: event.serverId,
11451
11535
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
11452
11536
  addressed: isAddressedMessage(event.tags, event.metadata),
11453
11537
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
@@ -11526,8 +11610,10 @@ export class AgentFramework {
11526
11610
  source: event.serverId,
11527
11611
  timestamp: Date.now(),
11528
11612
  // Route this turn's auto-published speech back to THIS channel, not
11529
- // the global most-recent-inbound locus (item-3 redux, trunk agents).
11613
+ // the global most-recent-inbound locus (item-3 redux, trunk agents),
11614
+ // through the server that sent it.
11530
11615
  channelId: event.channelId,
11616
+ serverId: event.serverId,
11531
11617
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
11532
11618
  // Addressed messages outrank ambient chatter when a batched wake
11533
11619
  // picks the turn's frozen speech locus.
@@ -11605,7 +11691,7 @@ export class AgentFramework {
11605
11691
  if (!agent) {
11606
11692
  // Bound agent vanished (e.g. external reset) — unbind so the next
11607
11693
  // qualifying message respawns a fresh fork.
11608
- router.unbind(event.channelId);
11694
+ router.unbind({ serverId: event.serverId, channelId: event.channelId });
11609
11695
  this.emitTrace({
11610
11696
  type: 'mcpl:conversation-binding-orphaned',
11611
11697
  channelId: event.channelId,
@@ -11621,7 +11707,7 @@ export class AgentFramework {
11621
11707
  messageMetadata.triggered = trigger;
11622
11708
 
11623
11709
  const id = agent.getContextManager().addMessage('user', event.content, messageMetadata);
11624
- router.touch(event.channelId);
11710
+ router.touch({ serverId: event.serverId, channelId: event.channelId });
11625
11711
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
11626
11712
 
11627
11713
  if (trigger) {
@@ -11633,6 +11719,7 @@ export class AgentFramework {
11633
11719
  // A fork's home channel wins in routeSpeech regardless, but carry the
11634
11720
  // triggering channel too so the trunk/active path stays consistent.
11635
11721
  channelId: event.channelId,
11722
+ serverId: event.serverId,
11636
11723
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
11637
11724
  addressed: isAddressedMessage(event.tags, event.metadata),
11638
11725
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
@@ -11777,7 +11864,7 @@ export class AgentFramework {
11777
11864
  }
11778
11865
  const decision = this.conversationRouter!.route(facts);
11779
11866
  if (decision.kind !== 'spawn') return decision;
11780
- return { ...decision, spawned: this.spawnConversationAgent(facts.channelId, decision) };
11867
+ return { ...decision, spawned: this.spawnConversationAgent(facts, decision) };
11781
11868
  }
11782
11869
  }
11783
11870
 
@@ -11793,11 +11880,12 @@ export class AgentFramework {
11793
11880
  * so the entry it removes is its own. It's removed before anyone waiting
11794
11881
  * on it resumes, so a waiter that decides again doesn't find it.
11795
11882
  */
11796
- private spawnConversationAgent(channelId: string, decision: { agentName: string; generation: number }): Promise<Agent> {
11883
+ private spawnConversationAgent(facts: IncomingMessageFacts, decision: { agentName: string; generation: number }): Promise<Agent> {
11884
+ const channelId = facts.channelId;
11797
11885
  const spawn = (async () => {
11798
11886
  this.storeConversationGeneration(channelId, decision.generation);
11799
11887
  const agent = await this.createConversationAgent(decision.agentName, channelId);
11800
- this.conversationRouter!.bind(channelId, decision.agentName, decision.generation);
11888
+ this.conversationRouter!.bind({ serverId: facts.serverId, channelId }, decision.agentName, decision.generation);
11801
11889
  return agent;
11802
11890
  })();
11803
11891
  const done = (): void => { this.conversationSpawns.delete(channelId); };
@@ -11927,7 +12015,7 @@ export class AgentFramework {
11927
12015
  this.lastConversationSweep = now;
11928
12016
 
11929
12017
  for (const binding of this.conversationRouter.expired(now)) {
11930
- this.conversationRouter.unbind(binding.channelId);
12018
+ this.conversationRouter.unbind(binding);
11931
12019
  this.emitTrace({
11932
12020
  type: 'mcpl:conversation-closed',
11933
12021
  channelId: binding.channelId,
@@ -12607,7 +12695,7 @@ export class AgentFramework {
12607
12695
  if (explicit) return explicit.filter((n) => this.agents.has(n));
12608
12696
  if (occ.scope.kind === 'channel' && this.conversationRouter) {
12609
12697
  const router = this.conversationRouter;
12610
- const bound = router.getBinding(occ.scope.id)?.agentName;
12698
+ const bound = router.getBinding({ serverId: occ.serverId, channelId: occ.scope.id })?.agentName;
12611
12699
  if (bound) return this.agents.has(bound) ? [bound] : [];
12612
12700
  // No fork yet: the same bind decision an incoming message would get. A
12613
12701
  // batch that qualifies spawns its fork now so it has someone to wake.
@@ -12723,6 +12811,7 @@ export class AgentFramework {
12723
12811
  source: occ.serverId,
12724
12812
  timestamp: Date.now(),
12725
12813
  channelId,
12814
+ serverId: channelId !== undefined ? occ.serverId : undefined,
12726
12815
  counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
12727
12816
  addressed: isAddressedMessage(occ.tags, origin),
12728
12817
  ...(plain ? {} : { coalescingSubject: subject, coalescingBatch: true }),
@@ -12936,6 +13025,7 @@ export class AgentFramework {
12936
13025
  source: event.serverId,
12937
13026
  timestamp: Date.now(),
12938
13027
  channelId: triggerChannel?.channelId,
13028
+ serverId: triggerChannel ? event.serverId : undefined,
12939
13029
  // DMs and addressed-while-closed messages arrive as push events;
12940
13030
  // they must outrank ambient chatter in a batched wake's locus
12941
13031
  // selection just like their channels/incoming counterparts.
@@ -12960,17 +13050,49 @@ export class AgentFramework {
12960
13050
  * Whether a batched gate wake's addressed route may become `agentName`'s
12961
13051
  * speech locus. The gate broadcasts to every agent, conversation forks
12962
13052
  * included. A fork speaks only in its home channel, and a channel bound to
12963
- * a fork is that fork's conversation, so no other agent takes it.
13053
+ * a fork is that fork's conversation, so no other agent takes it. Two
13054
+ * servers' channels with one id are two conversations, each bound to its
13055
+ * own fork with that id as its home, so a fork takes the route only where
13056
+ * the router binds the route's channel, its server and id, to it; a route
13057
+ * with no server names a fork only where one fork is bound to that id.
12964
13058
  */
12965
- private mayTakeGateRoute(agentName: string, routeChannelId: string): boolean {
13059
+ private mayTakeGateRoute(agentName: string, routeChannelId: string, routeServerId?: string): boolean {
12966
13060
  const home = this.conversationAgentHomes.get(agentName);
12967
- if (home !== undefined) return home === routeChannelId;
13061
+ if (home !== undefined) {
13062
+ return home === routeChannelId
13063
+ && this.conversationRouter?.getBinding({ serverId: routeServerId, channelId: routeChannelId })?.agentName === agentName;
13064
+ }
12968
13065
  for (const boundChannel of this.conversationAgentHomes.values()) {
12969
13066
  if (boundChannel === routeChannelId) return false;
12970
13067
  }
12971
13068
  return true;
12972
13069
  }
12973
13070
 
13071
+ /**
13072
+ * The channel a push names, and a server that registers it, when another
13073
+ * server registers that channel id and the pushing server doesn't. A
13074
+ * channel's id is the agent's locus on its own, so the reply to such a
13075
+ * push would go out through the server that registers it: the push
13076
+ * boundary refuses it (PushHandler). A push naming a channel no server
13077
+ * registers isn't refused, and registers it for the pushing server
13078
+ * (ensureChannelRegistered).
13079
+ */
13080
+ private channelOfAnotherServer(
13081
+ serverId: string,
13082
+ origin: Record<string, unknown> | undefined,
13083
+ ): { channelId: string; holder: string } | undefined {
13084
+ if (!this.channelRegistry) return undefined;
13085
+ const channel = this.derivePushEventChannel(origin);
13086
+ if (!channel) return undefined;
13087
+ let holder: string | undefined;
13088
+ for (const entry of this.channelRegistry.listChannelsRaw()) {
13089
+ if (entry.descriptor.id !== channel.channelId) continue;
13090
+ if (entry.serverId === serverId) return undefined;
13091
+ holder ??= entry.serverId;
13092
+ }
13093
+ return holder === undefined ? undefined : { channelId: channel.channelId, holder };
13094
+ }
13095
+
12974
13096
  /**
12975
13097
  * Derive the MCPL composite channel id (the outbound routing locus) for a
12976
13098
  * push event from its server-defined `origin`, if it carries one.
@@ -13302,6 +13424,7 @@ export class AgentFramework {
13302
13424
  suppressProse: silentOnly ? trigger?.suppressProse : undefined,
13303
13425
  silentHeartbeat: silentOnly ? trigger?.silentHeartbeat : undefined,
13304
13426
  channelId: channelReq?.channelId,
13427
+ serverId: channelReq?.channelId !== undefined ? onlyServerFor(requests, channelReq.channelId) : undefined,
13305
13428
  addressed: addressedReq !== undefined,
13306
13429
  // A context-budget restart continues the same logical turn: it keeps
13307
13430
  // the channel for routing but names no author — the restart is its
@@ -13769,8 +13892,8 @@ export class AgentFramework {
13769
13892
  /** The drafts tool's answer to a resident's call, from the model's own
13770
13893
  * dispatch or an agent-origin call through executeToolCall (a puppet's):
13771
13894
  * a failure the handler throws is answered as one. */
13772
- private draftsToolResult(agentName: string, rawInput: unknown): Promise<ToolResult> {
13773
- return this.handleDraftsTool(agentName, rawInput).catch((err): ToolResult => ({
13895
+ private draftsToolResult(agentName: string, rawInput: unknown, callerTurnId?: number): Promise<ToolResult> {
13896
+ return this.handleDraftsTool(agentName, rawInput, callerTurnId).catch((err): ToolResult => ({
13774
13897
  success: false,
13775
13898
  error: `drafts failed: ${thrownText(err)}`,
13776
13899
  isError: true,
@@ -13783,7 +13906,7 @@ export class AgentFramework {
13783
13906
  * each draft is published verbatim, in order, through the normal publish
13784
13907
  * executor, its attempt journaled durably before the request leaves.
13785
13908
  */
13786
- private async handleDraftsTool(agentName: string, rawInput: unknown): Promise<ToolResult> {
13909
+ private async handleDraftsTool(agentName: string, rawInput: unknown, callerTurnId?: number): Promise<ToolResult> {
13787
13910
  const input = (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? rawInput : {}) as Record<string, unknown>;
13788
13911
  // The turn that sends: a resent draft's channel is recorded as engaged on
13789
13912
  // it, as a send tool's is, even when the result comes after the turn.
@@ -13950,7 +14073,17 @@ export class AgentFramework {
13950
14073
  const cand = resolved.candidates?.length ? ` It could mean: ${resolved.candidates.join(', ')}.` : '';
13951
14074
  return refuse(`Destination "${spec}" did not resolve: ${resolved.error}.${cand} Nothing was sent.`);
13952
14075
  }
13953
- const target = registry.resolveDestination({ channelId: resolved.channelId });
14076
+ // A resend into a channel the calling turn recorded a server for goes
14077
+ // through that server (triggerServerFor), where the call was made inside
14078
+ // that turn; a background script's call carries no turn.
14079
+ const callingRouting = this.turnRouting.get(agentName);
14080
+ const triggerServer = callerTurnId !== undefined && callingRouting?.turnId === callerTurnId
14081
+ ? triggerServerFor(callingRouting, resolved.channelId)
14082
+ : undefined;
14083
+ const target = registry.resolveDestination({
14084
+ ...(triggerServer !== undefined ? { serverId: triggerServer } : {}),
14085
+ channelId: resolved.channelId,
14086
+ });
13954
14087
  if ('error' in target) return refuse(`Destination "${spec}": ${target.error}. Nothing was sent.`);
13955
14088
  const destination = target.destination;
13956
14089
  if (home && destination.channelId !== home) {
@@ -14303,7 +14436,10 @@ export class AgentFramework {
14303
14436
  if (!open) return;
14304
14437
  if ((metadata as { tuneOut?: unknown } | undefined)?.tuneOut) return;
14305
14438
  const channelId = this.repinChannel(metadata, open);
14306
- if (channelId) open.arrivals.push({ messageId, channelId });
14439
+ if (channelId) {
14440
+ const serverId = messageServerId(metadata);
14441
+ open.arrivals.push({ messageId, channelId, ...(serverId !== undefined ? { serverId } : {}) });
14442
+ }
14307
14443
  }
14308
14444
 
14309
14445
  /**
@@ -14339,14 +14475,14 @@ export class AgentFramework {
14339
14475
  * message in the store; the last compile can't be the test, since an
14340
14476
  * arrival between the turn's streams isn't in it yet.
14341
14477
  */
14342
- private remainingArrival(agent: Agent, routing: TurnRouting): string | undefined {
14478
+ private remainingArrival(agent: Agent, routing: TurnRouting): Arrival | undefined {
14343
14479
  const cm = agent.getContextManager();
14344
14480
  for (let i = routing.arrivals.length - 1; i >= 0; i--) {
14345
- const { messageId, channelId } = routing.arrivals[i]!;
14481
+ const arrival = routing.arrivals[i]!;
14346
14482
  // The store answers null for a message that isn't there. A store that
14347
14483
  // can't be read throws here, as anywhere else in the start, rather
14348
14484
  // than have its candidates read as withdrawn.
14349
- if (cm.getMessage(messageId)) return channelId;
14485
+ if (cm.getMessage(arrival.messageId)) return arrival;
14350
14486
  }
14351
14487
  return undefined;
14352
14488
  }
@@ -14368,10 +14504,12 @@ export class AgentFramework {
14368
14504
  routing: TurnRouting,
14369
14505
  channelId: string,
14370
14506
  cause: string,
14507
+ /** The server of the message that moved it. */
14508
+ serverId?: string,
14371
14509
  ): { participant: string; content: ContentBlock[]; metadata: MessageMetadata } | undefined {
14372
14510
  if (routing.locus === channelId) return undefined;
14373
14511
  const prevPin = routing.locus ?? null;
14374
- routing.locus = channelId;
14512
+ setTurnLocus(routing, channelId, serverId);
14375
14513
  const label = this.channelRegistry?.getDescriptor(channelId)?.label;
14376
14514
  const shown = label && label !== channelId
14377
14515
  ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})`
@@ -14657,11 +14795,13 @@ export class AgentFramework {
14657
14795
  body: string,
14658
14796
  channelId: string,
14659
14797
  unsent: Draft | undefined,
14798
+ /** The server the channel resolves with (triggerServerFor). */
14799
+ serverId: string | undefined,
14660
14800
  ): Promise<PublishOutcome> {
14661
14801
  const registry = this.channelRegistry!;
14662
14802
  let attemptId: string | undefined;
14663
14803
  if (unsent) {
14664
- const target = registry.resolveDestination({ channelId });
14804
+ const target = registry.resolveDestination({ ...(serverId !== undefined ? { serverId } : {}), channelId });
14665
14805
  if (!('error' in target)) {
14666
14806
  try {
14667
14807
  attemptId = this.proseDrafts.beginAttempt(agent.name, unsent.id, target.destination, 'unsent-token', false);
@@ -14682,7 +14822,8 @@ export class AgentFramework {
14682
14822
  try {
14683
14823
  // Both callers hold a failed delivery's words as a bounce draft, whose
14684
14824
  // notice says what happened, so the registry writes no second marker.
14685
- outcome = await registry.deliverSpeech(agent.name, body, channelId, { heldOnFailure: true });
14825
+ outcome = await registry.deliverSpeech(agent.name, body, channelId,
14826
+ { heldOnFailure: true, ...(serverId !== undefined ? { locusServerId: serverId } : {}) });
14686
14827
  } finally {
14687
14828
  if (flight) {
14688
14829
  this.draftsInFlight.delete(flight);
@@ -14781,7 +14922,7 @@ export class AgentFramework {
14781
14922
  continue;
14782
14923
  }
14783
14924
  try {
14784
- const outcome = await this.deliverWithUnsent(agent, body, resolved.channelId, unsent.draft);
14925
+ const outcome = await this.deliverWithUnsent(agent, body, resolved.channelId, unsent.draft, triggerServerFor(routing, resolved.channelId));
14785
14926
  this.recordProseDelivery(routing, outcome.status === 'delivered' ? { delivered: true, channelId: outcome.destination!.channelId } : null);
14786
14927
  if (outcome.status === 'delivered') {
14787
14928
  this.proseBounceStreaks.delete(agent.name);
@@ -14823,7 +14964,7 @@ export class AgentFramework {
14823
14964
  }
14824
14965
  const target = sticky ?? locus;
14825
14966
  try {
14826
- const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, target);
14967
+ const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, target, speechServerOpts(routing, target));
14827
14968
  this.recordProseDelivery(routing, outcome);
14828
14969
  } catch (err) {
14829
14970
  console.error('hybrid locus delivery failed:', err);
@@ -14946,7 +15087,7 @@ export class AgentFramework {
14946
15087
  }
14947
15088
 
14948
15089
  try {
14949
- const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft);
15090
+ const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft, triggerServerFor(routing, targetChannel));
14950
15091
  if (outcome.status === 'delivered') {
14951
15092
  this.proseBounceStreaks.delete(name);
14952
15093
  } else if (outcome.status === 'failed') {
@@ -15662,6 +15803,9 @@ export class AgentFramework {
15662
15803
  // turn, which falls back to the global default rather than inheriting
15663
15804
  // a previous turn's channel.
15664
15805
  routing.triggerChannel = trigger?.channelId;
15806
+ routing.triggerOrigin = trigger?.channelId && trigger.serverId
15807
+ ? { channelId: trigger.channelId, serverId: trigger.serverId }
15808
+ : undefined;
15665
15809
  // FREEZE the turn's outbound locus now (turn-frozen routing): the home
15666
15810
  // channel for forks, else the trigger channel, else the global default,
15667
15811
  // which an ephemeral run never takes (takesGlobalFallback).
@@ -15677,7 +15821,14 @@ export class AgentFramework {
15677
15821
  // stability).
15678
15822
  if (turnProseRouting !== 'explicit' && turnProseRouting !== 'disabled') {
15679
15823
  const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null;
15680
- if (locus !== null) routing.locus = locus;
15824
+ // A locus that is neither the trigger's channel nor the agent's home
15825
+ // is the global fallback: its server is the latest accepted
15826
+ // message's, taken now, with the locus.
15827
+ if (locus !== null) {
15828
+ setTurnLocus(routing, locus, locus !== routing.triggerChannel && !this.conversationAgentHomes.has(agent.name)
15829
+ ? this.channelRegistry?.fallbackServerFor(locus)
15830
+ : undefined);
15831
+ }
15681
15832
  if (attempt === 0) this.announceLocusIfChanged(agent.name, locus);
15682
15833
  }
15683
15834
  } else {
@@ -15690,7 +15841,7 @@ export class AgentFramework {
15690
15841
  ? this.remainingArrival(agent, routing)
15691
15842
  : undefined;
15692
15843
  routing.arrivals = [];
15693
- if (arrival) this.repinTurnLocus(agent, routing, arrival, 'arrival');
15844
+ if (arrival) this.repinTurnLocus(agent, routing, arrival.channelId, 'arrival', arrival.serverId);
15694
15845
  }
15695
15846
  // A silent start, or a disabled agent's, has no locus to show, whatever a
15696
15847
  // channel_open in a silent turn left on its object.
@@ -16243,7 +16394,7 @@ export class AgentFramework {
16243
16394
  };
16244
16395
  const enqueueSpeech = (text: string, locus: string | null): void => {
16245
16396
  chainSpeech(async () => {
16246
- const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus);
16397
+ const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus, speechServerOpts(streamRouting, locus));
16247
16398
  this.recordProseDelivery(streamRouting, outcome);
16248
16399
  }, 'mid-turn speech routing failed:');
16249
16400
  };
@@ -16366,6 +16517,7 @@ export class AgentFramework {
16366
16517
  for (const rd of deltas) {
16367
16518
  this.channelRegistry!.sendOutgoingChunk(
16368
16519
  rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta,
16520
+ triggerServerFor(streamRouting, rd.channelId),
16369
16521
  );
16370
16522
  }
16371
16523
  };
@@ -17288,7 +17440,7 @@ export class AgentFramework {
17288
17440
  );
17289
17441
  for (const segment of speechSegments) {
17290
17442
  try {
17291
- const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus);
17443
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus, speechServerOpts(streamRouting, locus));
17292
17444
  this.recordProseDelivery(streamRouting, outcome);
17293
17445
  } catch (err) {
17294
17446
  console.error('speech routing failed:', err);
@@ -17385,7 +17537,7 @@ export class AgentFramework {
17385
17537
  // Deliver sequentially (await each) so the segments land in order.
17386
17538
  for (const seg of segments) {
17387
17539
  try {
17388
- const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
17540
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus, speechServerOpts(streamRouting, locus));
17389
17541
  this.recordProseDelivery(streamRouting, outcome);
17390
17542
  } catch (err) {
17391
17543
  console.error('speech routing failed:', err);
@@ -18043,7 +18195,7 @@ export class AgentFramework {
18043
18195
  if (frameReachedTerminal && proseStream) {
18044
18196
  emitOutgoing(proseStream.finish());
18045
18197
  for (const [channelId, text] of proseStream.byChannel()) {
18046
- this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
18198
+ this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text, triggerServerFor(streamRouting, channelId));
18047
18199
  }
18048
18200
  }
18049
18201
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
@@ -18347,7 +18499,7 @@ export class AgentFramework {
18347
18499
  // agent origin and acts as that resident, as the model's own call does
18348
18500
  // through dispatchToolCall. A module holds no drafts.
18349
18501
  if (call.name === 'drafts' && this.channelRegistry && origin.kind === 'agent') {
18350
- return this.draftsToolResult(origin.agentName, call.input);
18502
+ return this.draftsToolResult(origin.agentName, call.input, call.callerTurnId);
18351
18503
  }
18352
18504
 
18353
18505
  // The utils meta-tool — programmatic callers (code_execution scripts,
@@ -19942,7 +20094,7 @@ export class AgentFramework {
19942
20094
  if (enrichedCall.name === 'drafts' && this.channelRegistry) {
19943
20095
  const startedAt = Date.now();
19944
20096
  this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'drafts', callId: enrichedCall.id, input: enrichedCall.input });
19945
- void this.draftsToolResult(agentName, enrichedCall.input)
20097
+ void this.draftsToolResult(agentName, enrichedCall.input, enrichedCall.callerTurnId)
19946
20098
  .then((result) => {
19947
20099
  this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'drafts', callId: enrichedCall.id, durationMs: Date.now() - startedAt });
19948
20100
  this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'framework', result });
@@ -21491,6 +21643,7 @@ export class AgentFramework {
21491
21643
  this.noteInboundAccepted(source);
21492
21644
  return source;
21493
21645
  },
21646
+ (serverId, params) => this.channelOfAnotherServer(serverId, params.origin),
21494
21647
  );
21495
21648
 
21496
21649
  // Server-initiated inference router (Step 6)
@@ -25126,8 +25279,17 @@ export class AgentFramework {
25126
25279
  // ordinary wake, starts only once its over-budget boundary has every
25127
25280
  // result of the round.
25128
25281
  const callingTurn = this.routingOf(agentName);
25282
+ const inTurn = call.callerTurnId !== undefined && call.callerTurnId === callingTurn.turnId;
25129
25283
 
25130
- this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName })
25284
+ this.channelRegistry!.handleChannelToolCall(call.name, call.input, {
25285
+ kind: 'agent',
25286
+ agentName,
25287
+ // The turn's recorded servers go only with a call made inside that
25288
+ // turn: a background script's call carries no turn, and takes a bare
25289
+ // id only where exactly one server registers it.
25290
+ ...(inTurn && callingTurn.triggerOrigin !== undefined ? { trigger: callingTurn.triggerOrigin } : {}),
25291
+ ...(inTurn && callingTurn.locusOrigin !== undefined ? { locus: callingTurn.locusOrigin } : {}),
25292
+ })
25131
25293
  .then((result) => {
25132
25294
  const durationMs = Date.now() - startTime;
25133
25295
  this.emitTrace({
@@ -25165,7 +25327,13 @@ export class AgentFramework {
25165
25327
  if (openerAgent
25166
25328
  && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')
25167
25329
  && this.openLogical.get(agentName) === callingTurn) {
25168
- callingTurn.locus = opened;
25330
+ const openedOn = this.channelRegistry!.resolveDestination({
25331
+ ...(typeof (call.input as { serverId?: unknown } | undefined)?.serverId === 'string'
25332
+ ? { serverId: (call.input as { serverId: string }).serverId }
25333
+ : {}),
25334
+ channelId: opened,
25335
+ });
25336
+ setTurnLocus(callingTurn, opened, 'error' in openedOn ? undefined : openedOn.destination.serverId);
25169
25337
  this.lastAnnouncedLocus.set(agentName, opened);
25170
25338
  result = {
25171
25339
  ...result,
@@ -25439,17 +25607,21 @@ export class AgentFramework {
25439
25607
  } else if (agent?.proseRouting === 'explicit') {
25440
25608
  // Explicit mode: announce to the turn's sticky prose target if the
25441
25609
  // model has set one; otherwise stay quiet — never guess a channel.
25442
- const target = this.turnRouting.get(agentName)?.pin;
25610
+ const routing = this.turnRouting.get(agentName);
25611
+ const target = routing?.pin;
25443
25612
  if (target) {
25444
- this.channelRegistry.routeSpeech(agentName, text, target).catch((err) => {
25613
+ this.channelRegistry.routeSpeech(agentName, text, target,
25614
+ speechServerOpts(call.callerTurnId === routing?.turnId ? routing : undefined, target)).catch((err) => {
25445
25615
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
25446
25616
  });
25447
25617
  } else {
25448
25618
  console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`);
25449
25619
  }
25450
25620
  } else {
25451
- const locus = this.turnRouting.get(agentName)?.locus ?? null;
25452
- this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => {
25621
+ const routing = this.turnRouting.get(agentName);
25622
+ const locus = routing?.locus ?? null;
25623
+ this.channelRegistry.routeSpeech(agentName, text, locus,
25624
+ speechServerOpts(call.callerTurnId === routing?.turnId ? routing : undefined, locus)).catch((err) => {
25453
25625
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
25454
25626
  });
25455
25627
  }
@@ -130,6 +130,9 @@ export interface WakeProvenance {
130
130
  * (2026-09-30: a DM reply published into a guild channel). Ambient-only
131
131
  * batches still set nothing and keep the legacy fallback. */
132
132
  routeChannelId?: string;
133
+ /** The server whose event `routeChannelId` came from: the turn's replies
134
+ * to that channel go out through this server only. */
135
+ routeServerId?: string;
133
136
  /** Timestamp (ms) of the chosen event, so a consumer coalescing several
134
137
  * requests can order by event recency, not by flush order. */
135
138
  at?: number;
@@ -235,7 +238,9 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin
235
238
  ...(channelId ? { channelId } : {}),
236
239
  ...(pick.authorId ? { counterparty: `${ns}:user:${pick.authorId}` } : {}),
237
240
  ...(pick.addressed ? { addressed: true } : {}),
238
- ...(pick.addressed && pick.routeChannelId ? { routeChannelId: pick.routeChannelId } : {}),
241
+ ...(pick.addressed && pick.routeChannelId
242
+ ? { routeChannelId: pick.routeChannelId, ...(pick.serverId ? { routeServerId: pick.serverId } : {}) }
243
+ : {}),
239
244
  at: pick.timestamp,
240
245
  };
241
246
  }