@animalabs/agent-framework 0.6.10 → 0.7.2
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/agent.d.ts +2 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +3 -0
- package/dist/src/agent.js.map +1 -1
- package/dist/src/code-execution/index.d.ts +14 -0
- package/dist/src/code-execution/index.d.ts.map +1 -0
- package/dist/src/code-execution/index.js +13 -0
- package/dist/src/code-execution/index.js.map +1 -0
- package/dist/src/code-execution/py-runner.d.ts +91 -0
- package/dist/src/code-execution/py-runner.d.ts.map +1 -0
- package/dist/src/code-execution/py-runner.js +364 -0
- package/dist/src/code-execution/py-runner.js.map +1 -0
- package/dist/src/code-execution/runtime-py.d.ts +32 -0
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -0
- package/dist/src/code-execution/runtime-py.js +265 -0
- package/dist/src/code-execution/runtime-py.js.map +1 -0
- package/dist/src/code-execution/tool-definition.d.ts +16 -0
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -0
- package/dist/src/code-execution/tool-definition.js +45 -0
- package/dist/src/code-execution/tool-definition.js.map +1 -0
- package/dist/src/framework.d.ts +157 -15
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1068 -104
- package/dist/src/framework.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +99 -7
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +374 -49
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/prose-grammar.d.ts +24 -0
- package/dist/src/mcpl/prose-grammar.d.ts.map +1 -0
- package/dist/src/mcpl/prose-grammar.js +33 -0
- package/dist/src/mcpl/prose-grammar.js.map +1 -0
- package/dist/src/mcpl/prose-stream-router.d.ts +58 -0
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -0
- package/dist/src/mcpl/prose-stream-router.js +168 -0
- package/dist/src/mcpl/prose-stream-router.js.map +1 -0
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +13 -4
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/types/agent.d.ts +17 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +33 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +32 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/channel-registry-routing.test.js +186 -10
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/code-execution.test.d.ts +11 -0
- package/dist/test/code-execution.test.d.ts.map +1 -0
- package/dist/test/code-execution.test.js +412 -0
- package/dist/test/code-execution.test.js.map +1 -0
- package/dist/test/explicit-prose-routing.test.d.ts +11 -0
- package/dist/test/explicit-prose-routing.test.d.ts.map +1 -0
- package/dist/test/explicit-prose-routing.test.js +247 -0
- package/dist/test/explicit-prose-routing.test.js.map +1 -0
- package/dist/test/present-while-acting.test.js +85 -8
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/prose-stream-router.test.d.ts +2 -0
- package/dist/test/prose-stream-router.test.d.ts.map +1 -0
- package/dist/test/prose-stream-router.test.js +87 -0
- package/dist/test/prose-stream-router.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +2 -2
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/agent.ts +3 -0
- package/src/code-execution/index.ts +19 -0
- package/src/code-execution/py-runner.ts +433 -0
- package/src/code-execution/runtime-py.ts +265 -0
- package/src/code-execution/tool-definition.ts +52 -0
- package/src/framework.ts +1160 -107
- package/src/index.ts +17 -0
- package/src/mcpl/channel-registry.ts +454 -54
- package/src/mcpl/prose-grammar.ts +40 -0
- package/src/mcpl/prose-stream-router.ts +161 -0
- package/src/modules/workspace/index.ts +13 -4
- package/src/types/agent.ts +18 -0
- package/src/types/framework.ts +35 -0
- package/src/types/trace.ts +47 -1
|
@@ -101,6 +101,10 @@ interface McplChannelIncomingEvent {
|
|
|
101
101
|
content: ContentBlock[];
|
|
102
102
|
timestamp: string;
|
|
103
103
|
metadata?: Record<string, unknown>;
|
|
104
|
+
/** MCPL RFC-001 event tags (`chat:addressed`, `chat:ambient`, …) — carried
|
|
105
|
+
* through so the host can rank addressed messages over ambient chatter
|
|
106
|
+
* when picking a turn's frozen speech locus. */
|
|
107
|
+
tags?: string[];
|
|
104
108
|
triggerInference?: boolean;
|
|
105
109
|
targetAgents?: string[];
|
|
106
110
|
}
|
|
@@ -286,6 +290,22 @@ interface ChannelRegistryOptions {
|
|
|
286
290
|
reason: string;
|
|
287
291
|
textLen: number;
|
|
288
292
|
}) => void;
|
|
293
|
+
/**
|
|
294
|
+
* Called when channels were opened WITHOUT the agent asking (subscription
|
|
295
|
+
* policy admitting a newly discovered channel, or delivery into a closed
|
|
296
|
+
* locus). The host wires this to drop a durable notice into the agent's
|
|
297
|
+
* window — the agent must always learn that new traffic will start
|
|
298
|
+
* flowing, and that `channel_close` opts out (their decision outranks
|
|
299
|
+
* policy). Fires ONCE per channel ever: the desired-state decision is
|
|
300
|
+
* durable, so reboots do not re-announce. Must not trigger inference.
|
|
301
|
+
*/
|
|
302
|
+
onChannelAutoOpened?: (info: {
|
|
303
|
+
/** Agent whose action caused the open (delivery); absent for policy opens. */
|
|
304
|
+
conversationId?: string;
|
|
305
|
+
serverId: string;
|
|
306
|
+
source: 'subscription-policy' | 'opened-by-delivery';
|
|
307
|
+
channels: Array<{ channelId: string; label?: string }>;
|
|
308
|
+
}) => void;
|
|
289
309
|
/**
|
|
290
310
|
* Resolve a conversation fork's HOME channel from its agent name. Conversation
|
|
291
311
|
* forks are spawned bound to a single channel (the framework tracks this in
|
|
@@ -335,6 +355,12 @@ export class ChannelRegistry {
|
|
|
335
355
|
reason: string;
|
|
336
356
|
textLen: number;
|
|
337
357
|
}) => void;
|
|
358
|
+
private onChannelAutoOpened?: (info: {
|
|
359
|
+
conversationId?: string;
|
|
360
|
+
serverId: string;
|
|
361
|
+
source: 'subscription-policy' | 'opened-by-delivery';
|
|
362
|
+
channels: Array<{ channelId: string; label?: string }>;
|
|
363
|
+
}) => void;
|
|
338
364
|
private homeChannelResolver?: (agentName: string) => string | undefined;
|
|
339
365
|
private activeChannelResolver?: (agentName: string) => string | undefined;
|
|
340
366
|
private store?: JsStore;
|
|
@@ -386,6 +412,7 @@ export class ChannelRegistry {
|
|
|
386
412
|
this.sendTypingFn = options?.sendTypingFn;
|
|
387
413
|
this.shouldTriggerInference = options?.shouldTriggerInference;
|
|
388
414
|
this.onRouteFailure = options?.onRouteFailure;
|
|
415
|
+
this.onChannelAutoOpened = options?.onChannelAutoOpened;
|
|
389
416
|
this.homeChannelResolver = options?.homeChannelResolver;
|
|
390
417
|
this.activeChannelResolver = options?.activeChannelResolver;
|
|
391
418
|
this.store = options?.store;
|
|
@@ -595,6 +622,7 @@ export class ChannelRegistry {
|
|
|
595
622
|
content: convertedContent,
|
|
596
623
|
timestamp: message.timestamp,
|
|
597
624
|
metadata: message.metadata,
|
|
625
|
+
...(message.tags ? { tags: message.tags } : {}),
|
|
598
626
|
triggerInference,
|
|
599
627
|
};
|
|
600
628
|
|
|
@@ -636,9 +664,26 @@ export class ChannelRegistry {
|
|
|
636
664
|
* channels/register or channels/changed overwrites this descriptor with the
|
|
637
665
|
* richer one.
|
|
638
666
|
*/
|
|
639
|
-
ensureChannelRegistered(
|
|
667
|
+
ensureChannelRegistered(
|
|
668
|
+
serverId: string,
|
|
669
|
+
channelId: string,
|
|
670
|
+
label?: string,
|
|
671
|
+
extraMetadata?: Record<string, unknown>,
|
|
672
|
+
): void {
|
|
640
673
|
const existing = this.findChannelEntry(channelId);
|
|
641
674
|
if (existing) {
|
|
675
|
+
// Backfill identity onto a bare lazy registration: a DM that first
|
|
676
|
+
// arrived with no label/recipient info gets a usable name the next
|
|
677
|
+
// time a message reveals it (labels must show people, not ids).
|
|
678
|
+
if (
|
|
679
|
+
(existing.descriptor.metadata as { lazyRegistered?: boolean } | undefined)?.lazyRegistered &&
|
|
680
|
+
label && existing.descriptor.label === channelId
|
|
681
|
+
) {
|
|
682
|
+
existing.descriptor.label = label;
|
|
683
|
+
if (extraMetadata) {
|
|
684
|
+
existing.descriptor.metadata = { ...existing.descriptor.metadata, ...extraMetadata };
|
|
685
|
+
}
|
|
686
|
+
}
|
|
642
687
|
return;
|
|
643
688
|
}
|
|
644
689
|
|
|
@@ -650,7 +695,7 @@ export class ChannelRegistry {
|
|
|
650
695
|
type: serverId,
|
|
651
696
|
label: label ?? channelId,
|
|
652
697
|
direction: 'bidirectional',
|
|
653
|
-
metadata: { lazyRegistered: true },
|
|
698
|
+
metadata: { lazyRegistered: true, ...(extraMetadata ?? {}) },
|
|
654
699
|
},
|
|
655
700
|
open: false,
|
|
656
701
|
});
|
|
@@ -1037,23 +1082,60 @@ export class ChannelRegistry {
|
|
|
1037
1082
|
});
|
|
1038
1083
|
}
|
|
1039
1084
|
|
|
1040
|
-
|
|
1085
|
+
/**
|
|
1086
|
+
* True when the server's recipe subscription policy wants this channel
|
|
1087
|
+
* open. This is an ONGOING admission policy (subscribed ⇒ open, no matter
|
|
1088
|
+
* when the channel is discovered) — not just a bootstrap seed. Allow-list
|
|
1089
|
+
* entries may be composite MCPL ids or raw server-internal ids.
|
|
1090
|
+
*/
|
|
1091
|
+
private policyWantsOpen(serverId: string, channel: ChannelDescriptor): boolean {
|
|
1092
|
+
const policy = this.legacyPolicies.get(serverId) ?? 'manual';
|
|
1093
|
+
if (policy === 'auto') return true;
|
|
1094
|
+
if (Array.isArray(policy)) {
|
|
1095
|
+
if (policy.includes(channel.id)) return true;
|
|
1096
|
+
const raw = (channel.address as { channelId?: string } | undefined)?.channelId;
|
|
1097
|
+
return typeof raw === 'string' && raw.length > 0 && policy.includes(raw);
|
|
1098
|
+
}
|
|
1099
|
+
return false;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Returns true when this call made a FRESH subscription-policy decision to
|
|
1104
|
+
* open the channel — the caller announces those to the agent (once per
|
|
1105
|
+
* channel ever: the decision persists in chronicle, so reboots see an
|
|
1106
|
+
* existing non-default source and never re-fire).
|
|
1107
|
+
*/
|
|
1108
|
+
private ensureInitialDesiredState(serverId: string, channel: ChannelDescriptor): boolean {
|
|
1109
|
+
const byPolicy = this.policyWantsOpen(serverId, channel);
|
|
1110
|
+
const wantsOpen = channel.initiallyOpen === true || byPolicy;
|
|
1041
1111
|
const existing = this.desiredStates.get(this.lifecycleKey(serverId, channel.id));
|
|
1042
1112
|
if (existing) {
|
|
1043
|
-
// A server upgrading its descriptor to initiallyOpen
|
|
1044
|
-
//
|
|
1113
|
+
// A server upgrading its descriptor to initiallyOpen — or a channel
|
|
1114
|
+
// matching the subscription policy — may lift a pure default:
|
|
1115
|
+
// 'default-closed' means nobody ever decided. Real decisions
|
|
1045
1116
|
// (agent-tool, invitation-declined, legacy migration, …) always stick.
|
|
1046
|
-
if (
|
|
1047
|
-
this.setDesiredState(
|
|
1117
|
+
if (wantsOpen && existing.state === 'closed' && existing.source === 'default-closed') {
|
|
1118
|
+
this.setDesiredState(
|
|
1119
|
+
serverId,
|
|
1120
|
+
channel.id,
|
|
1121
|
+
'open',
|
|
1122
|
+
channel.initiallyOpen === true ? 'server-bootstrap' : 'subscription-policy',
|
|
1123
|
+
);
|
|
1124
|
+
return channel.initiallyOpen !== true && byPolicy;
|
|
1048
1125
|
}
|
|
1049
|
-
return;
|
|
1126
|
+
return false;
|
|
1050
1127
|
}
|
|
1051
1128
|
this.setDesiredState(
|
|
1052
1129
|
serverId,
|
|
1053
1130
|
channel.id,
|
|
1054
|
-
|
|
1055
|
-
channel.initiallyOpen === true
|
|
1131
|
+
wantsOpen ? 'open' : 'closed',
|
|
1132
|
+
channel.initiallyOpen === true
|
|
1133
|
+
? 'server-bootstrap'
|
|
1134
|
+
: wantsOpen
|
|
1135
|
+
? 'subscription-policy'
|
|
1136
|
+
: 'default-closed',
|
|
1056
1137
|
);
|
|
1138
|
+
return channel.initiallyOpen !== true && byPolicy;
|
|
1057
1139
|
}
|
|
1058
1140
|
|
|
1059
1141
|
private async reconcileChannels(
|
|
@@ -1063,8 +1145,15 @@ export class ChannelRegistry {
|
|
|
1063
1145
|
const server = this.serverRegistry.getServer(serverId);
|
|
1064
1146
|
if (!server) return;
|
|
1065
1147
|
|
|
1148
|
+
// Channels the subscription policy freshly admitted in THIS pass —
|
|
1149
|
+
// announced to the agent as one batched notice after the loop, so a
|
|
1150
|
+
// first boot on a new policy doesn't produce one notice per channel.
|
|
1151
|
+
const policyOpened: Array<{ channelId: string; label?: string }> = [];
|
|
1152
|
+
|
|
1066
1153
|
for (const channel of channels) {
|
|
1067
|
-
this.ensureInitialDesiredState(serverId, channel)
|
|
1154
|
+
if (this.ensureInitialDesiredState(serverId, channel)) {
|
|
1155
|
+
policyOpened.push({ channelId: channel.id, label: channel.label });
|
|
1156
|
+
}
|
|
1068
1157
|
const key = `${serverId}:${channel.id}`;
|
|
1069
1158
|
const desired = this.getDesiredState(serverId, channel.id);
|
|
1070
1159
|
const entry = this.channels.get(key);
|
|
@@ -1102,6 +1191,21 @@ export class ChannelRegistry {
|
|
|
1102
1191
|
});
|
|
1103
1192
|
}
|
|
1104
1193
|
}
|
|
1194
|
+
|
|
1195
|
+
// Announce policy admissions to the agent — nothing may start flowing
|
|
1196
|
+
// traffic into their window without them being told, and told how to
|
|
1197
|
+
// opt out (channel_close; their decision outranks policy).
|
|
1198
|
+
if (policyOpened.length > 0) {
|
|
1199
|
+
try {
|
|
1200
|
+
this.onChannelAutoOpened?.({
|
|
1201
|
+
serverId,
|
|
1202
|
+
source: 'subscription-policy',
|
|
1203
|
+
channels: policyOpened,
|
|
1204
|
+
});
|
|
1205
|
+
} catch (err) {
|
|
1206
|
+
console.error('onChannelAutoOpened (policy) failed:', err);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1105
1209
|
}
|
|
1106
1210
|
|
|
1107
1211
|
// ==========================================================================
|
|
@@ -1211,6 +1315,229 @@ export class ChannelRegistry {
|
|
|
1211
1315
|
};
|
|
1212
1316
|
}
|
|
1213
1317
|
|
|
1318
|
+
/**
|
|
1319
|
+
* The single open executor: record durable desired-open intent, tell the
|
|
1320
|
+
* server to subscribe, mark the live entry open. Every path that opens a
|
|
1321
|
+
* channel — the agent's channel_open tool, delivery into a closed locus,
|
|
1322
|
+
* an explicit send into a closed channel — funnels here, so lifecycle
|
|
1323
|
+
* events, desired state, and live state can never diverge by path.
|
|
1324
|
+
* Desired state is recorded BEFORE the server round-trip: intent sticks
|
|
1325
|
+
* even if the subscribe fails, and reconciliation retries later.
|
|
1326
|
+
*/
|
|
1327
|
+
private async openChannelNow(
|
|
1328
|
+
entry: ChannelEntry,
|
|
1329
|
+
source: string,
|
|
1330
|
+
history?: ChannelHistoryRequest,
|
|
1331
|
+
): Promise<ChannelsOpenResult> {
|
|
1332
|
+
this.setDesiredState(entry.serverId, entry.descriptor.id, 'open', source);
|
|
1333
|
+
const server = this.serverRegistry.getServer(entry.serverId);
|
|
1334
|
+
if (!server) {
|
|
1335
|
+
throw new Error(`Server not found: ${entry.serverId}`);
|
|
1336
|
+
}
|
|
1337
|
+
const result: ChannelsOpenResult = await server.sendChannelsOpen({
|
|
1338
|
+
channelId: entry.descriptor.id,
|
|
1339
|
+
type: entry.descriptor.type,
|
|
1340
|
+
address: entry.descriptor.address,
|
|
1341
|
+
...(history ? { history } : {}),
|
|
1342
|
+
});
|
|
1343
|
+
entry.open = true;
|
|
1344
|
+
return result;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* Resolve a `>>` prose-routing target (explicit prose routing —
|
|
1349
|
+
* docs/explicit-prose-routing.md) to a registered channel.
|
|
1350
|
+
*
|
|
1351
|
+
* Accepted spellings, tried in order:
|
|
1352
|
+
* 1. exact descriptor id (`discord:guild:123`, always unambiguous)
|
|
1353
|
+
* 2. exact raw server-internal id (`address.channelId`)
|
|
1354
|
+
* 3. `#label` / bare label — case-insensitive match on descriptor label
|
|
1355
|
+
* with any leading '#' stripped from both sides
|
|
1356
|
+
* 4. `@name` — DM descriptor labels (`DM: name`), case-insensitive
|
|
1357
|
+
*
|
|
1358
|
+
* Ambiguity is an error carrying candidates — never a guess: this is the
|
|
1359
|
+
* mechanism that makes prose misdelivery structurally impossible.
|
|
1360
|
+
*/
|
|
1361
|
+
resolveProseTarget(
|
|
1362
|
+
spec: string,
|
|
1363
|
+
): { channelId: string; label?: string } | { error: string; candidates?: string[] } {
|
|
1364
|
+
const entries = [...this.channels.values()];
|
|
1365
|
+
const trimmed = spec.trim();
|
|
1366
|
+
if (!trimmed) return { error: 'empty target' };
|
|
1367
|
+
|
|
1368
|
+
const exact = entries.filter((e) => e.descriptor.id === trimmed);
|
|
1369
|
+
if (exact.length === 1) {
|
|
1370
|
+
return { channelId: exact[0]!.descriptor.id, label: exact[0]!.descriptor.label };
|
|
1371
|
+
}
|
|
1372
|
+
if (exact.length > 1) {
|
|
1373
|
+
return {
|
|
1374
|
+
error: `channel id "${trimmed}" is ambiguous across servers`,
|
|
1375
|
+
candidates: exact.map((e) => `${e.serverId}:${e.descriptor.id}`),
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const raw = entries.filter(
|
|
1380
|
+
(e) => (e.descriptor.address as { channelId?: string } | undefined)?.channelId === trimmed,
|
|
1381
|
+
);
|
|
1382
|
+
if (raw.length === 1) {
|
|
1383
|
+
return { channelId: raw[0]!.descriptor.id, label: raw[0]!.descriptor.label };
|
|
1384
|
+
}
|
|
1385
|
+
if (raw.length > 1) {
|
|
1386
|
+
return {
|
|
1387
|
+
error: `raw id "${trimmed}" is ambiguous`,
|
|
1388
|
+
candidates: raw.map((e) => e.descriptor.id),
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
const norm = (s: string) => s.replace(/^#/, '').toLowerCase();
|
|
1393
|
+
|
|
1394
|
+
// DM addressing is PEOPLE-first: usernames and mention tokens, never
|
|
1395
|
+
// ids-only (2026-07-24, antra + Fable's live-canary bug report). A DM
|
|
1396
|
+
// entry is matched by, in order: recipientId (from a `<@id>` mention
|
|
1397
|
+
// token), exact recipient/label name, then prefix-lenient name (handles
|
|
1398
|
+
// "@antra_tessera" vs a display name "antra" and vice versa).
|
|
1399
|
+
const mention = /^<@!?(\d+)>$/.exec(trimmed);
|
|
1400
|
+
if (trimmed.startsWith('@') || mention) {
|
|
1401
|
+
const dmMeta = (e: ChannelEntry) =>
|
|
1402
|
+
e.descriptor.metadata as { channelType?: string; recipientId?: string; recipientName?: string } | undefined;
|
|
1403
|
+
const dmName = (e: ChannelEntry) => {
|
|
1404
|
+
const meta = dmMeta(e);
|
|
1405
|
+
if (meta?.recipientName) return meta.recipientName.toLowerCase();
|
|
1406
|
+
const label = (e.descriptor.label ?? '').toLowerCase();
|
|
1407
|
+
return label.startsWith('dm: ') ? label.slice(4) : label;
|
|
1408
|
+
};
|
|
1409
|
+
const isDmEntry = (e: ChannelEntry) =>
|
|
1410
|
+
dmMeta(e)?.channelType === 'dm' ||
|
|
1411
|
+
(e.descriptor.label ?? '').toLowerCase().startsWith('dm: ') ||
|
|
1412
|
+
e.descriptor.id.includes(':dm:');
|
|
1413
|
+
const dms = entries.filter(isDmEntry);
|
|
1414
|
+
|
|
1415
|
+
if (mention) {
|
|
1416
|
+
const id = mention[1]!;
|
|
1417
|
+
const byId = dms.filter((e) => dmMeta(e)?.recipientId === id);
|
|
1418
|
+
if (byId.length === 1) {
|
|
1419
|
+
return { channelId: byId[0]!.descriptor.id, label: byId[0]!.descriptor.label };
|
|
1420
|
+
}
|
|
1421
|
+
return {
|
|
1422
|
+
error: `no registered DM matches the mention <@${id}>`,
|
|
1423
|
+
candidates: dms.map((e) => e.descriptor.label ?? e.descriptor.id).slice(0, 6),
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
const name = trimmed.slice(1).toLowerCase();
|
|
1428
|
+
const exact = dms.filter((e) => dmName(e) === name);
|
|
1429
|
+
const pool = exact.length > 0
|
|
1430
|
+
? exact
|
|
1431
|
+
: dms.filter((e) => {
|
|
1432
|
+
const n = dmName(e);
|
|
1433
|
+
return n.length >= 3 && (n.startsWith(name) || name.startsWith(n) || n.includes(name));
|
|
1434
|
+
});
|
|
1435
|
+
if (pool.length === 1) {
|
|
1436
|
+
return { channelId: pool[0]!.descriptor.id, label: pool[0]!.descriptor.label };
|
|
1437
|
+
}
|
|
1438
|
+
if (pool.length > 1) {
|
|
1439
|
+
return {
|
|
1440
|
+
error: `"${trimmed}" matches several DMs`,
|
|
1441
|
+
candidates: pool.map((e) => e.descriptor.label ?? e.descriptor.id),
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
return {
|
|
1445
|
+
error: `no registered DM found for "${trimmed}" — for someone without a registered DM channel, use the send_dm tool`,
|
|
1446
|
+
...(dms.length ? { candidates: dms.map((e) => e.descriptor.label ?? e.descriptor.id).slice(0, 6) } : {}),
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
const byLabel = entries.filter(
|
|
1451
|
+
(e) => norm(e.descriptor.label ?? '') === norm(trimmed),
|
|
1452
|
+
);
|
|
1453
|
+
if (byLabel.length === 1) {
|
|
1454
|
+
return { channelId: byLabel[0]!.descriptor.id, label: byLabel[0]!.descriptor.label };
|
|
1455
|
+
}
|
|
1456
|
+
if (byLabel.length > 1) {
|
|
1457
|
+
return {
|
|
1458
|
+
error: `label "${trimmed}" matches several channels`,
|
|
1459
|
+
candidates: byLabel.map((e) => e.descriptor.id),
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// Name-segment match: server labels carry a disambiguating suffix
|
|
1464
|
+
// (`#fable (antra's server)`) that agents naturally omit — `>>#fable`
|
|
1465
|
+
// must resolve. Strip a trailing parenthetical from the stored label and
|
|
1466
|
+
// compare the bare channel name. Same name in several guilds is a real
|
|
1467
|
+
// ambiguity: error with full labels so the agent can use the exact id.
|
|
1468
|
+
const nameOf = (label: string) => norm(label.replace(/\s*\([^)]*\)\s*$/, ''));
|
|
1469
|
+
const byName = entries.filter((e) => nameOf(e.descriptor.label ?? '') === norm(trimmed));
|
|
1470
|
+
if (byName.length === 1) {
|
|
1471
|
+
return { channelId: byName[0]!.descriptor.id, label: byName[0]!.descriptor.label };
|
|
1472
|
+
}
|
|
1473
|
+
if (byName.length > 1) {
|
|
1474
|
+
return {
|
|
1475
|
+
error: `"${trimmed}" matches several channels`,
|
|
1476
|
+
candidates: byName.map((e) => `${e.descriptor.label} = ${e.descriptor.id}`),
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// No match: offer near-candidates (labels containing the name) so the
|
|
1481
|
+
// bounce notice is self-healing rather than a dead end.
|
|
1482
|
+
const near = entries
|
|
1483
|
+
.filter((e) => norm(e.descriptor.label ?? '').includes(norm(trimmed)))
|
|
1484
|
+
.slice(0, 5)
|
|
1485
|
+
.map((e) => `${e.descriptor.label} = ${e.descriptor.id}`);
|
|
1486
|
+
return { error: `no channel matches "${trimmed}"`, ...(near.length ? { candidates: near } : {}) };
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* Open a channel because something was DELIVERED into it (explicit send
|
|
1491
|
+
* tool or routed speech). Sending into a closed channel is not a thing:
|
|
1492
|
+
* engaging a channel opens it, so typing indicators, reaction machinery,
|
|
1493
|
+
* and inbound forwarding all come alive with the first outbound message.
|
|
1494
|
+
* Accepts composite MCPL ids or raw server-internal ids (explicit send
|
|
1495
|
+
* tools receive whatever the agent typed).
|
|
1496
|
+
*/
|
|
1497
|
+
async openIfClosedForSend(
|
|
1498
|
+
rawChannelId: string,
|
|
1499
|
+
serverId?: string,
|
|
1500
|
+
): Promise<{
|
|
1501
|
+
status: 'opened' | 'already-open' | 'unknown-channel' | 'ambiguous' | 'open-failed';
|
|
1502
|
+
channelId?: string;
|
|
1503
|
+
label?: string;
|
|
1504
|
+
}> {
|
|
1505
|
+
let matches = [...this.channels.values()].filter(
|
|
1506
|
+
(e) => e.descriptor.id === rawChannelId && (!serverId || e.serverId === serverId),
|
|
1507
|
+
);
|
|
1508
|
+
if (matches.length === 0) {
|
|
1509
|
+
matches = [...this.channels.values()].filter(
|
|
1510
|
+
(e) =>
|
|
1511
|
+
(e.descriptor.address as { channelId?: string } | undefined)?.channelId === rawChannelId &&
|
|
1512
|
+
(!serverId || e.serverId === serverId),
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
if (matches.length === 0) return { status: 'unknown-channel' };
|
|
1516
|
+
if (matches.length > 1) return { status: 'ambiguous' };
|
|
1517
|
+
const entry = matches[0]!;
|
|
1518
|
+
const resolved = { channelId: entry.descriptor.id, label: entry.descriptor.label };
|
|
1519
|
+
if (entry.open && this.getDesiredState(entry.serverId, entry.descriptor.id) === 'open') {
|
|
1520
|
+
return { status: 'already-open', ...resolved };
|
|
1521
|
+
}
|
|
1522
|
+
try {
|
|
1523
|
+
await this.openChannelNow(entry, 'opened-by-reply');
|
|
1524
|
+
this.emitTraceFn({
|
|
1525
|
+
type: 'mcpl:channel-opened-by-send',
|
|
1526
|
+
serverId: entry.serverId,
|
|
1527
|
+
channelId: entry.descriptor.id,
|
|
1528
|
+
});
|
|
1529
|
+
return { status: 'opened', ...resolved };
|
|
1530
|
+
} catch (err) {
|
|
1531
|
+
this.emitTraceFn({
|
|
1532
|
+
type: 'mcpl:channel-open-failed',
|
|
1533
|
+
serverId: entry.serverId,
|
|
1534
|
+
channelId: entry.descriptor.id,
|
|
1535
|
+
error: (err as Error).message,
|
|
1536
|
+
});
|
|
1537
|
+
return { status: 'open-failed', ...resolved };
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1214
1541
|
private async handleToolOpen(input: {
|
|
1215
1542
|
channelId: string;
|
|
1216
1543
|
serverId?: string;
|
|
@@ -1235,17 +1562,6 @@ export class ChannelRegistry {
|
|
|
1235
1562
|
};
|
|
1236
1563
|
}
|
|
1237
1564
|
|
|
1238
|
-
this.setDesiredState(entry.serverId, input.channelId, 'open', 'agent-tool');
|
|
1239
|
-
|
|
1240
|
-
const server = this.serverRegistry.getServer(entry.serverId);
|
|
1241
|
-
if (!server) {
|
|
1242
|
-
return {
|
|
1243
|
-
success: false,
|
|
1244
|
-
error: `Server not found: ${entry.serverId}`,
|
|
1245
|
-
isError: true,
|
|
1246
|
-
};
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
1565
|
try {
|
|
1250
1566
|
const history: ChannelHistoryRequest | undefined = input.backscroll
|
|
1251
1567
|
? {
|
|
@@ -1253,13 +1569,7 @@ export class ChannelRegistry {
|
|
|
1253
1569
|
...(input.beforeMessageId ? { beforeMessageId: input.beforeMessageId } : {}),
|
|
1254
1570
|
}
|
|
1255
1571
|
: undefined;
|
|
1256
|
-
const result
|
|
1257
|
-
channelId: entry.descriptor.id,
|
|
1258
|
-
type: entry.descriptor.type,
|
|
1259
|
-
address: entry.descriptor.address,
|
|
1260
|
-
...(history ? { history } : {}),
|
|
1261
|
-
});
|
|
1262
|
-
entry.open = true;
|
|
1572
|
+
const result = await this.openChannelNow(entry, 'agent-tool', history);
|
|
1263
1573
|
return {
|
|
1264
1574
|
success: true,
|
|
1265
1575
|
data: {
|
|
@@ -1454,6 +1764,59 @@ export class ChannelRegistry {
|
|
|
1454
1764
|
/** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel →
|
|
1455
1765
|
* process-global default). Public so a multi-segment caller can snapshot it
|
|
1456
1766
|
* ONCE and pin every segment to it via routeSpeech's `overrideChannelId`. */
|
|
1767
|
+
/**
|
|
1768
|
+
* MCPL Spec 14.3 outgoing streaming: forward a moderated text delta to the
|
|
1769
|
+
* server owning the channel, AS THE MODEL GENERATES. Emitted only when that
|
|
1770
|
+
* server declared `channels.streaming` in its initialize capabilities —
|
|
1771
|
+
* servers that never opted in (the whole existing fleet) receive nothing.
|
|
1772
|
+
* Fire-and-forget and never throws: streaming is an observer surface; the
|
|
1773
|
+
* authoritative delivery is the eventual channels/publish.
|
|
1774
|
+
*/
|
|
1775
|
+
sendOutgoingChunk(
|
|
1776
|
+
channelId: string,
|
|
1777
|
+
conversationId: string,
|
|
1778
|
+
inferenceId: string,
|
|
1779
|
+
index: number,
|
|
1780
|
+
delta: string,
|
|
1781
|
+
): void {
|
|
1782
|
+
const server = this.streamingServerFor(channelId);
|
|
1783
|
+
if (!server) return;
|
|
1784
|
+
try {
|
|
1785
|
+
server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta });
|
|
1786
|
+
} catch { /* observer surface — never disturb the turn */ }
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/** Spec 14.3 companion: final moderated content per channel at stream end. */
|
|
1790
|
+
sendOutgoingComplete(
|
|
1791
|
+
channelId: string,
|
|
1792
|
+
conversationId: string,
|
|
1793
|
+
inferenceId: string,
|
|
1794
|
+
text: string,
|
|
1795
|
+
): void {
|
|
1796
|
+
const server = this.streamingServerFor(channelId);
|
|
1797
|
+
if (!server) return;
|
|
1798
|
+
try {
|
|
1799
|
+
server.sendChannelsOutgoingComplete({
|
|
1800
|
+
inferenceId,
|
|
1801
|
+
conversationId,
|
|
1802
|
+
channelId,
|
|
1803
|
+
content: [{ type: 'text', text }],
|
|
1804
|
+
});
|
|
1805
|
+
} catch { /* observer surface — never disturb the turn */ }
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
private streamingServerFor(channelId: string) {
|
|
1809
|
+
// The map is keyed `${serverId}:${channelId}`; stream targets arrive as
|
|
1810
|
+
// bare descriptor ids (what resolveProseTarget returns). Scan like the
|
|
1811
|
+
// resolver does, and fail closed on cross-server ambiguity — the same
|
|
1812
|
+
// never-guess rule that governs delivery.
|
|
1813
|
+
const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
|
|
1814
|
+
if (matches.length !== 1) return null;
|
|
1815
|
+
const server = this.serverRegistry.getServer(matches[0]!.serverId);
|
|
1816
|
+
if (!server?.capabilities?.channels?.streaming) return null;
|
|
1817
|
+
return server;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1457
1820
|
resolveLocus(conversationId: string): string | null {
|
|
1458
1821
|
const home = this.homeChannelResolver?.(conversationId);
|
|
1459
1822
|
return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null;
|
|
@@ -1462,13 +1825,15 @@ export class ChannelRegistry {
|
|
|
1462
1825
|
async routeSpeech(
|
|
1463
1826
|
conversationId: string,
|
|
1464
1827
|
text: string,
|
|
1465
|
-
/**
|
|
1466
|
-
*
|
|
1467
|
-
*
|
|
1468
|
-
*
|
|
1469
|
-
* and
|
|
1470
|
-
|
|
1471
|
-
|
|
1828
|
+
/** The turn's FROZEN locus, snapshotted once by the caller (resolveLocus
|
|
1829
|
+
* at turn start) and passed to every segment of the turn. Mandatory:
|
|
1830
|
+
* there is deliberately no live-resolution fallback here — a late
|
|
1831
|
+
* dispatch resolving live can read the NEXT turn's trigger state or a
|
|
1832
|
+
* stale global default and land the reply in the wrong channel (item-3,
|
|
1833
|
+
* PR #32, and the 2026-07-22 Sol DM misroute). Explicit `null` means
|
|
1834
|
+
* "this turn is pinned to no locus": fail loudly rather than guess. */
|
|
1835
|
+
locusChannelId: string | null,
|
|
1836
|
+
): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> {
|
|
1472
1837
|
// Surface a routing failure: emit a trace AND notify the host (which drops
|
|
1473
1838
|
// a `[discord-send-failed]` marker into chronicle) so the agent learns her
|
|
1474
1839
|
// reply never reached the human, instead of it vanishing silently.
|
|
@@ -1476,6 +1841,7 @@ export class ChannelRegistry {
|
|
|
1476
1841
|
console.error(`[routeSpeech] ${conversationId}: ${reason} — speech NOT routed (${text.length} chars stay in chronicle)`);
|
|
1477
1842
|
this.emitTraceFn({
|
|
1478
1843
|
type: 'mcpl:speech-route-failed',
|
|
1844
|
+
conversationId,
|
|
1479
1845
|
channelId: channelId ?? '',
|
|
1480
1846
|
reason,
|
|
1481
1847
|
textLen: text.length,
|
|
@@ -1484,24 +1850,18 @@ export class ChannelRegistry {
|
|
|
1484
1850
|
return null;
|
|
1485
1851
|
};
|
|
1486
1852
|
|
|
1487
|
-
//
|
|
1488
|
-
//
|
|
1489
|
-
//
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
// as push/events and never touch `defaultPublishChannel`.
|
|
1495
|
-
// 3. the process-global `defaultPublishChannel` — last resort for turns
|
|
1496
|
-
// with no triggering channel (heartbeats, timers).
|
|
1497
|
-
// Using the global for a fork or a concurrent trunk turn is the item-3 bug:
|
|
1498
|
-
// it tracks the most-recent inbound across ALL channels, so a reply lands
|
|
1499
|
-
// wherever a message last happened to arrive rather than where it belongs.
|
|
1500
|
-
const channelId = overrideChannelId ?? this.resolveLocus(conversationId);
|
|
1853
|
+
// Runtime backstop for JS callers the compiler can't see: an omitted
|
|
1854
|
+
// locus is a routing bug at the call site, never something to paper over
|
|
1855
|
+
// with a live re-resolution.
|
|
1856
|
+
if (locusChannelId === undefined) {
|
|
1857
|
+
return fail(null, 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)');
|
|
1858
|
+
}
|
|
1859
|
+
const channelId = locusChannelId;
|
|
1501
1860
|
if (!channelId) {
|
|
1502
|
-
//
|
|
1503
|
-
//
|
|
1504
|
-
|
|
1861
|
+
// The turn froze with no locus (no home, no triggering channel, no
|
|
1862
|
+
// global inbound ever seen) — the agent was told its prose stays in
|
|
1863
|
+
// the archive; honor that.
|
|
1864
|
+
return fail(null, 'turn has no locus (no home/trigger channel; nothing to deliver into)');
|
|
1505
1865
|
}
|
|
1506
1866
|
|
|
1507
1867
|
const entry = this.findChannelEntry(channelId);
|
|
@@ -1509,6 +1869,38 @@ export class ChannelRegistry {
|
|
|
1509
1869
|
return fail(channelId, `no registered channel for locus "${channelId}"`);
|
|
1510
1870
|
}
|
|
1511
1871
|
|
|
1872
|
+
// INVARIANT: it is not possible to send into a closed channel. Speech
|
|
1873
|
+
// routed into a closed-but-registered locus (the canonical case: a DM
|
|
1874
|
+
// that woke the agent — DMs register closed) OPENS the channel first,
|
|
1875
|
+
// so typing indicators, reaction machinery, and inbound forwarding come
|
|
1876
|
+
// alive with the reply. If the open fails, the speech does NOT go out:
|
|
1877
|
+
// a half-alive delivery is worse than a loud marker.
|
|
1878
|
+
if (!entry.open) {
|
|
1879
|
+
try {
|
|
1880
|
+
await this.openChannelNow(entry, 'opened-by-delivery');
|
|
1881
|
+
console.error(
|
|
1882
|
+
`[routeSpeech] ${conversationId}: locus ${channelId} was closed — opened by delivery (speech implies engagement)`,
|
|
1883
|
+
);
|
|
1884
|
+
this.emitTraceFn({
|
|
1885
|
+
type: 'mcpl:channel-opened-by-send',
|
|
1886
|
+
serverId: entry.serverId,
|
|
1887
|
+
channelId,
|
|
1888
|
+
});
|
|
1889
|
+
try {
|
|
1890
|
+
this.onChannelAutoOpened?.({
|
|
1891
|
+
conversationId,
|
|
1892
|
+
serverId: entry.serverId,
|
|
1893
|
+
source: 'opened-by-delivery',
|
|
1894
|
+
channels: [{ channelId, label: entry.descriptor.label }],
|
|
1895
|
+
});
|
|
1896
|
+
} catch (err) {
|
|
1897
|
+
console.error('onChannelAutoOpened (delivery) failed:', err);
|
|
1898
|
+
}
|
|
1899
|
+
} catch (err) {
|
|
1900
|
+
return fail(channelId, `locus channel is closed and open failed: ${(err as Error).message}`);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1512
1904
|
const server = this.serverRegistry.getServer(entry.serverId);
|
|
1513
1905
|
if (!server) {
|
|
1514
1906
|
return fail(channelId, `server "${entry.serverId}" not found`);
|
|
@@ -1521,6 +1913,11 @@ export class ChannelRegistry {
|
|
|
1521
1913
|
};
|
|
1522
1914
|
const result = await server.sendChannelsPublish(publishParams);
|
|
1523
1915
|
const delivered = (result as { delivered?: boolean } | undefined)?.delivered ?? true;
|
|
1916
|
+
// Surface the posted message's id (ChannelsPublishResult.messageId) so
|
|
1917
|
+
// trace consumers can act on the just-posted message — e.g. a TTS-relay
|
|
1918
|
+
// tap editing it down to the words actually voiced on interruption.
|
|
1919
|
+
// Previously this was silently dropped here.
|
|
1920
|
+
const messageId = (result as { messageId?: string } | undefined)?.messageId;
|
|
1524
1921
|
|
|
1525
1922
|
// The server accepted the publish RPC but reported the message was not
|
|
1526
1923
|
// actually delivered (e.g. missing Send Messages permission). Previously
|
|
@@ -1532,13 +1929,16 @@ export class ChannelRegistry {
|
|
|
1532
1929
|
console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${entry.serverId}, delivered=${delivered})`);
|
|
1533
1930
|
this.emitTraceFn({
|
|
1534
1931
|
type: 'mcpl:speech-routed',
|
|
1932
|
+
conversationId,
|
|
1535
1933
|
serverId: entry.serverId,
|
|
1536
1934
|
channelId,
|
|
1537
1935
|
delivered,
|
|
1538
1936
|
textLen: text.length,
|
|
1937
|
+
text,
|
|
1938
|
+
...(messageId !== undefined ? { messageId } : {}),
|
|
1539
1939
|
});
|
|
1540
1940
|
|
|
1541
|
-
return { delivered, channelId };
|
|
1941
|
+
return { delivered, channelId, ...(messageId !== undefined ? { messageId } : {}) };
|
|
1542
1942
|
}
|
|
1543
1943
|
|
|
1544
1944
|
private async handleToolPublish(input: { channelId?: string; content?: string; text?: string }): Promise<ToolResult> {
|