@yeaft/webchat-agent 0.1.748 → 0.1.749
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/compact/compactor.js +242 -0
- package/unify/session.js +15 -0
- package/unify/web-bridge.js +63 -199
package/package.json
CHANGED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compactor.js — per-group post-turn history compactor.
|
|
3
|
+
*
|
|
4
|
+
* Owns the orchestration that previously lived inline in
|
|
5
|
+
* `agent/unify/web-bridge.js` as `getCompactState` /
|
|
6
|
+
* `scheduleCompactAfterTurn` / `runCompactNow`. Bridge keeps WS knowledge
|
|
7
|
+
* and history ownership; this class owns:
|
|
8
|
+
* - per-group single-flight + anti-starvation `pending` chain,
|
|
9
|
+
* - the precheck via `shouldCompactHistory`,
|
|
10
|
+
* - the call into `compactHistory` (which itself calls the supplied
|
|
11
|
+
* `summarize` injectable — bound to `Engine.summarizeForCompact` by
|
|
12
|
+
* `session.js`),
|
|
13
|
+
* - the race-guard that bails when the live history reference / length
|
|
14
|
+
* diverges from the snapshot we captured before awaiting the LLM.
|
|
15
|
+
*
|
|
16
|
+
* History is passed in PER CALL via a `historyHandle = { get, set }` so
|
|
17
|
+
* Compactor never has to know about `groupContexts`, `historyHydrated`,
|
|
18
|
+
* or any other bridge-internal field. The WS event sink is wired
|
|
19
|
+
* separately via `setOnCompacted`.
|
|
20
|
+
*
|
|
21
|
+
* Engine instances are per-VP-per-group (`vpEngines` keyed by
|
|
22
|
+
* `${groupId}::${vpId}`), so this orchestration cannot live on `Engine`
|
|
23
|
+
* itself: a per-group single-flight slot pinned to a per-VP Engine is a
|
|
24
|
+
* category error. Compactor is constructed once per session, beside the
|
|
25
|
+
* `dreamScheduler`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { compactHistory, shouldCompactHistory } from '../history-compact.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Output cap for the summarizer call. The full compacted history is
|
|
32
|
+
* built around the LLM-produced summary, so the summary itself must be
|
|
33
|
+
* bounded — too long defeats the point of compacting, too short loses
|
|
34
|
+
* context. 1024 tokens is the value that lived inline in the old
|
|
35
|
+
* `runCompactNow` helper in `web-bridge.js` and matches the budget
|
|
36
|
+
* `compactHistory` plans around.
|
|
37
|
+
*/
|
|
38
|
+
const SUMMARIZER_MAX_TOKENS = 1024;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {object} CompactorHistoryHandle
|
|
42
|
+
* @property {() => Array<object>} get — return the current live history array
|
|
43
|
+
* @property {(next: Array<object>) => void} set — replace the array reference
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {object} CompactedResult
|
|
48
|
+
* @property {string|null} reason
|
|
49
|
+
* @property {number} beforeTurns
|
|
50
|
+
* @property {number} afterTurns
|
|
51
|
+
* @property {number} beforeTokens
|
|
52
|
+
* @property {number} afterTokens
|
|
53
|
+
* @property {number} archivedCount
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
export class Compactor {
|
|
57
|
+
/**
|
|
58
|
+
* @param {object} opts
|
|
59
|
+
* @param {(args: {system: string, prompt: string, maxTokens?: number}) => Promise<string>} opts.summarize
|
|
60
|
+
* Bound to `engine.summarizeForCompact` by `session.js`. Returns
|
|
61
|
+
* the trimmed summary text (or '' on failure — `compactHistory`
|
|
62
|
+
* treats that as a soft failure).
|
|
63
|
+
* @param {() => number|undefined} [opts.getMaxContextTokens]
|
|
64
|
+
* Returns `config.maxContextTokens` for `shouldCompactHistory`.
|
|
65
|
+
* @param {(groupId: string, result: CompactedResult) => void} [opts.onCompacted]
|
|
66
|
+
* Optional sink. Bridge wires this to send the
|
|
67
|
+
* `unify_history_compacted` WS event. Default: no-op. Can be
|
|
68
|
+
* replaced post-construction via `setOnCompacted`.
|
|
69
|
+
*/
|
|
70
|
+
constructor({ summarize, getMaxContextTokens, onCompacted } = {}) {
|
|
71
|
+
if (typeof summarize !== 'function') {
|
|
72
|
+
throw new TypeError('Compactor: summarize is required');
|
|
73
|
+
}
|
|
74
|
+
this._summarize = summarize;
|
|
75
|
+
this._getMaxContextTokens = typeof getMaxContextTokens === 'function'
|
|
76
|
+
? getMaxContextTokens
|
|
77
|
+
: () => undefined;
|
|
78
|
+
this._onCompacted = typeof onCompacted === 'function' ? onCompacted : () => {};
|
|
79
|
+
/** @type {Map<string, { inFlight: Promise<void>|null, pending: boolean }>} */
|
|
80
|
+
this._states = new Map();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Replace the post-success sink. Bridge calls this from
|
|
85
|
+
* `installUnifyRuntimeBridge` once the bridge-local
|
|
86
|
+
* `sendToServer` + `unifyConversationId` are available — keeps WS
|
|
87
|
+
* knowledge out of Compactor's constructor and avoids a circular
|
|
88
|
+
* import between `session.js` and `web-bridge.js`.
|
|
89
|
+
*/
|
|
90
|
+
setOnCompacted(fn) {
|
|
91
|
+
this._onCompacted = typeof fn === 'function' ? fn : () => {};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Get-or-create the per-group state record. */
|
|
95
|
+
_state(groupId) {
|
|
96
|
+
let s = this._states.get(groupId);
|
|
97
|
+
if (!s) {
|
|
98
|
+
s = { inFlight: null, pending: false };
|
|
99
|
+
this._states.set(groupId, s);
|
|
100
|
+
}
|
|
101
|
+
return s;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Entry-gate await. Bridge calls at the top of `handleUnifyGroupChat`
|
|
106
|
+
* so a brand-new turn never reads a half-mutated history mid-compact.
|
|
107
|
+
* Other groups' compacts never block this gate (per-group keying).
|
|
108
|
+
*
|
|
109
|
+
* @param {string} groupId
|
|
110
|
+
*/
|
|
111
|
+
async awaitInFlight(groupId) {
|
|
112
|
+
if (!groupId) return;
|
|
113
|
+
const s = this._states.get(groupId);
|
|
114
|
+
if (s && s.inFlight) {
|
|
115
|
+
try { await s.inFlight; } catch { /* _runOnce already logs */ }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Post-turn fire-and-forget. Bridge calls after the per-VP fanout
|
|
121
|
+
* completes for a turn. The `historyHandle` MUST be a per-call value:
|
|
122
|
+
* its `get` / `set` close over the bridge's groupId-scoped helpers
|
|
123
|
+
* (`getOrCreateGroupHistory` / `setGroupHistory`), not over a frozen
|
|
124
|
+
* snapshot, so a chained follow-up sees fresh state.
|
|
125
|
+
*
|
|
126
|
+
* Anti-starvation: while a compact is in flight, additional
|
|
127
|
+
* `scheduleAfterTurn` calls collapse to a single `pending=true`. Once
|
|
128
|
+
* the in-flight finishes, exactly one follow-up runs (no matter how
|
|
129
|
+
* many turns piled up during the await).
|
|
130
|
+
*
|
|
131
|
+
* @param {string} groupId
|
|
132
|
+
* @param {CompactorHistoryHandle} historyHandle
|
|
133
|
+
*/
|
|
134
|
+
scheduleAfterTurn(groupId, historyHandle) {
|
|
135
|
+
if (!groupId || !historyHandle) return;
|
|
136
|
+
const s = this._state(groupId);
|
|
137
|
+
if (s.inFlight) {
|
|
138
|
+
// Anti-starvation: compact is already running. Mark a follow-up
|
|
139
|
+
// so when it finishes, it re-evaluates and runs again if still
|
|
140
|
+
// triggered.
|
|
141
|
+
s.pending = true;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
s.inFlight = this._runOnce(groupId, historyHandle).finally(() => {
|
|
145
|
+
s.inFlight = null;
|
|
146
|
+
// If turns piled up while we were running and compaction is still
|
|
147
|
+
// needed, chain a follow-up. Use a microtask so the .finally
|
|
148
|
+
// chain settles cleanly before the next promise is created.
|
|
149
|
+
if (s.pending) {
|
|
150
|
+
s.pending = false;
|
|
151
|
+
queueMicrotask(() => this.scheduleAfterTurn(groupId, historyHandle));
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* One compact pass. Captures snapshot reference + length, runs the
|
|
158
|
+
* precheck, calls `compactHistory`, race-guards, applies via
|
|
159
|
+
* `historyHandle.set`, then notifies `onCompacted`. All errors are
|
|
160
|
+
* swallowed (logged) so `inFlight` always clears.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} groupId
|
|
163
|
+
* @param {CompactorHistoryHandle} historyHandle
|
|
164
|
+
*/
|
|
165
|
+
async _runOnce(groupId, historyHandle) {
|
|
166
|
+
try {
|
|
167
|
+
// Capture reference AND length. If a different code path swaps the
|
|
168
|
+
// array (`consolidate` event, session reset, manual clear) the
|
|
169
|
+
// reference will differ. If a driver path push-mutates new
|
|
170
|
+
// messages in place (e.g. `route_forward` triggering a new VP
|
|
171
|
+
// turn during compact), the reference is the same but the length
|
|
172
|
+
// grew. Both cases mean our snapshot is no longer canonical.
|
|
173
|
+
const snapshot = historyHandle.get();
|
|
174
|
+
if (!Array.isArray(snapshot) || snapshot.length === 0) return;
|
|
175
|
+
const snapshotLen = snapshot.length;
|
|
176
|
+
|
|
177
|
+
const maxContextTokens = this._getMaxContextTokens();
|
|
178
|
+
|
|
179
|
+
// Cheap O(n) precheck so we don't bother engaging the LLM at all
|
|
180
|
+
// when the conversation is still small. `compactHistory` runs the
|
|
181
|
+
// same check internally, but only after building the summarizer
|
|
182
|
+
// input — this keeps small chats off the LLM altogether.
|
|
183
|
+
const triage = shouldCompactHistory(snapshot, { maxContextTokens });
|
|
184
|
+
if (!triage.trigger) return;
|
|
185
|
+
|
|
186
|
+
const summarize = ({ system, prompt }) =>
|
|
187
|
+
this._summarize({ system, prompt, maxTokens: SUMMARIZER_MAX_TOKENS });
|
|
188
|
+
|
|
189
|
+
const result = await compactHistory(snapshot, { summarize, maxContextTokens });
|
|
190
|
+
if (!result || !result.compacted) {
|
|
191
|
+
if (result && result.error) {
|
|
192
|
+
console.warn(
|
|
193
|
+
`[Unify] history compact: summarizer failed (${result.error}); ` +
|
|
194
|
+
`keeping ${result.beforeTurns} turns / ~${result.beforeTokens} tokens`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Race guard: if the group's history was reassigned during the
|
|
201
|
+
// await (e.g. consolidate / reset), or push-mutated by a driver
|
|
202
|
+
// path, do NOT overwrite the fresh state with our stale compacted
|
|
203
|
+
// snapshot. These discards are EXPECTED during normal session
|
|
204
|
+
// resets / route_forward bursts — log at debug, not info.
|
|
205
|
+
const current = historyHandle.get();
|
|
206
|
+
if (current !== snapshot) {
|
|
207
|
+
console.debug('[Unify] history compact: history was reset during compact — discarding stale summary');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (current.length !== snapshotLen) {
|
|
211
|
+
console.debug('[Unify] history compact: history was appended-to during compact — discarding stale summary');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
historyHandle.set(result.messages);
|
|
216
|
+
console.log(
|
|
217
|
+
`[Unify] history compacted (reason=${result.reason}): ` +
|
|
218
|
+
`turns ${result.beforeTurns}→${result.afterTurns}, ` +
|
|
219
|
+
`tokens ~${result.beforeTokens}→${result.afterTokens}, ` +
|
|
220
|
+
`archived ${result.archivedCount} messages`
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
this._onCompacted(groupId, {
|
|
225
|
+
reason: result.reason,
|
|
226
|
+
beforeTurns: result.beforeTurns,
|
|
227
|
+
afterTurns: result.afterTurns,
|
|
228
|
+
beforeTokens: result.beforeTokens,
|
|
229
|
+
afterTokens: result.afterTokens,
|
|
230
|
+
archivedCount: result.archivedCount,
|
|
231
|
+
});
|
|
232
|
+
} catch { /* sink failure must not abort the orchestrator */ }
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.warn(`[Unify] history compact: unexpected failure (groupId=${groupId})`, err?.message || err);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Test-only: clear all per-group state. */
|
|
239
|
+
__testReset() {
|
|
240
|
+
this._states.clear();
|
|
241
|
+
}
|
|
242
|
+
}
|
package/unify/session.js
CHANGED
|
@@ -23,6 +23,7 @@ import { MCPManager } from './mcp.js';
|
|
|
23
23
|
import { createFullRegistry } from './tools/index.js';
|
|
24
24
|
import { initFeatureStore } from './tools/feature-tools.js';
|
|
25
25
|
import { Engine } from './engine.js';
|
|
26
|
+
import { Compactor } from './compact/compactor.js';
|
|
26
27
|
// H2.f.5: threads/, pipeline/dispatcher and input-queue retired. The
|
|
27
28
|
// session now exposes a single Engine.
|
|
28
29
|
//
|
|
@@ -65,6 +66,7 @@ import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe } from '
|
|
|
65
66
|
/**
|
|
66
67
|
* @typedef {Object} Session
|
|
67
68
|
* @property {Engine} engine — The wired engine, ready for .query()
|
|
69
|
+
* @property {import('./compact/compactor.js').Compactor} compactor — Per-group history compactor
|
|
68
70
|
* @property {import('./llm/adapter.js').LLMAdapter} adapter — The LLM adapter
|
|
69
71
|
* @property {object} config — Resolved configuration
|
|
70
72
|
* @property {ConversationStore} conversationStore — Conversation persistence
|
|
@@ -303,6 +305,18 @@ export async function loadSession(options = {}) {
|
|
|
303
305
|
yeaftDir,
|
|
304
306
|
});
|
|
305
307
|
|
|
308
|
+
// ─── 9a-pre. Create per-group history Compactor ────────
|
|
309
|
+
// Owns the post-turn single-flight + race-guarded compactor that used
|
|
310
|
+
// to live inline in web-bridge.js. Bridge keeps history ownership and
|
|
311
|
+
// wires the WS sink (`unify_history_compacted`) via
|
|
312
|
+
// `compactor.setOnCompacted` from `installUnifyRuntimeBridge`.
|
|
313
|
+
const compactor = new Compactor({
|
|
314
|
+
summarize: ({ system, prompt, maxTokens } = {}) =>
|
|
315
|
+
engine.summarizeForCompact({ system, prompt, maxTokens }),
|
|
316
|
+
getMaxContextTokens: () =>
|
|
317
|
+
typeof config.maxContextTokens === 'number' ? config.maxContextTokens : undefined,
|
|
318
|
+
});
|
|
319
|
+
|
|
306
320
|
// ─── 9a. Create dream scheduler ────────────
|
|
307
321
|
// The legacy R6 dream-scheduler was retired alongside recall-r6;
|
|
308
322
|
// dream-v2 is the only active path (the `config.memoryV2` opt-out
|
|
@@ -396,6 +410,7 @@ export async function loadSession(options = {}) {
|
|
|
396
410
|
config,
|
|
397
411
|
conversationStore,
|
|
398
412
|
dreamScheduler,
|
|
413
|
+
compactor,
|
|
399
414
|
skillManager,
|
|
400
415
|
mcpManager,
|
|
401
416
|
toolRegistry,
|
package/unify/web-bridge.js
CHANGED
|
@@ -46,8 +46,6 @@ import { openGroup, loadGroupMeta } from './groups/group-store.js';
|
|
|
46
46
|
import { createCoordinator } from './groups/coordinator.js';
|
|
47
47
|
import { seedDefaultGroup } from './groups/seed-default.js';
|
|
48
48
|
import {
|
|
49
|
-
shouldCompactHistory,
|
|
50
|
-
compactHistory,
|
|
51
49
|
trimSnapshotForBudget,
|
|
52
50
|
} from './history-compact.js';
|
|
53
51
|
import { persistUnifyAttachments, attachmentsForPersistence } from './attachments.js';
|
|
@@ -261,11 +259,8 @@ let _vpUnsubscribe = null;
|
|
|
261
259
|
* from disk (or explicitly assigned). The flag is required because an
|
|
262
260
|
* empty array is legitimate post-consolidate / post-clear state and
|
|
263
261
|
* MUST NOT trigger a re-hydrate. Without the flag, a partial entry
|
|
264
|
-
*
|
|
265
|
-
* short-circuit `getOrCreateGroupHistory` on truthy `[]` and skip
|
|
262
|
+
* would short-circuit `getOrCreateGroupHistory` on truthy `[]` and skip
|
|
266
263
|
* the disk load.
|
|
267
|
-
* @property {{inFlight: Promise<void>|null, pending: boolean}} [_compact]
|
|
268
|
-
* per-group compact state, lazily attached.
|
|
269
264
|
*/
|
|
270
265
|
|
|
271
266
|
/** Build a fresh stub entry with no coord/router/history loaded. */
|
|
@@ -350,10 +345,10 @@ function getOrCreateGroupHistory(groupId) {
|
|
|
350
345
|
let entry = groupContexts.get(groupId);
|
|
351
346
|
// Use `historyHydrated` rather than truthiness on `history` itself —
|
|
352
347
|
// an empty array (post-consolidate, post-clear, or a partial entry
|
|
353
|
-
// seeded by `
|
|
354
|
-
// legitimate state that does NOT mean "needs hydration"...
|
|
355
|
-
// never loaded from disk in the first place. The flag
|
|
356
|
-
// two cases.
|
|
348
|
+
// seeded by an early `getOrCreateGroupContext` call before data was
|
|
349
|
+
// loaded) is legitimate state that does NOT mean "needs hydration"...
|
|
350
|
+
// unless we never loaded from disk in the first place. The flag
|
|
351
|
+
// separates the two cases.
|
|
357
352
|
if (entry && entry.historyHydrated) return entry.history;
|
|
358
353
|
if (!entry) {
|
|
359
354
|
entry = makeGroupContextStub();
|
|
@@ -492,9 +487,9 @@ function getOrCreateGroupContext(groupId, groupHandle) {
|
|
|
492
487
|
let entry = groupContexts.get(groupId);
|
|
493
488
|
if (entry && entry.coord && entry.router) return entry;
|
|
494
489
|
// Either no entry, or a partial entry seeded by `getOrCreateGroupHistory`
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
490
|
+
// (no coord/router yet). Build the coord/router and merge into the
|
|
491
|
+
// existing record so the per-group history reference and hydration
|
|
492
|
+
// flag are preserved.
|
|
498
493
|
const coord = createCoordinator(groupHandle, {
|
|
499
494
|
deliver: (vpId, envelope) => enqueueForVp(groupId, vpId, envelope),
|
|
500
495
|
});
|
|
@@ -732,6 +727,12 @@ export async function __testResetVpState() {
|
|
|
732
727
|
vpEngines.clear();
|
|
733
728
|
vpAborts.clear();
|
|
734
729
|
groupContexts.clear();
|
|
730
|
+
// Per-group compact in-flight + pending state lives on the session's
|
|
731
|
+
// Compactor. Clear it so a follow-on test doesn't see ghost in-flight
|
|
732
|
+
// promises from a prior run.
|
|
733
|
+
if (session?.compactor && typeof session.compactor.__testReset === 'function') {
|
|
734
|
+
session.compactor.__testReset();
|
|
735
|
+
}
|
|
735
736
|
}
|
|
736
737
|
|
|
737
738
|
|
|
@@ -1097,6 +1098,28 @@ export function installUnifyRuntimeBridge(s) {
|
|
|
1097
1098
|
} catch { /* never let event delivery throw */ }
|
|
1098
1099
|
};
|
|
1099
1100
|
|
|
1101
|
+
// Wire the post-compact WS sink. Compactor is constructed in
|
|
1102
|
+
// session.js with a no-op sink; bridge owns `sendUnifyEvent` /
|
|
1103
|
+
// `unifyConversationId`, so the sink is wired here once a session is
|
|
1104
|
+
// present. Per-group `unify_history_compacted` events fire after a
|
|
1105
|
+
// successful summarize+swap.
|
|
1106
|
+
if (s.compactor && typeof s.compactor.setOnCompacted === 'function') {
|
|
1107
|
+
s.compactor.setOnCompacted((groupId, result) => {
|
|
1108
|
+
try {
|
|
1109
|
+
sendUnifyEvent({
|
|
1110
|
+
type: 'unify_history_compacted',
|
|
1111
|
+
reason: result?.reason ?? null,
|
|
1112
|
+
beforeTurns: result?.beforeTurns,
|
|
1113
|
+
afterTurns: result?.afterTurns,
|
|
1114
|
+
beforeTokens: result?.beforeTokens,
|
|
1115
|
+
afterTokens: result?.afterTokens,
|
|
1116
|
+
archivedCount: result?.archivedCount,
|
|
1117
|
+
ts: Date.now(),
|
|
1118
|
+
}, { groupId });
|
|
1119
|
+
} catch { /* WS pipeline failure must not crash compact */ }
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1100
1123
|
ctx.unifyRuntimeSettings = {
|
|
1101
1124
|
// No multi-thread settings to surface anymore. Stub for back-compat
|
|
1102
1125
|
// with message-router's update_unify_settings branch — assignments are
|
|
@@ -1433,10 +1456,11 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1433
1456
|
// history. Compact runs at turn END (post-fanout) so it does not
|
|
1434
1457
|
// block the user's current message latency, but a fast double-send
|
|
1435
1458
|
// from the user must not race with the swap. Other groups' compacts
|
|
1436
|
-
// never block this gate.
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1459
|
+
// never block this gate. Compactor is created in session.js — until
|
|
1460
|
+
// a session has loaded (or in test paths that never call
|
|
1461
|
+
// `ensureSessionLoaded`) it may be unavailable; skip gracefully.
|
|
1462
|
+
if (session?.compactor) {
|
|
1463
|
+
await session.compactor.awaitInFlight(groupId);
|
|
1440
1464
|
}
|
|
1441
1465
|
|
|
1442
1466
|
// yeaftDir is a hard prerequisite for both session boot and group seeding;
|
|
@@ -1700,13 +1724,21 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1700
1724
|
await waitForVpDrivers(groupId, primaryTargets);
|
|
1701
1725
|
|
|
1702
1726
|
// Post-turn compaction. Triggers when the JUST-APPENDED turn pushed
|
|
1703
|
-
// history past
|
|
1704
|
-
// background — does not block the response to this message.
|
|
1705
|
-
// next user message in the same group awaits its per-group
|
|
1706
|
-
//
|
|
1707
|
-
// so the swap is guaranteed to be
|
|
1708
|
-
// baseSnapshot capture. Errors are
|
|
1709
|
-
|
|
1727
|
+
// history past the configured token thresholds for THIS group. Runs
|
|
1728
|
+
// in the background — does not block the response to this message.
|
|
1729
|
+
// The next user message in the same group awaits its per-group
|
|
1730
|
+
// in-flight at the entry gate (handleUnifyGroupChat top) via
|
|
1731
|
+
// `compactor.awaitInFlight`, so the swap is guaranteed to be
|
|
1732
|
+
// observed before the next baseSnapshot capture. Errors are
|
|
1733
|
+
// swallowed; next turn retries. The Compactor takes a per-call
|
|
1734
|
+
// historyHandle so the bridge keeps history ownership and the
|
|
1735
|
+
// Compactor stays ignorant of `groupContexts` / `historyHydrated`.
|
|
1736
|
+
if (session?.compactor && groupId) {
|
|
1737
|
+
session.compactor.scheduleAfterTurn(groupId, {
|
|
1738
|
+
get: () => getOrCreateGroupHistory(groupId),
|
|
1739
|
+
set: (next) => setGroupHistory(groupId, next),
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1710
1742
|
}
|
|
1711
1743
|
|
|
1712
1744
|
/**
|
|
@@ -2258,181 +2290,13 @@ function persistUserMessageOnceByMsgId({ msgId, text, groupId }) {
|
|
|
2258
2290
|
*/
|
|
2259
2291
|
const _persistedUserMsgIds = new Set();
|
|
2260
2292
|
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
*
|
|
2269
|
-
* @typedef {{ inFlight: Promise<void>|null, pending: boolean }} CompactState
|
|
2270
|
-
*/
|
|
2271
|
-
|
|
2272
|
-
/** Get-or-create the per-group compact state. */
|
|
2273
|
-
function getCompactState(groupId) {
|
|
2274
|
-
if (!groupId) return { inFlight: null, pending: false };
|
|
2275
|
-
let entry = groupContexts.get(groupId);
|
|
2276
|
-
if (!entry) {
|
|
2277
|
-
// Don't pre-hydrate history here. The stub leaves
|
|
2278
|
-
// `historyHydrated:false`, so the first `getOrCreateGroupHistory`
|
|
2279
|
-
// call still triggers disk hydration. (Pre-fix this seeded
|
|
2280
|
-
// `history: []` and `getOrCreateGroupHistory` short-circuited on
|
|
2281
|
-
// the truthy empty array, leaving the group amnesiac for any
|
|
2282
|
-
// entry path that didn't first go through `handleUnifyLoadHistory`.)
|
|
2283
|
-
entry = makeGroupContextStub();
|
|
2284
|
-
groupContexts.set(groupId, entry);
|
|
2285
|
-
}
|
|
2286
|
-
if (!entry._compact) entry._compact = { inFlight: null, pending: false };
|
|
2287
|
-
return entry._compact;
|
|
2288
|
-
}
|
|
2289
|
-
|
|
2290
|
-
/**
|
|
2291
|
-
* Fire-and-forget post-turn compaction. Called once at the end of each
|
|
2292
|
-
* `handleUnifyGroupChat` after `Promise.all(runVpTurn)` resolves. If a
|
|
2293
|
-
* compaction is still in flight from an earlier turn IN THIS GROUP, we
|
|
2294
|
-
* set `_compact.pending` so the running compact chains a follow-up on
|
|
2295
|
-
* completion (anti-starvation).
|
|
2296
|
-
*
|
|
2297
|
-
* The promise is stored on the per-group state so the next user message
|
|
2298
|
-
* in the SAME group can await it before reading the group's history.
|
|
2299
|
-
*
|
|
2300
|
-
* @param {string} groupId — for envelope tagging on the emitted event
|
|
2301
|
-
*/
|
|
2302
|
-
function scheduleCompactAfterTurn(groupId) {
|
|
2303
|
-
if (!groupId) return;
|
|
2304
|
-
const cs = getCompactState(groupId);
|
|
2305
|
-
if (cs.inFlight) {
|
|
2306
|
-
// A compact is already running. Mark a follow-up so when it
|
|
2307
|
-
// finishes, it re-evaluates and runs again if still triggered.
|
|
2308
|
-
cs.pending = true;
|
|
2309
|
-
return;
|
|
2310
|
-
}
|
|
2311
|
-
// Cheap O(n) precheck so we don't bother engaging the LLM at all
|
|
2312
|
-
// when the conversation is still small. Mirrors the policy that
|
|
2313
|
-
// `runCompactNow` will apply: 30K soft floor / 40 % of configured
|
|
2314
|
-
// context / 200K hard ceiling. (The turn-count trigger is off by
|
|
2315
|
-
// default — DEFAULT_TURN_LIMIT=Infinity — pin `turnLimit` via opts
|
|
2316
|
-
// to re-enable it.)
|
|
2317
|
-
const maxContextTokens =
|
|
2318
|
-
typeof session?.config?.maxContextTokens === 'number'
|
|
2319
|
-
? session.config.maxContextTokens
|
|
2320
|
-
: undefined;
|
|
2321
|
-
const triage = shouldCompactHistory(getOrCreateGroupHistory(groupId), { maxContextTokens });
|
|
2322
|
-
if (!triage.trigger) return;
|
|
2323
|
-
if (!session?.engine || typeof session.engine.summarizeForCompact !== 'function') {
|
|
2324
|
-
console.warn('[Unify] history compact: engine.summarizeForCompact unavailable — skipping');
|
|
2325
|
-
return;
|
|
2326
|
-
}
|
|
2327
|
-
|
|
2328
|
-
cs.inFlight = runCompactNow(groupId).finally(() => {
|
|
2329
|
-
cs.inFlight = null;
|
|
2330
|
-
// If turns piled up while we were running and compaction is still
|
|
2331
|
-
// needed, chain a follow-up. Use a microtask so the .finally chain
|
|
2332
|
-
// settles cleanly before the next promise is created.
|
|
2333
|
-
if (cs.pending) {
|
|
2334
|
-
cs.pending = false;
|
|
2335
|
-
queueMicrotask(() => scheduleCompactAfterTurn(groupId));
|
|
2336
|
-
}
|
|
2337
|
-
});
|
|
2338
|
-
}
|
|
2339
|
-
|
|
2340
|
-
/**
|
|
2341
|
-
* Run the in-memory history compactor for ONE group. Replaces the older
|
|
2342
|
-
* prefix of the group's history with a single user-role summary
|
|
2343
|
-
* message, preserving the recent tail verbatim. Mutates the per-group
|
|
2344
|
-
* array via reassignment (`setGroupHistory`).
|
|
2345
|
-
*
|
|
2346
|
-
* Behaviour:
|
|
2347
|
-
* - If summarization fails, leaves history untouched.
|
|
2348
|
-
* - On success, emits a `unify_history_compacted` event so dev tools
|
|
2349
|
-
* can show what happened (frontend currently ignores it).
|
|
2350
|
-
*
|
|
2351
|
-
* Race safety:
|
|
2352
|
-
* - Single-flight via per-group `_compact.inFlight` (only one runs
|
|
2353
|
-
* at a time per group).
|
|
2354
|
-
* - Captures the array reference AND its length on entry. If anything
|
|
2355
|
-
* reassigns the group's history during the await (`consolidate` event
|
|
2356
|
-
* from the engine, `clearUnifyMessages`, `resetUnifySession`), we
|
|
2357
|
-
* detect the swap by reference comparison. If a `route_forward`
|
|
2358
|
-
* driver path appends new messages in place during the await
|
|
2359
|
-
* (push-mutate, not reassignment), the length grew — also bail,
|
|
2360
|
-
* because writing back the stale compacted view would silently drop
|
|
2361
|
-
* the in-flight messages. (Disk persistence is independent — the
|
|
2362
|
-
* stop-hooks already wrote those messages to disk.)
|
|
2363
|
-
*
|
|
2364
|
-
* @param {string} groupId
|
|
2365
|
-
* @returns {Promise<void>}
|
|
2366
|
-
*/
|
|
2367
|
-
async function runCompactNow(groupId) {
|
|
2368
|
-
const summarize = ({ system, prompt }) =>
|
|
2369
|
-
session.engine.summarizeForCompact({ system, prompt, maxTokens: 1024 });
|
|
2370
|
-
|
|
2371
|
-
// Capture the current array reference AND its length. If anyone
|
|
2372
|
-
// reassigns the group's history while we're summarizing (engine
|
|
2373
|
-
// consolidate event, session reset, manual clear), the reference will
|
|
2374
|
-
// differ. If a driver path push-mutates new messages in place
|
|
2375
|
-
// (route_forward turning into a new VP turn during compact), the
|
|
2376
|
-
// reference is the same but the length grew. Both cases mean the
|
|
2377
|
-
// snapshot we summarized is no longer the canonical state — bail.
|
|
2378
|
-
const snapshot = getOrCreateGroupHistory(groupId);
|
|
2379
|
-
const snapshotLen = snapshot.length;
|
|
2380
|
-
|
|
2381
|
-
// Pull the user-configured context width so the 40 %-of-context
|
|
2382
|
-
// threshold auto-adjusts to whatever model they're on. Falls back to
|
|
2383
|
-
// the module default when missing.
|
|
2384
|
-
const maxContextTokens =
|
|
2385
|
-
typeof session?.config?.maxContextTokens === 'number'
|
|
2386
|
-
? session.config.maxContextTokens
|
|
2387
|
-
: undefined;
|
|
2388
|
-
|
|
2389
|
-
try {
|
|
2390
|
-
const result = await compactHistory(snapshot, { summarize, maxContextTokens });
|
|
2391
|
-
if (!result.compacted) {
|
|
2392
|
-
if (result.error) {
|
|
2393
|
-
console.warn(
|
|
2394
|
-
`[Unify] history compact: summarizer failed (${result.error}); ` +
|
|
2395
|
-
`keeping ${result.beforeTurns} turns / ~${result.beforeTokens} tokens`
|
|
2396
|
-
);
|
|
2397
|
-
}
|
|
2398
|
-
return;
|
|
2399
|
-
}
|
|
2400
|
-
// Race guard: if the group's history was reassigned during the
|
|
2401
|
-
// await (e.g. consolidate / reset), or push-mutated by a driver
|
|
2402
|
-
// path (e.g. a route_forward triggered VP turn appending), do NOT
|
|
2403
|
-
// overwrite the fresh state with our stale compacted snapshot.
|
|
2404
|
-
const current = getOrCreateGroupHistory(groupId);
|
|
2405
|
-
if (current !== snapshot) {
|
|
2406
|
-
console.log('[Unify] history compact: history was reset during compact — discarding stale summary');
|
|
2407
|
-
return;
|
|
2408
|
-
}
|
|
2409
|
-
if (current.length !== snapshotLen) {
|
|
2410
|
-
console.log('[Unify] history compact: history was appended-to during compact — discarding stale summary');
|
|
2411
|
-
return;
|
|
2412
|
-
}
|
|
2413
|
-
setGroupHistory(groupId, result.messages);
|
|
2414
|
-
console.log(
|
|
2415
|
-
`[Unify] history compacted (reason=${result.reason}): ` +
|
|
2416
|
-
`turns ${result.beforeTurns}→${result.afterTurns}, ` +
|
|
2417
|
-
`tokens ~${result.beforeTokens}→${result.afterTokens}, ` +
|
|
2418
|
-
`archived ${result.archivedCount} messages`
|
|
2419
|
-
);
|
|
2420
|
-
try {
|
|
2421
|
-
sendUnifyEvent({
|
|
2422
|
-
type: 'unify_history_compacted',
|
|
2423
|
-
reason: result.reason,
|
|
2424
|
-
beforeTurns: result.beforeTurns,
|
|
2425
|
-
afterTurns: result.afterTurns,
|
|
2426
|
-
beforeTokens: result.beforeTokens,
|
|
2427
|
-
afterTokens: result.afterTokens,
|
|
2428
|
-
archivedCount: result.archivedCount,
|
|
2429
|
-
ts: Date.now(),
|
|
2430
|
-
}, { groupId });
|
|
2431
|
-
} catch { /* WS pipeline failure must not crash compact */ }
|
|
2432
|
-
} catch (err) {
|
|
2433
|
-
console.warn('[Unify] history compact: unexpected failure', err?.message || err);
|
|
2434
|
-
}
|
|
2435
|
-
}
|
|
2293
|
+
// Per-group post-turn compaction lives on `session.compactor`
|
|
2294
|
+
// (`agent/unify/compact/compactor.js`), constructed in `session.js`.
|
|
2295
|
+
// Bridge passes a per-call `historyHandle = { get, set }` and wires the
|
|
2296
|
+
// `unify_history_compacted` WS sink via `compactor.setOnCompacted` from
|
|
2297
|
+
// `installUnifyRuntimeBridge`. The bridge keeps history ownership; the
|
|
2298
|
+
// Compactor owns single-flight, anti-starvation, race-guard, and the
|
|
2299
|
+
// LLM summarize call.
|
|
2436
2300
|
|
|
2437
2301
|
/**
|
|
2438
2302
|
* Abort every in-flight VP turn and clear all queued envelopes across
|