@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.
@@ -139,6 +139,28 @@ const silenceCauseOf = (calls) => {
139
139
  return 'private';
140
140
  return calls.some(isSilencingCall) ? 'send' : null;
141
141
  };
142
+ /**
143
+ * The keys of a stored message's metadata that only the host writes and that
144
+ * some reader takes on its own as what the message is: `system` makes it a
145
+ * host notice, `draftOwner` and `draftIds` one that names those drafts,
146
+ * `tuneOut` a message a tune-out holds from the residents,
147
+ * `coalescingSubject` (beside the occurrence's `eventId`) the delivery of a
148
+ * coalesced occurrence, `deferredWriteId` the landed copy of a deferred
149
+ * write, `external` a module's own row for an outside message,
150
+ * `silentHeartbeat` a row of a silent heartbeat tick, which marks the tick
151
+ * started whoever's row it is (indexSilentTickRows), and
152
+ * `openaiResponsesItems` membrane's mark of an imported Responses history. A
153
+ * server's metadata, a channel message's `metadata` or a push event's
154
+ * `origin`, is stored without them, and the host writes its own after it:
155
+ * MCPL SPEC §16.6 makes metadata the producer's untrusted claims, which a
156
+ * host may disbelieve. Keys a server writes by protocol, such as `tags` and
157
+ * `mentioned`, stay. So does the Discord module's `discordTextFirst`, which
158
+ * it reads only on a row it found by `external`.
159
+ */
160
+ const HOST_METADATA_KEYS = [
161
+ 'system', 'draftOwner', 'draftIds', 'tuneOut', 'coalescingSubject', 'deferredWriteId', 'external',
162
+ 'silentHeartbeat', 'openaiResponsesItems',
163
+ ];
142
164
  /**
143
165
  * True when an injected message is real conversational input — something the
144
166
  * agent might actually be replying to — rather than ambient machinery.
@@ -467,8 +489,55 @@ function announcedDomainsOf(announce) {
467
489
  function resumesLogicalTurn(reason) {
468
490
  return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry';
469
491
  }
492
+ /** The server a stored channel message came from, as the host wrote it. */
493
+ function messageServerId(metadata) {
494
+ const serverId = metadata?.serverId;
495
+ return typeof serverId === 'string' && serverId ? serverId : undefined;
496
+ }
470
497
  /** The last TurnRouting.turnId given out: ids increase, and are unique within the process. */
471
498
  let lastTurnId = 0;
499
+ /**
500
+ * The server every request naming `channelId` came from, or undefined where
501
+ * one names no server or they name more than one (TurnRouting.triggerOrigin).
502
+ */
503
+ function onlyServerFor(requests, channelId) {
504
+ let serverId;
505
+ for (const r of requests) {
506
+ if (r.channelId !== channelId)
507
+ continue;
508
+ if (!r.serverId || (serverId !== undefined && r.serverId !== serverId))
509
+ return undefined;
510
+ serverId = r.serverId;
511
+ }
512
+ return serverId;
513
+ }
514
+ /**
515
+ * The server a send in `routing`'s turn resolves `channelId` with: the
516
+ * trigger's, where `channelId` is the trigger's channel
517
+ * (TurnRouting.triggerOrigin), else the locus's, where it's the channel the
518
+ * locus's server put there (TurnRouting.locusOrigin).
519
+ */
520
+ function triggerServerFor(routing, channelId) {
521
+ if (routing === undefined || !channelId)
522
+ return undefined;
523
+ if (routing.triggerOrigin?.channelId === channelId)
524
+ return routing.triggerOrigin.serverId;
525
+ if (routing.locusOrigin?.channelId === channelId)
526
+ return routing.locusOrigin.serverId;
527
+ return undefined;
528
+ }
529
+ /** Move a turn's locus, with the server of the message that put it there
530
+ * (TurnRouting.locusOrigin), or none. */
531
+ function setTurnLocus(routing, channelId, serverId) {
532
+ routing.locus = channelId;
533
+ routing.locusOrigin = serverId !== undefined ? { channelId, serverId } : undefined;
534
+ }
535
+ /** routeSpeech's options for speech into `channelId` in `routing`'s turn:
536
+ * the trigger's server, where it's the trigger's channel (triggerServerFor). */
537
+ function speechServerOpts(routing, channelId) {
538
+ const serverId = triggerServerFor(routing, channelId);
539
+ return serverId !== undefined ? { locusServerId: serverId } : undefined;
540
+ }
472
541
  function freshTurnRouting() {
473
542
  return {
474
543
  turnId: ++lastTurnId,
@@ -1943,8 +2012,8 @@ export class AgentFramework {
1943
2012
  // route applies to a fork only when it IS that home, and never to
1944
2013
  // another agent when a fork owns that channel. Otherwise a fork
1945
2014
  // bound elsewhere would show typing where it isn't replying.
1946
- ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId)
1947
- ? { channelId: provenance.routeChannelId, addressed: true }
2015
+ ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId, provenance.routeServerId)
2016
+ ? { channelId: provenance.routeChannelId, serverId: provenance.routeServerId, addressed: true }
1948
2017
  : {}),
