@animalabs/agent-framework 0.23.2 → 0.23.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -523,8 +523,55 @@ function announcedDomainsOf(announce) {
523
523
  function resumesLogicalTurn(reason) {
524
524
  return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry';
525
525
  }
526
+ /** The server a stored channel message came from, as the host wrote it. */
527
+ function messageServerId(metadata) {
528
+ const serverId = metadata?.serverId;
529
+ return typeof serverId === 'string' && serverId ? serverId : undefined;
530
+ }
526
531
  /** The last TurnRouting.turnId given out: ids increase, and are unique within the process. */
527
532
  let lastTurnId = 0;
533
+ /**
534
+ * The server every request naming `channelId` came from, or undefined where
535
+ * one names no server or they name more than one (TurnRouting.triggerOrigin).
536
+ */
537
+ function onlyServerFor(requests, channelId) {
538
+ let serverId;
539
+ for (const r of requests) {
540
+ if (r.channelId !== channelId)
541
+ continue;
542
+ if (!r.serverId || (serverId !== undefined && r.serverId !== serverId))
543
+ return undefined;
544
+ serverId = r.serverId;
545
+ }
546
+ return serverId;
547
+ }
548
+ /**
549
+ * The server a send in `routing`'s turn resolves `channelId` with: the
550
+ * trigger's, where `channelId` is the trigger's channel
551
+ * (TurnRouting.triggerOrigin), else the locus's, where it's the channel the
552
+ * locus's server put there (TurnRouting.locusOrigin).
553
+ */
554
+ function triggerServerFor(routing, channelId) {
555
+ if (routing === undefined || !channelId)
556
+ return undefined;
557
+ if (routing.triggerOrigin?.channelId === channelId)
558
+ return routing.triggerOrigin.serverId;
559
+ if (routing.locusOrigin?.channelId === channelId)
560
+ return routing.locusOrigin.serverId;
561
+ return undefined;
562
+ }
563
+ /** Move a turn's locus, with the server of the message that put it there
564
+ * (TurnRouting.locusOrigin), or none. */
565
+ function setTurnLocus(routing, channelId, serverId) {
566
+ routing.locus = channelId;
567
+ routing.locusOrigin = serverId !== undefined ? { channelId, serverId } : undefined;
568
+ }
569
+ /** routeSpeech's options for speech into `channelId` in `routing`'s turn:
570
+ * the trigger's server, where it's the trigger's channel (triggerServerFor). */
571
+ function speechServerOpts(routing, channelId) {
572
+ const serverId = triggerServerFor(routing, channelId);
573
+ return serverId !== undefined ? { locusServerId: serverId } : undefined;
574
+ }
528
575
  function freshTurnRouting() {
529
576
  return {
530
577
  turnId: ++lastTurnId,
@@ -2150,8 +2197,8 @@ export class AgentFramework {
2150
2197
  // route applies to a fork only when it IS that home, and never to
2151
2198
  // another agent when a fork owns that channel. Otherwise a fork
2152
2199
  // bound elsewhere would show typing where it isn't replying.
2153
- ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId)
2154
- ? { channelId: provenance.routeChannelId, addressed: true }
2200
+ ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId, provenance.routeServerId)
2201
+ ? { channelId: provenance.routeChannelId, serverId: provenance.routeServerId, addressed: true }
2155
2202
  : {}),
2156
2203
  });
2157
2204
  },
