@animalabs/agent-framework 0.6.10 → 0.7.0
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/framework.d.ts +115 -15
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +820 -104
- package/dist/src/framework.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +87 -7
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +329 -49
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- 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/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/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/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/framework.ts +881 -107
- package/src/mcpl/channel-registry.ts +401 -54
- package/src/modules/workspace/index.ts +13 -4
- package/src/types/agent.ts +18 -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: {
|
|
@@ -1462,13 +1772,15 @@ export class ChannelRegistry {
|
|
|
1462
1772
|
async routeSpeech(
|
|
1463
1773
|
conversationId: string,
|
|
1464
1774
|
text: string,
|
|
1465
|
-
/**
|
|
1466
|
-
*
|
|
1467
|
-
*
|
|
1468
|
-
*
|
|
1469
|
-
* and
|
|
1470
|
-
|
|
1471
|
-
|
|
1775
|
+
/** The turn's FROZEN locus, snapshotted once by the caller (resolveLocus
|
|
1776
|
+
* at turn start) and passed to every segment of the turn. Mandatory:
|
|
1777
|
+
* there is deliberately no live-resolution fallback here — a late
|
|
1778
|
+
* dispatch resolving live can read the NEXT turn's trigger state or a
|
|
1779
|
+
* stale global default and land the reply in the wrong channel (item-3,
|
|
1780
|
+
* PR #32, and the 2026-07-22 Sol DM misroute). Explicit `null` means
|
|
1781
|
+
* "this turn is pinned to no locus": fail loudly rather than guess. */
|
|
1782
|
+
locusChannelId: string | null,
|
|
1783
|
+
): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> {
|
|
1472
1784
|
// Surface a routing failure: emit a trace AND notify the host (which drops
|
|
1473
1785
|
// a `[discord-send-failed]` marker into chronicle) so the agent learns her
|
|
1474
1786
|
// reply never reached the human, instead of it vanishing silently.
|
|
@@ -1476,6 +1788,7 @@ export class ChannelRegistry {
|
|
|
1476
1788
|
console.error(`[routeSpeech] ${conversationId}: ${reason} — speech NOT routed (${text.length} chars stay in chronicle)`);
|
|
1477
1789
|
this.emitTraceFn({
|
|
1478
1790
|
type: 'mcpl:speech-route-failed',
|
|
1791
|
+
conversationId,
|
|
1479
1792
|
channelId: channelId ?? '',
|
|
1480
1793
|
reason,
|
|
1481
1794
|
textLen: text.length,
|
|
@@ -1484,24 +1797,18 @@ export class ChannelRegistry {
|
|
|
1484
1797
|
return null;
|
|
1485
1798
|
};
|
|
1486
1799
|
|
|
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);
|
|
1800
|
+
// Runtime backstop for JS callers the compiler can't see: an omitted
|
|
1801
|
+
// locus is a routing bug at the call site, never something to paper over
|
|
1802
|
+
// with a live re-resolution.
|
|
1803
|
+
if (locusChannelId === undefined) {
|
|
1804
|
+
return fail(null, 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)');
|
|
1805
|
+
}
|
|
1806
|
+
const channelId = locusChannelId;
|
|
1501
1807
|
if (!channelId) {
|
|
1502
|
-
//
|
|
1503
|
-
//
|
|
1504
|
-
|
|
1808
|
+
// The turn froze with no locus (no home, no triggering channel, no
|
|
1809
|
+
// global inbound ever seen) — the agent was told its prose stays in
|
|
1810
|
+
// the archive; honor that.
|
|
1811
|
+
return fail(null, 'turn has no locus (no home/trigger channel; nothing to deliver into)');
|
|
1505
1812
|
}
|
|
1506
1813
|
|
|
1507
1814
|
const entry = this.findChannelEntry(channelId);
|
|
@@ -1509,6 +1816,38 @@ export class ChannelRegistry {
|
|
|
1509
1816
|
return fail(channelId, `no registered channel for locus "${channelId}"`);
|
|
1510
1817
|
}
|
|
1511
1818
|
|
|
1819
|
+
// INVARIANT: it is not possible to send into a closed channel. Speech
|
|
1820
|
+
// routed into a closed-but-registered locus (the canonical case: a DM
|
|
1821
|
+
// that woke the agent — DMs register closed) OPENS the channel first,
|
|
1822
|
+
// so typing indicators, reaction machinery, and inbound forwarding come
|
|
1823
|
+
// alive with the reply. If the open fails, the speech does NOT go out:
|
|
1824
|
+
// a half-alive delivery is worse than a loud marker.
|
|
1825
|
+
if (!entry.open) {
|
|
1826
|
+
try {
|
|
1827
|
+
await this.openChannelNow(entry, 'opened-by-delivery');
|
|
1828
|
+
console.error(
|
|
1829
|
+
`[routeSpeech] ${conversationId}: locus ${channelId} was closed — opened by delivery (speech implies engagement)`,
|
|
1830
|
+
);
|
|
1831
|
+
this.emitTraceFn({
|
|
1832
|
+
type: 'mcpl:channel-opened-by-send',
|
|
1833
|
+
serverId: entry.serverId,
|
|
1834
|
+
channelId,
|
|
1835
|
+
});
|
|
1836
|
+
try {
|
|
1837
|
+
this.onChannelAutoOpened?.({
|
|
1838
|
+
conversationId,
|
|
1839
|
+
serverId: entry.serverId,
|
|
1840
|
+
source: 'opened-by-delivery',
|
|
1841
|
+
channels: [{ channelId, label: entry.descriptor.label }],
|
|
1842
|
+
});
|
|
1843
|
+
} catch (err) {
|
|
1844
|
+
console.error('onChannelAutoOpened (delivery) failed:', err);
|
|
1845
|
+
}
|
|
1846
|
+
} catch (err) {
|
|
1847
|
+
return fail(channelId, `locus channel is closed and open failed: ${(err as Error).message}`);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1512
1851
|
const server = this.serverRegistry.getServer(entry.serverId);
|
|
1513
1852
|
if (!server) {
|
|
1514
1853
|
return fail(channelId, `server "${entry.serverId}" not found`);
|
|
@@ -1521,6 +1860,11 @@ export class ChannelRegistry {
|
|
|
1521
1860
|
};
|
|
1522
1861
|
const result = await server.sendChannelsPublish(publishParams);
|
|
1523
1862
|
const delivered = (result as { delivered?: boolean } | undefined)?.delivered ?? true;
|
|
1863
|
+
// Surface the posted message's id (ChannelsPublishResult.messageId) so
|
|
1864
|
+
// trace consumers can act on the just-posted message — e.g. a TTS-relay
|
|
1865
|
+
// tap editing it down to the words actually voiced on interruption.
|
|
1866
|
+
// Previously this was silently dropped here.
|
|
1867
|
+
const messageId = (result as { messageId?: string } | undefined)?.messageId;
|
|
1524
1868
|
|
|
1525
1869
|
// The server accepted the publish RPC but reported the message was not
|
|
1526
1870
|
// actually delivered (e.g. missing Send Messages permission). Previously
|
|
@@ -1532,13 +1876,16 @@ export class ChannelRegistry {
|
|
|
1532
1876
|
console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${entry.serverId}, delivered=${delivered})`);
|
|
1533
1877
|
this.emitTraceFn({
|
|
1534
1878
|
type: 'mcpl:speech-routed',
|
|
1879
|
+
conversationId,
|
|
1535
1880
|
serverId: entry.serverId,
|
|
1536
1881
|
channelId,
|
|
1537
1882
|
delivered,
|
|
1538
1883
|
textLen: text.length,
|
|
1884
|
+
text,
|
|
1885
|
+
...(messageId !== undefined ? { messageId } : {}),
|
|
1539
1886
|
});
|
|
1540
1887
|
|
|
1541
|
-
return { delivered, channelId };
|
|
1888
|
+
return { delivered, channelId, ...(messageId !== undefined ? { messageId } : {}) };
|
|
1542
1889
|
}
|
|
1543
1890
|
|
|
1544
1891
|
private async handleToolPublish(input: { channelId?: string; content?: string; text?: string }): Promise<ToolResult> {
|
|
@@ -733,13 +733,13 @@ export class WorkspaceModule implements Module {
|
|
|
733
733
|
return [
|
|
734
734
|
{
|
|
735
735
|
name: 'read',
|
|
736
|
-
description: 'Read a file from the workspace. Returns content with line numbers.',
|
|
736
|
+
description: 'Read a file from the workspace. Returns content with line numbers. Reads at most 2000 lines per call by default — use offset/limit to page through larger files.',
|
|
737
737
|
inputSchema: {
|
|
738
738
|
type: 'object' as const,
|
|
739
739
|
properties: {
|
|
740
740
|
path: { type: 'string', description: 'File path (mount-prefixed, e.g., "project/src/main.ts")' },
|
|
741
741
|
offset: { type: 'number', description: 'Starting line number (1-indexed)' },
|
|
742
|
-
limit: { type: 'number', description: 'Maximum number of lines to return' },
|
|
742
|
+
limit: { type: 'number', description: 'Maximum number of lines to return (default 2000)' },
|
|
743
743
|
},
|
|
744
744
|
required: ['path'],
|
|
745
745
|
},
|
|
@@ -1344,10 +1344,16 @@ export class WorkspaceModule implements Module {
|
|
|
1344
1344
|
const content = blob.toString('utf-8');
|
|
1345
1345
|
const lines = content.split('\n');
|
|
1346
1346
|
|
|
1347
|
-
// Apply offset/limit
|
|
1347
|
+
// Apply offset/limit. An unlimited read of a large file would inject the
|
|
1348
|
+
// whole thing into the turn (a 2MB file ≈ 600k tokens → the request blows
|
|
1349
|
+
// past the model context and 400s), so an omitted limit falls back to a
|
|
1350
|
+
// default cap; the result reports total/from/to so the agent can page.
|
|
1351
|
+
const DEFAULT_READ_LINE_LIMIT = 2000;
|
|
1348
1352
|
const startLine = (input.offset ?? 1) - 1; // Convert to 0-indexed
|
|
1349
|
-
const
|
|
1353
|
+
const effectiveLimit = input.limit ?? DEFAULT_READ_LINE_LIMIT;
|
|
1354
|
+
const endLine = startLine + effectiveLimit;
|
|
1350
1355
|
const slice = lines.slice(startLine, endLine);
|
|
1356
|
+
const truncated = endLine < lines.length;
|
|
1351
1357
|
|
|
1352
1358
|
// Format with line numbers (cat -n style)
|
|
1353
1359
|
const formatted = slice
|
|
@@ -1361,6 +1367,9 @@ export class WorkspaceModule implements Module {
|
|
|
1361
1367
|
totalLines: lines.length,
|
|
1362
1368
|
fromLine: startLine + 1,
|
|
1363
1369
|
toLine: Math.min(endLine, lines.length),
|
|
1370
|
+
...(truncated
|
|
1371
|
+
? { note: `Truncated at ${effectiveLimit} lines (file has ${lines.length}). Use offset/limit to read more.` }
|
|
1372
|
+
: {}),
|
|
1364
1373
|
content: formatted,
|
|
1365
1374
|
},
|
|
1366
1375
|
};
|
package/src/types/agent.ts
CHANGED
|
@@ -133,6 +133,16 @@ export interface AgentConfig {
|
|
|
133
133
|
*/
|
|
134
134
|
announceHumanTurns?: boolean;
|
|
135
135
|
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* How the agent's PLAIN PROSE (non-tool output) reaches channels.
|
|
139
|
+
* - 'locus' (default): host-inferred — the turn-frozen locus machinery.
|
|
140
|
+
* - 'explicit': the model prefixes prose with a destination
|
|
141
|
+
* (`>>#channel` / `>>@person` / `>>skip_reply`); unprefixed prose is never
|
|
142
|
+
* delivered — it bounces to a clipboard for a cheap prefixed resend.
|
|
143
|
+
* See docs/explicit-prose-routing.md.
|
|
144
|
+
*/
|
|
145
|
+
proseRouting?: 'locus' | 'explicit';
|
|
136
146
|
}
|
|
137
147
|
|
|
138
148
|
/** The intentionally small set of agent settings that may change in-process. */
|
|
@@ -239,4 +249,12 @@ export interface InferenceRequest {
|
|
|
239
249
|
* which correctly fall back to the global default channel.
|
|
240
250
|
*/
|
|
241
251
|
channelId?: string;
|
|
252
|
+
/**
|
|
253
|
+
* True when the triggering message explicitly addressed the agent (mention,
|
|
254
|
+
* reply-to-bot, DM — `chat:addressed` in MCPL RFC-001 terms). When a wake
|
|
255
|
+
* batch spans several channels, an addressed channel outranks ambient
|
|
256
|
+
* chatter for the turn's frozen speech locus: overheard conversation must
|
|
257
|
+
* not capture the agent's voice just by being newest.
|
|
258
|
+
*/
|
|
259
|
+
addressed?: boolean;
|
|
242
260
|
}
|