@animalabs/agent-framework 0.22.5 → 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.
@@ -363,6 +363,8 @@ export class ChannelRegistry {
363
363
  dmClassified = new Map();
364
364
  /** Most recent incoming channel ID — used for speech routing / default publish. */
365
365
  defaultPublishChannel = null;
366
+ /** The server whose accepted message set defaultPublishChannel. */
367
+ defaultPublishServer = null;
366
368
  /** Most recent incoming message metadata, used for buildChannelContext. */
367
369
  defaultPublishMessageId = null;
368
370
  defaultPublishThreadId = undefined;
@@ -445,7 +447,7 @@ export class ChannelRegistry {
445
447
  descriptor: channel,
446
448
  open: false,
447
449
  });
448
- this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
450
+ this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
449
451
  registeredIds.push(channel.id);
450
452
  results.push({ id: channel.id, accepted: true });
451
453
  }
@@ -506,7 +508,7 @@ export class ChannelRegistry {
506
508
  continue;
507
509
  }
508
510
  existing.descriptor = channel;
509
- this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
511
+ this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
510
512
  addedResults.push({ id: channel.id, accepted: true });
511
513
  }
512
514
  }
@@ -526,7 +528,7 @@ export class ChannelRegistry {
526
528
  descriptor: channel,
527
529
  open: false,
528
530
  });
529
- this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
531
+ this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
530
532
  addedResults.push({ id: channel.id, accepted: true });
531
533
  accepted.push(channel);
532
534
  }
