@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/.turbo/turbo-build.log +10 -10
- package/AGENTS.md +62 -0
- package/CHANGELOG.md +10 -0
- package/dist/index.d.mts +62 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.js +105 -16
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +105 -16
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/router/MessageRouter.ts +169 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axiom-lattice/gateway",
|
|
3
|
-
"version": "2.1.
|
|
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.
|
|
44
|
-
"@axiom-lattice/core": "2.1.
|
|
45
|
-
"@axiom-lattice/pg-stores": "1.0.
|
|
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.
|
|
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 {
|