@yeaft/webchat-agent 0.1.661 → 0.1.663

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.
@@ -1,341 +0,0 @@
1
- /**
2
- * router/vp-planner.js — DESIGN.md Phase 3a (router per-VP plans).
3
- *
4
- * The legacy `intent-classifier.js` decides "which thread continues this
5
- * turn" — single-VP, single-plan. The multi-VP redesign (DESIGN.md §1.2.1)
6
- * generalises that into a `plans[]` array: one plan per VP that should act
7
- * this turn, in execution order. This module is the per-VP planner.
8
- *
9
- * Phase 3a scope: schema + validation + sequential fan-out runner. The LLM
10
- * call itself is wired in Phase 3b along with `priorPlan` continuity. Until
11
- * then, callers either (a) construct plans directly from override paths, or
12
- * (b) wrap the legacy single-plan classifier and call `wrapLegacyDecision`.
13
- *
14
- * Non-goals here:
15
- * - parallel fan-out (Phase 3.5).
16
- * - thinking-mode resolution (handled at the dispatcher level alongside
17
- * the UI > Router > VP > Global precedence chain — DESIGN.md §9.16).
18
- * - `priorPlan` skip-router heuristic (Phase 3b).
19
- *
20
- * Shape contract (DESIGN.md §1.2.1):
21
- *
22
- * {
23
- * action: 'continue' | 'switch_vp' | 'fork_task' | 'join_task' |
24
- * 'broadcast' | 'noop',
25
- * targetTaskId: string | null,
26
- * plans: [
27
- * {
28
- * vpId: string,
29
- * forwardQuery: { userOriginal: string, intent: string },
30
- * preselect: { memoryPaths: string[], taskIds: string[] },
31
- * thinking: 'high' | 'max' | null,
32
- * thinkingReason: string,
33
- * }
34
- * ],
35
- * reason: string,
36
- * }
37
- */
38
-
39
- import { isVpForeign } from '../memory/store-v2.js';
40
-
41
- /** @typedef {{ userOriginal: string, intent: string }} ForwardQuery */
42
- /** @typedef {{ memoryPaths: string[], taskIds: string[] }} Preselect */
43
- /** @typedef {{
44
- * vpId: string,
45
- * forwardQuery: ForwardQuery,
46
- * preselect: Preselect,
47
- * thinking: 'high'|'max'|null,
48
- * thinkingReason: string,
49
- * }} VpPlan
50
- */
51
- /** @typedef {{
52
- * action: 'continue'|'switch_vp'|'fork_task'|'join_task'|'broadcast'|'noop',
53
- * targetTaskId: string | null,
54
- * plans: VpPlan[],
55
- * reason: string,
56
- * }} RouterDecisionV2
57
- */
58
-
59
- const ALLOWED_ACTIONS = new Set([
60
- 'continue', 'switch_vp', 'fork_task', 'join_task', 'broadcast', 'noop',
61
- ]);
62
-
63
- const ALLOWED_THINKING = new Set([null, 'high', 'max']);
64
-
65
- /**
66
- * Validate + canonicalise a router decision. Throws on truly malformed
67
- * input (we want loud failures during Phase 3 wiring), but tolerates
68
- * missing optional fields by filling defaults.
69
- *
70
- * @param {*} raw
71
- * @returns {RouterDecisionV2}
72
- */
73
- export function validateDecision(raw) {
74
- if (!raw || typeof raw !== 'object') {
75
- throw new Error('validateDecision: decision must be an object');
76
- }
77
- const action = ALLOWED_ACTIONS.has(raw.action) ? raw.action : 'continue';
78
- const targetTaskId = (typeof raw.targetTaskId === 'string' && raw.targetTaskId)
79
- ? raw.targetTaskId : null;
80
- if (!Array.isArray(raw.plans)) {
81
- throw new Error('validateDecision: plans must be an array');
82
- }
83
- const plans = raw.plans.map(validatePlan);
84
- const reason = typeof raw.reason === 'string' ? raw.reason : '';
85
- return { action, targetTaskId, plans, reason };
86
- }
87
-
88
- /**
89
- * @param {*} raw
90
- * @returns {VpPlan}
91
- */
92
- export function validatePlan(raw) {
93
- if (!raw || typeof raw !== 'object') {
94
- throw new Error('validatePlan: plan must be an object');
95
- }
96
- if (typeof raw.vpId !== 'string' || !raw.vpId) {
97
- throw new Error('validatePlan: vpId required');
98
- }
99
- const fq = raw.forwardQuery && typeof raw.forwardQuery === 'object'
100
- ? raw.forwardQuery : {};
101
- const userOriginal = typeof fq.userOriginal === 'string' ? fq.userOriginal : '';
102
- const intent = typeof fq.intent === 'string' ? fq.intent : '';
103
- const pre = raw.preselect && typeof raw.preselect === 'object'
104
- ? raw.preselect : {};
105
- const memoryPaths = Array.isArray(pre.memoryPaths)
106
- ? pre.memoryPaths.filter(p => typeof p === 'string' && p)
107
- : [];
108
- const taskIds = Array.isArray(pre.taskIds)
109
- ? pre.taskIds.filter(t => typeof t === 'string' && t)
110
- : [];
111
- const thinkingRaw = raw.thinking === undefined ? null : raw.thinking;
112
- const thinking = ALLOWED_THINKING.has(thinkingRaw) ? thinkingRaw : null;
113
- const thinkingReason = typeof raw.thinkingReason === 'string'
114
- ? raw.thinkingReason : '';
115
- return {
116
- vpId: raw.vpId,
117
- forwardQuery: { userOriginal, intent },
118
- preselect: { memoryPaths, taskIds },
119
- thinking,
120
- thinkingReason,
121
- };
122
- }
123
-
124
- /**
125
- * Strip `vp/<other>/` paths from a plan's `preselect.memoryPaths`. The
126
- * planner runs BEFORE the worker so this is the right place to enforce
127
- * the cross-VP private-memory hard block (DESIGN.md §2.2). Returns a new
128
- * plan; original is not mutated.
129
- *
130
- * @param {VpPlan} plan
131
- * @returns {VpPlan}
132
- */
133
- export function stripForeignVpPaths(plan) {
134
- if (!plan) return plan;
135
- const own = plan.vpId;
136
- const filtered = plan.preselect.memoryPaths.filter(p => !isVpForeign(p, own));
137
- if (filtered.length === plan.preselect.memoryPaths.length) return plan;
138
- return {
139
- ...plan,
140
- preselect: { ...plan.preselect, memoryPaths: filtered },
141
- };
142
- }
143
-
144
- /**
145
- * Produce a default single-plan decision for "explicit @vp" or "no router
146
- * needed" paths. The dispatcher uses this when it has decided not to call
147
- * the LLM router (DESIGN.md §1.2.1 scenario A — explicit @vp; or §9.15
148
- * priorPlan skip).
149
- *
150
- * @param {{
151
- * vpId: string,
152
- * userOriginal: string,
153
- * intent?: string,
154
- * memoryPaths?: string[],
155
- * taskIds?: string[],
156
- * targetTaskId?: string | null,
157
- * thinking?: 'high'|'max'|null,
158
- * thinkingReason?: string,
159
- * action?: RouterDecisionV2['action'],
160
- * reason?: string,
161
- * }} args
162
- * @returns {RouterDecisionV2}
163
- */
164
- export function buildDirectDecision(args) {
165
- const {
166
- vpId, userOriginal, intent = '',
167
- memoryPaths = [], taskIds = [],
168
- targetTaskId = null,
169
- thinking = null, thinkingReason = '',
170
- action = 'continue', reason = 'direct',
171
- } = args || {};
172
- if (!vpId) throw new Error('buildDirectDecision: vpId required');
173
- return validateDecision({
174
- action,
175
- targetTaskId,
176
- plans: [{
177
- vpId,
178
- forwardQuery: { userOriginal, intent },
179
- preselect: { memoryPaths, taskIds },
180
- thinking,
181
- thinkingReason,
182
- }],
183
- reason,
184
- });
185
- }
186
-
187
- /**
188
- * Translate a legacy `intent-classifier` single-thread decision (action +
189
- * targetThreadId) into the V2 plans schema. We treat the old `targetThreadId`
190
- * as the `vpId` because — in the multi-VP redesign — every "thread" is a VP
191
- * (groups are sessions, see DESIGN.md §0.1). Callers that still produce
192
- * legacy decisions can pipe them through this until Phase 3b.
193
- *
194
- * Mapping:
195
- * continue / switch → continue / switch_vp (single-VP plan)
196
- * fork → fork_task (single-VP plan)
197
- * anything else → continue
198
- *
199
- * @param {{
200
- * action?: string,
201
- * targetThreadId?: string,
202
- * reason?: string,
203
- * source?: string,
204
- * }} legacy
205
- * @param {string} userOriginal
206
- * @returns {RouterDecisionV2}
207
- */
208
- export function wrapLegacyDecision(legacy, userOriginal = '') {
209
- if (!legacy || typeof legacy !== 'object') {
210
- return validateDecision({ action: 'noop', targetTaskId: null, plans: [], reason: '' });
211
- }
212
- const vpId = typeof legacy.targetThreadId === 'string' ? legacy.targetThreadId : '';
213
- if (!vpId) {
214
- return validateDecision({ action: 'noop', targetTaskId: null, plans: [], reason: legacy.reason || '' });
215
- }
216
- let action = 'continue';
217
- if (legacy.action === 'switch') action = 'switch_vp';
218
- else if (legacy.action === 'fork') action = 'fork_task';
219
- else if (legacy.action === 'continue') action = 'continue';
220
- return validateDecision({
221
- action,
222
- targetTaskId: null,
223
- reason: legacy.reason || '',
224
- plans: [{
225
- vpId,
226
- forwardQuery: { userOriginal, intent: '' },
227
- preselect: { memoryPaths: [], taskIds: [] },
228
- thinking: null,
229
- thinkingReason: '',
230
- }],
231
- });
232
- }
233
-
234
- /**
235
- * Sequential fan-out runner. Calls `runOne(plan, index, prior)` for each
236
- * plan in order, awaiting each before starting the next. The previous
237
- * plans' results are passed via `prior` so a later plan can read what an
238
- * earlier plan emitted (DESIGN.md §1.2.1 — "ordering is load bearing").
239
- *
240
- * Plans whose `vpId` is missing from `groupMemberIds` (when the caller
241
- * supplies that whitelist) are skipped with a logged `skipped:not_member`
242
- * entry — never silently routed to a non-member.
243
- *
244
- * Errors from `runOne` are caught, recorded, and the loop continues; the
245
- * report holds whatever each plan produced. The dispatcher decides whether
246
- * to surface or retry.
247
- *
248
- * @param {VpPlan[]} plans
249
- * @param {(plan: VpPlan, index: number, prior: any[]) => Promise<*>} runOne
250
- * @param {{ groupMemberIds?: string[] }} [opts]
251
- * @returns {Promise<{ results: any[], errors: Array<{ index: number, error: Error }> }>}
252
- */
253
- export async function runPlansSequential(plans, runOne, opts = {}) {
254
- if (!Array.isArray(plans)) throw new Error('runPlansSequential: plans array required');
255
- if (typeof runOne !== 'function') throw new Error('runPlansSequential: runOne fn required');
256
- const memberSet = Array.isArray(opts.groupMemberIds)
257
- ? new Set(opts.groupMemberIds) : null;
258
- const results = [];
259
- const errors = [];
260
- const prior = [];
261
- for (let i = 0; i < plans.length; i += 1) {
262
- const plan = plans[i];
263
- if (memberSet && !memberSet.has(plan.vpId)) {
264
- const skip = { index: i, vpId: plan.vpId, skipped: 'not_member' };
265
- results.push(skip);
266
- prior.push(skip);
267
- continue;
268
- }
269
- try {
270
- const out = await runOne(plan, i, prior);
271
- results.push(out);
272
- prior.push(out);
273
- } catch (err) {
274
- errors.push({ index: i, error: err });
275
- // Insert a sentinel into prior so a later plan can see "the previous
276
- // VP errored" rather than nothing — useful when a fallback VP is
277
- // queued behind a primary.
278
- prior.push({ index: i, vpId: plan.vpId, error: err });
279
- }
280
- }
281
- return { results, errors };
282
- }
283
-
284
- /**
285
- * Parallel fan-out runner (Phase 3.5). Calls `runOne(plan, index)` for each
286
- * plan concurrently, with optional `concurrency` cap. Results are returned
287
- * in input order regardless of completion order. Errors from `runOne` are
288
- * caught per-plan and DO NOT abort siblings (DESIGN.md §9.1 — concurrent
289
- * VP turns must be independent).
290
- *
291
- * NOTE: parallel mode loses the `prior[]` channel that the sequential
292
- * runner provides. Callers that need plan N to read plan N-1's output must
293
- * use `runPlansSequential`. The dispatcher chooses based on whether the
294
- * plans share a `targetTaskId` (parallel-safe) or pipeline data
295
- * (sequential-only).
296
- *
297
- * @param {VpPlan[]} plans
298
- * @param {(plan: VpPlan, index: number) => Promise<*>} runOne
299
- * @param {{ groupMemberIds?: string[], concurrency?: number }} [opts]
300
- * @returns {Promise<{ results: any[], errors: Array<{ index: number, error: Error }> }>}
301
- */
302
- export async function runPlansParallel(plans, runOne, opts = {}) {
303
- if (!Array.isArray(plans)) throw new Error('runPlansParallel: plans array required');
304
- if (typeof runOne !== 'function') throw new Error('runPlansParallel: runOne fn required');
305
- const memberSet = Array.isArray(opts.groupMemberIds)
306
- ? new Set(opts.groupMemberIds) : null;
307
- const concurrency = Number.isFinite(opts.concurrency) && opts.concurrency > 0
308
- ? Math.floor(opts.concurrency) : Infinity;
309
-
310
- const results = new Array(plans.length);
311
- const errors = [];
312
- let nextIdx = 0;
313
-
314
- const runSlot = async () => {
315
- // Workers pull tasks from a shared queue index — preserves backpressure
316
- // when concurrency < plans.length without per-task scheduling overhead.
317
- while (true) {
318
- const i = nextIdx;
319
- nextIdx += 1;
320
- if (i >= plans.length) return;
321
- const plan = plans[i];
322
- if (memberSet && !memberSet.has(plan.vpId)) {
323
- results[i] = { index: i, vpId: plan.vpId, skipped: 'not_member' };
324
- continue;
325
- }
326
- try {
327
- results[i] = await runOne(plan, i);
328
- } catch (err) {
329
- errors.push({ index: i, error: err });
330
- results[i] = { index: i, vpId: plan.vpId, error: err };
331
- }
332
- }
333
- };
334
-
335
- const workerCount = Math.min(plans.length, concurrency);
336
- const workers = [];
337
- for (let w = 0; w < workerCount; w += 1) workers.push(runSlot());
338
- await Promise.all(workers);
339
-
340
- return { results, errors };
341
- }