@animalabs/agent-framework 0.6.8 → 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 +6 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +11 -1
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +129 -15
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +967 -113
- 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 +10 -0
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +61 -6
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +2 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts +17 -0
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +143 -7
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/types/agent.d.ts +32 -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 +7 -3
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/test/workspace-watcher.test.js +161 -9
- package/dist/test/workspace-watcher.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/agent.ts +11 -1
- package/src/framework.ts +1037 -116
- package/src/mcpl/channel-registry.ts +401 -54
- package/src/modules/workspace/index.ts +60 -6
- package/src/modules/workspace/types.ts +2 -0
- package/src/modules/workspace/watcher.ts +160 -7
- package/src/types/agent.ts +35 -0
- package/src/types/trace.ts +47 -1
|
@@ -190,6 +190,7 @@ export class ChannelRegistry {
|
|
|
190
190
|
sendTypingFn;
|
|
191
191
|
shouldTriggerInference;
|
|
192
192
|
onRouteFailure;
|
|
193
|
+
onChannelAutoOpened;
|
|
193
194
|
homeChannelResolver;
|
|
194
195
|
activeChannelResolver;
|
|
195
196
|
store;
|
|
@@ -220,6 +221,7 @@ export class ChannelRegistry {
|
|
|
220
221
|
this.sendTypingFn = options?.sendTypingFn;
|
|
221
222
|
this.shouldTriggerInference = options?.shouldTriggerInference;
|
|
222
223
|
this.onRouteFailure = options?.onRouteFailure;
|
|
224
|
+
this.onChannelAutoOpened = options?.onChannelAutoOpened;
|
|
223
225
|
this.homeChannelResolver = options?.homeChannelResolver;
|
|
224
226
|
this.activeChannelResolver = options?.activeChannelResolver;
|
|
225
227
|
this.store = options?.store;
|
|
@@ -398,6 +400,7 @@ export class ChannelRegistry {
|
|
|
398
400
|
content: convertedContent,
|
|
399
401
|
timestamp: message.timestamp,
|
|
400
402
|
metadata: message.metadata,
|
|
403
|
+
...(message.tags ? { tags: message.tags } : {}),
|
|
401
404
|
triggerInference,
|
|
402
405
|
};
|
|
403
406
|
// Push to the processing queue
|
|
@@ -434,9 +437,19 @@ export class ChannelRegistry {
|
|
|
434
437
|
* channels/register or channels/changed overwrites this descriptor with the
|
|
435
438
|
* richer one.
|
|
436
439
|
*/
|
|
437
|
-
ensureChannelRegistered(serverId, channelId, label) {
|
|
440
|
+
ensureChannelRegistered(serverId, channelId, label, extraMetadata) {
|
|
438
441
|
const existing = this.findChannelEntry(channelId);
|
|
439
442
|
if (existing) {
|
|
443
|
+
// Backfill identity onto a bare lazy registration: a DM that first
|
|
444
|
+
// arrived with no label/recipient info gets a usable name the next
|
|
445
|
+
// time a message reveals it (labels must show people, not ids).
|
|
446
|
+
if (existing.descriptor.metadata?.lazyRegistered &&
|
|
447
|
+
label && existing.descriptor.label === channelId) {
|
|
448
|
+
existing.descriptor.label = label;
|
|
449
|
+
if (extraMetadata) {
|
|
450
|
+
existing.descriptor.metadata = { ...existing.descriptor.metadata, ...extraMetadata };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
440
453
|
return;
|
|
441
454
|
}
|
|
442
455
|
const key = `${serverId}:${channelId}`;
|
|
@@ -447,7 +460,7 @@ export class ChannelRegistry {
|
|
|
447
460
|
type: serverId,
|
|
448
461
|
label: label ?? channelId,
|
|
449
462
|
direction: 'bidirectional',
|
|
450
|
-
metadata: { lazyRegistered: true },
|
|
463
|
+
metadata: { lazyRegistered: true, ...(extraMetadata ?? {}) },
|
|
451
464
|
},
|
|
452
465
|
open: false,
|
|
453
466
|
});
|
|
@@ -775,25 +788,64 @@ export class ChannelRegistry {
|
|
|
775
788
|
timestamp: new Date().toISOString(),
|
|
776
789
|
});
|
|
777
790
|
}
|
|
791
|
+
/**
|
|
792
|
+
* True when the server's recipe subscription policy wants this channel
|
|
793
|
+
* open. This is an ONGOING admission policy (subscribed ⇒ open, no matter
|
|
794
|
+
* when the channel is discovered) — not just a bootstrap seed. Allow-list
|
|
795
|
+
* entries may be composite MCPL ids or raw server-internal ids.
|
|
796
|
+
*/
|
|
797
|
+
policyWantsOpen(serverId, channel) {
|
|
798
|
+
const policy = this.legacyPolicies.get(serverId) ?? 'manual';
|
|
799
|
+
if (policy === 'auto')
|
|
800
|
+
return true;
|
|
801
|
+
if (Array.isArray(policy)) {
|
|
802
|
+
if (policy.includes(channel.id))
|
|
803
|
+
return true;
|
|
804
|
+
const raw = channel.address?.channelId;
|
|
805
|
+
return typeof raw === 'string' && raw.length > 0 && policy.includes(raw);
|
|
806
|
+
}
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Returns true when this call made a FRESH subscription-policy decision to
|
|
811
|
+
* open the channel — the caller announces those to the agent (once per
|
|
812
|
+
* channel ever: the decision persists in chronicle, so reboots see an
|
|
813
|
+
* existing non-default source and never re-fire).
|
|
814
|
+
*/
|
|
778
815
|
ensureInitialDesiredState(serverId, channel) {
|
|
816
|
+
const byPolicy = this.policyWantsOpen(serverId, channel);
|
|
817
|
+
const wantsOpen = channel.initiallyOpen === true || byPolicy;
|
|
779
818
|
const existing = this.desiredStates.get(this.lifecycleKey(serverId, channel.id));
|
|
780
819
|
if (existing) {
|
|
781
|
-
// A server upgrading its descriptor to initiallyOpen
|
|
782
|
-
//
|
|
820
|
+
// A server upgrading its descriptor to initiallyOpen — or a channel
|
|
821
|
+
// matching the subscription policy — may lift a pure default:
|
|
822
|
+
// 'default-closed' means nobody ever decided. Real decisions
|
|
783
823
|
// (agent-tool, invitation-declined, legacy migration, …) always stick.
|
|
784
|
-
if (
|
|
785
|
-
this.setDesiredState(serverId, channel.id, 'open', 'server-bootstrap');
|
|
824
|
+
if (wantsOpen && existing.state === 'closed' && existing.source === 'default-closed') {
|
|
825
|
+
this.setDesiredState(serverId, channel.id, 'open', channel.initiallyOpen === true ? 'server-bootstrap' : 'subscription-policy');
|
|
826
|
+
return channel.initiallyOpen !== true && byPolicy;
|
|
786
827
|
}
|
|
787
|
-
return;
|
|
828
|
+
return false;
|
|
788
829
|
}
|
|
789
|
-
this.setDesiredState(serverId, channel.id,
|
|
830
|
+
this.setDesiredState(serverId, channel.id, wantsOpen ? 'open' : 'closed', channel.initiallyOpen === true
|
|
831
|
+
? 'server-bootstrap'
|
|
832
|
+
: wantsOpen
|
|
833
|
+
? 'subscription-policy'
|
|
834
|
+
: 'default-closed');
|
|
835
|
+
return channel.initiallyOpen !== true && byPolicy;
|
|
790
836
|
}
|
|
791
837
|
async reconcileChannels(serverId, channels) {
|
|
792
838
|
const server = this.serverRegistry.getServer(serverId);
|
|
793
839
|
if (!server)
|
|
794
840
|
return;
|
|
841
|
+
// Channels the subscription policy freshly admitted in THIS pass —
|
|
842
|
+
// announced to the agent as one batched notice after the loop, so a
|
|
843
|
+
// first boot on a new policy doesn't produce one notice per channel.
|
|
844
|
+
const policyOpened = [];
|
|
795
845
|
for (const channel of channels) {
|
|
796
|
-
this.ensureInitialDesiredState(serverId, channel)
|
|
846
|
+
if (this.ensureInitialDesiredState(serverId, channel)) {
|
|
847
|
+
policyOpened.push({ channelId: channel.id, label: channel.label });
|
|
848
|
+
}
|
|
797
849
|
const key = `${serverId}:${channel.id}`;
|
|
798
850
|
const desired = this.getDesiredState(serverId, channel.id);
|
|
799
851
|
const entry = this.channels.get(key);
|
|
@@ -835,6 +887,21 @@ export class ChannelRegistry {
|
|
|
835
887
|
});
|
|
836
888
|
}
|
|
837
889
|
}
|
|
890
|
+
// Announce policy admissions to the agent — nothing may start flowing
|
|
891
|
+
// traffic into their window without them being told, and told how to
|
|
892
|
+
// opt out (channel_close; their decision outranks policy).
|
|
893
|
+
if (policyOpened.length > 0) {
|
|
894
|
+
try {
|
|
895
|
+
this.onChannelAutoOpened?.({
|
|
896
|
+
serverId,
|
|
897
|
+
source: 'subscription-policy',
|
|
898
|
+
channels: policyOpened,
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
catch (err) {
|
|
902
|
+
console.error('onChannelAutoOpened (policy) failed:', err);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
838
905
|
}
|
|
839
906
|
// ==========================================================================
|
|
840
907
|
// Private: Typing notification
|
|
@@ -919,6 +986,198 @@ export class ChannelRegistry {
|
|
|
919
986
|
data: allChannels,
|
|
920
987
|
};
|
|
921
988
|
}
|
|
989
|
+
/**
|
|
990
|
+
* The single open executor: record durable desired-open intent, tell the
|
|
991
|
+
* server to subscribe, mark the live entry open. Every path that opens a
|
|
992
|
+
* channel — the agent's channel_open tool, delivery into a closed locus,
|
|
993
|
+
* an explicit send into a closed channel — funnels here, so lifecycle
|
|
994
|
+
* events, desired state, and live state can never diverge by path.
|
|
995
|
+
* Desired state is recorded BEFORE the server round-trip: intent sticks
|
|
996
|
+
* even if the subscribe fails, and reconciliation retries later.
|
|
997
|
+
*/
|
|
998
|
+
async openChannelNow(entry, source, history) {
|
|
999
|
+
this.setDesiredState(entry.serverId, entry.descriptor.id, 'open', source);
|
|
1000
|
+
const server = this.serverRegistry.getServer(entry.serverId);
|
|
1001
|
+
if (!server) {
|
|
1002
|
+
throw new Error(`Server not found: ${entry.serverId}`);
|
|
1003
|
+
}
|
|
1004
|
+
const result = await server.sendChannelsOpen({
|
|
1005
|
+
channelId: entry.descriptor.id,
|
|
1006
|
+
type: entry.descriptor.type,
|
|
1007
|
+
address: entry.descriptor.address,
|
|
1008
|
+
...(history ? { history } : {}),
|
|
1009
|
+
});
|
|
1010
|
+
entry.open = true;
|
|
1011
|
+
return result;
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Resolve a `>>` prose-routing target (explicit prose routing —
|
|
1015
|
+
* docs/explicit-prose-routing.md) to a registered channel.
|
|
1016
|
+
*
|
|
1017
|
+
* Accepted spellings, tried in order:
|
|
1018
|
+
* 1. exact descriptor id (`discord:guild:123`, always unambiguous)
|
|
1019
|
+
* 2. exact raw server-internal id (`address.channelId`)
|
|
1020
|
+
* 3. `#label` / bare label — case-insensitive match on descriptor label
|
|
1021
|
+
* with any leading '#' stripped from both sides
|
|
1022
|
+
* 4. `@name` — DM descriptor labels (`DM: name`), case-insensitive
|
|
1023
|
+
*
|
|
1024
|
+
* Ambiguity is an error carrying candidates — never a guess: this is the
|
|
1025
|
+
* mechanism that makes prose misdelivery structurally impossible.
|
|
1026
|
+
*/
|
|
1027
|
+
resolveProseTarget(spec) {
|
|
1028
|
+
const entries = [...this.channels.values()];
|
|
1029
|
+
const trimmed = spec.trim();
|
|
1030
|
+
if (!trimmed)
|
|
1031
|
+
return { error: 'empty target' };
|
|
1032
|
+
const exact = entries.filter((e) => e.descriptor.id === trimmed);
|
|
1033
|
+
if (exact.length === 1) {
|
|
1034
|
+
return { channelId: exact[0].descriptor.id, label: exact[0].descriptor.label };
|
|
1035
|
+
}
|
|
1036
|
+
if (exact.length > 1) {
|
|
1037
|
+
return {
|
|
1038
|
+
error: `channel id "${trimmed}" is ambiguous across servers`,
|
|
1039
|
+
candidates: exact.map((e) => `${e.serverId}:${e.descriptor.id}`),
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
const raw = entries.filter((e) => e.descriptor.address?.channelId === trimmed);
|
|
1043
|
+
if (raw.length === 1) {
|
|
1044
|
+
return { channelId: raw[0].descriptor.id, label: raw[0].descriptor.label };
|
|
1045
|
+
}
|
|
1046
|
+
if (raw.length > 1) {
|
|
1047
|
+
return {
|
|
1048
|
+
error: `raw id "${trimmed}" is ambiguous`,
|
|
1049
|
+
candidates: raw.map((e) => e.descriptor.id),
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
const norm = (s) => s.replace(/^#/, '').toLowerCase();
|
|
1053
|
+
// DM addressing is PEOPLE-first: usernames and mention tokens, never
|
|
1054
|
+
// ids-only (2026-07-24, antra + Fable's live-canary bug report). A DM
|
|
1055
|
+
// entry is matched by, in order: recipientId (from a `<@id>` mention
|
|
1056
|
+
// token), exact recipient/label name, then prefix-lenient name (handles
|
|
1057
|
+
// "@antra_tessera" vs a display name "antra" and vice versa).
|
|
1058
|
+
const mention = /^<@!?(\d+)>$/.exec(trimmed);
|
|
1059
|
+
if (trimmed.startsWith('@') || mention) {
|
|
1060
|
+
const dmMeta = (e) => e.descriptor.metadata;
|
|
1061
|
+
const dmName = (e) => {
|
|
1062
|
+
const meta = dmMeta(e);
|
|
1063
|
+
if (meta?.recipientName)
|
|
1064
|
+
return meta.recipientName.toLowerCase();
|
|
1065
|
+
const label = (e.descriptor.label ?? '').toLowerCase();
|
|
1066
|
+
return label.startsWith('dm: ') ? label.slice(4) : label;
|
|
1067
|
+
};
|
|
1068
|
+
const isDmEntry = (e) => dmMeta(e)?.channelType === 'dm' ||
|
|
1069
|
+
(e.descriptor.label ?? '').toLowerCase().startsWith('dm: ') ||
|
|
1070
|
+
e.descriptor.id.includes(':dm:');
|
|
1071
|
+
const dms = entries.filter(isDmEntry);
|
|
1072
|
+
if (mention) {
|
|
1073
|
+
const id = mention[1];
|
|
1074
|
+
const byId = dms.filter((e) => dmMeta(e)?.recipientId === id);
|
|
1075
|
+
if (byId.length === 1) {
|
|
1076
|
+
return { channelId: byId[0].descriptor.id, label: byId[0].descriptor.label };
|
|
1077
|
+
}
|
|
1078
|
+
return {
|
|
1079
|
+
error: `no registered DM matches the mention <@${id}>`,
|
|
1080
|
+
candidates: dms.map((e) => e.descriptor.label ?? e.descriptor.id).slice(0, 6),
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
const name = trimmed.slice(1).toLowerCase();
|
|
1084
|
+
const exact = dms.filter((e) => dmName(e) === name);
|
|
1085
|
+
const pool = exact.length > 0
|
|
1086
|
+
? exact
|
|
1087
|
+
: dms.filter((e) => {
|
|
1088
|
+
const n = dmName(e);
|
|
1089
|
+
return n.length >= 3 && (n.startsWith(name) || name.startsWith(n) || n.includes(name));
|
|
1090
|
+
});
|
|
1091
|
+
if (pool.length === 1) {
|
|
1092
|
+
return { channelId: pool[0].descriptor.id, label: pool[0].descriptor.label };
|
|
1093
|
+
}
|
|
1094
|
+
if (pool.length > 1) {
|
|
1095
|
+
return {
|
|
1096
|
+
error: `"${trimmed}" matches several DMs`,
|
|
1097
|
+
candidates: pool.map((e) => e.descriptor.label ?? e.descriptor.id),
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
return {
|
|
1101
|
+
error: `no registered DM found for "${trimmed}" — for someone without a registered DM channel, use the send_dm tool`,
|
|
1102
|
+
...(dms.length ? { candidates: dms.map((e) => e.descriptor.label ?? e.descriptor.id).slice(0, 6) } : {}),
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
const byLabel = entries.filter((e) => norm(e.descriptor.label ?? '') === norm(trimmed));
|
|
1106
|
+
if (byLabel.length === 1) {
|
|
1107
|
+
return { channelId: byLabel[0].descriptor.id, label: byLabel[0].descriptor.label };
|
|
1108
|
+
}
|
|
1109
|
+
if (byLabel.length > 1) {
|
|
1110
|
+
return {
|
|
1111
|
+
error: `label "${trimmed}" matches several channels`,
|
|
1112
|
+
candidates: byLabel.map((e) => e.descriptor.id),
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
// Name-segment match: server labels carry a disambiguating suffix
|
|
1116
|
+
// (`#fable (antra's server)`) that agents naturally omit — `>>#fable`
|
|
1117
|
+
// must resolve. Strip a trailing parenthetical from the stored label and
|
|
1118
|
+
// compare the bare channel name. Same name in several guilds is a real
|
|
1119
|
+
// ambiguity: error with full labels so the agent can use the exact id.
|
|
1120
|
+
const nameOf = (label) => norm(label.replace(/\s*\([^)]*\)\s*$/, ''));
|
|
1121
|
+
const byName = entries.filter((e) => nameOf(e.descriptor.label ?? '') === norm(trimmed));
|
|
1122
|
+
if (byName.length === 1) {
|
|
1123
|
+
return { channelId: byName[0].descriptor.id, label: byName[0].descriptor.label };
|
|
1124
|
+
}
|
|
1125
|
+
if (byName.length > 1) {
|
|
1126
|
+
return {
|
|
1127
|
+
error: `"${trimmed}" matches several channels`,
|
|
1128
|
+
candidates: byName.map((e) => `${e.descriptor.label} = ${e.descriptor.id}`),
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
// No match: offer near-candidates (labels containing the name) so the
|
|
1132
|
+
// bounce notice is self-healing rather than a dead end.
|
|
1133
|
+
const near = entries
|
|
1134
|
+
.filter((e) => norm(e.descriptor.label ?? '').includes(norm(trimmed)))
|
|
1135
|
+
.slice(0, 5)
|
|
1136
|
+
.map((e) => `${e.descriptor.label} = ${e.descriptor.id}`);
|
|
1137
|
+
return { error: `no channel matches "${trimmed}"`, ...(near.length ? { candidates: near } : {}) };
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Open a channel because something was DELIVERED into it (explicit send
|
|
1141
|
+
* tool or routed speech). Sending into a closed channel is not a thing:
|
|
1142
|
+
* engaging a channel opens it, so typing indicators, reaction machinery,
|
|
1143
|
+
* and inbound forwarding all come alive with the first outbound message.
|
|
1144
|
+
* Accepts composite MCPL ids or raw server-internal ids (explicit send
|
|
1145
|
+
* tools receive whatever the agent typed).
|
|
1146
|
+
*/
|
|
1147
|
+
async openIfClosedForSend(rawChannelId, serverId) {
|
|
1148
|
+
let matches = [...this.channels.values()].filter((e) => e.descriptor.id === rawChannelId && (!serverId || e.serverId === serverId));
|
|
1149
|
+
if (matches.length === 0) {
|
|
1150
|
+
matches = [...this.channels.values()].filter((e) => e.descriptor.address?.channelId === rawChannelId &&
|
|
1151
|
+
(!serverId || e.serverId === serverId));
|
|
1152
|
+
}
|
|
1153
|
+
if (matches.length === 0)
|
|
1154
|
+
return { status: 'unknown-channel' };
|
|
1155
|
+
if (matches.length > 1)
|
|
1156
|
+
return { status: 'ambiguous' };
|
|
1157
|
+
const entry = matches[0];
|
|
1158
|
+
const resolved = { channelId: entry.descriptor.id, label: entry.descriptor.label };
|
|
1159
|
+
if (entry.open && this.getDesiredState(entry.serverId, entry.descriptor.id) === 'open') {
|
|
1160
|
+
return { status: 'already-open', ...resolved };
|
|
1161
|
+
}
|
|
1162
|
+
try {
|
|
1163
|
+
await this.openChannelNow(entry, 'opened-by-reply');
|
|
1164
|
+
this.emitTraceFn({
|
|
1165
|
+
type: 'mcpl:channel-opened-by-send',
|
|
1166
|
+
serverId: entry.serverId,
|
|
1167
|
+
channelId: entry.descriptor.id,
|
|
1168
|
+
});
|
|
1169
|
+
return { status: 'opened', ...resolved };
|
|
1170
|
+
}
|
|
1171
|
+
catch (err) {
|
|
1172
|
+
this.emitTraceFn({
|
|
1173
|
+
type: 'mcpl:channel-open-failed',
|
|
1174
|
+
serverId: entry.serverId,
|
|
1175
|
+
channelId: entry.descriptor.id,
|
|
1176
|
+
error: err.message,
|
|
1177
|
+
});
|
|
1178
|
+
return { status: 'open-failed', ...resolved };
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
922
1181
|
async handleToolOpen(input) {
|
|
923
1182
|
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
|
|
924
1183
|
const entry = resolved.entry;
|
|
@@ -936,15 +1195,6 @@ export class ChannelRegistry {
|
|
|
936
1195
|
data: { channelId: input.channelId, status: 'already open' },
|
|
937
1196
|
};
|
|
938
1197
|
}
|
|
939
|
-
this.setDesiredState(entry.serverId, input.channelId, 'open', 'agent-tool');
|
|
940
|
-
const server = this.serverRegistry.getServer(entry.serverId);
|
|
941
|
-
if (!server) {
|
|
942
|
-
return {
|
|
943
|
-
success: false,
|
|
944
|
-
error: `Server not found: ${entry.serverId}`,
|
|
945
|
-
isError: true,
|
|
946
|
-
};
|
|
947
|
-
}
|
|
948
1198
|
try {
|
|
949
1199
|
const history = input.backscroll
|
|
950
1200
|
? {
|
|
@@ -952,13 +1202,7 @@ export class ChannelRegistry {
|
|
|
952
1202
|
...(input.beforeMessageId ? { beforeMessageId: input.beforeMessageId } : {}),
|
|
953
1203
|
}
|
|
954
1204
|
: undefined;
|
|
955
|
-
const result = await
|
|
956
|
-
channelId: entry.descriptor.id,
|
|
957
|
-
type: entry.descriptor.type,
|
|
958
|
-
address: entry.descriptor.address,
|
|
959
|
-
...(history ? { history } : {}),
|
|
960
|
-
});
|
|
961
|
-
entry.open = true;
|
|
1205
|
+
const result = await this.openChannelNow(entry, 'agent-tool', history);
|
|
962
1206
|
return {
|
|
963
1207
|
success: true,
|
|
964
1208
|
data: {
|
|
@@ -1144,12 +1388,14 @@ export class ChannelRegistry {
|
|
|
1144
1388
|
return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null;
|
|
1145
1389
|
}
|
|
1146
1390
|
async routeSpeech(conversationId, text,
|
|
1147
|
-
/**
|
|
1148
|
-
*
|
|
1149
|
-
*
|
|
1150
|
-
*
|
|
1151
|
-
* and
|
|
1152
|
-
|
|
1391
|
+
/** The turn's FROZEN locus, snapshotted once by the caller (resolveLocus
|
|
1392
|
+
* at turn start) and passed to every segment of the turn. Mandatory:
|
|
1393
|
+
* there is deliberately no live-resolution fallback here — a late
|
|
1394
|
+
* dispatch resolving live can read the NEXT turn's trigger state or a
|
|
1395
|
+
* stale global default and land the reply in the wrong channel (item-3,
|
|
1396
|
+
* PR #32, and the 2026-07-22 Sol DM misroute). Explicit `null` means
|
|
1397
|
+
* "this turn is pinned to no locus": fail loudly rather than guess. */
|
|
1398
|
+
locusChannelId) {
|
|
1153
1399
|
// Surface a routing failure: emit a trace AND notify the host (which drops
|
|
1154
1400
|
// a `[discord-send-failed]` marker into chronicle) so the agent learns her
|
|
1155
1401
|
// reply never reached the human, instead of it vanishing silently.
|
|
@@ -1157,6 +1403,7 @@ export class ChannelRegistry {
|
|
|
1157
1403
|
console.error(`[routeSpeech] ${conversationId}: ${reason} — speech NOT routed (${text.length} chars stay in chronicle)`);
|
|
1158
1404
|
this.emitTraceFn({
|
|
1159
1405
|
type: 'mcpl:speech-route-failed',
|
|
1406
|
+
conversationId,
|
|
1160
1407
|
channelId: channelId ?? '',
|
|
1161
1408
|
reason,
|
|
1162
1409
|
textLen: text.length,
|
|
@@ -1164,29 +1411,54 @@ export class ChannelRegistry {
|
|
|
1164
1411
|
this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length });
|
|
1165
1412
|
return null;
|
|
1166
1413
|
};
|
|
1167
|
-
//
|
|
1168
|
-
//
|
|
1169
|
-
//
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
// as push/events and never touch `defaultPublishChannel`.
|
|
1175
|
-
// 3. the process-global `defaultPublishChannel` — last resort for turns
|
|
1176
|
-
// with no triggering channel (heartbeats, timers).
|
|
1177
|
-
// Using the global for a fork or a concurrent trunk turn is the item-3 bug:
|
|
1178
|
-
// it tracks the most-recent inbound across ALL channels, so a reply lands
|
|
1179
|
-
// wherever a message last happened to arrive rather than where it belongs.
|
|
1180
|
-
const channelId = overrideChannelId ?? this.resolveLocus(conversationId);
|
|
1414
|
+
// Runtime backstop for JS callers the compiler can't see: an omitted
|
|
1415
|
+
// locus is a routing bug at the call site, never something to paper over
|
|
1416
|
+
// with a live re-resolution.
|
|
1417
|
+
if (locusChannelId === undefined) {
|
|
1418
|
+
return fail(null, 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)');
|
|
1419
|
+
}
|
|
1420
|
+
const channelId = locusChannelId;
|
|
1181
1421
|
if (!channelId) {
|
|
1182
|
-
//
|
|
1183
|
-
//
|
|
1184
|
-
|
|
1422
|
+
// The turn froze with no locus (no home, no triggering channel, no
|
|
1423
|
+
// global inbound ever seen) — the agent was told its prose stays in
|
|
1424
|
+
// the archive; honor that.
|
|
1425
|
+
return fail(null, 'turn has no locus (no home/trigger channel; nothing to deliver into)');
|
|
1185
1426
|
}
|
|
1186
1427
|
const entry = this.findChannelEntry(channelId);
|
|
1187
1428
|
if (!entry) {
|
|
1188
1429
|
return fail(channelId, `no registered channel for locus "${channelId}"`);
|
|
1189
1430
|
}
|
|
1431
|
+
// INVARIANT: it is not possible to send into a closed channel. Speech
|
|
1432
|
+
// routed into a closed-but-registered locus (the canonical case: a DM
|
|
1433
|
+
// that woke the agent — DMs register closed) OPENS the channel first,
|
|
1434
|
+
// so typing indicators, reaction machinery, and inbound forwarding come
|
|
1435
|
+
// alive with the reply. If the open fails, the speech does NOT go out:
|
|
1436
|
+
// a half-alive delivery is worse than a loud marker.
|
|
1437
|
+
if (!entry.open) {
|
|
1438
|
+
try {
|
|
1439
|
+
await this.openChannelNow(entry, 'opened-by-delivery');
|
|
1440
|
+
console.error(`[routeSpeech] ${conversationId}: locus ${channelId} was closed — opened by delivery (speech implies engagement)`);
|
|
1441
|
+
this.emitTraceFn({
|
|
1442
|
+
type: 'mcpl:channel-opened-by-send',
|
|
1443
|
+
serverId: entry.serverId,
|
|
1444
|
+
channelId,
|
|
1445
|
+
});
|
|
1446
|
+
try {
|
|
1447
|
+
this.onChannelAutoOpened?.({
|
|
1448
|
+
conversationId,
|
|
1449
|
+
serverId: entry.serverId,
|
|
1450
|
+
source: 'opened-by-delivery',
|
|
1451
|
+
channels: [{ channelId, label: entry.descriptor.label }],
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
catch (err) {
|
|
1455
|
+
console.error('onChannelAutoOpened (delivery) failed:', err);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
catch (err) {
|
|
1459
|
+
return fail(channelId, `locus channel is closed and open failed: ${err.message}`);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1190
1462
|
const server = this.serverRegistry.getServer(entry.serverId);
|
|
1191
1463
|
if (!server) {
|
|
1192
1464
|
return fail(channelId, `server "${entry.serverId}" not found`);
|
|
@@ -1198,6 +1470,11 @@ export class ChannelRegistry {
|
|
|
1198
1470
|
};
|
|
1199
1471
|
const result = await server.sendChannelsPublish(publishParams);
|
|
1200
1472
|
const delivered = result?.delivered ?? true;
|
|
1473
|
+
// Surface the posted message's id (ChannelsPublishResult.messageId) so
|
|
1474
|
+
// trace consumers can act on the just-posted message — e.g. a TTS-relay
|
|
1475
|
+
// tap editing it down to the words actually voiced on interruption.
|
|
1476
|
+
// Previously this was silently dropped here.
|
|
1477
|
+
const messageId = result?.messageId;
|
|
1201
1478
|
// The server accepted the publish RPC but reported the message was not
|
|
1202
1479
|
// actually delivered (e.g. missing Send Messages permission). Previously
|
|
1203
1480
|
// this returned `{ delivered: true }`, masking the failure. Surface it.
|
|
@@ -1207,12 +1484,15 @@ export class ChannelRegistry {
|
|
|
1207
1484
|
console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${entry.serverId}, delivered=${delivered})`);
|
|
1208
1485
|
this.emitTraceFn({
|
|
1209
1486
|
type: 'mcpl:speech-routed',
|
|
1487
|
+
conversationId,
|
|
1210
1488
|
serverId: entry.serverId,
|
|
1211
1489
|
channelId,
|
|
1212
1490
|
delivered,
|
|
1213
1491
|
textLen: text.length,
|
|
1492
|
+
text,
|
|
1493
|
+
...(messageId !== undefined ? { messageId } : {}),
|
|
1214
1494
|
});
|
|
1215
|
-
return { delivered, channelId };
|
|
1495
|
+
return { delivered, channelId, ...(messageId !== undefined ? { messageId } : {}) };
|
|
1216
1496
|
}
|
|
1217
1497
|
async handleToolPublish(input) {
|
|
1218
1498
|
// Resolve content: accept both `content` and `text` (backward compat)
|