@yeaft/webchat-agent 0.1.688 → 0.1.689

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.688",
3
+ "version": "0.1.689",
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/cli.js CHANGED
@@ -212,8 +212,11 @@ async function runREPL(config, args) {
212
212
 
213
213
  const { engine, conversationStore, trace, skillManager, mcpManager, toolRegistry } = session;
214
214
 
215
- // Load persisted conversation as initial messages
216
- let conversationMessages = conversationStore.loadRecent(50).map(m => ({
215
+ // Load persisted conversation as initial messages. `loadRecent` is now
216
+ // turn-based (one user round-trip = one turn; multi-VP fan-out collapses
217
+ // into one turn). 20 turns is the bootstrap window — the engine-level
218
+ // compactor in `history-compact.js` is the authoritative size limiter.
219
+ let conversationMessages = conversationStore.loadRecent().map(m => ({
217
220
  role: m.role,
218
221
  content: m.content,
219
222
  ...(m.toolCallId && { toolCallId: m.toolCallId }),
@@ -21,6 +21,25 @@
21
21
  import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync } from 'fs';
22
22
  import { join, basename } from 'path';
23
23
  import { isPermissionError } from '../init.js';
24
+ import { pairSanitize } from '../pair-sanitize.js';
25
+ import { sliceLastNTurns } from '../turn-utils.js';
26
+
27
+ /**
28
+ * Default cold-start "recent window" size, expressed in TURNS (not raw
29
+ * messages). One turn = one user prompt round-trip; multi-VP fan-out
30
+ * collapses N `@vp-X` variants of the same canonical prompt into ONE turn.
31
+ *
32
+ * Why turns and not messages: message-count slicing can cut mid-arc and
33
+ * orphan a `[assistant(toolCalls), tool…]` pair, which 400s the Anthropic /
34
+ * Chat-Completions adapter. Turn-based slicing always cuts at a user-
35
+ * message boundary, which is pair-safe by construction.
36
+ *
37
+ * 20 turns is the bootstrap window the user signed off on (2026-05-01).
38
+ * The session-level compactor in `history-compact.js` is the authoritative
39
+ * size limiter once the engine is running; this is just the cold-start
40
+ * replay window after a fresh boot or reconnect.
41
+ */
42
+ export const DEFAULT_RECENT_TURNS = 20;
24
43
 
25
44
  // ─── Token estimation ────────────────────────────────────────
26
45
 
@@ -424,13 +443,29 @@ export class ConversationStore {
424
443
  // ─── Read API ───────────────────────────────────────────
425
444
 
426
445
  /**
427
- * Load recent hot messages, sorted by id (chronological).
446
+ * Load recent hot messages, sliced to the last `turnsLimit` TURNS and
447
+ * sorted chronologically.
448
+ *
449
+ * Turn-based (not message-based) slicing is the contract here. A "turn"
450
+ * is one user-prompt round-trip — multi-VP fan-out emits N user
451
+ * messages for the same prompt, all of which collapse into ONE turn.
452
+ * `sliceLastNTurns` cuts at a user-message boundary, so an
453
+ * `[assistant(toolCalls), tool…]` arc is never split across the cut.
428
454
  *
429
- * @param {number} [limit=50] max messages to load
455
+ * `pairSanitize` runs as a defensive secondary pass — turn-boundary
456
+ * cuts are already pair-safe, but historical / hand-edited stores may
457
+ * contain orphans, and `pairSanitize` is idempotent.
458
+ *
459
+ * Back-compat: callers that pass `Infinity` (or a negative number) get
460
+ * the full hot history. `0` returns `[]`.
461
+ *
462
+ * @param {number} [turnsLimit=DEFAULT_RECENT_TURNS] — max turns to load
430
463
  * @returns {object[]} — parsed message objects
431
464
  */
432
- loadRecent(limit = 50) {
433
- return this.#loadFromDir(this.#msgDir, limit);
465
+ loadRecent(turnsLimit = DEFAULT_RECENT_TURNS) {
466
+ const all = this.#loadFromDir(this.#msgDir, Infinity);
467
+ if (turnsLimit === Infinity || turnsLimit < 0) return pairSanitize(all);
468
+ return pairSanitize(sliceLastNTurns(all, turnsLimit));
434
469
  }
435
470
 
436
471
  /**
@@ -443,29 +478,43 @@ export class ConversationStore {
443
478
  }
444
479
 
445
480
  /**
446
- * Load recent hot messages stamped with `groupId`, sorted chronologically.
447
- * Group-history-isolation (Bug 7): a message lives in exactly one group.
448
- * Messages without a `groupId` frontmatter (legacy / pre-grouping) are
449
- * NOT returned they would otherwise leak into every group's stream.
481
+ * Load recent hot messages stamped with `groupId`, sliced to the last
482
+ * `turnsLimit` TURNS and sorted chronologically.
483
+ *
484
+ * Group-history-isolation (Bug 7): a message lives in exactly one
485
+ * group. Messages without a `groupId` frontmatter (legacy / pre-
486
+ * grouping) are NOT returned — they would otherwise leak into every
487
+ * group's stream.
488
+ *
489
+ * Turn-based slicing (2026-05-01): we used to take the last N
490
+ * messages, which can land mid-arc and orphan a tool_use/tool_result
491
+ * pair (the Anthropic / Chat-Completions adapter then 400s on the
492
+ * orphan). Switching to `sliceLastNTurns` always cuts at a user-
493
+ * message boundary — multi-VP `@vp-X` variants of the same canonical
494
+ * prompt collapse into ONE turn, so a fan-out turn is kept whole.
495
+ *
496
+ * `pairSanitize` runs as a belt-and-suspenders second pass: turn-
497
+ * boundary cuts are pair-safe by construction, but if a hand-edited
498
+ * store somehow contains pre-existing orphans we drop them anyway.
450
499
  *
451
- * Implementation note: filters AFTER reading the most recent N files
500
+ * Implementation note: filters AFTER reading the most recent files
452
501
  * because the on-disk order is global by sequence id. We over-read by
453
- * loading all hot files and slicing the tail of the filtered set so
454
- * `limit` reflects "N most recent messages in this group", not "N most
455
- * recent messages on disk that happen to be in this group". For typical
456
- * inboxes (≤ a few thousand hot messages) this is cheap; if it ever
457
- * becomes a hot path we add a per-group on-disk index.
502
+ * loading every hot file and slicing the tail of the FILTERED set so
503
+ * `turnsLimit` reflects "N most recent turns in this group", not "N
504
+ * most recent messages on disk that happen to be in this group". For
505
+ * typical inboxes (≤ a few thousand hot messages) this is cheap; if
506
+ * it ever becomes a hot path we add a per-group on-disk index.
458
507
  *
459
508
  * @param {string} groupId — required; null/empty returns []
460
- * @param {number} [limit=50]
509
+ * @param {number} [turnsLimit=DEFAULT_RECENT_TURNS]
461
510
  * @returns {object[]}
462
511
  */
463
- loadRecentByGroup(groupId, limit = 50) {
512
+ loadRecentByGroup(groupId, turnsLimit = DEFAULT_RECENT_TURNS) {
464
513
  if (!groupId) return [];
465
514
  const all = this.#loadFromDir(this.#msgDir, Infinity);
466
515
  const filtered = all.filter(m => m && m.groupId === groupId);
467
- if (limit === Infinity || limit < 0) return filtered;
468
- return filtered.slice(-limit);
516
+ if (turnsLimit === Infinity || turnsLimit < 0) return pairSanitize(filtered);
517
+ return pairSanitize(sliceLastNTurns(filtered, turnsLimit));
469
518
  }
470
519
 
471
520
  /**
@@ -26,12 +26,19 @@
26
26
  * 4. Keep the last `keepRecent` user→assistant turns intact so the model
27
27
  * has fresh, untransformed context for whatever the user just said.
28
28
  *
29
- * Triggers (either fires):
30
- * - turn count > 20 (each user message in `conversationMessages` is a turn)
31
- * - estimated tokens > 80,000
29
+ * Triggers (any fires, but only above a 30K token soft floor):
30
+ * - tokens < 30_000 never compact (cheap chat, no point paying
31
+ * the summarizer)
32
+ * - tokens > 40 % of `maxContextTokens` (defaults to 200K → 80K)
33
+ * - tokens > 200,000 hard ceiling
32
34
  *
33
- * Defaults match the user-stated requirement; both are overridable via the
34
- * options bag for tests / future config plumbing.
35
+ * The "turn > 20" trigger that an earlier revision used was dropped:
36
+ * under a 30K token floor it's effectively dead code — the fractional
37
+ * threshold fires first in any conversation big enough to matter.
38
+ *
39
+ * Defaults are derived from `maxContextTokens` so the policy auto-adjusts
40
+ * when the user widens or narrows their context budget. All knobs are
41
+ * overridable via the options bag for tests / future config plumbing.
35
42
  *
36
43
  * Why role='user' for the summary message:
37
44
  * The Anthropic Messages API rejects assistant prefill at the tail
@@ -44,13 +51,53 @@
44
51
  */
45
52
 
46
53
  import { estimateTokens } from './conversation/persist.js';
54
+ import { pairSanitize } from './pair-sanitize.js';
55
+ import {
56
+ countTurns as countTurnsImpl,
57
+ indexOfNthTurnFromEnd,
58
+ } from './turn-utils.js';
59
+
60
+ /**
61
+ * Re-export `countTurns` so existing callers / tests that import it
62
+ * from this module continue to work. Implementation now lives in
63
+ * `turn-utils.js` and is shared with `ConversationStore`.
64
+ */
65
+ export const countTurns = countTurnsImpl;
47
66
 
48
67
  /**
49
- * Default trigger thresholds match the user's stated policy:
50
- * "如果 turn 超过 20 或者 message 上下文超过 80K,那么就 compact"
68
+ * Default trigger thresholds (2026-05-01 policy update):
69
+ * - never compact while total tokens < 30K (soft floor — most
70
+ * conversations under that aren't worth paying the summarizer
71
+ * cost; the LLM hasn't started feeling the context yet either),
72
+ * - otherwise compact if ANY of:
73
+ * tokens > 40 % of `maxContextTokens` (default 200K → 80K)
74
+ * tokens > 200K hard ceiling
75
+ *
76
+ * The earlier "turn count > 20" trigger was DROPPED because under
77
+ * the new floor it's effectively dead code — by the time you've sent
78
+ * 20 user prompts that exceed 30K tokens, the fractional threshold
79
+ * has already fired. `turnLimit` and the `turn_count` reason code
80
+ * are still accepted as overrides so tests / future config can re-
81
+ * enable a turn-based trigger if needed; with `turnLimit: Infinity`
82
+ * (the new default) the check is simply skipped.
83
+ *
84
+ * Token thresholds are derived from `maxContextTokens` at evaluation
85
+ * time so the policy auto-adjusts to the user's configured context.
86
+ */
87
+ export const DEFAULT_TURN_LIMIT = Infinity;
88
+ export const DEFAULT_MIN_TOKEN_FLOOR = 30_000;
89
+ export const DEFAULT_MAX_CONTEXT_TOKENS = 200_000;
90
+ export const DEFAULT_TOKEN_FRACTION = 0.4;
91
+ export const DEFAULT_HARD_TOKEN_CEILING = 200_000;
92
+ /**
93
+ * Effective default token trigger when no `maxContextTokens` is provided:
94
+ * min(40% of 200K, 200K) = 80K. Preserved as `DEFAULT_TOKEN_LIMIT` for
95
+ * back-compat with existing tests that import this name.
51
96
  */
52
- export const DEFAULT_TURN_LIMIT = 20;
53
- export const DEFAULT_TOKEN_LIMIT = 80_000;
97
+ export const DEFAULT_TOKEN_LIMIT = Math.min(
98
+ Math.floor(DEFAULT_MAX_CONTEXT_TOKENS * DEFAULT_TOKEN_FRACTION),
99
+ DEFAULT_HARD_TOKEN_CEILING
100
+ );
54
101
 
55
102
  /**
56
103
  * How many user→assistant pairs to leave intact at the tail. The summary
@@ -97,71 +144,67 @@ export function estimateMessagesTokens(messages) {
97
144
  return total;
98
145
  }
99
146
 
100
- /**
101
- * Strip a leading `@vp-<id> ` mention prefix from a user prompt. The
102
- * web bridge prefixes each VP's per-turn prompt with `@vp-<id> ` so
103
- * the engine knows which VP is replying. When counting "turns" we
104
- * want the user-facing notion of a turn (one round-trip), not one per
105
- * VP — so we strip the prefix before deduping consecutive identical
106
- * user messages.
107
- *
108
- * Format mirrors `web-bridge.js#runVpTurn`:
109
- * `@vp-${vpId} ${text}`
110
- *
111
- * @param {string} content
112
- * @returns {string}
113
- */
114
- function stripVpMentionPrefix(content) {
115
- if (typeof content !== 'string') return '';
116
- return content.replace(/^@vp-[^\s]+\s+/, '');
117
- }
118
-
119
- /**
120
- * Count "turns" — defined as a user-side round-trip, NOT one per
121
- * user-role message. Multi-VP fan-out appends one user message per VP
122
- * (each with an `@vp-<id>` prefix) for the same underlying user prompt;
123
- * those collapse into a single turn here.
124
- *
125
- * Algorithm: walk user-role messages, strip the `@vp-` prefix, count
126
- * a turn whenever the canonical text changes from the previous user
127
- * message (or it's the first one).
128
- *
129
- * @param {Array<object>} messages
130
- * @returns {number}
131
- */
132
- export function countTurns(messages) {
133
- if (!Array.isArray(messages)) return 0;
134
- let n = 0;
135
- let prev = null;
136
- for (const m of messages) {
137
- if (!m || m.role !== 'user') continue;
138
- const canonical = stripVpMentionPrefix(m.content || '');
139
- if (canonical !== prev) {
140
- n++;
141
- prev = canonical;
142
- }
143
- }
144
- return n;
145
- }
146
-
147
147
  /**
148
148
  * Pure trigger evaluator. Decides whether the in-memory history needs
149
149
  * compaction. No I/O, no LLM call.
150
150
  *
151
+ * Policy (2026-05-01):
152
+ * 1. tokens < `minTokenFloor` (default 30K) → trigger=false (always).
153
+ * 2. otherwise trigger if ANY of:
154
+ * turnCount > turnLimit (default Infinity → effectively off;
155
+ * callers can pin a number to re-enable a turn-count trigger)
156
+ * tokenCount > maxContextTokens*fraction (reason='token_threshold')
157
+ * tokenCount > hardTokenCeiling (reason='token_ceiling')
158
+ *
159
+ * `tokenLimit` is preserved as a back-compat override for callers /
160
+ * tests that pin a specific number; when set, it overrides the
161
+ * fraction-of-context calculation.
162
+ *
151
163
  * @param {Array<object>} messages
152
- * @param {{turnLimit?: number, tokenLimit?: number}} [opts]
153
- * @returns {{trigger: boolean, reason: 'turn_count'|'token_threshold'|null,
164
+ * @param {{
165
+ * turnLimit?: number,
166
+ * tokenLimit?: number,
167
+ * minTokenFloor?: number,
168
+ * maxContextTokens?: number,
169
+ * tokenFraction?: number,
170
+ * hardTokenCeiling?: number,
171
+ * }} [opts]
172
+ * @returns {{trigger: boolean, reason: 'turn_count'|'token_threshold'|'token_ceiling'|null,
154
173
  * turnCount: number, tokenCount: number,
155
- * turnLimit: number, tokenLimit: number}}
174
+ * turnLimit: number, tokenLimit: number,
175
+ * minTokenFloor: number, hardTokenCeiling: number}}
156
176
  */
157
177
  export function shouldCompactHistory(messages, opts = {}) {
158
178
  const turnLimit = opts.turnLimit ?? DEFAULT_TURN_LIMIT;
159
- const tokenLimit = opts.tokenLimit ?? DEFAULT_TOKEN_LIMIT;
179
+ const minTokenFloor = opts.minTokenFloor ?? DEFAULT_MIN_TOKEN_FLOOR;
180
+ const hardTokenCeiling = opts.hardTokenCeiling ?? DEFAULT_HARD_TOKEN_CEILING;
181
+ const maxContextTokens = opts.maxContextTokens ?? DEFAULT_MAX_CONTEXT_TOKENS;
182
+ const tokenFraction = opts.tokenFraction ?? DEFAULT_TOKEN_FRACTION;
183
+ // tokenLimit override wins; otherwise compute fractional threshold.
184
+ const tokenLimit =
185
+ opts.tokenLimit
186
+ ?? Math.min(Math.floor(maxContextTokens * tokenFraction), hardTokenCeiling);
187
+
160
188
  const turnCount = countTurns(messages);
161
189
  const tokenCount = estimateMessagesTokens(messages);
162
190
 
163
191
  let reason = null;
164
- if (turnCount > turnLimit) reason = 'turn_count';
192
+ // (1) Soft floor: never compact small conversations.
193
+ if (tokenCount < minTokenFloor) {
194
+ return {
195
+ trigger: false,
196
+ reason: null,
197
+ turnCount,
198
+ tokenCount,
199
+ turnLimit,
200
+ tokenLimit,
201
+ minTokenFloor,
202
+ hardTokenCeiling,
203
+ };
204
+ }
205
+ // (2) Trigger evaluation. Turn check is opt-in (Infinity by default).
206
+ if (Number.isFinite(turnLimit) && turnCount > turnLimit) reason = 'turn_count';
207
+ else if (tokenCount > hardTokenCeiling) reason = 'token_ceiling';
165
208
  else if (tokenCount > tokenLimit) reason = 'token_threshold';
166
209
 
167
210
  return {
@@ -171,6 +214,8 @@ export function shouldCompactHistory(messages, opts = {}) {
171
214
  tokenCount,
172
215
  turnLimit,
173
216
  tokenLimit,
217
+ minTokenFloor,
218
+ hardTokenCeiling,
174
219
  };
175
220
  }
176
221
 
@@ -219,13 +264,14 @@ export function buildSummarizerInput(messages) {
219
264
  * intact, fold everything before. Returns the index that the cut starts
220
265
  * AT, i.e. messages[0..cutIdx) gets summarised, messages[cutIdx..] stays.
221
266
  *
222
- * Strategy: walks user-role messages from the END backwards, counting
223
- * DISTINCT turns by canonical text (after stripping `@vp-<id>` prefix
224
- * via `stripVpMentionPrefix`). Multi-VP fan-out variants of the same
225
- * underlying turn collapse into one turn — and the candidate cut index
226
- * is extended backwards through them so all `@vp-X` variants of the
227
- * kept turn stay together. If there aren't enough turns to fold (history
228
- * shorter than keepRecent), returns -1 (caller treats as no-op).
267
+ * Thin wrapper around `turn-utils.indexOfNthTurnFromEnd` with the
268
+ * historical contract preserved:
269
+ * - empty input returns -1,
270
+ * - `keepRecent <= 0` folds everything (returns messages.length),
271
+ * - "fewer turns than keepRecent" maps to -1 (caller treats as no-op).
272
+ *
273
+ * Multi-VP fan-out: `@vp-X` variants of the same underlying turn count
274
+ * as ONE turn and the boundary extends backwards through them all.
229
275
  *
230
276
  * @param {Array<object>} messages
231
277
  * @param {number} keepRecent
@@ -234,42 +280,10 @@ export function buildSummarizerInput(messages) {
234
280
  export function findCutIndex(messages, keepRecent) {
235
281
  if (!Array.isArray(messages) || messages.length === 0) return -1;
236
282
  if (keepRecent <= 0) return messages.length; // fold everything
237
-
238
- // Walk from the end, counting DISTINCT turns (multiple consecutive
239
- // user messages with the same canonical text — i.e. one fan-out's
240
- // @vp-X variants — collapse into a single turn). Stop when we've
241
- // started the (keepRecent)-th turn from the end; everything before
242
- // its first user-message gets folded.
243
- let turnsFromEnd = 0;
244
- let nextCanonical = null; // canonical text of the turn we just opened
245
- let candidateIdx = -1;
246
- for (let i = messages.length - 1; i >= 0; i--) {
247
- if (!messages[i] || messages[i].role !== 'user') continue;
248
- const canonical = stripVpMentionPrefix(messages[i].content || '');
249
- if (canonical !== nextCanonical) {
250
- // New (older) turn boundary.
251
- turnsFromEnd++;
252
- nextCanonical = canonical;
253
- if (turnsFromEnd === keepRecent) {
254
- candidateIdx = i;
255
- // Keep walking — the same turn might extend further back via
256
- // earlier @vp variants of the same canonical text.
257
- continue;
258
- }
259
- if (turnsFromEnd > keepRecent) {
260
- // We've stepped into the (keepRecent+1)-th turn — stop. The
261
- // last recorded `candidateIdx` is the start of the LAST
262
- // keepRecent block.
263
- break;
264
- }
265
- } else if (turnsFromEnd === keepRecent) {
266
- // Same canonical text as the keepRecent-th-from-end turn — this
267
- // is an earlier @vp-variant of that same turn. Extend candidate
268
- // backwards to include it.
269
- candidateIdx = i;
270
- }
271
- }
272
- return candidateIdx;
283
+ const idx = indexOfNthTurnFromEnd(messages, keepRecent);
284
+ // `indexOfNthTurnFromEnd` returns -1 when there are fewer turns than
285
+ // requested historical contract is the same. Pass through.
286
+ return idx;
273
287
  }
274
288
 
275
289
  /**
@@ -331,6 +345,10 @@ export function buildSummaryPrompt(cleanedMessages) {
331
345
  * keepRecent?: number,
332
346
  * turnLimit?: number,
333
347
  * tokenLimit?: number,
348
+ * minTokenFloor?: number,
349
+ * maxContextTokens?: number,
350
+ * tokenFraction?: number,
351
+ * hardTokenCeiling?: number,
334
352
  * }} options
335
353
  * @returns {Promise<{
336
354
  * messages: Array<object>,
@@ -348,15 +366,29 @@ export async function compactHistory(messages, options) {
348
366
  const {
349
367
  summarize,
350
368
  keepRecent = DEFAULT_KEEP_RECENT_TURNS,
351
- turnLimit = DEFAULT_TURN_LIMIT,
352
- tokenLimit = DEFAULT_TOKEN_LIMIT,
369
+ turnLimit,
370
+ tokenLimit,
371
+ minTokenFloor,
372
+ maxContextTokens,
373
+ tokenFraction,
374
+ hardTokenCeiling,
353
375
  } = options || {};
354
376
 
355
377
  if (typeof summarize !== 'function') {
356
378
  throw new TypeError('compactHistory: options.summarize must be a function');
357
379
  }
358
380
 
359
- const before = shouldCompactHistory(messages, { turnLimit, tokenLimit });
381
+ // Pass thresholds through to shouldCompactHistory so a single options
382
+ // bag controls the policy. Undefined keys fall back to module defaults.
383
+ const triggerOpts = {
384
+ turnLimit,
385
+ tokenLimit,
386
+ minTokenFloor,
387
+ maxContextTokens,
388
+ tokenFraction,
389
+ hardTokenCeiling,
390
+ };
391
+ const before = shouldCompactHistory(messages, triggerOpts);
360
392
  if (!before.trigger) {
361
393
  return {
362
394
  messages,
@@ -433,19 +465,17 @@ export async function compactHistory(messages, options) {
433
465
 
434
466
  const summaryMsg = wrapSummaryAsUserMessage(summaryText);
435
467
 
436
- // Defensive: if the tail starts with a `role: 'tool'` message, the
437
- // adapter will reject it (tool messages must follow an assistant with
438
- // a matching tool_call). Drop leading tool messages from the tail
439
- // their preceding assistant has been folded into the summary, so the
440
- // tool result is orphaned anyway.
441
- let tailStart = 0;
442
- while (tailStart < tail.length && tail[tailStart] && tail[tailStart].role === 'tool') {
443
- tailStart++;
444
- }
445
- const safeTail = tail.slice(tailStart);
468
+ // Defensive pair-sanitize: the cut at `cutIdx` lands at a user-message
469
+ // boundary so an `[assistant(toolCalls), tool…]` arc is not split, but
470
+ // we still run `pairSanitize` over the tail as belt-and-suspenders
471
+ // it idempotently drops any orphan tool messages, and any assistant
472
+ // whose tool_use IDs aren't fully matched in the tail. This is what
473
+ // keeps the next adapter call from 400-ing on tool_use/tool_result
474
+ // mismatch when the storage / fan-out layer reorders messages.
475
+ const safeTail = pairSanitize(tail);
446
476
 
447
477
  const newMessages = [summaryMsg, ...safeTail];
448
- const after = shouldCompactHistory(newMessages, { turnLimit, tokenLimit });
478
+ const after = shouldCompactHistory(newMessages, triggerOpts);
449
479
 
450
480
  return {
451
481
  messages: newMessages,
@@ -0,0 +1,143 @@
1
+ /**
2
+ * pair-sanitize.js — Drop tool_use / tool_result orphans from a message
3
+ * slice so it can be safely fed to the LLM adapter.
4
+ *
5
+ * Why this exists:
6
+ * `agent/unify/conversation/persist.js#loadRecentByGroup` and
7
+ * `agent/unify/history-compact.js#compactHistory` both produce
8
+ * sub-slices of a longer message stream. Both paths can — depending on
9
+ * where the cut lands — produce one of two illegal shapes:
10
+ * 1. A `role: 'tool'` message whose owning assistant `tool_use` is
11
+ * no longer in the slice.
12
+ * 2. An `assistant` message whose `toolCalls[i].id` has no matching
13
+ * `role: 'tool'` follow-up inside the slice.
14
+ * The Anthropic Messages API and the Chat-Completions adapter both
15
+ * 400 on either shape ("`tool_use` blocks must be paired with
16
+ * `tool_result` blocks").
17
+ *
18
+ * Strategy (Strategy B from the design doc):
19
+ * Drop orphans rather than extending the slice backwards. Concretely:
20
+ *
21
+ * - Walk forward. For each `role: 'assistant'` message with
22
+ * `toolCalls`, look ahead at the contiguous run of `role: 'tool'`
23
+ * messages (or, in this codebase's flat array form, all `tool`
24
+ * messages between this assistant and the next assistant/user)
25
+ * and collect the set of `toolCallId`s that are present.
26
+ * - Filter the assistant's `toolCalls` to that set. If the result
27
+ * is empty AND the assistant has no text content, drop the
28
+ * assistant. Otherwise keep it with the surviving subset (which
29
+ * may be empty `toolCalls: []` if it had text).
30
+ * - Drop any `role: 'tool'` whose `toolCallId` is not in the
31
+ * surviving set of any preceding assistant in the slice.
32
+ *
33
+ * The transform is idempotent: running it twice produces the same
34
+ * result as running it once. It does not mutate the input.
35
+ *
36
+ * It's deliberately tolerant of "weird" inputs — null entries, missing
37
+ * fields, leading orphan tools (which become outright drops) — because
38
+ * the call sites already see all of those.
39
+ */
40
+
41
+ /**
42
+ * @typedef {Object} UnifiedMessage
43
+ * @property {string} role - 'user' | 'assistant' | 'tool' | 'system'
44
+ * @property {string} [content]
45
+ * @property {Array<{id: string, name?: string, input?: any}>} [toolCalls]
46
+ * @property {string} [toolCallId] - present on role:'tool'
47
+ * @property {boolean} [isError]
48
+ */
49
+
50
+ /**
51
+ * Sanitize a message slice so every `tool_use` is paired with its
52
+ * `tool_result` and vice versa. Returns a new array; never mutates input.
53
+ *
54
+ * @param {UnifiedMessage[]} messages
55
+ * @returns {UnifiedMessage[]}
56
+ */
57
+ export function pairSanitize(messages) {
58
+ if (!Array.isArray(messages) || messages.length === 0) return [];
59
+
60
+ // Pass 1: collect every toolCallId that has a matching `role:'tool'`
61
+ // message somewhere in the slice. (Pairing is positional, but for
62
+ // the orphan-drop policy a global presence check is sufficient — and
63
+ // it tolerates reorderings that the storage layer occasionally does
64
+ // when sequence ids cross seconds-boundaries.)
65
+ const toolResultIds = new Set();
66
+ for (const m of messages) {
67
+ if (!m || typeof m !== 'object') continue;
68
+ if (m.role === 'tool' && typeof m.toolCallId === 'string' && m.toolCallId) {
69
+ toolResultIds.add(m.toolCallId);
70
+ }
71
+ }
72
+
73
+ // Pass 2: walk messages, filter assistant.toolCalls down to those
74
+ // whose result is in the slice, and track which call-ids survived.
75
+ // A `role:'tool'` is kept iff its toolCallId is in survivingCallIds.
76
+ const survivingCallIds = new Set();
77
+ const out = [];
78
+ for (const m of messages) {
79
+ if (!m || typeof m !== 'object') {
80
+ // Pass through non-object junk so callers that intentionally
81
+ // include sentinels don't lose them. (Practically never happens.)
82
+ out.push(m);
83
+ continue;
84
+ }
85
+ if (m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
86
+ const keptCalls = m.toolCalls.filter(tc =>
87
+ tc && typeof tc.id === 'string' && toolResultIds.has(tc.id)
88
+ );
89
+ const text = typeof m.content === 'string' ? m.content : '';
90
+ const hasText = text.trim().length > 0;
91
+ if (keptCalls.length === 0 && !hasText) {
92
+ // No text, all tool_uses orphaned → drop the message entirely.
93
+ // Anthropic / OpenAI both reject empty assistant turns anyway.
94
+ continue;
95
+ }
96
+ // Record surviving call ids so we keep their tool_result counterparts.
97
+ for (const tc of keptCalls) survivingCallIds.add(tc.id);
98
+ // Replace toolCalls with the filtered subset. Preserve other fields.
99
+ out.push({ ...m, toolCalls: keptCalls });
100
+ continue;
101
+ }
102
+ if (m.role === 'tool') {
103
+ if (typeof m.toolCallId !== 'string' || !m.toolCallId) {
104
+ // Tool message with no id — can't pair, drop it.
105
+ continue;
106
+ }
107
+ if (!survivingCallIds.has(m.toolCallId)) {
108
+ // Orphan tool_result: the assistant that called it isn't in the
109
+ // slice (or its tool_use was filtered out above).
110
+ continue;
111
+ }
112
+ out.push(m);
113
+ continue;
114
+ }
115
+ // user / system / assistant-without-toolCalls / unknown — keep as-is.
116
+ out.push(m);
117
+ }
118
+
119
+ return out;
120
+ }
121
+
122
+ /**
123
+ * Quick predicate: does this slice contain at least one orphan?
124
+ * Useful for tests and diagnostics. Pure function over `pairSanitize`.
125
+ *
126
+ * @param {UnifiedMessage[]} messages
127
+ * @returns {boolean}
128
+ */
129
+ export function hasOrphanPairs(messages) {
130
+ if (!Array.isArray(messages) || messages.length === 0) return false;
131
+ const sanitized = pairSanitize(messages);
132
+ if (sanitized.length !== messages.length) return true;
133
+ // Same length but maybe an assistant's toolCalls shrank.
134
+ for (let i = 0; i < messages.length; i++) {
135
+ const a = messages[i];
136
+ const b = sanitized[i];
137
+ if (!a || !b) continue;
138
+ const aCalls = Array.isArray(a.toolCalls) ? a.toolCalls.length : 0;
139
+ const bCalls = Array.isArray(b.toolCalls) ? b.toolCalls.length : 0;
140
+ if (aCalls !== bCalls) return true;
141
+ }
142
+ return false;
143
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * turn-utils.js — Turn-based slicing primitives for the Unify history.
3
+ *
4
+ * "Turn" = one user-side round-trip, NOT one user-role message.
5
+ * Multi-VP fan-out emits N user messages (each `@vp-<id> <text>`) for
6
+ * the SAME underlying prompt. They collapse into ONE turn here.
7
+ *
8
+ * Two concerns this module unifies:
9
+ *
10
+ * 1. `compact/turn-group.js` already does atomicity grouping —
11
+ * `[user, assistant, tool…]` triples that must move as a unit
12
+ * so we don't split tool_use/tool_result pairs. That's storage-
13
+ * invariant work.
14
+ *
15
+ * 2. THIS module does turn IDENTITY — "is the next user-role message
16
+ * the start of a new conversational turn, or is it just another
17
+ * `@vp-X` variant of the previous one?" That's a higher-level
18
+ * semantic concern.
19
+ *
20
+ * Both are needed. `sliceLastNTurns` cuts at a turn boundary AND walks
21
+ * forward to include all `@vp-X` variants of that turn so the result
22
+ * is always a pair-safe, semantically-aligned slice.
23
+ */
24
+
25
+ /**
26
+ * Strip a leading `@vp-<id> ` mention prefix from a user prompt. The
27
+ * web bridge prefixes each VP's per-turn prompt with `@vp-<id> ` so
28
+ * the engine knows which VP is replying. When asking "are these the
29
+ * same turn?" we want the user-facing notion of a turn (one prompt
30
+ * fanned out to multiple VPs is one turn) — so we strip the prefix
31
+ * before comparing.
32
+ *
33
+ * Format mirrors `web-bridge.js#runVpTurn` (`@vp-${vpId} ${text}`)
34
+ * and the canonical vpId charset from `groups/ids.js#VP_ID_RE`
35
+ * (`[A-Za-z0-9_-]`). The regex here is intentionally constrained to
36
+ * that charset so a literal `@vp-` substring in a *user-typed* message
37
+ * (e.g. `"@vp-, fooled you"`, `"@vp-😀 hi"`) is NOT mistaken for a
38
+ * fan-out prefix and over-stripped.
39
+ *
40
+ * @param {string} content
41
+ * @returns {string}
42
+ */
43
+ export function stripVpMentionPrefix(content) {
44
+ if (typeof content !== 'string') return '';
45
+ return content.replace(/^@vp-[A-Za-z0-9_-]+\s+/, '');
46
+ }
47
+
48
+ /**
49
+ * Count "turns" — distinct user prompts after `@vp-X` collapsing.
50
+ *
51
+ * A turn is opened when we hit a user-role message whose canonical
52
+ * text differs from the previous user-role message. Two consecutive
53
+ * user-role messages with the same canonical text (i.e. `@vp-a foo`
54
+ * followed by `@vp-b foo`) count as ONE turn.
55
+ *
56
+ * @param {Array<object>} messages
57
+ * @returns {number}
58
+ */
59
+ export function countTurns(messages) {
60
+ if (!Array.isArray(messages)) return 0;
61
+ let n = 0;
62
+ let prev = null;
63
+ for (const m of messages) {
64
+ if (!m || m.role !== 'user') continue;
65
+ const canonical = stripVpMentionPrefix(m.content || '');
66
+ if (canonical !== prev) {
67
+ n++;
68
+ prev = canonical;
69
+ }
70
+ }
71
+ return n;
72
+ }
73
+
74
+ /**
75
+ * Find the index of the message that opens the (n)-th-from-end turn.
76
+ * Returns -1 if there aren't n turns in the history.
77
+ *
78
+ * Algorithm (mirrors `history-compact.js#findCutIndex`):
79
+ * - Walk user-role messages from END backwards, counting DISTINCT
80
+ * turns by canonical text.
81
+ * - When `turnsFromEnd === n`, record the index. Keep walking — the
82
+ * same turn might extend further back through earlier `@vp-X`
83
+ * variants. Stop on the FIRST user-role message whose canonical
84
+ * text differs (i.e. the (n+1)-th-from-end turn).
85
+ *
86
+ * The returned index always points at a user-role message — the
87
+ * natural turn boundary — which means messages[idx..] is a clean
88
+ * `[user, ..., user, ...]` slice with no orphan tool_use / tool_result
89
+ * pairs (provided the input was clean).
90
+ *
91
+ * @param {Array<object>} messages
92
+ * @param {number} n — 1 = "open the most recent turn"
93
+ * @returns {number}
94
+ */
95
+ export function indexOfNthTurnFromEnd(messages, n) {
96
+ if (!Array.isArray(messages) || messages.length === 0) return -1;
97
+ if (n <= 0) return messages.length; // "0 turns from end" = past everything
98
+
99
+ let turnsFromEnd = 0;
100
+ let openCanonical = null;
101
+ let candidate = -1;
102
+ for (let i = messages.length - 1; i >= 0; i--) {
103
+ if (!messages[i] || messages[i].role !== 'user') continue;
104
+ const canonical = stripVpMentionPrefix(messages[i].content || '');
105
+ if (canonical !== openCanonical) {
106
+ // Boundary: a new (older) turn starts here.
107
+ turnsFromEnd++;
108
+ openCanonical = canonical;
109
+ if (turnsFromEnd === n) {
110
+ candidate = i;
111
+ // Don't break — earlier `@vp-X` variants of THIS turn may
112
+ // extend the boundary further back. We'll catch them via the
113
+ // `else if` branch below until we hit a different canonical.
114
+ continue;
115
+ }
116
+ if (turnsFromEnd > n) {
117
+ // We've stepped one turn past the kept window — done.
118
+ break;
119
+ }
120
+ } else if (turnsFromEnd === n) {
121
+ // Same canonical text as the kept boundary — this is an earlier
122
+ // `@vp-X` variant of the same turn. Pull `candidate` back to it.
123
+ candidate = i;
124
+ }
125
+ }
126
+ return candidate;
127
+ }
128
+
129
+ /**
130
+ * Return the suffix of `messages` containing the last `n` turns.
131
+ *
132
+ * Always cuts at a user-message boundary (the start of the (n)-th-from-
133
+ * end turn), so the returned slice is pair-safe with respect to
134
+ * `[assistant(toolCalls), tool…]` arcs that LIVE inside one of the
135
+ * kept turns. Anything before the boundary — including any leading
136
+ * non-user messages from a prior turn — is dropped.
137
+ *
138
+ * If the history has fewer than `n` turns, returns the whole array
139
+ * (caller can decide whether that's a no-op).
140
+ *
141
+ * @param {Array<object>} messages
142
+ * @param {number} n
143
+ * @returns {Array<object>}
144
+ */
145
+ export function sliceLastNTurns(messages, n) {
146
+ if (!Array.isArray(messages) || messages.length === 0) return [];
147
+ if (n <= 0) return [];
148
+ const idx = indexOfNthTurnFromEnd(messages, n);
149
+ if (idx === -1) {
150
+ // Fewer than n turns — keep everything.
151
+ return messages.slice();
152
+ }
153
+ return messages.slice(idx);
154
+ }
@@ -1007,7 +1007,7 @@ async function ensureSessionLoaded() {
1007
1007
 
1008
1008
  unifyConversationId = `unify-${Date.now()}`;
1009
1009
 
1010
- restoreHistoryFromRecent(session.conversationStore.loadRecent(50));
1010
+ restoreHistoryFromRecent(session.conversationStore.loadRecent());
1011
1011
 
1012
1012
  sendUnifyEvent({
1013
1013
  type: 'session_ready',
@@ -1217,8 +1217,16 @@ function scheduleCompactAfterTurn(groupId) {
1217
1217
  return;
1218
1218
  }
1219
1219
  // Cheap O(n) precheck so we don't bother engaging the LLM at all
1220
- // when the conversation is still small.
1221
- const triage = shouldCompactHistory(conversationMessages);
1220
+ // when the conversation is still small. Mirrors the policy that
1221
+ // `runCompactNow` will apply: 30K soft floor / 40 % of configured
1222
+ // context / 200K hard ceiling. (The turn-count trigger is off by
1223
+ // default — DEFAULT_TURN_LIMIT=Infinity — pin `turnLimit` via opts
1224
+ // to re-enable it.)
1225
+ const maxContextTokens =
1226
+ typeof session?.config?.maxContextTokens === 'number'
1227
+ ? session.config.maxContextTokens
1228
+ : undefined;
1229
+ const triage = shouldCompactHistory(conversationMessages, { maxContextTokens });
1222
1230
  if (!triage.trigger) return;
1223
1231
  if (!session?.engine || typeof session.engine.summarizeForCompact !== 'function') {
1224
1232
  console.warn('[Unify] history compact: engine.summarizeForCompact unavailable — skipping');
@@ -1269,8 +1277,16 @@ async function runCompactNow(groupId) {
1269
1277
  // and we abandon the swap.
1270
1278
  const snapshot = conversationMessages;
1271
1279
 
1280
+ // Pull the user-configured context width so the 40 %-of-context
1281
+ // threshold auto-adjusts to whatever model they're on. Falls back to
1282
+ // the module default when missing.
1283
+ const maxContextTokens =
1284
+ typeof session?.config?.maxContextTokens === 'number'
1285
+ ? session.config.maxContextTokens
1286
+ : undefined;
1287
+
1272
1288
  try {
1273
- const result = await compactHistory(snapshot, { summarize });
1289
+ const result = await compactHistory(snapshot, { summarize, maxContextTokens });
1274
1290
  if (!result.compacted) {
1275
1291
  if (result.error) {
1276
1292
  console.warn(
@@ -1613,6 +1629,10 @@ export function handleUnifyModelSwitch(msg) {
1613
1629
  */
1614
1630
  export async function handleUnifyLoadHistory(msg) {
1615
1631
  const groupId = (msg && typeof msg.groupId === 'string' && msg.groupId) || null;
1632
+ // `lim` is now expressed in TURNS, not raw messages. `loadRecent` and
1633
+ // `loadRecentByGroup` use turn-based slicing so the cut never lands
1634
+ // mid-tool-arc. Pass `undefined` to use the persistence-layer default
1635
+ // (DEFAULT_RECENT_TURNS = 20 turns).
1616
1636
  const pickRecent = (store, lim) =>
1617
1637
  groupId ? store.loadRecentByGroup(groupId, lim) : store.loadRecent(lim);
1618
1638
 
@@ -1627,12 +1647,12 @@ export async function handleUnifyLoadHistory(msg) {
1627
1647
 
1628
1648
  unifyConversationId = `unify-${Date.now()}`;
1629
1649
 
1630
- restoreHistoryFromRecent(pickRecent(session.conversationStore, 50));
1650
+ restoreHistoryFromRecent(pickRecent(session.conversationStore, undefined));
1631
1651
  } else if (groupId) {
1632
1652
  // Re-entering an existing session with a (possibly new) group filter:
1633
1653
  // re-seed the engine's flat history so it doesn't carry messages from
1634
1654
  // another group into the next turn's context.
1635
- restoreHistoryFromRecent(pickRecent(session.conversationStore, 50));
1655
+ restoreHistoryFromRecent(pickRecent(session.conversationStore, undefined));
1636
1656
  }
1637
1657
 
1638
1658
  // Always replay session_ready so refresh / reconnect rebuilds UI state.
@@ -1647,6 +1667,11 @@ export async function handleUnifyLoadHistory(msg) {
1647
1667
  });
1648
1668
  sendGroupSnapshotBroadcast();
1649
1669
 
1670
+ // `msg.limit` is the replay-scrollback request from the frontend (UI
1671
+ // history pane, not engine context). Semantics changed (2026-05-01):
1672
+ // now expressed in TURNS. The previous default (50 messages) maps to
1673
+ // ~20–25 turns; in the turn-count world 50 turns of UI scrollback is
1674
+ // still cheap and matches what the frontend already passes through.
1650
1675
  const limit = (typeof msg.limit === 'number') ? msg.limit : 50;
1651
1676
  const messages = limit > 0 ? pickRecent(session.conversationStore, limit) : [];
1652
1677
  const compactSummary = session.conversationStore.readCompactSummary();
@@ -1704,7 +1729,7 @@ export async function resetUnifySession() {
1704
1729
 
1705
1730
  unifyConversationId = `unify-${Date.now()}`;
1706
1731
 
1707
- restoreHistoryFromRecent(session.conversationStore.loadRecent(50));
1732
+ restoreHistoryFromRecent(session.conversationStore.loadRecent());
1708
1733
 
1709
1734
  sendUnifyEvent({
1710
1735
  type: 'session_ready',