@peopl-health/nexus 3.12.0 → 3.13.1
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/lib/core/BatchingManager.js +17 -100
- package/lib/core/NexusMessaging.js +15 -3
- package/lib/core/ProcessingPipeline.js +145 -0
- package/lib/index.d.ts +35 -1
- package/package.json +1 -1
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
const { logger } = require('../utils/logger');
|
|
2
2
|
const { Message } = require('../models/messageModel');
|
|
3
3
|
|
|
4
|
-
const VALID_HOOKS = ['preBatch', 'postBatch', 'onBatchError', 'prePromptBuild'];
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Manages message batching, processing locks, run abandonment, and lifecycle hooks.
|
|
8
|
-
*/
|
|
9
4
|
class BatchingManager {
|
|
10
5
|
constructor({ provider = null, config = {} }) {
|
|
11
6
|
this.provider = provider;
|
|
@@ -20,33 +15,7 @@ class BatchingManager {
|
|
|
20
15
|
this.activeRequests = new Map();
|
|
21
16
|
this.abandonedRuns = new Set();
|
|
22
17
|
this.typingIntervals = new Map();
|
|
23
|
-
|
|
24
|
-
this._hooks = {
|
|
25
|
-
preBatch: [],
|
|
26
|
-
postBatch: [],
|
|
27
|
-
onBatchError: [],
|
|
28
|
-
prePromptBuild: [],
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
if (config.hooks) {
|
|
32
|
-
for (const [name, handlers] of Object.entries(config.hooks)) {
|
|
33
|
-
const fns = Array.isArray(handlers) ? handlers : [handlers];
|
|
34
|
-
for (const fn of fns) {
|
|
35
|
-
this.addHook(name, fn);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
addHook(name, fn) {
|
|
42
|
-
if (!VALID_HOOKS.includes(name)) {
|
|
43
|
-
throw new Error(`[BatchingManager] Unknown hook: "${name}". Valid hooks: ${VALID_HOOKS.join(', ')}`);
|
|
44
|
-
}
|
|
45
|
-
if (typeof fn !== 'function') {
|
|
46
|
-
throw new Error(`[BatchingManager] Hook "${name}" must be a function`);
|
|
47
|
-
}
|
|
48
|
-
this._hooks[name].push(fn);
|
|
49
|
-
return this;
|
|
18
|
+
this.chatQueues = new Map();
|
|
50
19
|
}
|
|
51
20
|
|
|
52
21
|
setProvider(provider) {
|
|
@@ -70,28 +39,33 @@ class BatchingManager {
|
|
|
70
39
|
|
|
71
40
|
if (this.config.immediateRestart) {
|
|
72
41
|
this._clearProcessingState(chatId);
|
|
73
|
-
|
|
42
|
+
this.processingLocks.set(chatId, true);
|
|
43
|
+
await this._runWithLock(chatId, processingFn, sendResponseFn);
|
|
74
44
|
}
|
|
75
45
|
}
|
|
76
46
|
return;
|
|
77
47
|
}
|
|
78
48
|
|
|
79
49
|
this.processingLocks.set(chatId, true);
|
|
80
|
-
await this.
|
|
50
|
+
await this._runWithLock(chatId, processingFn, sendResponseFn);
|
|
81
51
|
}
|
|
82
52
|
|
|
83
|
-
async
|
|
53
|
+
async enqueueProcessing(chatId, processingFn, sendResponseFn) {
|
|
54
|
+
await this._runWithLock(chatId, processingFn, sendResponseFn);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async _runWithLock(chatId, processingFn, sendResponseFn) {
|
|
58
|
+
const prev = this.chatQueues.get(chatId) || Promise.resolve();
|
|
59
|
+
let resolveGate;
|
|
60
|
+
const gate = new Promise(r => { resolveGate = r; });
|
|
61
|
+
this.chatQueues.set(chatId, gate);
|
|
62
|
+
await prev;
|
|
63
|
+
|
|
84
64
|
this.processingLocks.set(chatId, true);
|
|
85
65
|
const runId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
86
66
|
this.activeRequests.set(chatId, runId);
|
|
87
67
|
|
|
88
68
|
try {
|
|
89
|
-
const pre = await this._runSequentialHooks(chatId, runId);
|
|
90
|
-
if (pre?.skip) {
|
|
91
|
-
logger.debug('[BatchingManager] Batch skipped by preBatch hook', { chatId, runId });
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
69
|
await this._startTypingRefresh(chatId, runId);
|
|
96
70
|
if (this._checkAbandoned(runId)) return;
|
|
97
71
|
|
|
@@ -101,12 +75,12 @@ class BatchingManager {
|
|
|
101
75
|
if (sendResponseFn && result) await sendResponseFn(result);
|
|
102
76
|
} catch (error) {
|
|
103
77
|
logger.error('[BatchingManager] Error processing messages', { chatId, error: error.message });
|
|
104
|
-
this._runDeferredHooks('onBatchError', chatId, error);
|
|
105
78
|
} finally {
|
|
106
79
|
if (this.activeRequests.get(chatId) === runId) {
|
|
107
80
|
this._clearProcessingState(chatId);
|
|
108
|
-
this._runDeferredHooks('postBatch', chatId);
|
|
109
81
|
}
|
|
82
|
+
resolveGate();
|
|
83
|
+
if (this.chatQueues.get(chatId) === gate) this.chatQueues.delete(chatId);
|
|
110
84
|
if (this.abandonedRuns.size > 100) {
|
|
111
85
|
const toKeep = [...this.abandonedRuns].slice(-20);
|
|
112
86
|
this.abandonedRuns.clear();
|
|
@@ -121,63 +95,6 @@ class BatchingManager {
|
|
|
121
95
|
this._stopTyping(chatId);
|
|
122
96
|
}
|
|
123
97
|
|
|
124
|
-
async _runSequentialHooks(chatId, runId) {
|
|
125
|
-
const hooks = this._hooks.preBatch;
|
|
126
|
-
if (hooks.length === 0) return;
|
|
127
|
-
|
|
128
|
-
const context = { chatId, runId, metadata: {} };
|
|
129
|
-
|
|
130
|
-
for (const fn of hooks) {
|
|
131
|
-
const result = await fn(context);
|
|
132
|
-
if (result?.skip) return { skip: true };
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async runPromptBuildHooks(context) {
|
|
137
|
-
const hooks = this._hooks.prePromptBuild;
|
|
138
|
-
if (hooks.length === 0) return null;
|
|
139
|
-
|
|
140
|
-
let additionalInstructions = '';
|
|
141
|
-
const additionalMessages = [];
|
|
142
|
-
let toolChoice = null;
|
|
143
|
-
for (const fn of hooks) {
|
|
144
|
-
const result = await fn(context);
|
|
145
|
-
if (result?.additionalInstructions) {
|
|
146
|
-
additionalInstructions += additionalInstructions
|
|
147
|
-
? `\n\n${result.additionalInstructions}`
|
|
148
|
-
: result.additionalInstructions;
|
|
149
|
-
}
|
|
150
|
-
if (Array.isArray(result?.additionalMessages) && result.additionalMessages.length > 0) {
|
|
151
|
-
additionalMessages.push(...result.additionalMessages);
|
|
152
|
-
}
|
|
153
|
-
if (result?.toolChoice) {
|
|
154
|
-
toolChoice = result.toolChoice;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
return { additionalInstructions, additionalMessages, toolChoice };
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
_runDeferredHooks(name, chatId, error) {
|
|
161
|
-
const hooks = this._hooks[name];
|
|
162
|
-
if (hooks.length === 0) return;
|
|
163
|
-
|
|
164
|
-
setImmediate(() => {
|
|
165
|
-
if (this.isProcessing(chatId)) return;
|
|
166
|
-
|
|
167
|
-
const context = { chatId };
|
|
168
|
-
if (error) context.error = error;
|
|
169
|
-
|
|
170
|
-
Promise.allSettled(hooks.map(fn => fn(context)))
|
|
171
|
-
.then(results => {
|
|
172
|
-
for (const result of results) {
|
|
173
|
-
if (result.status === 'rejected') {
|
|
174
|
-
logger.error(`[BatchingManager] ${name} hook failed`, { chatId, error: result.reason?.message });
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
|
|
181
98
|
_stopTyping(chatId) {
|
|
182
99
|
const interval = this.typingIntervals.get(chatId);
|
|
183
100
|
if (interval) {
|
|
@@ -18,6 +18,7 @@ const { addMsgAssistant, replyAssistant } = require('../services/assistantServic
|
|
|
18
18
|
const { hasPreprocessingHandler, invokePreprocessingHandler } = require('../services/preprocessingService');
|
|
19
19
|
|
|
20
20
|
const { BatchingManager } = require('../core/BatchingManager');
|
|
21
|
+
const { ProcessingPipeline } = require('../core/ProcessingPipeline');
|
|
21
22
|
const { AssistantProcessor } = require('../core/AssistantProcessor');
|
|
22
23
|
|
|
23
24
|
const { createQueueAdapter } = require('../queue');
|
|
@@ -65,6 +66,8 @@ class NexusMessaging {
|
|
|
65
66
|
config: this.batchingConfig
|
|
66
67
|
});
|
|
67
68
|
|
|
69
|
+
this.pipeline = new ProcessingPipeline(this.batchingConfig.hooks);
|
|
70
|
+
|
|
68
71
|
const queueType = config.queue?.type || 'local';
|
|
69
72
|
const queueConfig = config.queue?.config || {};
|
|
70
73
|
this.queueAdapter = createQueueAdapter(queueType, queueConfig);
|
|
@@ -155,6 +158,7 @@ class NexusMessaging {
|
|
|
155
158
|
getLLMProvider() { return this.llmProvider; }
|
|
156
159
|
getEventBus() { return this.events; }
|
|
157
160
|
getBatchingManager() { return this.batchingManager; }
|
|
161
|
+
getPipeline() { return this.pipeline; }
|
|
158
162
|
getAssistantProcessor() { return this.assistantProcessor; }
|
|
159
163
|
getQueueAdapter() { return this.queueAdapter; }
|
|
160
164
|
isConnected() { return this.provider?.getConnectionStatus() ?? false; }
|
|
@@ -351,9 +355,17 @@ class NexusMessaging {
|
|
|
351
355
|
|
|
352
356
|
async _handleWithCheckAfter(chatId) {
|
|
353
357
|
const processingFn = async (runId) => {
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
return await this.
|
|
358
|
+
const shouldContinue = () => this.batchingManager.isActiveRun(chatId, runId);
|
|
359
|
+
|
|
360
|
+
return await this.pipeline.run(
|
|
361
|
+
{ chatId, runId, type: 'message' },
|
|
362
|
+
async (preProcessResult) => {
|
|
363
|
+
return await this._processMessages(chatId, () =>
|
|
364
|
+
this.assistantProcessor.process({ code: chatId, runOptions: { runId, prePromptResult: preProcessResult } })
|
|
365
|
+
, shouldContinue);
|
|
366
|
+
},
|
|
367
|
+
shouldContinue
|
|
368
|
+
);
|
|
357
369
|
};
|
|
358
370
|
|
|
359
371
|
const sendResponseFn = async (result) => {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const { logger } = require('../utils/logger');
|
|
2
|
+
|
|
3
|
+
const VALID_HOOKS = ['preProcess', 'postProcess'];
|
|
4
|
+
|
|
5
|
+
const LEGACY_MAP = {
|
|
6
|
+
preBatch: 'preProcess',
|
|
7
|
+
prePromptBuild: 'preProcess',
|
|
8
|
+
postBatch: 'postProcess',
|
|
9
|
+
onBatchError: 'postProcess',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
class ProcessingPipeline {
|
|
13
|
+
constructor(hooks = {}) {
|
|
14
|
+
this._hooks = {
|
|
15
|
+
preProcess: [],
|
|
16
|
+
postProcess: [],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
this._hookMeta = new WeakMap();
|
|
20
|
+
|
|
21
|
+
if (hooks && typeof hooks === 'object') {
|
|
22
|
+
for (const [name, handlers] of Object.entries(hooks)) {
|
|
23
|
+
const fns = Array.isArray(handlers) ? handlers : [handlers];
|
|
24
|
+
for (const fn of fns) {
|
|
25
|
+
this.addHook(name, fn);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
addHook(name, fn) {
|
|
32
|
+
const resolved = LEGACY_MAP[name] || name;
|
|
33
|
+
|
|
34
|
+
if (!VALID_HOOKS.includes(resolved)) {
|
|
35
|
+
throw new Error(`[ProcessingPipeline] Unknown hook: "${name}". Valid hooks: ${VALID_HOOKS.join(', ')}`);
|
|
36
|
+
}
|
|
37
|
+
if (typeof fn !== 'function') {
|
|
38
|
+
throw new Error(`[ProcessingPipeline] Hook "${name}" must be a function`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this._hooks[resolved].push(fn);
|
|
42
|
+
this._hookMeta.set(fn, { originalName: name });
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async run(context, executeFn, shouldContinue = () => true) {
|
|
47
|
+
const ctx = { metadata: {}, ...context };
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const preProcessResult = await this._runPreProcessHooks(ctx);
|
|
51
|
+
if (preProcessResult.skip) {
|
|
52
|
+
logger.debug('[ProcessingPipeline] Skipped by preProcess hook', { chatId: ctx.chatId, type: ctx.type });
|
|
53
|
+
this._runPostProcessHooks(ctx, null, null);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!shouldContinue()) return null;
|
|
58
|
+
|
|
59
|
+
const result = await executeFn(preProcessResult, shouldContinue);
|
|
60
|
+
|
|
61
|
+
if (!shouldContinue()) return null;
|
|
62
|
+
|
|
63
|
+
this._runPostProcessHooks(ctx, result, null);
|
|
64
|
+
return result;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
logger.error('[ProcessingPipeline] Error during processing', { chatId: ctx.chatId, type: ctx.type, error: error.message });
|
|
67
|
+
this._runPostProcessHooks(ctx, null, error);
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async _runPreProcessHooks(context) {
|
|
73
|
+
const hooks = this._hooks.preProcess;
|
|
74
|
+
const merged = {
|
|
75
|
+
skip: false,
|
|
76
|
+
additionalInstructions: '',
|
|
77
|
+
additionalMessages: [],
|
|
78
|
+
toolChoice: null,
|
|
79
|
+
metadata: context.metadata || {},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (hooks.length === 0) return merged;
|
|
83
|
+
|
|
84
|
+
for (const fn of hooks) {
|
|
85
|
+
const result = await fn(context);
|
|
86
|
+
if (!result) continue;
|
|
87
|
+
|
|
88
|
+
if (result.skip) {
|
|
89
|
+
merged.skip = true;
|
|
90
|
+
return merged;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (result.additionalInstructions) {
|
|
94
|
+
merged.additionalInstructions = merged.additionalInstructions
|
|
95
|
+
? `${merged.additionalInstructions}\n\n${result.additionalInstructions}`
|
|
96
|
+
: result.additionalInstructions;
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(result.additionalMessages) && result.additionalMessages.length > 0) {
|
|
99
|
+
merged.additionalMessages.push(...result.additionalMessages);
|
|
100
|
+
}
|
|
101
|
+
if (result.toolChoice) {
|
|
102
|
+
merged.toolChoice = result.toolChoice;
|
|
103
|
+
}
|
|
104
|
+
if (result.metadata && typeof result.metadata === 'object') {
|
|
105
|
+
Object.assign(context.metadata, result.metadata);
|
|
106
|
+
merged.metadata = context.metadata;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return merged;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_runPostProcessHooks(context, result, error) {
|
|
114
|
+
const hooks = this._hooks.postProcess;
|
|
115
|
+
if (hooks.length === 0) return;
|
|
116
|
+
|
|
117
|
+
setImmediate(() => {
|
|
118
|
+
const postContext = { chatId: context.chatId, type: context.type };
|
|
119
|
+
if (result) postContext.result = result;
|
|
120
|
+
if (error) postContext.error = error;
|
|
121
|
+
|
|
122
|
+
// onBatchError: error only. postBatch: always (original used finally block).
|
|
123
|
+
// postProcess and untagged: always.
|
|
124
|
+
const applicable = hooks.filter(fn => {
|
|
125
|
+
const meta = this._hookMeta.get(fn);
|
|
126
|
+
if (meta?.originalName === 'onBatchError') return !!error;
|
|
127
|
+
return true;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
Promise.allSettled(applicable.map(fn => fn(postContext)))
|
|
131
|
+
.then(results => {
|
|
132
|
+
for (const r of results) {
|
|
133
|
+
if (r.status === 'rejected') {
|
|
134
|
+
logger.error('[ProcessingPipeline] postProcess hook failed', {
|
|
135
|
+
chatId: context.chatId,
|
|
136
|
+
error: r.reason?.message
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { ProcessingPipeline };
|
package/lib/index.d.ts
CHANGED
|
@@ -244,6 +244,7 @@ declare module '@peopl-health/nexus' {
|
|
|
244
244
|
processIncomingMessage(messageData: MessageData): Promise<any>;
|
|
245
245
|
getEventBus(): import('events').EventEmitter;
|
|
246
246
|
getBatchingManager(): BatchingManager;
|
|
247
|
+
getPipeline(): ProcessingPipeline;
|
|
247
248
|
getAssistantProcessor(): AssistantProcessor;
|
|
248
249
|
isConnected(): boolean;
|
|
249
250
|
disconnect(): Promise<void>;
|
|
@@ -495,12 +496,13 @@ declare module '@peopl-health/nexus' {
|
|
|
495
496
|
export function getPatientMemory(): any;
|
|
496
497
|
export function getConversationSummary(): any;
|
|
497
498
|
|
|
498
|
-
// BatchingManager
|
|
499
|
+
// BatchingManager — per-chatId concurrency control
|
|
499
500
|
export interface BatchingConfig {
|
|
500
501
|
enabled?: boolean;
|
|
501
502
|
abortOnNewMessage?: boolean;
|
|
502
503
|
immediateRestart?: boolean;
|
|
503
504
|
typingIndicator?: boolean;
|
|
505
|
+
hooks?: Record<string, Function | Function[]>;
|
|
504
506
|
}
|
|
505
507
|
|
|
506
508
|
export class BatchingManager {
|
|
@@ -510,11 +512,43 @@ declare module '@peopl-health/nexus' {
|
|
|
510
512
|
});
|
|
511
513
|
setProvider(provider: MessageProvider): void;
|
|
512
514
|
isProcessing(chatId: string): boolean;
|
|
515
|
+
isActiveRun(chatId: string, runId: string): boolean;
|
|
513
516
|
handleBatchedProcessing(
|
|
514
517
|
chatId: string,
|
|
515
518
|
processingFn: (runId: string) => Promise<any>,
|
|
516
519
|
sendResponseFn: (result: any) => Promise<void>
|
|
517
520
|
): Promise<void>;
|
|
521
|
+
enqueueProcessing(
|
|
522
|
+
chatId: string,
|
|
523
|
+
processingFn: (runId: string) => Promise<any>,
|
|
524
|
+
sendResponseFn: (result: any) => Promise<void>
|
|
525
|
+
): Promise<void>;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ProcessingPipeline — hook lifecycle for all LLM operations
|
|
529
|
+
export interface PreProcessResult {
|
|
530
|
+
skip?: boolean;
|
|
531
|
+
additionalInstructions?: string;
|
|
532
|
+
additionalMessages?: Array<Record<string, any>>;
|
|
533
|
+
toolChoice?: Record<string, any> | null;
|
|
534
|
+
metadata?: Record<string, any>;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export interface ProcessingContext {
|
|
538
|
+
chatId: string;
|
|
539
|
+
runId: string;
|
|
540
|
+
type: string;
|
|
541
|
+
metadata?: Record<string, any>;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export class ProcessingPipeline {
|
|
545
|
+
constructor(hooks?: Record<string, Function | Function[]>);
|
|
546
|
+
addHook(name: string, fn: Function): this;
|
|
547
|
+
run(
|
|
548
|
+
context: ProcessingContext,
|
|
549
|
+
executeFn: (preProcessResult: PreProcessResult, shouldContinue: () => boolean) => Promise<any>,
|
|
550
|
+
shouldContinue?: () => boolean
|
|
551
|
+
): Promise<any>;
|
|
518
552
|
}
|
|
519
553
|
|
|
520
554
|
// AssistantProcessor
|