@animalabs/agent-framework 0.23.1 → 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
@@ -282,6 +282,28 @@ const silenceCauseOf = (calls: Array<{ name: string; input?: unknown }>): 'send'
282
282
  return calls.some(isSilencingCall) ? 'send' : null;
283
283
  };
284
284
 
285
+ /**
286
+ * The keys of a stored message's metadata that only the host writes and that
287
+ * some reader takes on its own as what the message is: `system` and `kind`
288
+ * make it a host notice of that kind, `draftOwner` and `draftIds` one that
289
+ * names those drafts, `tuneOut` a message a tune-out holds from the
290
+ * residents, `coalescingSubject` (beside the occurrence's `eventId`) the
291
+ * delivery of a coalesced occurrence, `deferredWriteId` the landed copy of a
292
+ * deferred write, `external` a module's own row for an outside message, and
293
+ * `openaiResponsesItems` membrane's mark of an imported Responses history. A
294
+ * server's metadata, a channel message's `metadata` or a push event's
295
+ * `origin`, is stored without them, and the host writes its own after it:
296
+ * MCPL SPEC §16.6 makes metadata the producer's untrusted claims, which a
297
+ * host may disbelieve. Keys a server writes by protocol, such as `tags` and
298
+ * `mentioned`, stay. So do the keys a reader takes only beside one of these:
299
+ * a failed reply's `reason` and `detail` beside its `kind`, and the Discord
300
+ * module's `discordTextFirst` on a row it found by `external`.
301
+ */
302
+ const HOST_METADATA_KEYS = [
303
+ 'system', 'kind', 'draftOwner', 'draftIds', 'tuneOut', 'coalescingSubject', 'deferredWriteId',
304
+ 'external', 'openaiResponsesItems',
305
+ ] as const;
306
+
285
307
  /**
286
308
  * True when an injected message is real conversational input — something the
287
309
  * agent might actually be replying to — rather than ambient machinery.
@@ -779,6 +801,31 @@ interface TurnRouting {
779
801
  * sent Sol's DM reply to a stale guild channel (2026-07-22).
780
802
  */
781
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 };
782
829
  /**
783
830
  * Locus and hybrid mode: where the turn's unpinned plain prose lands.
784
831
  * Frozen when the turn opens (home channel for a fork, else the trigger
@@ -855,11 +902,60 @@ interface Arrival {
855
902
  messageId: MessageId;
856
903
  /** The channel it re-pins the locus to. */
857
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;
858
913
  }
859
914
 
860
915
  /** The last TurnRouting.turnId given out: ids increase, and are unique within the process. */
861
916
  let lastTurnId = 0;
862
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
+
863
959
  function freshTurnRouting(): TurnRouting {
864
960
  return {
865
961
  turnId: ++lastTurnId,
@@ -2929,8 +3025,8 @@ export class AgentFramework {
2929
3025
  // route applies to a fork only when it IS that home, and never to
2930
3026
  // another agent when a fork owns that channel. Otherwise a fork
2931
3027
  // bound elsewhere would show typing where it isn't replying.
2932
- ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId)
2933
- ? { channelId: provenance.routeChannelId, addressed: true }
3028
+ ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId, provenance.routeServerId)
3029
+ ? { channelId: provenance.routeChannelId, serverId: provenance.routeServerId, addressed: true }
2934
3030
  : {}),
2935
3031
  });
2936
3032
  },
@@ -8486,7 +8582,9 @@ export class AgentFramework {
8486
8582
  // tools/call directly on its connection (bare tool name — no prefix
8487
8583
  // games), bypassing the agent event queue so no synthetic tool-result
8488
8584
  // is injected into a turn the agent never took.
8489
- 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);
8490
8588
  const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
