@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.
Files changed (101) hide show
  1. package/dist/src/agent.d.ts +20 -4
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +45 -14
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +153 -5
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +1033 -56
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +27 -0
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +140 -1
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/gate/types.d.ts +17 -0
  14. package/dist/src/gate/types.d.ts.map +1 -1
  15. package/dist/src/index.d.ts +2 -0
  16. package/dist/src/index.d.ts.map +1 -1
  17. package/dist/src/index.js +2 -0
  18. package/dist/src/index.js.map +1 -1
  19. package/dist/src/mcpl/channel-registry.d.ts +50 -0
  20. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  21. package/dist/src/mcpl/channel-registry.js +180 -0
  22. package/dist/src/mcpl/channel-registry.js.map +1 -1
  23. package/dist/src/mcpl/conversation-router.d.ts +161 -0
  24. package/dist/src/mcpl/conversation-router.d.ts.map +1 -0
  25. package/dist/src/mcpl/conversation-router.js +179 -0
  26. package/dist/src/mcpl/conversation-router.js.map +1 -0
  27. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  28. package/dist/src/mcpl/feature-set-manager.js +10 -1
  29. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  30. package/dist/src/mcpl/index.d.ts +2 -0
  31. package/dist/src/mcpl/index.d.ts.map +1 -1
  32. package/dist/src/mcpl/index.js +2 -0
  33. package/dist/src/mcpl/index.js.map +1 -1
  34. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  35. package/dist/src/mcpl/server-connection.js +4 -0
  36. package/dist/src/mcpl/server-connection.js.map +1 -1
  37. package/dist/src/module-registry.d.ts +3 -1
  38. package/dist/src/module-registry.d.ts.map +1 -1
  39. package/dist/src/module-registry.js +9 -2
  40. package/dist/src/module-registry.js.map +1 -1
  41. package/dist/src/modules/discord/index.d.ts +10 -0
  42. package/dist/src/modules/discord/index.d.ts.map +1 -1
  43. package/dist/src/modules/discord/index.js +83 -0
  44. package/dist/src/modules/discord/index.js.map +1 -1
  45. package/dist/src/tool-result-history.d.ts +17 -0
  46. package/dist/src/tool-result-history.d.ts.map +1 -0
  47. package/dist/src/tool-result-history.js +60 -0
  48. package/dist/src/tool-result-history.js.map +1 -0
  49. package/dist/src/types/agent.d.ts +11 -0
  50. package/dist/src/types/agent.d.ts.map +1 -1
  51. package/dist/src/types/framework.d.ts +8 -0
  52. package/dist/src/types/framework.d.ts.map +1 -1
  53. package/dist/src/types/module.d.ts +15 -0
  54. package/dist/src/types/module.d.ts.map +1 -1
  55. package/dist/src/types/trace.d.ts +28 -0
  56. package/dist/src/types/trace.d.ts.map +1 -1
  57. package/dist/test/channel-registry-routing.test.d.ts +2 -0
  58. package/dist/test/channel-registry-routing.test.d.ts.map +1 -0
  59. package/dist/test/channel-registry-routing.test.js +75 -0
  60. package/dist/test/channel-registry-routing.test.js.map +1 -0
  61. package/dist/test/conversation-router.test.d.ts +2 -0
  62. package/dist/test/conversation-router.test.d.ts.map +1 -0
  63. package/dist/test/conversation-router.test.js +160 -0
  64. package/dist/test/conversation-router.test.js.map +1 -0
  65. package/dist/test/conversation-routing.test.d.ts +7 -0
  66. package/dist/test/conversation-routing.test.d.ts.map +1 -0
  67. package/dist/test/conversation-routing.test.js +203 -0
  68. package/dist/test/conversation-routing.test.js.map +1 -0
  69. package/dist/test/framework.test.js +73 -1
  70. package/dist/test/framework.test.js.map +1 -1
  71. package/dist/test/helpers/mock-membrane.d.ts +41 -0
  72. package/dist/test/helpers/mock-membrane.d.ts.map +1 -0
  73. package/dist/test/helpers/mock-membrane.js +161 -0
  74. package/dist/test/helpers/mock-membrane.js.map +1 -0
  75. package/dist/test/inference-failure-observability.test.d.ts +2 -0
  76. package/dist/test/inference-failure-observability.test.d.ts.map +1 -0
  77. package/dist/test/inference-failure-observability.test.js +75 -0
  78. package/dist/test/inference-failure-observability.test.js.map +1 -0
  79. package/dist/test/tool-result-history.test.d.ts +7 -0
  80. package/dist/test/tool-result-history.test.d.ts.map +1 -0
  81. package/dist/test/tool-result-history.test.js +77 -0
  82. package/dist/test/tool-result-history.test.js.map +1 -0
  83. package/dist/tsconfig.tsbuildinfo +1 -1
  84. package/package.json +2 -2
  85. package/src/agent.ts +53 -18
  86. package/src/framework.ts +1188 -59
  87. package/src/gate/event-gate.ts +144 -1
  88. package/src/gate/types.ts +17 -0
  89. package/src/index.ts +12 -0
  90. package/src/mcpl/channel-registry.ts +221 -0
  91. package/src/mcpl/conversation-router.ts +284 -0
  92. package/src/mcpl/feature-set-manager.ts +14 -1
  93. package/src/mcpl/index.ts +12 -0
  94. package/src/mcpl/server-connection.ts +4 -0
  95. package/src/module-registry.ts +14 -2
  96. package/src/modules/discord/index.ts +93 -0
  97. package/src/tool-result-history.ts +57 -0
  98. package/src/types/agent.ts +11 -0
  99. package/src/types/framework.ts +9 -0
  100. package/src/types/module.ts +16 -0
  101. 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
- const declared = capabilities.featureSets ?? {};
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
  /**
@@ -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
+ }
@@ -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
 
@@ -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
  /**
@@ -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
  /**