@animalabs/agent-framework 0.5.4 → 0.5.7
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 +20 -4
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +45 -14
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +153 -5
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1046 -56
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +27 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +140 -1
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/types.d.ts +17 -0
- package/dist/src/gate/types.d.ts.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 +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +50 -0
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +180 -0
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +161 -0
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -0
- package/dist/src/mcpl/conversation-router.js +179 -0
- package/dist/src/mcpl/conversation-router.js.map +1 -0
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
- package/dist/src/mcpl/feature-set-manager.js +10 -1
- package/dist/src/mcpl/feature-set-manager.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +2 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +2 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +4 -0
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/module-registry.d.ts +3 -1
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +9 -2
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts +10 -0
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +83 -0
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +17 -0
- package/dist/src/tool-result-history.d.ts.map +1 -0
- package/dist/src/tool-result-history.js +60 -0
- package/dist/src/tool-result-history.js.map +1 -0
- package/dist/src/types/agent.d.ts +11 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +8 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +15 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +48 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/channel-registry-routing.test.d.ts +2 -0
- package/dist/test/channel-registry-routing.test.d.ts.map +1 -0
- package/dist/test/channel-registry-routing.test.js +75 -0
- package/dist/test/channel-registry-routing.test.js.map +1 -0
- package/dist/test/conversation-router.test.d.ts +2 -0
- package/dist/test/conversation-router.test.d.ts.map +1 -0
- package/dist/test/conversation-router.test.js +160 -0
- package/dist/test/conversation-router.test.js.map +1 -0
- package/dist/test/conversation-routing.test.d.ts +7 -0
- package/dist/test/conversation-routing.test.d.ts.map +1 -0
- package/dist/test/conversation-routing.test.js +203 -0
- package/dist/test/conversation-routing.test.js.map +1 -0
- package/dist/test/framework.test.js +120 -2
- package/dist/test/framework.test.js.map +1 -1
- package/dist/test/helpers/mock-membrane.d.ts +41 -0
- package/dist/test/helpers/mock-membrane.d.ts.map +1 -0
- package/dist/test/helpers/mock-membrane.js +161 -0
- package/dist/test/helpers/mock-membrane.js.map +1 -0
- package/dist/test/inference-failure-observability.test.d.ts +2 -0
- package/dist/test/inference-failure-observability.test.d.ts.map +1 -0
- package/dist/test/inference-failure-observability.test.js +75 -0
- package/dist/test/inference-failure-observability.test.js.map +1 -0
- package/dist/test/tool-result-history.test.d.ts +7 -0
- package/dist/test/tool-result-history.test.d.ts.map +1 -0
- package/dist/test/tool-result-history.test.js +77 -0
- package/dist/test/tool-result-history.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -2
- package/src/agent.ts +53 -18
- package/src/framework.ts +1202 -59
- package/src/gate/event-gate.ts +144 -1
- package/src/gate/types.ts +17 -0
- package/src/index.ts +12 -0
- package/src/mcpl/channel-registry.ts +221 -0
- package/src/mcpl/conversation-router.ts +284 -0
- package/src/mcpl/feature-set-manager.ts +14 -1
- package/src/mcpl/index.ts +12 -0
- package/src/mcpl/server-connection.ts +4 -0
- package/src/module-registry.ts +14 -2
- package/src/modules/discord/index.ts +93 -0
- package/src/tool-result-history.ts +57 -0
- package/src/types/agent.ts +11 -0
- package/src/types/framework.ts +9 -0
- package/src/types/module.ts +16 -0
- package/src/types/trace.ts +57 -0
package/src/gate/event-gate.ts
CHANGED
|
@@ -330,6 +330,20 @@ export class EventGate {
|
|
|
330
330
|
private defaultSkipped = 0;
|
|
331
331
|
private defaultByEventType = new Map<string, { triggered: number; skipped: number }>();
|
|
332
332
|
|
|
333
|
+
// Sleep state (set via the `sleep` tool). Passive: evaluate() suppresses
|
|
334
|
+
// non-privileged wakes while now() < sleepUntil. No timer — after expiry the
|
|
335
|
+
// next event evaluates normally.
|
|
336
|
+
private sleepUntil = 0;
|
|
337
|
+
private sleepNote: string | undefined;
|
|
338
|
+
private sleepSuppressed = 0;
|
|
339
|
+
|
|
340
|
+
// Privileged users who may wake the agent through sleep. Hot-reloaded from
|
|
341
|
+
// privilegedUsersPath on change.
|
|
342
|
+
private privilegedUsersPath: string | undefined;
|
|
343
|
+
private privilegedUserIds = new Set<string>();
|
|
344
|
+
private privilegedMtime = 0;
|
|
345
|
+
private lastPrivilegedCheck = 0;
|
|
346
|
+
|
|
333
347
|
// Dependency-injected callbacks
|
|
334
348
|
private emitTrace: (event: TraceEventLike) => void;
|
|
335
349
|
private addMessageFn: (participant: string, content: Array<{ type: 'text'; text: string }>, metadata?: Record<string, unknown>) => unknown;
|
|
@@ -342,6 +356,7 @@ export class EventGate {
|
|
|
342
356
|
constructor(opts: {
|
|
343
357
|
configPath: string;
|
|
344
358
|
initialConfig?: GateConfig;
|
|
359
|
+
privilegedUsersPath?: string;
|
|
345
360
|
emitTrace: (event: TraceEventLike) => void;
|
|
346
361
|
addMessage: (participant: string, content: Array<{ type: 'text'; text: string }>, metadata?: Record<string, unknown>) => unknown;
|
|
347
362
|
requestInference: (agentName: string, reason: string, source: string) => void;
|
|
@@ -350,11 +365,13 @@ export class EventGate {
|
|
|
350
365
|
now?: () => number;
|
|
351
366
|
}) {
|
|
352
367
|
this.configPath = opts.configPath;
|
|
368
|
+
this.privilegedUsersPath = opts.privilegedUsersPath;
|
|
353
369
|
this.emitTrace = opts.emitTrace;
|
|
354
370
|
this.addMessageFn = opts.addMessage;
|
|
355
371
|
this.requestInferenceFn = opts.requestInference;
|
|
356
372
|
this.getAgentNamesFn = opts.getAgentNames;
|
|
357
373
|
this.now = opts.now ?? (() => Date.now());
|
|
374
|
+
this.loadPrivileged();
|
|
358
375
|
|
|
359
376
|
// Seed or reconcile the on-disk config.
|
|
360
377
|
//
|
|
@@ -597,6 +614,35 @@ export class EventGate {
|
|
|
597
614
|
evaluate(info: GateEventInfo): GateDecision {
|
|
598
615
|
this.reloadIfChanged();
|
|
599
616
|
|
|
617
|
+
// Sleep suppression: while asleep, drop EXTERNAL wakes. Two things still
|
|
618
|
+
// rouse the agent — its own heartbeat (an internal clock, not an outside
|
|
619
|
+
// interruption: each tick lets the agent reconsider and optionally `wake`)
|
|
620
|
+
// and a privileged user's ping. The event always enters context regardless
|
|
621
|
+
// (the gate only governs inference triggering).
|
|
622
|
+
if (this.sleepUntil > this.now()) {
|
|
623
|
+
this.reloadPrivilegedIfChanged();
|
|
624
|
+
const isHeartbeat =
|
|
625
|
+
info.metadata?.source === 'heartbeat' || info.serverId === 'heartbeat';
|
|
626
|
+
const authorId = this.extractAuthorId(info.metadata);
|
|
627
|
+
const privileged = authorId !== null && this.privilegedUserIds.has(authorId);
|
|
628
|
+
if (!isHeartbeat && !privileged) {
|
|
629
|
+
this.sleepSuppressed++;
|
|
630
|
+
this.totalEvaluations++;
|
|
631
|
+
this.emitTrace({
|
|
632
|
+
type: 'gate:decision',
|
|
633
|
+
eventType: info.eventType,
|
|
634
|
+
serverId: info.serverId || undefined,
|
|
635
|
+
channelId: info.channelId || undefined,
|
|
636
|
+
matchedPolicy: 'asleep',
|
|
637
|
+
trigger: false,
|
|
638
|
+
behavior: 'skip',
|
|
639
|
+
timestamp: this.now(),
|
|
640
|
+
});
|
|
641
|
+
return { trigger: false, policyName: 'asleep', behavior: 'skip' };
|
|
642
|
+
}
|
|
643
|
+
// Privileged author — fall through to normal policy evaluation.
|
|
644
|
+
}
|
|
645
|
+
|
|
600
646
|
const policy = this.matchPolicy(info);
|
|
601
647
|
const decision = this.computeDecision(policy, info);
|
|
602
648
|
|
|
@@ -927,9 +973,93 @@ export class EventGate {
|
|
|
927
973
|
// Tool: gate:status
|
|
928
974
|
// =========================================================================
|
|
929
975
|
|
|
976
|
+
// =========================================================================
|
|
977
|
+
// Sleep
|
|
978
|
+
// =========================================================================
|
|
979
|
+
|
|
980
|
+
/** Begin (or extend) a sleep window of `seconds`. Returns the wake time. */
|
|
981
|
+
setSleep(seconds: number, note?: string): { until: number } {
|
|
982
|
+
const ms = Math.max(0, Math.floor(seconds * 1000));
|
|
983
|
+
this.sleepUntil = this.now() + ms;
|
|
984
|
+
this.sleepNote = note;
|
|
985
|
+
this.sleepSuppressed = 0;
|
|
986
|
+
this.reloadPrivilegedIfChanged();
|
|
987
|
+
this.emitTrace({
|
|
988
|
+
type: 'gate:decision',
|
|
989
|
+
eventType: 'sleep:set',
|
|
990
|
+
matchedPolicy: 'asleep',
|
|
991
|
+
trigger: false,
|
|
992
|
+
behavior: 'skip',
|
|
993
|
+
timestamp: this.now(),
|
|
994
|
+
});
|
|
995
|
+
return { until: this.sleepUntil };
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/** End sleep immediately. Returns true if the agent was asleep. */
|
|
999
|
+
clearSleep(): boolean {
|
|
1000
|
+
const wasAsleep = this.sleepUntil > this.now();
|
|
1001
|
+
this.sleepUntil = 0;
|
|
1002
|
+
this.sleepNote = undefined;
|
|
1003
|
+
return wasAsleep;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** Current sleep state, or null if awake. */
|
|
1007
|
+
getSleepState(): { until: number; remainingMs: number; note?: string; suppressed: number } | null {
|
|
1008
|
+
const remainingMs = this.sleepUntil - this.now();
|
|
1009
|
+
if (remainingMs <= 0) return null;
|
|
1010
|
+
return { until: this.sleepUntil, remainingMs, note: this.sleepNote, suppressed: this.sleepSuppressed };
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** Author id from an event's metadata, across the discord ingest shapes. */
|
|
1014
|
+
private extractAuthorId(metadata?: Record<string, unknown>): string | null {
|
|
1015
|
+
if (!metadata) return null;
|
|
1016
|
+
const author = metadata.author as { id?: unknown } | undefined;
|
|
1017
|
+
if (author && author.id != null) return String(author.id);
|
|
1018
|
+
if (metadata.authorId != null) return String(metadata.authorId);
|
|
1019
|
+
return null;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** Load the privileged-users file (bare array or { userIds: [...] }). */
|
|
1023
|
+
private loadPrivileged(): void {
|
|
1024
|
+
if (!this.privilegedUsersPath) return;
|
|
1025
|
+
try {
|
|
1026
|
+
if (!existsSync(this.privilegedUsersPath)) {
|
|
1027
|
+
this.privilegedUserIds = new Set();
|
|
1028
|
+
this.privilegedMtime = 0;
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
this.privilegedMtime = statSync(this.privilegedUsersPath).mtimeMs;
|
|
1032
|
+
const raw = JSON.parse(readFileSync(this.privilegedUsersPath, 'utf8'));
|
|
1033
|
+
const ids = Array.isArray(raw) ? raw : Array.isArray(raw?.userIds) ? raw.userIds : [];
|
|
1034
|
+
this.privilegedUserIds = new Set(ids.map((x: unknown) => String(x)));
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
this.configErrors.push(
|
|
1037
|
+
`Failed to load privileged-users file: ${err instanceof Error ? err.message : String(err)}`,
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/** Throttled mtime-based reload of the privileged-users file. */
|
|
1043
|
+
private reloadPrivilegedIfChanged(): void {
|
|
1044
|
+
if (!this.privilegedUsersPath) return;
|
|
1045
|
+
const now = this.now();
|
|
1046
|
+
if (now - this.lastPrivilegedCheck < RELOAD_THROTTLE_MS) return;
|
|
1047
|
+
this.lastPrivilegedCheck = now;
|
|
1048
|
+
try {
|
|
1049
|
+
if (!existsSync(this.privilegedUsersPath)) {
|
|
1050
|
+
if (this.privilegedUserIds.size > 0) this.privilegedUserIds = new Set();
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
const mtime = statSync(this.privilegedUsersPath).mtimeMs;
|
|
1054
|
+
if (mtime !== this.privilegedMtime) this.loadPrivileged();
|
|
1055
|
+
} catch {
|
|
1056
|
+
/* leave the last-known set in place on a transient stat/read error */
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
930
1060
|
getToolDefinition(): ToolDefinition {
|
|
931
1061
|
return {
|
|
932
|
-
name: '
|
|
1062
|
+
name: 'gate_status',
|
|
933
1063
|
description: 'Show the current EventGate configuration, per-policy match counts, debounce state, and any config errors.',
|
|
934
1064
|
inputSchema: {
|
|
935
1065
|
type: 'object',
|
|
@@ -976,7 +1106,20 @@ export class EventGate {
|
|
|
976
1106
|
const byEventType: Record<string, { triggered: number; skipped: number }> = {};
|
|
977
1107
|
for (const [k, v] of this.defaultByEventType) byEventType[k] = { ...v };
|
|
978
1108
|
|
|
1109
|
+
const sleepState = this.getSleepState();
|
|
1110
|
+
|
|
979
1111
|
return {
|
|
1112
|
+
...(sleepState
|
|
1113
|
+
? {
|
|
1114
|
+
sleep: {
|
|
1115
|
+
until: sleepState.until,
|
|
1116
|
+
remainingMs: sleepState.remainingMs,
|
|
1117
|
+
note: sleepState.note,
|
|
1118
|
+
suppressed: sleepState.suppressed,
|
|
1119
|
+
privilegedCount: this.privilegedUserIds.size,
|
|
1120
|
+
},
|
|
1121
|
+
}
|
|
1122
|
+
: {}),
|
|
980
1123
|
configPath: this.configPath,
|
|
981
1124
|
configSource: this.configSource,
|
|
982
1125
|
lastReloadTimestamp: this.lastReloadTimestamp,
|
package/src/gate/types.ts
CHANGED
|
@@ -129,6 +129,13 @@ export interface GateOptions {
|
|
|
129
129
|
configPath?: string;
|
|
130
130
|
/** Initial config, seeded to gate.json if the file doesn't exist. */
|
|
131
131
|
config?: GateConfig;
|
|
132
|
+
/**
|
|
133
|
+
* Path to a JSON file listing user ids that may wake the agent even while
|
|
134
|
+
* it is asleep (`sleep` tool). The file is either a bare array of id
|
|
135
|
+
* strings or `{ "userIds": [...] }`. Hot-reloaded on change. When unset or
|
|
136
|
+
* missing, no one bypasses sleep.
|
|
137
|
+
*/
|
|
138
|
+
privilegedUsersPath?: string;
|
|
132
139
|
}
|
|
133
140
|
|
|
134
141
|
// ---------------------------------------------------------------------------
|
|
@@ -202,4 +209,14 @@ export interface GateStatus {
|
|
|
202
209
|
skipped: number;
|
|
203
210
|
byEventType: Record<string, { triggered: number; skipped: number }>;
|
|
204
211
|
};
|
|
212
|
+
/** Present when the agent is currently asleep (sleep tool). */
|
|
213
|
+
sleep?: {
|
|
214
|
+
until: number;
|
|
215
|
+
remainingMs: number;
|
|
216
|
+
note?: string;
|
|
217
|
+
/** How many wake events have been suppressed during the current sleep. */
|
|
218
|
+
suppressed: number;
|
|
219
|
+
/** Count of privileged user ids that may wake through sleep. */
|
|
220
|
+
privilegedCount: number;
|
|
221
|
+
};
|
|
205
222
|
}
|
package/src/index.ts
CHANGED
|
@@ -63,6 +63,18 @@ export type { GateConfig, GateOptions, GatePolicy, GatePolicyMatch, GateBehavior
|
|
|
63
63
|
// MCPL channel registry (exposed for modules that need channel-level operations)
|
|
64
64
|
export { ChannelRegistry } from './mcpl/index.js';
|
|
65
65
|
|
|
66
|
+
// Per-channel conversation routing
|
|
67
|
+
export { ConversationRouter, DEFAULT_CLOSURE_PROMPT } from './mcpl/index.js';
|
|
68
|
+
export type {
|
|
69
|
+
ConversationRouterConfig,
|
|
70
|
+
ConversationBinding,
|
|
71
|
+
RoutingDecision,
|
|
72
|
+
IncomingMessageFacts,
|
|
73
|
+
BindRule,
|
|
74
|
+
TriggerRule,
|
|
75
|
+
ChannelKind,
|
|
76
|
+
} from './mcpl/index.js';
|
|
77
|
+
|
|
66
78
|
// Re-export commonly used types from dependencies
|
|
67
79
|
export type { ContextManager, ContextStrategy, TokenBudget } from '@animalabs/context-manager';
|
|
68
80
|
export { PassthroughStrategy, AutobiographicalStrategy, KnowledgeStrategy } from '@animalabs/context-manager';
|
|
@@ -174,6 +174,43 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
174
174
|
required: [],
|
|
175
175
|
},
|
|
176
176
|
},
|
|
177
|
+
{
|
|
178
|
+
name: 'think',
|
|
179
|
+
description:
|
|
180
|
+
'Reason privately. The content stays in your own context and is NOT sent to any ' +
|
|
181
|
+
'channel or surface. This does NOT end or silence your turn: if you also write ' +
|
|
182
|
+
'plain text this turn, that text is still posted as your reply. Use think() purely ' +
|
|
183
|
+
'to work things out before (or instead of) speaking. To deliberately NOT reply this ' +
|
|
184
|
+
'turn, call skip_reply instead.',
|
|
185
|
+
inputSchema: {
|
|
186
|
+
type: 'object' as const,
|
|
187
|
+
properties: {
|
|
188
|
+
content: {
|
|
189
|
+
type: 'string',
|
|
190
|
+
description: 'Your private thought / reasoning (optional; not sent anywhere).',
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
required: [],
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: 'skip_reply',
|
|
198
|
+
description:
|
|
199
|
+
'End your turn WITHOUT sending anything to any channel or surface. Use when you have ' +
|
|
200
|
+
'read the messages but deliberately choose not to reply right now — ambient chatter, ' +
|
|
201
|
+
'nothing to add, or you are waiting. Any plain text you wrote this turn stays private ' +
|
|
202
|
+
'and is NOT posted. To reply instead, just write plain text (no tool call).',
|
|
203
|
+
inputSchema: {
|
|
204
|
+
type: 'object' as const,
|
|
205
|
+
properties: {
|
|
206
|
+
reason: {
|
|
207
|
+
type: 'string',
|
|
208
|
+
description: 'Optional private note on why you are not replying (not sent anywhere).',
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
required: [],
|
|
212
|
+
},
|
|
213
|
+
},
|
|
177
214
|
];
|
|
178
215
|
|
|
179
216
|
// ============================================================================
|
|
@@ -183,6 +220,20 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
183
220
|
interface ChannelRegistryOptions {
|
|
184
221
|
/** Callback to determine whether an incoming message should trigger inference. */
|
|
185
222
|
shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
|
|
223
|
+
/**
|
|
224
|
+
* Called when a text-only turn's speech could NOT be delivered to its
|
|
225
|
+
* conversational locus — no locus, an unregistered channel, a missing
|
|
226
|
+
* server, or the server reporting `delivered: false`. The host wires this
|
|
227
|
+
* to drop a `[discord-send-failed]` marker into chronicle so the failure is
|
|
228
|
+
* visible to the agent (and operator) rather than silently lost. Must not
|
|
229
|
+
* itself trigger inference (avoid wake loops).
|
|
230
|
+
*/
|
|
231
|
+
onRouteFailure?: (info: {
|
|
232
|
+
conversationId: string;
|
|
233
|
+
channelId: string | null;
|
|
234
|
+
reason: string;
|
|
235
|
+
textLen: number;
|
|
236
|
+
}) => void;
|
|
186
237
|
}
|
|
187
238
|
|
|
188
239
|
// ============================================================================
|
|
@@ -201,6 +252,12 @@ export class ChannelRegistry {
|
|
|
201
252
|
op?: 'start' | 'stop',
|
|
202
253
|
) => void;
|
|
203
254
|
private shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
|
|
255
|
+
private onRouteFailure?: (info: {
|
|
256
|
+
conversationId: string;
|
|
257
|
+
channelId: string | null;
|
|
258
|
+
reason: string;
|
|
259
|
+
textLen: number;
|
|
260
|
+
}) => void;
|
|
204
261
|
|
|
205
262
|
/** Registered channels, keyed by `{serverId}:{channelId}`. */
|
|
206
263
|
private channels = new Map<string, ChannelEntry>();
|
|
@@ -246,6 +303,7 @@ export class ChannelRegistry {
|
|
|
246
303
|
this.emitTraceFn = emitTraceFn;
|
|
247
304
|
this.sendTypingFn = options?.sendTypingFn;
|
|
248
305
|
this.shouldTriggerInference = options?.shouldTriggerInference;
|
|
306
|
+
this.onRouteFailure = options?.onRouteFailure;
|
|
249
307
|
}
|
|
250
308
|
|
|
251
309
|
/**
|
|
@@ -372,6 +430,43 @@ export class ChannelRegistry {
|
|
|
372
430
|
this.defaultPublishMessageId = message.messageId;
|
|
373
431
|
this.defaultPublishThreadId = message.threadId;
|
|
374
432
|
|
|
433
|
+
// Lazy-register the channel if we've never seen it. A channel can deliver
|
|
434
|
+
// an incoming message before its channels/register (boot enumeration) or
|
|
435
|
+
// channels/changed (post-boot create / View-permission grant) round-trip
|
|
436
|
+
// lands — or the registration event can be missed entirely (e.g. the bot
|
|
437
|
+
// gains visibility in a way that fires neither `channelCreate` nor a
|
|
438
|
+
// View-permission transition). Without a registry entry, routeSpeech()
|
|
439
|
+
// can't resolve this channel as an outbound locus and the agent's reply
|
|
440
|
+
// is silently dropped, even though this very message proves the channel
|
|
441
|
+
// is reachable. The inbound message carries enough to make it publishable,
|
|
442
|
+
// so register it here; a later authoritative channels/register or
|
|
443
|
+
// channels/changed will overwrite this descriptor with the richer one.
|
|
444
|
+
const incomingKey = `${serverId}:${message.channelId}`;
|
|
445
|
+
if (!this.channels.has(incomingKey)) {
|
|
446
|
+
const channelLabel =
|
|
447
|
+
typeof message.metadata?.channelName === 'string' && message.metadata.channelName
|
|
448
|
+
? (message.metadata.channelName as string)
|
|
449
|
+
: message.channelId;
|
|
450
|
+
this.channels.set(incomingKey, {
|
|
451
|
+
serverId,
|
|
452
|
+
descriptor: {
|
|
453
|
+
id: message.channelId,
|
|
454
|
+
type: serverId,
|
|
455
|
+
label: channelLabel,
|
|
456
|
+
direction: 'bidirectional',
|
|
457
|
+
address: message.threadId ? { threadId: message.threadId } : undefined,
|
|
458
|
+
metadata: { lazyRegistered: true },
|
|
459
|
+
},
|
|
460
|
+
open: true,
|
|
461
|
+
});
|
|
462
|
+
this.emitTraceFn({
|
|
463
|
+
type: 'mcpl:channel-lazy-registered',
|
|
464
|
+
serverId,
|
|
465
|
+
channelId: message.channelId,
|
|
466
|
+
label: channelLabel,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
375
470
|
// Determine whether to trigger inference
|
|
376
471
|
let triggerInference = true;
|
|
377
472
|
if (this.shouldTriggerInference) {
|
|
@@ -536,6 +631,14 @@ export class ChannelRegistry {
|
|
|
536
631
|
return this.defaultPublishChannel;
|
|
537
632
|
}
|
|
538
633
|
|
|
634
|
+
/**
|
|
635
|
+
* Get the descriptor for a channel by its channelId (first match across
|
|
636
|
+
* servers). Used by the conversation router for DM classification.
|
|
637
|
+
*/
|
|
638
|
+
getDescriptor(channelId: string): ChannelDescriptor | undefined {
|
|
639
|
+
return this.findChannelEntry(channelId)?.descriptor;
|
|
640
|
+
}
|
|
641
|
+
|
|
539
642
|
/**
|
|
540
643
|
* Get all open channels.
|
|
541
644
|
*/
|
|
@@ -577,6 +680,12 @@ export class ChannelRegistry {
|
|
|
577
680
|
case 'channel_publish':
|
|
578
681
|
return this.handleToolPublish(input as { channelId: string; content: string });
|
|
579
682
|
|
|
683
|
+
case 'think':
|
|
684
|
+
return this.handleToolThink(input as { content?: string });
|
|
685
|
+
|
|
686
|
+
case 'skip_reply':
|
|
687
|
+
return this.handleToolSkipReply(input as { reason?: string });
|
|
688
|
+
|
|
580
689
|
default:
|
|
581
690
|
return { success: false, error: `Unknown channel tool: ${toolName}`, isError: true };
|
|
582
691
|
}
|
|
@@ -730,6 +839,11 @@ export class ChannelRegistry {
|
|
|
730
839
|
// Private: Channel Lookup
|
|
731
840
|
// ==========================================================================
|
|
732
841
|
|
|
842
|
+
/** Server id owning a registered channel (by descriptor id), or null. */
|
|
843
|
+
getChannelServerId(channelId: string): string | null {
|
|
844
|
+
return this.findChannelEntry(channelId)?.serverId ?? null;
|
|
845
|
+
}
|
|
846
|
+
|
|
733
847
|
/**
|
|
734
848
|
* Find a channel entry by channelId (searches across all servers).
|
|
735
849
|
* Returns the first match.
|
|
@@ -874,6 +988,113 @@ export class ChannelRegistry {
|
|
|
874
988
|
}
|
|
875
989
|
}
|
|
876
990
|
|
|
991
|
+
/**
|
|
992
|
+
* Handle the synthesized `think` tool — a private reasoning scratchpad. It
|
|
993
|
+
* sends nothing, and (unlike before) does NOT silence the turn: trailing
|
|
994
|
+
* plain text is still routed as the reply. The thought stays in the agent's
|
|
995
|
+
* own context/chronicle. To deliberately not reply, the agent uses skip_reply.
|
|
996
|
+
*/
|
|
997
|
+
private handleToolThink(input: { content?: string }): ToolResult {
|
|
998
|
+
return {
|
|
999
|
+
success: true,
|
|
1000
|
+
data: {
|
|
1001
|
+
noted: true,
|
|
1002
|
+
note:
|
|
1003
|
+
'Thought recorded (private — not sent anywhere). This did NOT silence your turn: ' +
|
|
1004
|
+
'write plain text to reply, or call skip_reply to end the turn without replying.',
|
|
1005
|
+
content: typeof input?.content === 'string' ? input.content : undefined,
|
|
1006
|
+
},
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Handle the synthesized `skip_reply` tool — the deliberate "stay silent"
|
|
1012
|
+
* signal. A no-op as far as any surface is concerned (sends nothing); its
|
|
1013
|
+
* effect is that the framework's output routing treats this as a silencing
|
|
1014
|
+
* tool, so any trailing prose this turn is NOT posted. Replaces the old
|
|
1015
|
+
* overloaded use of `think` for staying silent.
|
|
1016
|
+
*/
|
|
1017
|
+
private handleToolSkipReply(input: { reason?: string }): ToolResult {
|
|
1018
|
+
return {
|
|
1019
|
+
success: true,
|
|
1020
|
+
data: {
|
|
1021
|
+
skipped: true,
|
|
1022
|
+
note: 'Ended the turn without replying — nothing was sent to any channel.',
|
|
1023
|
+
reason: typeof input?.reason === 'string' ? input.reason : undefined,
|
|
1024
|
+
},
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Host-owned output routing (see forking-knowledge-miner LOCUS-ROUTING-DESIGN).
|
|
1030
|
+
* Publish the agent's plain-text speech to the current conversational locus
|
|
1031
|
+
* (the most recent incoming channel, tracked cross-surface here in the host).
|
|
1032
|
+
* Called by the framework on a text-only turn — replaces the per-surface
|
|
1033
|
+
* sticky auto-post that used to live in discord-mcpl. Returns null when there
|
|
1034
|
+
* is no locus / the channel or its server can't be resolved (in which case
|
|
1035
|
+
* the speech simply stays in chronicle + module surfaces).
|
|
1036
|
+
*/
|
|
1037
|
+
async routeSpeech(
|
|
1038
|
+
conversationId: string,
|
|
1039
|
+
text: string,
|
|
1040
|
+
): Promise<{ delivered: boolean; channelId: string } | null> {
|
|
1041
|
+
// Surface a routing failure: emit a trace AND notify the host (which drops
|
|
1042
|
+
// a `[discord-send-failed]` marker into chronicle) so the agent learns her
|
|
1043
|
+
// reply never reached the human, instead of it vanishing silently.
|
|
1044
|
+
const fail = (channelId: string | null, reason: string): null => {
|
|
1045
|
+
console.error(`[routeSpeech] ${conversationId}: ${reason} — speech NOT routed (${text.length} chars stay in chronicle)`);
|
|
1046
|
+
this.emitTraceFn({
|
|
1047
|
+
type: 'mcpl:speech-route-failed',
|
|
1048
|
+
channelId: channelId ?? '',
|
|
1049
|
+
reason,
|
|
1050
|
+
textLen: text.length,
|
|
1051
|
+
});
|
|
1052
|
+
this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length });
|
|
1053
|
+
return null;
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
const channelId = this.defaultPublishChannel;
|
|
1057
|
+
if (!channelId) {
|
|
1058
|
+
return fail(null, 'no locus (defaultPublishChannel null)');
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const entry = this.findChannelEntry(channelId);
|
|
1062
|
+
if (!entry) {
|
|
1063
|
+
return fail(channelId, `no registered channel for locus "${channelId}"`);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const server = this.serverRegistry.getServer(entry.serverId);
|
|
1067
|
+
if (!server) {
|
|
1068
|
+
return fail(channelId, `server "${entry.serverId}" not found`);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const publishParams: ChannelsPublishParams = {
|
|
1072
|
+
conversationId,
|
|
1073
|
+
channelId,
|
|
1074
|
+
content: [{ type: 'text', text }],
|
|
1075
|
+
};
|
|
1076
|
+
const result = await server.sendChannelsPublish(publishParams);
|
|
1077
|
+
const delivered = (result as { delivered?: boolean } | undefined)?.delivered ?? true;
|
|
1078
|
+
|
|
1079
|
+
// The server accepted the publish RPC but reported the message was not
|
|
1080
|
+
// actually delivered (e.g. missing Send Messages permission). Previously
|
|
1081
|
+
// this returned `{ delivered: true }`, masking the failure. Surface it.
|
|
1082
|
+
if (delivered === false) {
|
|
1083
|
+
return fail(channelId, `server "${entry.serverId}" reported delivered:false for "${channelId}"`);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${entry.serverId}, delivered=${delivered})`);
|
|
1087
|
+
this.emitTraceFn({
|
|
1088
|
+
type: 'mcpl:speech-routed',
|
|
1089
|
+
serverId: entry.serverId,
|
|
1090
|
+
channelId,
|
|
1091
|
+
delivered,
|
|
1092
|
+
textLen: text.length,
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
return { delivered, channelId };
|
|
1096
|
+
}
|
|
1097
|
+
|
|
877
1098
|
private async handleToolPublish(input: { channelId?: string; content?: string; text?: string }): Promise<ToolResult> {
|
|
878
1099
|
// Resolve content: accept both `content` and `text` (backward compat)
|
|
879
1100
|
const messageText = input.content ?? input.text;
|