@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.
@@ -180,6 +180,27 @@ const silenceCauseOf = (calls) => {
180
180
  return 'private';
181
181
  return calls.some(isSilencingCall) ? 'send' : null;
182
182
  };
183
+ /**
184
+ * The keys of a stored message's metadata that only the host writes and that
185
+ * some reader takes on its own as what the message is: `system` and `kind`
186
+ * make it a host notice of that kind, `draftOwner` and `draftIds` one that
187
+ * names those drafts, `tuneOut` a message a tune-out holds from the
188
+ * residents, `coalescingSubject` (beside the occurrence's `eventId`) the
189
+ * delivery of a coalesced occurrence, `deferredWriteId` the landed copy of a
190
+ * deferred write, `external` a module's own row for an outside message, and
191
+ * `openaiResponsesItems` membrane's mark of an imported Responses history. A
192
+ * server's metadata, a channel message's `metadata` or a push event's
193
+ * `origin`, is stored without them, and the host writes its own after it:
194
+ * MCPL SPEC §16.6 makes metadata the producer's untrusted claims, which a
195
+ * host may disbelieve. Keys a server writes by protocol, such as `tags` and
196
+ * `mentioned`, stay. So do the keys a reader takes only beside one of these:
197
+ * a failed reply's `reason` and `detail` beside its `kind`, and the Discord
198
+ * module's `discordTextFirst` on a row it found by `external`.
199
+ */
200
+ const HOST_METADATA_KEYS = [
201
+ 'system', 'kind', 'draftOwner', 'draftIds', 'tuneOut', 'coalescingSubject', 'deferredWriteId',
202
+ 'external', 'openaiResponsesItems',
203
+ ];
183
204
  /**
184
205
  * True when an injected message is real conversational input — something the
185
206
  * agent might actually be replying to — rather than ambient machinery.
@@ -502,8 +523,55 @@ function announcedDomainsOf(announce) {
502
523
  function resumesLogicalTurn(reason) {
503
524
  return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry';
504
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
+ }
505
531
  /** The last TurnRouting.turnId given out: ids increase, and are unique within the process. */
506
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
+ }
507
575
  function freshTurnRouting() {
508
576
  return {
509
577
  turnId: ++lastTurnId,
@@ -2129,8 +2197,8 @@ export class AgentFramework {
2129
2197
  // route applies to a fork only when it IS that home, and never to
2130
2198
  // another agent when a fork owns that channel. Otherwise a fork
2131
2199
  // bound elsewhere would show typing where it isn't replying.
2132
- ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId)
2133
- ? { channelId: provenance.routeChannelId, addressed: true }
2200
+ ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId, provenance.routeServerId)
2201
+ ? { channelId: provenance.routeChannelId, serverId: provenance.routeServerId, addressed: true }
2134
2202
  : {}),
2135
2203
  });
2136
2204
  },
@@ -7430,7 +7498,9 @@ export class AgentFramework {
7430
7498
  // tools/call directly on its connection (bare tool name — no prefix
7431
7499
  // games), bypassing the agent event queue so no synthetic tool-result
7432
7500
  // is injected into a turn the agent never took.
7433
- 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);
7434
7504
  const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
