@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.
- package/dist/src/framework.d.ts +15 -1
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +194 -56
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +3 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +3 -1
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +42 -5
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +96 -27
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +32 -7
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +52 -15
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +19 -3
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +48 -3
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/types/agent.d.ts +6 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/framework.ts +222 -48
- package/src/gate/event-gate.ts +6 -1
- package/src/mcpl/channel-registry.ts +110 -25
- package/src/mcpl/conversation-router.ts +65 -14
- package/src/mcpl/push-handler.ts +52 -2
- package/src/types/agent.ts +6 -0
|
@@ -610,7 +610,17 @@ interface ChannelRegistryOptions {
|
|
|
610
610
|
*/
|
|
611
611
|
export type ChannelToolOrigin =
|
|
612
612
|
| { kind: 'module'; moduleName?: string }
|
|
613
|
-
| {
|
|
613
|
+
| {
|
|
614
|
+
kind: 'agent';
|
|
615
|
+
agentName: string;
|
|
616
|
+
/** The calling turn's trigger channel with the server whose message it
|
|
617
|
+
* was: a channel_publish into that channel goes out through that server
|
|
618
|
+
* only. */
|
|
619
|
+
trigger?: { channelId: string; serverId: string };
|
|
620
|
+
/** The calling turn's locus with the server of the message that put it
|
|
621
|
+
* there, where that isn't the trigger: the same holds for it. */
|
|
622
|
+
locus?: { channelId: string; serverId: string };
|
|
623
|
+
};
|
|
614
624
|
|
|
615
625
|
/** The machine decision sources a module-origin channel_close may record.
|
|
616
626
|
* Closed on purpose: honest provenance means a small, auditable vocabulary,
|
|
@@ -699,6 +709,8 @@ export class ChannelRegistry {
|
|
|
699
709
|
|
|
700
710
|
/** Most recent incoming channel ID — used for speech routing / default publish. */
|
|
701
711
|
private defaultPublishChannel: string | null = null;
|
|
712
|
+
/** The server whose accepted message set defaultPublishChannel. */
|
|
713
|
+
private defaultPublishServer: string | null = null;
|
|
702
714
|
|
|
703
715
|
/** Most recent incoming message metadata, used for buildChannelContext. */
|
|
704
716
|
private defaultPublishMessageId: string | null = null;
|
|
@@ -822,7 +834,7 @@ export class ChannelRegistry {
|
|
|
822
834
|
descriptor: channel,
|
|
823
835
|
open: false,
|
|
824
836
|
});
|
|
825
|
-
this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
837
|
+
this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
826
838
|
registeredIds.push(channel.id);
|
|
827
839
|
results.push({ id: channel.id, accepted: true });
|
|
828
840
|
}
|
|
@@ -892,7 +904,7 @@ export class ChannelRegistry {
|
|
|
892
904
|
continue;
|
|
893
905
|
}
|
|
894
906
|
existing.descriptor = channel;
|
|
895
|
-
this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
907
|
+
this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
896
908
|
addedResults.push({ id: channel.id, accepted: true });
|
|
897
909
|
}
|
|
898
910
|
}
|
|
@@ -913,7 +925,7 @@ export class ChannelRegistry {
|
|
|
913
925
|
descriptor: channel,
|
|
914
926
|
open: false,
|
|
915
927
|
});
|
|
916
|
-
this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
928
|
+
this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
917
929
|
addedResults.push({ id: channel.id, accepted: true });
|
|
918
930
|
accepted.push(channel);
|
|
919
931
|
}
|
|
@@ -1113,6 +1125,7 @@ export class ChannelRegistry {
|
|
|
1113
1125
|
// for those this runs after the hook (below).
|
|
1114
1126
|
const markAccepted = () => {
|
|
1115
1127
|
this.defaultPublishChannel = message.channelId;
|
|
1128
|
+
this.defaultPublishServer = serverId;
|
|
1116
1129
|
this.defaultPublishMessageId = message.messageId;
|
|
1117
1130
|
this.defaultPublishThreadId = message.threadId;
|
|
1118
1131
|
// A server sending channels/incoming is authoritative evidence that
|
|
@@ -1237,7 +1250,9 @@ export class ChannelRegistry {
|
|
|
1237
1250
|
label?: string,
|
|
1238
1251
|
extraMetadata?: Record<string, unknown>,
|
|
1239
1252
|
): void {
|
|
1240
|
-
|
|
1253
|
+
// The pushing server's own entry: another server's entry for the same id
|
|
1254
|
+
// is that server's, and a push leaves it as it was.
|
|
1255
|
+
const existing = this.channels.get(`${serverId}:${channelId}`);
|
|
1241
1256
|
if (existing) {
|
|
1242
1257
|
// Backfill identity onto a bare lazy registration: a DM that first
|
|
1243
1258
|
// arrived with no label/recipient info gets a usable name the next
|
|
@@ -1253,7 +1268,7 @@ export class ChannelRegistry {
|
|
|
1253
1268
|
// Recorded before the live descriptor takes it, since this branch's
|
|
1254
1269
|
// guard reads the descriptor: an append that throws leaves the
|
|
1255
1270
|
// placeholder, so the next push tries again.
|
|
1256
|
-
this.appendLabelSighting(existing.descriptor.id, label, extractDmMeta(metadata));
|
|
1271
|
+
this.appendLabelSighting(serverId, existing.descriptor.id, label, extractDmMeta(metadata));
|
|
1257
1272
|
existing.descriptor.label = label;
|
|
1258
1273
|
existing.descriptor.metadata = metadata;
|
|
1259
1274
|
}
|
|
@@ -1273,7 +1288,7 @@ export class ChannelRegistry {
|
|
|
1273
1288
|
// routing; only the durable write is skipped when there's no real
|
|
1274
1289
|
// label to record. A real label is recorded before the entry exists:
|
|
1275
1290
|
// an append that throws registers nothing, so the next push tries again.
|
|
1276
|
-
if (label) this.appendLabelSighting(channelId, label, extractDmMeta(extraMetadata));
|
|
1291
|
+
if (label) this.appendLabelSighting(serverId, channelId, label, extractDmMeta(extraMetadata));
|
|
1277
1292
|
const key = `${serverId}:${channelId}`;
|
|
1278
1293
|
this.channels.set(key, {
|
|
1279
1294
|
serverId,
|
|
@@ -1508,7 +1523,7 @@ export class ChannelRegistry {
|
|
|
1508
1523
|
});
|
|
1509
1524
|
|
|
1510
1525
|
case 'channel_publish':
|
|
1511
|
-
return this.handleToolPublish(input as { channelId: string; content: string });
|
|
1526
|
+
return this.handleToolPublish(input as { channelId: string; content: string }, origin);
|
|
1512
1527
|
|
|
1513
1528
|
case 'think':
|
|
1514
1529
|
return this.handleToolThink(input as { content?: string });
|
|
@@ -1593,8 +1608,11 @@ export class ChannelRegistry {
|
|
|
1593
1608
|
if (entry.serverId !== serverId) continue;
|
|
1594
1609
|
this.channels.delete(key);
|
|
1595
1610
|
this.stopTyping(entry.descriptor.id);
|
|
1596
|
-
|
|
1611
|
+
// The fallback goes with the server whose message set it, not with
|
|
1612
|
+
// another server's entry for the same id.
|
|
1613
|
+
if (this.defaultPublishChannel === entry.descriptor.id && this.defaultPublishServer === serverId) {
|
|
1597
1614
|
this.defaultPublishChannel = null;
|
|
1615
|
+
this.defaultPublishServer = null;
|
|
1598
1616
|
this.defaultPublishMessageId = null;
|
|
1599
1617
|
this.defaultPublishThreadId = undefined;
|
|
1600
1618
|
}
|
|
@@ -1617,6 +1635,7 @@ export class ChannelRegistry {
|
|
|
1617
1635
|
|
|
1618
1636
|
// Reset default publish tracking
|
|
1619
1637
|
this.defaultPublishChannel = null;
|
|
1638
|
+
this.defaultPublishServer = null;
|
|
1620
1639
|
this.defaultPublishMessageId = null;
|
|
1621
1640
|
this.defaultPublishThreadId = undefined;
|
|
1622
1641
|
}
|
|
@@ -1761,11 +1780,17 @@ export class ChannelRegistry {
|
|
|
1761
1780
|
* `resolveProseTargetDurable()`.
|
|
1762
1781
|
*/
|
|
1763
1782
|
private appendLabelSighting(
|
|
1783
|
+
serverId: string,
|
|
1764
1784
|
channelId: string,
|
|
1765
1785
|
label: string | undefined,
|
|
1766
1786
|
dmMeta: { recipientId?: string; recipientName?: string; isDm?: boolean } = {},
|
|
1767
1787
|
): void {
|
|
1768
1788
|
if (typeof label !== 'string' || !label) return;
|
|
1789
|
+
// The log is kept by the bare id, and a durable lookup by label resolves
|
|
1790
|
+
// to that id with no server. So a server's label for an id another
|
|
1791
|
+
// server registers isn't recorded: it would name the other server's
|
|
1792
|
+
// channel, across restarts.
|
|
1793
|
+
if (this.registeredByAnotherServer(serverId, channelId)) return;
|
|
1769
1794
|
const { recipientId, recipientName, isDm } = dmMeta;
|
|
1770
1795
|
// Never let a bare-id placeholder (ensureChannelRegistered's fallback
|
|
1771
1796
|
// `label ?? channelId` when no real label was ever supplied — e.g. a
|
|
@@ -2379,11 +2404,28 @@ export class ChannelRegistry {
|
|
|
2379
2404
|
.map((e) => e.descriptor);
|
|
2380
2405
|
}
|
|
2381
2406
|
|
|
2407
|
+
/**
|
|
2408
|
+
* The server whose accepted message set the global fallback, where
|
|
2409
|
+
* `channelId` is the fallback's channel: a turn whose locus is the
|
|
2410
|
+
* fallback resolves it with this server, taken with the locus.
|
|
2411
|
+
*/
|
|
2412
|
+
fallbackServerFor(channelId: string): string | undefined {
|
|
2413
|
+
return this.defaultPublishChannel === channelId ? this.defaultPublishServer ?? undefined : undefined;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2382
2416
|
/** Server id owning a registered channel (by descriptor id), or null. */
|
|
2383
2417
|
getChannelServerId(channelId: string): string | null {
|
|
2384
2418
|
return this.findChannelEntry(channelId)?.serverId ?? null;
|
|
2385
2419
|
}
|
|
2386
2420
|
|
|
2421
|
+
/** Whether a server other than `serverId` registers `channelId`. */
|
|
2422
|
+
private registeredByAnotherServer(serverId: string, channelId: string): boolean {
|
|
2423
|
+
for (const entry of this.channels.values()) {
|
|
2424
|
+
if (entry.descriptor.id === channelId && entry.serverId !== serverId) return true;
|
|
2425
|
+
}
|
|
2426
|
+
return false;
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2387
2429
|
/**
|
|
2388
2430
|
* Find a channel entry by channelId (searches across all servers).
|
|
2389
2431
|
* Returns the first match.
|
|
@@ -3089,8 +3131,11 @@ export class ChannelRegistry {
|
|
|
3089
3131
|
inferenceId: string,
|
|
3090
3132
|
index: number,
|
|
3091
3133
|
delta: string,
|
|
3134
|
+
/** The server the channel resolves with, as the speech's locus does
|
|
3135
|
+
* (deliverSpeech's locusServerId). */
|
|
3136
|
+
serverId?: string,
|
|
3092
3137
|
): void {
|
|
3093
|
-
const server = this.streamingServerFor(channelId);
|
|
3138
|
+
const server = this.streamingServerFor(channelId, serverId);
|
|
3094
3139
|
if (!server) return;
|
|
3095
3140
|
try {
|
|
3096
3141
|
server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta });
|
|
@@ -3103,8 +3148,10 @@ export class ChannelRegistry {
|
|
|
3103
3148
|
conversationId: string,
|
|
3104
3149
|
inferenceId: string,
|
|
3105
3150
|
text: string,
|
|
3151
|
+
/** As sendOutgoingChunk's. */
|
|
3152
|
+
serverId?: string,
|
|
3106
3153
|
): void {
|
|
3107
|
-
const server = this.streamingServerFor(channelId);
|
|
3154
|
+
const server = this.streamingServerFor(channelId, serverId);
|
|
3108
3155
|
if (!server) return;
|
|
3109
3156
|
try {
|
|
3110
3157
|
server.sendChannelsOutgoingComplete({
|
|
@@ -3116,14 +3163,18 @@ export class ChannelRegistry {
|
|
|
3116
3163
|
} catch { /* observer surface — never disturb the turn */ }
|
|
3117
3164
|
}
|
|
3118
3165
|
|
|
3119
|
-
private streamingServerFor(channelId: string) {
|
|
3166
|
+
private streamingServerFor(channelId: string, serverId?: string) {
|
|
3120
3167
|
// The map is keyed `${serverId}:${channelId}`; stream targets arrive as
|
|
3121
|
-
// bare descriptor ids (what resolveProseTarget returns).
|
|
3122
|
-
//
|
|
3123
|
-
//
|
|
3168
|
+
// bare descriptor ids (what resolveProseTarget returns). With the server
|
|
3169
|
+
// the speech resolves with, only that server's entry; otherwise scan like
|
|
3170
|
+
// the resolver does, and fail closed on cross-server ambiguity — the
|
|
3171
|
+
// same never-guess rule that governs delivery.
|
|
3124
3172
|
const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
|
|
3125
|
-
|
|
3126
|
-
|
|
3173
|
+
const entry = serverId !== undefined
|
|
3174
|
+
? matches.find((e) => e.serverId === serverId)
|
|
3175
|
+
: matches.length === 1 ? matches[0] : undefined;
|
|
3176
|
+
if (!entry) return null;
|
|
3177
|
+
const server = this.serverRegistry.getServer(entry.serverId);
|
|
3127
3178
|
// §5.4: the GRANT gates streaming, not the raw advertisement. The old
|
|
3128
3179
|
// `capabilities?.channels?.streaming` check was doubly wrong: undefined
|
|
3129
3180
|
// for the boolean `channels: true` shape (masking discord-mcpl's latent
|
|
@@ -3323,8 +3374,13 @@ export class ChannelRegistry {
|
|
|
3323
3374
|
locusChannelId: string | null,
|
|
3324
3375
|
/** `heldOnFailure`: the caller holds a `failed` delivery's words as a
|
|
3325
3376
|
* draft with its own notice, so no failure marker is written for it.
|
|
3326
|
-
* An `unknown` outcome still gets its marker.
|
|
3327
|
-
|
|
3377
|
+
* An `unknown` outcome still gets its marker. `locusServerId`: the
|
|
3378
|
+
* server the locus resolves with, snapshotted by the caller with the
|
|
3379
|
+
* locus: the server whose message triggered the turn, where the locus is
|
|
3380
|
+
* that message's channel. The speech goes out through that server only,
|
|
3381
|
+
* and fails where it no longer registers the channel. Without it, the
|
|
3382
|
+
* locus is taken only where exactly one server registers it. */
|
|
3383
|
+
opts?: { heldOnFailure?: boolean; locusServerId?: string },
|
|
3328
3384
|
): Promise<PublishOutcome> {
|
|
3329
3385
|
const fail = (channelId: string | null, reason: string, outcome: PublishOutcome): PublishOutcome => {
|
|
3330
3386
|
const status = outcome.status === 'unknown' ? 'unknown' : 'failed';
|
|
@@ -3361,7 +3417,11 @@ export class ChannelRegistry {
|
|
|
3361
3417
|
return fail(null, reason, { status: 'failed', reason, at: Date.now() });
|
|
3362
3418
|
}
|
|
3363
3419
|
|
|
3364
|
-
const outcome = await this.publish(conversationId, text, {
|
|
3420
|
+
const outcome = await this.publish(conversationId, text, {
|
|
3421
|
+
...(opts?.locusServerId !== undefined ? { serverId: opts.locusServerId } : {}),
|
|
3422
|
+
channelId: locusChannelId,
|
|
3423
|
+
routed: true,
|
|
3424
|
+
});
|
|
3365
3425
|
const channelId = outcome.destination?.channelId ?? locusChannelId;
|
|
3366
3426
|
if (outcome.status !== 'delivered') {
|
|
3367
3427
|
return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome);
|
|
@@ -3384,13 +3444,16 @@ export class ChannelRegistry {
|
|
|
3384
3444
|
return outcome;
|
|
3385
3445
|
}
|
|
3386
3446
|
|
|
3387
|
-
/** deliverSpeech, reduced to the confirmed delivery (null when there was
|
|
3447
|
+
/** deliverSpeech, reduced to the confirmed delivery (null when there was
|
|
3448
|
+
* none). `opts.locusServerId` is deliverSpeech's. */
|
|
3388
3449
|
async routeSpeech(
|
|
3389
3450
|
conversationId: string,
|
|
3390
3451
|
text: string,
|
|
3391
3452
|
locusChannelId: string | null,
|
|
3453
|
+
opts?: { locusServerId?: string },
|
|
3392
3454
|
): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> {
|
|
3393
|
-
const outcome = await this.deliverSpeech(conversationId, text, locusChannelId
|
|
3455
|
+
const outcome = await this.deliverSpeech(conversationId, text, locusChannelId,
|
|
3456
|
+
opts?.locusServerId !== undefined ? { locusServerId: opts.locusServerId } : undefined);
|
|
3394
3457
|
if (outcome.status !== 'delivered') return null;
|
|
3395
3458
|
return {
|
|
3396
3459
|
delivered: true,
|
|
@@ -3399,7 +3462,10 @@ export class ChannelRegistry {
|
|
|
3399
3462
|
};
|
|
3400
3463
|
}
|
|
3401
3464
|
|
|
3402
|
-
private async handleToolPublish(
|
|
3465
|
+
private async handleToolPublish(
|
|
3466
|
+
input: { channelId?: string; serverId?: string; content?: string; text?: string },
|
|
3467
|
+
origin?: ChannelToolOrigin,
|
|
3468
|
+
): Promise<ToolResult> {
|
|
3403
3469
|
// Resolve content: accept both `content` and `text` (backward compat)
|
|
3404
3470
|
const messageText = input.content ?? input.text;
|
|
3405
3471
|
if (!messageText) {
|
|
@@ -3420,11 +3486,30 @@ export class ChannelRegistry {
|
|
|
3420
3486
|
};
|
|
3421
3487
|
}
|
|
3422
3488
|
|
|
3423
|
-
|
|
3489
|
+
// A serverId the call gives names the exact pair, as the other channel
|
|
3490
|
+
// tools read theirs (this tool's schema doesn't declare one); with no
|
|
3491
|
+
// channelId, it's the default channel on that server. A call naming no
|
|
3492
|
+
// server takes the calling turn's server for its trigger's or locus's
|
|
3493
|
+
// channel; else, for the default channel, the server whose accepted
|
|
3494
|
+
// message set it; else a channelId only where exactly one server
|
|
3495
|
+
// registers it.
|
|
3496
|
+
const serverId = input.serverId;
|
|
3497
|
+
const trigger = !serverId && origin?.kind === 'agent'
|
|
3498
|
+
? [origin.trigger, origin.locus].find((o) => o?.channelId === channelId)
|
|
3499
|
+
: undefined;
|
|
3500
|
+
const defaultServer = !serverId && trigger === undefined && input.channelId === undefined
|
|
3501
|
+
? this.fallbackServerFor(channelId)
|
|
3502
|
+
: undefined;
|
|
3503
|
+
const resolved = this.resolveToolChannelEntry(channelId, serverId || (trigger?.serverId ?? defaultServer));
|
|
3504
|
+
const entry = resolved.entry;
|
|
3424
3505
|
if (!entry) {
|
|
3425
3506
|
return {
|
|
3426
3507
|
success: false,
|
|
3427
|
-
error:
|
|
3508
|
+
error: trigger !== undefined
|
|
3509
|
+
? `Channel ${channelId} is no longer registered by the server whose message brought you there, so nothing was published.`
|
|
3510
|
+
: defaultServer !== undefined
|
|
3511
|
+
? `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.`
|
|
3512
|
+
: resolved.error!,
|
|
3428
3513
|
isError: true,
|
|
3429
3514
|
};
|
|
3430
3515
|
}
|
|
@@ -108,6 +108,10 @@ export interface ConversationRouterConfig {
|
|
|
108
108
|
|
|
109
109
|
export interface ConversationBinding {
|
|
110
110
|
channelId: string;
|
|
111
|
+
/** The channel's MCPL server, where the binding was made with one: channel
|
|
112
|
+
* ids are unique only within one server's connection (MCPL §14.2), so each
|
|
113
|
+
* server's channel has its own binding. */
|
|
114
|
+
serverId?: string;
|
|
111
115
|
agentName: string;
|
|
112
116
|
/** Engagement counter per channel — fresh forks get a new generation. */
|
|
113
117
|
generation: number;
|
|
@@ -157,11 +161,15 @@ export const DEFAULT_CLOSURE_PROMPT =
|
|
|
157
161
|
export class ConversationRouter {
|
|
158
162
|
private config: ConversationRouterConfig;
|
|
159
163
|
|
|
160
|
-
/** Active bindings, keyed by
|
|
164
|
+
/** Active bindings, keyed by the channel's server and id (bindingKey):
|
|
165
|
+
* two servers' channels with one id are two conversations. */
|
|
161
166
|
private bindings = new Map<string, ConversationBinding>();
|
|
162
167
|
|
|
163
|
-
/** Engagement counter per channel — survives unbind, so a re-bound
|
|
164
|
-
* gets a distinct fork agent name.
|
|
168
|
+
/** Engagement counter per channel id — survives unbind, so a re-bound
|
|
169
|
+
* channel gets a distinct fork agent name. Kept by bare id, as it's
|
|
170
|
+
* persisted: two servers sharing an id share a counter, which still never
|
|
171
|
+
* repeats a name, and their forks' names differ anyway, since each hashes
|
|
172
|
+
* its server (forkAgentName). */
|
|
165
173
|
private generations = new Map<string, number>();
|
|
166
174
|
|
|
167
175
|
constructor(config: ConversationRouterConfig) {
|
|
@@ -191,7 +199,7 @@ export class ConversationRouter {
|
|
|
191
199
|
* unchanged; a failed spawn doesn't leave a dangling route.
|
|
192
200
|
*/
|
|
193
201
|
route(facts: IncomingMessageFacts): RoutingDecision {
|
|
194
|
-
const existing = this.bindings.get(facts
|
|
202
|
+
const existing = this.bindings.get(bindingKey(facts));
|
|
195
203
|
|
|
196
204
|
if (existing) {
|
|
197
205
|
return {
|
|
@@ -223,33 +231,57 @@ export class ConversationRouter {
|
|
|
223
231
|
* Record a binding after the framework successfully spawned the fork.
|
|
224
232
|
* Returns the new binding.
|
|
225
233
|
*/
|
|
226
|
-
bind(
|
|
227
|
-
|
|
234
|
+
bind(channel: string | ConversationChannel, agentName: string, generation: number, now = Date.now()): ConversationBinding {
|
|
235
|
+
const { channelId, serverId } = asChannel(channel);
|
|
236
|
+
// The counter is kept by bare id, and two servers' spawns on one id can
|
|
237
|
+
// run beside each other, so a bind can finish after a later spawn's: the
|
|
238
|
+
// counter keeps the higher, or the next spawn would propose a fork name
|
|
239
|
+
// already used.
|
|
240
|
+
this.generations.set(channelId, Math.max(this.generations.get(channelId) ?? 0, generation));
|
|
228
241
|
const binding: ConversationBinding = {
|
|
229
242
|
channelId,
|
|
243
|
+
...(serverId !== undefined ? { serverId } : {}),
|
|
230
244
|
agentName,
|
|
231
245
|
generation,
|
|
232
246
|
boundAt: now,
|
|
233
247
|
lastActivity: now,
|
|
234
248
|
};
|
|
235
|
-
this.bindings.set(channelId, binding);
|
|
249
|
+
this.bindings.set(bindingKey({ channelId, serverId }), binding);
|
|
236
250
|
return binding;
|
|
237
251
|
}
|
|
238
252
|
|
|
239
253
|
/** Record delivered activity on an existing binding. Does not bind a
|
|
240
254
|
* channel or advance its generation. Call after successful delivery, not
|
|
241
255
|
* when previewing a route. The optional clock supports deterministic use. */
|
|
242
|
-
touch(
|
|
243
|
-
const
|
|
244
|
-
if (
|
|
256
|
+
touch(channel: string | ConversationChannel, now = Date.now()): void {
|
|
257
|
+
const found = this.findBinding(channel);
|
|
258
|
+
if (found) found[1].lastActivity = now;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
unbind(channel: string | ConversationChannel): void {
|
|
262
|
+
const found = this.findBinding(channel);
|
|
263
|
+
if (found) this.bindings.delete(found[0]);
|
|
245
264
|
}
|
|
246
265
|
|
|
247
|
-
|
|
248
|
-
this.
|
|
266
|
+
getBinding(channel: string | ConversationChannel): ConversationBinding | undefined {
|
|
267
|
+
return this.findBinding(channel)?.[1];
|
|
249
268
|
}
|
|
250
269
|
|
|
251
|
-
|
|
252
|
-
|
|
270
|
+
/**
|
|
271
|
+
* The binding a caller names, with its key. A channel given with its server
|
|
272
|
+
* names that server's binding only. A bare id, as callers gave it before
|
|
273
|
+
* bindings had servers, names the binding made with no server, else the one
|
|
274
|
+
* binding for that id; where several servers' channels with that id are
|
|
275
|
+
* bound, it names none.
|
|
276
|
+
*/
|
|
277
|
+
private findBinding(channel: string | ConversationChannel): [string, ConversationBinding] | undefined {
|
|
278
|
+
const named = asChannel(channel);
|
|
279
|
+
const key = bindingKey(named);
|
|
280
|
+
const exact = this.bindings.get(key);
|
|
281
|
+
if (exact) return [key, exact];
|
|
282
|
+
if (named.serverId !== undefined) return undefined;
|
|
283
|
+
const matches = [...this.bindings.entries()].filter(([, b]) => b.channelId === named.channelId);
|
|
284
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
253
285
|
}
|
|
254
286
|
|
|
255
287
|
/** Reverse lookup: the channel an agent is bound to, if any. */
|
|
@@ -317,6 +349,25 @@ export class ConversationRouter {
|
|
|
317
349
|
}
|
|
318
350
|
}
|
|
319
351
|
|
|
352
|
+
/**
|
|
353
|
+
* A channel as the router's bindings take it: its id, with the MCPL server it
|
|
354
|
+
* belongs to where the caller has one. A bare id, as callers gave it before,
|
|
355
|
+
* is the channel with no server.
|
|
356
|
+
*/
|
|
357
|
+
export interface ConversationChannel {
|
|
358
|
+
channelId: string;
|
|
359
|
+
serverId?: string;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function asChannel(channel: string | ConversationChannel): ConversationChannel {
|
|
363
|
+
return typeof channel === 'string' ? { channelId: channel } : channel;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** A binding's key: its channel's server and id, unambiguous whatever either holds. */
|
|
367
|
+
function bindingKey(channel: ConversationChannel): string {
|
|
368
|
+
return JSON.stringify([channel.serverId ?? null, channel.channelId]);
|
|
369
|
+
}
|
|
370
|
+
|
|
320
371
|
/** The readable part of a fork's name stops here: uniqueness rides on the hash. */
|
|
321
372
|
const FORK_NAME_READABLE_MAX = 64;
|
|
322
373
|
|
package/src/mcpl/push-handler.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { InboundSource } from './inbound-source.js';
|
|
|
22
22
|
import { isSilentHeartbeatMarker, isVisiblyEmptyConversion } from './visible-content.js';
|
|
23
23
|
import { logDetail, logValue } from './log-value.js';
|
|
24
24
|
import { RefusalLines } from './refusal-lines.js';
|
|
25
|
+
import { channelNotPermitted } from './errors.js';
|
|
25
26
|
|
|
26
27
|
// ============================================================================
|
|
27
28
|
// McplPushEvent (the ProcessEvent shape pushed to the queue)
|
|
@@ -109,8 +110,9 @@ export class PushHandler {
|
|
|
109
110
|
private emitTraceFn: (event: { type: string; [key: string]: unknown }) => void;
|
|
110
111
|
private shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
|
|
111
112
|
private dedup = new DedupSet(1000);
|
|
112
|
-
/** Holds back repeats of the line for a
|
|
113
|
-
*
|
|
113
|
+
/** Holds back repeats of the line for a push refused for its channel: a
|
|
114
|
+
* channel-scoped one whose channel isn't declared (-32023) or may not be
|
|
115
|
+
* used (-32017), and one naming a channel another server registers. */
|
|
114
116
|
private channelRefusalLines = new RefusalLines();
|
|
115
117
|
|
|
116
118
|
constructor(
|
|
@@ -123,6 +125,9 @@ export class PushHandler {
|
|
|
123
125
|
* or acknowledged: return its source envelope, frozen now
|
|
124
126
|
* (mcpl/inbound-source.ts). Coalesced work is stamped by its own path. */
|
|
125
127
|
private readonly acceptInbound?: (event: McplPushEvent) => InboundSource | undefined,
|
|
128
|
+
/** The channel a push names, and the server that registers it, when
|
|
129
|
+
* another server registers that channel id and this server doesn't. */
|
|
130
|
+
private readonly channelOfAnotherServer?: (serverId: string, params: PushEventParams) => { channelId: string; holder: string } | undefined,
|
|
126
131
|
) {
|
|
127
132
|
this.featureSetManager = featureSetManager;
|
|
128
133
|
this.pushEventFn = pushEventFn;
|
|
@@ -135,6 +140,7 @@ export class PushHandler {
|
|
|
135
140
|
*
|
|
136
141
|
* 1. Validate feature set
|
|
137
142
|
* 2. Convert content blocks; reject visibly-empty content
|
|
143
|
+
* Refuse a push naming a channel another server registers
|
|
138
144
|
* 3. Deduplicate by eventId
|
|
139
145
|
* 4. Optionally check shouldTriggerInference callback
|
|
140
146
|
* 5. Push event to queue
|
|
@@ -208,6 +214,18 @@ export class PushHandler {
|
|
|
208
214
|
return;
|
|
209
215
|
}
|
|
210
216
|
|
|
217
|
+
// A push is its own server's event, and the agent's reply to it goes to
|
|
218
|
+
// the channel the push names. A channel's id is the agent's locus, so a
|
|
219
|
+
// push naming a channel another server registers, which this server
|
|
220
|
+
// doesn't, would have the reply sent through that other server. It's
|
|
221
|
+
// refused, before dedup, so a retry once this server registers the
|
|
222
|
+
// channel isn't refused as a duplicate.
|
|
223
|
+
const elsewhere = this.channelOfAnotherServer?.(serverId, params);
|
|
224
|
+
if (elsewhere) {
|
|
225
|
+
this.refuseChannelOfAnotherServer(serverId, params, elsewhere, responder);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
211
229
|
// 3. Deduplicate by eventId. A coalesced occurrence is deduplicated by the
|
|
212
230
|
// coalescer's receipts instead (RFC-006 §3.1: a retry within the window
|
|
213
231
|
// gets its original result, which this set could not return).
|
|
@@ -335,6 +353,38 @@ export class PushHandler {
|
|
|
335
353
|
});
|
|
336
354
|
}
|
|
337
355
|
|
|
356
|
+
/** A push naming a channel another server registers: refused with -32017.
|
|
357
|
+
* The operator's line names the server that registers it; the answer to
|
|
358
|
+
* the pushing server doesn't. The line prints at most once a minute per
|
|
359
|
+
* channel, as the other channel refusals' do, and every refusal is traced. */
|
|
360
|
+
private refuseChannelOfAnotherServer(
|
|
361
|
+
serverId: string,
|
|
362
|
+
params: PushEventParams,
|
|
363
|
+
elsewhere: { channelId: string; holder: string },
|
|
364
|
+
responder: Responder | undefined,
|
|
365
|
+
): void {
|
|
366
|
+
const held = this.channelRefusalLines.admit(serverId, elsewhere.channelId);
|
|
367
|
+
if (held !== null) {
|
|
368
|
+
console.error(
|
|
369
|
+
`[push-event-rejected] server=${serverId} featureSet=${logValue(params.featureSet ?? '')} eventId=${logValue(params.eventId ?? '')} ` +
|
|
370
|
+
`channel=${logValue(elsewhere.channelId)} reason=channel-of-another-server: registered by server ${logValue(elsewhere.holder)}, not this one` +
|
|
371
|
+
(held ? ` (${held} more since the last line)` : ''),
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
this.emitTraceFn({
|
|
375
|
+
type: 'mcpl:push-event-rejected',
|
|
376
|
+
serverId,
|
|
377
|
+
eventId: typeof params.eventId === 'string' ? params.eventId : '',
|
|
378
|
+
featureSet: params.featureSet,
|
|
379
|
+
reason: 'channel-of-another-server',
|
|
380
|
+
channelId: elsewhere.channelId,
|
|
381
|
+
detail: `registered by server ${elsewhere.holder}`,
|
|
382
|
+
});
|
|
383
|
+
const error = channelNotPermitted(elsewhere.channelId);
|
|
384
|
+
if (responder?.respondError) responder.respondError(error.code, error.message, error.data);
|
|
385
|
+
else responder?.respond({ accepted: false, reason: error.message });
|
|
386
|
+
}
|
|
387
|
+
|
|
338
388
|
/** Loud, like every other push rejection: the producer's wake went nowhere.
|
|
339
389
|
* A missing eventId prints as `""` and traces as `''`, as on every push line. */
|
|
340
390
|
private traceEmptyRejection(serverId: string, params: PushEventParams): void {
|
package/src/types/agent.ts
CHANGED
|
@@ -375,6 +375,12 @@ export interface InferenceRequest {
|
|
|
375
375
|
* which correctly fall back to the global default channel.
|
|
376
376
|
*/
|
|
377
377
|
channelId?: string;
|
|
378
|
+
/**
|
|
379
|
+
* Host-owned: the MCPL server whose message set `channelId`. A turn's
|
|
380
|
+
* replies to that channel go out through this server only. Unset where the
|
|
381
|
+
* wake's channel came from no one server's message.
|
|
382
|
+
*/
|
|
383
|
+
serverId?: string;
|
|
378
384
|
/**
|
|
379
385
|
* True when the triggering message explicitly addressed the agent (mention,
|
|
380
386
|
* reply-to-bot, DM — `chat:addressed` in MCPL RFC-001 terms). When a wake
|