@yeaft/webchat-agent 0.1.593 → 0.1.594

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.593",
3
+ "version": "0.1.594",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/prompts.js CHANGED
@@ -163,6 +163,8 @@ const RAW_TEMPLATES = {
163
163
  // buildRouterPrompt callers will simply omit the section.
164
164
  harnessWorkerShape: readTemplate('harness/worker-shape.md', { required: false }),
165
165
  harnessRouterShape: readTemplate('harness/router-shape.md', { required: false }),
166
+ // Phase 3b — coordinator harness rule for inter-VP forwarding.
167
+ harnessRouterHandoff: readTemplate('harness/router-handoff.md', { required: false }),
166
168
  };
167
169
 
168
170
  /**
@@ -768,6 +770,39 @@ export function buildWorkerPrompt(params = {}) {
768
770
  return parts.join('\n\n');
769
771
  }
770
772
 
773
+ /**
774
+ * Render the previous turn's router plan as a `## prior_plan` block, so
775
+ * the router can decide whether to extend it or start fresh
776
+ * (DESIGN.md §9.15). Returns '' when there is no prior plan to render.
777
+ *
778
+ * @param {object|null|undefined} priorPlan
779
+ * @param {'en'|'zh'} [language='en']
780
+ * @returns {string}
781
+ */
782
+ export function renderPriorPlan(priorPlan, language = 'en') {
783
+ if (!priorPlan || typeof priorPlan !== 'object') return '';
784
+ const header = language === 'zh' ? '## 上一轮 plan' : '## prior_plan';
785
+ const lines = [];
786
+ if (priorPlan.vpId) lines.push(`vpId: ${priorPlan.vpId}`);
787
+ const fq = priorPlan.forwardQuery;
788
+ if (fq && (fq.userOriginal || fq.intent)) {
789
+ if (fq.intent) lines.push(`intent: ${fq.intent}`);
790
+ if (fq.userOriginal) lines.push(`userOriginal: ${fq.userOriginal}`);
791
+ }
792
+ const pre = priorPlan.preselect;
793
+ if (pre) {
794
+ if (Array.isArray(pre.memoryPaths) && pre.memoryPaths.length) {
795
+ lines.push(`memoryPaths: ${pre.memoryPaths.join(', ')}`);
796
+ }
797
+ if (Array.isArray(pre.taskIds) && pre.taskIds.length) {
798
+ lines.push(`taskIds: ${pre.taskIds.join(', ')}`);
799
+ }
800
+ }
801
+ if (priorPlan.thinking) lines.push(`thinking: ${priorPlan.thinking}`);
802
+ if (!lines.length) return '';
803
+ return `${header}\n${lines.join('\n')}`;
804
+ }
805
+
771
806
  /**
772
807
  * Router prompt entry point (DESIGN.md Phase 1).
773
808
  *
@@ -780,12 +815,13 @@ export function buildWorkerPrompt(params = {}) {
780
815
  * language?: 'en'|'zh',
781
816
  * summaries?: {user?: string, group?: string, vp?: string},
782
817
  * routerContext?: string,
818
+ * priorPlan?: object|null,
783
819
  * includeShape?: boolean,
784
820
  * }} params
785
821
  * @returns {string}
786
822
  */
