@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.
@@ -420,6 +420,8 @@ export class ChannelRegistry {
420
420
  dmClassified = new Map();
421
421
  /** Most recent incoming channel ID — used for speech routing / default publish. */
422
422
  defaultPublishChannel = null;
423
+ /** The server whose accepted message set defaultPublishChannel. */
424
+ defaultPublishServer = null;
423
425
  /** Most recent incoming message metadata, used for buildChannelContext. */
424
426
  defaultPublishMessageId = null;
425
427
  defaultPublishThreadId = undefined;
@@ -534,7 +536,7 @@ export class ChannelRegistry {
534
536
  }
535
537
  const key = `${serverId}:${channel.id}`;
536
538
  this.registerEntry(key, serverId, channel);
537
- this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
539
+ this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
538
540
  registeredIds.push(channel.id);
539
541
  results.push({ id: channel.id, accepted: true });
540
542
  }
@@ -611,7 +613,7 @@ export class ChannelRegistry {
611
613
  }
612
614
  }
613
615
  existing.descriptor = channel;
614
- this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
616
+ this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
615
617
  addedResults.push({ id: channel.id, accepted: true });
616
618
  }
617
619
  }
@@ -627,7 +629,7 @@ export class ChannelRegistry {
627
629
  }
628
630
  const key = `${serverId}:${channel.id}`;
629
631
  this.registerEntry(key, serverId, channel);
630
- this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
632
+ this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
631
633
  addedResults.push({ id: channel.id, accepted: true });
632
634
  accepted.push(channel);
633
635
  }