1949
2018
  });
1950
2019
  },
@@ -6911,7 +6980,9 @@ export class AgentFramework {
6911
6980
  // tools/call directly on its connection (bare tool name — no prefix
6912
6981
  // games), bypassing the agent event queue so no synthetic tool-result
6913
6982
  // is injected into a turn the agent never took.
6914
- const serverId = this.channelRegistry?.getChannelServerId(incoming.channelId);
6983
+ // Through the server whose message it marks: the latest accepted
6984
+ // message's, kept beside its channel and id (fallbackServerFor).
6985
+ const serverId = this.channelRegistry?.fallbackServerFor(incoming.channelId);
6915
6986
  const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
6916
6987
  if (!server) {
6917
6988
  console.error(`[inference-refusal] reaction skipped: no MCPL server for locus ${logValue(incoming.channelId)} (agent=${agentName})`);
@@ -7068,7 +7139,9 @@ export class AgentFramework {
7068
7139
  if (parts[0] !== 'discord')
7069
7140
  return;
7070
7141
  const channelId = parts[parts.length - 1];
7071
- const serverId = this.channelRegistry?.getChannelServerId(incoming.channelId);
7142
+ // As reactToRefusal resolves it: through the server whose message the
7143
+ // latest inbound is (fallbackServerFor).
7144
+ const serverId = this.channelRegistry?.fallbackServerFor(incoming.channelId);
7072
7145
  const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
7073
7146
  if (!server)
7074
7147
  return;
@@ -9012,9 +9085,9 @@ export class AgentFramework {
9012
9085
  // turn ends. The window stores them here, right after the
9013
9086
  // tool_result message, which is exactly where the membrane appends
9014
9087
  // them in the live conversation — same participant, same content
9015
- // blocks, same metadata, and compile() emits recent messages
9016
- // verbatim, so the next turn's compiled prefix byte-matches the
9017
- // live request (prompt-cache safe).
9088
+ // blocks, and neither carries the row's metadata — and compile()
9089
+ // emits recent messages verbatim, so the next turn's compiled prefix
9090
+ // byte-matches the live request (prompt-cache safe).
9018
9091
  //
9019
9092
  // The queue is drained ONLY at the target agent's own boundary:
9020
9093
  // draining at another agent's boundary would store the messages
@@ -9166,13 +9239,19 @@ export class AgentFramework {
9166
9239
  // The last injection that qualifies wins (repinChannel).
9167
9240
  const routing = this.routingOf(agent.name);
9168
9241
  let newLocus;
9169
- for (const inj of midTurnInjections)
9170
- newLocus = this.repinChannel(inj.metadata, routing) ?? newLocus;
9242
+ let newLocusServer;
9243
+ for (const inj of midTurnInjections) {
9244
+ const channelId = this.repinChannel(inj.metadata, routing);
9245
+ if (channelId) {
9246
+ newLocus = channelId;
9247
+ newLocusServer = messageServerId(inj.metadata);
9248
+ }
9249
+ }
9171
9250
  if (newLocus) {
9172
9251
  // What this boundary just stored is newer than any arrival the
9173
9252
  // turn holds, so the arrivals can't move the locus back.
9174
9253
  routing.arrivals = [];
9175
- const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed');
9254
+ const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed', newLocusServer);
9176
9255
  if (notice)
9177
9256
  midTurnInjections.push(notice);
9178
9257
  }
@@ -9254,7 +9333,11 @@ export class AgentFramework {
9254
9333
  // Mid-turn messages collected above ride along as injected user
9255
9334
  // messages (membrane ≥0.5.72) — appended after the tool_result
9256
9335
  // envelope so the next round of THIS turn hears them.
9257
- currentState.stream.provideToolResults(agent.toolResultGuard.submissionResults(membraneResults), midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined);
9336
+ // Each goes as its participant and content alone: a stored row's
9337
+ // metadata is the host's record, not input for membrane.
9338
+ currentState.stream.provideToolResults(agent.toolResultGuard.submissionResults(membraneResults), midTurnInjections.length > 0
9339
+ ? { injectedMessages: midTurnInjections.map(({ participant, content }) => ({ participant, content })) }
9340
+ : undefined);
9258
9341
  // RFC-006 §3.3: consumption is determined at assembly, and this
9259
9342
  // is assembly too when the stream carries the injected messages
9260
9343
  // into its next request. Without the mark they would sit above
@@ -9421,6 +9504,29 @@ export class AgentFramework {
9421
9504
  // ==========================================================================
9422
9505
  // Built-in MCPL event → context message conversion
9423
9506
  // ==========================================================================
9507
+ /** The servers and keys whose drop withoutHostKeys has logged, each as
9508
+ * `${serverId}\u0000${key}`. */
9509
+ hostKeysDropped = new Set();
9510
+ /**
9511
+ * A server's metadata as it is stored: a copy without HOST_METADATA_KEYS.
9512
+ * A server may use one of those names for something of its own, so the
9513
+ * first drop of each key from each server is logged.
9514
+ */
9515
+ withoutHostKeys(serverId, given) {
9516
+ const kept = { ...given };
9517
+ for (const key of HOST_METADATA_KEYS) {
9518
+ if (!Object.hasOwn(kept, key))
9519
+ continue;
9520
+ delete kept[key];
9521
+ const logged = `${serverId}\u0000${key}`;
9522
+ if (this.hostKeysDropped.has(logged))
9523
+ continue;
9524
+ this.hostKeysDropped.add(logged);
9525
+ console.error(`[mcpl] ${serverId}: dropped "${key}" from a message, since only the host writes that key ` +
9526
+ `(logged once for each server and key)`);
9527
+ }
9528
+ return kept;
9529
+ }
9424
9530
  /**
9425
9531
  * Convert an incoming MCPL channel message to a context message.
9426
9532
  * This replaces the old MCPLModule.onProcess() message conversion.
@@ -9437,7 +9543,7 @@ export class AgentFramework {
9437
9543
  return undefined;
9438
9544
  }
9439
9545
  const metadata = {
9440
- ...event.metadata,
9546
+ ...this.withoutHostKeys(event.serverId, event.metadata),
9441
9547
  ...(event.eventId ? { eventId: event.eventId } : {}),
9442
9548
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
9443
9549
  channelId: event.channelId,
@@ -9480,14 +9586,15 @@ export class AgentFramework {
9480
9586
  const id = target.getContextManager().addMessage('user', event.content, metadata);
9481
9587
  // A correction may target an older engagement. Refresh only the
9482
9588
  // binding whose agent actually received the message.
9483
- if (this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) {
9484
- this.conversationRouter.touch(event.channelId);
9589
+ const channel = { serverId: event.serverId, channelId: event.channelId };
9590
+ if (this.conversationRouter.getBinding(channel)?.agentName === target.name) {
9591
+ this.conversationRouter.touch(channel);
9485
9592
  }
9486
9593
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
9487
9594
  if (event.triggerInference) {
9488
9595
  this.pendingRequests.push({
9489
9596
  agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
9490
- channelId: event.channelId,
9597
+ channelId: event.channelId, serverId: event.serverId,
9491
9598
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
9492
9599
  addressed: isAddressedMessage(event.tags, event.metadata),
9493
9600
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
@@ -9559,8 +9666,10 @@ export class AgentFramework {
9559
9666
  source: event.serverId,
9560
9667
  timestamp: Date.now(),
9561
9668
  // Route this turn's auto-published speech back to THIS channel, not
9562
- // the global most-recent-inbound locus (item-3 redux, trunk agents).
9669
+ // the global most-recent-inbound locus (item-3 redux, trunk agents),
9670
+ // through the server that sent it.
9563
9671
  channelId: event.channelId,
9672
+ serverId: event.serverId,
9564
9673
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
9565
9674
  // Addressed messages outrank ambient chatter when a batched wake
9566
9675
  // picks the turn's frozen speech locus.
@@ -9623,7 +9732,7 @@ export class AgentFramework {
9623
9732
  if (!agent) {
9624
9733
  // Bound agent vanished (e.g. external reset) — unbind so the next
9625
9734
  // qualifying message respawns a fresh fork.
9626
- router.unbind(event.channelId);
9735
+ router.unbind({ serverId: event.serverId, channelId: event.channelId });
9627
9736
  this.emitTrace({
9628
9737
  type: 'mcpl:conversation-binding-orphaned',
9629
9738
  channelId: event.channelId,
@@ -9637,7 +9746,7 @@ export class AgentFramework {
9637
9746
  const trigger = decision.trigger && event.triggerInference !== false;
9638
9747
  messageMetadata.triggered = trigger;
9639
9748
  const id = agent.getContextManager().addMessage('user', event.content, messageMetadata);
9640
- router.touch(event.channelId);
9749
+ router.touch({ serverId: event.serverId, channelId: event.channelId });
9641
9750
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
9642
9751
  if (trigger) {
9643
9752
  this.pendingRequests.push({
@@ -9648,6 +9757,7 @@ export class AgentFramework {
9648
9757
  // A fork's home channel wins in routeSpeech regardless, but carry the
9649
9758
  // triggering channel too so the trunk/active path stays consistent.
9650
9759
  channelId: event.channelId,
9760
+ serverId: event.serverId,
9651
9761
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
9652
9762
  addressed: isAddressedMessage(event.tags, event.metadata),
9653
9763
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
@@ -9786,7 +9896,7 @@ export class AgentFramework {
9786
9896
  const decision = this.conversationRouter.route(facts);
9787
9897
  if (decision.kind !== 'spawn')
9788
9898
  return decision;
9789
- return { ...decision, spawned: this.spawnConversationAgent(facts.channelId, decision) };
9899
+ return { ...decision, spawned: this.spawnConversationAgent(facts, decision) };
9790
9900
  }
9791
9901
  }
9792
9902
  /**
@@ -9801,11 +9911,12 @@ export class AgentFramework {
9801
9911
  * so the entry it removes is its own. It's removed before anyone waiting
9802
9912
  * on it resumes, so a waiter that decides again doesn't find it.
9803
9913
  */
9804
- spawnConversationAgent(channelId, decision) {
9914
+ spawnConversationAgent(facts, decision) {
9915
+ const channelId = facts.channelId;
9805
9916
  const spawn = (async () => {
9806
9917
  this.storeConversationGeneration(channelId, decision.generation);
9807
9918
  const agent = await this.createConversationAgent(decision.agentName, channelId);
9808
- this.conversationRouter.bind(channelId, decision.agentName, decision.generation);
9919
+ this.conversationRouter.bind({ serverId: facts.serverId, channelId }, decision.agentName, decision.generation);
9809
9920
  return agent;
9810
9921
  })();
9811
9922
  const done = () => { this.conversationSpawns.delete(channelId); };
@@ -9926,7 +10037,7 @@ export class AgentFramework {
9926
10037
  return;
9927
10038
  this.lastConversationSweep = now;
9928
10039
  for (const binding of this.conversationRouter.expired(now)) {
9929
- this.conversationRouter.unbind(binding.channelId);
10040
+ this.conversationRouter.unbind(binding);
9930
10041
  this.emitTrace({
9931
10042
  type: 'mcpl:conversation-closed',
9932
10043
  channelId: binding.channelId,
@@ -10563,7 +10674,7 @@ export class AgentFramework {
10563
10674
  return explicit.filter((n) => this.agents.has(n));
10564
10675
  if (occ.scope.kind === 'channel' && this.conversationRouter) {
10565
10676
  const router = this.conversationRouter;
10566
- const bound = router.getBinding(occ.scope.id)?.agentName;
10677
+ const bound = router.getBinding({ serverId: occ.serverId, channelId: occ.scope.id })?.agentName;
10567
10678
  if (bound)
10568
10679
  return this.agents.has(bound) ? [bound] : [];
10569
10680
  // No fork yet: the same bind decision an incoming message would get. A
@@ -10668,6 +10779,7 @@ export class AgentFramework {
10668
10779
  source: occ.serverId,
10669
10780
  timestamp: Date.now(),
10670
10781
  channelId,
10782
+ serverId: channelId !== undefined ? occ.serverId : undefined,
10671
10783
  counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
10672
10784
  addressed: isAddressedMessage(occ.tags, origin),
10673
10785
  coalescingSubject: subject,
@@ -10815,7 +10927,7 @@ export class AgentFramework {
10815
10927
  this.channelRegistry.ensureChannelRegistered(event.serverId, triggerChannel.channelId, triggerChannel.label, triggerChannel.metadata);
10816
10928
  }
10817
10929
  const metadata = {
10818
- ...event.origin,
10930
+ ...this.withoutHostKeys(event.serverId, event.origin),
10819
10931
  serverId: event.serverId,
10820
10932
  featureSet: event.featureSet,
10821
10933
  eventId: event.eventId,
@@ -10886,6 +10998,7 @@ export class AgentFramework {
10886
10998
  source: event.serverId,
10887
10999
  timestamp: Date.now(),
10888
11000
  channelId: triggerChannel?.channelId,
11001
+ serverId: triggerChannel ? event.serverId : undefined,
10889
11002
  // DMs and addressed-while-closed messages arrive as push events;
10890
11003
  // they must outrank ambient chatter in a batched wake's locus
10891
11004
  // selection just like their channels/incoming counterparts.
@@ -10906,18 +11019,49 @@ export class AgentFramework {
10906
11019
  * Whether a batched gate wake's addressed route may become `agentName`'s
10907
11020
  * speech locus. The gate broadcasts to every agent, conversation forks
10908
11021
  * included. A fork speaks only in its home channel, and a channel bound to
10909
- * a fork is that fork's conversation, so no other agent takes it.
11022
+ * a fork is that fork's conversation, so no other agent takes it. Two
11023
+ * servers' channels with one id are two conversations, each bound to its
11024
+ * own fork with that id as its home, so a fork takes the route only where
11025
+ * the router binds the route's channel, its server and id, to it; a route
11026
+ * with no server names a fork only where one fork is bound to that id.
10910
11027
  */
10911
- mayTakeGateRoute(agentName, routeChannelId) {
11028
+ mayTakeGateRoute(agentName, routeChannelId, routeServerId) {
10912
11029
  const home = this.conversationAgentHomes.get(agentName);
10913
- if (home !== undefined)
10914
- return home === routeChannelId;
11030
+ if (home !== undefined) {
11031
+ return home === routeChannelId
11032
+ && this.conversationRouter?.getBinding({ serverId: routeServerId, channelId: routeChannelId })?.agentName === agentName;
11033
+ }
10915
11034
  for (const boundChannel of this.conversationAgentHomes.values()) {
10916
11035
  if (boundChannel === routeChannelId)
10917
11036
  return false;
10918
11037
  }
10919
11038
  return true;
10920
11039
  }
11040
+ /**
11041
+ * The channel a push names, and a server that registers it, when another
11042
+ * server registers that channel id and the pushing server doesn't. A
11043
+ * channel's id is the agent's locus on its own, so the reply to such a
11044
+ * push would go out through the server that registers it: the push
11045
+ * boundary refuses it (PushHandler). A push naming a channel no server
11046
+ * registers isn't refused, and registers it for the pushing server
11047
+ * (ensureChannelRegistered).
11048
+ */
11049
+ channelOfAnotherServer(serverId, origin) {
11050
+ if (!this.channelRegistry)
11051
+ return undefined;
11052
+ const channel = this.derivePushEventChannel(origin);
11053
+ if (!channel)
11054
+ return undefined;
11055
+ let holder;
11056
+ for (const entry of this.channelRegistry.listChannelsRaw()) {
11057
+ if (entry.descriptor.id !== channel.channelId)
11058
+ continue;
11059
+ if (entry.serverId === serverId)
11060
+ return undefined;
11061
+ holder ??= entry.serverId;
11062
+ }
11063
+ return holder === undefined ? undefined : { channelId: channel.channelId, holder };
11064
+ }
10921
11065
  /**
10922
11066
  * Derive the MCPL composite channel id (the outbound routing locus) for a
10923
11067
  * push event from its server-defined `origin`, if it carries one.
@@ -11229,6 +11373,7 @@ export class AgentFramework {
11229
11373
  ephemeralSystemPrompt: silentOnly ? trigger?.ephemeralSystemPrompt : undefined,
11230
11374
  silentHeartbeat: silentOnly ? trigger?.silentHeartbeat : undefined,
11231
11375
  channelId: channelReq?.channelId,
11376
+ serverId: channelReq?.channelId !== undefined ? onlyServerFor(requests, channelReq.channelId) : undefined,
11232
11377
  addressed: addressedReq !== undefined,
11233
11378
  // A context-budget restart continues the same logical turn: it keeps
11234
11379
  // the channel for routing but names no author — the restart is its
@@ -11653,8 +11798,8 @@ export class AgentFramework {
11653
11798
  /** The drafts tool's answer to a resident's call, from the model's own
11654
11799
  * dispatch or an agent-origin call through executeToolCall (a puppet's):
11655
11800
  * a failure the handler throws is answered as one. */
11656
- draftsToolResult(agentName, rawInput) {
11657
- return this.handleDraftsTool(agentName, rawInput).catch((err) => ({
11801
+ draftsToolResult(agentName, rawInput, callerTurnId) {
11802
+ return this.handleDraftsTool(agentName, rawInput, callerTurnId).catch((err) => ({
11658
11803
  success: false,
11659
11804
  error: `drafts failed: ${thrownText(err)}`,
11660
11805
  isError: true,
@@ -11666,7 +11811,7 @@ export class AgentFramework {
11666
11811
  * each draft is published verbatim, in order, through the normal publish
11667
11812
  * executor, its attempt journaled durably before the request leaves.
11668
11813
  */
11669
- async handleDraftsTool(agentName, rawInput) {
11814
+ async handleDraftsTool(agentName, rawInput, callerTurnId) {
11670
11815
  const input = (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? rawInput : {});
11671
11816
  // The turn that sends: a resent draft's channel is recorded as engaged on
11672
11817
  // it, as a send tool's is, even when the result comes after the turn.
@@ -11837,7 +11982,17 @@ export class AgentFramework {
11837
11982
  const cand = resolved.candidates?.length ? ` It could mean: ${resolved.candidates.join(', ')}.` : '';
11838
11983
  return refuse(`Destination "${spec}" did not resolve: ${resolved.error}.${cand} Nothing was sent.`);
11839
11984
  }
11840
- const target = registry.resolveDestination({ channelId: resolved.channelId });
11985
+ // A resend into a channel the calling turn recorded a server for goes
11986
+ // through that server (triggerServerFor), where the call was made inside
11987
+ // that turn; a background script's call carries no turn.
11988
+ const callingRouting = this.turnRouting.get(agentName);
11989
+ const triggerServer = callerTurnId !== undefined && callingRouting?.turnId === callerTurnId
11990
+ ? triggerServerFor(callingRouting, resolved.channelId)
11991
+ : undefined;
11992
+ const target = registry.resolveDestination({
11993
+ ...(triggerServer !== undefined ? { serverId: triggerServer } : {}),
11994
+ channelId: resolved.channelId,
11995
+ });
11841
11996
  if ('error' in target)
11842
11997
  return refuse(`Destination "${spec}": ${target.error}. Nothing was sent.`);
11843
11998
  const destination = target.destination;
@@ -12174,8 +12329,10 @@ export class AgentFramework {
12174
12329
  if (metadata?.tuneOut)
12175
12330
  return;
12176
12331
  const channelId = this.repinChannel(metadata, open);
12177
- if (channelId)
12178
- open.arrivals.push({ messageId, channelId });
12332
+ if (channelId) {
12333
+ const serverId = messageServerId(metadata);
12334
+ open.arrivals.push({ messageId, channelId, ...(serverId !== undefined ? { serverId } : {}) });
12335
+ }
12179
12336
  }
12180
12337
  /**
12181
12338
  * Whether `agent`'s store is the residents' one message slot, which every
@@ -12212,12 +12369,12 @@ export class AgentFramework {
12212
12369
  remainingArrival(agent, routing) {
12213
12370
  const cm = agent.getContextManager();
12214
12371
  for (let i = routing.arrivals.length - 1; i >= 0; i--) {
12215
- const { messageId, channelId } = routing.arrivals[i];
12372
+ const arrival = routing.arrivals[i];
12216
12373
  // The store answers null for a message that isn't there. A store that
12217
12374
  // can't be read throws here, as anywhere else in the start, rather
12218
12375
  // than have its candidates read as withdrawn.
12219
- if (cm.getMessage(messageId))
12220
- return channelId;
12376
+ if (cm.getMessage(arrival.messageId))
12377
+ return arrival;
12221
12378
  }
12222
12379
  return undefined;
12223
12380
  }
@@ -12233,11 +12390,13 @@ export class AgentFramework {
12233
12390
  * stored. The locus has moved then too, but the announce baseline hasn't,
12234
12391
  * so the agent's next turn announces where its speech lands.
12235
12392
  */
12236
- repinTurnLocus(agent, routing, channelId, cause) {
12393
+ repinTurnLocus(agent, routing, channelId, cause,
12394
+ /** The server of the message that moved it. */
12395
+ serverId) {
12237
12396
  if (routing.locus === channelId)
12238
12397
  return undefined;
12239
12398
  const prevPin = routing.locus ?? null;
12240
- routing.locus = channelId;
12399
+ setTurnLocus(routing, channelId, serverId);
12241
12400
  const label = this.channelRegistry?.getDescriptor(channelId)?.label;
12242
12401
  const shown = label && label !== channelId
12243
12402
  ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})`
@@ -12463,11 +12622,13 @@ export class AgentFramework {
12463
12622
  * draft's attempt is journaled durably before the request leaves, and its
12464
12623
  * outcome after, exactly as a drafts resend would.
12465
12624
  */
12466
- async deliverWithUnsent(agent, body, channelId, unsent) {
12625
+ async deliverWithUnsent(agent, body, channelId, unsent,
12626
+ /** The server the channel resolves with (triggerServerFor). */
12627
+ serverId) {
12467
12628
  const registry = this.channelRegistry;
12468
12629
  let attemptId;
12469
12630
  if (unsent) {
12470
- const target = registry.resolveDestination({ channelId });
12631
+ const target = registry.resolveDestination({ ...(serverId !== undefined ? { serverId } : {}), channelId });
12471
12632
  if (!('error' in target)) {
12472
12633
  try {
12473
12634
  attemptId = this.proseDrafts.beginAttempt(agent.name, unsent.id, target.destination, 'unsent-token', false);
@@ -12489,7 +12650,7 @@ export class AgentFramework {
12489
12650
  try {
12490
12651
  // Both callers hold a failed delivery's words as a bounce draft, whose
12491
12652
  // notice says what happened, so the registry writes no second marker.
12492
- outcome = await registry.deliverSpeech(agent.name, body, channelId, { heldOnFailure: true });
12653
+ outcome = await registry.deliverSpeech(agent.name, body, channelId, { heldOnFailure: true, ...(serverId !== undefined ? { locusServerId: serverId } : {}) });
12493
12654
  }
12494
12655
  finally {
12495
12656
  if (flight) {
@@ -12579,7 +12740,7 @@ export class AgentFramework {
12579
12740
  continue;
12580
12741
  }
12581
12742
  try {
12582
- const outcome = await this.deliverWithUnsent(agent, body, resolved.channelId, unsent.draft);
12743
+ const outcome = await this.deliverWithUnsent(agent, body, resolved.channelId, unsent.draft, triggerServerFor(routing, resolved.channelId));
12583
12744
  this.recordProseDelivery(routing, outcome.status === 'delivered' ? { delivered: true, channelId: outcome.destination.channelId } : null);
12584
12745
  if (outcome.status === 'delivered') {
12585
12746
  this.proseBounceStreaks.delete(agent.name);
@@ -12623,7 +12784,7 @@ export class AgentFramework {
12623
12784
  }
12624
12785
  const target = sticky ?? locus;
12625
12786
  try {
12626
- const outcome = await this.channelRegistry.routeSpeech(agent.name, envelope, target);
12787
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, envelope, target, speechServerOpts(routing, target));
12627
12788
  this.recordProseDelivery(routing, outcome);
12628
12789
  }
12629
12790
  catch (err) {
@@ -12742,7 +12903,7 @@ export class AgentFramework {
12742
12903
  return;
12743
12904
  }
12744
12905
  try {
12745
- const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft);
12906
+ const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft, triggerServerFor(routing, targetChannel));
12746
12907
  if (outcome.status === 'delivered') {
12747
12908
  this.proseBounceStreaks.delete(name);
12748
12909
  }
@@ -13309,6 +13470,9 @@ export class AgentFramework {
13309
13470
  // turn, which falls back to the global default rather than inheriting
13310
13471
  // a previous turn's channel.
13311
13472
  routing.triggerChannel = trigger?.channelId;
13473
+ routing.triggerOrigin = trigger?.channelId && trigger.serverId
13474
+ ? { channelId: trigger.channelId, serverId: trigger.serverId }
13475
+ : undefined;
13312
13476
  // FREEZE the turn's outbound locus now (turn-frozen routing): the home
13313
13477
  // channel for forks, else the trigger channel, else the global default.
13314
13478
  // Mid-turn injections must not redirect the agent's plain prose
@@ -13323,8 +13487,14 @@ export class AgentFramework {
13323
13487
  // stability).
13324
13488
  if (turnProseRouting !== 'explicit' && turnProseRouting !== 'disabled') {
13325
13489
  const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null;
13326
- if (locus !== null)
13327
- routing.locus = locus;
13490
+ // A locus that is neither the trigger's channel nor the agent's home
13491
+ // is the global fallback: its server is the latest accepted
13492
+ // message's, taken now, with the locus.
13493
+ if (locus !== null) {
13494
+ setTurnLocus(routing, locus, locus !== routing.triggerChannel && !this.conversationAgentHomes.has(agent.name)
13495
+ ? this.channelRegistry?.fallbackServerFor(locus)
13496
+ : undefined);
13497
+ }
13328
13498
  if (attempt === 0)
13329
13499
  this.announceLocusIfChanged(agent.name, locus);
13330
13500
  }
@@ -13340,7 +13510,7 @@ export class AgentFramework {
13340
13510
  : undefined;
13341
13511
  routing.arrivals = [];
13342
13512
  if (arrival)
13343
- this.repinTurnLocus(agent, routing, arrival, 'arrival');
13513
+ this.repinTurnLocus(agent, routing, arrival.channelId, 'arrival', arrival.serverId);
13344
13514
  }
13345
13515
  // A silent start, or a disabled agent's, has no locus to show, whatever a
13346
13516
  // channel_open in a silent turn left on its object.
@@ -13784,7 +13954,7 @@ export class AgentFramework {
13784
13954
  };
13785
13955
  const enqueueSpeech = (text, locus) => {
13786
13956
  chainSpeech(async () => {
13787
- const outcome = await this.channelRegistry.routeSpeech(agent.name, text, locus);
13957
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, text, locus, speechServerOpts(streamRouting, locus));
13788
13958
  this.recordProseDelivery(streamRouting, outcome);
13789
13959
  }, 'mid-turn speech routing failed:');
13790
13960
  };
@@ -13896,7 +14066,7 @@ export class AgentFramework {
13896
14066
  : null;
13897
14067
  const emitOutgoing = (deltas) => {
13898
14068
  for (const rd of deltas) {
13899
- this.channelRegistry.sendOutgoingChunk(rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta);
14069
+ this.channelRegistry.sendOutgoingChunk(rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta, triggerServerFor(streamRouting, rd.channelId));
13900
14070
  }
13901
14071
  };
13902
14072
  // Tool-result-guard publication boundary. While a SUBMITTED batch is
@@ -14631,7 +14801,7 @@ export class AgentFramework {
14631
14801
  console.error(`[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`);
14632
14802
  for (const segment of speechSegments) {
14633
14803
  try {
14634
- const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus);
14804
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus, speechServerOpts(streamRouting, locus));
14635
14805
  this.recordProseDelivery(streamRouting, outcome);
14636
14806
  }
14637
14807
  catch (err) {
@@ -14725,7 +14895,7 @@ export class AgentFramework {
14725
14895
  // Deliver sequentially (await each) so the segments land in order.
14726
14896
  for (const seg of segments) {
14727
14897
  try {
14728
- const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
14898
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus, speechServerOpts(streamRouting, locus));
14729
14899
  this.recordProseDelivery(streamRouting, outcome);
14730
14900
  }
14731
14901
  catch (err) {
@@ -15357,7 +15527,7 @@ export class AgentFramework {
15357
15527
  if (frameReachedTerminal && proseStream) {
15358
15528
  emitOutgoing(proseStream.finish());
15359
15529
  for (const [channelId, text] of proseStream.byChannel()) {
15360
- this.channelRegistry.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
15530
+ this.channelRegistry.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text, triggerServerFor(streamRouting, channelId));
15361
15531
  }
15362
15532
  }
15363
15533
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
@@ -15620,7 +15790,7 @@ export class AgentFramework {
15620
15790
  // agent origin and acts as that resident, as the model's own call does
15621
15791
  // through dispatchToolCall. A module holds no drafts.
15622
15792
  if (call.name === 'drafts' && this.channelRegistry && origin.kind === 'agent') {
15623
- return this.draftsToolResult(origin.agentName, call.input);
15793
+ return this.draftsToolResult(origin.agentName, call.input, call.callerTurnId);
15624
15794
  }
15625
15795
  // The utils meta-tool — programmatic callers (code_execution scripts,
15626
15796
  // ephemerals, ModuleContext.callTool) reach utilities the same way the
@@ -16975,7 +17145,7 @@ export class AgentFramework {
16975
17145
  if (enrichedCall.name === 'drafts' && this.channelRegistry) {
16976
17146
  const startedAt = Date.now();
16977
17147
  this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'drafts', callId: enrichedCall.id, input: enrichedCall.input });
16978
- void this.draftsToolResult(agentName, enrichedCall.input)
17148
+ void this.draftsToolResult(agentName, enrichedCall.input, enrichedCall.callerTurnId)
16979
17149
  .then((result) => {
16980
17150
  this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'drafts', callId: enrichedCall.id, durationMs: Date.now() - startedAt });
16981
17151
  this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'framework', result });
@@ -17966,7 +18136,7 @@ export class AgentFramework {
17966
18136
  const source = this.pushSource(event);
17967
18137
  this.noteInboundAccepted(source);
17968
18138
  return source;
17969
- });
18139
+ }, (serverId, params) => this.channelOfAnotherServer(serverId, params.origin));
17970
18140
  // Server-initiated inference router (Step 6)
17971
18141
  this.inferenceRouter = new InferenceRouter(this.membrane, this.hookOrchestrator, this.featureSetManager, inferenceRouting ?? null, (event) => this.emitTrace(event), (serverId, params) => {
17972
18142
  const server = this.mcplServerRegistry.getServer(serverId);
@@ -20076,7 +20246,16 @@ export class AgentFramework {
20076
20246
  // ordinary wake, starts only once its over-budget boundary has every
20077
20247
  // result of the round.
20078
20248
  const callingTurn = this.routingOf(agentName);
20079
- this.channelRegistry.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName })
20249
+ const inTurn = call.callerTurnId !== undefined && call.callerTurnId === callingTurn.turnId;
20250
+ this.channelRegistry.handleChannelToolCall(call.name, call.input, {
20251
+ kind: 'agent',
20252
+ agentName,
20253
+ // The turn's recorded servers go only with a call made inside that
20254
+ // turn: a background script's call carries no turn, and takes a bare
20255
+ // id only where exactly one server registers it.
20256
+ ...(inTurn && callingTurn.triggerOrigin !== undefined ? { trigger: callingTurn.triggerOrigin } : {}),
20257
+ ...(inTurn && callingTurn.locusOrigin !== undefined ? { locus: callingTurn.locusOrigin } : {}),
20258
+ })
20080
20259
  .then((result) => {
20081
20260
  const durationMs = Date.now() - startTime;
20082
20261
  this.emitTrace({
@@ -20113,7 +20292,13 @@ export class AgentFramework {
20113
20292
  if (openerAgent
20114
20293
  && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')
20115
20294
  && this.openLogical.get(agentName) === callingTurn) {
20116
- callingTurn.locus = opened;
20295
+ const openedOn = this.channelRegistry.resolveDestination({
20296
+ ...(typeof call.input?.serverId === 'string'
20297
+ ? { serverId: call.input.serverId }
20298
+ : {}),
20299
+ channelId: opened,
20300
+ });
20301
+ setTurnLocus(callingTurn, opened, 'error' in openedOn ? undefined : openedOn.destination.serverId);
20117
20302
  this.lastAnnouncedLocus.set(agentName, opened);
20118
20303
  result = {
20119
20304
  ...result,
@@ -20366,9 +20551,10 @@ export class AgentFramework {
20366
20551
  else if (agent?.proseRouting === 'explicit') {
20367
20552
  // Explicit mode: announce to the turn's sticky prose target if the
20368
20553
  // model has set one; otherwise stay quiet — never guess a channel.
20369
- const target = this.turnRouting.get(agentName)?.pin;
20554
+ const routing = this.turnRouting.get(agentName);
20555
+ const target = routing?.pin;
20370
20556
  if (target) {
20371
- this.channelRegistry.routeSpeech(agentName, text, target).catch((err) => {
20557
+ this.channelRegistry.routeSpeech(agentName, text, target, speechServerOpts(call.callerTurnId === routing?.turnId ? routing : undefined, target)).catch((err) => {
20372
20558
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
20373
20559
  });
20374
20560
  }
@@ -20377,8 +20563,9 @@ export class AgentFramework {
20377
20563
  }
20378
20564
  }
20379
20565
  else {
20380
- const locus = this.turnRouting.get(agentName)?.locus ?? null;
20381
- this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => {
20566
+ const routing = this.turnRouting.get(agentName);
20567
+ const locus = routing?.locus ?? null;
20568
+ this.channelRegistry.routeSpeech(agentName, text, locus, speechServerOpts(call.callerTurnId === routing?.turnId ? routing : undefined, locus)).catch((err) => {
20382
20569
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
20383
20570
  });
20384
20571
  }