@animalabs/agent-framework 0.5.6 → 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 +1033 -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 +28 -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 +73 -1
- 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 +2 -2
- package/src/agent.ts +53 -18
- package/src/framework.ts +1188 -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 +36 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConversationRouter — routes incoming channel messages to per-channel
|
|
3
|
+
* conversation agents ("forks") instead of the primary conversation.
|
|
4
|
+
*
|
|
5
|
+
* The router is a lookup table plus policy — deliberately no LLM in the
|
|
6
|
+
* routing path. The framework consults it from handleMcplChannelIncoming:
|
|
7
|
+
*
|
|
8
|
+
* - A channel with an active binding routes to its bound agent. Whether the
|
|
9
|
+
* message *triggers* inference (vs. just landing in the fork's context) is
|
|
10
|
+
* a separate per-channel-kind rule: in a busy channel everything is case
|
|
11
|
+
* input but only mentions demand a reply.
|
|
12
|
+
* - An unbound channel binds (→ the framework spawns a fork from the
|
|
13
|
+
* template/trunk agent) when the bind rule matches: by default any message
|
|
14
|
+
* in a DM, an explicit bot mention in a channel (`metadata.mentioned`,
|
|
15
|
+
* set server-side by the platform adapters).
|
|
16
|
+
* - Everything else is left unrouted and dropped by the framework — the
|
|
17
|
+
* trunk is a dormant template, not a listener.
|
|
18
|
+
*
|
|
19
|
+
* Bindings expire after an idle TTL. Expiry runs a final closure turn on the
|
|
20
|
+
* fork (see framework wiring) and unbinds; the next qualifying message on
|
|
21
|
+
* that channel spawns a *fresh* fork (generation + 1) from the current
|
|
22
|
+
* trunk, so handbook/template updates propagate between engagements.
|
|
23
|
+
*
|
|
24
|
+
* The binding table is in-memory — bindings are re-established by the next
|
|
25
|
+
* qualifying message after a restart. The generation counters, by contrast,
|
|
26
|
+
* are persisted by the framework (exportGenerations/hydrateGenerations):
|
|
27
|
+
* a restarted host must NOT reuse generation 1's agent name, or it would
|
|
28
|
+
* reopen the previous engagement's Chronicle namespace and re-seed the
|
|
29
|
+
* template context on top of the old history.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { ContextStrategy } from '@animalabs/context-manager';
|
|
33
|
+
import type { ChannelDescriptor } from './types.js';
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// Config & types
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
/** When an unbound channel acquires a fork. */
|
|
40
|
+
export type BindRule = 'always' | 'mention' | 'never';
|
|
41
|
+
|
|
42
|
+
/** When a message on a bound channel triggers inference (it always lands in
|
|
43
|
+
* the fork's context). */
|
|
44
|
+
export type TriggerRule = 'always' | 'mention';
|
|
45
|
+
|
|
46
|
+
/** How a channel is classified for bind/trigger rules. Group DMs are their
|
|
47
|
+
* own kind: the bot was added deliberately (so they bind like DMs) but they
|
|
48
|
+
* host multi-human chatter (so they trigger like channels by default —
|
|
49
|
+
* inferring on every message of a four-human group DM is a firehose). */
|
|
50
|
+
export type ChannelKind = 'dm' | 'groupDm' | 'channel';
|
|
51
|
+
|
|
52
|
+
export interface ConversationRouterConfig {
|
|
53
|
+
/** Agent whose context seeds new forks (the "trunk"/warm checkpoint). */
|
|
54
|
+
templateAgent: string;
|
|
55
|
+
|
|
56
|
+
/** Bind rules per channel kind.
|
|
57
|
+
* Defaults: dm 'always', groupDm 'always', channel 'mention'. */
|
|
58
|
+
bind?: { dm?: BindRule; groupDm?: BindRule; channel?: BindRule };
|
|
59
|
+
|
|
60
|
+
/** Trigger rules per channel kind.
|
|
61
|
+
* Defaults: dm 'always', groupDm 'mention', channel 'mention'. */
|
|
62
|
+
trigger?: { dm?: TriggerRule; groupDm?: TriggerRule; channel?: TriggerRule };
|
|
63
|
+
|
|
64
|
+
/** Idle time before a binding expires and the fork is closed. Default 12h. */
|
|
65
|
+
idleTtlMs?: number;
|
|
66
|
+
|
|
67
|
+
/** Final system-initiated user message sent to a fork on expiry. */
|
|
68
|
+
closurePrompt?: string;
|
|
69
|
+
|
|
70
|
+
/** Prefix for generated fork agent names. Default 'conversation'. */
|
|
71
|
+
agentPrefix?: string;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fresh compression strategy per fork. Strategy instances are stateful and
|
|
75
|
+
* must not be shared between ContextManagers, so the template agent's own
|
|
76
|
+
* strategy is never reused — without a factory, forks get passthrough.
|
|
77
|
+
*/
|
|
78
|
+
strategyFactory?: () => ContextStrategy;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ConversationBinding {
|
|
82
|
+
channelId: string;
|
|
83
|
+
agentName: string;
|
|
84
|
+
/** Engagement counter per channel — fresh forks get a new generation. */
|
|
85
|
+
generation: number;
|
|
86
|
+
boundAt: number;
|
|
87
|
+
lastActivity: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The facts route() needs about one incoming message, pre-extracted by the
|
|
91
|
+
* framework so the router stays free of event-shape knowledge. */
|
|
92
|
+
export interface IncomingMessageFacts {
|
|
93
|
+
channelId: string;
|
|
94
|
+
/** Personal mention of the bot (metadata.mentioned from the adapter). */
|
|
95
|
+
mentioned: boolean;
|
|
96
|
+
/** Channel classification (see classifyChannel). */
|
|
97
|
+
kind: ChannelKind;
|
|
98
|
+
/** Clock injection for tests; defaults to Date.now(). */
|
|
99
|
+
now?: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type RoutingDecision =
|
|
103
|
+
/** Channel already has a fork — deliver there. */
|
|
104
|
+
| { kind: 'existing'; agentName: string; trigger: boolean }
|
|
105
|
+
/** Bind rule matched — framework should spawn this agent, then bind(). */
|
|
106
|
+
| { kind: 'spawn'; agentName: string; generation: number; trigger: boolean }
|
|
107
|
+
/** No binding and bind rule didn't match — drop. */
|
|
108
|
+
| { kind: 'unbound' };
|
|
109
|
+
|
|
110
|
+
const DEFAULT_IDLE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
111
|
+
|
|
112
|
+
export const DEFAULT_CLOSURE_PROMPT =
|
|
113
|
+
'[system] This engagement is closing due to inactivity. Finalize your ' +
|
|
114
|
+
'work: post any promised results to the channel and record outstanding ' +
|
|
115
|
+
'threads as unresolved. Do not ask follow-up questions.';
|
|
116
|
+
|
|
117
|
+
// ============================================================================
|
|
118
|
+
// ConversationRouter
|
|
119
|
+
// ============================================================================
|
|
120
|
+
|
|
121
|
+
export class ConversationRouter {
|
|
122
|
+
private config: ConversationRouterConfig;
|
|
123
|
+
|
|
124
|
+
/** Active bindings, keyed by channelId. */
|
|
125
|
+
private bindings = new Map<string, ConversationBinding>();
|
|
126
|
+
|
|
127
|
+
/** Engagement counter per channel — survives unbind, so a re-bound channel
|
|
128
|
+
* gets a distinct fork agent name. */
|
|
129
|
+
private generations = new Map<string, number>();
|
|
130
|
+
|
|
131
|
+
constructor(config: ConversationRouterConfig) {
|
|
132
|
+
this.config = config;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get templateAgent(): string {
|
|
136
|
+
return this.config.templateAgent;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
get closurePrompt(): string {
|
|
140
|
+
return this.config.closurePrompt ?? DEFAULT_CLOSURE_PROMPT;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
get idleTtlMs(): number {
|
|
144
|
+
return this.config.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
get strategyFactory(): (() => ContextStrategy) | undefined {
|
|
148
|
+
return this.config.strategyFactory;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Decide where an incoming message goes. Pure read except for refreshing
|
|
153
|
+
* lastActivity on an existing binding — a 'spawn' decision does NOT create
|
|
154
|
+
* the binding; the framework calls bind() after the fork agent actually
|
|
155
|
+
* exists, so a failed spawn doesn't leave a dangling route.
|
|
156
|
+
*/
|
|
157
|
+
route(facts: IncomingMessageFacts): RoutingDecision {
|
|
158
|
+
const now = facts.now ?? Date.now();
|
|
159
|
+
const existing = this.bindings.get(facts.channelId);
|
|
160
|
+
|
|
161
|
+
if (existing) {
|
|
162
|
+
existing.lastActivity = now;
|
|
163
|
+
return {
|
|
164
|
+
kind: 'existing',
|
|
165
|
+
agentName: existing.agentName,
|
|
166
|
+
trigger: this.shouldTrigger(facts),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const bindRule = this.config.bind?.[facts.kind]
|
|
171
|
+
?? (facts.kind === 'channel' ? 'mention' : 'always');
|
|
172
|
+
|
|
173
|
+
const binds =
|
|
174
|
+
bindRule === 'always' || (bindRule === 'mention' && facts.mentioned);
|
|
175
|
+
if (!binds) {
|
|
176
|
+
return { kind: 'unbound' };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const generation = (this.generations.get(facts.channelId) ?? 0) + 1;
|
|
180
|
+
return {
|
|
181
|
+
kind: 'spawn',
|
|
182
|
+
agentName: this.forkAgentName(facts.channelId, generation),
|
|
183
|
+
generation,
|
|
184
|
+
trigger: this.shouldTrigger(facts),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Record a binding after the framework successfully spawned the fork.
|
|
190
|
+
* Returns the new binding.
|
|
191
|
+
*/
|
|
192
|
+
bind(channelId: string, agentName: string, generation: number, now = Date.now()): ConversationBinding {
|
|
193
|
+
this.generations.set(channelId, generation);
|
|
194
|
+
const binding: ConversationBinding = {
|
|
195
|
+
channelId,
|
|
196
|
+
agentName,
|
|
197
|
+
generation,
|
|
198
|
+
boundAt: now,
|
|
199
|
+
lastActivity: now,
|
|
200
|
+
};
|
|
201
|
+
this.bindings.set(channelId, binding);
|
|
202
|
+
return binding;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
unbind(channelId: string): void {
|
|
206
|
+
this.bindings.delete(channelId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
getBinding(channelId: string): ConversationBinding | undefined {
|
|
210
|
+
return this.bindings.get(channelId);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Reverse lookup: the channel an agent is bound to, if any. */
|
|
214
|
+
channelForAgent(agentName: string): string | undefined {
|
|
215
|
+
for (const binding of this.bindings.values()) {
|
|
216
|
+
if (binding.agentName === agentName) return binding.channelId;
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
getBindings(): ConversationBinding[] {
|
|
222
|
+
return Array.from(this.bindings.values());
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Bindings whose idle TTL has elapsed. Does not unbind — the framework
|
|
226
|
+
* runs the closure turn first, then calls unbind(). */
|
|
227
|
+
expired(now = Date.now()): ConversationBinding[] {
|
|
228
|
+
const ttl = this.idleTtlMs;
|
|
229
|
+
return this.getBindings().filter((b) => now - b.lastActivity >= ttl);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ==========================================================================
|
|
233
|
+
// Helpers
|
|
234
|
+
// ==========================================================================
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Classify a channel from its descriptor and/or message metadata. Slack
|
|
238
|
+
* marks DMs/group DMs with is_im/is_mpim on the channel descriptor and
|
|
239
|
+
* channel_type on each message; other platforms can adopt either
|
|
240
|
+
* convention.
|
|
241
|
+
*/
|
|
242
|
+
static classifyChannel(
|
|
243
|
+
descriptor?: ChannelDescriptor,
|
|
244
|
+
messageMetadata?: Record<string, unknown>,
|
|
245
|
+
): ChannelKind {
|
|
246
|
+
const meta = descriptor?.metadata as Record<string, unknown> | undefined;
|
|
247
|
+
if (meta?.is_im === true) return 'dm';
|
|
248
|
+
if (meta?.is_mpim === true) return 'groupDm';
|
|
249
|
+
const channelType = messageMetadata?.channel_type;
|
|
250
|
+
if (channelType === 'im') return 'dm';
|
|
251
|
+
if (channelType === 'mpim') return 'groupDm';
|
|
252
|
+
return 'channel';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Export the per-channel engagement counters for persistence — they must
|
|
256
|
+
* survive restarts, or a re-engaged channel reuses generation 1's name and
|
|
257
|
+
* reopens (and re-seeds) the previous engagement's Chronicle namespace. */
|
|
258
|
+
exportGenerations(): Record<string, number> {
|
|
259
|
+
return Object.fromEntries(this.generations);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Restore persisted engagement counters (see exportGenerations). */
|
|
263
|
+
hydrateGenerations(generations: Record<string, number>): void {
|
|
264
|
+
for (const [channelId, generation] of Object.entries(generations)) {
|
|
265
|
+
if (typeof generation === 'number' && generation > (this.generations.get(channelId) ?? 0)) {
|
|
266
|
+
this.generations.set(channelId, generation);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private shouldTrigger(facts: IncomingMessageFacts): boolean {
|
|
272
|
+
const rule = this.config.trigger?.[facts.kind]
|
|
273
|
+
?? (facts.kind === 'dm' ? 'always' : 'mention');
|
|
274
|
+
return rule === 'always' || facts.mentioned;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** `conversation-slack-C123-g2` — agent names double as Chronicle
|
|
278
|
+
* namespace components, so the channel id is sanitized. */
|
|
279
|
+
private forkAgentName(channelId: string, generation: number): string {
|
|
280
|
+
const prefix = this.config.agentPrefix ?? 'conversation';
|
|
281
|
+
const safe = channelId.replace(/[^A-Za-z0-9_-]+/g, '-');
|
|
282
|
+
return `${prefix}-${safe}-g${generation}`;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -145,7 +145,20 @@ export class FeatureSetManager {
|
|
|
145
145
|
capabilities: McplCapabilities,
|
|
146
146
|
config?: { enabledFeatureSets?: string[]; disabledFeatureSets?: string[] }
|
|
147
147
|
): FeatureSetsUpdateParams {
|
|
148
|
-
|
|
148
|
+
// Cross-package compat: agent-framework's McplCapabilities types
|
|
149
|
+
// featureSets as Record<string, FeatureSetDeclaration>, but mcpl-core-ts
|
|
150
|
+
// (used by discord-mcpl and other servers) types it as an array of
|
|
151
|
+
// {name, ...FeatureSetDeclaration}. Both representations arrive over
|
|
152
|
+
// the wire. Normalize to Record here so downstream code (resolvePatterns,
|
|
153
|
+
// validateInbound, etc.) can rely on the keyed shape.
|
|
154
|
+
const featureSetsRaw = capabilities.featureSets ?? {};
|
|
155
|
+
const declared: Record<string, FeatureSetDeclaration> = Array.isArray(featureSetsRaw)
|
|
156
|
+
? Object.fromEntries(
|
|
157
|
+
(featureSetsRaw as ReadonlyArray<FeatureSetDeclaration & { name: string }>).map(
|
|
158
|
+
(d) => [d.name, d],
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
: featureSetsRaw;
|
|
149
162
|
|
|
150
163
|
const state: ServerState = {
|
|
151
164
|
declared: { ...declared },
|
package/src/mcpl/index.ts
CHANGED
|
@@ -130,5 +130,17 @@ export { InferenceRouter } from './inference-router.js';
|
|
|
130
130
|
// Channel registry (channel lifecycle, incoming messages, synthesized tools)
|
|
131
131
|
export { ChannelRegistry } from './channel-registry.js';
|
|
132
132
|
|
|
133
|
+
// Per-channel conversation routing (fork-per-channel agents)
|
|
134
|
+
export { ConversationRouter, DEFAULT_CLOSURE_PROMPT } from './conversation-router.js';
|
|
135
|
+
export type {
|
|
136
|
+
ConversationRouterConfig,
|
|
137
|
+
ConversationBinding,
|
|
138
|
+
RoutingDecision,
|
|
139
|
+
IncomingMessageFacts,
|
|
140
|
+
BindRule,
|
|
141
|
+
TriggerRule,
|
|
142
|
+
ChannelKind,
|
|
143
|
+
} from './conversation-router.js';
|
|
144
|
+
|
|
133
145
|
// State management (Section 8, checkpoint trees + JSON Patch)
|
|
134
146
|
export { CheckpointManager, applyJsonPatch } from './checkpoint-manager.js';
|
|
@@ -646,6 +646,10 @@ export class McplServerConnection extends EventEmitter {
|
|
|
646
646
|
[McplMethod.ChannelsIncoming]: 'channels-incoming',
|
|
647
647
|
[McplMethod.FeatureSetsChanged]: 'feature-sets-changed',
|
|
648
648
|
'notifications/tools/list_changed': 'tools-list-changed',
|
|
649
|
+
// Host-level admin commands initiated from a surface (e.g. a Discord
|
|
650
|
+
// slash command). Params: { command, ... }; host responds with a
|
|
651
|
+
// command-specific result object.
|
|
652
|
+
'host/command': 'host-command',
|
|
649
653
|
};
|
|
650
654
|
|
|
651
655
|
/**
|
package/src/module-registry.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
SpeechContext,
|
|
22
22
|
SpeechHandlerOptions,
|
|
23
23
|
ProcessEvent,
|
|
24
|
+
TraceEventListener,
|
|
24
25
|
} from './types/index.js';
|
|
25
26
|
import type { Agent } from './agent.js';
|
|
26
27
|
|
|
@@ -53,6 +54,7 @@ export class ModuleRegistry {
|
|
|
53
54
|
private queryMessagesFn: (filter: MessageQuery) => MessageQueryResult;
|
|
54
55
|
|
|
55
56
|
private pushEventFn: (event: ProcessEvent) => void;
|
|
57
|
+
private onTraceFn: (listener: TraceEventListener) => () => void;
|
|
56
58
|
|
|
57
59
|
constructor(
|
|
58
60
|
store: JsStore,
|
|
@@ -65,6 +67,7 @@ export class ModuleRegistry {
|
|
|
65
67
|
getMessage: (id: MessageId) => StoredMessage | null;
|
|
66
68
|
queryMessages: (filter: MessageQuery) => MessageQueryResult;
|
|
67
69
|
pushEvent: (event: ProcessEvent) => void;
|
|
70
|
+
onTrace: (listener: TraceEventListener) => () => void;
|
|
68
71
|
}
|
|
69
72
|
) {
|
|
70
73
|
this.store = store;
|
|
@@ -76,6 +79,7 @@ export class ModuleRegistry {
|
|
|
76
79
|
this.getMessageFn = options.getMessage;
|
|
77
80
|
this.queryMessagesFn = options.queryMessages;
|
|
78
81
|
this.pushEventFn = options.pushEvent;
|
|
82
|
+
this.onTraceFn = options.onTrace;
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
/**
|
|
@@ -115,7 +119,8 @@ export class ModuleRegistry {
|
|
|
115
119
|
this.removeMessageFn,
|
|
116
120
|
this.getMessageFn,
|
|
117
121
|
this.queryMessagesFn,
|
|
118
|
-
this.pushEventFn
|
|
122
|
+
this.pushEventFn,
|
|
123
|
+
this.onTraceFn
|
|
119
124
|
);
|
|
120
125
|
|
|
121
126
|
this.modules.set(module.name, module);
|
|
@@ -366,6 +371,7 @@ class ModuleContextImpl implements ModuleContext {
|
|
|
366
371
|
private getMessageFn: (id: MessageId) => StoredMessage | null;
|
|
367
372
|
private queryMessagesFn: (filter: MessageQuery) => MessageQueryResult;
|
|
368
373
|
private pushEventFn: (event: ProcessEvent) => void;
|
|
374
|
+
private onTraceFn: (listener: TraceEventListener) => () => void;
|
|
369
375
|
|
|
370
376
|
// External ID mapping stored in module state
|
|
371
377
|
private externalIdMap: Map<string, MessageId> = new Map();
|
|
@@ -383,7 +389,8 @@ class ModuleContextImpl implements ModuleContext {
|
|
|
383
389
|
removeMessage: (id: MessageId) => void,
|
|
384
390
|
getMessage: (id: MessageId) => StoredMessage | null,
|
|
385
391
|
queryMessages: (filter: MessageQuery) => MessageQueryResult,
|
|
386
|
-
pushEvent: (event: ProcessEvent) => void
|
|
392
|
+
pushEvent: (event: ProcessEvent) => void,
|
|
393
|
+
onTrace: (listener: TraceEventListener) => () => void
|
|
387
394
|
) {
|
|
388
395
|
this.moduleName = moduleName;
|
|
389
396
|
this.store = store;
|
|
@@ -398,6 +405,7 @@ class ModuleContextImpl implements ModuleContext {
|
|
|
398
405
|
this.getMessageFn = getMessage;
|
|
399
406
|
this.queryMessagesFn = queryMessages;
|
|
400
407
|
this.pushEventFn = pushEvent;
|
|
408
|
+
this.onTraceFn = onTrace;
|
|
401
409
|
|
|
402
410
|
// Load external ID map from state if exists
|
|
403
411
|
const state = this.getState<{ externalIdMap?: Record<string, string> }>();
|
|
@@ -424,6 +432,10 @@ class ModuleContextImpl implements ModuleContext {
|
|
|
424
432
|
return this.registry.getModule<T>(name);
|
|
425
433
|
}
|
|
426
434
|
|
|
435
|
+
onTrace(listener: TraceEventListener): () => void {
|
|
436
|
+
return this.onTraceFn(listener);
|
|
437
|
+
}
|
|
438
|
+
|
|
427
439
|
addMessage(
|
|
428
440
|
participant: string,
|
|
429
441
|
content: ContentBlock[],
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
ToolCall,
|
|
19
19
|
ToolResult,
|
|
20
20
|
SpeechContext,
|
|
21
|
+
TraceEvent,
|
|
21
22
|
} from '../../types/index.js';
|
|
22
23
|
import type {
|
|
23
24
|
DiscordModuleConfig,
|
|
@@ -56,6 +57,24 @@ const DEFAULT_CONFIG: Partial<DiscordModuleConfig> = {
|
|
|
56
57
|
historyScrollback: 50,
|
|
57
58
|
};
|
|
58
59
|
|
|
60
|
+
// Re-trigger the Discord typing indicator on this interval. Discord's
|
|
61
|
+
// indicator auto-expires after ~10s, so we refresh comfortably inside that.
|
|
62
|
+
const TYPING_REFRESH_MS = 8_000;
|
|
63
|
+
|
|
64
|
+
// Absolute safety cap on a single typing keepalive. If a terminal trace event
|
|
65
|
+
// is somehow missed (crash, dropped stream), this guarantees typing can't get
|
|
66
|
+
// stuck on forever.
|
|
67
|
+
const TYPING_MAX_MS = 5 * 60_000;
|
|
68
|
+
|
|
69
|
+
// Trace event types that terminate an agent's turn — any of these stops the
|
|
70
|
+
// typing keepalive. They fire once per turn, after all tool-call cycles.
|
|
71
|
+
const TURN_TERMINAL_TRACES = new Set([
|
|
72
|
+
'inference:turn_ended',
|
|
73
|
+
'inference:aborted',
|
|
74
|
+
'inference:failed',
|
|
75
|
+
'inference:exhausted',
|
|
76
|
+
]);
|
|
77
|
+
|
|
59
78
|
// Image MIME types that Claude can process
|
|
60
79
|
const SUPPORTED_IMAGE_TYPES = [
|
|
61
80
|
'image/jpeg',
|
|
@@ -95,6 +114,15 @@ export class DiscordModule implements Module {
|
|
|
95
114
|
// Last message sent by a tool (for thought-editing)
|
|
96
115
|
private lastSentMessage: { channelId: string; messageId: string } | null = null;
|
|
97
116
|
|
|
117
|
+
// Typing keepalive: agentName -> { interval, deadline }. Started on
|
|
118
|
+
// inference:started, stopped on the turn's terminal trace event. Discord's
|
|
119
|
+
// typing indicator auto-expires after ~10s, so we re-trigger on an interval
|
|
120
|
+
// to keep it alive for the whole turn (including tool-call cycles).
|
|
121
|
+
private typingLoops: Map<string, { interval: ReturnType<typeof setInterval>; deadline: number }> = new Map();
|
|
122
|
+
|
|
123
|
+
// Unsubscribe handle for the trace subscription (cleared in stop()).
|
|
124
|
+
private unsubscribeTrace: (() => void) | null = null;
|
|
125
|
+
|
|
98
126
|
constructor(client: DiscordClientInterface, config: DiscordModuleConfig) {
|
|
99
127
|
this.client = client;
|
|
100
128
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
@@ -114,6 +142,22 @@ export class DiscordModule implements Module {
|
|
|
114
142
|
// Register as speech handler
|
|
115
143
|
ctx.registerSpeechHandler('*');
|
|
116
144
|
|
|
145
|
+
// Typing keepalive: bracket the indicator between an agent starting
|
|
146
|
+
// inference and its turn ending, so it stays alive for the whole turn
|
|
147
|
+
// (including tool-call cycles) and stops cleanly whether the agent
|
|
148
|
+
// responds, errors, or decides to stay silent.
|
|
149
|
+
if (this.config.autoTyping) {
|
|
150
|
+
this.unsubscribeTrace = ctx.onTrace((event: TraceEvent) => {
|
|
151
|
+
const agentName = (event as { agentName?: string }).agentName;
|
|
152
|
+
if (!agentName) return;
|
|
153
|
+
if (event.type === 'inference:started') {
|
|
154
|
+
this.startTypingLoop(agentName);
|
|
155
|
+
} else if (TURN_TERMINAL_TRACES.has(event.type)) {
|
|
156
|
+
this.stopTypingLoop(agentName);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
117
161
|
// Set up Discord event handlers
|
|
118
162
|
this.client.onMessage((message) => this.handleDiscordMessage(message));
|
|
119
163
|
this.client.onMessageEdit((id, content) => this.handleDiscordMessageEdit(id, content));
|
|
@@ -146,10 +190,59 @@ export class DiscordModule implements Module {
|
|
|
146
190
|
this.ctx.setState(this.state);
|
|
147
191
|
}
|
|
148
192
|
|
|
193
|
+
// Tear down typing keepalive: drop the trace subscription and clear any
|
|
194
|
+
// in-flight intervals so nothing leaks across teardown/recreate.
|
|
195
|
+
this.unsubscribeTrace?.();
|
|
196
|
+
this.unsubscribeTrace = null;
|
|
197
|
+
for (const { interval } of this.typingLoops.values()) {
|
|
198
|
+
clearInterval(interval);
|
|
199
|
+
}
|
|
200
|
+
this.typingLoops.clear();
|
|
201
|
+
|
|
149
202
|
await this.client.disconnect();
|
|
150
203
|
this.ctx = null;
|
|
151
204
|
}
|
|
152
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Start (or refresh) the typing keepalive for an agent's reply channel.
|
|
208
|
+
* Idempotent: a fresh `inference:started` resets the safety deadline and
|
|
209
|
+
* restarts the interval. No-op if we can't resolve a channel for the agent.
|
|
210
|
+
*/
|
|
211
|
+
private startTypingLoop(agentName: string): void {
|
|
212
|
+
const replyCtx = this.replyContexts.get(agentName) ?? this.replyContexts.get('default');
|
|
213
|
+
if (!replyCtx) return;
|
|
214
|
+
const channel = replyCtx.threadId ?? replyCtx.channelId;
|
|
215
|
+
|
|
216
|
+
// Restart cleanly if one is already running for this agent.
|
|
217
|
+
this.stopTypingLoop(agentName);
|
|
218
|
+
|
|
219
|
+
const deadline = Date.now() + TYPING_MAX_MS;
|
|
220
|
+
const fire = () => {
|
|
221
|
+
if (Date.now() >= deadline) {
|
|
222
|
+
this.stopTypingLoop(agentName);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
this.client.sendTyping(channel).catch((err) => {
|
|
226
|
+
console.warn('Discord: Failed to send typing indicator:', err);
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
fire(); // immediate feedback, don't wait a full interval
|
|
231
|
+
const interval = setInterval(fire, TYPING_REFRESH_MS);
|
|
232
|
+
// Don't keep the process alive solely for a typing indicator.
|
|
233
|
+
(interval as { unref?: () => void }).unref?.();
|
|
234
|
+
this.typingLoops.set(agentName, { interval, deadline });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Stop the typing keepalive for an agent, if one is running. */
|
|
238
|
+
private stopTypingLoop(agentName: string): void {
|
|
239
|
+
const loop = this.typingLoops.get(agentName);
|
|
240
|
+
if (loop) {
|
|
241
|
+
clearInterval(loop.interval);
|
|
242
|
+
this.typingLoops.delete(agentName);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
153
246
|
getTools(): ToolDefinition[] {
|
|
154
247
|
return [
|
|
155
248
|
{
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert an AF `ToolResult.data` value into the string form that lives in
|
|
3
|
+
* the agent's conversation history.
|
|
4
|
+
*
|
|
5
|
+
* The live path (`toMembraneToolResult` in framework.ts) preserves MCP image
|
|
6
|
+
* blocks natively so the model sees the bytes on the current turn. The
|
|
7
|
+
* *persisted* copy needs to survive recompilation, compression, and
|
|
8
|
+
* `/restore` — storing megabytes of base64 there gets corrupted on the next
|
|
9
|
+
* `maxChars` slice and re-introduces, one turn deferred, exactly the silent
|
|
10
|
+
* hallucination this feature exists to kill.
|
|
11
|
+
*
|
|
12
|
+
* For MCP-shaped content arrays, this helper keeps text blocks verbatim and
|
|
13
|
+
* replaces image blocks with a short `[image: mimeType, ~NKB]` placeholder.
|
|
14
|
+
* For anything else it falls back to JSON.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { safeSlice } from './safe-slice.js';
|
|
18
|
+
|
|
19
|
+
export function toolResultDataToHistoryString(data: unknown, maxChars?: number): string {
|
|
20
|
+
const fromArray = tryHistoryStringFromContentArray(data);
|
|
21
|
+
const str = fromArray ?? JSON.stringify(data);
|
|
22
|
+
if (maxChars && str.length > maxChars) {
|
|
23
|
+
return safeSlice(str, 0, maxChars)
|
|
24
|
+
+ '\n\n[truncated — original was ' + str.length + ' chars]';
|
|
25
|
+
}
|
|
26
|
+
return str;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* If `data` is an MCP content array whose every block has a shape we
|
|
31
|
+
* recognize, return a history-safe string (images → placeholders). Otherwise
|
|
32
|
+
* return `null` to defer to the caller's fallback (JSON, usually).
|
|
33
|
+
*/
|
|
34
|
+
function tryHistoryStringFromContentArray(data: unknown): string | null {
|
|
35
|
+
if (!Array.isArray(data)) return null;
|
|
36
|
+
const parts: string[] = [];
|
|
37
|
+
for (const raw of data) {
|
|
38
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
39
|
+
const b = raw as { type?: unknown; text?: unknown; data?: unknown; mimeType?: unknown };
|
|
40
|
+
if (b.type === 'text' && typeof b.text === 'string') {
|
|
41
|
+
parts.push(b.text);
|
|
42
|
+
} else if (b.type === 'image' && typeof b.data === 'string' && typeof b.mimeType === 'string') {
|
|
43
|
+
// Decoded byte estimate from base64 length (3/4 ratio, rounded).
|
|
44
|
+
const approxBytes = Math.floor(b.data.length * 3 / 4);
|
|
45
|
+
parts.push(`[image: ${b.mimeType}, ${formatSize(approxBytes)}]`);
|
|
46
|
+
} else {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return parts.join('\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatSize(bytes: number): string {
|
|
54
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
55
|
+
if (bytes < 1024 * 1024) return `~${Math.round(bytes / 1024)}KB`;
|
|
56
|
+
return `~${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
57
|
+
}
|
package/src/types/agent.ts
CHANGED
|
@@ -42,6 +42,9 @@ export interface AgentConfig {
|
|
|
42
42
|
* restarts with recompiled (compressed) context. Default: 150000. */
|
|
43
43
|
maxStreamTokens?: number;
|
|
44
44
|
|
|
45
|
+
/** Per-agent context compile budget (input tokens). When unset, the
|
|
46
|
+
* ContextManager's built-in default (100k) applies. */
|
|
47
|
+
contextBudgetTokens?: number;
|
|
45
48
|
/**
|
|
46
49
|
* Extended thinking config. When `enabled: true`, the agent runs with
|
|
47
50
|
* Anthropic's native extended thinking; responses include `thinking` blocks
|
|
@@ -51,6 +54,14 @@ export interface AgentConfig {
|
|
|
51
54
|
thinking?: {
|
|
52
55
|
enabled: boolean;
|
|
53
56
|
budgetTokens?: number;
|
|
57
|
+
/** 'enabled' (explicit budget, default) or 'adaptive' (model-managed) */
|
|
58
|
+
type?: 'enabled' | 'adaptive';
|
|
59
|
+
/**
|
|
60
|
+
* How thinking content is returned: 'summarized' (readable summary) or
|
|
61
|
+
* 'omitted' (empty thinking field, signature only). Fable 5 / Opus 4.7+
|
|
62
|
+
* default to 'omitted' — set 'summarized' to receive thinking text.
|
|
63
|
+
*/
|
|
64
|
+
display?: 'summarized' | 'omitted';
|
|
54
65
|
};
|
|
55
66
|
}
|
|
56
67
|
|
package/src/types/framework.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { Module, EventResponse } from './module.js';
|
|
|
4
4
|
import type { AgentConfig, InferenceRequest } from './agent.js';
|
|
5
5
|
import type { ProcessEvent } from './events.js';
|
|
6
6
|
import type { McplServerConfig, InferenceRoutingPolicy } from '../mcpl/types.js';
|
|
7
|
+
import type { ConversationRouterConfig } from '../mcpl/conversation-router.js';
|
|
7
8
|
import type { GateOptions } from '../gate/types.js';
|
|
8
9
|
|
|
9
10
|
// Re-export trace types
|
|
@@ -66,6 +67,14 @@ export interface FrameworkConfig {
|
|
|
66
67
|
|
|
67
68
|
/** EventGate config. If omitted, all events trigger inference (unchanged default). */
|
|
68
69
|
gate?: GateOptions;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Per-channel conversation routing. When set, incoming MCPL channel
|
|
73
|
+
* messages route to per-channel fork agents spawned from the template
|
|
74
|
+
* agent instead of the primary conversation. If omitted, behavior is
|
|
75
|
+
* unchanged (all messages go to the primary agent).
|
|
76
|
+
*/
|
|
77
|
+
conversations?: ConversationRouterConfig;
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
/**
|
package/src/types/module.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ContextInjection,
|
|
7
7
|
} from '@animalabs/context-manager';
|
|
8
8
|
import type { ProcessEvent, ToolDefinition, ToolCall, ToolResult } from './events.js';
|
|
9
|
+
import type { TraceEventListener } from './trace.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Filter criteria for querying messages.
|
|
@@ -184,6 +185,21 @@ export interface ModuleContext {
|
|
|
184
185
|
* Unregister this module as a speech handler.
|
|
185
186
|
*/
|
|
186
187
|
unregisterSpeechHandler(): void;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Subscribe to the framework's trace event stream for observability.
|
|
191
|
+
*
|
|
192
|
+
* Modules use this to react to agent lifecycle without polling — e.g. an
|
|
193
|
+
* adapter that wants to show a "typing" indicator can bracket it between
|
|
194
|
+
* `inference:started` and the terminal events (`inference:turn_ended`,
|
|
195
|
+
* `inference:aborted`, `inference:failed`, `inference:exhausted`), all of
|
|
196
|
+
* which carry `agentName`. The terminal events fire once per turn, after any
|
|
197
|
+
* tool-call cycles, so a single start/stop bracket spans the whole turn.
|
|
198
|
+
*
|
|
199
|
+
* Returns an unsubscribe function. Call it in the module's `stop()` to avoid
|
|
200
|
+
* leaking listeners across teardown/recreate (e.g. session switch).
|
|
201
|
+
*/
|
|
202
|
+
onTrace(listener: TraceEventListener): () => void;
|
|
187
203
|
}
|
|
188
204
|
|
|
189
205
|
/**
|