7435
7505
  if (!server) {
7436
7506
  console.error(`[inference-refusal] reaction skipped: no MCPL server for locus ${logValue(incoming.channelId)} (agent=${agentName})`);
@@ -9495,9 +9565,9 @@ export class AgentFramework {
9495
9565
  // turn ends. The window stores them here, right after the
9496
9566
  // tool_result message, which is exactly where the membrane appends
9497
9567
  // them in the live conversation — same participant, same content
9498
- // blocks, same metadata, and compile() emits recent messages
9499
- // verbatim, so the next turn's compiled prefix byte-matches the
9500
- // live request (prompt-cache safe).
9568
+ // blocks, and neither carries the row's metadata — and compile()
9569
+ // emits recent messages verbatim, so the next turn's compiled prefix
9570
+ // byte-matches the live request (prompt-cache safe).
9501
9571
  //
9502
9572
  // The queue is drained ONLY at the target agent's own boundary:
9503
9573
  // draining at another agent's boundary would store the messages
@@ -9664,13 +9734,19 @@ export class AgentFramework {
9664
9734
  // The last injection that qualifies wins (repinChannel).
9665
9735
  const routing = this.routingOf(agent.name);
9666
9736
  let newLocus;
9667
- for (const inj of midTurnInjections)
9668
- 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
+ }
9669
9745
  if (newLocus) {
9670
9746
  // What this boundary just stored is newer than any arrival the
9671
9747
  // turn holds, so the arrivals can't move the locus back.
9672
9748
  routing.arrivals = [];
9673
- const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed');
9749
+ const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed', newLocusServer);
9674
9750
  if (notice)
9675
9751
  midTurnInjections.push(notice);
9676
9752
  }
@@ -9750,8 +9826,12 @@ export class AgentFramework {
9750
9826
  // Streaming path: convert results and resume the stream.
9751
9827
  // Mid-turn messages collected above ride along as injected user
9752
9828
  // messages (membrane ≥0.5.72) — appended after the tool_result
9753
- // envelope so the next round of THIS turn hears them.
9754
- currentState.stream.provideToolResults(agent.toolResultGuard.submissionResults(membraneResults), midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined);
9829
+ // envelope so the next round of THIS turn hears them. Each goes
9830
+ // as its participant and content alone: a stored row's metadata
9831
+ // is the host's record, not input for membrane.
9832
+ currentState.stream.provideToolResults(agent.toolResultGuard.submissionResults(membraneResults), midTurnInjections.length > 0
9833
+ ? { injectedMessages: midTurnInjections.map(({ participant, content }) => ({ participant, content })) }
9834
+ : undefined);
9755
9835
  // RFC-006 §3.3: consumption is determined at assembly, and this
9756
9836
  // is assembly too when the stream carries the injected messages
9757
9837
  // into its next request. Without the mark they would sit above
@@ -9921,6 +10001,29 @@ export class AgentFramework {
9921
10001
  // ==========================================================================
9922
10002
  // Built-in MCPL event → context message conversion
9923
10003
  // ==========================================================================
10004
+ /** The servers and keys whose drop withoutHostKeys has logged, each as
10005
+ * `${serverId}\u0000${key}`. */
10006
+ hostKeysDropped = new Set();
10007
+ /**
10008
+ * A server's metadata as it is stored: a copy without HOST_METADATA_KEYS.
10009
+ * A server may use one of those names for something of its own, so the
10010
+ * first drop of each key from each server is logged.
10011
+ */
10012
+ withoutHostKeys(serverId, given) {
10013
+ const kept = { ...given };
10014
+ for (const key of HOST_METADATA_KEYS) {
10015
+ if (!Object.hasOwn(kept, key))
10016
+ continue;
10017
+ delete kept[key];
10018
+ const logged = `${serverId}\u0000${key}`;
10019
+ if (this.hostKeysDropped.has(logged))
10020
+ continue;
10021
+ this.hostKeysDropped.add(logged);
10022
+ console.error(`[mcpl] ${serverId}: dropped "${key}" from a message, since only the host writes that key ` +
10023
+ `(logged once for each server and key)`);
10024
+ }
10025
+ return kept;
10026
+ }
9924
10027
  /**
9925
10028
  * Convert an incoming MCPL channel message to a context message.
9926
10029
  * This replaces the old MCPLModule.onProcess() message conversion.
@@ -9937,7 +10040,7 @@ export class AgentFramework {
9937
10040
  return undefined;
9938
10041
  }
9939
10042
  const metadata = {
9940
- ...event.metadata,
10043
+ ...this.withoutHostKeys(event.serverId, event.metadata),
9941
10044
  ...(event.eventId ? { eventId: event.eventId } : {}),
9942
10045
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
9943
10046
  channelId: event.channelId,
@@ -9980,14 +10083,15 @@ export class AgentFramework {
9980
10083
  const id = target.getContextManager().addMessage('user', event.content, metadata);
9981
10084
  // A correction may target an older engagement. Refresh only the
9982
10085
  // binding whose agent actually received the message.
9983
- if (this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) {
9984
- 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);
9985
10089
  }
9986
10090
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
9987
10091
  if (event.triggerInference) {
9988
10092
  this.pendingRequests.push({
9989
10093
  agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
9990
- channelId: event.channelId,
10094
+ channelId: event.channelId, serverId: event.serverId,
9991
10095
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
9992
10096
  addressed: isAddressedMessage(event.tags, event.metadata),
9993
10097
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
@@ -10059,8 +10163,10 @@ export class AgentFramework {
10059
10163
  source: event.serverId,
10060
10164
  timestamp: Date.now(),
10061
10165
  // Route this turn's auto-published speech back to THIS channel, not
10062
- // 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.
10063
10168
  channelId: event.channelId,
10169
+ serverId: event.serverId,
10064
10170
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
10065
10171
  // Addressed messages outrank ambient chatter when a batched wake
10066
10172
  // picks the turn's frozen speech locus.
@@ -10123,7 +10229,7 @@ export class AgentFramework {
10123
10229
  if (!agent) {
10124
10230
  // Bound agent vanished (e.g. external reset) — unbind so the next
10125
10231
  // qualifying message respawns a fresh fork.
10126
- router.unbind(event.channelId);
10232
+ router.unbind({ serverId: event.serverId, channelId: event.channelId });
10127
10233
  this.emitTrace({
10128
10234
  type: 'mcpl:conversation-binding-orphaned',
10129
10235
  channelId: event.channelId,
@@ -10137,7 +10243,7 @@ export class AgentFramework {
10137
10243
  const trigger = decision.trigger && event.triggerInference !== false;
10138
10244
  messageMetadata.triggered = trigger;
10139
10245
  const id = agent.getContextManager().addMessage('user', event.content, messageMetadata);
10140
- router.touch(event.channelId);
10246
+ router.touch({ serverId: event.serverId, channelId: event.channelId });
10141
10247
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
10142
10248
  if (trigger) {
10143
10249
  this.pendingRequests.push({
@@ -10148,6 +10254,7 @@ export class AgentFramework {
10148
10254
  // A fork's home channel wins in routeSpeech regardless, but carry the
10149
10255
  // triggering channel too so the trunk/active path stays consistent.
10150
10256
  channelId: event.channelId,
10257
+ serverId: event.serverId,
10151
10258
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
10152
10259
  addressed: isAddressedMessage(event.tags, event.metadata),
10153
10260
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
@@ -10286,7 +10393,7 @@ export class AgentFramework {
10286
10393
  const decision = this.conversationRouter.route(facts);
10287
10394
  if (decision.kind !== 'spawn')
10288
10395
  return decision;
10289
- return { ...decision, spawned: this.spawnConversationAgent(facts.channelId, decision) };
10396
+ return { ...decision, spawned: this.spawnConversationAgent(facts, decision) };
10290
10397
  }
10291
10398
  }
10292
10399
  /**
@@ -10301,11 +10408,12 @@ export class AgentFramework {
10301
10408
  * so the entry it removes is its own. It's removed before anyone waiting
10302
10409
  * on it resumes, so a waiter that decides again doesn't find it.
10303
10410
  */
10304
- spawnConversationAgent(channelId, decision) {
10411
+ spawnConversationAgent(facts, decision) {
10412
+ const channelId = facts.channelId;
10305
10413
  const spawn = (async () => {
10306
10414
  this.storeConversationGeneration(channelId, decision.generation);
10307
10415
  const agent = await this.createConversationAgent(decision.agentName, channelId);
10308
- this.conversationRouter.bind(channelId, decision.agentName, decision.generation);
10416
+ this.conversationRouter.bind({ serverId: facts.serverId, channelId }, decision.agentName, decision.generation);
10309
10417
  return agent;
10310
10418
  })();
10311
10419
  const done = () => { this.conversationSpawns.delete(channelId); };
@@ -10428,7 +10536,7 @@ export class AgentFramework {
10428
10536
  return;
10429
10537
  this.lastConversationSweep = now;
10430
10538
  for (const binding of this.conversationRouter.expired(now)) {
10431
- this.conversationRouter.unbind(binding.channelId);
10539
+ this.conversationRouter.unbind(binding);
10432
10540
  this.emitTrace({
10433
10541
  type: 'mcpl:conversation-closed',
10434
10542
  channelId: binding.channelId,
@@ -11076,7 +11184,7 @@ export class AgentFramework {
11076
11184
  return explicit.filter((n) => this.agents.has(n));
11077
11185
  if (occ.scope.kind === 'channel' && this.conversationRouter) {
11078
11186
  const router = this.conversationRouter;
11079
- const bound = router.getBinding(occ.scope.id)?.agentName;
11187
+ const bound = router.getBinding({ serverId: occ.serverId, channelId: occ.scope.id })?.agentName;
11080
11188
  if (bound)
11081
11189
  return this.agents.has(bound) ? [bound] : [];
11082
11190
  // No fork yet: the same bind decision an incoming message would get. A
@@ -11188,6 +11296,7 @@ export class AgentFramework {
11188
11296
  source: occ.serverId,
11189
11297
  timestamp: Date.now(),
11190
11298
  channelId,
11299
+ serverId: channelId !== undefined ? occ.serverId : undefined,
11191
11300
  counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
11192
11301
  addressed: isAddressedMessage(occ.tags, origin),
11193
11302
  ...(plain ? {} : { coalescingSubject: subject, coalescingBatch: true }),
@@ -11334,7 +11443,7 @@ export class AgentFramework {
11334
11443
  this.channelRegistry.ensureChannelRegistered(event.serverId, triggerChannel.channelId, triggerChannel.label, triggerChannel.metadata);
11335
11444
  }
11336
11445
  const metadata = {
11337
- ...event.origin,
11446
+ ...this.withoutHostKeys(event.serverId, event.origin),
11338
11447
  serverId: event.serverId,
11339
11448
  featureSet: event.featureSet,
11340
11449
  eventId: event.eventId,
@@ -11405,6 +11514,7 @@ export class AgentFramework {
11405
11514
  source: event.serverId,
11406
11515
  timestamp: Date.now(),
11407
11516
  channelId: triggerChannel?.channelId,
11517
+ serverId: triggerChannel ? event.serverId : undefined,
11408
11518
  // DMs and addressed-while-closed messages arrive as push events;
11409
11519
  // they must outrank ambient chatter in a batched wake's locus
11410
11520
  // selection just like their channels/incoming counterparts.
@@ -11428,18 +11538,49 @@ export class AgentFramework {
11428
11538
  * Whether a batched gate wake's addressed route may become `agentName`'s
11429
11539
  * speech locus. The gate broadcasts to every agent, conversation forks
11430
11540
  * included. A fork speaks only in its home channel, and a channel bound to
11431
- * 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.
11432
11546
  */
11433
- mayTakeGateRoute(agentName, routeChannelId) {
11547
+ mayTakeGateRoute(agentName, routeChannelId, routeServerId) {
11434
11548
  const home = this.conversationAgentHomes.get(agentName);
11435
- if (home !== undefined)
11436
- return home === routeChannelId;
11549
+ if (home !== undefined) {
11550
+ return home === routeChannelId
11551
+ && this.conversationRouter?.getBinding({ serverId: routeServerId, channelId: routeChannelId })?.agentName === agentName;
11552
+ }
11437
11553
  for (const boundChannel of this.conversationAgentHomes.values()) {
11438
11554
  if (boundChannel === routeChannelId)
11439
11555
  return false;
11440
11556
  }
11441
11557
  return true;
11442
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
+ }
11443
11584
  /**
11444
11585
  * Derive the MCPL composite channel id (the outbound routing locus) for a
11445
11586
  * push event from its server-defined `origin`, if it carries one.
@@ -11758,6 +11899,7 @@ export class AgentFramework {
11758
11899
  suppressProse: silentOnly ? trigger?.suppressProse : undefined,
11759
11900
  silentHeartbeat: silentOnly ? trigger?.silentHeartbeat : undefined,
11760
11901
  channelId: channelReq?.channelId,
11902
+ serverId: channelReq?.channelId !== undefined ? onlyServerFor(requests, channelReq.channelId) : undefined,
11761
11903
  addressed: addressedReq !== undefined,
11762
11904
  // A context-budget restart continues the same logical turn: it keeps
11763
11905
  // the channel for routing but names no author — the restart is its
@@ -12207,8 +12349,8 @@ export class AgentFramework {
12207
12349
  /** The drafts tool's answer to a resident's call, from the model's own
12208
12350
  * dispatch or an agent-origin call through executeToolCall (a puppet's):
12209
12351
  * a failure the handler throws is answered as one. */
12210
- draftsToolResult(agentName, rawInput) {
12211
- return this.handleDraftsTool(agentName, rawInput).catch((err) => ({
12352
+ draftsToolResult(agentName, rawInput, callerTurnId) {
12353
+ return this.handleDraftsTool(agentName, rawInput, callerTurnId).catch((err) => ({
12212
12354
  success: false,
12213
12355
  error: `drafts failed: ${thrownText(err)}`,
12214
12356
  isError: true,
@@ -12220,7 +12362,7 @@ export class AgentFramework {
12220
12362
  * each draft is published verbatim, in order, through the normal publish
12221
12363
  * executor, its attempt journaled durably before the request leaves.
12222
12364
  */
12223
- async handleDraftsTool(agentName, rawInput) {
12365
+ async handleDraftsTool(agentName, rawInput, callerTurnId) {
12224
12366
  const input = (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? rawInput : {});
12225
12367
  // The turn that sends: a resent draft's channel is recorded as engaged on
12226
12368
  // it, as a send tool's is, even when the result comes after the turn.
@@ -12391,7 +12533,17 @@ export class AgentFramework {
12391
12533
  const cand = resolved.candidates?.length ? ` It could mean: ${resolved.candidates.join(', ')}.` : '';
12392
12534
  return refuse(`Destination "${spec}" did not resolve: ${resolved.error}.${cand} Nothing was sent.`);
12393
12535
  }
12394
- 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
+ });
12395
12547
  if ('error' in target)
12396
12548
  return refuse(`Destination "${spec}": ${target.error}. Nothing was sent.`);
12397
12549
  const destination = target.destination;
@@ -12735,8 +12887,10 @@ export class AgentFramework {
12735
12887
  if (metadata?.tuneOut)
12736
12888
  return;
12737
12889
  const channelId = this.repinChannel(metadata, open);
12738
- if (channelId)
12739
- open.arrivals.push({ messageId, channelId });
12890
+ if (channelId) {
12891
+ const serverId = messageServerId(metadata);
12892
+ open.arrivals.push({ messageId, channelId, ...(serverId !== undefined ? { serverId } : {}) });
12893
+ }
12740
12894
  }
12741
12895
  /**
12742
12896
  * Whether `agent`'s store is the residents' one message slot, which every
@@ -12773,12 +12927,12 @@ export class AgentFramework {
12773
12927
  remainingArrival(agent, routing) {
12774
12928
  const cm = agent.getContextManager();
12775
12929
  for (let i = routing.arrivals.length - 1; i >= 0; i--) {
12776
- const { messageId, channelId } = routing.arrivals[i];
12930
+ const arrival = routing.arrivals[i];
12777
12931
  // The store answers null for a message that isn't there. A store that
12778
12932
  // can't be read throws here, as anywhere else in the start, rather
12779
12933
  // than have its candidates read as withdrawn.
12780
- if (cm.getMessage(messageId))
12781
- return channelId;
12934
+ if (cm.getMessage(arrival.messageId))
12935
+ return arrival;
12782
12936
  }
12783
12937
  return undefined;
12784
12938
  }
@@ -12794,11 +12948,13 @@ export class AgentFramework {
12794
12948
  * stored. The locus has moved then too, but the announce baseline hasn't,
12795
12949
  * so the agent's next turn announces where its speech lands.
12796
12950
  */
12797
- repinTurnLocus(agent, routing, channelId, cause) {
12951
+ repinTurnLocus(agent, routing, channelId, cause,
12952
+ /** The server of the message that moved it. */
12953
+ serverId) {
12798
12954
  if (routing.locus === channelId)
12799
12955
  return undefined;
12800
12956
  const prevPin = routing.locus ?? null;
12801
- routing.locus = channelId;
12957
+ setTurnLocus(routing, channelId, serverId);
12802
12958
  const label = this.channelRegistry?.getDescriptor(channelId)?.label;
12803
12959
  const shown = label && label !== channelId
12804
12960
  ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})`
@@ -13054,11 +13210,13 @@ export class AgentFramework {
13054
13210
  * draft's attempt is journaled durably before the request leaves, and its
13055
13211
  * outcome after, exactly as a drafts resend would.
13056
13212
  */
13057
- async deliverWithUnsent(agent, body, channelId, unsent) {
13213
+ async deliverWithUnsent(agent, body, channelId, unsent,
13214
+ /** The server the channel resolves with (triggerServerFor). */
13215
+ serverId) {
13058
13216
  const registry = this.channelRegistry;
13059
13217
  let attemptId;
13060
13218
  if (unsent) {
13061
- const target = registry.resolveDestination({ channelId });
13219
+ const target = registry.resolveDestination({ ...(serverId !== undefined ? { serverId } : {}), channelId });
13062
13220
  if (!('error' in target)) {
13063
13221
  try {
13064
13222
  attemptId = this.proseDrafts.beginAttempt(agent.name, unsent.id, target.destination, 'unsent-token', false);
@@ -13080,7 +13238,7 @@ export class AgentFramework {
13080
13238
  try {
13081
13239
  // Both callers hold a failed delivery's words as a bounce draft, whose
13082
13240
  // notice says what happened, so the registry writes no second marker.
13083
- 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 } : {}) });
13084
13242
  }
13085
13243
  finally {
13086
13244
  if (flight) {
@@ -13172,7 +13330,7 @@ export class AgentFramework {
13172
13330
  continue;
13173
13331
  }
13174
13332
  try {
13175
- 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));
13176
13334
  this.recordProseDelivery(routing, outcome.status === 'delivered' ? { delivered: true, channelId: outcome.destination.channelId } : null);
13177
13335
  if (outcome.status === 'delivered') {
13178
13336
  this.proseBounceStreaks.delete(agent.name);
@@ -13216,7 +13374,7 @@ export class AgentFramework {
13216
13374
  }
13217
13375
  const target = sticky ?? locus;
13218
13376
  try {
13219
- const outcome = await this.channelRegistry.routeSpeech(agent.name, envelope, target);
13377
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, envelope, target, speechServerOpts(routing, target));
13220
13378
  this.recordProseDelivery(routing, outcome);
13221
13379
  }
13222
13380
  catch (err) {
@@ -13338,7 +13496,7 @@ export class AgentFramework {
13338
13496
  return;
13339
13497
  }
13340
13498
  try {
13341
- const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft);
13499
+ const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft, triggerServerFor(routing, targetChannel));
13342
13500
  if (outcome.status === 'delivered') {
13343
13501
  this.proseBounceStreaks.delete(name);
13344
13502
  }
@@ -14025,6 +14183,9 @@ export class AgentFramework {
14025
14183
  // turn, which falls back to the global default rather than inheriting
14026
14184
  // a previous turn's channel.
14027
14185
  routing.triggerChannel = trigger?.channelId;
14186
+ routing.triggerOrigin = trigger?.channelId && trigger.serverId
14187
+ ? { channelId: trigger.channelId, serverId: trigger.serverId }
14188
+ : undefined;
14028
14189
  // FREEZE the turn's outbound locus now (turn-frozen routing): the home
14029
14190
  // channel for forks, else the trigger channel, else the global default,
14030
14191
  // which an ephemeral run never takes (takesGlobalFallback).
@@ -14040,8 +14201,14 @@ export class AgentFramework {
14040
14201
  // stability).
14041
14202
  if (turnProseRouting !== 'explicit' && turnProseRouting !== 'disabled') {
14042
14203
  const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null;
14043
- if (locus !== null)
14044
- 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
+ }
14045
14212
  if (attempt === 0)
14046
14213
  this.announceLocusIfChanged(agent.name, locus);
14047
14214
  }
@@ -14057,7 +14224,7 @@ export class AgentFramework {
14057
14224
  : undefined;
14058
14225
  routing.arrivals = [];
14059
14226
  if (arrival)
14060
- this.repinTurnLocus(agent, routing, arrival, 'arrival');
14227
+ this.repinTurnLocus(agent, routing, arrival.channelId, 'arrival', arrival.serverId);
14061
14228
  }
14062
14229
  // A silent start, or a disabled agent's, has no locus to show, whatever a
14063
14230
  // channel_open in a silent turn left on its object.
@@ -14576,7 +14743,7 @@ export class AgentFramework {
14576
14743
  };
14577
14744
  const enqueueSpeech = (text, locus) => {
14578
14745
  chainSpeech(async () => {
14579
- const outcome = await this.channelRegistry.routeSpeech(agent.name, text, locus);
14746
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, text, locus, speechServerOpts(streamRouting, locus));
14580
14747
  this.recordProseDelivery(streamRouting, outcome);
14581
14748
  }, 'mid-turn speech routing failed:');
14582
14749
  };
@@ -14691,7 +14858,7 @@ export class AgentFramework {
14691
14858
  : null;
14692
14859
  const emitOutgoing = (deltas) => {
14693
14860
  for (const rd of deltas) {
14694
- 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));
14695
14862
  }
14696
14863
  };
14697
14864
  // Tool-result-guard publication boundary. While a SUBMITTED batch is
@@ -15539,7 +15706,7 @@ export class AgentFramework {
15539
15706
  console.error(`[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`);
15540
15707
  for (const segment of speechSegments) {
15541
15708
  try {
15542
- const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus);
15709
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus, speechServerOpts(streamRouting, locus));
15543
15710
  this.recordProseDelivery(streamRouting, outcome);
15544
15711
  }
15545
15712
  catch (err) {
@@ -15633,7 +15800,7 @@ export class AgentFramework {
15633
15800
  // Deliver sequentially (await each) so the segments land in order.
15634
15801
  for (const seg of segments) {
15635
15802
  try {
15636
- const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus);
15803
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus, speechServerOpts(streamRouting, locus));
15637
15804
  this.recordProseDelivery(streamRouting, outcome);
15638
15805
  }
15639
15806
  catch (err) {
@@ -16279,7 +16446,7 @@ export class AgentFramework {
16279
16446
  if (frameReachedTerminal && proseStream) {
16280
16447
  emitOutgoing(proseStream.finish());
16281
16448
  for (const [channelId, text] of proseStream.byChannel()) {
16282
- this.channelRegistry.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text);
16449
+ this.channelRegistry.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text, triggerServerFor(streamRouting, channelId));
16283
16450
  }
16284
16451
  }
16285
16452
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
@@ -16554,7 +16721,7 @@ export class AgentFramework {
16554
16721
  // agent origin and acts as that resident, as the model's own call does
16555
16722
  // through dispatchToolCall. A module holds no drafts.
16556
16723
  if (call.name === 'drafts' && this.channelRegistry && origin.kind === 'agent') {
16557
- return this.draftsToolResult(origin.agentName, call.input);
16724
+ return this.draftsToolResult(origin.agentName, call.input, call.callerTurnId);
16558
16725
  }
16559
16726
  // The utils meta-tool — programmatic callers (code_execution scripts,
16560
16727
  // ephemerals, ModuleContext.callTool) reach utilities the same way the
@@ -18031,7 +18198,7 @@ export class AgentFramework {
18031
18198
  if (enrichedCall.name === 'drafts' && this.channelRegistry) {
18032
18199
  const startedAt = Date.now();
18033
18200
  this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'drafts', callId: enrichedCall.id, input: enrichedCall.input });
18034
- void this.draftsToolResult(agentName, enrichedCall.input)
18201
+ void this.draftsToolResult(agentName, enrichedCall.input, enrichedCall.callerTurnId)
18035
18202
  .then((result) => {
18036
18203
  this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'drafts', callId: enrichedCall.id, durationMs: Date.now() - startedAt });
18037
18204
  this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'framework', result });
@@ -19423,7 +19590,7 @@ export class AgentFramework {
19423
19590
  const source = this.pushSource(event);
19424
19591
  this.noteInboundAccepted(source);
19425
19592
  return source;
19426
- });
19593
+ }, (serverId, params) => this.channelOfAnotherServer(serverId, params.origin));
19427
19594
  // Server-initiated inference router (Step 6)
19428
19595
  this.inferenceRouter = new InferenceRouter(this.membrane, this.hookOrchestrator, this.featureSetManager, inferenceRouting ?? null, (event) => this.emitTrace(event));
19429
19596
  // Checkpoint manager (Step 8), built and loaded at the top
@@ -22688,7 +22855,16 @@ export class AgentFramework {
22688
22855
  // ordinary wake, starts only once its over-budget boundary has every
22689
22856
  // result of the round.
22690
22857
  const callingTurn = this.routingOf(agentName);
22691
- 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
+ })
22692
22868
  .then((result) => {
22693
22869
  const durationMs = Date.now() - startTime;
22694
22870
  this.emitTrace({
@@ -22725,7 +22901,13 @@ export class AgentFramework {
22725
22901
  if (openerAgent
22726
22902
  && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')
22727
22903
  && this.openLogical.get(agentName) === callingTurn) {
22728
- 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);
22729
22911
  this.lastAnnouncedLocus.set(agentName, opened);
22730
22912
  result = {
22731
22913
  ...result,
@@ -22978,9 +23160,10 @@ export class AgentFramework {
22978
23160
  else if (agent?.proseRouting === 'explicit') {
22979
23161
  // Explicit mode: announce to the turn's sticky prose target if the
22980
23162
  // model has set one; otherwise stay quiet — never guess a channel.
22981
- const target = this.turnRouting.get(agentName)?.pin;
23163
+ const routing = this.turnRouting.get(agentName);
23164
+ const target = routing?.pin;
22982
23165
  if (target) {
22983
- 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) => {
22984
23167
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
22985
23168
  });
22986
23169
  }
@@ -22989,8 +23172,9 @@ export class AgentFramework {
22989
23172
  }
22990
23173
  }
22991
23174
  else {
22992
- const locus = this.turnRouting.get(agentName)?.locus ?? null;
22993
- 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) => {
22994
23178
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
22995
23179
  });
22996
23180
  }