@@ -708,6 +710,7 @@ export class ChannelRegistry {
708
710
  // for those this runs after the hook (below).
709
711
  const markAccepted = () => {
710
712
  this.defaultPublishChannel = message.channelId;
713
+ this.defaultPublishServer = serverId;
711
714
  this.defaultPublishMessageId = message.messageId;
712
715
  this.defaultPublishThreadId = message.threadId;
713
716
  // A server sending channels/incoming is authoritative evidence that
@@ -819,7 +822,9 @@ export class ChannelRegistry {
819
822
  * richer one.
820
823
  */
821
824
  ensureChannelRegistered(serverId, channelId, label, extraMetadata) {
822
- const existing = this.findChannelEntry(channelId);
825
+ // The pushing server's own entry: another server's entry for the same id
826
+ // is that server's, and a push leaves it as it was.
827
+ const existing = this.channels.get(`${serverId}:${channelId}`);
823
828
  if (existing) {
824
829
  // Backfill identity onto a bare lazy registration: a DM that first
825
830
  // arrived with no label/recipient info gets a usable name the next
@@ -833,7 +838,7 @@ export class ChannelRegistry {
833
838
  // Recorded before the live descriptor takes it, since this branch's
834
839
  // guard reads the descriptor: an append that throws leaves the
835
840
  // placeholder, so the next push tries again.
836
- this.appendLabelSighting(existing.descriptor.id, label, extractDmMeta(metadata));
841
+ this.appendLabelSighting(serverId, existing.descriptor.id, label, extractDmMeta(metadata));
837
842
  existing.descriptor.label = label;
838
843
  existing.descriptor.metadata = metadata;
839
844
  }
@@ -853,7 +858,7 @@ export class ChannelRegistry {
853
858
  // label to record. A real label is recorded before the entry exists:
854
859
  // an append that throws registers nothing, so the next push tries again.
855
860
  if (label)
856
- this.appendLabelSighting(channelId, label, extractDmMeta(extraMetadata));
861
+ this.appendLabelSighting(serverId, channelId, label, extractDmMeta(extraMetadata));
857
862
  const key = `${serverId}:${channelId}`;
858
863
  this.channels.set(key, {
859
864
  serverId,
@@ -1044,7 +1049,7 @@ export class ChannelRegistry {
1044
1049
  case 'channel_decline':
1045
1050
  return this.handleToolDecline(input);
1046
1051
  case 'channel_publish':
1047
- return this.handleToolPublish(input);
1052
+ return this.handleToolPublish(input, origin);
1048
1053
  case 'think':
1049
1054
  return this.handleToolThink(input);
1050
1055
  case 'journal':
@@ -1115,8 +1120,11 @@ export class ChannelRegistry {
1115
1120
  continue;
1116
1121
  this.channels.delete(key);
1117
1122
  this.stopTyping(entry.descriptor.id);
1118
- if (this.defaultPublishChannel === entry.descriptor.id) {
1123
+ // The fallback goes with the server whose message set it, not with
1124
+ // another server's entry for the same id.
1125
+ if (this.defaultPublishChannel === entry.descriptor.id && this.defaultPublishServer === serverId) {
1119
1126
  this.defaultPublishChannel = null;
1127
+ this.defaultPublishServer = null;
1120
1128
  this.defaultPublishMessageId = null;
1121
1129
  this.defaultPublishThreadId = undefined;
1122
1130
  }
@@ -1136,6 +1144,7 @@ export class ChannelRegistry {
1136
1144
  this.channels.clear();
1137
1145
  // Reset default publish tracking
1138
1146
  this.defaultPublishChannel = null;
1147
+ this.defaultPublishServer = null;
1139
1148
  this.defaultPublishMessageId = null;
1140
1149
  this.defaultPublishThreadId = undefined;
1141
1150
  }
@@ -1269,9 +1278,15 @@ export class ChannelRegistry {
1269
1278
  * `HistoryModule` tool call passes through unguarded via
1270
1279
  * `resolveProseTargetDurable()`.
1271
1280
  */
1272
- appendLabelSighting(channelId, label, dmMeta = {}) {
1281
+ appendLabelSighting(serverId, channelId, label, dmMeta = {}) {
1273
1282
  if (typeof label !== 'string' || !label)
1274
1283
  return;
1284
+ // The log is kept by the bare id, and a durable lookup by label resolves
1285
+ // to that id with no server. So a server's label for an id another
1286
+ // server registers isn't recorded: it would name the other server's
1287
+ // channel, across restarts.
1288
+ if (this.registeredByAnotherServer(serverId, channelId))
1289
+ return;
1275
1290
  const { recipientId, recipientName, isDm } = dmMeta;
1276
1291
  // Never let a bare-id placeholder (ensureChannelRegistered's fallback
1277
1292
  // `label ?? channelId` when no real label was ever supplied — e.g. a
@@ -1842,10 +1857,26 @@ export class ChannelRegistry {
1842
1857
  .filter((e) => e.serverId === serverId)
1843
1858
  .map((e) => e.descriptor);
1844
1859
  }
1860
+ /**
1861
+ * The server whose accepted message set the global fallback, where
1862
+ * `channelId` is the fallback's channel: a turn whose locus is the
1863
+ * fallback resolves it with this server, taken with the locus.
1864
+ */
1865
+ fallbackServerFor(channelId) {
1866
+ return this.defaultPublishChannel === channelId ? this.defaultPublishServer ?? undefined : undefined;
1867
+ }
1845
1868
  /** Server id owning a registered channel (by descriptor id), or null. */
1846
1869
  getChannelServerId(channelId) {
1847
1870
  return this.findChannelEntry(channelId)?.serverId ?? null;
1848
1871
  }
1872
+ /** Whether a server other than `serverId` registers `channelId`. */
1873
+ registeredByAnotherServer(serverId, channelId) {
1874
+ for (const entry of this.channels.values()) {
1875
+ if (entry.descriptor.id === channelId && entry.serverId !== serverId)
1876
+ return true;
1877
+ }
1878
+ return false;
1879
+ }
1849
1880
  /**
1850
1881
  * Find a channel entry by channelId (searches across all servers).
1851
1882
  * Returns the first match.
@@ -2451,8 +2482,11 @@ export class ChannelRegistry {
2451
2482
  * Fire-and-forget and never throws: streaming is an observer surface; the
2452
2483
  * authoritative delivery is the eventual channels/publish.
2453
2484
  */
2454
- sendOutgoingChunk(channelId, conversationId, inferenceId, index, delta) {
2455
- const server = this.streamingServerFor(channelId);
2485
+ sendOutgoingChunk(channelId, conversationId, inferenceId, index, delta,
2486
+ /** The server the channel resolves with, as the speech's locus does
2487
+ * (deliverSpeech's locusServerId). */
2488
+ serverId) {
2489
+ const server = this.streamingServerFor(channelId, serverId);
2456
2490
  if (!server)
2457
2491
  return;
2458
2492
  try {
@@ -2461,8 +2495,10 @@ export class ChannelRegistry {
2461
2495
  catch { /* observer surface — never disturb the turn */ }
2462
2496
  }
2463
2497
  /** Spec 14.3 companion: final moderated content per channel at stream end. */
2464
- sendOutgoingComplete(channelId, conversationId, inferenceId, text) {
2465
- const server = this.streamingServerFor(channelId);
2498
+ sendOutgoingComplete(channelId, conversationId, inferenceId, text,
2499
+ /** As sendOutgoingChunk's. */
2500
+ serverId) {
2501
+ const server = this.streamingServerFor(channelId, serverId);
2466
2502
  if (!server)
2467
2503
  return;
2468
2504
  try {
@@ -2475,15 +2511,19 @@ export class ChannelRegistry {
2475
2511
  }
2476
2512
  catch { /* observer surface — never disturb the turn */ }
2477
2513
  }
2478
- streamingServerFor(channelId) {
2514
+ streamingServerFor(channelId, serverId) {
2479
2515
  // The map is keyed `${serverId}:${channelId}`; stream targets arrive as
2480
- // bare descriptor ids (what resolveProseTarget returns). Scan like the
2481
- // resolver does, and fail closed on cross-server ambiguity — the same
2482
- // never-guess rule that governs delivery.
2516
+ // bare descriptor ids (what resolveProseTarget returns). With the server
2517
+ // the speech resolves with, only that server's entry; otherwise scan like
2518
+ // the resolver does, and fail closed on cross-server ambiguity — the
2519
+ // same never-guess rule that governs delivery.
2483
2520
  const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
2484
- if (matches.length !== 1)
2521
+ const entry = serverId !== undefined
2522
+ ? matches.find((e) => e.serverId === serverId)
2523
+ : matches.length === 1 ? matches[0] : undefined;
2524
+ if (!entry)
2485
2525
  return null;
2486
- const server = this.serverRegistry.getServer(matches[0].serverId);
2526
+ const server = this.serverRegistry.getServer(entry.serverId);
2487
2527
  // §5.4: the GRANT gates streaming, not the raw advertisement. The old
2488
2528
  // `capabilities?.channels?.streaming` check was doubly wrong: undefined
2489
2529
  // for the boolean `channels: true` shape (masking discord-mcpl's latent
@@ -2659,7 +2699,12 @@ export class ChannelRegistry {
2659
2699
  locusChannelId,
2660
2700
  /** `heldOnFailure`: the caller holds a `failed` delivery's words as a
2661
2701
  * draft with its own notice, so no failure marker is written for it.
2662
- * An `unknown` outcome still gets its marker. */
2702
+ * An `unknown` outcome still gets its marker. `locusServerId`: the
2703
+ * server the locus resolves with, snapshotted by the caller with the
2704
+ * locus: the server whose message triggered the turn, where the locus is
2705
+ * that message's channel. The speech goes out through that server only,
2706
+ * and fails where it no longer registers the channel. Without it, the
2707
+ * locus is taken only where exactly one server registers it. */
2663
2708
  opts) {
2664
2709
  const fail = (channelId, reason, outcome) => {
2665
2710
  const status = outcome.status === 'unknown' ? 'unknown' : 'failed';
@@ -2694,7 +2739,11 @@ export class ChannelRegistry {
2694
2739
  const reason = 'turn has no locus (no home/trigger channel; nothing to deliver into)';
2695
2740
  return fail(null, reason, { status: 'failed', reason, at: Date.now() });
2696
2741
  }
2697
- const outcome = await this.publish(conversationId, text, { channelId: locusChannelId, routed: true });
2742
+ const outcome = await this.publish(conversationId, text, {
2743
+ ...(opts?.locusServerId !== undefined ? { serverId: opts.locusServerId } : {}),
2744
+ channelId: locusChannelId,
2745
+ routed: true,
2746
+ });
2698
2747
  const channelId = outcome.destination?.channelId ?? locusChannelId;
2699
2748
  if (outcome.status !== 'delivered') {
2700
2749
  return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome);
@@ -2715,9 +2764,10 @@ export class ChannelRegistry {
2715
2764
  });
2716
2765
  return outcome;
2717
2766
  }
2718
- /** deliverSpeech, reduced to the confirmed delivery (null when there was none). */
2719
- async routeSpeech(conversationId, text, locusChannelId) {
2720
- const outcome = await this.deliverSpeech(conversationId, text, locusChannelId);
2767
+ /** deliverSpeech, reduced to the confirmed delivery (null when there was
2768
+ * none). `opts.locusServerId` is deliverSpeech's. */
2769
+ async routeSpeech(conversationId, text, locusChannelId, opts) {
2770
+ const outcome = await this.deliverSpeech(conversationId, text, locusChannelId, opts?.locusServerId !== undefined ? { locusServerId: opts.locusServerId } : undefined);
2721
2771
  if (outcome.status !== 'delivered')
2722
2772
  return null;
2723
2773
  return {
@@ -2726,7 +2776,7 @@ export class ChannelRegistry {
2726
2776
  ...(outcome.messageId !== undefined ? { messageId: outcome.messageId } : {}),
2727
2777
  };
2728
2778
  }
2729
- async handleToolPublish(input) {
2779
+ async handleToolPublish(input, origin) {
2730
2780
  // Resolve content: accept both `content` and `text` (backward compat)
2731
2781
  const messageText = input.content ?? input.text;
2732
2782
  if (!messageText) {
@@ -2745,11 +2795,30 @@ export class ChannelRegistry {
2745
2795
  isError: true,
2746
2796
  };
2747
2797
  }
2748
- const entry = this.findChannelEntry(channelId);
2798
+ // A serverId the call gives names the exact pair, as the other channel
2799
+ // tools read theirs (this tool's schema doesn't declare one); with no
2800
+ // channelId, it's the default channel on that server. A call naming no
2801
+ // server takes the calling turn's server for its trigger's or locus's
2802
+ // channel; else, for the default channel, the server whose accepted
2803
+ // message set it; else a channelId only where exactly one server
2804
+ // registers it.
2805
+ const serverId = input.serverId;
2806
+ const trigger = !serverId && origin?.kind === 'agent'
2807
+ ? [origin.trigger, origin.locus].find((o) => o?.channelId === channelId)
2808
+ : undefined;
2809
+ const defaultServer = !serverId && trigger === undefined && input.channelId === undefined
2810
+ ? this.fallbackServerFor(channelId)
2811
+ : undefined;
2812
+ const resolved = this.resolveToolChannelEntry(channelId, serverId || (trigger?.serverId ?? defaultServer));
2813
+ const entry = resolved.entry;
2749
2814
  if (!entry) {
2750
2815
  return {
2751
2816
  success: false,
2752
- error: `Channel not found: ${channelId}`,
2817
+ error: trigger !== undefined
2818
+ ? `Channel ${channelId} is no longer registered by the server whose message brought you there, so nothing was published.`
2819
+ : defaultServer !== undefined
2820
+ ? `Channel ${channelId}, which this call took as its default, is no longer registered by the server whose message set that default, so nothing was published.`
2821
+ : resolved.error,
2753
2822
  isError: true,
2754
2823
  };
2755
2824
  }