@animalabs/agent-framework 0.22.4 → 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
@@ -238,6 +238,29 @@ const silenceCauseOf = (calls: Array<{ name: string; input?: unknown }>): 'send'
238
238
  return calls.some(isSilencingCall) ? 'send' : null;
239
239
  };
240
240
 
241
+ /**
242
+ * The keys of a stored message's metadata that only the host writes and that
243
+ * some reader takes on its own as what the message is: `system` makes it a
244
+ * host notice, `draftOwner` and `draftIds` one that names those drafts,
245
+ * `tuneOut` a message a tune-out holds from the residents,
246
+ * `coalescingSubject` (beside the occurrence's `eventId`) the delivery of a
247
+ * coalesced occurrence, `deferredWriteId` the landed copy of a deferred
248
+ * write, `external` a module's own row for an outside message,
249
+ * `silentHeartbeat` a row of a silent heartbeat tick, which marks the tick
250
+ * started whoever's row it is (indexSilentTickRows), and
251
+ * `openaiResponsesItems` membrane's mark of an imported Responses history. A
252
+ * server's metadata, a channel message's `metadata` or a push event's
253
+ * `origin`, is stored without them, and the host writes its own after it:
254
+ * MCPL SPEC §16.6 makes metadata the producer's untrusted claims, which a
255
+ * host may disbelieve. Keys a server writes by protocol, such as `tags` and
256
+ * `mentioned`, stay. So does the Discord module's `discordTextFirst`, which
257
+ * it reads only on a row it found by `external`.
258
+ */
259
+ const HOST_METADATA_KEYS = [
260
+ 'system', 'draftOwner', 'draftIds', 'tuneOut', 'coalescingSubject', 'deferredWriteId', 'external',
261
+ 'silentHeartbeat', 'openaiResponsesItems',
262
+ ] as const;
263
+
241
264
  /**
242
265
  * True when an injected message is real conversational input — something the
243
266
  * agent might actually be replying to — rather than ambient machinery.
@@ -731,6 +754,31 @@ interface TurnRouting {
731
754
  * sent Sol's DM reply to a stale guild channel (2026-07-22).
732
755
  */
733
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 };
734
782
  /**
735
783
  * Locus and hybrid mode: where the turn's unpinned plain prose lands.
736
784
  * Frozen when the turn opens (home channel for a fork, else the trigger
@@ -807,11 +855,60 @@ interface Arrival {
807
855
  messageId: MessageId;
808
856
  /** The channel it re-pins the locus to. */
809
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;
810
866
  }
811
867
 
812
868
  /** The last TurnRouting.turnId given out: ids increase, and are unique within the process. */
813
869
  let lastTurnId = 0;
814
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
+
815
912
  function freshTurnRouting(): TurnRouting {
816
913
  return {
817
914
  turnId: ++lastTurnId,
@@ -2704,8 +2801,8 @@ export class AgentFramework {
2704
2801
  // route applies to a fork only when it IS that home, and never to
2705
2802
  // another agent when a fork owns that channel. Otherwise a fork
2706
2803
  // bound elsewhere would show typing where it isn't replying.
2707
- ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId)
2708
- ? { channelId: provenance.routeChannelId, addressed: true }
2804
+ ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId, provenance.routeServerId)
2805
+ ? { channelId: provenance.routeChannelId, serverId: provenance.routeServerId, addressed: true }
2709
2806
  : {}),
2710
2807
  });
2711
2808
  },
@@ -7937,7 +8034,9 @@ export class AgentFramework {
7937
8034
  // tools/call directly on its connection (bare tool name — no prefix
7938
8035
  // games), bypassing the agent event queue so no synthetic tool-result
7939
8036
  // is injected into a turn the agent never took.
7940
- 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);
7941
8040
  const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
