@yeaft/webchat-agent 0.1.485 → 0.1.486
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 +1 -1
- package/unify/router/intent-classifier.js +444 -0
- package/unify/session.js +7 -0
package/package.json
CHANGED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* intent-classifier.js — task-309 (Phase 2 Router).
|
|
3
|
+
*
|
|
4
|
+
* Routes an incoming user message to one of four intents relative to the
|
|
5
|
+
* current set of live threads + pending tasks:
|
|
6
|
+
*
|
|
7
|
+
* - 'continue' — append to currentThreadId (default / most common)
|
|
8
|
+
* - 'interrupt' — steal focus on another LIVE thread (e.g. user replies
|
|
9
|
+
* while another thread is mid-stream)
|
|
10
|
+
* - 'fork' — spawn a NEW thread from the current one
|
|
11
|
+
* - 'switch' — re-focus on a different existing thread
|
|
12
|
+
*
|
|
13
|
+
* ### Routing pipeline
|
|
14
|
+
*
|
|
15
|
+
* 1. **Explicit signal parse** (no LLM):
|
|
16
|
+
* - Prefix `@thread-<id>` → switch/interrupt that thread (direct).
|
|
17
|
+
* - Prefix `@task-<nnn>` → switch to the thread attached to that task,
|
|
18
|
+
* if any; otherwise fall through to LLM.
|
|
19
|
+
* 2. **User override lookup**: if UI previously called `.override(msgId,…)`
|
|
20
|
+
* for this message, return that decision verbatim.
|
|
21
|
+
* 3. **LLM classification**: one call to `primaryModel` (Q2 — router also
|
|
22
|
+
* uses primary; fast-model route disabled for this phase) with a small
|
|
23
|
+
* JSON-only prompt. Parse `{action, targetThreadId, reason}`.
|
|
24
|
+
* 4. **Fallback**: on ANY exception, unknown action, or unknown
|
|
25
|
+
* targetThreadId → degrade to `continue` on the current thread and
|
|
26
|
+
* record a `router.failure` trace event.
|
|
27
|
+
*
|
|
28
|
+
* ### Out of scope (task-310)
|
|
29
|
+
*
|
|
30
|
+
* - user_input_queue storage of pending messages.
|
|
31
|
+
* - Actual dispatch to an EngineInstance (the router just decides WHERE;
|
|
32
|
+
* task-310 owns the WHO/WHEN).
|
|
33
|
+
* - Concurrent stream flush-back semantics.
|
|
34
|
+
*
|
|
35
|
+
* This module only exposes `classify()` + `override()`; the caller owns the
|
|
36
|
+
* registry routing after the decision is returned.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {'continue'|'interrupt'|'fork'|'switch'} RouterAction
|
|
41
|
+
*
|
|
42
|
+
* @typedef {Object} RouterDecision
|
|
43
|
+
* @property {RouterAction} action
|
|
44
|
+
* @property {string} targetThreadId — resolved thread (always defined; for
|
|
45
|
+
* 'fork' this is the PARENT thread, the actual new-thread id is chosen
|
|
46
|
+
* by the caller when it creates the thread)
|
|
47
|
+
* @property {string} reason — short human-readable explanation
|
|
48
|
+
* @property {'explicit'|'override'|'llm'|'fallback'} [source]
|
|
49
|
+
*
|
|
50
|
+
* @typedef {Object} ThreadSummary — minimum info the classifier needs
|
|
51
|
+
* @property {string} id
|
|
52
|
+
* @property {string} [name]
|
|
53
|
+
* @property {string} [goal]
|
|
54
|
+
* @property {string} [status]
|
|
55
|
+
*
|
|
56
|
+
* @typedef {Object} PendingTask
|
|
57
|
+
* @property {string} id
|
|
58
|
+
* @property {string} [title]
|
|
59
|
+
* @property {string} [threadId] — attached thread, if any
|
|
60
|
+
* @property {string} [status]
|
|
61
|
+
*
|
|
62
|
+
* @typedef {Object} ClassifyInput
|
|
63
|
+
* @property {string} userMessage
|
|
64
|
+
* @property {string} currentThreadId
|
|
65
|
+
* @property {Array<ThreadSummary>} [allThreads]
|
|
66
|
+
* @property {Array<PendingTask>} [pendingTasks]
|
|
67
|
+
* @property {string} [messageId] — if provided, any stored override for this
|
|
68
|
+
* id is consulted before the LLM path
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
const VALID_ACTIONS = ['continue', 'interrupt', 'fork', 'switch'];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Match a leading `@thread-xxx` marker. Captures the thread id WITHOUT the
|
|
75
|
+
* `@` prefix. Case-sensitive (thread ids are canonical).
|
|
76
|
+
* Example matches: "@thread-main ...", "@thread-abcd1234 ..."
|
|
77
|
+
*/
|
|
78
|
+
const THREAD_PREFIX_RE = /^@(thread-[A-Za-z0-9_-]+)\b\s*/;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Match a leading `@task-NNN` marker. Captures the task id WITHOUT the `@`.
|
|
82
|
+
* Example matches: "@task-309 ...", "@task-abc ..."
|
|
83
|
+
*/
|
|
84
|
+
const TASK_PREFIX_RE = /^@(task-[A-Za-z0-9_-]+)\b\s*/;
|
|
85
|
+
|
|
86
|
+
export class IntentClassifier {
|
|
87
|
+
/** @type {object} */ #adapter;
|
|
88
|
+
/** @type {object} */ #trace;
|
|
89
|
+
/** @type {object} */ #config;
|
|
90
|
+
/** @type {Map<string, RouterDecision>} */ #overrides;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {{
|
|
94
|
+
* adapter: object,
|
|
95
|
+
* trace?: object,
|
|
96
|
+
* config: object,
|
|
97
|
+
* }} deps
|
|
98
|
+
*/
|
|
99
|
+
constructor({ adapter, trace, config } = {}) {
|
|
100
|
+
if (!adapter || typeof adapter.stream !== 'function') {
|
|
101
|
+
throw new Error('IntentClassifier: adapter with .stream() is required');
|
|
102
|
+
}
|
|
103
|
+
if (!config || typeof config !== 'object') {
|
|
104
|
+
throw new Error('IntentClassifier: config is required');
|
|
105
|
+
}
|
|
106
|
+
this.#adapter = adapter;
|
|
107
|
+
this.#trace = trace || null;
|
|
108
|
+
this.#config = config;
|
|
109
|
+
this.#overrides = new Map();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Store a user correction for a specific messageId. Next `.classify()`
|
|
114
|
+
* call with the matching `messageId` will return this decision verbatim
|
|
115
|
+
* (and consume it, so a second call re-enters normal routing).
|
|
116
|
+
*
|
|
117
|
+
* Used by the UI "不对,我是问 X" affordance.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} messageId
|
|
120
|
+
* @param {{ action: RouterAction, targetThreadId: string, reason?: string }} decision
|
|
121
|
+
* @returns {void}
|
|
122
|
+
*/
|
|
123
|
+
override(messageId, decision) {
|
|
124
|
+
if (!messageId || typeof messageId !== 'string') {
|
|
125
|
+
throw new Error('IntentClassifier.override: messageId required');
|
|
126
|
+
}
|
|
127
|
+
if (!decision || !VALID_ACTIONS.includes(decision.action)) {
|
|
128
|
+
throw new Error(`IntentClassifier.override: invalid action ${decision && decision.action}`);
|
|
129
|
+
}
|
|
130
|
+
if (!decision.targetThreadId || typeof decision.targetThreadId !== 'string') {
|
|
131
|
+
throw new Error('IntentClassifier.override: targetThreadId required');
|
|
132
|
+
}
|
|
133
|
+
this.#overrides.set(messageId, {
|
|
134
|
+
action: decision.action,
|
|
135
|
+
targetThreadId: decision.targetThreadId,
|
|
136
|
+
reason: decision.reason || 'user_override',
|
|
137
|
+
source: 'override',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Whether an override is currently stored for a given messageId. */
|
|
142
|
+
hasOverride(messageId) {
|
|
143
|
+
return this.#overrides.has(messageId);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Test-only / admin — drop all stored overrides. */
|
|
147
|
+
clearOverrides() {
|
|
148
|
+
this.#overrides.clear();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Classify the user message into a router decision.
|
|
153
|
+
*
|
|
154
|
+
* Resolution order: override → explicit @prefix → LLM → fallback.
|
|
155
|
+
*
|
|
156
|
+
* @param {ClassifyInput} input
|
|
157
|
+
* @returns {Promise<RouterDecision>}
|
|
158
|
+
*/
|
|
159
|
+
async classify(input) {
|
|
160
|
+
const {
|
|
161
|
+
userMessage,
|
|
162
|
+
currentThreadId,
|
|
163
|
+
allThreads = [],
|
|
164
|
+
pendingTasks = [],
|
|
165
|
+
messageId,
|
|
166
|
+
} = input || {};
|
|
167
|
+
|
|
168
|
+
if (!userMessage || typeof userMessage !== 'string') {
|
|
169
|
+
throw new Error('classify: userMessage is required');
|
|
170
|
+
}
|
|
171
|
+
if (!currentThreadId || typeof currentThreadId !== 'string') {
|
|
172
|
+
throw new Error('classify: currentThreadId is required');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 1. User override takes precedence over everything else.
|
|
176
|
+
if (messageId && this.#overrides.has(messageId)) {
|
|
177
|
+
const decision = this.#overrides.get(messageId);
|
|
178
|
+
this.#overrides.delete(messageId);
|
|
179
|
+
return { ...decision, source: 'override' };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 2. Explicit signals (no LLM call).
|
|
183
|
+
const explicit = this.#parseExplicit(userMessage, {
|
|
184
|
+
currentThreadId, allThreads, pendingTasks,
|
|
185
|
+
});
|
|
186
|
+
if (explicit) return explicit;
|
|
187
|
+
|
|
188
|
+
// 3. LLM classification (best-effort).
|
|
189
|
+
try {
|
|
190
|
+
const decision = await this.#classifyWithLLM({
|
|
191
|
+
userMessage, currentThreadId, allThreads, pendingTasks,
|
|
192
|
+
});
|
|
193
|
+
return this.#validateOrFallback(decision, {
|
|
194
|
+
currentThreadId, allThreads, reason: 'llm',
|
|
195
|
+
});
|
|
196
|
+
} catch (err) {
|
|
197
|
+
this.#traceFailure(err, { userMessage, currentThreadId });
|
|
198
|
+
return this.#fallback(currentThreadId, `classifier_exception: ${err.message}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ──────────────────────────────────────────────────────────────
|
|
203
|
+
// Explicit-signal parser
|
|
204
|
+
// ──────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {string} msg
|
|
208
|
+
* @param {{ currentThreadId: string, allThreads: Array<ThreadSummary>, pendingTasks: Array<PendingTask> }} ctx
|
|
209
|
+
* @returns {RouterDecision|null}
|
|
210
|
+
*/
|
|
211
|
+
#parseExplicit(msg, { currentThreadId, allThreads, pendingTasks }) {
|
|
212
|
+
const trimmed = msg.replace(/^\s+/, '');
|
|
213
|
+
|
|
214
|
+
// @thread-xxx
|
|
215
|
+
const tm = trimmed.match(THREAD_PREFIX_RE);
|
|
216
|
+
if (tm) {
|
|
217
|
+
const targetId = tm[1];
|
|
218
|
+
const known = allThreads.some(t => t && t.id === targetId);
|
|
219
|
+
if (!known) {
|
|
220
|
+
// Unknown thread — silently degrade. Record trace so ops can see it.
|
|
221
|
+
this.#traceFailure(
|
|
222
|
+
new Error(`unknown thread in @prefix: ${targetId}`),
|
|
223
|
+
{ userMessage: msg, currentThreadId },
|
|
224
|
+
);
|
|
225
|
+
return this.#fallback(currentThreadId, `unknown_thread:${targetId}`);
|
|
226
|
+
}
|
|
227
|
+
const action = targetId === currentThreadId ? 'continue' : 'switch';
|
|
228
|
+
return {
|
|
229
|
+
action,
|
|
230
|
+
targetThreadId: targetId,
|
|
231
|
+
reason: `explicit @${targetId}`,
|
|
232
|
+
source: 'explicit',
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// @task-NNN
|
|
237
|
+
const mt = trimmed.match(TASK_PREFIX_RE);
|
|
238
|
+
if (mt) {
|
|
239
|
+
const taskId = mt[1];
|
|
240
|
+
const task = pendingTasks.find(t => t && t.id === taskId);
|
|
241
|
+
if (task && task.threadId) {
|
|
242
|
+
const action = task.threadId === currentThreadId ? 'continue' : 'switch';
|
|
243
|
+
return {
|
|
244
|
+
action,
|
|
245
|
+
targetThreadId: task.threadId,
|
|
246
|
+
reason: `explicit @${taskId} → ${task.threadId}`,
|
|
247
|
+
source: 'explicit',
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
// Task unknown or not attached — fall through to LLM.
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ──────────────────────────────────────────────────────────────
|
|
258
|
+
// LLM classification path
|
|
259
|
+
// ──────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
/** Build the prompt/messages for the router LLM call. */
|
|
262
|
+
#buildMessages({ userMessage, currentThreadId, allThreads, pendingTasks }) {
|
|
263
|
+
const system = [
|
|
264
|
+
'You are a thread-routing classifier for a multi-thread AI chat.',
|
|
265
|
+
'Given the user message and current thread context, pick exactly one action:',
|
|
266
|
+
" - 'continue' — the message belongs to the current thread",
|
|
267
|
+
" - 'interrupt' — it answers / redirects a DIFFERENT live thread",
|
|
268
|
+
" - 'fork' — it starts a new tangent that should be its own thread",
|
|
269
|
+
" - 'switch' — it explicitly re-focuses on another existing thread",
|
|
270
|
+
'',
|
|
271
|
+
'Respond with ONE LINE of JSON, nothing else:',
|
|
272
|
+
'{"action":"<action>","targetThreadId":"<id>","reason":"<short>"}',
|
|
273
|
+
'',
|
|
274
|
+
'Rules:',
|
|
275
|
+
'- For fork, set targetThreadId to the CURRENT thread (it is the parent).',
|
|
276
|
+
'- For continue, set targetThreadId to the CURRENT thread.',
|
|
277
|
+
'- For switch/interrupt, targetThreadId MUST be one of the known thread ids.',
|
|
278
|
+
'- If uncertain, pick continue.',
|
|
279
|
+
].join('\n');
|
|
280
|
+
|
|
281
|
+
const ctx = {
|
|
282
|
+
currentThreadId,
|
|
283
|
+
threads: (allThreads || []).map(t => ({
|
|
284
|
+
id: t.id,
|
|
285
|
+
name: t.name || '',
|
|
286
|
+
goal: t.goal || '',
|
|
287
|
+
status: t.status || 'active',
|
|
288
|
+
})),
|
|
289
|
+
pendingTasks: (pendingTasks || []).map(t => ({
|
|
290
|
+
id: t.id,
|
|
291
|
+
title: t.title || '',
|
|
292
|
+
threadId: t.threadId || null,
|
|
293
|
+
})),
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const user = [
|
|
297
|
+
'Context:',
|
|
298
|
+
JSON.stringify(ctx),
|
|
299
|
+
'',
|
|
300
|
+
'User message:',
|
|
301
|
+
userMessage,
|
|
302
|
+
].join('\n');
|
|
303
|
+
|
|
304
|
+
return { system, messages: [{ role: 'user', content: user }] };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** @returns {Promise<RouterDecision>} */
|
|
308
|
+
async #classifyWithLLM({ userMessage, currentThreadId, allThreads, pendingTasks }) {
|
|
309
|
+
const { system, messages } = this.#buildMessages({
|
|
310
|
+
userMessage, currentThreadId, allThreads, pendingTasks,
|
|
311
|
+
});
|
|
312
|
+
// Q2: router uses primaryModel (no fast-model split yet).
|
|
313
|
+
const model = this.#config.primaryModel || this.#config.model;
|
|
314
|
+
if (!model) {
|
|
315
|
+
throw new Error('router: no primaryModel configured');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let text = '';
|
|
319
|
+
for await (const event of this.#adapter.stream({
|
|
320
|
+
model,
|
|
321
|
+
system,
|
|
322
|
+
messages,
|
|
323
|
+
maxTokens: 256,
|
|
324
|
+
})) {
|
|
325
|
+
if (event && event.type === 'text_delta' && typeof event.text === 'string') {
|
|
326
|
+
text += event.text;
|
|
327
|
+
} else if (event && event.type === 'error') {
|
|
328
|
+
throw event.error || new Error('router stream error');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return parseLLMDecision(text);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ──────────────────────────────────────────────────────────────
|
|
335
|
+
// Validation & fallback
|
|
336
|
+
// ──────────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
#validateOrFallback(decision, { currentThreadId, allThreads, reason }) {
|
|
339
|
+
if (!decision || !VALID_ACTIONS.includes(decision.action)) {
|
|
340
|
+
this.#traceFailure(
|
|
341
|
+
new Error(`invalid action from classifier: ${decision && decision.action}`),
|
|
342
|
+
{ currentThreadId },
|
|
343
|
+
);
|
|
344
|
+
return this.#fallback(currentThreadId, 'invalid_action');
|
|
345
|
+
}
|
|
346
|
+
const known = new Set((allThreads || []).map(t => t && t.id).filter(Boolean));
|
|
347
|
+
known.add(currentThreadId);
|
|
348
|
+
|
|
349
|
+
// For fork/continue the target MUST be the current thread parent (we
|
|
350
|
+
// allow any known thread since callers may want to fork from a
|
|
351
|
+
// non-current parent, but continue MUST land on current).
|
|
352
|
+
if (decision.action === 'continue' && decision.targetThreadId !== currentThreadId) {
|
|
353
|
+
decision.targetThreadId = currentThreadId;
|
|
354
|
+
}
|
|
355
|
+
if (!decision.targetThreadId || !known.has(decision.targetThreadId)) {
|
|
356
|
+
this.#traceFailure(
|
|
357
|
+
new Error(`unknown targetThreadId: ${decision.targetThreadId}`),
|
|
358
|
+
{ currentThreadId },
|
|
359
|
+
);
|
|
360
|
+
return this.#fallback(currentThreadId, 'unknown_target');
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
action: decision.action,
|
|
364
|
+
targetThreadId: decision.targetThreadId,
|
|
365
|
+
reason: decision.reason || reason,
|
|
366
|
+
source: 'llm',
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** @returns {RouterDecision} */
|
|
371
|
+
#fallback(currentThreadId, reason) {
|
|
372
|
+
return {
|
|
373
|
+
action: 'continue',
|
|
374
|
+
targetThreadId: currentThreadId,
|
|
375
|
+
reason,
|
|
376
|
+
source: 'fallback',
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
#traceFailure(err, ctx) {
|
|
381
|
+
if (!this.#trace || typeof this.#trace.logEvent !== 'function') return;
|
|
382
|
+
try {
|
|
383
|
+
this.#trace.logEvent({
|
|
384
|
+
traceId: 'router',
|
|
385
|
+
eventType: 'router.failure',
|
|
386
|
+
eventData: {
|
|
387
|
+
error: err && err.message ? err.message : String(err),
|
|
388
|
+
currentThreadId: ctx && ctx.currentThreadId,
|
|
389
|
+
userMessage: ctx && typeof ctx.userMessage === 'string'
|
|
390
|
+
? ctx.userMessage.slice(0, 200)
|
|
391
|
+
: undefined,
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
} catch {
|
|
395
|
+
// Trace must never propagate errors into the router path.
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Parse the LLM's single-line JSON response. Tolerates a leading/trailing
|
|
402
|
+
* code fence ```json ... ``` because some proxies wrap.
|
|
403
|
+
*
|
|
404
|
+
* @param {string} raw
|
|
405
|
+
* @returns {RouterDecision}
|
|
406
|
+
*/
|
|
407
|
+
export function parseLLMDecision(raw) {
|
|
408
|
+
if (!raw || typeof raw !== 'string') {
|
|
409
|
+
throw new Error('empty classifier response');
|
|
410
|
+
}
|
|
411
|
+
let text = raw.trim();
|
|
412
|
+
// Strip ```json fences if present.
|
|
413
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
|
414
|
+
if (fenced) text = fenced[1].trim();
|
|
415
|
+
// Take the first { ... } block.
|
|
416
|
+
const start = text.indexOf('{');
|
|
417
|
+
const end = text.lastIndexOf('}');
|
|
418
|
+
if (start < 0 || end <= start) {
|
|
419
|
+
throw new Error('no JSON object in classifier response');
|
|
420
|
+
}
|
|
421
|
+
const slice = text.slice(start, end + 1);
|
|
422
|
+
let obj;
|
|
423
|
+
try {
|
|
424
|
+
obj = JSON.parse(slice);
|
|
425
|
+
} catch (e) {
|
|
426
|
+
throw new Error(`classifier response not valid JSON: ${e.message}`);
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
action: obj.action,
|
|
430
|
+
targetThreadId: obj.targetThreadId,
|
|
431
|
+
reason: obj.reason || '',
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Build an IntentClassifier from session-level deps. This is the entry
|
|
437
|
+
* point used by session.js to populate `session.router`.
|
|
438
|
+
*
|
|
439
|
+
* @param {{ adapter: object, trace?: object, config: object }} deps
|
|
440
|
+
* @returns {IntentClassifier}
|
|
441
|
+
*/
|
|
442
|
+
export function createIntentClassifier(deps) {
|
|
443
|
+
return new IntentClassifier(deps);
|
|
444
|
+
}
|
package/unify/session.js
CHANGED
|
@@ -27,6 +27,7 @@ import { initThreadStore } from './threads/store.js';
|
|
|
27
27
|
import { Engine } from './engine.js';
|
|
28
28
|
import { createThreadEngineRegistry } from './threads/engine-registry.js';
|
|
29
29
|
import { MAIN_THREAD_ID } from './threads/store.js';
|
|
30
|
+
import { createIntentClassifier } from './router/intent-classifier.js';
|
|
30
31
|
import { join } from 'path';
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -187,6 +188,11 @@ export async function loadSession(options = {}) {
|
|
|
187
188
|
// Seed the main-thread instance so listActive() is non-empty from T=0.
|
|
188
189
|
engineRegistry.ensure(MAIN_THREAD_ID);
|
|
189
190
|
|
|
191
|
+
// task-309 Phase 2 router: intent classifier that routes incoming user
|
|
192
|
+
// messages to the right EngineInstance. Shares the same adapter/trace/
|
|
193
|
+
// config as the engines so it can use primaryModel for classification.
|
|
194
|
+
const router = createIntentClassifier({ adapter, trace, config });
|
|
195
|
+
|
|
190
196
|
// ─── 10. Build session ─────────────────────────────────
|
|
191
197
|
const status = {
|
|
192
198
|
skills: skillManager.size,
|
|
@@ -217,6 +223,7 @@ export async function loadSession(options = {}) {
|
|
|
217
223
|
return {
|
|
218
224
|
engine,
|
|
219
225
|
engineRegistry,
|
|
226
|
+
router,
|
|
220
227
|
adapter,
|
|
221
228
|
config,
|
|
222
229
|
conversationStore,
|