@yeaft/webchat-agent 0.1.725 → 0.1.727

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.725",
3
+ "version": "0.1.727",
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/engine.js CHANGED
@@ -873,11 +873,19 @@ export class Engine {
873
873
  * Persist user message and assistant response to conversation store.
874
874
  * Skipped in read-only mode (config._readOnly).
875
875
  *
876
+ * Multi-VP fan-out (Bug 1): when several engines run the same user
877
+ * prompt in parallel, we must NOT each write our own copy of the user
878
+ * message — `coord.ingest`/the orchestrator already wrote it once. Pass
879
+ * `userAlreadyPersisted: true` from the caller to skip the user-row
880
+ * append while still persisting the assistant + tool rows.
881
+ *
876
882
  * @param {string} userContent
877
883
  * @param {string} assistantContent
878
884
  * @param {object[]} [toolCalls]
885
+ * @param {string} [groupId]
886
+ * @param {boolean} [userAlreadyPersisted]
879
887
  */
880
- #persistMessages(userContent, assistantContent, toolCalls, groupId) {
888
+ #persistMessages(userContent, assistantContent, toolCalls, groupId, userAlreadyPersisted = false) {
881
889
  if (!this.#conversationStore) return;
882
890
  if (this.#config._readOnly) return;
883
891
 
@@ -885,14 +893,17 @@ export class Engine {
885
893
  // for back-compat with old conversation files; new writes always use 'main'.
886
894
  const threadId = MAIN_THREAD_ID;
887
895
 
888
- // Persist user message
889
- this.#conversationStore.append({
890
- role: 'user',
891
- content: userContent,
892
- threadId,
893
- // Bug 6: stamp groupId so history replay can route by group.
894
- ...(groupId ? { groupId } : {}),
895
- });
896
+ // Persist user message — unless an upstream caller (e.g. the group
897
+ // coordinator) has already done so for this turn.
898
+ if (!userAlreadyPersisted) {
899
+ this.#conversationStore.append({
900
+ role: 'user',
901
+ content: userContent,
902
+ threadId,
903
+ // Bug 6: stamp groupId so history replay can route by group.
904
+ ...(groupId ? { groupId } : {}),
905
+ });
906
+ }
896
907
 
897
908
  // Persist assistant message
898
909
  const assistantMsg = {
@@ -1040,7 +1051,7 @@ export class Engine {
1040
1051
  * string-prompt shape (no regression for existing callers).
1041
1052
  * @yields {EngineEvent}
1042
1053
  */
1043
- async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement } = {}) {
1054
+ async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, userAlreadyPersisted = false } = {}) {
1044
1055
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
1045
1056
  yield {
1046
1057
  type: 'error',
@@ -1099,7 +1110,7 @@ export class Engine {
1099
1110
  const runSignal = abortCtrl.signal;
1100
1111
 
1101
1112
  try {
1102
- yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement });
1113
+ yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, userAlreadyPersisted });
1103
1114
  } finally {
1104
1115
  if (signal) {
1105
1116
  try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
@@ -1117,7 +1128,7 @@ export class Engine {
1117
1128
  * in a try/finally without indenting the whole loop.
1118
1129
  * @private
1119
1130
  */
1120
- async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement }) {
1131
+ async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, userAlreadyPersisted = false }) {
1121
1132
 
1122
1133
  // ─── Pre-query: FTS5 Memory Recall + AMS snapshot ─────
1123
1134
  // Memory has a SINGLE render outlet now (DESIGN-PROMPT §3 ③):
@@ -1728,6 +1739,11 @@ export class Engine {
1728
1739
  // Bug 6: tag persisted messages with the originating group so
1729
1740
  // history replay can re-stamp them on reload.
1730
1741
  groupId,
1742
+ // Multi-VP fan-out (history-dedup): skip the user-row append
1743
+ // in stop-hooks when the orchestrator already wrote it once
1744
+ // for this turn. The hook still persists assistant + tool
1745
+ // rows for THIS VP's contribution.
1746
+ userAlreadyPersisted,
1731
1747
  });
1732
1748
 