@@ -7451,7 +7498,9 @@ export class AgentFramework {
7451
7498
  // tools/call directly on its connection (bare tool name — no prefix
7452
7499
  // games), bypassing the agent event queue so no synthetic tool-result
7453
7500
  // is injected into a turn the agent never took.
7454
- const serverId = this.channelRegistry?.getChannelServerId(incoming.channelId);
7501
+ // Through the server whose message it marks: the latest accepted
7502
+ // message's, kept beside its channel and id (fallbackServerFor).
7503
+ const serverId = this.channelRegistry?.fallbackServerFor(incoming.channelId);
7455
7504
  const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
7456
7505
  if (!server) {
7457
7506
  console.error(`[inference-refusal] reaction skipped: no MCPL server for locus ${logValue(incoming.channelId)} (agent=${agentName})`);
@@ -9685,13 +9734,19 @@ export class AgentFramework {
9685
9734
  // The last injection that qualifies wins (repinChannel).
9686
9735
  const routing = this.routingOf(agent.name);
9687
9736
  let newLocus;
9688
- for (const inj of midTurnInjections)
9689
- newLocus = this.repinChannel(inj.metadata, routing) ?? newLocus;
9737
+ let newLocusServer;
9738
+ for (const inj of midTurnInjections) {
9739
+ const channelId = this.repinChannel(inj.metadata, routing);
9740
+ if (channelId) {
9741
+ newLocus = channelId;
9742
+ newLocusServer = messageServerId(inj.metadata);
9743
+ }
9744
+ }
9690
9745
  if (newLocus) {
9691
9746
  // What this boundary just stored is newer than any arrival the
9692
9747
  // turn holds, so the arrivals can't move the locus back.
9693
9748
  routing.arrivals = [];
9694
- const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed');
9749
+ const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed', newLocusServer);
9695
9750
  if (notice)
9696
9751
  midTurnInjections.push(notice);
9697
9752
  }
@@ -10028,14 +10083,15 @@ export class AgentFramework {
10028
10083
  const id = target.getContextManager().addMessage('user', event.content, metadata);
10029
10084
  // A correction may target an older engagement. Refresh only the
10030
10085
  // binding whose agent actually received the message.
10031
- if (this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) {
10032
- this.conversationRouter.touch(event.channelId);
10086
+ const channel = { serverId: event.serverId, channelId: event.channelId };
10087
+ if (this.conversationRouter.getBinding(channel)?.agentName === target.name) {
10088
+ this.conversationRouter.touch(channel);
10033
10089
  }
10034
10090
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
10035
10091
  if (event.triggerInference) {
10036
10092
  this.pendingRequests.push({
10037
10093
  agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
10038
- channelId: event.channelId,
10094
+ channelId: event.channelId, serverId: event.serverId,
10039
10095
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
10040
10096
  addressed: isAddressedMessage(event.tags, event.metadata),
10041
10097
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
@@ -10107,8 +10163,10 @@ export class AgentFramework {
10107
10163
  source: event.serverId,
10108
10164
  timestamp: Date.now(),
10109
10165
  // Route this turn's auto-published speech back to THIS channel, not
10110
- // the global most-recent-inbound locus (item-3 redux, trunk agents).
10166
+ // the global most-recent-inbound locus (item-3 redux, trunk agents),
10167
+ // through the server that sent it.
10111
10168
  channelId: event.channelId,
10169
+ serverId: event.serverId,
10112
10170
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
10113
10171
  // Addressed messages outrank ambient chatter when a batched wake
10114
10172
  // picks the turn's frozen speech locus.
@@ -10171,7 +10229,7 @@ export class AgentFramework {
10171
10229
  if (!agent) {
10172
10230
  // Bound agent vanished (e.g. external reset) — unbind so the next
10173
10231
  // qualifying message respawns a fresh fork.
10174
- router.unbind(event.channelId);
10232
+ router.unbind({ serverId: event.serverId, channelId: event.channelId });
10175
10233
  this.emitTrace({
10176
10234
  type: 'mcpl:conversation-binding-orphaned',
10177
10235
  channelId: event.channelId,
@@ -10185,7 +10243,7 @@ export class AgentFramework {
10185
10243
  const trigger = decision.trigger && event.triggerInference !== false;
10186
10244
  messageMetadata.triggered = trigger;
10187
10245
  const id = agent.getContextManager().addMessage('user', event.content, messageMetadata);
10188
- router.touch(event.channelId);
10246
+ router.touch({ serverId: event.serverId, channelId: event.channelId });
10189
10247
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
10190
10248
  if (trigger) {
10191
10249
  this.pendingRequests.push({
@@ -10196,6 +10254,7 @@ export class AgentFramework {
10196
10254
  // A fork's home channel wins in routeSpeech regardless, but carry the
10197
10255
  // triggering channel too so the trunk/active path stays consistent.
10198
10256
  channelId: event.channelId,
10257
+ serverId: event.serverId,
10199
10258
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
10200
10259
  addressed: isAddressedMessage(event.tags, event.metadata),
10201
10260
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
@@ -10334,7 +10393,7 @@ export class AgentFramework {
10334
10393
  const decision = this.conversationRouter.route(facts);
10335
10394
  if (decision.kind !== 'spawn')
10336
10395
  return decision;
10337
- return { ...decision, spawned: this.spawnConversationAgent(facts.channelId, decision) };
10396
+ return { ...decision, spawned: this.spawnConversationAgent(facts, decision) };
10338
10397
  }
10339
10398
  }
10340
10399
  /**
@@ -10349,11 +10408,12 @@ export class AgentFramework {
10349
10408
  * so the entry it removes is its own. It's removed before anyone waiting
10350
10409
  * on it resumes, so a waiter that decides again doesn't find it.
10351
10410
  */
10352
- spawnConversationAgent(channelId, decision) {
10411
+ spawnConversationAgent(facts, decision) {
10412
+ const channelId = facts.channelId;
10353
10413
  const spawn = (async () => {
10354
10414
  this.storeConversationGeneration(channelId, decision.generation);
10355
10415
  const agent = await this.createConversationAgent(decision.agentName, channelId);
10356
- this.conversationRouter.bind(channelId, decision.agentName, decision.generation);
10416
+ this.conversationRouter.bind({ serverId: facts.serverId, channelId }, decision.agentName, decision.generation);
10357
10417
  return agent;
10358
10418
  })();
10359
10419
  const done = () => { this.conversationSpawns.delete(channelId); };
@@ -10476,7 +10536,7 @@ export class AgentFramework {
10476
10536
  return;
10477
10537
  this.lastConversationSweep = now;
10478
10538
  for (const binding of this.conversationRouter.expired(now)) {
10479
- this.conversationRouter.unbind(binding.channelId);
10539
+ this.conversationRouter.unbind(binding);
10480
10540
  this.emitTrace({
10481
10541
  type: 'mcpl:conversation-closed',
10482
10542
  channelId: binding.channelId,
@@ -11124,7 +11184,7 @@ export class AgentFramework {
11124
11184
  return explicit.filter((n) => this.agents.has(n));
11125
11185
  if (occ.scope.kind === 'channel' && this.conversationRouter) {
11126
11186
  const router = this.conversationRouter;
11127
- const bound = router.getBinding(occ.scope.id)?.agentName;
11187
+ const bound = router.getBinding({ serverId: occ.serverId, channelId: occ.scope.id })?.agentName;
11128
11188
  if (bound)
11129
11189
  return this.agents.has(bound) ? [bound] : [];
11130
11190
  // No fork yet: the same bind decision an incoming message would get. A
@@ -11236,6 +11296,7 @@ export class AgentFramework {
11236
11296
  source: occ.serverId,
11237
11297
  timestamp: Date.now(),
11238
11298
  channelId,
11299
+ serverId: channelId !== undefined ? occ.serverId : undefined,
11239
11300
  counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
11240
11301
  addressed: isAddressedMessage(occ.tags, origin),
11241
11302
  ...(plain ? {} : { coalescingSubject: subject, coalescingBatch: true }),
@@ -11453,6 +11514,7 @@ export class AgentFramework {
11453
11514
  source: event.serverId,
11454
11515
  timestamp: Date.now(),
11455
11516
  channelId: triggerChannel?.channelId,
11517
+ serverId: triggerChannel ? event.serverId : undefined,
11456
11518
  // DMs and addressed-while-closed messages arrive as push events;
11457
11519
  // they must outrank ambient chatter in a batched wake's locus
11458
11520
  // selection just like their channels/incoming counterparts.
@@ -11476,18 +11538,49 @@ export class AgentFramework {
11476
11538
  * Whether a batched gate wake's addressed route may become `agentName`'s
11477
11539
  * speech locus. The gate broadcasts to every agent, conversation forks
11478
11540
  * included. A fork speaks only in its home channel, and a channel bound to
11479
- * a fork is that fork's conversation, so no other agent takes it.
11541
+ * a fork is that fork's conversation, so no other agent takes it. Two
11542
+ * servers' channels with one id are two conversations, each bound to its
11543
+ * own fork with that id as its home, so a fork takes the route only where
11544
+ * the router binds the route's channel, its server and id, to it; a route
11545
+ * with no server names a fork only where one fork is bound to that id.
11480
11546
  */
11481
- mayTakeGateRoute(agentName, routeChannelId) {
11547
+ mayTakeGateRoute(agentName, routeChannelId, routeServerId) {
11482
11548
  const home = this.conversationAgentHomes.get(agentName);
11483
- if (home !== undefined)
11484
- return home === routeChannelId;
11549
+ if (home !== undefined) {
11550
+ return home === routeChannelId
11551
+ && this.conversationRouter?.getBinding({ serverId: routeServerId, channelId: routeChannelId })?.agentName === agentName;
11552
+ }
11485
11553
  for (const boundChannel of this.conversationAgentHomes.values()) {
11486
11554
  if (boundChannel === routeChannelId)
11487
11555
  return false;
11488
11556
  }
11489
11557
  return true;
11490
11558
  }
11559
+ /**
11560
+ * The channel a push names, and a server that registers it, when another
11561
+ * server registers that channel id and the pushing server doesn't. A
11562
+ * channel's id is the agent's locus on its own, so the reply to such a
11563
+ * push would go out through the server that registers it: the push
11564
+ * boundary refuses it (PushHandler). A push naming a channel no server
11565
+ * registers isn't refused, and registers it for the pushing server
11566
+ * (ensureChannelRegistered).
11567
+ */
11568
+ channelOfAnotherServer(serverId, origin) {
11569
+ if (!this.channelRegistry)
11570
+ return undefined;
11571
+ const channel = this.derivePushEventChannel(origin);
11572
+ if (!channel)
11573
+ return undefined;
11574
+ let holder;
11575
+ for (const entry of this.channelRegistry.listChannelsRaw()) {
11576
+ if (entry.descriptor.id !== channel.channelId)
11577
+ continue;
11578
+ if (entry.serverId === serverId)
11579
+ return undefined;
11580
+ holder ??= entry.serverId;
11581
+ }
11582
+ return holder === undefined ? undefined : { channelId: channel.channelId, holder };
11583
+ }
11491
11584
  /**
11492
11585
  * Derive the MCPL composite channel id (the outbound routing locus) for a
11493
11586
  * push event from its server-defined `origin`, if it carries one.
@@ -11806,6 +11899,7 @@ export class AgentFramework {
11806
11899
  suppressProse: silentOnly ? trigger?.suppressProse : undefined,
11807
11900
  silentHeartbeat: silentOnly ? trigger?.silentHeartbeat : undefined,
11808
11901
  channelId: channelReq?.channelId,
11902
+ serverId: channelReq?.channelId !== undefined ? onlyServerFor(requests, channelReq.channelId) : undefined,
11809
11903
  addressed: addressedReq !== undefined,
11810
11904
  // A context-budget restart continues the same logical turn: it keeps
11811
11905
  // the channel for routing but names no author — the restart is its
@@ -12255,8 +12349,8 @@ export class AgentFramework {
12255
12349
  /** The drafts tool's answer to a resident's call, from the model's own
12256
12350
  * dispatch or an agent-origin call through executeToolCall (a puppet's):
12257
12351
  * a failure the handler throws is answered as one. */
12258
- draftsToolResult(agentName, rawInput) {
12259
- return this.handleDraftsTool(agentName, rawInput).catch((err) => ({
12352
+ draftsToolResult(agentName, rawInput, callerTurnId) {
12353
+ return this.handleDraftsTool(agentName, rawInput, callerTurnId).catch((err) => ({
12260
12354
  success: false,
12261
12355
  error: `drafts failed: ${thrownText(err)}`,
12262
12356
  isError: true,
@@ -12268,7 +12362,7 @@ export class AgentFramework {
12268
12362
  * each draft is published verbatim, in order, through the normal publish
12269
12363
  * executor, its attempt journaled durably before the request leaves.
12270
12364
  */
12271
- async handleDraftsTool(agentName, rawInput) {
12365
+ async handleDraftsTool(agentName, rawInput, callerTurnId) {
12272
12366
  const input = (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? rawInput : {});
12273
12367
  // The turn that sends: a resent draft's channel is recorded as engaged on
12274
12368
  // it, as a send tool's is, even when the result comes after the turn.
@@ -12439,7 +12533,17 @@ export class AgentFramework {
12439
12533
  const cand = resolved.candidates?.length ? ` It could mean: ${resolved.candidates.join(', ')}.` : '';
12440
12534
  return refuse(`Destination "${spec}" did not resolve: ${resolved.error}.${cand} Nothing was sent.`);
12441
12535
  }
12442
- const target = registry.resolveDestination({ channelId: resolved.channelId });
12536
+ // A resend into a channel the calling turn recorded a server for goes
12537
+ // through that server (triggerServerFor), where the call was made inside
12538
+ // that turn; a background script's call carries no turn.
12539
+ const callingRouting = this.turnRouting.get(agentName);
12540
+ const triggerServer = callerTurnId !== undefined && callingRouting?.turnId === callerTurnId
12541
+ ? triggerServerFor(callingRouting, resolved.channelId)
12542
+ : undefined;
12543
+ const target = registry.resolveDestination({
12544
+ ...(triggerServer !== undefined ? { serverId: triggerServer } : {}),
12545
+ channelId: resolved.channelId,
12546
+ });
12443
12547
  if ('error' in target)
12444
12548
  return refuse(`Destination "${spec}": ${target.error}. Nothing was sent.`);
12445
12549
  const destination = target.destination;
@@ -12783,8 +12887,10 @@ export class AgentFramework {
12783
12887
  if (metadata?.tuneOut)
12784
12888
  return;
12785
12889
  const channelId = this.repinChannel(metadata, open);
12786
- if (channelId)
12787
- open.arrivals.push({ messageId, channelId });
12890
+ if (channelId) {
12891
+ const serverId = messageServerId(metadata);
12892
+ open.arrivals.push({ messageId, channelId, ...(serverId !== undefined ? { serverId } : {}) });
12893
+ }
12788
12894
  }
12789
12895
  /**
12790
12896
  * Whether `agent`'s store is the residents' one message slot, which every
@@ -12821,12 +12927,12 @@ export class AgentFramework {
12821
12927
  remainingArrival(agent, routing) {
12822
12928
  const cm = agent.getContextManager();
12823
12929
  for (let i = routing.arrivals.length - 1; i >= 0; i--) {
12824
- const { messageId, channelId } = routing.arrivals[i];
12930
+ const arrival = routing.arrivals[i];
12825
12931
  // The store answers null for a message that isn't there. A store that
12826
12932
  // can't be read throws here, as anywhere else in the start, rather
12827
12933
  // than have its candidates read as withdrawn.
12828
- if (cm.getMessage(messageId))
12829
- return channelId;
12934
+ if (cm.getMessage(arrival.messageId))
12935
+ return arrival;
12830
12936
  }
12831
12937
  return undefined;
12832
12938
  }
@@ -12842,11 +12948,13 @@ export class AgentFramework {
12842
12948
  * stored. The locus has moved then too, but the announce baseline hasn't,
12843
12949
  * so the agent's next turn announces where its speech lands.
12844
12950
  */
12845
- repinTurnLocus(agent, routing, channelId, cause) {
12951
+ repinTurnLocus(agent, routing, channelId, cause,
12952
+ /** The server of the message that moved it. */
12953
+ serverId) {
12846
12954
  if (routing.locus === channelId)
12847
12955
  return undefined;
12848
12956
  const prevPin = routing.locus ?? null;
12849
- routing.locus = channelId;
12957
+ setTurnLocus(routing, channelId, serverId);
12850
12958
  const label = this.channelRegistry?.getDescriptor(channelId)?.label;
12851
12959
  const shown = label && label !== channelId
12852
12960
  ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})`
@@ -13102,11 +13210,13 @@ export class AgentFramework {
13102
13210
  * draft's attempt is journaled durably before the request leaves, and its
13103
13211
  * outcome after, exactly as a drafts resend would.
13104
13212
  */
13105
- async deliverWithUnsent(agent, body, channelId, unsent) {
13213
+ async deliverWithUnsent(agent, body, channelId, unsent,
13214
+ /** The server the channel resolves with (triggerServerFor). */
13215
+ serverId) {
13106
13216
  const registry = this.channelRegistry;
13107
13217
  let attemptId;
13108
13218
  if (unsent) {
13109
- const target = registry.resolveDestination({ channelId });
13219
+ const target = registry.resolveDestination({ ...(serverId !== undefined ? { serverId } : {}), channelId });
13110
13220
  if (!('error' in target)) {
13111
13221
  try {
13112
13222
  attemptId = this.proseDrafts.beginAttempt(agent.name, unsent.id, target.destination, 'unsent-token', false);
@@ -13128,7 +13238,7 @@ export class AgentFramework {
13128
13238
  try {
13129
13239
  // Both callers hold a failed delivery's words as a bounce draft, whose
13130
13240
  // notice says what happened, so the registry writes no second marker.
13131
- outcome = await registry.deliverSpeech(agent.name, body, channelId, { heldOnFailure: true });
13241
+ outcome = await registry.deliverSpeech(agent.name, body, channelId, { heldOnFailure: true, ...(serverId !== undefined ? { locusServerId: serverId } : {}) });
13132
13242
  }
13133
13243
  finally {
13134
13244
  if (flight) {
@@ -13220,7 +13330,7 @@ export class AgentFramework {
13220
13330
  continue;
13221
13331
  }
13222
13332
  try {
13223
- const outcome = await this.deliverWithUnsent(agent, body, resolved.channelId, unsent.draft);
13333
+ const outcome = await this.deliverWithUnsent(agent, body, resolved.channelId, unsent.draft, triggerServerFor(routing, resolved.channelId));
13224
13334
  this.recordProseDelivery(routing, outcome.status === 'delivered' ? { delivered: true, channelId: outcome.destination.channelId } : null);
13225
13335
  if (outcome.status === 'delivered') {
13226
13336
  this.proseBounceStreaks.delete(agent.name);
@@ -13264,7 +13374,7 @@ export class AgentFramework {
13264
13374
  }
13265
13375
  const target = sticky ?? locus;
13266
13376
  try {
13267
- const outcome = await this.channelRegistry.routeSpeech(agent.name, envelope, target);
13377
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, envelope, target, speechServerOpts(routing, target));
13268
13378
  this.recordProseDelivery(routing, outcome);
13269
13379
  }
13270
13380
  catch (err) {
@@ -13386,7 +13496,7 @@ export class AgentFramework {
13386
13496
  return;
13387
13497
  }
13388
13498
  try {
13389
- const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft);
13499
+ const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft, triggerServerFor(routing, targetChannel));
13390
13500
  if (outcome.status === 'delivered') {
13391
13501
  this.proseBounceStreaks.delete(name);
13392
13502
  }
@@ -14073,6 +14183,9 @@ export class AgentFramework {
14073
14183
  // turn, which falls back to the global default rather than inheriting
14074
14184
  // a previous turn's channel.
14075
14185
  routing.triggerChannel = trigger?.channelId;
14186
+ routing.triggerOrigin = trigger?.channelId && trigger.serverId
14187
+ ? { channelId: trigger.channelId, serverId: trigger.serverId }
14188
+ : undefined;
14076
14189
  // FREEZE the turn's outbound locus now (turn-frozen routing): the home
14077
14190
  // channel for forks, else the trigger channel, else the global default,
14078
14191
  // which an ephemeral run never takes (takesGlobalFallback).
@@ -14088,8 +14201,14 @@ export class AgentFramework {
14088
14201
  // stability).
14089
14202
  if (turnProseRouting !== 'explicit' && turnProseRouting !== 'disabled') {
14090
14203
  const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null;
14091
- if (locus !== null)
14092
- routing.locus = locus;
14204
+ // A locus that is neither the trigger's channel nor the agent's home
14205
+ // is the global fallback: its server is the latest accepted
14206
+ // message's, taken now, with the locus.
14207
+ if (locus !== null) {
14208
+ setTurnLocus(routing, locus, locus !== routing.triggerChannel && !this.conversationAgentHomes.has(agent.name)
14209
+ ? this.channelRegistry?.fallbackServerFor(locus)
14210
+ : undefined);
14211
+ }
14093
14212
  if (attempt === 0)
14094
14213
  this.announceLocusIfChanged(agent.name, locus);
14095
14214
  }
@@ -14105,7 +14224,7 @@ export class AgentFramework {
14105
14224
  : undefined;
14106
14225
  routing.arrivals = [];
14107
14226
  if (arrival)
14108
- this.repinTurnLocus(agent, routing, arrival, 'arrival');
14227
+ this.repinTurnLocus(agent, routing, arrival.channelId, 'arrival', arrival.serverId);
14109
14228
  }
14110
14229
  // A silent start, or a disabled agent's, has no locus to show, whatever a
14111
14230
  // channel_open in a silent turn left on its object.
@@ -14624,7 +14743,7 @@ export class AgentFramework {
14624
14743
  };
14625
14744
  const enqueueSpeech = (text, locus) => {
14626
14745
  chainSpeech(async () => {
14627
- const outcome = await this.channelRegistry.routeSpeech(agent.name, text, locus);
14746
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, text, locus, speechServerOpts(streamRouting, locus));
14628
14747
  this.recordProseDelivery(streamRouting, outcome);
14629
14748
  }, 'mid-turn speech routing failed:');
14630
14749
  };
@@ -14739,7 +14858,7 @@ export class AgentFramework {
14739
14858
  : null;
14740
14859
  const emitOutgoing = (deltas) => {
14741
14860
  for (const rd of deltas) {
14742
- this.channelRegistry.sendOutgoingChunk(rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta);
14861
+ this.channelRegistry.sendOutgoingChunk(rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta, triggerServerFor(streamRouting, rd.channelId));
14743
14862
  }
14744
14863
  };
14745
14864
  // Tool-result-guard publication boundary. While a SUBMITTED batch is
@@ -15587,7 +15706,7 @@ export class AgentFramework {
15587
15706
  console.error(`[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`);
15588
15707
  for (const segment of speechSegments) {
15589
15708
  try {
15590
- const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus);
15709
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus, speechServerOpts(streamRouting, locus));
15591
15710
  this.recordProseDelivery(streamRouting, outcome);
15592
15711
  }
15593
15712
  catch (err) {
@@ -15681,7 +15800,7 @@ export class AgentFramework {
15681
15800
  // Deliver sequentially (await each) so the segments land in order.
15682
15801
  for (const seg of segments) {
15683
15802
  try {
15684
- const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
15803
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus, speechServerOpts(streamRouting, locus));
15685
15804
  this.recordProseDelivery(streamRouting, outcome);
15686
15805
  }
15687
15806
  catch (err) {
@@ -16327,7 +16446,7 @@ export class AgentFramework {
16327
16446
  if (frameReachedTerminal && proseStream) {
16328
16447
  emitOutgoing(proseStream.finish());
16329
16448
  for (const [channelId, text] of proseStream.byChannel()) {
16330
- this.channelRegistry.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
16449
+ this.channelRegistry.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text, triggerServerFor(streamRouting, channelId));
16331
16450
  }
16332
16451
  }
16333
16452
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
@@ -16602,7 +16721,7 @@ export class AgentFramework {
16602
16721
  // agent origin and acts as that resident, as the model's own call does
16603
16722
  // through dispatchToolCall. A module holds no drafts.
16604
16723
  if (call.name === 'drafts' && this.channelRegistry && origin.kind === 'agent') {
16605
- return this.draftsToolResult(origin.agentName, call.input);
16724
+ return this.draftsToolResult(origin.agentName, call.input, call.callerTurnId);
16606
16725
  }
16607
16726
  // The utils meta-tool — programmatic callers (code_execution scripts,
16608
16727
  // ephemerals, ModuleContext.callTool) reach utilities the same way the
@@ -18079,7 +18198,7 @@ export class AgentFramework {
18079
18198
  if (enrichedCall.name === 'drafts' && this.channelRegistry) {
18080
18199
  const startedAt = Date.now();
18081
18200
  this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'drafts', callId: enrichedCall.id, input: enrichedCall.input });
18082
- void this.draftsToolResult(agentName, enrichedCall.input)
18201
+ void this.draftsToolResult(agentName, enrichedCall.input, enrichedCall.callerTurnId)
18083
18202
  .then((result) => {
18084
18203
  this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'drafts', callId: enrichedCall.id, durationMs: Date.now() - startedAt });
18085
18204
  this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'framework', result });
@@ -19471,7 +19590,7 @@ export class AgentFramework {
19471
19590
  const source = this.pushSource(event);
19472
19591
  this.noteInboundAccepted(source);
19473
19592
  return source;
19474
- });
19593
+ }, (serverId, params) => this.channelOfAnotherServer(serverId, params.origin));
19475
19594
  // Server-initiated inference router (Step 6)
19476
19595
  this.inferenceRouter = new InferenceRouter(this.membrane, this.hookOrchestrator, this.featureSetManager, inferenceRouting ?? null, (event) => this.emitTrace(event));
19477
19596
  // Checkpoint manager (Step 8), built and loaded at the top
@@ -22736,7 +22855,16 @@ export class AgentFramework {
22736
22855
  // ordinary wake, starts only once its over-budget boundary has every
22737
22856
  // result of the round.
22738
22857
  const callingTurn = this.routingOf(agentName);
22739
- this.channelRegistry.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName })
22858
+ const inTurn = call.callerTurnId !== undefined && call.callerTurnId === callingTurn.turnId;
22859
+ this.channelRegistry.handleChannelToolCall(call.name, call.input, {
22860
+ kind: 'agent',
22861
+ agentName,
22862
+ // The turn's recorded servers go only with a call made inside that
22863
+ // turn: a background script's call carries no turn, and takes a bare
22864
+ // id only where exactly one server registers it.
22865
+ ...(inTurn && callingTurn.triggerOrigin !== undefined ? { trigger: callingTurn.triggerOrigin } : {}),
22866
+ ...(inTurn && callingTurn.locusOrigin !== undefined ? { locus: callingTurn.locusOrigin } : {}),
22867
+ })
22740
22868
  .then((result) => {
22741
22869
  const durationMs = Date.now() - startTime;
22742
22870
  this.emitTrace({
@@ -22773,7 +22901,13 @@ export class AgentFramework {
22773
22901
  if (openerAgent
22774
22902
  && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')
22775
22903
  && this.openLogical.get(agentName) === callingTurn) {
22776
- callingTurn.locus = opened;
22904
+ const openedOn = this.channelRegistry.resolveDestination({
22905
+ ...(typeof call.input?.serverId === 'string'
22906
+ ? { serverId: call.input.serverId }
22907
+ : {}),
22908
+ channelId: opened,
22909
+ });
22910
+ setTurnLocus(callingTurn, opened, 'error' in openedOn ? undefined : openedOn.destination.serverId);
22777
22911
  this.lastAnnouncedLocus.set(agentName, opened);
22778
22912
  result = {
22779
22913
  ...result,
@@ -23026,9 +23160,10 @@ export class AgentFramework {
23026
23160
  else if (agent?.proseRouting === 'explicit') {
23027
23161
  // Explicit mode: announce to the turn's sticky prose target if the
23028
23162
  // model has set one; otherwise stay quiet — never guess a channel.
23029
- const target = this.turnRouting.get(agentName)?.pin;
23163
+ const routing = this.turnRouting.get(agentName);
23164
+ const target = routing?.pin;
23030
23165
  if (target) {
23031
- this.channelRegistry.routeSpeech(agentName, text, target).catch((err) => {
23166
+ this.channelRegistry.routeSpeech(agentName, text, target, speechServerOpts(call.callerTurnId === routing?.turnId ? routing : undefined, target)).catch((err) => {
23032
23167
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
23033
23168
  });
23034
23169
  }
@@ -23037,8 +23172,9 @@ export class AgentFramework {
23037
23172
  }
23038
23173
  }
23039
23174
  else {
23040
- const locus = this.turnRouting.get(agentName)?.locus ?? null;
23041
- this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => {
23175
+ const routing = this.turnRouting.get(agentName);
23176
+ const locus = routing?.locus ?? null;
23177
+ this.channelRegistry.routeSpeech(agentName, text, locus, speechServerOpts(call.callerTurnId === routing?.turnId ? routing : undefined, locus)).catch((err) => {
23042
23178
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
23043
23179
  });
23044
23180
  }