@@ -811,6 +813,7 @@ export class ChannelRegistry {
811
813
  const incomingEntry = this.channels.get(incomingKey);
812
814
  const markAccepted = () => {
813
815
  this.defaultPublishChannel = message.channelId;
816
+ this.defaultPublishServer = serverId;
814
817
  this.defaultPublishMessageId = message.messageId;
815
818
  this.defaultPublishThreadId = message.threadId;
816
819
  // Where the host directs this server's lifecycle, only a receipt for
@@ -940,7 +943,9 @@ export class ChannelRegistry {
940
943
  * richer one.
941
944
  */
942
945
  ensureChannelRegistered(serverId, channelId, label, extraMetadata) {
943
- const existing = this.findChannelEntry(channelId);
946
+ // The pushing server's own entry: another server's entry for the same id
947
+ // is that server's, and a push leaves it as it was.
948
+ const existing = this.channels.get(`${serverId}:${channelId}`);
944
949
  if (existing) {
945
950
  // Backfill identity onto a bare lazy registration: a DM that first
946
951
  // arrived with no label/recipient info gets a usable name the next
@@ -954,7 +959,7 @@ export class ChannelRegistry {
954
959
  // Recorded before the live descriptor takes it, since this branch's
955
960
  // guard reads the descriptor: an append that throws leaves the
956
961
  // placeholder, so the next push tries again.
957
- this.appendLabelSighting(existing.descriptor.id, label, extractDmMeta(metadata));
962
+ this.appendLabelSighting(serverId, existing.descriptor.id, label, extractDmMeta(metadata));
958
963
  existing.descriptor.label = label;
959
964
  existing.descriptor.metadata = metadata;
960
965
  }
@@ -974,7 +979,7 @@ export class ChannelRegistry {
974
979
  // label to record. A real label is recorded before the entry exists:
975
980
  // an append that throws registers nothing, so the next push tries again.
976
981
  if (label)
977
- this.appendLabelSighting(channelId, label, extractDmMeta(extraMetadata));
982
+ this.appendLabelSighting(serverId, channelId, label, extractDmMeta(extraMetadata));
978
983
  const key = `${serverId}:${channelId}`;
979
984
  this.channels.set(key, {
980
985
  serverId,
@@ -1246,8 +1251,11 @@ export class ChannelRegistry {
1246
1251
  continue;
1247
1252
  this.channels.delete(key);
1248
1253
  this.stopTyping(entry.descriptor.id);
1249
- if (this.defaultPublishChannel === entry.descriptor.id) {
1254
+ // The fallback goes with the server whose message set it, not with
1255
+ // another server's entry for the same id.
1256
+ if (this.defaultPublishChannel === entry.descriptor.id && this.defaultPublishServer === serverId) {
1250
1257
  this.defaultPublishChannel = null;
1258
+ this.defaultPublishServer = null;
1251
1259
  this.defaultPublishMessageId = null;
1252
1260
  this.defaultPublishThreadId = undefined;
1253
1261
  }
@@ -1267,6 +1275,7 @@ export class ChannelRegistry {
1267
1275
  this.channels.clear();
1268
1276
  // Reset default publish tracking
1269
1277
  this.defaultPublishChannel = null;
1278
+ this.defaultPublishServer = null;
1270
1279
  this.defaultPublishMessageId = null;
1271
1280
  this.defaultPublishThreadId = undefined;
1272
1281
  }
@@ -1400,9 +1409,15 @@ export class ChannelRegistry {
1400
1409
  * `HistoryModule` tool call passes through unguarded via
1401
1410
  * `resolveProseTargetDurable()`.
1402
1411
  */
1403
- appendLabelSighting(channelId, label, dmMeta = {}) {
1412
+ appendLabelSighting(serverId, channelId, label, dmMeta = {}) {
1404
1413
  if (typeof label !== 'string' || !label)
1405
1414
  return;
1415
+ // The log is kept by the bare id, and a durable lookup by label resolves
1416
+ // to that id with no server. So a server's label for an id another
1417
+ // server registers isn't recorded: it would name the other server's
1418
+ // channel, across restarts.
1419
+ if (this.registeredByAnotherServer(serverId, channelId))
1420
+ return;
1406
1421
  const { recipientId, recipientName, isDm } = dmMeta;
1407
1422
  // Never let a bare-id placeholder (ensureChannelRegistered's fallback
1408
1423
  // `label ?? channelId` when no real label was ever supplied — e.g. a
@@ -2176,10 +2191,26 @@ export class ChannelRegistry {
2176
2191
  .filter((e) => e.serverId === serverId)
2177
2192
  .map((e) => e.descriptor);
2178
2193
  }
2194
+ /**
2195
+ * The server whose accepted message set the global fallback, where
2196
+ * `channelId` is the fallback's channel: a turn whose locus is the
2197
+ * fallback resolves it with this server, taken with the locus.
2198
+ */
2199
+ fallbackServerFor(channelId) {
2200
+ return this.defaultPublishChannel === channelId ? this.defaultPublishServer ?? undefined : undefined;
2201
+ }
2179
2202
  /** Server id owning a registered channel (by descriptor id), or null. */
2180
2203
  getChannelServerId(channelId) {
2181
2204
  return this.findChannelEntry(channelId)?.serverId ?? null;
2182
2205
  }
2206
+ /** Whether a server other than `serverId` registers `channelId`. */
2207
+ registeredByAnotherServer(serverId, channelId) {
2208
+ for (const entry of this.channels.values()) {
2209
+ if (entry.descriptor.id === channelId && entry.serverId !== serverId)
2210
+ return true;
2211
+ }
2212
+ return false;
2213
+ }
2183
2214
  /**
2184
2215
  * Find a channel entry by channelId (searches across all servers).
2185
2216
  * Returns the first match.
@@ -2829,8 +2860,11 @@ export class ChannelRegistry {
2829
2860
  * Fire-and-forget and never throws: streaming is an observer surface; the
2830
2861
  * authoritative delivery is the eventual channels/publish.
2831
2862
  */
2832
- sendOutgoingChunk(channelId, conversationId, inferenceId, index, delta) {
2833
- const server = this.streamingServerFor(channelId);
2863
+ sendOutgoingChunk(channelId, conversationId, inferenceId, index, delta,
2864
+ /** The server the channel resolves with, as the speech's locus does
2865
+ * (deliverSpeech's locusServerId). */
2866
+ serverId) {
2867
+ const server = this.streamingServerFor(channelId, serverId);
2834
2868
  if (!server)
2835
2869
  return;
2836
2870
  try {
@@ -2839,8 +2873,10 @@ export class ChannelRegistry {
2839
2873
  catch { /* observer surface — never disturb the turn */ }
2840
2874
  }
2841
2875
  /** Spec 14.3 companion: final moderated content per channel at stream end. */
2842
- sendOutgoingComplete(channelId, conversationId, inferenceId, text) {
2843
- const server = this.streamingServerFor(channelId);
2876
+ sendOutgoingComplete(channelId, conversationId, inferenceId, text,
2877
+ /** As sendOutgoingChunk's. */
2878
+ serverId) {
2879
+ const server = this.streamingServerFor(channelId, serverId);
2844
2880
  if (!server)
2845
2881
  return;
2846
2882
  try {
@@ -2853,15 +2889,19 @@ export class ChannelRegistry {
2853
2889
  }
2854
2890
  catch { /* observer surface — never disturb the turn */ }
2855
2891
  }
2856
- streamingServerFor(channelId) {
2892
+ streamingServerFor(channelId, serverId) {
2857
2893
  // The map is keyed `${serverId}:${channelId}`; stream targets arrive as
2858
- // bare descriptor ids (what resolveProseTarget returns). Scan like the
2859
- // resolver does, and fail closed on cross-server ambiguity — the same
2860
- // never-guess rule that governs delivery.
2894
+ // bare descriptor ids (what resolveProseTarget returns). With the server
2895
+ // the speech resolves with, only that server's entry; otherwise scan like
2896
+ // the resolver does, and fail closed on cross-server ambiguity — the
2897
+ // same never-guess rule that governs delivery.
2861
2898
  const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
2862
- if (matches.length !== 1)
2899
+ const entry = serverId !== undefined
2900
+ ? matches.find((e) => e.serverId === serverId)
2901
+ : matches.length === 1 ? matches[0] : undefined;
2902
+ if (!entry)
2863
2903
  return null;
2864
- const server = this.serverRegistry.getServer(matches[0].serverId);
2904
+ const server = this.serverRegistry.getServer(entry.serverId);
2865
2905
  // §5.4: the GRANT gates streaming, not the raw advertisement. The old
2866
2906
  // `capabilities?.channels?.streaming` check was doubly wrong: undefined
2867
2907
  // for the boolean `channels: true` shape (masking discord-mcpl's latent
@@ -3076,7 +3116,12 @@ export class ChannelRegistry {
3076
3116
  locusChannelId,
3077
3117
  /** `heldOnFailure`: the caller holds a `failed` delivery's words as a
3078
3118
  * draft with its own notice, so no failure marker is written for it.
3079
- * An `unknown` outcome still gets its marker. */
3119
+ * An `unknown` outcome still gets its marker. `locusServerId`: the
3120
+ * server the locus resolves with, snapshotted by the caller with the
3121
+ * locus: the server whose message triggered the turn, where the locus is
3122
+ * that message's channel. The speech goes out through that server only,
3123
+ * and fails where it no longer registers the channel. Without it, the
3124
+ * locus is taken only where exactly one server registers it. */
3080
3125
  opts) {
3081
3126
  const fail = (channelId, reason, outcome) => {
3082
3127
  const status = outcome.status === 'unknown' ? 'unknown' : 'failed';
@@ -3117,7 +3162,11 @@ export class ChannelRegistry {
3117
3162
  const reason = 'turn has no locus (no home/trigger channel; nothing to deliver into)';
3118
3163
  return fail(null, reason, { status: 'failed', reason, at: Date.now() });
3119
3164
  }
3120
- const outcome = await this.publish(conversationId, text, { channelId: locusChannelId, routed: true });
3165
+ const outcome = await this.publish(conversationId, text, {
3166
+ ...(opts?.locusServerId !== undefined ? { serverId: opts.locusServerId } : {}),
3167
+ channelId: locusChannelId,
3168
+ routed: true,
3169
+ });
3121
3170
  const channelId = outcome.destination?.channelId ?? locusChannelId;
3122
3171
  if (outcome.status !== 'delivered') {
3123
3172
  return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome);
@@ -3138,9 +3187,10 @@ export class ChannelRegistry {
3138
3187
  });
3139
3188
  return outcome;
3140
3189
  }
3141
- /** deliverSpeech, reduced to the confirmed delivery (null when there was none). */
3142
- async routeSpeech(conversationId, text, locusChannelId) {
3143
- const outcome = await this.deliverSpeech(conversationId, text, locusChannelId);
3190
+ /** deliverSpeech, reduced to the confirmed delivery (null when there was
3191
+ * none). `opts.locusServerId` is deliverSpeech's. */
3192
+ async routeSpeech(conversationId, text, locusChannelId, opts) {
3193
+ const outcome = await this.deliverSpeech(conversationId, text, locusChannelId, opts?.locusServerId !== undefined ? { locusServerId: opts.locusServerId } : undefined);
3144
3194
  if (outcome.status !== 'delivered')
3145
3195
  return null;
3146
3196
  return {
@@ -3173,11 +3223,30 @@ export class ChannelRegistry {
3173
3223
  isError: true,
3174
3224
  };
3175
3225
  }
3176
- const entry = this.findChannelEntry(channelId);
3226
+ // A serverId the call gives names the exact pair, as the other channel
3227
+ // tools read theirs (this tool's schema doesn't declare one); with no
3228
+ // channelId, it's the default channel on that server. A call naming no
3229
+ // server takes the calling turn's server for its trigger's or locus's
3230
+ // channel; else, for the default channel, the server whose accepted
3231
+ // message set it; else a channelId only where exactly one server
3232
+ // registers it.
3233
+ const serverId = input.serverId;
3234
+ const trigger = !serverId && origin?.kind === 'agent'
3235
+ ? [origin.trigger, origin.locus].find((o) => o?.channelId === channelId)
3236
+ : undefined;
3237
+ const defaultServer = !serverId && trigger === undefined && input.channelId === undefined
3238
+ ? this.fallbackServerFor(channelId)
3239
+ : undefined;
3240
+ const resolved = this.resolveToolChannelEntry(channelId, serverId || (trigger?.serverId ?? defaultServer));
3241
+ const entry = resolved.entry;
3177
3242
  if (!entry) {
3178
3243
  return {
3179
3244
  success: false,
3180
- error: `Channel not found: ${channelId}`,
3245
+ error: trigger !== undefined
3246
+ ? `Channel ${channelId} is no longer registered by the server whose message brought you there, so nothing was published.`
3247
+ : defaultServer !== undefined
3248
+ ? `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.`
3249
+ : resolved.error,
3181
3250
  isError: true,
3182
3251
  };
3183
3252
  }