@yeaft/webchat-agent 0.1.747 → 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/engine.js +11 -0
- package/unify/session.js +15 -0
- package/unify/stop-hooks.js +51 -12
- package/unify/web-bridge.js +294 -213
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/engine.js
CHANGED
|
@@ -1755,6 +1755,17 @@ export class Engine {
|
|
|
1755
1755
|
config: this.#fastConfig,
|
|
1756
1756
|
primaryModel: this.#config.model,
|
|
1757
1757
|
messages: conversationMessages,
|
|
1758
|
+
// Reflect-persist fix: tell stop-hooks the EXACT turn boundary
|
|
1759
|
+
// instead of letting it heuristically scan back to the last
|
|
1760
|
+
// role:'user'. With T1/T2 reflection collapse, the last
|
|
1761
|
+
// role:'user' is the synthetic reflection message — not the
|
|
1762
|
+
// original user prompt — so the heuristic was dropping
|
|
1763
|
+
// earlier reflection messages and the original prompt off
|
|
1764
|
+
// the persistence window. `turnStartIdx` is the index of
|
|
1765
|
+
// the original user prompt (set at query() entry); slicing
|
|
1766
|
+
// from there persists the full collapsed turn including all
|
|
1767
|
+
// reflection messages and the trailing assistant response.
|
|
1768
|
+
turnStartIdx,
|
|
1758
1769
|
trace: this.#trace,
|
|
1759
1770
|
// Bug 6: tag persisted messages with the originating group so
|
|
1760
1771
|
// history replay can re-stamp them on reload.
|
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/stop-hooks.js
CHANGED
|
@@ -30,6 +30,7 @@ let _permissionWarned = false;
|
|
|
30
30
|
* config: object,
|
|
31
31
|
* primaryModel?: string,
|
|
32
32
|
* messages?: object[],
|
|
33
|
+
* turnStartIdx?: number,
|
|
33
34
|
* taskId?: string,
|
|
34
35
|
* workerId?: string,
|
|
35
36
|
* trace?: object,
|
|
@@ -45,6 +46,19 @@ export async function runStopHooks(context) {
|
|
|
45
46
|
config,
|
|
46
47
|
primaryModel,
|
|
47
48
|
messages = [],
|
|
49
|
+
// Reflect-persist fix: when the engine knows the exact turn boundary
|
|
50
|
+
// (it does — `turnStartIdx` is set at query() entry as
|
|
51
|
+
// `conversationMessages.length - 1`), pass it in. The legacy
|
|
52
|
+
// heuristic of "scan back for the last role:'user'" is wrong once
|
|
53
|
+
// T1/T2 reflection has collapsed the tool arc into a synthetic
|
|
54
|
+
// role:'user' message — that synthetic message would be picked as
|
|
55
|
+
// the turn start, dropping the original prompt AND any earlier
|
|
56
|
+
// reflection messages from the persistence window.
|
|
57
|
+
//
|
|
58
|
+
// When undefined, falls back to the legacy heuristic so older
|
|
59
|
+
// callers (sub-agents, workers) that don't pass it continue to
|
|
60
|
+
// work.
|
|
61
|
+
turnStartIdx,
|
|
48
62
|
taskId,
|
|
49
63
|
trace,
|
|
50
64
|
// Bug 6: groupId/threadId stamped on every persisted message so
|
|
@@ -77,19 +91,33 @@ export async function runStopHooks(context) {
|
|
|
77
91
|
// assistant's `toolCalls` and each paired `role:'tool'` result —
|
|
78
92
|
// otherwise restoring history on session reload drops the pairing
|
|
79
93
|
// and causes "No tool output found for function call" 400s on the
|
|
80
|
-
// next chat-completions request.
|
|
81
|
-
//
|
|
82
|
-
//
|
|
94
|
+
// next chat-completions request.
|
|
95
|
+
//
|
|
96
|
+
// Reflect-persist fix: prefer the explicit `turnStartIdx` from the
|
|
97
|
+
// engine when given. The legacy heuristic of "find the last
|
|
98
|
+
// role:'user'" is broken in the presence of T1/T2 reflection
|
|
99
|
+
// collapse, because the collapsed reflection is itself a
|
|
100
|
+
// role:'user' message — using it as the turn start would drop the
|
|
101
|
+
// real user prompt and any earlier reflections.
|
|
83
102
|
try {
|
|
84
103
|
if (conversationStore && messages.length > 0) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
104
|
+
let turnStart;
|
|
105
|
+
if (typeof turnStartIdx === 'number'
|
|
106
|
+
&& Number.isFinite(turnStartIdx)
|
|
107
|
+
&& turnStartIdx >= 0
|
|
108
|
+
&& turnStartIdx < messages.length) {
|
|
109
|
+
// Engine-supplied exact turn boundary — preferred.
|
|
110
|
+
turnStart = turnStartIdx;
|
|
111
|
+
} else {
|
|
112
|
+
// Legacy heuristic — find the last role:'user' message.
|
|
113
|
+
// Used by sub-agent / worker callers that don't compute the
|
|
114
|
+
// boundary explicitly.
|
|
115
|
+
turnStart = messages.length - 1;
|
|
116
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
117
|
+
if (messages[i] && messages[i].role === 'user') {
|
|
118
|
+
turnStart = i;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
93
121
|
}
|
|
94
122
|
}
|
|
95
123
|
const recentMessages = messages.slice(turnStart);
|
|
@@ -99,7 +127,18 @@ export async function runStopHooks(context) {
|
|
|
99
127
|
// this turn (multi-VP fan-out: every VP's engine sees the same
|
|
100
128
|
// user prompt at conversationMessages[turnStart] but only the
|
|
101
129
|
// first writer should land on disk).
|
|
102
|
-
|
|
130
|
+
//
|
|
131
|
+
// With the engine-supplied turnStartIdx, `messages[turnStart]`
|
|
132
|
+
// is the ORIGINAL user prompt (not a reflection placeholder),
|
|
133
|
+
// so the first message in `recentMessages` is the one that
|
|
134
|
+
// gets skipped on subsequent VPs. Reflection messages that
|
|
135
|
+
// come AFTER turnStart still have role:'user' — they are NOT
|
|
136
|
+
// skipped because `userAlreadyPersisted` only suppresses the
|
|
137
|
+
// first user-prompt copy; reflections are per-VP outputs and
|
|
138
|
+
// each VP's reflections are valid contributions to the
|
|
139
|
+
// shared history (this matches today's per-VP fan-out where
|
|
140
|
+
// each VP appends its own assistant + tool rows).
|
|
141
|
+
if (userAlreadyPersisted && msg.role === 'user' && msg === recentMessages[0]) continue;
|
|
103
142
|
// Allow empty assistant content when toolCalls are present;
|
|
104
143
|
// tool messages have content by construction.
|
|
105
144
|
const hasContent =
|
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';
|
|
@@ -236,36 +234,189 @@ let unifyConversationId = null;
|
|
|
236
234
|
let _vpUnsubscribe = null;
|
|
237
235
|
|
|
238
236
|
/**
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
237
|
+
* Per-group conversation history lives on the GroupContext entry
|
|
238
|
+
* (`groupContexts.get(groupId).history`). The pre-refactor module-level
|
|
239
|
+
* `conversationMessages` was a single array shared across every group —
|
|
240
|
+
* a user prompt in group-A would leak into group-B's next-turn snapshot
|
|
241
|
+
* because the bridge appended every turn to the same array regardless
|
|
242
|
+
* of which group it belonged to. Disk was group-tagged correctly, but
|
|
243
|
+
* the in-memory tape was unified.
|
|
244
|
+
*
|
|
245
|
+
* Post-refactor: each GroupContext owns its own `history`, lazily
|
|
246
|
+
* hydrated from `conversationStore.loadRecentByGroup(groupId)` on first
|
|
247
|
+
* access. Group-A and group-B are isolated.
|
|
248
|
+
*
|
|
249
|
+
* @typedef {Array<{role:'user'|'assistant'|'tool', content:string|Array, toolCalls?:Array, toolCallId?:string, isError?:boolean}>} GroupHistory
|
|
242
250
|
*/
|
|
243
|
-
let conversationMessages = [];
|
|
244
251
|
|
|
245
252
|
/**
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
253
|
+
* @typedef {Object} GroupContextEntry
|
|
254
|
+
* @property {object|null} coord — group coordinator (lazily built by getOrCreateGroupContext)
|
|
255
|
+
* @property {object|null} router — message router (lazily built by getOrCreateGroupContext)
|
|
256
|
+
* @property {object|null} groupHandle — opened group handle (lazily built by getOrCreateGroupContext)
|
|
257
|
+
* @property {GroupHistory} history — per-group conversation tape
|
|
258
|
+
* @property {boolean} historyHydrated — true once history has been loaded
|
|
259
|
+
* from disk (or explicitly assigned). The flag is required because an
|
|
260
|
+
* empty array is legitimate post-consolidate / post-clear state and
|
|
261
|
+
* MUST NOT trigger a re-hydrate. Without the flag, a partial entry
|
|
262
|
+
* would short-circuit `getOrCreateGroupHistory` on truthy `[]` and skip
|
|
263
|
+
* the disk load.
|
|
264
|
+
*/
|
|
265
|
+
|
|
266
|
+
/** Build a fresh stub entry with no coord/router/history loaded. */
|
|
267
|
+
function makeGroupContextStub() {
|
|
268
|
+
return {
|
|
269
|
+
coord: null,
|
|
270
|
+
router: null,
|
|
271
|
+
groupHandle: null,
|
|
272
|
+
history: [],
|
|
273
|
+
historyHydrated: false,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Project a persisted message record into the in-memory history shape.
|
|
279
|
+
* Accepts `role:'tool'` and preserves `toolCalls`/`toolCallId` so the
|
|
280
|
+
* next chat-completions serialization includes paired tool messages
|
|
281
|
+
* (avoids "No tool output found for function call" 400s).
|
|
250
282
|
*
|
|
251
|
-
* @param {
|
|
283
|
+
* @param {object} m — record from conversationStore.loadRecent*()
|
|
284
|
+
* @returns {object|null} history-shape entry, or null to skip
|
|
252
285
|
*/
|
|
253
|
-
function
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}));
|
|
265
|
-
}
|
|
266
|
-
if (m.isError) entry.isError = true;
|
|
267
|
-
conversationMessages.push(entry);
|
|
286
|
+
function projectPersistedToHistoryEntry(m) {
|
|
287
|
+
if (!m) return null;
|
|
288
|
+
if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool') return null;
|
|
289
|
+
const entry = { role: m.role, content: m.content };
|
|
290
|
+
if (m.toolCallId) entry.toolCallId = m.toolCallId;
|
|
291
|
+
if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
|
|
292
|
+
entry.toolCalls = m.toolCalls.map(tc => ({
|
|
293
|
+
id: tc.id,
|
|
294
|
+
name: tc.name,
|
|
295
|
+
input: tc.input,
|
|
296
|
+
}));
|
|
268
297
|
}
|
|
298
|
+
if (m.isError) entry.isError = true;
|
|
299
|
+
return entry;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Hydrate a freshly-created GroupContext's history from the on-disk
|
|
304
|
+
* conversation store. Returns an empty array if the session isn't
|
|
305
|
+
* loaded yet (sub-agent / test paths) or if the load throws.
|
|
306
|
+
*
|
|
307
|
+
* @param {string} groupId
|
|
308
|
+
* @returns {GroupHistory}
|
|
309
|
+
*/
|
|
310
|
+
function hydrateGroupHistory(groupId) {
|
|
311
|
+
if (!session?.conversationStore || !groupId) return [];
|
|
312
|
+
let recent;
|
|
313
|
+
try {
|
|
314
|
+
recent = session.conversationStore.loadRecentByGroup(groupId);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
console.warn('[Unify] hydrateGroupHistory failed (groupId=%s):', groupId, err?.message || err);
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
const out = [];
|
|
320
|
+
for (const m of recent || []) {
|
|
321
|
+
const entry = projectPersistedToHistoryEntry(m);
|
|
322
|
+
if (entry) out.push(entry);
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Get-or-create the per-group history array. Used everywhere the bridge
|
|
329
|
+
* needs to read/append/snapshot a group's conversation tape. Lazily
|
|
330
|
+
* inserts an entry into `groupContexts` on first access — no
|
|
331
|
+
* `groupHandle` required (history is independent of coord/router
|
|
332
|
+
* lifecycle, so a sub-agent / route_forward path that hasn't yet
|
|
333
|
+
* opened the group can still read history).
|
|
334
|
+
*
|
|
335
|
+
* Returns the SAME array reference across calls within the same
|
|
336
|
+
* lifecycle, so consumers can mutate-in-place. Reassigned only by
|
|
337
|
+
* compact (race guard checks reference equality), `consolidate`
|
|
338
|
+
* events, and session reset.
|
|
339
|
+
*
|
|
340
|
+
* @param {string} groupId
|
|
341
|
+
* @returns {GroupHistory}
|
|
342
|
+
*/
|
|
343
|
+
function getOrCreateGroupHistory(groupId) {
|
|
344
|
+
if (!groupId) return [];
|
|
345
|
+
let entry = groupContexts.get(groupId);
|
|
346
|
+
// Use `historyHydrated` rather than truthiness on `history` itself —
|
|
347
|
+
// an empty array (post-consolidate, post-clear, or a partial entry
|
|
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.
|
|
352
|
+
if (entry && entry.historyHydrated) return entry.history;
|
|
353
|
+
if (!entry) {
|
|
354
|
+
entry = makeGroupContextStub();
|
|
355
|
+
groupContexts.set(groupId, entry);
|
|
356
|
+
}
|
|
357
|
+
entry.history = hydrateGroupHistory(groupId);
|
|
358
|
+
entry.historyHydrated = true;
|
|
359
|
+
return entry.history;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Reassign a group's history reference. Used by compact + consolidate +
|
|
364
|
+
* clear paths that need to swap the array (not just mutate it). Returns
|
|
365
|
+
* the new reference. Idempotent if the entry doesn't exist (creates one).
|
|
366
|
+
*
|
|
367
|
+
* Sets `historyHydrated = true` because an explicit assignment is itself
|
|
368
|
+
* a hydration — even setting `[]` after `consolidate` means "this is the
|
|
369
|
+
* canonical state right now, don't re-load from disk".
|
|
370
|
+
*
|
|
371
|
+
* @param {string} groupId
|
|
372
|
+
* @param {GroupHistory} next
|
|
373
|
+
*/
|
|
374
|
+
function setGroupHistory(groupId, next) {
|
|
375
|
+
if (!groupId) return;
|
|
376
|
+
let entry = groupContexts.get(groupId);
|
|
377
|
+
if (!entry) {
|
|
378
|
+
entry = makeGroupContextStub();
|
|
379
|
+
groupContexts.set(groupId, entry);
|
|
380
|
+
}
|
|
381
|
+
entry.history = next;
|
|
382
|
+
entry.historyHydrated = true;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Test-only access to a group's history array. Re-exported below as
|
|
387
|
+
* `__testGroupHistory`. Lets tests pin the per-group isolation contract
|
|
388
|
+
* without booting a full session.
|
|
389
|
+
*
|
|
390
|
+
* @param {string} groupId
|
|
391
|
+
*/
|
|
392
|
+
export function __testGroupHistory(groupId) {
|
|
393
|
+
return getOrCreateGroupHistory(groupId);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Test-only: install a minimal `session` so `hydrateGroupHistory` can
|
|
398
|
+
* read from a real `ConversationStore`. Pass `null` to clear.
|
|
399
|
+
*
|
|
400
|
+
* Tests that need to verify the hydrate-from-disk path can construct a
|
|
401
|
+
* `ConversationStore` against a tmp dir, write per-group records via
|
|
402
|
+
* `store.append({groupId, ...})`, then call this helper to wire the
|
|
403
|
+
* store into the bridge before calling `__testGroupHistory(groupId)`.
|
|
404
|
+
*
|
|
405
|
+
* @param {{ conversationStore: object } | null} sessionLike
|
|
406
|
+
*/
|
|
407
|
+
export function __testSetSession(sessionLike) {
|
|
408
|
+
session = sessionLike;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Test-only: peek at the GroupContext entry for a group (or undefined
|
|
413
|
+
* if never seeded). Lets tests assert the `historyHydrated` flag without
|
|
414
|
+
* exporting the entire `groupContexts` Map.
|
|
415
|
+
*
|
|
416
|
+
* @param {string} groupId
|
|
417
|
+
*/
|
|
418
|
+
export function __testGroupContextEntry(groupId) {
|
|
419
|
+
return groupContexts.get(groupId);
|
|
269
420
|
}
|
|
270
421
|
|
|
271
422
|
/** Whether we've already sent a permission warning to the UI */
|
|
@@ -334,13 +485,29 @@ function getOrCreateVpEngine(groupId, vpId) {
|
|
|
334
485
|
*/
|
|
335
486
|
function getOrCreateGroupContext(groupId, groupHandle) {
|
|
336
487
|
let entry = groupContexts.get(groupId);
|
|
337
|
-
if (entry) return entry;
|
|
488
|
+
if (entry && entry.coord && entry.router) return entry;
|
|
489
|
+
// Either no entry, or a partial entry seeded by `getOrCreateGroupHistory`
|
|
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.
|
|
338
493
|
const coord = createCoordinator(groupHandle, {
|
|
339
494
|
deliver: (vpId, envelope) => enqueueForVp(groupId, vpId, envelope),
|
|
340
495
|
});
|
|
341
496
|
const router = createRouter({ coordinator: coord });
|
|
342
|
-
entry
|
|
343
|
-
|
|
497
|
+
if (!entry) {
|
|
498
|
+
entry = makeGroupContextStub();
|
|
499
|
+
groupContexts.set(groupId, entry);
|
|
500
|
+
}
|
|
501
|
+
entry.coord = coord;
|
|
502
|
+
entry.router = router;
|
|
503
|
+
entry.groupHandle = groupHandle;
|
|
504
|
+
// Defend against a future caller that builds a coord/router without
|
|
505
|
+
// having gone through `getOrCreateGroupHistory` first: a partial entry
|
|
506
|
+
// could exist with `historyHydrated:false`, so do the load now.
|
|
507
|
+
if (!entry.historyHydrated) {
|
|
508
|
+
entry.history = hydrateGroupHistory(groupId);
|
|
509
|
+
entry.historyHydrated = true;
|
|
510
|
+
}
|
|
344
511
|
return entry;
|
|
345
512
|
}
|
|
346
513
|
|
|
@@ -411,8 +578,9 @@ function ensureDriverRunning(groupId, vpId) {
|
|
|
411
578
|
turnAbortCtrls.set(turnId, vpAbort);
|
|
412
579
|
// Snapshot history at the moment this turn starts. Later turns in
|
|
413
580
|
// the same driver loop see updated history (post-append from the
|
|
414
|
-
// previous turn).
|
|
415
|
-
|
|
581
|
+
// previous turn). Per-group: each driver only sees its own group's
|
|
582
|
+
// tape, so cross-group prompts never leak into a VP's snapshot.
|
|
583
|
+
const baseSnapshot = [...getOrCreateGroupHistory(groupId)];
|
|
416
584
|
const trigger = envelope?.trigger || 'fallback';
|
|
417
585
|
// Synthesize the prompt. For coordinator-emitted envelopes the
|
|
418
586
|
// text lives at envelope.msg.text. We prefix `@vp-<id>` to mirror
|
|
@@ -559,6 +727,12 @@ export async function __testResetVpState() {
|
|
|
559
727
|
vpEngines.clear();
|
|
560
728
|
vpAborts.clear();
|
|
561
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
|
+
}
|
|
562
736
|
}
|
|
563
737
|
|
|
564
738
|
|
|
@@ -924,6 +1098,28 @@ export function installUnifyRuntimeBridge(s) {
|
|
|
924
1098
|
} catch { /* never let event delivery throw */ }
|
|
925
1099
|
};
|
|
926
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
|
+
|
|
927
1123
|
ctx.unifyRuntimeSettings = {
|
|
928
1124
|
// No multi-thread settings to surface anymore. Stub for back-compat
|
|
929
1125
|
// with message-router's update_unify_settings branch — assignments are
|
|
@@ -1081,8 +1277,9 @@ function handleEngineEvent(event, hctx) {
|
|
|
1081
1277
|
break;
|
|
1082
1278
|
|
|
1083
1279
|
case 'consolidate':
|
|
1084
|
-
// Engine compressed the context — clear
|
|
1085
|
-
|
|
1280
|
+
// Engine compressed the context — clear THIS group's accumulated
|
|
1281
|
+
// history. Other groups' histories stay intact.
|
|
1282
|
+
if (hctx.groupId) setGroupHistory(hctx.groupId, []);
|
|
1086
1283
|
sendUnifyEvent({
|
|
1087
1284
|
type: 'consolidate',
|
|
1088
1285
|
archivedCount: event.archivedCount,
|
|
@@ -1254,13 +1451,16 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1254
1451
|
? msg.groupId.trim()
|
|
1255
1452
|
: 'grp_default';
|
|
1256
1453
|
|
|
1257
|
-
// Entry gate: if a compact is in flight from the previous turn
|
|
1258
|
-
// wait for it to finish before reading
|
|
1259
|
-
// runs at turn END (post-fanout) so it does not
|
|
1260
|
-
// current message latency, but a fast double-send
|
|
1261
|
-
// not race with the swap.
|
|
1262
|
-
|
|
1263
|
-
|
|
1454
|
+
// Entry gate: if a compact is in flight from the previous turn IN
|
|
1455
|
+
// THIS GROUP, wait for it to finish before reading the group's
|
|
1456
|
+
// history. Compact runs at turn END (post-fanout) so it does not
|
|
1457
|
+
// block the user's current message latency, but a fast double-send
|
|
1458
|
+
// from the user must not race with the swap. Other groups' compacts
|
|
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);
|
|
1264
1464
|
}
|
|
1265
1465
|
|
|
1266
1466
|
// yeaftDir is a hard prerequisite for both session boot and group seeding;
|
|
@@ -1524,12 +1724,21 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1524
1724
|
await waitForVpDrivers(groupId, primaryTargets);
|
|
1525
1725
|
|
|
1526
1726
|
// Post-turn compaction. Triggers when the JUST-APPENDED turn pushed
|
|
1527
|
-
// history past
|
|
1528
|
-
// not block the response to this message.
|
|
1529
|
-
//
|
|
1530
|
-
//
|
|
1531
|
-
//
|
|
1532
|
-
|
|
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
|
+
}
|
|
1533
1742
|
}
|
|
1534
1743
|
|
|
1535
1744
|
/**
|
|
@@ -1696,7 +1905,8 @@ async function ensureSessionLoaded() {
|
|
|
1696
1905
|
|
|
1697
1906
|
unifyConversationId = `unify-${Date.now()}`;
|
|
1698
1907
|
|
|
1699
|
-
|
|
1908
|
+
// Per-group history is hydrated lazily on first `getOrCreateGroupHistory`
|
|
1909
|
+
// — there's no global "all conversations" tape any more.
|
|
1700
1910
|
|
|
1701
1911
|
sendUnifyEvent({
|
|
1702
1912
|
type: 'session_ready',
|
|
@@ -1902,7 +2112,7 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1902
2112
|
}
|
|
1903
2113
|
|
|
1904
2114
|
// Turn completed — atomically append this VP's output to shared history.
|
|
1905
|
-
|
|
2115
|
+
appendTurnToGroupHistory(groupId, prompt, assistantTextParts, toolCallsAccum, toolResultsAccum);
|
|
1906
2116
|
|
|
1907
2117
|
sendUnifyOutput({
|
|
1908
2118
|
type: 'assistant',
|
|
@@ -1960,11 +2170,21 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1960
2170
|
}
|
|
1961
2171
|
|
|
1962
2172
|
/**
|
|
1963
|
-
* Atomically append a completed VP-turn's messages to the
|
|
2173
|
+
* Atomically append a completed VP-turn's messages to the GROUP'S
|
|
1964
2174
|
* conversation history. Called once at turn end (not during streaming).
|
|
2175
|
+
*
|
|
2176
|
+
* Note: this does NOT see the engine's collapsed form — it appends the
|
|
2177
|
+
* raw user prompt + the per-VP assistant text + tool results. The
|
|
2178
|
+
* engine's own `conversationMessages` (with T1/T2 collapse applied)
|
|
2179
|
+
* is persisted to disk via stop-hooks, so the next turn's history is
|
|
2180
|
+
* read from disk via `loadRecentByGroup` on next session boot. Within
|
|
2181
|
+
* a session, this in-memory tape carries the un-collapsed form — which
|
|
2182
|
+
* is fine because each VP turn's `engine.query` re-collapses on the fly.
|
|
1965
2183
|
*/
|
|
1966
|
-
function
|
|
1967
|
-
|
|
2184
|
+
function appendTurnToGroupHistory(groupId, prompt, assistantTextParts, toolCallsAccum, toolResultsAccum) {
|
|
2185
|
+
if (!groupId) return;
|
|
2186
|
+
const history = getOrCreateGroupHistory(groupId);
|
|
2187
|
+
history.push({ role: 'user', content: prompt });
|
|
1968
2188
|
|
|
1969
2189
|
const fullText = assistantTextParts.join('');
|
|
1970
2190
|
if (fullText || toolCallsAccum.length > 0) {
|
|
@@ -1976,10 +2196,10 @@ function appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolRes
|
|
|
1976
2196
|
input: tc.input,
|
|
1977
2197
|
}));
|
|
1978
2198
|
}
|
|
1979
|
-
|
|
2199
|
+
history.push(assistantMsg);
|
|
1980
2200
|
|
|
1981
2201
|
for (const tr of toolResultsAccum) {
|
|
1982
|
-
|
|
2202
|
+
history.push({
|
|
1983
2203
|
role: 'tool',
|
|
1984
2204
|
toolCallId: tr.toolCallId,
|
|
1985
2205
|
content: tr.content,
|
|
@@ -2070,160 +2290,13 @@ function persistUserMessageOnceByMsgId({ msgId, text, groupId }) {
|
|
|
2070
2290
|
*/
|
|
2071
2291
|
const _persistedUserMsgIds = new Set();
|
|
2072
2292
|
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
* latency to the user's current message. The trade-off: the next user
|
|
2081
|
-
* message may have to wait briefly for the compact to finish — but
|
|
2082
|
-
* compact uses the fast model and typically completes in 1–3s.
|
|
2083
|
-
*
|
|
2084
|
-
* @type {Promise<void>|null}
|
|
2085
|
-
*/
|
|
2086
|
-
let _compactInFlight = null;
|
|
2087
|
-
|
|
2088
|
-
/**
|
|
2089
|
-
* Re-trigger flag. If `scheduleCompactAfterTurn` is called while a
|
|
2090
|
-
* compact is already in flight, set this so the in-flight one chains
|
|
2091
|
-
* a follow-up immediately on completion. Without this, a sustained
|
|
2092
|
-
* burst of turns could starve compaction: turn N triggers compact,
|
|
2093
|
-
* turns N+1 / N+2 / … each find `_compactInFlight` set and skip,
|
|
2094
|
-
* leaving history above threshold until the burst ends.
|
|
2095
|
-
*/
|
|
2096
|
-
let _compactPending = false;
|
|
2097
|
-
|
|
2098
|
-
/**
|
|
2099
|
-
* Fire-and-forget post-turn compaction. Called once at the end of each
|
|
2100
|
-
* `handleUnifyGroupChat` after `Promise.all(runVpTurn)` resolves. If a
|
|
2101
|
-
* compaction is still in flight from an earlier turn, we set
|
|
2102
|
-
* `_compactPending` so the running compact chains a follow-up on
|
|
2103
|
-
* completion (anti-starvation).
|
|
2104
|
-
*
|
|
2105
|
-
* The promise is stored in `_compactInFlight` so the next user message
|
|
2106
|
-
* can await it before reading `conversationMessages`.
|
|
2107
|
-
*
|
|
2108
|
-
* @param {string} groupId — for envelope tagging on the emitted event
|
|
2109
|
-
*/
|
|
2110
|
-
function scheduleCompactAfterTurn(groupId) {
|
|
2111
|
-
if (_compactInFlight) {
|
|
2112
|
-
// A compact is already running. Mark a follow-up so when it
|
|
2113
|
-
// finishes, it re-evaluates and runs again if still triggered.
|
|
2114
|
-
_compactPending = true;
|
|
2115
|
-
return;
|
|
2116
|
-
}
|
|
2117
|
-
// Cheap O(n) precheck so we don't bother engaging the LLM at all
|
|
2118
|
-
// when the conversation is still small. Mirrors the policy that
|
|
2119
|
-
// `runCompactNow` will apply: 30K soft floor / 40 % of configured
|
|
2120
|
-
// context / 200K hard ceiling. (The turn-count trigger is off by
|
|
2121
|
-
// default — DEFAULT_TURN_LIMIT=Infinity — pin `turnLimit` via opts
|
|
2122
|
-
// to re-enable it.)
|
|
2123
|
-
const maxContextTokens =
|
|
2124
|
-
typeof session?.config?.maxContextTokens === 'number'
|
|
2125
|
-
? session.config.maxContextTokens
|
|
2126
|
-
: undefined;
|
|
2127
|
-
const triage = shouldCompactHistory(conversationMessages, { maxContextTokens });
|
|
2128
|
-
if (!triage.trigger) return;
|
|
2129
|
-
if (!session?.engine || typeof session.engine.summarizeForCompact !== 'function') {
|
|
2130
|
-
console.warn('[Unify] history compact: engine.summarizeForCompact unavailable — skipping');
|
|
2131
|
-
return;
|
|
2132
|
-
}
|
|
2133
|
-
|
|
2134
|
-
_compactInFlight = runCompactNow(groupId).finally(() => {
|
|
2135
|
-
_compactInFlight = null;
|
|
2136
|
-
// If turns piled up while we were running and compaction is still
|
|
2137
|
-
// needed, chain a follow-up. Use a microtask so the .finally chain
|
|
2138
|
-
// settles cleanly before the next promise is created.
|
|
2139
|
-
if (_compactPending) {
|
|
2140
|
-
_compactPending = false;
|
|
2141
|
-
queueMicrotask(() => scheduleCompactAfterTurn(groupId));
|
|
2142
|
-
}
|
|
2143
|
-
});
|
|
2144
|
-
}
|
|
2145
|
-
|
|
2146
|
-
/**
|
|
2147
|
-
* Run the in-memory history compactor. Replaces the older prefix of
|
|
2148
|
-
* `conversationMessages` with a single user-role summary message,
|
|
2149
|
-
* preserving the recent tail verbatim. Mutates the module-level
|
|
2150
|
-
* variable in place via reassignment.
|
|
2151
|
-
*
|
|
2152
|
-
* Behaviour:
|
|
2153
|
-
* - If summarization fails, leaves history untouched.
|
|
2154
|
-
* - On success, emits a `unify_history_compacted` event so dev tools
|
|
2155
|
-
* can show what happened (frontend currently ignores it).
|
|
2156
|
-
*
|
|
2157
|
-
* Race safety:
|
|
2158
|
-
* - Single-flight via `_compactInFlight` (only one runs at a time).
|
|
2159
|
-
* - Reads the array reference once into `snapshot`. If anything else
|
|
2160
|
-
* reassigns `conversationMessages` during the await (`consolidate`
|
|
2161
|
-
* event from the engine, `clearUnifyMessages`, `resetUnifySession`),
|
|
2162
|
-
* we detect the swap by reference comparison and bail without
|
|
2163
|
-
* overwriting their fresh state.
|
|
2164
|
-
*
|
|
2165
|
-
* @param {string} groupId
|
|
2166
|
-
* @returns {Promise<void>}
|
|
2167
|
-
*/
|
|
2168
|
-
async function runCompactNow(groupId) {
|
|
2169
|
-
const summarize = ({ system, prompt }) =>
|
|
2170
|
-
session.engine.summarizeForCompact({ system, prompt, maxTokens: 1024 });
|
|
2171
|
-
|
|
2172
|
-
// Capture the current array reference. If anyone reassigns
|
|
2173
|
-
// `conversationMessages` while we're summarizing (engine consolidate
|
|
2174
|
-
// event, session reset, manual clear), the reference will differ
|
|
2175
|
-
// and we abandon the swap.
|
|
2176
|
-
const snapshot = conversationMessages;
|
|
2177
|
-
|
|
2178
|
-
// Pull the user-configured context width so the 40 %-of-context
|
|
2179
|
-
// threshold auto-adjusts to whatever model they're on. Falls back to
|
|
2180
|
-
// the module default when missing.
|
|
2181
|
-
const maxContextTokens =
|
|
2182
|
-
typeof session?.config?.maxContextTokens === 'number'
|
|
2183
|
-
? session.config.maxContextTokens
|
|
2184
|
-
: undefined;
|
|
2185
|
-
|
|
2186
|
-
try {
|
|
2187
|
-
const result = await compactHistory(snapshot, { summarize, maxContextTokens });
|
|
2188
|
-
if (!result.compacted) {
|
|
2189
|
-
if (result.error) {
|
|
2190
|
-
console.warn(
|
|
2191
|
-
`[Unify] history compact: summarizer failed (${result.error}); ` +
|
|
2192
|
-
`keeping ${result.beforeTurns} turns / ~${result.beforeTokens} tokens`
|
|
2193
|
-
);
|
|
2194
|
-
}
|
|
2195
|
-
return;
|
|
2196
|
-
}
|
|
2197
|
-
// Race guard: if `conversationMessages` was reassigned during the
|
|
2198
|
-
// await (e.g. consolidate / reset), do NOT overwrite the fresh
|
|
2199
|
-
// state with our stale compacted snapshot.
|
|
2200
|
-
if (conversationMessages !== snapshot) {
|
|
2201
|
-
console.log('[Unify] history compact: history was reset during compact — discarding stale summary');
|
|
2202
|
-
return;
|
|
2203
|
-
}
|
|
2204
|
-
conversationMessages = result.messages;
|
|
2205
|
-
console.log(
|
|
2206
|
-
`[Unify] history compacted (reason=${result.reason}): ` +
|
|
2207
|
-
`turns ${result.beforeTurns}→${result.afterTurns}, ` +
|
|
2208
|
-
`tokens ~${result.beforeTokens}→${result.afterTokens}, ` +
|
|
2209
|
-
`archived ${result.archivedCount} messages`
|
|
2210
|
-
);
|
|
2211
|
-
try {
|
|
2212
|
-
sendUnifyEvent({
|
|
2213
|
-
type: 'unify_history_compacted',
|
|
2214
|
-
reason: result.reason,
|
|
2215
|
-
beforeTurns: result.beforeTurns,
|
|
2216
|
-
afterTurns: result.afterTurns,
|
|
2217
|
-
beforeTokens: result.beforeTokens,
|
|
2218
|
-
afterTokens: result.afterTokens,
|
|
2219
|
-
archivedCount: result.archivedCount,
|
|
2220
|
-
ts: Date.now(),
|
|
2221
|
-
}, { groupId });
|
|
2222
|
-
} catch { /* WS pipeline failure must not crash compact */ }
|
|
2223
|
-
} catch (err) {
|
|
2224
|
-
console.warn('[Unify] history compact: unexpected failure', err?.message || err);
|
|
2225
|
-
}
|
|
2226
|
-
}
|
|
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.
|
|
2227
2300
|
|
|
2228
2301
|
/**
|
|
2229
2302
|
* Abort every in-flight VP turn and clear all queued envelopes across
|
|
@@ -2593,12 +2666,17 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
2593
2666
|
|
|
2594
2667
|
unifyConversationId = `unify-${Date.now()}`;
|
|
2595
2668
|
|
|
2596
|
-
|
|
2669
|
+
// Per-group history hydrates lazily via getOrCreateGroupHistory.
|
|
2670
|
+
// When the load-history call carries a groupId, force-refresh THAT
|
|
2671
|
+
// group's tape so the next user message sees on-disk state. When
|
|
2672
|
+
// it doesn't (legacy callers), do nothing — the per-group lazy
|
|
2673
|
+
// hydration handles it.
|
|
2674
|
+
if (groupId) setGroupHistory(groupId, hydrateGroupHistory(groupId));
|
|
2597
2675
|
} else if (groupId) {
|
|
2598
2676
|
// Re-entering an existing session with a (possibly new) group filter:
|
|
2599
|
-
// re-seed
|
|
2600
|
-
//
|
|
2601
|
-
|
|
2677
|
+
// re-seed THIS group's history from disk so it doesn't carry stale
|
|
2678
|
+
// in-memory state into the next turn's context.
|
|
2679
|
+
setGroupHistory(groupId, hydrateGroupHistory(groupId));
|
|
2602
2680
|
}
|
|
2603
2681
|
|
|
2604
2682
|
// Always replay session_ready so refresh / reconnect rebuilds UI state.
|
|
@@ -2753,7 +2831,9 @@ export async function resetUnifySession() {
|
|
|
2753
2831
|
session = null;
|
|
2754
2832
|
}
|
|
2755
2833
|
unifyConversationId = null;
|
|
2756
|
-
|
|
2834
|
+
// Per-group histories live on groupContexts entries — clearing the
|
|
2835
|
+
// map (a few lines below) drops every group's history with it. No
|
|
2836
|
+
// separate global tape to clear.
|
|
2757
2837
|
// Re-arm the permission warning. The user might have fixed the
|
|
2758
2838
|
// ~/.yeaft/ permissions in the interim and is now restarting the
|
|
2759
2839
|
// session — they should see the diagnostic again if it still fails.
|
|
@@ -2786,7 +2866,8 @@ export async function resetUnifySession() {
|
|
|
2786
2866
|
|
|
2787
2867
|
unifyConversationId = `unify-${Date.now()}`;
|
|
2788
2868
|
|
|
2789
|
-
|
|
2869
|
+
// Per-group history hydrates lazily via getOrCreateGroupHistory on
|
|
2870
|
+
// first read. Nothing to seed here.
|
|
2790
2871
|
|
|
2791
2872
|
sendUnifyEvent({
|
|
2792
2873
|
type: 'session_ready',
|