8491
8589
  if (!server) {
8492
8590
  console.error(
@@ -10852,9 +10950,9 @@ export class AgentFramework {
10852
10950
  // turn ends. The window stores them here, right after the
10853
10951
  // tool_result message, which is exactly where the membrane appends
10854
10952
  // them in the live conversation — same participant, same content
10855
- // blocks, same metadata, and compile() emits recent messages
10856
- // verbatim, so the next turn's compiled prefix byte-matches the
10857
- // live request (prompt-cache safe).
10953
+ // blocks, and neither carries the row's metadata — and compile()
10954
+ // emits recent messages verbatim, so the next turn's compiled prefix
10955
+ // byte-matches the live request (prompt-cache safe).
10858
10956
  //
10859
10957
  // The queue is drained ONLY at the target agent's own boundary:
10860
10958
  // draining at another agent's boundary would store the messages
@@ -11028,12 +11126,19 @@ export class AgentFramework {
11028
11126
  // The last injection that qualifies wins (repinChannel).
11029
11127
  const routing = this.routingOf(agent.name);
11030
11128
  let newLocus: string | undefined;
11031
- 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
+ }
11032
11137
  if (newLocus) {
11033
11138
  // What this boundary just stored is newer than any arrival the
11034
11139
  // turn holds, so the arrivals can't move the locus back.
11035
11140
  routing.arrivals = [];
11036
- const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed');
11141
+ const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed', newLocusServer);
11037
11142
  if (notice) midTurnInjections.push(notice);
11038
11143
  }
11039
11144
  } else if (routesLocus && !shouldEndTurn) {
@@ -11109,10 +11214,14 @@ export class AgentFramework {
11109
11214
  // Streaming path: convert results and resume the stream.
11110
11215
  // Mid-turn messages collected above ride along as injected user
11111
11216
  // messages (membrane ≥0.5.72) — appended after the tool_result
11112
- // envelope so the next round of THIS turn hears them.
11217
+ // envelope so the next round of THIS turn hears them. Each goes
11218
+ // as its participant and content alone: a stored row's metadata
11219
+ // is the host's record, not input for membrane.
11113
11220
  currentState.stream.provideToolResults(
11114
11221
  agent.toolResultGuard.submissionResults(membraneResults),
11115
- midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined,
11222
+ midTurnInjections.length > 0
11223
+ ? { injectedMessages: midTurnInjections.map(({ participant, content }) => ({ participant, content })) }
11224
+ : undefined,
11116
11225
  );
11117
11226
  // RFC-006 §3.3: consumption is determined at assembly, and this
11118
11227
  // is assembly too when the stream carries the injected messages
@@ -11313,6 +11422,31 @@ export class AgentFramework {
11313
11422
  // Built-in MCPL event → context message conversion
11314
11423
  // ==========================================================================
11315
11424
 
11425
+ /** The servers and keys whose drop withoutHostKeys has logged, each as
11426
+ * `${serverId}\u0000${key}`. */
11427
+ private readonly hostKeysDropped = new Set<string>();
11428
+
11429
+ /**
11430
+ * A server's metadata as it is stored: a copy without HOST_METADATA_KEYS.
11431
+ * A server may use one of those names for something of its own, so the
11432
+ * first drop of each key from each server is logged.
11433
+ */
11434
+ private withoutHostKeys(serverId: string, given: Record<string, unknown> | undefined): Record<string, unknown> {
11435
+ const kept: Record<string, unknown> = { ...given };
11436
+ for (const key of HOST_METADATA_KEYS) {
11437
+ if (!Object.hasOwn(kept, key)) continue;
11438
+ delete kept[key];
11439
+ const logged = `${serverId}\u0000${key}`;
11440
+ if (this.hostKeysDropped.has(logged)) continue;
11441
+ this.hostKeysDropped.add(logged);
11442
+ console.error(
11443
+ `[mcpl] ${serverId}: dropped "${key}" from a message, since only the host writes that key ` +
11444
+ `(logged once for each server and key)`,
11445
+ );
11446
+ }
11447
+ return kept;
11448
+ }
11449
+
11316
11450
  /**
11317
11451
  * Convert an incoming MCPL channel message to a context message.
11318
11452
  * This replaces the old MCPLModule.onProcess() message conversion.
@@ -11349,7 +11483,7 @@ export class AgentFramework {
11349
11483
  return undefined;
11350
11484
  }
11351
11485
  const metadata: Record<string, unknown> = {
11352
- ...event.metadata,
11486
+ ...this.withoutHostKeys(event.serverId, event.metadata),
11353
11487
  ...(event.eventId ? { eventId: event.eventId } : {}),
11354
11488
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
11355
11489
  channelId: event.channelId,
@@ -11389,14 +11523,15 @@ export class AgentFramework {
11389
11523
  const id = target.getContextManager().addMessage('user', event.content, metadata);
11390
11524
  // A correction may target an older engagement. Refresh only the
11391
11525
  // binding whose agent actually received the message.
11392
- if (this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) {
11393
- 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);
11394
11529
  }
11395
11530
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
11396
11531
  if (event.triggerInference) {
11397
11532
  this.pendingRequests.push({
11398
11533
  agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
11399
- channelId: event.channelId,
11534
+ channelId: event.channelId, serverId: event.serverId,
11400
11535
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
11401
11536
  addressed: isAddressedMessage(event.tags, event.metadata),
11402
11537
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
@@ -11475,8 +11610,10 @@ export class AgentFramework {
11475
11610
  source: event.serverId,
11476
11611
  timestamp: Date.now(),
11477
11612
  // Route this turn's auto-published speech back to THIS channel, not
11478
- // 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.
11479
11615
  channelId: event.channelId,
11616
+ serverId: event.serverId,
11480
11617
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
11481
11618
  // Addressed messages outrank ambient chatter when a batched wake
11482
11619
  // picks the turn's frozen speech locus.
@@ -11554,7 +11691,7 @@ export class AgentFramework {
11554
11691
  if (!agent) {
11555
11692
  // Bound agent vanished (e.g. external reset) — unbind so the next
11556
11693
  // qualifying message respawns a fresh fork.
11557
- router.unbind(event.channelId);
11694
+ router.unbind({ serverId: event.serverId, channelId: event.channelId });
11558
11695
  this.emitTrace({
11559
11696
  type: 'mcpl:conversation-binding-orphaned',
11560
11697
  channelId: event.channelId,
@@ -11570,7 +11707,7 @@ export class AgentFramework {
11570
11707
  messageMetadata.triggered = trigger;
11571
11708
 
11572
11709
  const id = agent.getContextManager().addMessage('user', event.content, messageMetadata);
11573
- router.touch(event.channelId);
11710
+ router.touch({ serverId: event.serverId, channelId: event.channelId });
11574
11711
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
11575
11712
 
11576
11713
  if (trigger) {
@@ -11582,6 +11719,7 @@ export class AgentFramework {
11582
11719
  // A fork's home channel wins in routeSpeech regardless, but carry the
11583
11720
  // triggering channel too so the trunk/active path stays consistent.
11584
11721
  channelId: event.channelId,
11722
+ serverId: event.serverId,
11585
11723
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
11586
11724
  addressed: isAddressedMessage(event.tags, event.metadata),
11587
11725
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
@@ -11726,7 +11864,7 @@ export class AgentFramework {
11726
11864
  }
11727
11865
  const decision = this.conversationRouter!.route(facts);
11728
11866
  if (decision.kind !== 'spawn') return decision;
11729
- return { ...decision, spawned: this.spawnConversationAgent(facts.channelId, decision) };
11867
+ return { ...decision, spawned: this.spawnConversationAgent(facts, decision) };
11730
11868
  }
11731
11869
  }
11732
11870
 
@@ -11742,11 +11880,12 @@ export class AgentFramework {
11742
11880
  * so the entry it removes is its own. It's removed before anyone waiting
11743
11881
  * on it resumes, so a waiter that decides again doesn't find it.
11744
11882
  */
11745
- 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;
11746
11885
  const spawn = (async () => {
11747
11886
  this.storeConversationGeneration(channelId, decision.generation);
11748
11887
  const agent = await this.createConversationAgent(decision.agentName, channelId);
11749
- this.conversationRouter!.bind(channelId, decision.agentName, decision.generation);
11888
+ this.conversationRouter!.bind({ serverId: facts.serverId, channelId }, decision.agentName, decision.generation);
11750
11889
  return agent;
11751
11890
  })();
11752
11891
  const done = (): void => { this.conversationSpawns.delete(channelId); };
@@ -11876,7 +12015,7 @@ export class AgentFramework {
11876
12015
  this.lastConversationSweep = now;
11877
12016
 
11878
12017
  for (const binding of this.conversationRouter.expired(now)) {
11879
- this.conversationRouter.unbind(binding.channelId);
12018
+ this.conversationRouter.unbind(binding);
11880
12019
  this.emitTrace({
11881
12020
  type: 'mcpl:conversation-closed',
11882
12021
  channelId: binding.channelId,
@@ -12556,7 +12695,7 @@ export class AgentFramework {
12556
12695
  if (explicit) return explicit.filter((n) => this.agents.has(n));
12557
12696
  if (occ.scope.kind === 'channel' && this.conversationRouter) {
12558
12697
  const router = this.conversationRouter;
12559
- const bound = router.getBinding(occ.scope.id)?.agentName;
12698
+ const bound = router.getBinding({ serverId: occ.serverId, channelId: occ.scope.id })?.agentName;
12560
12699
  if (bound) return this.agents.has(bound) ? [bound] : [];
12561
12700
  // No fork yet: the same bind decision an incoming message would get. A
12562
12701
  // batch that qualifies spawns its fork now so it has someone to wake.
@@ -12672,6 +12811,7 @@ export class AgentFramework {
12672
12811
  source: occ.serverId,
12673
12812
  timestamp: Date.now(),
12674
12813
  channelId,
12814
+ serverId: channelId !== undefined ? occ.serverId : undefined,
12675
12815
  counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
12676
12816
  addressed: isAddressedMessage(occ.tags, origin),
12677
12817
  ...(plain ? {} : { coalescingSubject: subject, coalescingBatch: true }),
@@ -12810,7 +12950,7 @@ export class AgentFramework {
12810
12950
  }
12811
12951
 
12812
12952
  const metadata: Record<string, unknown> = {
12813
- ...event.origin,
12953
+ ...this.withoutHostKeys(event.serverId, event.origin),
12814
12954
  serverId: event.serverId,
12815
12955
  featureSet: event.featureSet,
12816
12956
  eventId: event.eventId,
@@ -12885,6 +13025,7 @@ export class AgentFramework {
12885
13025
  source: event.serverId,
12886
13026
  timestamp: Date.now(),
12887
13027
  channelId: triggerChannel?.channelId,
13028
+ serverId: triggerChannel ? event.serverId : undefined,
12888
13029
  // DMs and addressed-while-closed messages arrive as push events;
12889
13030
  // they must outrank ambient chatter in a batched wake's locus
12890
13031
  // selection just like their channels/incoming counterparts.
@@ -12909,17 +13050,49 @@ export class AgentFramework {
12909
13050
  * Whether a batched gate wake's addressed route may become `agentName`'s
12910
13051
  * speech locus. The gate broadcasts to every agent, conversation forks
12911
13052
  * included. A fork speaks only in its home channel, and a channel bound to
12912
- * 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.
12913
13058
  */
12914
- private mayTakeGateRoute(agentName: string, routeChannelId: string): boolean {
13059
+ private mayTakeGateRoute(agentName: string, routeChannelId: string, routeServerId?: string): boolean {
12915
13060
  const home = this.conversationAgentHomes.get(agentName);
12916
- 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
+ }
12917
13065
  for (const boundChannel of this.conversationAgentHomes.values()) {
12918
13066
  if (boundChannel === routeChannelId) return false;
12919
13067
  }
12920
13068
  return true;
12921
13069
  }
12922
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
+
12923
13096
  /**
12924
13097
  * Derive the MCPL composite channel id (the outbound routing locus) for a
12925
13098
  * push event from its server-defined `origin`, if it carries one.
@@ -13251,6 +13424,7 @@ export class AgentFramework {
13251
13424
  suppressProse: silentOnly ? trigger?.suppressProse : undefined,
13252
13425
  silentHeartbeat: silentOnly ? trigger?.silentHeartbeat : undefined,
13253
13426
  channelId: channelReq?.channelId,
13427
+ serverId: channelReq?.channelId !== undefined ? onlyServerFor(requests, channelReq.channelId) : undefined,
13254
13428
  addressed: addressedReq !== undefined,
13255
13429
  // A context-budget restart continues the same logical turn: it keeps
13256
13430
  // the channel for routing but names no author — the restart is its
@@ -13718,8 +13892,8 @@ export class AgentFramework {
13718
13892
  /** The drafts tool's answer to a resident's call, from the model's own
13719
13893
  * dispatch or an agent-origin call through executeToolCall (a puppet's):
13720
13894
  * a failure the handler throws is answered as one. */
13721
- private draftsToolResult(agentName: string, rawInput: unknown): Promise<ToolResult> {
13722
- 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 => ({
13723
13897
  success: false,
13724
13898
  error: `drafts failed: ${thrownText(err)}`,
13725
13899
  isError: true,
@@ -13732,7 +13906,7 @@ export class AgentFramework {
13732
13906
  * each draft is published verbatim, in order, through the normal publish
13733
13907
  * executor, its attempt journaled durably before the request leaves.
13734
13908
  */
13735
- private async handleDraftsTool(agentName: string, rawInput: unknown): Promise<ToolResult> {
13909
+ private async handleDraftsTool(agentName: string, rawInput: unknown, callerTurnId?: number): Promise<ToolResult> {
13736
13910
  const input = (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? rawInput : {}) as Record<string, unknown>;
13737
13911
  // The turn that sends: a resent draft's channel is recorded as engaged on
13738
13912
  // it, as a send tool's is, even when the result comes after the turn.
@@ -13899,7 +14073,17 @@ export class AgentFramework {
13899
14073
  const cand = resolved.candidates?.length ? ` It could mean: ${resolved.candidates.join(', ')}.` : '';
13900
14074
  return refuse(`Destination "${spec}" did not resolve: ${resolved.error}.${cand} Nothing was sent.`);
13901
14075
  }
13902
- 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
+ });
13903
14087
  if ('error' in target) return refuse(`Destination "${spec}": ${target.error}. Nothing was sent.`);
13904
14088
  const destination = target.destination;
13905
14089
  if (home && destination.channelId !== home) {
@@ -14252,7 +14436,10 @@ export class AgentFramework {
14252
14436
  if (!open) return;
14253
14437
  if ((metadata as { tuneOut?: unknown } | undefined)?.tuneOut) return;
14254
14438
  const channelId = this.repinChannel(metadata, open);
14255
- 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
+ }
14256
14443
  }
14257
14444
 
14258
14445
  /**
@@ -14288,14 +14475,14 @@ export class AgentFramework {
14288
14475
  * message in the store; the last compile can't be the test, since an
14289
14476
  * arrival between the turn's streams isn't in it yet.
14290
14477
  */
14291
- private remainingArrival(agent: Agent, routing: TurnRouting): string | undefined {
14478
+ private remainingArrival(agent: Agent, routing: TurnRouting): Arrival | undefined {
14292
14479
  const cm = agent.getContextManager();
14293
14480
  for (let i = routing.arrivals.length - 1; i >= 0; i--) {
14294
- const { messageId, channelId } = routing.arrivals[i]!;
14481
+ const arrival = routing.arrivals[i]!;
14295
14482
  // The store answers null for a message that isn't there. A store that
14296
14483
  // can't be read throws here, as anywhere else in the start, rather
14297
14484
  // than have its candidates read as withdrawn.
14298
- if (cm.getMessage(messageId)) return channelId;
14485
+ if (cm.getMessage(arrival.messageId)) return arrival;
14299
14486
  }
14300
14487
  return undefined;
14301
14488
  }
@@ -14317,10 +14504,12 @@ export class AgentFramework {
14317
14504
  routing: TurnRouting,
14318
14505
  channelId: string,
14319
14506
  cause: string,
14507
+ /** The server of the message that moved it. */
14508
+ serverId?: string,
14320
14509
  ): { participant: string; content: ContentBlock[]; metadata: MessageMetadata } | undefined {
14321
14510
  if (routing.locus === channelId) return undefined;
14322
14511
  const prevPin = routing.locus ?? null;
14323
- routing.locus = channelId;
14512
+ setTurnLocus(routing, channelId, serverId);
14324
14513
  const label = this.channelRegistry?.getDescriptor(channelId)?.label;
14325
14514
  const shown = label && label !== channelId
14326
14515
  ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})`
@@ -14606,11 +14795,13 @@ export class AgentFramework {
14606
14795
  body: string,
14607
14796
  channelId: string,
14608
14797
  unsent: Draft | undefined,
14798
+ /** The server the channel resolves with (triggerServerFor). */
14799
+ serverId: string | undefined,
14609
14800
  ): Promise<PublishOutcome> {
14610
14801
  const registry = this.channelRegistry!;
14611
14802
  let attemptId: string | undefined;
14612
14803
  if (unsent) {
14613
- const target = registry.resolveDestination({ channelId });
14804
+ const target = registry.resolveDestination({ ...(serverId !== undefined ? { serverId } : {}), channelId });
14614
14805
  if (!('error' in target)) {
14615
14806
  try {
14616
14807
  attemptId = this.proseDrafts.beginAttempt(agent.name, unsent.id, target.destination, 'unsent-token', false);
@@ -14631,7 +14822,8 @@ export class AgentFramework {
14631
14822
  try {
14632
14823
  // Both callers hold a failed delivery's words as a bounce draft, whose
14633
14824
  // notice says what happened, so the registry writes no second marker.
14634
- 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 } : {}) });
14635
14827
  } finally {
14636
14828
  if (flight) {
14637
14829
  this.draftsInFlight.delete(flight);
@@ -14730,7 +14922,7 @@ export class AgentFramework {
14730
14922
  continue;
14731
14923
  }
14732
14924
  try {
14733
- 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));
14734
14926
  this.recordProseDelivery(routing, outcome.status === 'delivered' ? { delivered: true, channelId: outcome.destination!.channelId } : null);
14735
14927
  if (outcome.status === 'delivered') {
14736
14928
  this.proseBounceStreaks.delete(agent.name);
@@ -14772,7 +14964,7 @@ export class AgentFramework {
14772
14964
  }
14773
14965
  const target = sticky ?? locus;
14774
14966
  try {
14775
- const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, target);
14967
+ const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, target, speechServerOpts(routing, target));
14776
14968
  this.recordProseDelivery(routing, outcome);
14777
14969
  } catch (err) {
14778
14970
  console.error('hybrid locus delivery failed:', err);
@@ -14895,7 +15087,7 @@ export class AgentFramework {
14895
15087
  }
14896
15088
 
14897
15089
  try {
14898
- const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft);
15090
+ const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft, triggerServerFor(routing, targetChannel));
14899
15091
  if (outcome.status === 'delivered') {
14900
15092
  this.proseBounceStreaks.delete(name);
14901
15093
  } else if (outcome.status === 'failed') {
@@ -15611,6 +15803,9 @@ export class AgentFramework {
15611
15803
  // turn, which falls back to the global default rather than inheriting
15612
15804
  // a previous turn's channel.
15613
15805
  routing.triggerChannel = trigger?.channelId;
15806
+ routing.triggerOrigin = trigger?.channelId && trigger.serverId
15807
+ ? { channelId: trigger.channelId, serverId: trigger.serverId }
15808
+ : undefined;
15614
15809
  // FREEZE the turn's outbound locus now (turn-frozen routing): the home
15615
15810
  // channel for forks, else the trigger channel, else the global default,
15616
15811
  // which an ephemeral run never takes (takesGlobalFallback).
@@ -15626,7 +15821,14 @@ export class AgentFramework {
15626
15821
  // stability).
15627
15822
  if (turnProseRouting !== 'explicit' && turnProseRouting !== 'disabled') {
15628
15823
  const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null;
15629
- 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
+ }
15630
15832
  if (attempt === 0) this.announceLocusIfChanged(agent.name, locus);
15631
15833
  }
15632
15834
  } else {
@@ -15639,7 +15841,7 @@ export class AgentFramework {
15639
15841
  ? this.remainingArrival(agent, routing)
15640
15842
  : undefined;
15641
15843
  routing.arrivals = [];
15642
- if (arrival) this.repinTurnLocus(agent, routing, arrival, 'arrival');
15844
+ if (arrival) this.repinTurnLocus(agent, routing, arrival.channelId, 'arrival', arrival.serverId);
15643
15845
  }
15644
15846
  // A silent start, or a disabled agent's, has no locus to show, whatever a
15645
15847
  // channel_open in a silent turn left on its object.
@@ -16192,7 +16394,7 @@ export class AgentFramework {
16192
16394
  };
16193
16395
  const enqueueSpeech = (text: string, locus: string | null): void => {
16194
16396
  chainSpeech(async () => {
16195
- const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus);
16397
+ const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus, speechServerOpts(streamRouting, locus));
16196
16398
  this.recordProseDelivery(streamRouting, outcome);
16197
16399
  }, 'mid-turn speech routing failed:');
16198
16400
  };
@@ -16315,6 +16517,7 @@ export class AgentFramework {
16315
16517
  for (const rd of deltas) {
16316
16518
  this.channelRegistry!.sendOutgoingChunk(
16317
16519
  rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta,
16520
+ triggerServerFor(streamRouting, rd.channelId),
16318
16521
  );
16319
16522
  }
16320
16523
  };
@@ -17237,7 +17440,7 @@ export class AgentFramework {
17237
17440
  );
17238
17441
  for (const segment of speechSegments) {
17239
17442
  try {
17240
- const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus);
17443
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus, speechServerOpts(streamRouting, locus));
17241
17444
  this.recordProseDelivery(streamRouting, outcome);
17242
17445
  } catch (err) {
17243
17446
  console.error('speech routing failed:', err);
@@ -17334,7 +17537,7 @@ export class AgentFramework {
17334
17537
  // Deliver sequentially (await each) so the segments land in order.
17335
17538
  for (const seg of segments) {
17336
17539
  try {
17337
- const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
17540
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus, speechServerOpts(streamRouting, locus));
17338
17541
  this.recordProseDelivery(streamRouting, outcome);
17339
17542
  } catch (err) {
17340
17543
  console.error('speech routing failed:', err);
@@ -17992,7 +18195,7 @@ export class AgentFramework {
17992
18195
  if (frameReachedTerminal && proseStream) {
17993
18196
  emitOutgoing(proseStream.finish());
17994
18197
  for (const [channelId, text] of proseStream.byChannel()) {
17995
- this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
18198
+ this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text, triggerServerFor(streamRouting, channelId));
17996
18199
  }
17997
18200
  }
17998
18201
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
@@ -18296,7 +18499,7 @@ export class AgentFramework {
18296
18499
  // agent origin and acts as that resident, as the model's own call does
18297
18500
  // through dispatchToolCall. A module holds no drafts.
18298
18501
  if (call.name === 'drafts' && this.channelRegistry && origin.kind === 'agent') {
18299
- return this.draftsToolResult(origin.agentName, call.input);
18502
+ return this.draftsToolResult(origin.agentName, call.input, call.callerTurnId);
18300
18503
  }
18301
18504
 
18302
18505
  // The utils meta-tool — programmatic callers (code_execution scripts,
@@ -19891,7 +20094,7 @@ export class AgentFramework {
19891
20094
  if (enrichedCall.name === 'drafts' && this.channelRegistry) {
19892
20095
  const startedAt = Date.now();
19893
20096
  this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'drafts', callId: enrichedCall.id, input: enrichedCall.input });
19894
- void this.draftsToolResult(agentName, enrichedCall.input)
20097
+ void this.draftsToolResult(agentName, enrichedCall.input, enrichedCall.callerTurnId)
19895
20098
  .then((result) => {
19896
20099
  this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'drafts', callId: enrichedCall.id, durationMs: Date.now() - startedAt });
19897
20100
  this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'framework', result });
@@ -21440,6 +21643,7 @@ export class AgentFramework {
21440
21643
  this.noteInboundAccepted(source);
21441
21644
  return source;
21442
21645
  },
21646
+ (serverId, params) => this.channelOfAnotherServer(serverId, params.origin),
21443
21647
  );
21444
21648
 
21445
21649
  // Server-initiated inference router (Step 6)
@@ -25075,8 +25279,17 @@ export class AgentFramework {
25075
25279
  // ordinary wake, starts only once its over-budget boundary has every
25076
25280
  // result of the round.
25077
25281
  const callingTurn = this.routingOf(agentName);
25282
+ const inTurn = call.callerTurnId !== undefined && call.callerTurnId === callingTurn.turnId;
25078
25283
 
25079
- 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
+ })
25080
25293
  .then((result) => {
25081
25294
  const durationMs = Date.now() - startTime;
25082
25295
  this.emitTrace({
@@ -25114,7 +25327,13 @@ export class AgentFramework {
25114
25327
  if (openerAgent
25115
25328
  && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')
25116
25329
  && this.openLogical.get(agentName) === callingTurn) {
25117
- 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);
25118
25337
  this.lastAnnouncedLocus.set(agentName, opened);
25119
25338
  result = {
25120
25339
  ...result,
@@ -25388,17 +25607,21 @@ export class AgentFramework {
25388
25607
  } else if (agent?.proseRouting === 'explicit') {
25389
25608
  // Explicit mode: announce to the turn's sticky prose target if the
25390
25609
  // model has set one; otherwise stay quiet — never guess a channel.
25391
- const target = this.turnRouting.get(agentName)?.pin;
25610
+ const routing = this.turnRouting.get(agentName);
25611
+ const target = routing?.pin;
25392
25612
  if (target) {
25393
- 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) => {
25394
25615
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
25395
25616
  });
25396
25617
  } else {
25397
25618
  console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`);
25398
25619
  }
25399
25620
  } else {
25400
- const locus = this.turnRouting.get(agentName)?.locus ?? null;
25401
- 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) => {
25402
25625
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
25403
25626
  });
25404
25627
  }