@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.
- package/dist/src/framework.d.ts +24 -1
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +246 -62
- 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 +94 -25
- 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 +4 -1
- package/src/framework.ts +277 -54
- package/src/gate/event-gate.ts +6 -1
- package/src/mcpl/channel-registry.ts +106 -24
- package/src/mcpl/conversation-router.ts +65 -14
- package/src/mcpl/push-handler.ts +52 -2
- package/src/types/agent.ts +6 -0
package/src/gate/event-gate.ts
CHANGED
|
@@ -130,6 +130,9 @@ export interface WakeProvenance {
|
|
|
130
130
|
* (2026-09-30: a DM reply published into a guild channel). Ambient-only
|
|
131
131
|
* batches still set nothing and keep the legacy fallback. */
|
|
132
132
|
routeChannelId?: string;
|
|
133
|
+
/** The server whose event `routeChannelId` came from: the turn's replies
|
|
134
|
+
* to that channel go out through this server only. */
|
|
135
|
+
routeServerId?: string;
|
|
133
136
|
/** Timestamp (ms) of the chosen event, so a consumer coalescing several
|
|
134
137
|
* requests can order by event recency, not by flush order. */
|
|
135
138
|
at?: number;
|
|
@@ -235,7 +238,9 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin
|
|
|
235
238
|
...(channelId ? { channelId } : {}),
|
|
236
239
|
...(pick.authorId ? { counterparty: `${ns}:user:${pick.authorId}` } : {}),
|
|
237
240
|
...(pick.addressed ? { addressed: true } : {}),
|
|
238
|
-
...(pick.addressed && pick.routeChannelId
|
|
241
|
+
...(pick.addressed && pick.routeChannelId
|
|
242
|
+
? { routeChannelId: pick.routeChannelId, ...(pick.serverId ? { routeServerId: pick.serverId } : {}) }
|
|
243
|
+
: {}),
|
|
239
244
|
at: pick.timestamp,
|
|
240
245
|
};
|
|
241
246
|
}
|
|
@@ -682,7 +682,17 @@ interface ChannelRegistryOptions {
|
|
|
682
682
|
*/
|
|
683
683
|
export type ChannelToolOrigin =
|
|
684
684
|
| { kind: 'module'; moduleName?: string }
|
|
685
|
-
| {
|
|
685
|
+
| {
|
|
686
|
+
kind: 'agent';
|
|
687
|
+
agentName: string;
|
|
688
|
+
/** The calling turn's trigger channel with the server whose message it
|
|
689
|
+
* was: a channel_publish into that channel goes out through that server
|
|
690
|
+
* only. */
|
|
691
|
+
trigger?: { channelId: string; serverId: string };
|
|
692
|
+
/** The calling turn's locus with the server of the message that put it
|
|
693
|
+
* there, where that isn't the trigger: the same holds for it. */
|
|
694
|
+
locus?: { channelId: string; serverId: string };
|
|
695
|
+
};
|
|
686
696
|
|
|
687
697
|
/** The machine decision sources a module-origin channel_close may record.
|
|
688
698
|
* Closed on purpose: honest provenance means a small, auditable vocabulary,
|
|
@@ -772,6 +782,8 @@ export class ChannelRegistry {
|
|
|
772
782
|
|
|
773
783
|
/** Most recent incoming channel ID — used for speech routing / default publish. */
|
|
774
784
|
private defaultPublishChannel: string | null = null;
|
|
785
|
+
/** The server whose accepted message set defaultPublishChannel. */
|
|
786
|
+
private defaultPublishServer: string | null = null;
|
|
775
787
|
|
|
776
788
|
/** Most recent incoming message metadata, used for buildChannelContext. */
|
|
777
789
|
private defaultPublishMessageId: string | null = null;
|
|
@@ -932,7 +944,7 @@ export class ChannelRegistry {
|
|
|
932
944
|
}
|
|
933
945
|
const key = `${serverId}:${channel.id}`;
|
|
934
946
|
this.registerEntry(key, serverId, channel);
|
|
935
|
-
this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
947
|
+
this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
936
948
|
registeredIds.push(channel.id);
|
|
937
949
|
results.push({ id: channel.id, accepted: true });
|
|
938
950
|
}
|
|
@@ -1018,7 +1030,7 @@ export class ChannelRegistry {
|
|
|
1018
1030
|
}
|
|
1019
1031
|
}
|
|
1020
1032
|
existing.descriptor = channel;
|
|
1021
|
-
this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
1033
|
+
this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
1022
1034
|
addedResults.push({ id: channel.id, accepted: true });
|
|
1023
1035
|
}
|
|
1024
1036
|
}
|
|
@@ -1035,7 +1047,7 @@ export class ChannelRegistry {
|
|
|
1035
1047
|
}
|
|
1036
1048
|
const key = `${serverId}:${channel.id}`;
|
|
1037
1049
|
this.registerEntry(key, serverId, channel);
|
|
1038
|
-
this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
1050
|
+
this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
1039
1051
|
addedResults.push({ id: channel.id, accepted: true });
|
|
1040
1052
|
accepted.push(channel);
|
|
1041
1053
|
}
|
|
@@ -1237,6 +1249,7 @@ export class ChannelRegistry {
|
|
|
1237
1249
|
const incomingEntry = this.channels.get(incomingKey)!;
|
|
1238
1250
|
const markAccepted = () => {
|
|
1239
1251
|
this.defaultPublishChannel = message.channelId;
|
|
1252
|
+
this.defaultPublishServer = serverId;
|
|
1240
1253
|
this.defaultPublishMessageId = message.messageId;
|
|
1241
1254
|
this.defaultPublishThreadId = message.threadId;
|
|
1242
1255
|
// Where the host directs this server's lifecycle, only a receipt for
|
|
@@ -1376,7 +1389,9 @@ export class ChannelRegistry {
|
|
|
1376
1389
|
label?: string,
|
|
1377
1390
|
extraMetadata?: Record<string, unknown>,
|
|
1378
1391
|
): void {
|
|
1379
|
-
|
|
1392
|
+
// The pushing server's own entry: another server's entry for the same id
|
|
1393
|
+
// is that server's, and a push leaves it as it was.
|
|
1394
|
+
const existing = this.channels.get(`${serverId}:${channelId}`);
|
|
1380
1395
|
if (existing) {
|
|
1381
1396
|
// Backfill identity onto a bare lazy registration: a DM that first
|
|
1382
1397
|
// arrived with no label/recipient info gets a usable name the next
|
|
@@ -1392,7 +1407,7 @@ export class ChannelRegistry {
|
|
|
1392
1407
|
// Recorded before the live descriptor takes it, since this branch's
|
|
1393
1408
|
// guard reads the descriptor: an append that throws leaves the
|
|
1394
1409
|
// placeholder, so the next push tries again.
|
|
1395
|
-
this.appendLabelSighting(existing.descriptor.id, label, extractDmMeta(metadata));
|
|
1410
|
+
this.appendLabelSighting(serverId, existing.descriptor.id, label, extractDmMeta(metadata));
|
|
1396
1411
|
existing.descriptor.label = label;
|
|
1397
1412
|
existing.descriptor.metadata = metadata;
|
|
1398
1413
|
}
|
|
@@ -1412,7 +1427,7 @@ export class ChannelRegistry {
|
|
|
1412
1427
|
// routing; only the durable write is skipped when there's no real
|
|
1413
1428
|
// label to record. A real label is recorded before the entry exists:
|
|
1414
1429
|
// an append that throws registers nothing, so the next push tries again.
|
|
1415
|
-
if (label) this.appendLabelSighting(channelId, label, extractDmMeta(extraMetadata));
|
|
1430
|
+
if (label) this.appendLabelSighting(serverId, channelId, label, extractDmMeta(extraMetadata));
|
|
1416
1431
|
const key = `${serverId}:${channelId}`;
|
|
1417
1432
|
this.channels.set(key, {
|
|
1418
1433
|
serverId,
|
|
@@ -1742,8 +1757,11 @@ export class ChannelRegistry {
|
|
|
1742
1757
|
if (entry.serverId !== serverId) continue;
|
|
1743
1758
|
this.channels.delete(key);
|
|
1744
1759
|
this.stopTyping(entry.descriptor.id);
|
|
1745
|
-
|
|
1760
|
+
// The fallback goes with the server whose message set it, not with
|
|
1761
|
+
// another server's entry for the same id.
|
|
1762
|
+
if (this.defaultPublishChannel === entry.descriptor.id && this.defaultPublishServer === serverId) {
|
|
1746
1763
|
this.defaultPublishChannel = null;
|
|
1764
|
+
this.defaultPublishServer = null;
|
|
1747
1765
|
this.defaultPublishMessageId = null;
|
|
1748
1766
|
this.defaultPublishThreadId = undefined;
|
|
1749
1767
|
}
|
|
@@ -1766,6 +1784,7 @@ export class ChannelRegistry {
|
|
|
1766
1784
|
|
|
1767
1785
|
// Reset default publish tracking
|
|
1768
1786
|
this.defaultPublishChannel = null;
|
|
1787
|
+
this.defaultPublishServer = null;
|
|
1769
1788
|
this.defaultPublishMessageId = null;
|
|
1770
1789
|
this.defaultPublishThreadId = undefined;
|
|
1771
1790
|
}
|
|
@@ -1910,11 +1929,17 @@ export class ChannelRegistry {
|
|
|
1910
1929
|
* `resolveProseTargetDurable()`.
|
|
1911
1930
|
*/
|
|
1912
1931
|
private appendLabelSighting(
|
|
1932
|
+
serverId: string,
|
|
1913
1933
|
channelId: string,
|
|
1914
1934
|
label: string | undefined,
|
|
1915
1935
|
dmMeta: { recipientId?: string; recipientName?: string; isDm?: boolean } = {},
|
|
1916
1936
|
): void {
|
|
1917
1937
|
if (typeof label !== 'string' || !label) return;
|
|
1938
|
+
// The log is kept by the bare id, and a durable lookup by label resolves
|
|
1939
|
+
// to that id with no server. So a server's label for an id another
|
|
1940
|
+
// server registers isn't recorded: it would name the other server's
|
|
1941
|
+
// channel, across restarts.
|
|
1942
|
+
if (this.registeredByAnotherServer(serverId, channelId)) return;
|
|
1918
1943
|
const { recipientId, recipientName, isDm } = dmMeta;
|
|
1919
1944
|
// Never let a bare-id placeholder (ensureChannelRegistered's fallback
|
|
1920
1945
|
// `label ?? channelId` when no real label was ever supplied — e.g. a
|
|
@@ -2735,11 +2760,28 @@ export class ChannelRegistry {
|
|
|
2735
2760
|
.map((e) => e.descriptor);
|
|
2736
2761
|
}
|
|
2737
2762
|
|
|
2763
|
+
/**
|
|
2764
|
+
* The server whose accepted message set the global fallback, where
|
|
2765
|
+
* `channelId` is the fallback's channel: a turn whose locus is the
|
|
2766
|
+
* fallback resolves it with this server, taken with the locus.
|
|
2767
|
+
*/
|
|
2768
|
+
fallbackServerFor(channelId: string): string | undefined {
|
|
2769
|
+
return this.defaultPublishChannel === channelId ? this.defaultPublishServer ?? undefined : undefined;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2738
2772
|
/** Server id owning a registered channel (by descriptor id), or null. */
|
|
2739
2773
|
getChannelServerId(channelId: string): string | null {
|
|
2740
2774
|
return this.findChannelEntry(channelId)?.serverId ?? null;
|
|
2741
2775
|
}
|
|
2742
2776
|
|
|
2777
|
+
/** Whether a server other than `serverId` registers `channelId`. */
|
|
2778
|
+
private registeredByAnotherServer(serverId: string, channelId: string): boolean {
|
|
2779
|
+
for (const entry of this.channels.values()) {
|
|
2780
|
+
if (entry.descriptor.id === channelId && entry.serverId !== serverId) return true;
|
|
2781
|
+
}
|
|
2782
|
+
return false;
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2743
2785
|
/**
|
|
2744
2786
|
* Find a channel entry by channelId (searches across all servers).
|
|
2745
2787
|
* Returns the first match.
|
|
@@ -3488,8 +3530,11 @@ export class ChannelRegistry {
|
|
|
3488
3530
|
inferenceId: string,
|
|
3489
3531
|
index: number,
|
|
3490
3532
|
delta: string,
|
|
3533
|
+
/** The server the channel resolves with, as the speech's locus does
|
|
3534
|
+
* (deliverSpeech's locusServerId). */
|
|
3535
|
+
serverId?: string,
|
|
3491
3536
|
): void {
|
|
3492
|
-
const server = this.streamingServerFor(channelId);
|
|
3537
|
+
const server = this.streamingServerFor(channelId, serverId);
|
|
3493
3538
|
if (!server) return;
|
|
3494
3539
|
try {
|
|
3495
3540
|
server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta });
|
|
@@ -3502,8 +3547,10 @@ export class ChannelRegistry {
|
|
|
3502
3547
|
conversationId: string,
|
|
3503
3548
|
inferenceId: string,
|
|
3504
3549
|
text: string,
|
|
3550
|
+
/** As sendOutgoingChunk's. */
|
|
3551
|
+
serverId?: string,
|
|
3505
3552
|
): void {
|
|
3506
|
-
const server = this.streamingServerFor(channelId);
|
|
3553
|
+
const server = this.streamingServerFor(channelId, serverId);
|
|
3507
3554
|
if (!server) return;
|
|
3508
3555
|
try {
|
|
3509
3556
|
server.sendChannelsOutgoingComplete({
|
|
@@ -3515,14 +3562,18 @@ export class ChannelRegistry {
|
|
|
3515
3562
|
} catch { /* observer surface — never disturb the turn */ }
|
|
3516
3563
|
}
|
|
3517
3564
|
|
|
3518
|
-
private streamingServerFor(channelId: string) {
|
|
3565
|
+
private streamingServerFor(channelId: string, serverId?: string) {
|
|
3519
3566
|
// The map is keyed `${serverId}:${channelId}`; stream targets arrive as
|
|
3520
|
-
// bare descriptor ids (what resolveProseTarget returns).
|
|
3521
|
-
//
|
|
3522
|
-
//
|
|
3567
|
+
// bare descriptor ids (what resolveProseTarget returns). With the server
|
|
3568
|
+
// the speech resolves with, only that server's entry; otherwise scan like
|
|
3569
|
+
// the resolver does, and fail closed on cross-server ambiguity — the
|
|
3570
|
+
// same never-guess rule that governs delivery.
|
|
3523
3571
|
const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
|
|
3524
|
-
|
|
3525
|
-
|
|
3572
|
+
const entry = serverId !== undefined
|
|
3573
|
+
? matches.find((e) => e.serverId === serverId)
|
|
3574
|
+
: matches.length === 1 ? matches[0] : undefined;
|
|
3575
|
+
if (!entry) return null;
|
|
3576
|
+
const server = this.serverRegistry.getServer(entry.serverId);
|
|
3526
3577
|
// §5.4: the GRANT gates streaming, not the raw advertisement. The old
|
|
3527
3578
|
// `capabilities?.channels?.streaming` check was doubly wrong: undefined
|
|
3528
3579
|
// for the boolean `channels: true` shape (masking discord-mcpl's latent
|
|
@@ -3762,8 +3813,13 @@ export class ChannelRegistry {
|
|
|
3762
3813
|
locusChannelId: string | null,
|
|
3763
3814
|
/** `heldOnFailure`: the caller holds a `failed` delivery's words as a
|
|
3764
3815
|
* draft with its own notice, so no failure marker is written for it.
|
|
3765
|
-
* An `unknown` outcome still gets its marker.
|
|
3766
|
-
|
|
3816
|
+
* An `unknown` outcome still gets its marker. `locusServerId`: the
|
|
3817
|
+
* server the locus resolves with, snapshotted by the caller with the
|
|
3818
|
+
* locus: the server whose message triggered the turn, where the locus is
|
|
3819
|
+
* that message's channel. The speech goes out through that server only,
|
|
3820
|
+
* and fails where it no longer registers the channel. Without it, the
|
|
3821
|
+
* locus is taken only where exactly one server registers it. */
|
|
3822
|
+
opts?: { heldOnFailure?: boolean; locusServerId?: string },
|
|
3767
3823
|
): Promise<PublishOutcome> {
|
|
3768
3824
|
const fail = (channelId: string | null, reason: string, outcome: PublishOutcome): PublishOutcome => {
|
|
3769
3825
|
const status = outcome.status === 'unknown' ? 'unknown' : 'failed';
|
|
@@ -3806,7 +3862,11 @@ export class ChannelRegistry {
|
|
|
3806
3862
|
return fail(null, reason, { status: 'failed', reason, at: Date.now() });
|
|
3807
3863
|
}
|
|
3808
3864
|
|
|
3809
|
-
const outcome = await this.publish(conversationId, text, {
|
|
3865
|
+
const outcome = await this.publish(conversationId, text, {
|
|
3866
|
+
...(opts?.locusServerId !== undefined ? { serverId: opts.locusServerId } : {}),
|
|
3867
|
+
channelId: locusChannelId,
|
|
3868
|
+
routed: true,
|
|
3869
|
+
});
|
|
3810
3870
|
const channelId = outcome.destination?.channelId ?? locusChannelId;
|
|
3811
3871
|
if (outcome.status !== 'delivered') {
|
|
3812
3872
|
return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome);
|
|
@@ -3829,13 +3889,16 @@ export class ChannelRegistry {
|
|
|
3829
3889
|
return outcome;
|
|
3830
3890
|
}
|
|
3831
3891
|
|
|
3832
|
-
/** deliverSpeech, reduced to the confirmed delivery (null when there was
|
|
3892
|
+
/** deliverSpeech, reduced to the confirmed delivery (null when there was
|
|
3893
|
+
* none). `opts.locusServerId` is deliverSpeech's. */
|
|
3833
3894
|
async routeSpeech(
|
|
3834
3895
|
conversationId: string,
|
|
3835
3896
|
text: string,
|
|
3836
3897
|
locusChannelId: string | null,
|
|
3898
|
+
opts?: { locusServerId?: string },
|
|
3837
3899
|
): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> {
|
|
3838
|
-
const outcome = await this.deliverSpeech(conversationId, text, locusChannelId
|
|
3900
|
+
const outcome = await this.deliverSpeech(conversationId, text, locusChannelId,
|
|
3901
|
+
opts?.locusServerId !== undefined ? { locusServerId: opts.locusServerId } : undefined);
|
|
3839
3902
|
if (outcome.status !== 'delivered') return null;
|
|
3840
3903
|
return {
|
|
3841
3904
|
delivered: true,
|
|
@@ -3845,7 +3908,7 @@ export class ChannelRegistry {
|
|
|
3845
3908
|
}
|
|
3846
3909
|
|
|
3847
3910
|
private async handleToolPublish(
|
|
3848
|
-
input: { channelId?: string; content?: string; text?: string },
|
|
3911
|
+
input: { channelId?: string; serverId?: string; content?: string; text?: string },
|
|
3849
3912
|
origin?: ChannelToolOrigin,
|
|
3850
3913
|
): Promise<ToolResult> {
|
|
3851
3914
|
// Resolve content: accept both `content` and `text` (backward compat)
|
|
@@ -3873,11 +3936,30 @@ export class ChannelRegistry {
|
|
|
3873
3936
|
};
|
|
3874
3937
|
}
|
|
3875
3938
|
|
|
3876
|
-
|
|
3939
|
+
// A serverId the call gives names the exact pair, as the other channel
|
|
3940
|
+
// tools read theirs (this tool's schema doesn't declare one); with no
|
|
3941
|
+
// channelId, it's the default channel on that server. A call naming no
|
|
3942
|
+
// server takes the calling turn's server for its trigger's or locus's
|
|
3943
|
+
// channel; else, for the default channel, the server whose accepted
|
|
3944
|
+
// message set it; else a channelId only where exactly one server
|
|
3945
|
+
// registers it.
|
|
3946
|
+
const serverId = input.serverId;
|
|
3947
|
+
const trigger = !serverId && origin?.kind === 'agent'
|
|
3948
|
+
? [origin.trigger, origin.locus].find((o) => o?.channelId === channelId)
|
|
3949
|
+
: undefined;
|
|
3950
|
+
const defaultServer = !serverId && trigger === undefined && input.channelId === undefined
|
|
3951
|
+
? this.fallbackServerFor(channelId)
|
|
3952
|
+
: undefined;
|
|
3953
|
+
const resolved = this.resolveToolChannelEntry(channelId, serverId || (trigger?.serverId ?? defaultServer));
|
|
3954
|
+
const entry = resolved.entry;
|
|
3877
3955
|
if (!entry) {
|
|
3878
3956
|
return {
|
|
3879
3957
|
success: false,
|
|
3880
|
-
error:
|
|
3958
|
+
error: trigger !== undefined
|
|
3959
|
+
? `Channel ${channelId} is no longer registered by the server whose message brought you there, so nothing was published.`
|
|
3960
|
+
: defaultServer !== undefined
|
|
3961
|
+
? `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.`
|
|
3962
|
+
: resolved.error!,
|
|
3881
3963
|
isError: true,
|
|
3882
3964
|
};
|
|
3883
3965
|
}
|
|
@@ -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
|
@@ -386,6 +386,12 @@ export interface InferenceRequest {
|
|
|
386
386
|
* ephemeral run's (a subagent's), which takes no global fallback.
|
|
387
387
|
*/
|
|
388
388
|
channelId?: string;
|
|
389
|
+
/**
|
|
390
|
+
* Host-owned: the MCPL server whose message set `channelId`. A turn's
|
|
391
|
+
* replies to that channel go out through this server only. Unset where the
|
|
392
|
+
* wake's channel came from no one server's message.
|
|
393
|
+
*/
|
|
394
|
+
serverId?: string;
|
|
389
395
|
/**
|
|
390
396
|
* True when the triggering message explicitly addressed the agent (mention,
|
|
391
397
|
* reply-to-bot, DM — `chat:addressed` in MCPL RFC-001 terms). When a wake
|