787
823
  export function buildRouterPrompt(params = {}) {
788
- const { language = 'en', summaries, routerContext, includeShape = true } = params;
824
+ const { language = 'en', summaries, routerContext, priorPlan, includeShape = true } = params;
789
825
  const parts = [];
790
826
 
791
827
  if (includeShape) {
@@ -796,6 +832,9 @@ export function buildRouterPrompt(params = {}) {
796
832
  const summaryBlock = renderLayerASummaries(summaries, language);
797
833
  if (summaryBlock) parts.push(summaryBlock);
798
834
 
835
+ const priorBlock = renderPriorPlan(priorPlan, language);
836
+ if (priorBlock) parts.push(priorBlock);
837
+
799
838
  if (typeof routerContext === 'string' && routerContext.trim()) {
800
839
  parts.push(routerContext.trim());
801
840
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * router/continuity.js — DESIGN.md §9.15 priorPlan carry-back.
3
+ *
4
+ * Phase 3b scope:
5
+ * - `attachRouterPlan(message, plan)` — write the plan as `_meta.routerPlan`
6
+ * on the assistant message that produced it.
7
+ * - `extractPriorPlan(messages, vpId)` — find the most recent assistant
8
+ * message belonging to the given VP and return its `_meta.routerPlan`.
9
+ * - `stripMetaForWire(messages)` — drop `_meta` before sending to the LLM
10
+ * (it's bookkeeping, never model-visible).
11
+ *
12
+ * The skip-router heuristic (§9.15 #1) is intentionally NOT implemented in
13
+ * Phase 3b — DESIGN.md §8 line 391 says "do NOT ship the skip-router
14
+ * heuristic yet". We just plumb the metadata; the dispatcher can decide.
15
+ *
16
+ * Per-VP attribution: an assistant message belongs to a VP when its
17
+ * `_meta.routerPlan.vpId` matches; we never guess from content. First turn
18
+ * of a fresh group has no priorPlan — that is the expected cold-start.
19
+ */
20
+
21
+ /** @typedef {{
22
+ * vpId: string,
23
+ * forwardQuery?: { userOriginal?: string, intent?: string },
24
+ * preselect?: { memoryPaths?: string[], taskIds?: string[] },
25
+ * thinking?: 'high'|'max'|null,
26
+ * thinkingReason?: string,
27
+ * }} RouterPlanLike
28
+ */
29
+
30
+ /**
31
+ * Attach a router plan to an assistant message. Mutates `message` in place
32
+ * and returns it. We mutate (rather than clone) because the caller is the
33
+ * engine appending to its own `conversationMessages` array — cloning would
34
+ * just discard the work.
35
+ *
36
+ * Tool messages do not carry plans (no plan attached to a tool result).
37
+ *
38
+ * @param {object} message
39
+ * @param {RouterPlanLike|null|undefined} plan
40
+ * @returns {object}
41
+ */
42
+ export function attachRouterPlan(message, plan) {
43
+ if (!message || typeof message !== 'object') return message;
44
+ if (message.role !== 'assistant') return message;
45
+ if (!plan || typeof plan !== 'object' || !plan.vpId) return message;
46
+ message._meta = message._meta || {};
47
+ message._meta.routerPlan = {
48
+ vpId: plan.vpId,
49
+ forwardQuery: plan.forwardQuery
50
+ ? {
51
+ userOriginal: plan.forwardQuery.userOriginal || '',
52
+ intent: plan.forwardQuery.intent || '',
53
+ } : undefined,
54
+ preselect: plan.preselect
55
+ ? {
56
+ memoryPaths: Array.isArray(plan.preselect.memoryPaths)
57
+ ? [...plan.preselect.memoryPaths] : [],
58
+ taskIds: Array.isArray(plan.preselect.taskIds)
59
+ ? [...plan.preselect.taskIds] : [],
60
+ } : undefined,
61
+ thinking: plan.thinking ?? null,
62
+ thinkingReason: plan.thinkingReason || '',
63
+ };
64
+ return message;
65
+ }
66
+
67
+ /**
68
+ * Walk `messages` from the end, return the most recent assistant message's
69
+ * `_meta.routerPlan` whose `vpId` matches. Returns null if none found —
70
+ * that's a cold start, not an error.
71
+ *
72
+ * @param {object[]} messages
73
+ * @param {string} vpId
74
+ * @returns {RouterPlanLike | null}
75
+ */
76
+ export function extractPriorPlan(messages, vpId) {
77
+ if (!Array.isArray(messages) || !vpId) return null;
78
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
79
+ const m = messages[i];
80
+ if (!m || m.role !== 'assistant') continue;
81
+ const plan = m._meta && m._meta.routerPlan;
82
+ if (plan && plan.vpId === vpId) return plan;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Return a copy of the messages array with `_meta` stripped from every
89
+ * message. The serialisers (anthropic/openai-responses) read this; it is
90
+ * NEVER part of the wire payload. Cheap because we only shallow-clone the
91
+ * messages that actually have `_meta`.
92
+ *
93
+ * @param {object[]} messages
94
+ * @returns {object[]}
95
+ */
96
+ export function stripMetaForWire(messages) {
97
+ if (!Array.isArray(messages)) return messages;
98
+ let mutated = false;
99
+ const out = messages.map(m => {
100
+ if (m && typeof m === 'object' && '_meta' in m) {
101
+ mutated = true;
102
+ const { _meta, ...rest } = m;
103
+ return rest;
104
+ }
105
+ return m;
106
+ });
107
+ return mutated ? out : messages;
108
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * router/thinking.js — DESIGN.md §9.16 thinking-mode precedence chain.
3
+ *
4
+ * Resolves the final `thinking` value the engine should pass to the
5
+ * adapter, given the four signal sources:
6
+ *
7
+ * 1. UI override (highest) — submitOptions / topbar selector
8
+ * 2. Router plan — — per-plan thinking field
9
+ * 3. VP default — — vp/<id>/role.md frontmatter
10
+ * 4. Global default (lowest) — config.thinking.default
11
+ *
12
+ * Allowed values: `'high' | 'max' | null`. (`null` ⇒ adapter drops the
13
+ * field; provider-specific normalisation happens at the adapter via
14
+ * `models.js#normalizeEffort`.)
15
+ *
16
+ * Continuity rule (§9.16): when no UI override is in force AND the router
17
+ * did not change its recommendation versus the prior plan, keep the prior
18
+ * plan's value. Anthropic prompt cache keys include the thinking field;
19
+ * unstable values cause prefix re-encoding every turn.
20
+ *
21
+ * The `allowRouterEscalate: false` config gate hard-blocks the router
22
+ * from bumping below→`max`. UI overrides bypass that gate (they're the
23
+ * user's direct intent, not a heuristic).
24
+ */
25
+
26
+ const ALLOWED = new Set([null, 'high', 'max']);
27
+
28
+ /**
29
+ * @param {*} v
30
+ * @returns {'high'|'max'|null}
31
+ */
32
+ function clean(v) {
33
+ if (v === undefined) return null;
34
+ return ALLOWED.has(v) ? v : null;
35
+ }
36
+
37
+ /**
38
+ * @param {{
39
+ * uiOverride?: 'high'|'max'|null,
40
+ * routerPlan?: 'high'|'max'|null,
41
+ * priorPlan?: 'high'|'max'|null,
42
+ * vpDefault?: 'high'|'max'|null,
43
+ * globalDefault?: 'high'|'max'|null,
44
+ * allowRouterEscalate?: boolean,
45
+ * }} signals
46
+ * @returns {{ value: 'high'|'max'|null, source: 'ui'|'router'|'prior'|'vp'|'global'|'default' }}
47
+ */
48
+ export function resolveThinking(signals = {}) {
49
+ const ui = clean(signals.uiOverride);
50
+ if (ui) return { value: ui, source: 'ui' };
51
+
52
+ const router = clean(signals.routerPlan);
53
+ const prior = clean(signals.priorPlan);
54
+ const vp = clean(signals.vpDefault);
55
+ const global_ = clean(signals.globalDefault);
56
+ const escalateOk = signals.allowRouterEscalate !== false;
57
+
58
+ // Continuity: if router agrees with prior or is silent, prefer prior to
59
+ // keep the cache key stable.
60
+ if (router && prior && router === prior) {
61
+ return { value: prior, source: 'prior' };
62
+ }
63
+
64
+ if (router) {
65
+ // allowRouterEscalate=false hard-blocks router from emitting 'max'
66
+ // when the baseline is 'high'.
67
+ const baseline = prior || vp || global_ || 'high';
68
+ if (!escalateOk && router === 'max' && baseline !== 'max') {
69
+ return { value: baseline, source: prior ? 'prior' : (vp ? 'vp' : 'global') };
70
+ }
71
+ return { value: router, source: 'router' };
72
+ }
73
+
74
+ if (prior) return { value: prior, source: 'prior' };
75
+ if (vp) return { value: vp, source: 'vp' };
76
+ if (global_) return { value: global_, source: 'global' };
77
+ return { value: 'high', source: 'default' };
78
+ }
@@ -280,3 +280,62 @@ export async function runPlansSequential(plans, runOne, opts = {}) {
280
280
  }
281
281
  return { results, errors };
282
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
+ }
@@ -0,0 +1,34 @@
1
+ <!-- lang:en -->
2
+ # Harness — Router Handoff
3
+
4
+ If, while drafting your reply, you realise you are the wrong VP for this
5
+ turn, hand off instead of guessing. Call `route_forward(targetVpId, reason)`
6
+ with a short, actionable reason. The next turn becomes the receiving VP's
7
+ turn with your reason as the inbound envelope — they act with no other
8
+ context from you.
9
+
10
+ Use this when:
11
+
12
+ - The user's question is outside your expertise and another VP in the
13
+ group clearly owns it.
14
+ - Your read of the situation is "this is comms not kernel" / "this is
15
+ legal not engineering" — name the boundary.
16
+
17
+ Do NOT use this to dodge hard questions you legitimately own. The router
18
+ already picked you; only forward when the topic genuinely belongs to
19
+ someone else.
20
+ <!-- lang:zh -->
21
+ # Harness — Router 转交
22
+
23
+ 如果你在起草回复时发现本轮应该由其他 VP 来回答,请直接转交,而不是
24
+ 强答。调用 `route_forward(targetVpId, reason)` 并给出简短可操作的原因。
25
+ 下一轮变为目标 VP 的回合,你给的 reason 即为他们看到的入站信封——他
26
+ 们不会读到你的其他上下文。
27
+
28
+ 适用场景:
29
+
30
+ - 用户的问题超出你的专业范围,群里另一个 VP 显然更合适。
31
+ - 你判断「这是沟通不是内核」/「这是法务不是工程」——说出边界。
32
+
33
+ 不要用它来回避你确实该回答的问题。Router 既然选了你,只有当话题
34
+ 确实属于他人时才转交。