7942
8041
  if (!server) {
7943
8042
  console.error(
@@ -8110,7 +8209,9 @@ export class AgentFramework {
8110
8209
  const parts = incoming.channelId.split(':');
8111
8210
  if (parts[0] !== 'discord') return;
8112
8211
  const channelId = parts[parts.length - 1];
8113
- 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);
8114
8215
  const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
8115
8216
  if (!server) return;
8116
8217
  await server.sendToolsCall('send_message', {
@@ -10345,9 +10446,9 @@ export class AgentFramework {
10345
10446
  // turn ends. The window stores them here, right after the
10346
10447
  // tool_result message, which is exactly where the membrane appends
10347
10448
  // them in the live conversation — same participant, same content
10348
- // blocks, same metadata, and compile() emits recent messages
10349
- // verbatim, so the next turn's compiled prefix byte-matches the
10350
- // live request (prompt-cache safe).
10449
+ // blocks, and neither carries the row's metadata — and compile()
10450
+ // emits recent messages verbatim, so the next turn's compiled prefix
10451
+ // byte-matches the live request (prompt-cache safe).
10351
10452
  //
10352
10453
  // The queue is drained ONLY at the target agent's own boundary:
10353
10454
  // draining at another agent's boundary would store the messages
@@ -10506,12 +10607,19 @@ export class AgentFramework {
10506
10607
  // The last injection that qualifies wins (repinChannel).
10507
10608
  const routing = this.routingOf(agent.name);
10508
10609
  let newLocus: string | undefined;
10509
- 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
+ }
10510
10618
  if (newLocus) {
10511
10619
  // What this boundary just stored is newer than any arrival the
10512
10620
  // turn holds, so the arrivals can't move the locus back.
10513
10621
  routing.arrivals = [];
10514
- const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed');
10622
+ const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed', newLocusServer);
10515
10623
  if (notice) midTurnInjections.push(notice);
10516
10624
  }
10517
10625
  } else if (routesLocus && !shouldEndTurn) {
@@ -10589,9 +10697,13 @@ export class AgentFramework {
10589
10697
  // Mid-turn messages collected above ride along as injected user
10590
10698
  // messages (membrane ≥0.5.72) — appended after the tool_result
10591
10699
  // envelope so the next round of THIS turn hears them.
10700
+ // Each goes as its participant and content alone: a stored row's
10701
+ // metadata is the host's record, not input for membrane.
10592
10702
  currentState.stream.provideToolResults(
10593
10703
  agent.toolResultGuard.submissionResults(membraneResults),
10594
- midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined,
10704
+ midTurnInjections.length > 0
10705
+ ? { injectedMessages: midTurnInjections.map(({ participant, content }) => ({ participant, content })) }
10706
+ : undefined,
10595
10707
  );
10596
10708
  // RFC-006 §3.3: consumption is determined at assembly, and this
10597
10709
  // is assembly too when the stream carries the injected messages
@@ -10789,6 +10901,31 @@ export class AgentFramework {
10789
10901
  // Built-in MCPL event → context message conversion
10790
10902
  // ==========================================================================
10791
10903
 
10904
+ /** The servers and keys whose drop withoutHostKeys has logged, each as
10905
+ * `${serverId}\u0000${key}`. */
10906
+ private readonly hostKeysDropped = new Set<string>();
10907
+
10908
+ /**
10909
+ * A server's metadata as it is stored: a copy without HOST_METADATA_KEYS.
10910
+ * A server may use one of those names for something of its own, so the
10911
+ * first drop of each key from each server is logged.
10912
+ */
10913
+ private withoutHostKeys(serverId: string, given: Record<string, unknown> | undefined): Record<string, unknown> {
10914
+ const kept: Record<string, unknown> = { ...given };
10915
+ for (const key of HOST_METADATA_KEYS) {
10916
+ if (!Object.hasOwn(kept, key)) continue;
10917
+ delete kept[key];
10918
+ const logged = `${serverId}\u0000${key}`;
10919
+ if (this.hostKeysDropped.has(logged)) continue;
10920
+ this.hostKeysDropped.add(logged);
10921
+ console.error(
10922
+ `[mcpl] ${serverId}: dropped "${key}" from a message, since only the host writes that key ` +
10923
+ `(logged once for each server and key)`,
10924
+ );
10925
+ }
10926
+ return kept;
10927
+ }
10928
+
10792
10929
  /**
10793
10930
  * Convert an incoming MCPL channel message to a context message.
10794
10931
  * This replaces the old MCPLModule.onProcess() message conversion.
@@ -10825,7 +10962,7 @@ export class AgentFramework {
10825
10962
  return undefined;
10826
10963
  }
10827
10964
  const metadata: Record<string, unknown> = {
10828
- ...event.metadata,
10965
+ ...this.withoutHostKeys(event.serverId, event.metadata),
10829
10966
  ...(event.eventId ? { eventId: event.eventId } : {}),
10830
10967
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
10831
10968
  channelId: event.channelId,
@@ -10865,14 +11002,15 @@ export class AgentFramework {
10865
11002
  const id = target.getContextManager().addMessage('user', event.content, metadata);
10866
11003
  // A correction may target an older engagement. Refresh only the
10867
11004
  // binding whose agent actually received the message.
10868
- if (this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) {
10869
- 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);
10870
11008
  }
10871
11009
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
10872
11010
  if (event.triggerInference) {
10873
11011
  this.pendingRequests.push({
10874
11012
  agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
10875
- channelId: event.channelId,
11013
+ channelId: event.channelId, serverId: event.serverId,
10876
11014
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
10877
11015
  addressed: isAddressedMessage(event.tags, event.metadata),
10878
11016
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
@@ -10951,8 +11089,10 @@ export class AgentFramework {
10951
11089
  source: event.serverId,
10952
11090
  timestamp: Date.now(),
10953
11091
  // Route this turn's auto-published speech back to THIS channel, not
10954
- // 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.
10955
11094
  channelId: event.channelId,
11095
+ serverId: event.serverId,
10956
11096
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
10957
11097
  // Addressed messages outrank ambient chatter when a batched wake
10958
11098
  // picks the turn's frozen speech locus.
@@ -11030,7 +11170,7 @@ export class AgentFramework {
11030
11170
  if (!agent) {
11031
11171
  // Bound agent vanished (e.g. external reset) — unbind so the next
11032
11172
  // qualifying message respawns a fresh fork.
11033
- router.unbind(event.channelId);
11173
+ router.unbind({ serverId: event.serverId, channelId: event.channelId });
11034
11174
  this.emitTrace({
11035
11175
  type: 'mcpl:conversation-binding-orphaned',
11036
11176
  channelId: event.channelId,
@@ -11046,7 +11186,7 @@ export class AgentFramework {
11046
11186
  messageMetadata.triggered = trigger;
11047
11187
 
11048
11188
  const id = agent.getContextManager().addMessage('user', event.content, messageMetadata);
11049
- router.touch(event.channelId);
11189
+ router.touch({ serverId: event.serverId, channelId: event.channelId });
11050
11190
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
11051
11191
 
11052
11192
  if (trigger) {
@@ -11058,6 +11198,7 @@ export class AgentFramework {
11058
11198
  // A fork's home channel wins in routeSpeech regardless, but carry the
11059
11199
  // triggering channel too so the trunk/active path stays consistent.
11060
11200
  channelId: event.channelId,
11201
+ serverId: event.serverId,
11061
11202
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
11062
11203
  addressed: isAddressedMessage(event.tags, event.metadata),
11063
11204
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
@@ -11202,7 +11343,7 @@ export class AgentFramework {
11202
11343
  }
11203
11344
  const decision = this.conversationRouter!.route(facts);
11204
11345
  if (decision.kind !== 'spawn') return decision;
11205
- return { ...decision, spawned: this.spawnConversationAgent(facts.channelId, decision) };
11346
+ return { ...decision, spawned: this.spawnConversationAgent(facts, decision) };
11206
11347
  }
11207
11348
  }
11208
11349
 
@@ -11218,11 +11359,12 @@ export class AgentFramework {
11218
11359
  * so the entry it removes is its own. It's removed before anyone waiting
11219
11360
  * on it resumes, so a waiter that decides again doesn't find it.
11220
11361
  */
11221
- 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;
11222
11364
  const spawn = (async () => {
11223
11365
  this.storeConversationGeneration(channelId, decision.generation);
11224
11366
  const agent = await this.createConversationAgent(decision.agentName, channelId);
11225
- this.conversationRouter!.bind(channelId, decision.agentName, decision.generation);
11367
+ this.conversationRouter!.bind({ serverId: facts.serverId, channelId }, decision.agentName, decision.generation);
11226
11368
  return agent;
11227
11369
  })();
11228
11370
  const done = (): void => { this.conversationSpawns.delete(channelId); };
@@ -11351,7 +11493,7 @@ export class AgentFramework {
11351
11493
  this.lastConversationSweep = now;
11352
11494
 
11353
11495
  for (const binding of this.conversationRouter.expired(now)) {
11354
- this.conversationRouter.unbind(binding.channelId);
11496
+ this.conversationRouter.unbind(binding);
11355
11497
  this.emitTrace({
11356
11498
  type: 'mcpl:conversation-closed',
11357
11499
  channelId: binding.channelId,
@@ -12020,7 +12162,7 @@ export class AgentFramework {
12020
12162
  if (explicit) return explicit.filter((n) => this.agents.has(n));
12021
12163
  if (occ.scope.kind === 'channel' && this.conversationRouter) {
12022
12164
  const router = this.conversationRouter;
12023
- const bound = router.getBinding(occ.scope.id)?.agentName;
12165
+ const bound = router.getBinding({ serverId: occ.serverId, channelId: occ.scope.id })?.agentName;
12024
12166
  if (bound) return this.agents.has(bound) ? [bound] : [];
12025
12167
  // No fork yet: the same bind decision an incoming message would get. A
12026
12168
  // batch that qualifies spawns its fork now so it has someone to wake.
@@ -12128,6 +12270,7 @@ export class AgentFramework {
12128
12270
  source: occ.serverId,
12129
12271
  timestamp: Date.now(),
12130
12272
  channelId,
12273
+ serverId: channelId !== undefined ? occ.serverId : undefined,
12131
12274
  counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
12132
12275
  addressed: isAddressedMessage(occ.tags, origin),
12133
12276
  coalescingSubject: subject,
@@ -12267,7 +12410,7 @@ export class AgentFramework {
12267
12410
  }
12268
12411
 
12269
12412
  const metadata: Record<string, unknown> = {
12270
- ...event.origin,
12413
+ ...this.withoutHostKeys(event.serverId, event.origin),
12271
12414
  serverId: event.serverId,
12272
12415
  featureSet: event.featureSet,
12273
12416
  eventId: event.eventId,
@@ -12342,6 +12485,7 @@ export class AgentFramework {
12342
12485
  source: event.serverId,
12343
12486
  timestamp: Date.now(),
12344
12487
  channelId: triggerChannel?.channelId,
12488
+ serverId: triggerChannel ? event.serverId : undefined,
12345
12489
  // DMs and addressed-while-closed messages arrive as push events;
12346
12490
  // they must outrank ambient chatter in a batched wake's locus
12347
12491
  // selection just like their channels/incoming counterparts.
@@ -12364,17 +12508,49 @@ export class AgentFramework {
12364
12508
  * Whether a batched gate wake's addressed route may become `agentName`'s
12365
12509
  * speech locus. The gate broadcasts to every agent, conversation forks
12366
12510
  * included. A fork speaks only in its home channel, and a channel bound to
12367
- * 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.
12368
12516
  */
12369
- private mayTakeGateRoute(agentName: string, routeChannelId: string): boolean {
12517
+ private mayTakeGateRoute(agentName: string, routeChannelId: string, routeServerId?: string): boolean {
12370
12518
  const home = this.conversationAgentHomes.get(agentName);
12371
- 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
+ }
12372
12523
  for (const boundChannel of this.conversationAgentHomes.values()) {
12373
12524
  if (boundChannel === routeChannelId) return false;
12374
12525
  }
12375
12526
  return true;
12376
12527
  }
12377
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
+
12378
12554
  /**
12379
12555
  * Derive the MCPL composite channel id (the outbound routing locus) for a
12380
12556
  * push event from its server-defined `origin`, if it carries one.
@@ -12699,6 +12875,7 @@ export class AgentFramework {
12699
12875
  ephemeralSystemPrompt: silentOnly ? trigger?.ephemeralSystemPrompt : undefined,
12700
12876
  silentHeartbeat: silentOnly ? trigger?.silentHeartbeat : undefined,
12701
12877
  channelId: channelReq?.channelId,
12878
+ serverId: channelReq?.channelId !== undefined ? onlyServerFor(requests, channelReq.channelId) : undefined,
12702
12879
  addressed: addressedReq !== undefined,
12703
12880
  // A context-budget restart continues the same logical turn: it keeps
12704
12881
  // the channel for routing but names no author — the restart is its
@@ -13145,8 +13322,8 @@ export class AgentFramework {
13145
13322
  /** The drafts tool's answer to a resident's call, from the model's own
13146
13323
  * dispatch or an agent-origin call through executeToolCall (a puppet's):
13147
13324
  * a failure the handler throws is answered as one. */
13148
- private draftsToolResult(agentName: string, rawInput: unknown): Promise<ToolResult> {
13149
- 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 => ({
13150
13327
  success: false,
13151
13328
  error: `drafts failed: ${thrownText(err)}`,
13152
13329
  isError: true,
@@ -13159,7 +13336,7 @@ export class AgentFramework {
13159
13336
  * each draft is published verbatim, in order, through the normal publish
13160
13337
  * executor, its attempt journaled durably before the request leaves.
13161
13338
  */
13162
- private async handleDraftsTool(agentName: string, rawInput: unknown): Promise<ToolResult> {
13339
+ private async handleDraftsTool(agentName: string, rawInput: unknown, callerTurnId?: number): Promise<ToolResult> {
13163
13340
  const input = (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? rawInput : {}) as Record<string, unknown>;
13164
13341
  // The turn that sends: a resent draft's channel is recorded as engaged on
13165
13342
  // it, as a send tool's is, even when the result comes after the turn.
@@ -13326,7 +13503,17 @@ export class AgentFramework {
13326
13503
  const cand = resolved.candidates?.length ? ` It could mean: ${resolved.candidates.join(', ')}.` : '';
13327
13504
  return refuse(`Destination "${spec}" did not resolve: ${resolved.error}.${cand} Nothing was sent.`);
13328
13505
  }
13329
- 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
+ });
13330
13517
  if ('error' in target) return refuse(`Destination "${spec}": ${target.error}. Nothing was sent.`);
13331
13518
  const destination = target.destination;
13332
13519
  if (home && destination.channelId !== home) {
@@ -13672,7 +13859,10 @@ export class AgentFramework {
13672
13859
  if (!open) return;
13673
13860
  if ((metadata as { tuneOut?: unknown } | undefined)?.tuneOut) return;
13674
13861
  const channelId = this.repinChannel(metadata, open);
13675
- 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
+ }
13676
13866
  }
13677
13867
 
13678
13868
  /**
@@ -13708,14 +13898,14 @@ export class AgentFramework {
13708
13898
  * message in the store; the last compile can't be the test, since an
13709
13899
  * arrival between the turn's streams isn't in it yet.
13710
13900
  */
13711
- private remainingArrival(agent: Agent, routing: TurnRouting): string | undefined {
13901
+ private remainingArrival(agent: Agent, routing: TurnRouting): Arrival | undefined {
13712
13902
  const cm = agent.getContextManager();
13713
13903
  for (let i = routing.arrivals.length - 1; i >= 0; i--) {
13714
- const { messageId, channelId } = routing.arrivals[i]!;
13904
+ const arrival = routing.arrivals[i]!;
13715
13905
  // The store answers null for a message that isn't there. A store that
13716
13906
  // can't be read throws here, as anywhere else in the start, rather
13717
13907
  // than have its candidates read as withdrawn.
13718
- if (cm.getMessage(messageId)) return channelId;
13908
+ if (cm.getMessage(arrival.messageId)) return arrival;
13719
13909
  }
13720
13910
  return undefined;
13721
13911
  }
@@ -13737,10 +13927,12 @@ export class AgentFramework {
13737
13927
  routing: TurnRouting,
13738
13928
  channelId: string,
13739
13929
  cause: string,
13930
+ /** The server of the message that moved it. */
13931
+ serverId?: string,
13740
13932
  ): { participant: string; content: ContentBlock[]; metadata: MessageMetadata } | undefined {
13741
13933
  if (routing.locus === channelId) return undefined;
13742
13934
  const prevPin = routing.locus ?? null;
13743
- routing.locus = channelId;
13935
+ setTurnLocus(routing, channelId, serverId);
13744
13936
  const label = this.channelRegistry?.getDescriptor(channelId)?.label;
13745
13937
  const shown = label && label !== channelId
13746
13938
  ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})`
@@ -14002,11 +14194,13 @@ export class AgentFramework {
14002
14194
  body: string,
14003
14195
  channelId: string,
14004
14196
  unsent: Draft | undefined,
14197
+ /** The server the channel resolves with (triggerServerFor). */
14198
+ serverId: string | undefined,
14005
14199
  ): Promise<PublishOutcome> {
14006
14200
  const registry = this.channelRegistry!;
14007
14201
  let attemptId: string | undefined;
14008
14202
  if (unsent) {
14009
- const target = registry.resolveDestination({ channelId });
14203
+ const target = registry.resolveDestination({ ...(serverId !== undefined ? { serverId } : {}), channelId });
14010
14204
  if (!('error' in target)) {
14011
14205
  try {
14012
14206
  attemptId = this.proseDrafts.beginAttempt(agent.name, unsent.id, target.destination, 'unsent-token', false);
@@ -14027,7 +14221,8 @@ export class AgentFramework {
14027
14221
  try {
14028
14222
  // Both callers hold a failed delivery's words as a bounce draft, whose
14029
14223
  // notice says what happened, so the registry writes no second marker.
14030
- 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 } : {}) });
14031
14226
  } finally {
14032
14227
  if (flight) {
14033
14228
  this.draftsInFlight.delete(flight);
@@ -14125,7 +14320,7 @@ export class AgentFramework {
14125
14320
  continue;
14126
14321
  }
14127
14322
  try {
14128
- 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));
14129
14324
  this.recordProseDelivery(routing, outcome.status === 'delivered' ? { delivered: true, channelId: outcome.destination!.channelId } : null);
14130
14325
  if (outcome.status === 'delivered') {
14131
14326
  this.proseBounceStreaks.delete(agent.name);
@@ -14167,7 +14362,7 @@ export class AgentFramework {
14167
14362
  }
14168
14363
  const target = sticky ?? locus;
14169
14364
  try {
14170
- const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, target);
14365
+ const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, target, speechServerOpts(routing, target));
14171
14366
  this.recordProseDelivery(routing, outcome);
14172
14367
  } catch (err) {
14173
14368
  console.error('hybrid locus delivery failed:', err);
@@ -14288,7 +14483,7 @@ export class AgentFramework {
14288
14483
  }
14289
14484
 
14290
14485
  try {
14291
- const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft);
14486
+ const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft, triggerServerFor(routing, targetChannel));
14292
14487
  if (outcome.status === 'delivered') {
14293
14488
  this.proseBounceStreaks.delete(name);
14294
14489
  } else if (outcome.status === 'failed') {
@@ -14879,6 +15074,9 @@ export class AgentFramework {
14879
15074
  // turn, which falls back to the global default rather than inheriting
14880
15075
  // a previous turn's channel.
14881
15076
  routing.triggerChannel = trigger?.channelId;
15077
+ routing.triggerOrigin = trigger?.channelId && trigger.serverId
15078
+ ? { channelId: trigger.channelId, serverId: trigger.serverId }
15079
+ : undefined;
14882
15080
  // FREEZE the turn's outbound locus now (turn-frozen routing): the home
14883
15081
  // channel for forks, else the trigger channel, else the global default.
14884
15082
  // Mid-turn injections must not redirect the agent's plain prose
@@ -14893,7 +15091,14 @@ export class AgentFramework {
14893
15091
  // stability).
14894
15092
  if (turnProseRouting !== 'explicit' && turnProseRouting !== 'disabled') {
14895
15093
  const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null;
14896
- 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
+ }
14897
15102
  if (attempt === 0) this.announceLocusIfChanged(agent.name, locus);
14898
15103
  }
14899
15104
  } else {
@@ -14906,7 +15111,7 @@ export class AgentFramework {
14906
15111
  ? this.remainingArrival(agent, routing)
14907
15112
  : undefined;
14908
15113
  routing.arrivals = [];
14909
- if (arrival) this.repinTurnLocus(agent, routing, arrival, 'arrival');
15114
+ if (arrival) this.repinTurnLocus(agent, routing, arrival.channelId, 'arrival', arrival.serverId);
14910
15115
  }
14911
15116
  // A silent start, or a disabled agent's, has no locus to show, whatever a
14912
15117
  // channel_open in a silent turn left on its object.
@@ -15386,7 +15591,7 @@ export class AgentFramework {
15386
15591
  };
15387
15592
  const enqueueSpeech = (text: string, locus: string | null): void => {
15388
15593
  chainSpeech(async () => {
15389
- const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus);
15594
+ const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus, speechServerOpts(streamRouting, locus));
15390
15595
  this.recordProseDelivery(streamRouting, outcome);
15391
15596
  }, 'mid-turn speech routing failed:');
15392
15597
  };
@@ -15506,6 +15711,7 @@ export class AgentFramework {
15506
15711
  for (const rd of deltas) {
15507
15712
  this.channelRegistry!.sendOutgoingChunk(
15508
15713
  rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta,
15714
+ triggerServerFor(streamRouting, rd.channelId),
15509
15715
  );
15510
15716
  }
15511
15717
  };
@@ -16311,7 +16517,7 @@ export class AgentFramework {
16311
16517
  );
16312
16518
  for (const segment of speechSegments) {
16313
16519
  try {
16314
- const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus);
16520
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus, speechServerOpts(streamRouting, locus));
16315
16521
  this.recordProseDelivery(streamRouting, outcome);
16316
16522
  } catch (err) {
16317
16523
  console.error('speech routing failed:', err);
@@ -16407,7 +16613,7 @@ export class AgentFramework {
16407
16613
  // Deliver sequentially (await each) so the segments land in order.
16408
16614
  for (const seg of segments) {
16409
16615
  try {
16410
- const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
16616
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus, speechServerOpts(streamRouting, locus));
16411
16617
  this.recordProseDelivery(streamRouting, outcome);
16412
16618
  } catch (err) {
16413
16619
  console.error('speech routing failed:', err);
@@ -17051,7 +17257,7 @@ export class AgentFramework {
17051
17257
  if (frameReachedTerminal && proseStream) {
17052
17258
  emitOutgoing(proseStream.finish());
17053
17259
  for (const [channelId, text] of proseStream.byChannel()) {
17054
- this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
17260
+ this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text, triggerServerFor(streamRouting, channelId));
17055
17261
  }
17056
17262
  }
17057
17263
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
@@ -17343,7 +17549,7 @@ export class AgentFramework {
17343
17549
  // agent origin and acts as that resident, as the model's own call does
17344
17550
  // through dispatchToolCall. A module holds no drafts.
17345
17551
  if (call.name === 'drafts' && this.channelRegistry && origin.kind === 'agent') {
17346
- return this.draftsToolResult(origin.agentName, call.input);
17552
+ return this.draftsToolResult(origin.agentName, call.input, call.callerTurnId);
17347
17553
  }
17348
17554
 
17349
17555
  // The utils meta-tool — programmatic callers (code_execution scripts,
@@ -18813,7 +19019,7 @@ export class AgentFramework {
18813
19019
  if (enrichedCall.name === 'drafts' && this.channelRegistry) {
18814
19020
  const startedAt = Date.now();
18815
19021
  this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'drafts', callId: enrichedCall.id, input: enrichedCall.input });
18816
- void this.draftsToolResult(agentName, enrichedCall.input)
19022
+ void this.draftsToolResult(agentName, enrichedCall.input, enrichedCall.callerTurnId)
18817
19023
  .then((result) => {
18818
19024
  this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'drafts', callId: enrichedCall.id, durationMs: Date.now() - startedAt });
18819
19025
  this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'framework', result });
@@ -19965,6 +20171,7 @@ export class AgentFramework {
19965
20171
  this.noteInboundAccepted(source);
19966
20172
  return source;
19967
20173
  },
20174
+ (serverId, params) => this.channelOfAnotherServer(serverId, params.origin),
19968
20175
  );
19969
20176
 
19970
20177
  // Server-initiated inference router (Step 6)
@@ -22423,8 +22630,17 @@ export class AgentFramework {
22423
22630
  // ordinary wake, starts only once its over-budget boundary has every
22424
22631
  // result of the round.
22425
22632
  const callingTurn = this.routingOf(agentName);
22633
+ const inTurn = call.callerTurnId !== undefined && call.callerTurnId === callingTurn.turnId;
22426
22634
 
22427
- 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
+ })
22428
22644
  .then((result) => {
22429
22645
  const durationMs = Date.now() - startTime;
22430
22646
  this.emitTrace({
@@ -22462,7 +22678,13 @@ export class AgentFramework {
22462
22678
  if (openerAgent
22463
22679
  && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')
22464
22680
  && this.openLogical.get(agentName) === callingTurn) {
22465
- 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);
22466
22688
  this.lastAnnouncedLocus.set(agentName, opened);
22467
22689
  result = {
22468
22690
  ...result,
@@ -22736,17 +22958,21 @@ export class AgentFramework {
22736
22958
  } else if (agent?.proseRouting === 'explicit') {
22737
22959
  // Explicit mode: announce to the turn's sticky prose target if the
22738
22960
  // model has set one; otherwise stay quiet — never guess a channel.
22739
- const target = this.turnRouting.get(agentName)?.pin;
22961
+ const routing = this.turnRouting.get(agentName);
22962
+ const target = routing?.pin;
22740
22963
  if (target) {
22741
- 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) => {
22742
22966
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
22743
22967
  });
22744
22968
  } else {
22745
22969
  console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`);
22746
22970
  }
22747
22971
  } else {
22748
- const locus = this.turnRouting.get(agentName)?.locus ?? null;
22749
- 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) => {
22750
22976
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
22751
22977
  });
22752
22978
  }