1733
1749
  if (hookResult.consolidated) {
@@ -1735,7 +1751,7 @@ export class Engine {
1735
1751
  }
1736
1752
  } else {
1737
1753
  // Legacy path (no yeaftDir → use old behavior)
1738
- this.#persistMessages(prompt, fullResponseText, assistantMsg.toolCalls, groupId);
1754
+ this.#persistMessages(prompt, fullResponseText, assistantMsg.toolCalls, groupId, userAlreadyPersisted);
1739
1755
 
1740
1756
  const consolidated = await this.#maybeConsolidate();
1741
1757
  if (consolidated && consolidated.archivedCount > 0) {
@@ -51,6 +51,11 @@ export async function runStopHooks(context) {
51
51
  // history replay can route messages back into the originating group.
52
52
  groupId,
53
53
  threadId,
54
+ // Multi-VP fan-out (history-dedup): when several engines run the
55
+ // same user prompt in parallel, the orchestrator persists the user
56
+ // row exactly once before fan-out. Each VP's stop-hook then skips
57
+ // the user record but still writes its own assistant + tool rows.
58
+ userAlreadyPersisted = false,
54
59
  } = context;
55
60
 
56
61
  // Model name for persisted messages: use primaryModel if provided, else config.model
@@ -90,6 +95,11 @@ export async function runStopHooks(context) {
90
95
  const recentMessages = messages.slice(turnStart);
91
96
  for (const msg of recentMessages) {
92
97
  if (!msg || !msg.role) continue;
98
+ // Skip the user row if the orchestrator already wrote it once for
99
+ // this turn (multi-VP fan-out: every VP's engine sees the same
100
+ // user prompt at conversationMessages[turnStart] but only the
101
+ // first writer should land on disk).
102
+ if (userAlreadyPersisted && msg.role === 'user') continue;
93
103
  // Allow empty assistant content when toolCalls are present;
94
104
  // tool messages have content by construction.
95
105
  const hasContent =
@@ -399,6 +399,30 @@ function ensureDriverRunning(groupId, vpId) {
399
399
  const promptParts = inboundParts.length > 0
400
400
  ? [...inboundParts, { type: 'text', text: prompt }]
401
401
  : null;
402
+
403
+ // Multi-VP fan-out — history dedup. Persist the user row exactly
404
+ // once per envelope (keyed by coordinator-minted msg.id). The
405
+ // first driver to pick up an envelope writes; later drivers
406
+ // (other VPs in the same fan-out, or downstream route_forward
407
+ // targets sharing an msg.id) become no-ops. Each VP's engine
408
+ // runs with `userAlreadyPersisted: true` so its stop-hook never
409
+ // tries to write the user row a second time.
410
+ //
411
+ // Belt-and-suspenders against the handleUnifyGroupChat call: in
412
+ // the common path that one runs first and this is a no-op; in
413
+ // edge paths (route_forward without an upstream user write) this
414
+ // is the writer.
415
+ try {
416
+ const envMsgId = envelope?.msg?.id;
417
+ if (envMsgId && text) {
418
+ persistUserMessageOnceByMsgId({
419
+ msgId: envMsgId,
420
+ text,
421
+ groupId,
422
+ });
423
+ }
424
+ } catch { /* never crash WS pipeline */ }
425
+
402
426
  try {
403
427
  await runVpTurn({
404
428
  prompt,
@@ -1411,6 +1435,36 @@ export async function handleUnifyGroupChat(msg) {
1411
1435
  return;
1412
1436
  }
1413
1437
 
1438
+ // Multi-VP fan-out — history dedup (PR-fix-unify-group-history-dedup):
1439
+ // persist the user row EXACTLY ONCE per turn. We do it AFTER
1440
+ // `coord.ingest` so we can key dedup on the coordinator-minted
1441
+ // `report.message.id` — the same id the per-VP driver will see on
1442
+ // `envelope.msg.id`, which lets a route_forward injection that lands
1443
+ // on the same id be a no-op.
1444
+ //
1445
+ // Each VP's engine then runs with `userAlreadyPersisted: true` (see
1446
+ // runVpTurn's vpEngine.query call) so its stop-hook skips the
1447
+ // user-row append while still writing assistant + tool rows.
1448
+ //
1449
+ // We persist the canonical `text` (no `@vp-X ` prefix) because that's
1450
+ // what the user actually typed. Clean text matches what `loadHistory`
1451
+ // replays back to the frontend on refresh.
1452
+ try {
1453
+ const persistedMsgId = report?.message?.id;
1454
+ if (persistedMsgId) {
1455
+ persistUserMessageOnceByMsgId({
1456
+ msgId: persistedMsgId,
1457
+ text,
1458
+ groupId,
1459
+ });
1460
+ }
1461
+ } catch (err) {
1462
+ console.warn(
1463
+ '[Unify] unify_group_chat: persistUserMessageOnceByMsgId failed',
1464
+ err?.message || err,
1465
+ );
1466
+ }
1467
+
1414
1468
  const dispatchedIds = Array.isArray(report?.dispatched) ? report.dispatched : [];
1415
1469
  const fallbackId = typeof report?.fallback === 'string' ? report.fallback : null;
1416
1470
  if (dispatchedIds.length === 0 && !fallbackId) {
@@ -1792,6 +1846,14 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
1792
1846
  promptParts,
1793
1847
  messages: trimmedMessages,
1794
1848
  signal: vpAbort.signal,
1849
+ // Multi-VP fan-out (history-dedup): the user row was persisted
1850
+ // ONCE by handleUnifyGroupChat → persistUserMessageOnce before
1851
+ // fan-out. Tell the engine's stop-hook to skip the user-row
1852
+ // append for THIS VP's turn (it still writes assistant + tool
1853
+ // rows for this VP). Without this the magnet of N engines would
1854
+ // each write a copy of the user message, and history replay
1855
+ // would render the user's prompt N times.
1856
+ userAlreadyPersisted: true,
1795
1857
  ...queryOpts,
1796
1858
  })) {
1797
1859
  resetQueryTimer();
@@ -1916,6 +1978,87 @@ function appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolRes
1916
1978
  }
1917
1979
  }
1918
1980
 
1981
+ /**
1982
+ * Persist the user row to disk EXACTLY ONCE per coordinator-ingest call,
1983
+ * keyed by the coordinator-assigned `msgId`. Both `handleUnifyGroupChat`
1984
+ * (real user input) and `enqueueForVp`'s driver loop (route_forward
1985
+ * synthetic injections) call this — the Set guard makes either path
1986
+ * the writer, whichever runs first, while the other becomes a no-op.
1987
+ *
1988
+ * Without this dedup, a 2-VP group prompt produces TWO `m{NNNN}.md`
1989
+ * user rows (one per engine) — `handleUnifyLoadHistory` then replays
1990
+ * the user's prompt twice and sandwiches one VP's reply between two
1991
+ * copies of the user message. Visually this reads as "messages out of
1992
+ * order" because the second copy of the user prompt sits BETWEEN the
1993
+ * two VPs' replies.
1994
+ *
1995
+ * Best-effort: a write failure does NOT abort the turn — engines can
1996
+ * still run, and the next user message will trigger another append.
1997
+ *
1998
+ * Note: we mirror `engine.#persistMessages`'s schema for the user row
1999
+ * exactly (role/content/threadId/groupId), so existing parsers /
2000
+ * loaders see no schema drift. Attachments are not part of the on-disk
2001
+ * schema today (the engine never wrote them either) — they live on the
2002
+ * coordinator's jsonl-log under group meta.
2003
+ *
2004
+ * @param {{ msgId:string, text:string, groupId:string }} args
2005
+ * @returns {boolean} true if this call wrote the row, false if a prior
2006
+ * call already wrote it (dedup hit).
2007
+ */
2008
+ function persistUserMessageOnceByMsgId({ msgId, text, groupId }) {
2009
+ if (!session?.conversationStore) return false;
2010
+ // No msgId means no dedup key — caller is responsible for guarding.
2011
+ // Both call sites already do (`if (envMsgId && text)` and
2012
+ // `if (persistedMsgId)`); refusing here keeps the helper's contract
2013
+ // clean. A synthetic-id fallback (Date.now+random) would defeat dedup —
2014
+ // every call would mint a unique id and write a duplicate row, which
2015
+ // is the exact bug this helper exists to prevent.
2016
+ if (!msgId || typeof msgId !== 'string') return false;
2017
+ if (_persistedUserMsgIds.has(msgId)) return false;
2018
+ // Mark BEFORE the empty-text bail. If a later same-id call arrives
2019
+ // with non-empty text (e.g. a route_forward injection that the first
2020
+ // caller passed in with empty text), the Set must already remember
2021
+ // this id so the second call dedups instead of writing.
2022
+ _persistedUserMsgIds.add(msgId);
2023
+ if (!text || typeof text !== 'string') return false;
2024
+ // Bound the Set so it doesn't grow unbounded over a long session.
2025
+ // 4096 msg-ids is well past any realistic "messages in flight"
2026
+ // window — once N drivers have observed the id, the rest can fall
2027
+ // back to "write again" without harm (the second writer would be a
2028
+ // duplicate, but it requires both: (a) the Set evicting an id AND
2029
+ // (b) a still-running driver getting around to its first persist).
2030
+ if (_persistedUserMsgIds.size > 4096) {
2031
+ const iter = _persistedUserMsgIds.values();
2032
+ for (let i = 0; i < 1024; i++) {
2033
+ const v = iter.next();
2034
+ if (v.done) break;
2035
+ _persistedUserMsgIds.delete(v.value);
2036
+ }
2037
+ }
2038
+ try {
2039
+ const record = {
2040
+ role: 'user',
2041
+ content: text,
2042
+ threadId: 'main',
2043
+ };
2044
+ if (groupId) record.groupId = groupId;
2045
+ session.conversationStore.append(record);
2046
+ return true;
2047
+ } catch (err) {
2048
+ console.warn(
2049
+ '[Unify] persistUserMessageOnceByMsgId failed (non-fatal):',
2050
+ err?.message || err,
2051
+ );
2052
+ return false;
2053
+ }
2054
+ }
2055
+
2056
+ /**
2057
+ * Cleared on session reset (resetUnifySession) so a fresh session
2058
+ * starts with no stale msg-ids.
2059
+ */
2060
+ const _persistedUserMsgIds = new Set();
2061
+
1919
2062
  /**
1920
2063
  * In-flight compact promise. Set by `scheduleCompactAfterTurn` when a
1921
2064
  * turn ends and triggers compaction; awaited by the next
@@ -2501,6 +2644,9 @@ export async function resetUnifySession() {
2501
2644
  vpDrivers.clear();
2502
2645
  vpEngines.clear();
2503
2646
  groupContexts.clear();
2647
+ // History-dedup cache is keyed by per-session coordinator msg ids;
2648
+ // a fresh session resets the id space, so clear the cache too.
2649
+ _persistedUserMsgIds.clear();
2504
2650
 
2505
2651
  try {
2506
2652
  const yeaftDir = ctx.CONFIG?.yeaftDir;