@yeaft/webchat-agent 0.1.654 → 0.1.656
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/connection/message-router.js +4 -12
- package/package.json +1 -1
- package/unify/engine.js +8 -29
- package/unify/session.js +11 -80
- package/unify/web-bridge.js +15 -93
- package/unify/input-queue/store.js +0 -322
- package/unify/pipeline/dispatcher.js +0 -196
- package/unify/threads/engine-instance.js +0 -312
- package/unify/threads/engine-registry.js +0 -260
- package/unify/threads/store.js +0 -886
|
@@ -1,312 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* engine-instance.js — task-308 Phase 2.
|
|
3
|
-
*
|
|
4
|
-
* An EngineInstance binds a single `threadId` to an Engine + an independent
|
|
5
|
-
* per-thread `messages` array + an independent memory scope ref. Multiple
|
|
6
|
-
* EngineInstance objects can run .query() concurrently on the same unify
|
|
7
|
-
* session without cross-contaminating state:
|
|
8
|
-
*
|
|
9
|
-
* - The underlying Engine's query loop is a pure async generator that
|
|
10
|
-
* takes `messages` as a parameter — it holds no mutable turn state on
|
|
11
|
-
* `this` during a run, so concurrent generators cannot alias each
|
|
12
|
-
* other's conversation or tool-call arrays.
|
|
13
|
-
* - All yielded events are re-tagged with the instance's bound
|
|
14
|
-
* `threadId` (not with the global current-thread marker from the
|
|
15
|
-
* singleton ThreadStore), so the web-bridge can route them to the
|
|
16
|
-
* right pane even while several threads stream simultaneously.
|
|
17
|
-
* - `messages` is owned by the instance: user/assistant messages
|
|
18
|
-
* appended during a query are persisted to the instance's own array,
|
|
19
|
-
* not to a global.
|
|
20
|
-
*
|
|
21
|
-
* Memory scope: Phase 2 design doc §6 — the memory store is shared across
|
|
22
|
-
* threads (one user, one brain), but the EngineInstance carries a
|
|
23
|
-
* `memoryScope` ref that can later be used to namespace recall/query
|
|
24
|
-
* results by thread. Today the ref is the threadId itself; downstream
|
|
25
|
-
* memory adapters can opt in.
|
|
26
|
-
*
|
|
27
|
-
* Q2 decision (PM brief): all threads use session primaryModel. No
|
|
28
|
-
* per-thread model override is accepted.
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
import { Engine } from '../engine.js';
|
|
32
|
-
import { MAIN_THREAD_ID } from './store.js';
|
|
33
|
-
|
|
34
|
-
export class EngineInstance {
|
|
35
|
-
/** @type {string} */
|
|
36
|
-
#threadId;
|
|
37
|
-
|
|
38
|
-
/** @type {Engine} */
|
|
39
|
-
#engine;
|
|
40
|
-
|
|
41
|
-
/** @type {Array<object>} owned per-thread conversation messages */
|
|
42
|
-
#messages;
|
|
43
|
-
|
|
44
|
-
/** @type {string} memory scope ref — today simply the threadId */
|
|
45
|
-
#memoryScope;
|
|
46
|
-
|
|
47
|
-
/** @type {boolean} */
|
|
48
|
-
#terminated = false;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* @param {{
|
|
52
|
-
* threadId: string,
|
|
53
|
-
* engine: Engine,
|
|
54
|
-
* memoryScope?: string,
|
|
55
|
-
* initialMessages?: Array<object>,
|
|
56
|
-
* }} params
|
|
57
|
-
*/
|
|
58
|
-
constructor({ threadId, engine, memoryScope, initialMessages }) {
|
|
59
|
-
if (!threadId || typeof threadId !== 'string') {
|
|
60
|
-
throw new Error('EngineInstance: threadId is required');
|
|
61
|
-
}
|
|
62
|
-
if (!engine) {
|
|
63
|
-
throw new Error('EngineInstance: engine is required');
|
|
64
|
-
}
|
|
65
|
-
this.#threadId = threadId;
|
|
66
|
-
this.#engine = engine;
|
|
67
|
-
this.#memoryScope = memoryScope || threadId;
|
|
68
|
-
this.#messages = Array.isArray(initialMessages) ? [...initialMessages] : [];
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** @returns {string} */
|
|
72
|
-
get threadId() { return this.#threadId; }
|
|
73
|
-
|
|
74
|
-
/** @returns {string} */
|
|
75
|
-
get memoryScope() { return this.#memoryScope; }
|
|
76
|
-
|
|
77
|
-
/** @returns {boolean} */
|
|
78
|
-
get terminated() { return this.#terminated; }
|
|
79
|
-
|
|
80
|
-
/** Number of messages recorded on this instance. */
|
|
81
|
-
get messageCount() { return this.#messages.length; }
|
|
82
|
-
|
|
83
|
-
/** Snapshot of the current messages array (copy, safe for callers). */
|
|
84
|
-
get messages() { return [...this.#messages]; }
|
|
85
|
-
|
|
86
|
-
/** Underlying Engine (for tool registration, trace access, etc.). */
|
|
87
|
-
get engine() { return this.#engine; }
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Run a query on this thread's engine. Yields events tagged with this
|
|
91
|
-
* instance's bound threadId. After the run, user + assistant messages
|
|
92
|
-
* are appended to the owned messages array.
|
|
93
|
-
*
|
|
94
|
-
* @param {object} params
|
|
95
|
-
* @param {string} params.prompt
|
|
96
|
-
* @param {'dream'} [params.mode]
|
|
97
|
-
* @param {AbortSignal} [params.signal]
|
|
98
|
-
* @yields {object} EngineEvent with { ...event, threadId }
|
|
99
|
-
*/
|
|
100
|
-
async *query({ prompt, mode, signal, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan } = {}) {
|
|
101
|
-
if (this.#terminated) {
|
|
102
|
-
yield {
|
|
103
|
-
type: 'error',
|
|
104
|
-
threadId: this.#threadId,
|
|
105
|
-
error: new Error(`EngineInstance(${this.#threadId}) has been terminated`),
|
|
106
|
-
retryable: false,
|
|
107
|
-
};
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Snapshot of messages passed to the engine; the engine treats this
|
|
112
|
-
// as read-only (it builds its own conversation array internally).
|
|
113
|
-
const snapshot = [...this.#messages];
|
|
114
|
-
|
|
115
|
-
// task-fix: chat-completions protocol requires every `tool_call_id`
|
|
116
|
-
// on an assistant message to be paired with a matching `role:'tool'`
|
|
117
|
-
// message in history. Without this, turn N+1 sends `tool_calls`
|
|
118
|
-
// orphaned from their results and OpenAI-compatible proxies return
|
|
119
|
-
// `invalid_request_body: No tool output found for function call`.
|
|
120
|
-
//
|
|
121
|
-
// A single query may contain MULTIPLE internal iterations (assistant
|
|
122
|
-
// → tools → assistant → tools → … → assistant-final). We mirror
|
|
123
|
-
// engine.js's own conversationMessages structure so the same
|
|
124
|
-
// interleaved pairing is preserved for subsequent turns:
|
|
125
|
-
//
|
|
126
|
-
// [user, assistant(text1, toolCalls1), tool r1a, tool r1b,
|
|
127
|
-
// assistant(text2, toolCalls2), tool r2a,
|
|
128
|
-
// assistant(finalText)]
|
|
129
|
-
//
|
|
130
|
-
// We flush one assistant message per `turn_end` boundary and
|
|
131
|
-
// append tool results as they stream in. Any assistant turn with
|
|
132
|
-
// toolCalls must have all its `role:'tool'` results paired before
|
|
133
|
-
// the NEXT assistant message (or placeholders — see below).
|
|
134
|
-
const newMessages = [];
|
|
135
|
-
let curText = '';
|
|
136
|
-
let curToolCalls = [];
|
|
137
|
-
let curToolResults = []; // buffered per-iteration, flushed AFTER assistant
|
|
138
|
-
const seenToolResults = new Set();
|
|
139
|
-
|
|
140
|
-
function flushAssistantTurn() {
|
|
141
|
-
// Emit the assistant message for the current iteration. Preserve
|
|
142
|
-
// an empty-content assistant (pure tool_calls) — some providers
|
|
143
|
-
// require content:'' rather than omission. The chat-completions
|
|
144
|
-
// adapter normalises either shape.
|
|
145
|
-
const assistantMsg = { role: 'assistant', content: curText };
|
|
146
|
-
if (curToolCalls.length > 0) {
|
|
147
|
-
assistantMsg.toolCalls = curToolCalls.map(tc => ({
|
|
148
|
-
id: tc.id, name: tc.name, input: tc.input,
|
|
149
|
-
}));
|
|
150
|
-
}
|
|
151
|
-
// Skip empty / no-op flushes (can happen on pre-first-turn boundaries).
|
|
152
|
-
if (curText || curToolCalls.length > 0) {
|
|
153
|
-
newMessages.push(assistantMsg);
|
|
154
|
-
}
|
|
155
|
-
// Any buffered tool results for THIS iteration must immediately
|
|
156
|
-
// follow the assistant that produced them — the adapter's history
|
|
157
|
-
// serialiser pairs by order-in-history.
|
|
158
|
-
for (const tr of curToolResults) newMessages.push(tr);
|
|
159
|
-
// Synthesize placeholders for unmatched toolCalls (abort paths).
|
|
160
|
-
for (const tc of curToolCalls) {
|
|
161
|
-
if (!seenToolResults.has(tc.id)) {
|
|
162
|
-
newMessages.push({
|
|
163
|
-
role: 'tool',
|
|
164
|
-
toolCallId: tc.id,
|
|
165
|
-
content: '[tool call did not produce a result — aborted or errored before completion]',
|
|
166
|
-
isError: true,
|
|
167
|
-
});
|
|
168
|
-
seenToolResults.add(tc.id);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
curText = '';
|
|
172
|
-
curToolCalls = [];
|
|
173
|
-
curToolResults = [];
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
for await (const event of this.#engine.query({ prompt, mode, messages: snapshot, signal, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan })) {
|
|
177
|
-
// Re-tag every event with the bound threadId. Non-object events
|
|
178
|
-
// (shouldn't happen — all engine events are objects) are passed
|
|
179
|
-
// through untouched.
|
|
180
|
-
const tagged = event && typeof event === 'object'
|
|
181
|
-
? { ...event, threadId: this.#threadId }
|
|
182
|
-
: event;
|
|
183
|
-
yield tagged;
|
|
184
|
-
|
|
185
|
-
if (!event || typeof event !== 'object') continue;
|
|
186
|
-
|
|
187
|
-
switch (event.type) {
|
|
188
|
-
case 'text_delta':
|
|
189
|
-
if (typeof event.text === 'string') curText += event.text;
|
|
190
|
-
break;
|
|
191
|
-
case 'tool_call':
|
|
192
|
-
curToolCalls.push({ id: event.id, name: event.name, input: event.input });
|
|
193
|
-
break;
|
|
194
|
-
case 'tool_end':
|
|
195
|
-
if (event.id) {
|
|
196
|
-
// Mirror engine.js: tool result body is the `output` string;
|
|
197
|
-
// `isError:true` is carried forward. BUFFER here — flush
|
|
198
|
-
// places the assistant message FIRST, then these results,
|
|
199
|
-
// so the order [assistant(toolCalls), tool r1, tool r2]
|
|
200
|
-
// holds (required by OpenAI pairing rules).
|
|
201
|
-
const entry = {
|
|
202
|
-
role: 'tool',
|
|
203
|
-
toolCallId: event.id,
|
|
204
|
-
content: typeof event.output === 'string' ? event.output : String(event.output ?? ''),
|
|
205
|
-
};
|
|
206
|
-
if (event.isError) entry.isError = true;
|
|
207
|
-
curToolResults.push(entry);
|
|
208
|
-
seenToolResults.add(event.id);
|
|
209
|
-
}
|
|
210
|
-
break;
|
|
211
|
-
case 'turn_end':
|
|
212
|
-
// Boundary between internal iterations. engine.js order is:
|
|
213
|
-
// [text_delta*] [tool_call*] [tool_start tool_end]*
|
|
214
|
-
// then turn_end{stopReason:'tool_use'} (or 'end_turn')
|
|
215
|
-
// Flushing here writes the assistant message, then its
|
|
216
|
-
// buffered tool results, then placeholders for any orphans.
|
|
217
|
-
flushAssistantTurn();
|
|
218
|
-
break;
|
|
219
|
-
default:
|
|
220
|
-
break;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Final safety flush — if the engine terminated without a final
|
|
225
|
-
// turn_end (shouldn't happen in normal flows, but abort/error
|
|
226
|
-
// paths sometimes skip it), flush whatever we have.
|
|
227
|
-
if (curText || curToolCalls.length > 0 || curToolResults.length > 0) {
|
|
228
|
-
flushAssistantTurn();
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Append user + all captured messages to the owned array so
|
|
232
|
-
// subsequent queries on this thread carry conversational context.
|
|
233
|
-
this.#messages.push({ role: 'user', content: prompt });
|
|
234
|
-
for (const m of newMessages) {
|
|
235
|
-
this.#messages.push(m);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Terminate this engine instance. Further .query() calls will emit an
|
|
241
|
-
* error event and return early. Does NOT tear down the underlying
|
|
242
|
-
* Engine (engines are shared across instances via composition from
|
|
243
|
-
* the registry — only the instance's per-thread state is dropped).
|
|
244
|
-
*/
|
|
245
|
-
terminate() {
|
|
246
|
-
this.#terminated = true;
|
|
247
|
-
this.#messages = [];
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Reset the owned messages array. Used by the registry for crash
|
|
252
|
-
* recovery / test cleanup. Does NOT terminate the instance.
|
|
253
|
-
* @param {Array<object>} [messages=[]]
|
|
254
|
-
*/
|
|
255
|
-
resetMessages(messages = []) {
|
|
256
|
-
this.#messages = Array.isArray(messages) ? [...messages] : [];
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* Factory helper — builds an EngineInstance that owns a fresh Engine,
|
|
262
|
-
* sharing the given dependency bag across all threads of a session.
|
|
263
|
-
*
|
|
264
|
-
* @param {{
|
|
265
|
-
* threadId: string,
|
|
266
|
-
* adapter: object,
|
|
267
|
-
* trace: object,
|
|
268
|
-
* config: object,
|
|
269
|
-
* conversationStore?: object,
|
|
270
|
-
* memoryStore?: object,
|
|
271
|
-
* toolRegistry?: object,
|
|
272
|
-
* skillManager?: object,
|
|
273
|
-
* mcpManager?: object,
|
|
274
|
-
* yeaftDir?: string,
|
|
275
|
-
* initialMessages?: Array<object>,
|
|
276
|
-
* }} deps
|
|
277
|
-
* @returns {EngineInstance}
|
|
278
|
-
*/
|
|
279
|
-
export function createEngineInstance(deps) {
|
|
280
|
-
const {
|
|
281
|
-
threadId,
|
|
282
|
-
adapter,
|
|
283
|
-
trace,
|
|
284
|
-
config,
|
|
285
|
-
conversationStore,
|
|
286
|
-
memoryStore,
|
|
287
|
-
memoryShardStore,
|
|
288
|
-
toolRegistry,
|
|
289
|
-
skillManager,
|
|
290
|
-
mcpManager,
|
|
291
|
-
yeaftDir,
|
|
292
|
-
initialMessages,
|
|
293
|
-
} = deps;
|
|
294
|
-
const engine = new Engine({
|
|
295
|
-
adapter,
|
|
296
|
-
trace,
|
|
297
|
-
config,
|
|
298
|
-
conversationStore,
|
|
299
|
-
memoryStore,
|
|
300
|
-
memoryShardStore,
|
|
301
|
-
toolRegistry,
|
|
302
|
-
skillManager,
|
|
303
|
-
mcpManager,
|
|
304
|
-
yeaftDir,
|
|
305
|
-
});
|
|
306
|
-
return new EngineInstance({
|
|
307
|
-
threadId: threadId || MAIN_THREAD_ID,
|
|
308
|
-
engine,
|
|
309
|
-
memoryScope: threadId || MAIN_THREAD_ID,
|
|
310
|
-
initialMessages,
|
|
311
|
-
});
|
|
312
|
-
}
|
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* engine-registry.js — task-308 Phase 2.
|
|
3
|
-
*
|
|
4
|
-
* A ThreadEngineRegistry is a per-session Map<threadId, EngineInstance>.
|
|
5
|
-
* It owns instance lifecycles:
|
|
6
|
-
*
|
|
7
|
-
* - `get(threadId)` returns the existing instance for a thread.
|
|
8
|
-
* - `ensure(threadId, opts)` lazily creates one via the configured
|
|
9
|
-
* factory if it does not yet exist. This is the primary entry point
|
|
10
|
-
* for routing a user message to the correct thread engine.
|
|
11
|
-
* - `listActive()` enumerates non-terminated instances, useful for
|
|
12
|
-
* the web-bridge to show "active threads" indicators.
|
|
13
|
-
* - `terminate(threadId)` tears down a single thread engine without
|
|
14
|
-
* disturbing the rest.
|
|
15
|
-
* - `terminateAll()` is called on session shutdown.
|
|
16
|
-
*
|
|
17
|
-
* The registry holds no LLM/tool state itself — it delegates to the
|
|
18
|
-
* factory, which in production will be the closure over the shared
|
|
19
|
-
* session deps (adapter, trace, config, stores, tool registry, …).
|
|
20
|
-
*
|
|
21
|
-
* Concurrency note: Node's single-threaded event loop means the
|
|
22
|
-
* registry's Map mutations are race-free. Concurrent .query() calls
|
|
23
|
-
* interleave only at await points, and each EngineInstance keeps its
|
|
24
|
-
* per-turn state inside the async generator's local scope, not on
|
|
25
|
-
* `this` — so two threads can stream simultaneously without stepping
|
|
26
|
-
* on each other.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { MAIN_THREAD_ID } from './store.js';
|
|
30
|
-
import { createEngineInstance } from './engine-instance.js';
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Coerce a maxConcurrent input to either a positive integer or null
|
|
34
|
-
* (meaning "no cap"). Keeps the cap logic branch-free elsewhere.
|
|
35
|
-
*/
|
|
36
|
-
function normaliseMaxConcurrent(n) {
|
|
37
|
-
if (n === null || n === undefined) return null;
|
|
38
|
-
const v = Number(n);
|
|
39
|
-
if (!Number.isFinite(v) || v <= 0) return null;
|
|
40
|
-
return Math.floor(v);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export class ThreadEngineRegistry {
|
|
44
|
-
/** @type {Map<string, import('./engine-instance.js').EngineInstance>} */
|
|
45
|
-
#instances;
|
|
46
|
-
|
|
47
|
-
/** @type {(threadId: string, opts?: object) => import('./engine-instance.js').EngineInstance} */
|
|
48
|
-
#factory;
|
|
49
|
-
|
|
50
|
-
/** @type {string} */
|
|
51
|
-
#currentThreadId;
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* task-318: soft cap on concurrent live engine instances. When set,
|
|
55
|
-
* `ensure()` refuses to spawn a new instance beyond this many live
|
|
56
|
-
* entries. Already-live instances continue to serve query()s — the
|
|
57
|
-
* cap only gates net-new thread creation. Can be mutated at runtime
|
|
58
|
-
* by `setMaxConcurrent()` so the Settings UI takes effect without a
|
|
59
|
-
* session restart.
|
|
60
|
-
*
|
|
61
|
-
* Null / 0 / negative → unlimited (treat as "no cap").
|
|
62
|
-
* @type {number | null}
|
|
63
|
-
*/
|
|
64
|
-
#maxConcurrent;
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* @param {{
|
|
68
|
-
* factory: (threadId: string, opts?: object) => import('./engine-instance.js').EngineInstance,
|
|
69
|
-
* maxConcurrent?: number | null,
|
|
70
|
-
* }} params
|
|
71
|
-
*/
|
|
72
|
-
constructor({ factory, maxConcurrent } = {}) {
|
|
73
|
-
if (typeof factory !== 'function') {
|
|
74
|
-
throw new Error('ThreadEngineRegistry: factory function is required');
|
|
75
|
-
}
|
|
76
|
-
this.#instances = new Map();
|
|
77
|
-
this.#factory = factory;
|
|
78
|
-
this.#currentThreadId = MAIN_THREAD_ID;
|
|
79
|
-
this.#maxConcurrent = normaliseMaxConcurrent(maxConcurrent);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** @returns {string} */
|
|
83
|
-
get currentThreadId() { return this.#currentThreadId; }
|
|
84
|
-
|
|
85
|
-
/** Total number of registered (including terminated) instances. */
|
|
86
|
-
get size() { return this.#instances.size; }
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Get an existing instance for a thread. Returns null if not yet
|
|
90
|
-
* created. Does NOT lazy-create — use ensure() for that.
|
|
91
|
-
* @param {string} threadId
|
|
92
|
-
*/
|
|
93
|
-
get(threadId) {
|
|
94
|
-
return this.#instances.get(threadId) || null;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Lazy-get-or-create an instance for a thread. If one already exists
|
|
99
|
-
* and is not terminated, it is returned; if it was terminated, a new
|
|
100
|
-
* one replaces it. Any `opts` are forwarded to the factory.
|
|
101
|
-
*
|
|
102
|
-
* @param {string} threadId
|
|
103
|
-
* @param {object} [opts]
|
|
104
|
-
* @returns {import('./engine-instance.js').EngineInstance}
|
|
105
|
-
*/
|
|
106
|
-
ensure(threadId, opts) {
|
|
107
|
-
if (!threadId || typeof threadId !== 'string') {
|
|
108
|
-
throw new Error('ThreadEngineRegistry.ensure: threadId required');
|
|
109
|
-
}
|
|
110
|
-
const existing = this.#instances.get(threadId);
|
|
111
|
-
if (existing && !existing.terminated) return existing;
|
|
112
|
-
// task-318: enforce concurrency cap before spawning a net-new
|
|
113
|
-
// instance. Replacing a terminated slot for the same threadId does
|
|
114
|
-
// NOT count against the cap — it's the same thread resuming.
|
|
115
|
-
if (!existing && this.#maxConcurrent !== null) {
|
|
116
|
-
const live = this.#countLive();
|
|
117
|
-
if (live >= this.#maxConcurrent) {
|
|
118
|
-
const err = new Error(
|
|
119
|
-
`ThreadEngineRegistry: concurrent thread limit reached (${live}/${this.#maxConcurrent}). ` +
|
|
120
|
-
`Archive or terminate an existing thread before starting a new one.`
|
|
121
|
-
);
|
|
122
|
-
err.code = 'ERR_MAX_CONCURRENT_THREADS';
|
|
123
|
-
err.limit = this.#maxConcurrent;
|
|
124
|
-
err.live = live;
|
|
125
|
-
throw err;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
const instance = this.#factory(threadId, opts);
|
|
129
|
-
if (!instance || typeof instance.query !== 'function') {
|
|
130
|
-
throw new Error(`ThreadEngineRegistry.ensure: factory did not return an EngineInstance for ${threadId}`);
|
|
131
|
-
}
|
|
132
|
-
this.#instances.set(threadId, instance);
|
|
133
|
-
return instance;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Count currently non-terminated instances — the denominator for the
|
|
138
|
-
* concurrency cap. O(n) scan, n is small (≤ 50 in practice).
|
|
139
|
-
* @returns {number}
|
|
140
|
-
*/
|
|
141
|
-
#countLive() {
|
|
142
|
-
let n = 0;
|
|
143
|
-
for (const inst of this.#instances.values()) {
|
|
144
|
-
if (!inst.terminated) n += 1;
|
|
145
|
-
}
|
|
146
|
-
return n;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* task-318: read or update the concurrency cap. `setMaxConcurrent(null)`
|
|
151
|
-
* disables the cap entirely. Existing live instances are NOT terminated
|
|
152
|
-
* if the cap is lowered below their count — the cap only gates new
|
|
153
|
-
* `ensure()` calls going forward.
|
|
154
|
-
* @returns {number | null}
|
|
155
|
-
*/
|
|
156
|
-
get maxConcurrent() { return this.#maxConcurrent; }
|
|
157
|
-
setMaxConcurrent(n) {
|
|
158
|
-
this.#maxConcurrent = normaliseMaxConcurrent(n);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Set the current thread marker. Does not lazy-create — caller must
|
|
163
|
-
* ensure() if they want an instance for an unseen thread.
|
|
164
|
-
* @param {string} threadId
|
|
165
|
-
*/
|
|
166
|
-
setCurrent(threadId) {
|
|
167
|
-
if (!threadId || typeof threadId !== 'string') {
|
|
168
|
-
throw new Error('ThreadEngineRegistry.setCurrent: threadId required');
|
|
169
|
-
}
|
|
170
|
-
this.#currentThreadId = threadId;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* List all non-terminated instances. The order is insertion order.
|
|
175
|
-
* @returns {Array<import('./engine-instance.js').EngineInstance>}
|
|
176
|
-
*/
|
|
177
|
-
listActive() {
|
|
178
|
-
const out = [];
|
|
179
|
-
for (const inst of this.#instances.values()) {
|
|
180
|
-
if (!inst.terminated) out.push(inst);
|
|
181
|
-
}
|
|
182
|
-
return out;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* All instances including terminated ones (for inspection / tests).
|
|
187
|
-
* @returns {Array<import('./engine-instance.js').EngineInstance>}
|
|
188
|
-
*/
|
|
189
|
-
listAll() {
|
|
190
|
-
return [...this.#instances.values()];
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Terminate a single thread's instance. Safe on unknown threadId.
|
|
195
|
-
* @param {string} threadId
|
|
196
|
-
* @returns {boolean} true if a live instance was terminated
|
|
197
|
-
*/
|
|
198
|
-
terminate(threadId) {
|
|
199
|
-
const inst = this.#instances.get(threadId);
|
|
200
|
-
if (!inst) return false;
|
|
201
|
-
if (inst.terminated) return false;
|
|
202
|
-
inst.terminate();
|
|
203
|
-
return true;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Terminate all instances. Used on session shutdown.
|
|
208
|
-
* @returns {number} count terminated
|
|
209
|
-
*/
|
|
210
|
-
terminateAll() {
|
|
211
|
-
let n = 0;
|
|
212
|
-
for (const inst of this.#instances.values()) {
|
|
213
|
-
if (!inst.terminated) {
|
|
214
|
-
inst.terminate();
|
|
215
|
-
n += 1;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
return n;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Remove a thread's instance from the map entirely. The registry
|
|
223
|
-
* will no longer return it from listAll / listActive. Primarily used
|
|
224
|
-
* after terminate() when the caller wants a full forget.
|
|
225
|
-
* @param {string} threadId
|
|
226
|
-
* @returns {boolean}
|
|
227
|
-
*/
|
|
228
|
-
delete(threadId) {
|
|
229
|
-
const inst = this.#instances.get(threadId);
|
|
230
|
-
if (!inst) return false;
|
|
231
|
-
if (!inst.terminated) inst.terminate();
|
|
232
|
-
return this.#instances.delete(threadId);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Build a registry whose factory constructs full Engine instances using
|
|
238
|
-
* a shared dependency bag. This is the production entry point used by
|
|
239
|
-
* session.js:
|
|
240
|
-
*
|
|
241
|
-
* const registry = createThreadEngineRegistry({
|
|
242
|
-
* adapter, trace, config, conversationStore, memoryStore,
|
|
243
|
-
* toolRegistry, skillManager, mcpManager, yeaftDir,
|
|
244
|
-
* });
|
|
245
|
-
* const inst = registry.ensure(threadId);
|
|
246
|
-
* for await (const event of inst.query({ prompt })) { ... }
|
|
247
|
-
*
|
|
248
|
-
* @param {object} deps — shared session deps (see session.js §9)
|
|
249
|
-
* @returns {ThreadEngineRegistry}
|
|
250
|
-
*/
|
|
251
|
-
export function createThreadEngineRegistry(deps) {
|
|
252
|
-
return new ThreadEngineRegistry({
|
|
253
|
-
factory: (threadId, opts = {}) => createEngineInstance({
|
|
254
|
-
...deps,
|
|
255
|
-
...opts,
|
|
256
|
-
threadId,
|
|
257
|
-
}),
|
|
258
|
-
maxConcurrent: deps?.maxConcurrent ?? null,
|
|
259
|
-
});
|
|
260
|
-
}
|