@axiom-lattice/gateway 2.1.88 → 2.1.89

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/gateway",
3
- "version": "2.1.88",
3
+ "version": "2.1.89",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -40,9 +40,9 @@
40
40
  "redis": "^5.0.1",
41
41
  "uuid": "^9.0.1",
42
42
  "zod": "3.25.76",
43
- "@axiom-lattice/agent-eval": "2.1.70",
44
- "@axiom-lattice/core": "2.1.76",
45
- "@axiom-lattice/pg-stores": "1.0.67",
43
+ "@axiom-lattice/agent-eval": "2.1.71",
44
+ "@axiom-lattice/core": "2.1.77",
45
+ "@axiom-lattice/pg-stores": "1.0.68",
46
46
  "@axiom-lattice/protocols": "2.1.39",
47
47
  "@axiom-lattice/queue-redis": "1.0.38"
48
48
  },
@@ -19,6 +19,14 @@ export class BindingNotFoundError extends Error {
19
19
  }
20
20
  }
21
21
 
22
+ /**
23
+ * Configuration for {@link MessageRouter}.
24
+ *
25
+ * @param middlewares - Ordered middleware chain executed before dispatch
26
+ * @param bindingRegistry - Resolves sender-to-agent bindings
27
+ * @param adapterRegistry - Registry of {@link ChannelAdapter} implementations
28
+ * @param installationStore - Persisted channel installation configs
29
+ */
22
30
  export interface MessageRouterConfig {
23
31
  middlewares: MessageMiddleware[];
24
32
  bindingRegistry: BindingRegistry;
@@ -26,12 +34,49 @@ export interface MessageRouterConfig {
26
34
  installationStore: ChannelInstallationStore;
27
35
  }
28
36
 
37
+ /**
38
+ * Core message router for external channel integration.
39
+ *
40
+ * Receives normalized {@link InboundMessage} objects from channel adapters,
41
+ * resolves the target agent via {@link BindingRegistry}, manages thread
42
+ * lifecycle, and sends AI responses back to the channel via
43
+ * `ChannelAdapter.sendReply`.
44
+ *
45
+ * ## Reply flow
46
+ *
47
+ * The router uses a counter-based deduplication scheme on the internal
48
+ * {@link Agent.reply:ready} event:
49
+ *
50
+ * 1. Before `agent.addMessage()`, it subscribes to `reply:ready` on the agent.
51
+ * 2. Multiple concurrent dispatches on the same thread increment a counter;
52
+ * only the first registers the `EventEmitter.once` listener.
53
+ * 3. When `reply:ready` fires, the callback extracts the last AI message from
54
+ * the LangGraph state and sends it via `ChannelAdapter.sendReply`.
55
+ * 4. The counter is decremented; the subscription is only removed when the
56
+ * counter reaches 0.
57
+ * 5. Stale subscriptions are cleaned up after a 1-hour timeout.
58
+ *
59
+ * The `replyTarget` is carried through by injecting `_replyTarget` into
60
+ * `custom_run_config` on {@link Agent.addMessage}, and retrieved from the
61
+ * `reply:ready` event payload.
62
+ *
63
+ * @see {@link ChannelAdapter.sendReply}
64
+ * @see {@link InboundMessage.replyTarget}
65
+ */
29
66
  export class MessageRouter {
30
67
  private middlewares: MessageMiddleware[];
31
68
  private bindingRegistry: BindingRegistry;
32
69
  private adapterRegistry: ChannelAdapterRegistry;
33
70
  private installationStore: ChannelInstallationStore;
34
71
 
72
+ /**
73
+ * Tracks reply subscriptions per thread+channel to avoid duplicate
74
+ * `subscribeOnce` registrations and ensure proper cleanup.
75
+ *
76
+ * Key format: `{threadId}:{adapterChannel}:reply`
77
+ */
78
+ private _replySubs: Map<string, { count: number; timer: NodeJS.Timeout }> = new Map();
79
+
35
80
  constructor(config: MessageRouterConfig) {
36
81
  this.middlewares = [...config.middlewares];
37
82
  this.bindingRegistry = config.bindingRegistry;
@@ -39,10 +84,28 @@ export class MessageRouter {
39
84
  this.installationStore = config.installationStore;
40
85
  }
41
86
 
87
+ /**
88
+ * Register an additional middleware at the end of the chain.
89
+ *
90
+ * @param middleware - A {@link MessageMiddleware} function
91
+ */
42
92
  use(middleware: MessageMiddleware): void {
43
93
  this.middlewares.push(middleware);
44
94
  }
45
95
 
96
+ /**
97
+ * Dispatch an inbound channel message to the bound agent.
98
+ *
99
+ * Full pipeline: middleware chain → binding resolution → thread lifecycle
100
+ * → agent.addMessage() → (async) reply via {@link ChannelAdapter.sendReply}.
101
+ *
102
+ * If the message has a {@link InboundMessage.replyTarget}, the router subscribes
103
+ * to the agent's `reply:ready` event before enqueuing the message, and sends
104
+ * the AI response back to the channel when it arrives.
105
+ *
106
+ * @param message - Normalized inbound message from a channel adapter
107
+ * @returns {@link DispatchResult} with success status, bindingId, and threadId
108
+ */
46
109
  async dispatch(message: InboundMessage): Promise<DispatchResult> {
47
110
  const ctx: MessageContext = {
48
111
  inboundMessage: message,
@@ -208,9 +271,114 @@ export class MessageRouter {
208
271
  project_id: ctx.binding.projectId || "",
209
272
  });
210
273
 
274
+ /**
275
+ * Subscribe to reply:ready before addMessage to avoid a race with
276
+ * an already-running queue processor.
277
+ *
278
+ * The `_replySubs` map uses a counter (not a boolean) so that multiple
279
+ * concurrent dispatches on the same thread each get a callback invocation.
280
+ * The `subscribeOnce` listener is only registered when the counter goes
281
+ * from 0 → 1, and removed when it goes back to 0. A 1-hour timeout
282
+ * protects against leaked subscriptions due to agent errors/aborts.
283
+ */
284
+ if (message.replyTarget) {
285
+ const replySubKey = `${threadId}:${message.replyTarget.adapterChannel}:reply`;
286
+ const adapter = this.adapterRegistry.get(message.replyTarget.adapterChannel);
287
+ if (adapter) {
288
+ const installation = await this.installationStore.getInstallationById(
289
+ message.channelInstallationId,
290
+ );
291
+ if (installation) {
292
+ const existing = this._replySubs.get(replySubKey);
293
+ if (!existing || existing.count === 0) {
294
+ // First subscriber for this thread+channel — register the event listener
295
+ const timer = setTimeout(() => {
296
+ // Cleanup if reply never fires (agent aborted, errored, etc.)
297
+ const entry = this._replySubs.get(replySubKey);
298
+ if (entry) {
299
+ this._replySubs.delete(replySubKey);
300
+ console.warn({
301
+ event: "dispatch:reply:timeout",
302
+ threadId,
303
+ channel: message.replyTarget!.adapterChannel,
304
+ }, "Reply subscription timed out — no reply:ready fired within 1h");
305
+ }
306
+ }, 3_600_000);
307
+
308
+ this._replySubs.set(replySubKey, { count: 1, timer });
309
+
310
+ console.log({
311
+ event: "dispatch:reply:subscribed",
312
+ threadId,
313
+ channel: message.replyTarget.adapterChannel,
314
+ senderId: message.sender.id,
315
+ }, "Subscribed to reply:ready for thread");
316
+
317
+ agent.subscribeOnce('reply:ready', (data: any) => {
318
+ const messages = data.state?.values?.messages;
319
+ const lastAI = messages
320
+ ?.filter((m: any) => m.type === 'ai' || m.getType?.() === 'ai')
321
+ .pop();
322
+ const replyText = lastAI?.content ?? '';
323
+
324
+ // Decrement counter; unsubscribe only when count reaches 0
325
+ const entry = this._replySubs.get(replySubKey);
326
+ if (entry) {
327
+ entry.count--;
328
+ if (entry.count <= 0) {
329
+ clearTimeout(entry.timer);
330
+ this._replySubs.delete(replySubKey);
331
+ }
332
+ }
333
+
334
+ if (replyText) {
335
+ console.log({
336
+ event: "dispatch:reply:sending",
337
+ threadId,
338
+ channel: message.replyTarget!.adapterChannel,
339
+ replyLength: replyText.length,
340
+ }, "Sending channel reply");
341
+ adapter.sendReply(message.replyTarget!, { text: replyText }, installation)
342
+ .then(() => {
343
+ console.log({
344
+ event: "dispatch:reply:sent",
345
+ threadId,
346
+ channel: message.replyTarget!.adapterChannel,
347
+ }, "Channel reply sent successfully");
348
+ })
349
+ .catch((err) => console.error({
350
+ event: "dispatch:reply:failed",
351
+ threadId,
352
+ channel: message.replyTarget!.adapterChannel,
353
+ error: err instanceof Error ? err.message : String(err),
354
+ }, "Failed to send channel reply"));
355
+ } else {
356
+ console.warn({
357
+ event: "dispatch:reply:empty",
358
+ threadId,
359
+ channel: message.replyTarget!.adapterChannel,
360
+ }, "Agent produced no text output — skipping reply");
361
+ }
362
+ });
363
+ } else {
364
+ // Another dispatch already subscribed — increment counter
365
+ existing.count++;
366
+ console.log({
367
+ event: "dispatch:reply:incremented",
368
+ threadId,
369
+ channel: message.replyTarget.adapterChannel,
370
+ count: existing.count,
371
+ }, "Incremented reply counter for thread (already subscribed)");
372
+ }
373
+ }
374
+ }
375
+ }
376
+
211
377
  const addResult = await agent.addMessage({
212
378
  input: { message: message.content.text },
213
- custom_run_config: message.content.metadata || {},
379
+ custom_run_config: message.replyTarget
380
+ ? { ...(message.content.metadata || {}), _replyTarget: message.replyTarget }
381
+ : (message.content.metadata || {}),
214
382
  });
215
383
 
216
384
  console.log({
@@ -220,22 +388,6 @@ export class MessageRouter {
220
388
  messageId: (addResult as Record<string, unknown>)?.messageId,
221
389
  result: JSON.stringify(addResult),
222
390
  }, "Agent dispatch complete — messageId = " + ((addResult as Record<string, unknown>)?.messageId || "N/A"));
223
-
224
- if (message.replyTarget) {
225
- const adapter = this.adapterRegistry.get(message.replyTarget.adapterChannel);
226
- if (adapter) {
227
- const installation = await this.installationStore.getInstallationById(
228
- message.channelInstallationId,
229
- );
230
- if (installation) {
231
- await adapter.sendReply(
232
- message.replyTarget,
233
- { text: ctx.result || "" },
234
- installation,
235
- );
236
- }
237
- }
238
- }
239
391
  });
240
392
 
241
393
  return {