dsh-compaction-tower 0.1.42 → 0.1.45

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/lib/tower.js CHANGED
@@ -1,1529 +1,1530 @@
1
- /**
2
- * Tower 的核心:提交策略 + 摘要供给 + 观察者。
3
- *
4
- * 分工:
5
- * - 观察者(后台)把早期历史蒸馏成块记录,写进日志,模型看不见;
6
- * - 提交(请求前)只做查表:把「已经蒸馏好的整块」交给引擎换进表层;
7
- * - 摘要在提交路径上零 LLM 调用——这是整个方案存在的理由。
8
- */
9
- import {
10
- appendBlock, appendCommit, blockHeader, checkpointIndex, checkpointOwner, closedStepEnd,
11
- describeMessage, iterateEvents, nextBlockId, readBlocks, refOf, roleLabel, stepStartSeqs,
12
- } from './core.js';
13
- import { BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm';
14
- import { isCompactCheckpointSource, toolPairingBalancedAfter } from '@deepseek-ai/dsh-compaction';
15
- import { summaryInstruction } from './prompts.js';
16
- import { requestSummary } from './summarize.js';
17
- import { headRun, planCommits, planObservation, protectedTail, selectMergeRun, selectRawRun, usable } from './select.js';
18
-
19
- /**
20
- * 把流的终止状态翻成错误:出错/被中断/截断都算失败,正常结束返回 undefined。
21
- * 与 compaction-basic 的同名私有实现一致。
22
- * @param {object} finish
23
- */
24
- function finishError(finish) {
25
- if (finish === undefined) return undefined;
26
- if (finish.kind === 'error' || finish.kind === 'aborted') {
27
- return new LlmError(finish.failure.message, finish.failure.code, finish.failure);
28
- }
29
- if (finish.kind === 'max-tokens') {
30
- const error = new Error('answer truncated at the token cap');
31
- error.code = 'MAX_TOKENS';
32
- return error;
33
- }
34
- return undefined;
35
- }
36
-
37
- /** 安全取服务:未注入或 context 已失效时返回 undefined,不抛。 */
38
- function captureService(ctx, name) {
39
- try {
40
- return ctx?.[name];
41
- } catch {
42
- return undefined;
43
- }
44
- }
45
-
46
- /**
47
- * 一块大约能回收它覆盖内容的多少。
48
- *
49
- * 提交一块换掉的是 covered(原文 / 下层摘要),换进来的是 summary(自己的摘要),
50
- * 所以真实回收 = covered − summary。实测约 92%,这里取 0.8 留余量。
51
- */
52
- const RECLAIM_RATE = 0.8;
53
-
54
- /** 把块记录变成模型可见的消息块。头部 + 正文。 */
55
- export function summaryBlocks(block, childBlockIds = []) {
56
- const header = blockHeader({
57
- blockId: block.blockId,
58
- tier: block.tier,
59
- covers: block.covers,
60
- coveredTokens: block.coveredTokens,
61
- childBlockIds,
62
- });
63
- return [{ type: 'text', text: header }, ...block.summary];
64
- }
65
-
66
- /** 摘要记录的纯文本(搜索/UI 用)。 */
67
- export function blockText(block) {
68
- return block.summary.map((part) => part.text ?? '').join('\n');
69
- }
70
-
71
- export class Tower {
72
- /**
73
- * @param {object} ctx 宿主 context(插件级)
74
- * @param {object} config 已校验的配置
75
- */
76
- constructor(ctx, config) {
77
- this.ctx = ctx;
78
- this.config = config;
79
- // 服务引用必须在装载时抓住:回合结束后 context 会变成 inactive,
80
- // 那时再通过 ctx.sessions 取会抛 "cannot get required service … in inactive context"。
81
- this.sessions = captureService(ctx, 'sessions');
82
- /** 宿主投影:工具 schema 的 token 只能从这里拿(工具不是表层节点)。 */
83
- this.projections = captureService(ctx, 'sessionProjections');
84
- /** 提交时用于把「选中的整块」传给 summarize:WeakMap<Session, {block, seqs}> */
85
- this.pending = new WeakMap();
86
- /** 正在后台蒸馏的会话,避免同一会话同时排两批 */
87
- this.distilling = new WeakSet();
88
- /** 已分配但还没写入的块号(并发蒸馏防撞号) */
89
- this.pendingIds = new WeakMap();
90
- /** 已被在飞任务占用的 seq:并发蒸馏时防止两个任务挑到同一段材料 */
91
- this.reserved = new WeakMap();
92
- /** 上次请求前排程的时间,用来做 5 秒节流(每个 step 都全量测量太贵) */
93
- this.lastPump = new WeakMap();
94
- /** 上一次压力判定(面板用来回答「为什么没压」) */
95
- this.lastPressure = new WeakMap();
96
- /** 上一次观察判定(面板用来回答「为什么没蒸」) */
97
- this.lastObservation = new WeakMap();
98
- /** 诊断计数,便于日志确认补丁真的在工作 */
99
- this.stats = { distilled: 0, committed: 0, served: 0, passthrough: 0, failures: 0 };
100
- /**
101
- * 见过的压缩引擎:WeakMap<Agent, engine>。
102
- *
103
- * 压缩后端挂在 preset 的隔离组里,某些组合(例如 web)从 `agent.ctx` 取不到
104
- * 那个服务;但引擎自己会调用我们的补丁方法,那时 `this` 就是它 —— 顺手记下来,
105
- * 后台蒸馏与手动操作就不必再猜服务名。
106
- */
107
- this.engines = new WeakMap();
108
- /** 手动要求「立刻提交」的会话:下一个回合开始时执行一次全量提交。 */
109
- this.commitRequests = new WeakSet();
110
- /** 事实修正系数:WeakMap<Session, {scale, samples}>(见 calibration) */
111
- this.fits = new WeakMap();
112
- /** 已经认领过宿主遗留 checkpoint 的会话(每次进程一次就够)。 */
113
- this.imported = new WeakSet();
114
- }
115
-
116
- /**
117
- * 认领「宿主压过、账本没记」的段。
118
- *
119
- * 装插件之前宿主压缩留下的 checkpoint 没有块号:对账本来说它是一个洞 ——
120
- * 面板解释不了它,被它盖住的块悄悄失效却还挂着。这里把它登记成一个**只读块**:
121
- *
122
- * - `covers` 从事件的 `surfaceOp` 还原(宿主写的是 replace 区间);
123
- * - 写一条普通的 block 记录 + commit 记录,于是身份、状态、面板全都自动对上;
124
- * - 但它的 covers 已经不在表层,`usable()` 判 false → **永远不会被再次提交**;
125
- * - `decompress` 直接读日志,所以这些块的原文仍然取得回来。
126
- *
127
- * 幂等:某条 checkpoint 已经有块号(不管是我们提交的还是上次认领的)就跳过。
128
- * @param {object} agent
129
- */
130
- async ensureImported(agent) {
131
- const session = agent?.session;
132
- if (session === undefined || session === null) return;
133
- if (this.imported.has(session)) return;
134
- try {
135
- const blocks = readBlocks(session);
136
- const owner = checkpointOwner(session);
137
- let added = 0;
138
- for (const seq of session.surface.nodes) {
139
- if (owner.has(seq)) continue;
140
- const event = session.eventAt(seq);
141
- if (event?.type !== 'user/message') continue;
142
- const source = event.data?.source;
143
- if (source === undefined || !isCompactCheckpointSource(source)) continue;
144
- const op = event.surfaceOp;
145
- if (op?.op !== 'replace') continue;
146
- // 宿主写 replace 时同时记了 sourceEventSeqs(被换掉的节点清单),比区间精确;
147
- // 老格式没有这个字段就退回区间遍历。
148
- const shadowed = Array.isArray(event.sourceEventSeqs) && event.sourceEventSeqs.length > 0
149
- ? event.sourceEventSeqs
150
- : (() => {
151
- const list = [];
152
- for (let cursor = op.startSeq; cursor <= op.endSeq; cursor += 1) list.push(cursor);
153
- return list;
154
- })();
155
- const covers = [];
156
- const children = new Set();
157
- for (const cursor of shadowed) {
158
- const covered = session.eventAt(cursor);
159
- if (covered === undefined) continue;
160
- if (session.deriveEventMessage(covered) === null) continue;
161
- covers.push(cursor);
162
- const child = owner.get(cursor);
163
- if (child !== undefined) children.add(child);
164
- }
165
- if (covers.length === 0) continue;
166
-
167
- const text = (event.data?.content ?? [])
168
- .filter((part) => part.type === 'text')
169
- .map((part) => part.text)
170
- .join('\n');
171
- const summaryTokens = Math.max(1, this.estimateText(text));
172
- let coveredTokens = 0;
173
- for (const cursor of covers) {
174
- try {
175
- coveredTokens += this.ctx.tokenMeter.estimateMessage(session.deriveEventMessage(session.eventAt(cursor)));
176
- } catch {
177
- // 估不出来就少算一点:这个数只影响合并配额,不影响正确性。
178
- }
179
- }
180
- const tier = children.size === 0
181
- ? 1
182
- : Math.max(...[...children].map((id) => Number(blocks.find((b) => b.blockId === id)?.tier ?? 1))) + 1;
183
- const blockId = this.allocateBlockId(session, blocks);
184
- const record = {
185
- blockId,
186
- tier,
187
- kind: 'import',
188
- covers,
189
- coveredTokens: Math.max(coveredTokens, summaryTokens),
190
- summaryTokens,
191
- childBlockIds: [...children],
192
- summary: [{ type: 'text', text }],
193
- origin: { kind: 'foreign', checkpointSeq: seq, compactionId: source?.compactionId ?? null },
194
- tokensEstimated: true,
195
- createdAt: new Date().toISOString(),
196
- };
197
- appendBlock(session, record);
198
- appendCommit(session, {
199
- blockId,
200
- checkpointSeq: seq,
201
- ...(typeof source?.compactionId === 'string' ? { compactionId: source.compactionId } : {}),
202
- });
203
- blocks.push(record);
204
- owner.set(seq, blockId);
205
- added += 1;
206
- }
207
- this.imported.add(session);
208
- if (added > 0) {
209
- this.debug(`认领了 ${added} 段宿主压缩留下的历史(只读块)`);
210
- await this.flushSession(agent, session);
211
- }
212
- } catch (error) {
213
- this.debug(`认领宿主 checkpoint 失败:${error?.message ?? error}`);
214
- }
215
- }
216
-
217
- /**
218
- * 换一份配置(配置界面保存后调用)。
219
- *
220
- * 所有读配置的地方都走 `this.config`,所以换掉这个对象即刻生效,
221
- * 不需要重启宿主,也不需要重建 Tower 实例。
222
- * @param {object} next 已校验的完整配置
223
- */
224
- updateConfig(next) {
225
- this.config = next;
226
- // 判定记录里的触发线、单位都是旧参数算出来的。不作废的话,面板会出现
227
- // 「进度条触发线 471.9K、上次判定触发线 314.6K」这种自相矛盾,一直挂到下次请求。
228
- this.lastPressure = new WeakMap();
229
- this.lastObservation = new WeakMap();
230
- }
231
-
232
- /** 补丁入口调用:记住「这个 agent 用的是哪个引擎实例」。 */
233
- rememberEngine(agent, engine) {
234
- // 按**会话**记,不按 agent 对象记:宿主在不同钩子里传进来的 agent 包装可能不是同一个引用。
235
- const session = agent?.session;
236
- if (session !== undefined && session !== null && engine !== undefined && engine !== null) {
237
- this.engines.set(session, engine);
238
- }
239
- }
240
-
241
- /**
242
- * 每层该备多少份库存:把「触发线 → 目标线」这段缺口填满,再留 1 份安全垫。
243
- *
244
- * 一块顶掉的不是它覆盖的全部原文,而是 covered − summary(摘要本身还占着)。
245
- * 实测回收率约 92%,这里按 80% 算 —— 模糊的正确:把份数放大一点,
246
- * 宁可多备一块,也别在越线那一刻差一点点。
247
- *
248
- * 不做成配置项 —— 它完全由 triggerRatio / targetRatio / 一个单位 推出来,
249
- * 手填只会和这三个数打架。
250
- * @param {number} usable 可用预算(窗口 − 固定开销)
251
- * @returns {{unit:number, reclaim:number, blocks:number, tokens:number}}
252
- */
253
- reserveOf(usable) {
254
- const unit = Math.max(1, this.config.distillMinRunRatio * usable);
255
- const reclaim = Math.max(1, Math.round(unit * RECLAIM_RATE));
256
- const gap = Math.max(0, this.config.triggerRatio - this.config.targetRatio) * usable;
257
- // 减一个极小量再 ceil:0.45−0.15 在浮点里是 0.30000000000000004,
258
- // 直接 ceil(6.000000000000001) 会多算一份。
259
- const blocks = Math.ceil(gap / reclaim - 1e-9) + 1;
260
- return { unit, reclaim, blocks, tokens: blocks * reclaim };
261
- }
262
-
263
- /**
264
- * 分配块号。并发蒸馏时多个任务各自持有同一份 blocks 快照,
265
- * 直接 nextBlockId() 会算出同一个号,所以这里把「已分配但还没写入」的号也占位。
266
- * @param {object} session
267
- * @param {Array<object>} blocks
268
- * @returns {string}
269
- */
270
- allocateBlockId(session, blocks) {
271
- let reserved = this.pendingIds.get(session);
272
- if (reserved === undefined) {
273
- reserved = new Set();
274
- this.pendingIds.set(session, reserved);
275
- }
276
- const taken = [...blocks.map((block) => block.blockId), ...reserved].map((blockId) => ({ blockId }));
277
- const id = nextBlockId(taken);
278
- reserved.add(id);
279
- return id;
280
- }
281
-
282
- /** 取该 agent 的压缩引擎:先用见过的实例,再回落服务查找。 */
283
- engineFor(agent) {
284
- const session = agent?.session;
285
- const seen = session === undefined || session === null ? undefined : this.engines.get(session);
286
- if (seen !== undefined) return seen;
287
- // 两条退路:插件自己的 context(profile 级),以及 agent 的 context。
288
- const candidates = [this.ctx, agent?.ctx];
289
- for (const ctx of candidates) {
290
- let found;
291
- try {
292
- found = ctx?.get?.('compaction');
293
- } catch {
294
- found = undefined;
295
- }
296
- if (found !== undefined && found !== null) {
297
- if (session !== undefined && session !== null) this.engines.set(session, found);
298
- return found;
299
- }
300
- }
301
- return undefined;
302
- }
303
-
304
- /** 诊断:TOWER_DEBUG=1 时把决策过程写进 stderr(不依赖 logger 是否可用)。 */
305
- debug(message) {
306
- if (process.env.TOWER_DEBUG !== '1') return;
307
- console.error(`tower[debug]: ${message}`);
308
- }
309
-
310
- /** 读出某个会话的全部块记录。 */
311
- blocksOf(session) {
312
- return readBlocks(session);
313
- }
314
-
315
- /**
316
- * 把刚追加的记录落盘。
317
- *
318
- * 宿主的持久化检查点在「每次请求」前后,回合结束后的后台追加不在检查点里;
319
- * 不主动 flush,进程退出(headless 一次性任务)就会把块丢掉。
320
- */
321
- async flushSession(agent, session) {
322
- for (const [label, service] of [['plugin', this.sessions], ['agent', captureService(agent?.ctx, 'sessions')]]) {
323
- try {
324
- if (service && typeof service.flush === 'function') {
325
- await service.flush(session);
326
- this.debug(`flush 完成(${label})`);
327
- return;
328
- }
329
- } catch (error) {
330
- this.debug(`flush 失败(${label}): ${error?.message ?? error}`);
331
- }
332
- }
333
- this.debug('没有 flush 通道,块记录只留在内存里');
334
- }
335
-
336
- /**
337
- * 表层上的 system 节点:恒占窗口、永不压缩,阈值分母要先扣掉。
338
- * @param {object} session
339
- * @param {ReadonlyArray<{seq:number}>} nodes
340
- * @returns {Set<number>}
341
- */
342
- systemSeqsOf(session, nodes) {
343
- const systemSeqs = new Set();
344
- for (const node of nodes) {
345
- if (session.eventAt(node.seq)?.type === 'system/message') systemSeqs.add(node.seq);
346
- }
347
- return systemSeqs;
348
- }
349
-
350
- /**
351
- * 工具 schema 占多少 token。
352
- *
353
- * 工具不是表层节点,唯一的来源是宿主 tokenMeter 的 `contextBreakdown` 投影。
354
- * 服务不在或读失败就当 0 —— 固定开销只会算少,不会算多。
355
- * @param {object} session
356
- * @returns {number}
357
- */
358
- toolsTokens(session) {
359
- const projections = this.projections;
360
- if (projections === undefined || typeof projections.snapshot !== 'function') return 0;
361
- try {
362
- const breakdown = projections.snapshot(session, ['contextBreakdown'])?.values?.contextBreakdown;
363
- const tokens = Number(breakdown?.toolsTokens ?? 0);
364
- return Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
365
- } catch {
366
- return 0;
367
- }
368
- }
369
-
370
- /**
371
- * 固定开销:system 提示词 + 工具 schema。
372
- *
373
- * 这两块恒占窗口、永远不会被压缩,所以「内容」与「预算」都要先扣掉它们:
374
- * 阈值按可用预算算,进度条把它们画成最前面那一段。
375
- *
376
- * @param {object} session
377
- * @param {ReadonlyArray<{seq:number, tokens:number}>} nodes
378
- * @returns {{systemSeqs: Set<number>, system: number, tools: number, total: number}}
379
- */
380
- overheadOf(session, nodes) {
381
- const systemSeqs = this.systemSeqsOf(session, nodes);
382
- let system = 0;
383
- for (const node of nodes) if (systemSeqs.has(node.seq)) system += node.tokens ?? 0;
384
- const tools = this.toolsTokens(session);
385
- return { systemSeqs, system, tools, total: system + tools };
386
- }
387
-
388
- /**
389
- * 事实修正系数:把 chars/4 的估算折算成 provider 报的真实 token。
390
- *
391
- * 宿主没有分词器,只有两种数:provider 每次响应里的真实用量(整条请求一个数),
392
- * 和每条消息的 chars/4 估算。中文被估算低估三成,实测整条请求的
393
- * 「真实 / 估算」≈ 1.25,而且会随内容构成漂移。
394
- *
395
- * 所以每次拿到真实用量就重算一次并做平滑,再乘到所有估算值上:
396
- * 阈值、单位、块大小、面板显示全部走同一把尺子。
397
- *
398
- * 注意它同时放大了 system + 工具 schema —— 那部分只有 4K 量级,
399
- * 放大后仍小于真实值(实测 26K),差的 20K 在 1M 窗口里是 2%,不值得再引入第二个参数。
400
- *
401
- * @param {object} session
402
- * @param {object} measurement tokenMeter.measure 的结果
403
- * @param {object} overhead overheadOf 的结果
404
- * @returns {number} ≥1 的换算系数;没有真实用量时是 1
405
- */
406
- calibration(session, measurement, overhead) {
407
- let fit = this.fits.get(session);
408
- if (fit === undefined) {
409
- fit = { scale: 1, samples: 0 };
410
- this.fits.set(session, fit);
411
- }
412
- const estimated = Number(measurement.surfaceTokens ?? 0) + Number(overhead.tools ?? 0);
413
- const total = Number(measurement.totalTokens ?? 0);
414
- if (measurement.baseline?.kind === 'usage' && total > 0 && estimated > 5000) {
415
- const ratio = total / estimated;
416
- if (Number.isFinite(ratio) && ratio > 0.5 && ratio < 4) {
417
- const smoothed = fit.samples === 0 ? ratio : fit.scale * 0.7 + ratio * 0.3;
418
- // 保留两位:长会话里它会收敛成一个稳定值,面板上的 token 数不会每轮抖一下。
419
- fit.scale = Math.round(smoothed * 100) / 100;
420
- fit.samples += 1;
421
- }
422
- }
423
- return fit.scale;
424
- }
425
- /**
426
- * 用 usage 差分测一段材料的真实 token。
427
- *
428
- * 第 n 次请求的 prompt 是「此前所有消息 + system + 工具」。所以
429
- * `prompt(区间后第一条) − prompt(区间前最后一条)` 就是这两次之间新增的消息
430
- * 一共占了多少真实 token —— 也就是这段材料在请求里真实的花费。
431
- *
432
- * 只在两端都找得到、且量级合理时返回;跨过压缩或裁剪的区间会算出负数或者
433
- * 离谱的倍数,一律返回 null,让调用方退回「估算 × 系数」。
434
- *
435
- * @param {object} session
436
- * @param {number[]} seqs 升序 seq
437
- * @param {number} estimated 同一段的估算值,用来做量级校验
438
- * @returns {number|null}
439
- */
440
- realTokensOf(session, seqs, estimated) {
441
- if (seqs.length === 0) return null;
442
- const first = seqs[0];
443
- const last = seqs[seqs.length - 1];
444
- let before = null;
445
- let after = null;
446
- for (const event of iterateEvents(session)) {
447
- if (event.type !== 'assistant/message' || event.data?.usage === undefined) continue;
448
- if (event.seq < first) before = event;
449
- else if (event.seq > last) { after = event; break; }
450
- }
451
- if (before === null || after === null) return null;
452
- const promptOf = (event) => {
453
- const usage = event.data.usage ?? {};
454
- return Number(usage.inputTokens ?? 0) + Number(usage.cacheReadTokens ?? 0) + Number(usage.cacheWriteTokens ?? 0);
455
- };
456
- // 差分把 before 那条 assistant 也算了进去(它在 first 之前),减掉它的输出。
457
- const real = promptOf(after) - promptOf(before) - Number(before.data.usage?.outputTokens ?? 0);
458
- if (!Number.isFinite(real) || real <= 0) return null;
459
- const ratio = real / Math.max(1, estimated);
460
- if (ratio < 0.4 || ratio > 5) return null;
461
- return Math.round(real);
462
- }
463
-
464
- /**
465
- * 把表层节点分成三类:system(永不动)、受保护(最近 N 步)、其余。
466
- * @param {object} session
467
- * @param {ReadonlyArray<{seq:number}>} nodes
468
- * @param {Set<number>} [systemSeqs] 已经算过就传进来,省一次扫描
469
- */
470
- classify(session, nodes, systemSeqs = this.systemSeqsOf(session, nodes)) {
471
- const surfaceSeqs = session.surface.nodes;
472
- const protectedSeqs = protectedTail(surfaceSeqs, stepStartSeqs(session), this.config.retainRecentGroups);
473
- const checkpointSeqs = new Set();
474
- for (const seq of surfaceSeqs) {
475
- const event = session.eventAt(seq);
476
- const source = event?.type === 'user/message' ? event.data?.source : undefined;
477
- if (source !== undefined && isCompactCheckpointSource(source)) checkpointSeqs.add(seq);
478
- }
479
- return { systemSeqs, protectedSeqs, checkpointSeqs, surfaceSeqs: new Set(surfaceSeqs) };
480
- }
481
-
482
- /**
483
- * 取当前路由的上下文窗口(token)。拿不到就返回 null,调用方放弃压缩。
484
- * @param {object} agent
485
- * @param {AbortSignal|undefined} signal
486
- * @returns {Promise<number|null>}
487
- */
488
- /**
489
- * 当前该用哪个上下文窗口。
490
- *
491
- * **优先用会话最近一次请求的路由**,其次才是 agent.options ——
492
- * 面板(state.js)和提交判定(这里)必须用同一个来源,否则会出现
493
- * 「面板显示越线了、判定却拿不到窗口直接跳过」这种前后矛盾。
494
- *
495
- * @param {object} agent
496
- * @param {AbortSignal} [signal]
497
- * @returns {Promise<number|null>}
498
- */
499
- async budgetOf(agent, signal) {
500
- const header = agent.session?.requestHeader?.()?.config;
501
- if (typeof header?.provider === 'string' && header.provider !== ''
502
- && typeof header?.model === 'string' && header.model !== '') {
503
- const routed = await this.budgetOfRoute(header.provider, header.model, signal);
504
- if (routed !== null) return routed;
505
- }
506
- return this.budgetOfRoute(agent.options?.provider, agent.options?.model, signal);
507
- }
508
-
509
- /**
510
- * 按 provider/model 取上下文窗口。拿不到返回 null(调用方据此放弃压缩)。
511
- * @param {string|undefined} provider
512
- * @param {string|undefined} model
513
- * @param {AbortSignal|undefined} signal
514
- * @returns {Promise<number|null>}
515
- */
516
- async budgetOfRoute(provider, model, signal) {
517
- if (typeof provider !== 'string' || typeof model !== 'string') return null;
518
- try {
519
- const info = await this.ctx.llm.resolveModelInfo(provider, model, signal);
520
- const window = info?.context?.contextWindow;
521
- return Number.isFinite(window) && window > 0 ? window : null;
522
- } catch {
523
- return null;
524
- }
525
- }
526
-
527
- /**
528
- * 压力触发时决定「压哪一块」。
529
- * @param {object} agent
530
- * @param {AbortSignal} [signal]
531
- * @param {{force?: boolean}} [options] force = 手动要求,忽略触发线、按目标 0 全提交
532
- * @returns {Promise<{kind:'skip'}|{kind:'no-block'}|{kind:'commit', blocks:object[]}>}
533
- */
534
- async planPressure(agent, signal, options = {}) {
535
- const force = options.force === true;
536
- const budget = await this.budgetOf(agent, signal);
537
- if (budget === null) {
538
- this.lastPressure.set(agent.session, { at: Date.now(), kind: 'no-window' });
539
- this.debug('量不到上下文窗口,这次不压');
540
- return { kind: 'no-window' };
541
- }
542
- const session = agent.session;
543
- const measurement = this.ctx.tokenMeter.measure(session);
544
- const nodes = measurement.nodes;
545
- // 固定开销(system + 工具 schema)恒占窗口、永不压缩:
546
- // 分母扣掉它得「可用预算」,分子扣掉它得「内容」,两者才可比。
547
- const overhead = this.overheadOf(session, nodes);
548
- // 估算值统一乘上事实修正系数,和 provider 报的真实 token 同一把尺子。
549
- const scale = this.calibration(session, measurement, overhead);
550
- const fixed = overhead.total * scale;
551
- const messages = Math.max(0, Number(measurement.surfaceTokens ?? 0) - overhead.system);
552
- const usable = Math.max(1, budget - fixed);
553
- const content = Math.max(0, messages * scale);
554
- const triggerTokens = Math.round(this.config.triggerRatio * usable);
555
- const nodeSum = nodes.reduce((sum, node) => sum + (node.tokens ?? 0), 0);
556
- this.debug(`压力判定:total=${measurement.totalTokens} 固定=${Math.round(fixed)} 内容=${Math.round(content)} 可用=${usable} surface=${measurement.surfaceTokens} 系数=${scale.toFixed(2)} baseline=${measurement.baseline} nodes=${nodes.length} 节点合计=${nodeSum} 触发线=${triggerTokens}${force ? '(手动要求)' : ''}`);
557
- /** 记下这次判定,面板据此回答「为什么没压」。 */
558
- const note = (info) => {
559
- this.lastPressure.set(session, {
560
- at: Date.now(),
561
- total: measurement.totalTokens,
562
- content,
563
- fixed: Math.round(fixed),
564
- ratio: scale,
565
- usable,
566
- trigger: triggerTokens,
567
- budget,
568
- calls: this.stats.pressureCalls ?? 0,
569
- ...info,
570
- });
571
- return info;
572
- };
573
- if (force !== true && content < triggerTokens) {
574
- return note({ kind: 'below-trigger' });
575
- }
576
-
577
- const { systemSeqs, protectedSeqs, surfaceSeqs } = this.classify(session, nodes, overhead.systemSeqs);
578
- const run = headRun(nodes, { systemSeqs, protectedSeqs });
579
- if (run === null) return note({ kind: 'no-range' });
580
-
581
- const blocks = this.blocksOf(session);
582
- const plan = planCommits({
583
- blocks,
584
- runSeqs: run.seqs,
585
- surfaceSeqs,
586
- contentTokens: content,
587
- targetTokens: force ? 0 : this.config.targetRatio * usable,
588
- });
589
- this.debug(`提交计划:${plan.blocks.length} 块(${plan.blocks.map((b) => `${b.blockId}/T${b.tier}`).join(' ') || '无'}),预计内容 ${content} → ${Math.round(plan.projectedTokens)}`);
590
- if (plan.blocks.length === 0) {
591
- return note({ kind: 'no-block', blocks: blocks.length, run: run.seqs.length });
592
- }
593
- return note({
594
- kind: 'commit',
595
- blocks: plan.blocks,
596
- blockIds: plan.blocks.map((block) => block.blockId),
597
- projected: Math.round(plan.projectedTokens),
598
- });
599
- }
600
-
601
- /**
602
- * 把区间末尾对齐到 step 边界。
603
- *
604
- * 宿主的 `compactRegion` 拒绝「end 落在 step 中间」的区间,所以提交前把 end
605
- * 向后推到该 step 的最后一条表层消息;找不到(比如 end 根本不是任何 step 的成员,
606
- * 例如 checkpoint 节点)就返回 null,让调用方决定是跳过还是原样传。
607
- *
608
- * @param {object} session
609
- * @param {number} endSeq
610
- * @param {Set<number>} surface 表层 seq 集合
611
- * @returns {number|null}
612
- */
613
- alignedEnd(session, endSeq, surface) {
614
- const stepEnd = closedStepEnd(session, endSeq);
615
- if (stepEnd === null) return null;
616
- let best = null;
617
- for (const seq of surface) {
618
- if (seq < endSeq || seq > stepEnd) continue;
619
- if (best === null || seq > best) best = seq;
620
- }
621
- return best;
622
- }
623
-
624
- /**
625
- * 让一段原材料落在工具配对平衡的位置上:向后补齐同一 step 的尾消息,补不动就剪掉这半个 step。
626
- *
627
- * 宿主要的是「工具配对平衡」(工具调用和它的结果不跨切点),step 末尾天然满足。
628
- * 不补齐的话,切点可能正好落在一个工具调用和它的结果之间,提交时会被宿主拒绝
629
- * (`not a balanced boundary`)——块白蒸,空间压不下去。
630
- *
631
- * @param {object} session
632
- * @param {number[]} seqs
633
- * @param {Set<number>} blockers 受保护 / 已覆盖 / checkpoint:跨过去就不许补
634
- * @returns {number[]} 对齐后的 seq 列表(可能为空)
635
- */
636
- alignRun(session, seqs, blockers) {
637
- const list = seqs.slice();
638
- const surface = session.surface.nodes.slice().sort((a, b) => a - b);
639
- for (let guard = 0; guard < 80 && list.length > 0; guard += 1) {
640
- const last = list[list.length - 1];
641
- const stepEnd = closedStepEnd(session, last);
642
- if (stepEnd === null) {
643
- list.pop();
644
- continue;
645
- }
646
- const extras = surface.filter((seq) => seq > last && seq <= stepEnd);
647
- if (extras.some((seq) => blockers.has(seq))) {
648
- list.pop();
649
- continue;
650
- }
651
- return [...list, ...extras];
652
- }
653
- return [];
654
- }
655
-
656
- /**
657
- * 在块自己的 covers 里找一个「工具配对平衡的末条」:优先缩,不向后扩。
658
- *
659
- * 宿主的提交闸门是**工具配对平衡**,不是 step 边界 —— dsh-compaction 的
660
- * tool-pairing 文档原话:safe cuts 由 tool-call/result 的内容推出,
661
- * "rather than step markers"。那句 `would split a step` 的报错文案是旧语义的遗留。
662
- *
663
- * 以前按 step 找边界,结果合并块的成员全是 checkpoint 节点、落在 step 之外
664
- * (compactRegion 就在 step 之间跑),一个都找不到 —— T2/T3 永远提交不了。
665
- *
666
- * 为什么只缩不扩:扩展会把没进摘要的消息也吞进 checkpoint —— 那几条既不在块的
667
- * covers 里(`decompress` 取不到),也不在摘要里,等于**悄悄丢内容**。
668
- * 缩到块的内部边界顶多让同一条消息被覆盖两次,是可接受的重复,不是丢失。
669
- *
670
- * @param {object} session
671
- * @param {number[]} covers 块覆盖的 seq(升序)
672
- * @param {Set<number>} surface 表层 seq 集合
673
- * @returns {number|null} 可安全作为 end 的 seq;找不到就返回 null(这一块先不提交)
674
- */
675
- balancedPrefixEnd(session, covers, surface) {
676
- for (let index = covers.length - 1; index >= 0; index -= 1) {
677
- const candidate = covers[index];
678
- if (!surface.has(candidate)) continue;
679
- try {
680
- if (toolPairingBalancedAfter(session, candidate)) return candidate;
681
- } catch {
682
- // 宿主判不了(事件缺失 / 表层已变)就当不齐,继续往前缩。
683
- }
684
- }
685
- return null;
686
- }
687
-
688
- /**
689
- * 补丁:compactIfNeeded。塔自己决定压不压、压多少,**不再交回宿主**。
690
- *
691
- * 交回宿主的代价是它会在塔外压出一段没有块号的 checkpoint:既不能 `decompress`,
692
- * 也不参与分层,塔的账本从此对不上账。宁可这次不压(请求照发,额度不够就报错),
693
- * 也不留这种无法追溯的压缩。溢出(context-overflow)不再直接放过:当成一次
694
- * 强制提交,用手上的块把空间腾出来,腾不动就让请求自己失败。
695
- */
696
- async compactIfNeeded(engine, agent, trigger, signal) {
697
- this.stats.pressureCalls = (this.stats.pressureCalls ?? 0) + 1;
698
- this.debug(`compactIfNeeded 被调用:trigger=${trigger}`);
699
- // 先把「宿主压过、账本没记」的段认领成块:否则它们是账本上的洞,
700
- // 库存与提交计划都会被这段无名历史带偏。
701
- await this.ensureImported(agent);
702
- // 手动提交没法在空闲时做:宿主的 compactRegion 要求事件落在一个「打开的回合」里。
703
- // 所以按钮只排队,等下一回合开始、引擎来问的时候按目标 0 一次提交完。
704
- const manual = this.commitRequests.has(agent.session);
705
- const force = manual || trigger !== 'pressure';
706
- let plan;
707
- try {
708
- plan = await this.planPressure(agent, signal, { force });
709
- } catch (error) {
710
- this.stats.failures += 1;
711
- this.ctx.logger?.warn(`tower: pressure planning failed (${String(error)}); leaving this request uncompressed`);
712
- return null;
713
- } finally {
714
- if (manual) this.commitRequests.delete(agent.session);
715
- }
716
- this.debug(`pressure 计划:${plan.kind}${plan.blockIds ? ` ${plan.blockIds.join(',')}` : ''}`);
717
- if (plan.kind === 'below-trigger') return null;
718
- // 量不到窗口 / 算不出可压区 / 没有就绪块:这次不压。原因已记进 lastPressure,面板会说。
719
- if (plan.kind !== 'commit') return null;
720
-
721
- // 一次请求可以连续提交多块:每块自己一对 compaction 事件,区间互不重叠。
722
- let first = null;
723
- const results = [];
724
- let succeeded = 0;
725
- let lastError = null;
726
- const surface = new Set(agent.session.surface.nodes);
727
- for (const block of plan.blocks) {
728
- // 宿主的闸门是工具配对平衡。优先在块自己的 covers 里缩到平衡点(不丢内容),
729
- // 缩不出来就跳过这一块 —— 绝不向后扩,那会把没进摘要的消息也盖掉。
730
- const end = this.balancedPrefixEnd(agent.session, block.covers, surface);
731
- if (end === null) {
732
- lastError = `block ${block.blockId} 的 covers 里找不到平衡点,跳过`;
733
- this.debug(lastError);
734
- continue;
735
- }
736
- this.pending.set(agent.session, { block });
737
- try {
738
- const result = await engine.compactRegion(block.covers[0], end, agent, signal);
739
- if (result !== null) {
740
- this.stats.committed += 1;
741
- succeeded += 1;
742
- results.push(result);
743
- this.recordCheckpoint(agent.session, block.blockId, result);
744
- first ??= result;
745
- }
746
- } catch (error) {
747
- this.stats.failures += 1;
748
- lastError = error?.message ?? String(error);
749
- this.ctx.logger?.warn(`tower: 提交 ${block.blockId} 失败(${lastError})`);
750
- this.debug(`提交 ${block.blockId} 失败:${lastError}`);
751
- break;
752
- } finally {
753
- this.pending.delete(agent.session);
754
- }
755
- }
756
- // 把真实结果补回判定记录:只写「计划提交」会让人误以为已经压下去了。
757
- const previous = this.lastPressure.get(agent.session) ?? {};
758
- this.lastPressure.set(agent.session, {
759
- ...previous,
760
- outcome: { planned: plan.blocks.length, succeeded, error: lastError },
761
- });
762
- // 手动压缩(/compact)一次可能提交多块,把总量合起来回报给命令层,
763
- // 否则它只会说「压了 1 段」。
764
- if (manual === true && results.length > 1) {
765
- return {
766
- ...results[results.length - 1],
767
- shadowedSeqs: results.flatMap((item) => item.shadowedSeqs ?? []),
768
- shadowedTokenCount: results.reduce((sum, item) => sum + (item.shadowedTokenCount ?? 0), 0),
769
- };
770
- }
771
- return first;
772
- }
773
-
774
- /**
775
- * `/compact` 的落点:把塔里已经蒸好的块提交进上下文。
776
- *
777
- * 装了本插件之后,「压缩」这个动作的语义就是提交块 —— 宿主原来那套
778
- * 「现场挑一段、现调模型摘要」已经不该再跑了(它压出来的东西没有块号,
779
- * 账本无法解释)。所以这里不复用原实现,而是走自己的提交路径。
780
- *
781
- * 宿主要求手动压缩独占 agent,所以外面套一层 `runMaintenance`。
782
- * @param {object} engine
783
- * @param {object} agent
784
- * @param {AbortSignal} [signal]
785
- * @returns {Promise<object|null>} 宿主 compactRegion 的结果;没有可提交的块时为 null
786
- */
787
- async manualCompact(engine, agent, signal) {
788
- const withMaintenance = typeof agent?.runMaintenance === 'function'
789
- ? (task) => agent.runMaintenance(task)
790
- : (task) => task(signal);
791
- return withMaintenance(async (agentSignal) => {
792
- const operation = agentSignal === undefined || signal === undefined
793
- ? (agentSignal ?? signal)
794
- : AbortSignal.any([agentSignal, signal]);
795
- this.commitRequests.add(agent.session);
796
- try {
797
- return await this.compactIfNeeded(engine, agent, 'manual', operation);
798
- } finally {
799
- this.commitRequests.delete(agent.session);
800
- }
801
- });
802
- }
803
-
804
- /**
805
- * 提交成功后,把「块号 ↔ checkpoint 节点」记进账本。
806
- *
807
- * 块身份不能靠 checkpoint 正文里的块头反推:格式一改,历史块就全部失联,
808
- * 库存会把它们重新算成待命,塔又停摆。checkpoint 是宿主在 compactRegion 里
809
- * 追加的,落在 startSeq…endSeq(compaction/end)之间,source 带 compactionId。
810
- *
811
- * @param {object} session
812
- * @param {string} blockId
813
- * @param {object} result compactRegion 的返回值
814
- */
815
- recordCheckpoint(session, blockId, result) {
816
- const first = Number(result.startSeq ?? 0);
817
- const last = Number(result.endSeq ?? session.seq);
818
- for (let seq = last; seq >= first; seq -= 1) {
819
- const event = session.eventAt(seq);
820
- if (event?.type !== 'user/message') continue;
821
- const source = event.data?.source;
822
- if (source === undefined || !isCompactCheckpointSource(source)) continue;
823
- if (result.compactionId !== undefined && source.compactionId !== result.compactionId) continue;
824
- const data = { blockId, checkpointSeq: event.seq };
825
- if (result.compactionId !== undefined) data.compactionId = result.compactionId;
826
- try {
827
- appendCommit(session, data);
828
- } catch (error) {
829
- // 记不上只是退化成正文回落,不能让一次提交被它带翻。
830
- this.debug(`写提交记录失败(${error?.message ?? error}),该块只能走正文回落`);
831
- }
832
- return;
833
- }
834
- this.debug(`提交 ${blockId} 后没找到 checkpoint 节点,未写提交记录(该会话只能走正文回落)`);
835
- }
836
-
837
- /**
838
- * 手动要求「立刻提交」。
839
- *
840
- * 合成本身可以在空闲时做(只写块记录),但把块换成 checkpoint 必须落在
841
- * 一个打开的回合里——`compactRegion` 会拒绝 `no open turn`。所以这里只排队,
842
- * 由下一个回合的 compactIfNeeded(那时有回合)忽略触发线、按目标 0 一次提交完。
843
- *
844
- * @param {object} agent
845
- * @returns {boolean} 是否已排队
846
- */
847
- requestCommit(agent) {
848
- if (agent?.session === undefined || agent.session === null) return false;
849
- this.commitRequests.add(agent.session);
850
- this.debug(`已排队手动提交:session=${agent.session.id}`);
851
- return true;
852
- }
853
-
854
- /**
855
- * 补丁:summarize。有现成块就零成本返回,否则交回宿主现场蒸馏。
856
- * @returns {Promise<{summary: object[]}>}
857
- */
858
- async serveSummary(engine, original, input, agent, signal) {
859
- const pending = this.pending.get(agent.session);
860
- if (pending !== undefined) {
861
- this.pending.delete(agent.session);
862
- this.stats.served += 1;
863
- const summary = summaryBlocks(pending.block, pending.block.childBlockIds ?? []);
864
- this.debug(`serveSummary 返回 ${summary.length} 个内容块: ${JSON.stringify(summary).slice(0, 160)}`);
865
- // provider/model 是 compaction/summary 事件的必填字段:引擎无条件写入这两个键,
866
- // 传 undefined 会让 append 以「carries non-JSON-serializable data」拒绝整条事件。
867
- // 值必须取**块自己的**来源:摘要可能来自观察者路由(observerProvider/observerModel)
868
- // 或自带提示词那一发,写主模型的名字等于把账记到别人头上。
869
- return {
870
- summary,
871
- provider: pending.block.origin?.provider ?? 'unknown',
872
- model: pending.block.origin?.model ?? 'unknown',
873
- };
874
- }
875
- this.stats.passthrough += 1;
876
- return original.call(engine, input, agent, signal);
877
- }
878
-
879
- /**
880
- * 会话是否还在 store 里活着。
881
- *
882
- * 后台蒸馏要花几秒,期间宿主可能已经关闭会话;那样写进内存的记录永远落不了盘,
883
- * 下一步就会重复蒸馏同一段。宁可不写,也不留悬空的块。
884
- * @param {object} session
885
- * @returns {boolean}
886
- */
887
- isLive(session) {
888
- const service = this.sessions;
889
- if (service === undefined || typeof service.get !== 'function') return true;
890
- try {
891
- return service.get(session.id) === session;
892
- } catch {
893
- return false;
894
- }
895
- }
896
-
897
- /**
898
- * 观察者(回合尾):按分层库存补蒸一批块,并等它们写完。
899
- *
900
- * 之所以在 turn-stopping 上等(而不是 fire-and-forget):宿主的持久化检查点
901
- * 只在请求前后,回合结束立刻关会话;不等就必然丢块。代价是这个回合晚几秒收尾。
902
- *
903
- * 请求前的那一次(pre-step)走同一个 `pump`,但**不等**:那时回合还开着,
904
- * 块写进内存后由下一次请求的检查点落盘。
905
- *
906
- * @param {object} agent
907
- * @param {number} turn
908
- * @param {AbortSignal|undefined} outerSignal
909
- */
910
- async observe(agent, turn, outerSignal) {
911
- const session = agent.session;
912
- this.debug(`turn-stopping 收到:session=${session.id} turn=${turn}`);
913
- if (!this.isLive(session)) {
914
- this.debug('会话已不在 store 里,跳过观察');
915
- return;
916
- }
917
- await this.pump(agent, outerSignal, turn, { wait: true });
918
- }
919
-
920
- /**
921
- * 排一批观察任务:分层算缺口 → 从最底层开始发名额 → 并发蒸馏。
922
- *
923
- * @param {object} agent
924
- * @param {AbortSignal|undefined} outerSignal
925
- * @param {number} turn
926
- * @param {{wait?: boolean}} options wait=true 时等这批全部结束(回合尾用)
927
- */
928
- async pump(agent, outerSignal, turn, options = {}) {
929
- const session = agent.session;
930
- if (!this.isLive(session)) return;
931
- if (this.distilling.has(session)) {
932
- this.debug('已有一批观察在跑,跳过');
933
- return;
934
- }
935
- if (options.wait !== true) {
936
- // 每个 step 都跑一遍全量测量太重:请求前的判定做个 5 秒节流。
937
- const last = this.lastPump.get(session) ?? 0;
938
- if (Date.now() - last < 5000) return;
939
- this.lastPump.set(session, Date.now());
940
- }
941
- this.distilling.add(session);
942
- const timeout = this.config.observerTimeoutMs;
943
- const batch = (async () => {
944
- try {
945
- await this.scheduleObservations(agent, outerSignal, turn);
946
- } catch (error) {
947
- this.stats.failures += 1;
948
- this.debug(`观察排程失败:${error?.message ?? error}`);
949
- }
950
- })();
951
- if (options.wait !== true) {
952
- // 请求前:发起即走,别卡住这次请求。批次结束自己收拾状态。
953
- batch.finally(() => this.distilling.delete(session));
954
- return;
955
- }
956
- const timer = setTimeout(() => this.debug(`观察批次超过 ${timeout}ms 仍未结束,先放手`), timeout);
957
- try {
958
- await batch;
959
- } finally {
960
- clearTimeout(timer);
961
- this.distilling.delete(session);
962
- }
963
- }
964
-
965
- /**
966
- * 算这批要蒸几块、每层几块,然后并发发出去。
967
- *
968
- * 分三步:
969
- * 1. 量出「每层未提交块还能回收多少 token」(分层库存);
970
- * 2. 量出「每层现在有几个单位的材料」;
971
- * 3. 从最底层开始按缺口发名额,发满并发上限就停。
972
- *
973
- * @param {object} agent
974
- * @param {AbortSignal|undefined} signal
975
- * @param {number} turn
976
- */
977
- async scheduleObservations(agent, signal, turn) {
978
- const session = agent.session;
979
- const engine = this.engineFor(agent);
980
- if (engine === undefined || engine === null) {
981
- this.debug('引擎实例还没露面(首次请求后才有),跳过这次观察');
982
- this.lastObservation.set(session, { at: Date.now(), kind: 'no-engine' });
983
- return;
984
- }
985
- const budget = await this.budgetOf(agent, signal);
986
- if (budget === null) {
987
- this.debug('拿不到上下文窗口,跳过观察');
988
- this.lastObservation.set(session, { at: Date.now(), kind: 'no-window' });
989
- return;
990
- }
991
-
992
- const measurement = this.ctx.tokenMeter.measure(session);
993
- const nodes = measurement.nodes;
994
- // 单位与库存都按「可用预算」算:system 与工具 schema 不参与压缩,也就不该进分母。
995
- const overhead = this.overheadOf(session, nodes);
996
- const scale = this.calibration(session, measurement, overhead);
997
- const blocks = this.blocksOf(session);
998
- const usable = Math.max(1, budget - overhead.total * scale);
999
- const { systemSeqs, protectedSeqs, checkpointSeqs } = this.classify(session, nodes, overhead.systemSeqs);
1000
- const coveredSeqs = new Set();
1001
- for (const block of blocks) for (const seq of block.covers) coveredSeqs.add(seq);
1002
- const tierOf = this.checkpointTiers(session, checkpointSeqs, blocks);
1003
- // 在飞任务已经占用的材料,不能再被另一个任务挑中
1004
- const reserved = this.reservedOf(session);
1005
- for (const seq of reserved) coveredSeqs.add(seq);
1006
-
1007
- const unit = Math.min(this.config.distillMinRunRatio * usable, this.config.observerMaxTokens);
1008
- // unit 是真实口径,而 availableUnits 累加的是节点估算值 → 换算成估算口径再比。
1009
- const unitH = Math.max(1, Math.round(unit / scale));
1010
- // 库存与配额都按「一块能回收多少」记账:inventory 数的是 covered − summary,
1011
- // 拿 unit 当分母会永远差一点点(差的就是摘要本身的大小)。
1012
- const reserve = this.reserveOf(usable);
1013
- const inventory = this.inventoryOf(session, blocks);
1014
- const available = this.availableUnits({
1015
- nodes, systemSeqs, coveredSeqs, checkpointSeqs, tierOf, unit: unitH, blocks,
1016
- });
1017
- const plan = planObservation({
1018
- available,
1019
- inventory,
1020
- reserveTokens: reserve.tokens,
1021
- unitTokens: reserve.reclaim,
1022
- concurrency: this.config.observerConcurrency,
1023
- });
1024
- /** 记下这次观察判定,面板据此回答「为什么没蒸」。 */
1025
- const note = (info) => {
1026
- this.lastObservation.set(session, {
1027
- at: Date.now(),
1028
- unit: Math.round(unit),
1029
- protected: protectedSeqs.size,
1030
- available: [...available].map(([tier, units]) => [tier, units]),
1031
- inventory: [...inventory].map(([tier, tokens]) => [tier, Math.round(tokens)]),
1032
- ...info,
1033
- });
1034
- };
1035
- if (plan.length === 0) {
1036
- // 全部材料都在保护区里 或 库存已够:两种都记下来,别让人以为观察者没跑。
1037
- const stocked = [...available.keys()].some((tier) => (inventory.get(tier) ?? 0) >= reserve.tokens);
1038
- note({ kind: available.size === 0 ? 'no-material' : stocked ? 'stocked' : 'no-quota' });
1039
- this.debug(`不补蒸:库存=${[...inventory].map(([t, v]) => `T${t}:${Math.round(v)}`).join(',') || '空'} 可用=${[...available].map(([t, v]) => `T${t}:${v}`).join(',') || '空'}`);
1040
- return;
1041
- }
1042
- note({ kind: 'launched', plan });
1043
- this.debug(`本批观察:${plan.map((job) => `T${job.tier}×${job.count}`).join(' ')}(单位 ${Math.round(unit)} tok)`);
1044
-
1045
- const jobs = [];
1046
- for (const job of plan) {
1047
- for (let index = 0; index < job.count; index += 1) {
1048
- jobs.push(this.distillOne(agent, signal, turn, job.tier));
1049
- }
1050
- }
1051
- const results = await Promise.allSettled(jobs);
1052
- for (const result of results) {
1053
- if (result.status === 'rejected') {
1054
- this.stats.failures += 1;
1055
- this.debug(`蒸馏失败:${result.reason?.message ?? result.reason}`);
1056
- }
1057
- }
1058
- }
1059
-
1060
- /** 这个会话上已被在飞任务占用的 seq 集合(懒建)。 */
1061
- reservedOf(session) {
1062
- let set = this.reserved.get(session);
1063
- if (set === undefined) {
1064
- set = new Set();
1065
- this.reserved.set(session, set);
1066
- }
1067
- return set;
1068
- }
1069
-
1070
- /**
1071
- * 分层库存:每层「已蒸馏、还没进上下文、且**材料还在**」的块能回收多少 token。
1072
- *
1073
- * 未提交 = 没被合并进上层(consumed)、没在表层生成 checkpoint(committed),
1074
- * 且 covers 全部仍在表层(usable)——最后这条与提交计划共用。
1075
- * 塔外的压缩(宿主的 /compact、或老会话里已有的 checkpoint)会把一段历史换成
1076
- * 没有块号的 checkpoint,覆盖该段的块从此永远提交不了;若还把它们算成库存,
1077
- * 观察者会以为库存满了而不再补料。
1078
- *
1079
- * 保护区不影响库存:蒸馏不受保护区限制,保护只在提交时起作用,块提前备着等材料滑出去。
1080
- *
1081
- * @param {object} session
1082
- * @param {Array<object>} blocks
1083
- * @returns {Map<number, number>}
1084
- */
1085
- inventoryOf(session, blocks) {
1086
- const surface = new Set(session.surface.nodes);
1087
- const index = checkpointIndex(session);
1088
- const consumed = new Set();
1089
- for (const block of blocks) for (const child of block.childBlockIds ?? []) consumed.add(child);
1090
- const inventory = new Map();
1091
- for (const block of blocks) {
1092
- if (consumed.has(block.blockId)) continue;
1093
- const seq = index.get(block.blockId);
1094
- if (seq !== undefined && surface.has(seq)) continue;
1095
- if (!usable(block, { surfaceSeqs: surface })) continue;
1096
- const tier = Number(block.tier ?? 1);
1097
- const reclaim = Number(block.coveredTokens ?? 0) - Number(block.summaryTokens ?? 0);
1098
- inventory.set(tier, (inventory.get(tier) ?? 0) + reclaim);
1099
- }
1100
- return inventory;
1101
- }
1102
-
1103
- /**
1104
- * 每层现在有几个「单位」的材料可蒸。
1105
- *
1106
- * T1 看最早那段连续可蒸原文;T2+ 看同层相邻 checkpoint 攒了多少。
1107
- * 这是**近似值**:只用来决定这一批发几个名额,不需要精确到个位。
1108
- *
1109
- * @returns {Map<number, number>}
1110
- */
1111
- availableUnits({ nodes, systemSeqs, coveredSeqs, checkpointSeqs, tierOf, unit, blocks }) {
1112
- const tokensOf = new Map(nodes.map((node) => [node.seq, node.tokens ?? 0]));
1113
- const available = new Map();
1114
-
1115
- // T1:整段可蒸原文一共多少 token。
1116
- // 注意这里**不受保护区限制** —— 蒸馏不改变上下文,保护区只在提交时起作用。
1117
- const rawRun = selectRawRun(nodes, {
1118
- systemSeqs,
1119
- protectedSeqs: new Set(),
1120
- coveredSeqs,
1121
- checkpointSeqs,
1122
- minTokens: unit,
1123
- maxTokens: Number.MAX_SAFE_INTEGER,
1124
- });
1125
- if (rawRun.length > 0) {
1126
- let raw = 0;
1127
- for (const seq of rawRun) raw += tokensOf.get(seq) ?? 0;
1128
- const units = Math.floor(raw / unit);
1129
- if (units > 0) available.set(1, units);
1130
- }
1131
-
1132
- // T2+:把表层按「同层相邻 checkpoint」分组,每组能凑几个单位
1133
- const groups = new Map();
1134
- let previous = null;
1135
- for (const seq of nodes.map((node) => node.seq)) {
1136
- const tier = checkpointSeqs.has(seq) ? tierOf.get(seq) : undefined;
1137
- if (tier === undefined) {
1138
- previous = null;
1139
- continue;
1140
- }
1141
- if (previous === null || previous.tier !== tier) {
1142
- previous = { tier, tokens: 0, count: 0 };
1143
- if (!groups.has(tier)) groups.set(tier, []);
1144
- groups.get(tier).push(previous);
1145
- }
1146
- previous.tokens += tokensOf.get(seq) ?? 0;
1147
- previous.count += 1;
1148
- }
1149
- for (const [tier, list] of groups) {
1150
- let units = 0;
1151
- for (const group of list) {
1152
- if (group.count < this.config.mergeMinBlocks) continue;
1153
- units += Math.floor(group.tokens / unit);
1154
- }
1155
- if (units > 0) available.set(tier + 1, (available.get(tier + 1) ?? 0) + units);
1156
- }
1157
- return available;
1158
- }
1159
-
1160
- /**
1161
- * 蒸一块:按目标层挑段 → 借宿主的 summarize 生成摘要 → 写块记录。
1162
- *
1163
- * @param {object} agent
1164
- * @param {AbortSignal|undefined} outerSignal
1165
- * @param {number} turn
1166
- * @param {number} wantTier 目标层:1 = 蒸原文,≥2 = 合并同层 checkpoint
1167
- */
1168
- async distillOne(agent, outerSignal, turn, wantTier) {
1169
- const session = agent.session;
1170
- const engine = this.engineFor(agent);
1171
- if (engine === undefined || engine === null) return;
1172
- const controller = new AbortController();
1173
- const timer = setTimeout(() => controller.abort(new Error('tower: distillation timed out')), this.config.observerTimeoutMs);
1174
- if (outerSignal !== undefined) {
1175
- if (outerSignal.aborted) controller.abort(outerSignal.reason);
1176
- else outerSignal.addEventListener('abort', () => controller.abort(outerSignal.reason), { once: true });
1177
- }
1178
- const reserved = this.reservedOf(session);
1179
- let claimed = [];
1180
- try {
1181
- const budget = await this.budgetOf(agent, controller.signal);
1182
- if (budget === null) return;
1183
- const measurement = this.ctx.tokenMeter.measure(session);
1184
- const nodes = measurement.nodes;
1185
- // 与提交端同一分母:可用预算 = 窗口 − 固定开销。
1186
- const overhead = this.overheadOf(session, nodes);
1187
- const scale = this.calibration(session, measurement, overhead);
1188
- const blocks = this.blocksOf(session);
1189
- const usable = Math.max(1, budget - overhead.total * scale);
1190
- const { systemSeqs, protectedSeqs, checkpointSeqs } = this.classify(session, nodes, overhead.systemSeqs);
1191
- const coveredSeqs = new Set();
1192
- for (const block of blocks) for (const seq of block.covers) coveredSeqs.add(seq);
1193
- for (const seq of reserved) coveredSeqs.add(seq);
1194
- const tierOf = this.checkpointTiers(session, checkpointSeqs, blocks);
1195
- const unit = Math.min(this.config.distillMinRunRatio * usable, this.config.observerMaxTokens);
1196
- // 选段累加的是节点估算值,门槛是真实口径 → 一起换算到估算口径再比。
1197
- const unitH = Math.max(1, Math.round(unit / scale));
1198
- const maxH = Math.max(unitH, Math.round(this.config.observerMaxTokens / scale));
1199
-
1200
- let seqs = [];
1201
- if (wantTier <= 1) {
1202
- // 蒸馏不受保护区限制:块只是写进账本,不改变上下文。
1203
- // 真正需要保护的是「提交」——那一步在 planCommits 里按保护区过滤。
1204
- seqs = selectRawRun(nodes, {
1205
- systemSeqs, protectedSeqs: new Set(), coveredSeqs, checkpointSeqs,
1206
- minTokens: unitH,
1207
- maxTokens: maxH,
1208
- stopAtTokens: unitH,
1209
- });
1210
- } else {
1211
- const mergeRun = selectMergeRun(nodes, {
1212
- protectedSeqs, coveredSeqs, checkpointSeqs, tierOf,
1213
- // 只从「上一层」取料:想要 T2 就只从 T1 里选。先选再比对的话,
1214
- // 扫描会先撞上更老的深层 run,对不上目标层就整轮空手,浅层永远轮不到。
1215
- memberTier: wantTier - 1,
1216
- minCount: this.config.mergeMinBlocks,
1217
- minTokens: unitH,
1218
- maxTokens: maxH,
1219
- });
1220
- // 再验一次目标层:memberTier 已经在选料时过滤过,这里防的是 tierOf 缺项。
1221
- const tier = mergeRun.length === 0 ? undefined : (tierOf.get(mergeRun[0]) ?? 0) + 1;
1222
- if (tier === wantTier) seqs = mergeRun;
1223
- }
1224
- if (seqs.length === 0) {
1225
- this.debug(`T${wantTier} 这一份没挑到材料(可能刚被别的任务占了)`);
1226
- return;
1227
- }
1228
- // 原材料块的尾巴要落在工具配对平衡的位置上(补齐到 step 末尾正好满足:
1229
- // step 结束意味着工具调用和结果都齐了)。合并块的材料是 checkpoint 节点,
1230
- // 它替换的区间两端本来就是平衡的,不需要这一步。
1231
- if (wantTier <= 1) {
1232
- const blockers = new Set([...coveredSeqs, ...checkpointSeqs, ...reserved]);
1233
- const aligned = this.alignRun(session, seqs, blockers);
1234
- if (aligned.length === 0) {
1235
- this.debug('对齐 step 边界后没有完整的一段,放弃这一份');
1236
- return;
1237
- }
1238
- seqs = aligned;
1239
- }
1240
- claimed = seqs;
1241
- for (const seq of claimed) reserved.add(seq);
1242
- await this.materialize(agent, controller.signal, turn, seqs, nodes, blocks, engine, scale);
1243
- } finally {
1244
- for (const seq of claimed) reserved.delete(seq);
1245
- clearTimeout(timer);
1246
- }
1247
- }
1248
-
1249
- /**
1250
- * 手动合并:把指定的几个块的 checkpoint 节点合成一个新块。
1251
- * 只接受当前表层上、层级相同的块,避免造出跨层的怪块。
1252
- *
1253
- * @param {object} agent
1254
- * @param {AbortSignal} signal
1255
- * @param {string[]} blockIds
1256
- * @param {number} [turn]
1257
- * @returns {Promise<object|null>} 新块记录,失败返回 null
1258
- */
1259
- async mergeBlocks(agent, signal, blockIds, turn = 0) {
1260
- const session = agent.session;
1261
- const engine = this.engineFor(agent);
1262
- if (engine === undefined || engine === null) return null;
1263
- const blocks = this.blocksOf(session);
1264
- const byId = new Map(blocks.map((block) => [block.blockId, block]));
1265
- const wanted = [];
1266
- for (const id of blockIds) {
1267
- const block = byId.get(id);
1268
- if (block === undefined) return null;
1269
- wanted.push(block);
1270
- }
1271
- if (wanted.length < 2) return null;
1272
- const tier = Number(wanted[0].tier ?? 1);
1273
- if (wanted.some((block) => Number(block.tier ?? 1) !== tier)) return null;
1274
-
1275
- const surface = new Set(session.surface.nodes);
1276
- const index = checkpointIndex(session);
1277
- const seqs = [];
1278
- for (const block of wanted) {
1279
- const seq = index.get(block.blockId);
1280
- if (seq === undefined || !surface.has(seq)) return null;
1281
- seqs.push(seq);
1282
- }
1283
- seqs.sort((a, b) => a - b);
1284
-
1285
- const measurement = this.ctx.tokenMeter.measure(session);
1286
- const nodes = measurement.nodes;
1287
- const scale = this.calibration(session, measurement, this.overheadOf(session, nodes));
1288
- return this.materialize(agent, signal, turn, seqs, nodes, blocks, engine, scale);
1289
- }
1290
-
1291
- /**
1292
- * 把选中的一段做成块:借宿主的 summarize 生成摘要 → 写块记录。
1293
- * 自动蒸馏与手动合并共用这一段,唯一的差别是 seqs 从哪来。
1294
- *
1295
- * @param {object} agent
1296
- * @param {AbortSignal} signal
1297
- * @param {number} turn
1298
- * @param {number[]} seqs
1299
- * @param {Array<{seq:number, tokens:number, heuristicTokens?:number}>} nodes
1300
- * @param {Array<object>} blocks
1301
- * @param {object} engine
1302
- * @param {number} [scale] 事实修正系数(见 calibration)
1303
- * @returns {Promise<object|null>} 新块记录
1304
- */
1305
- async materialize(agent, signal, turn, seqs, nodes, blocks, engine, scale = 1) {
1306
- const session = agent.session;
1307
- const input = this.buildSummaryInput(session, seqs);
1308
- if (input.messages.length === 0) {
1309
- this.debug('选中段投影不出消息,跳过');
1310
- return null;
1311
- }
1312
- this.debug(`开始蒸馏 ${seqs.length} 条(seq ${seqs[0]}…${seqs[seqs.length - 1]})`);
1313
-
1314
- const route = this.observerRoute(agent);
1315
- const produced = await this.summarizeWithRoute(engine, input, agent, signal);
1316
- const text = (produced?.summary ?? [])
1317
- .filter((part) => part.type === 'text')
1318
- .map((part) => part.text)
1319
- .join('\n')
1320
- .trim();
1321
- if (text.length === 0) {
1322
- this.debug('摘要为空,跳过');
1323
- return null;
1324
- }
1325
-
1326
- // 真值优先:同一段材料在两次请求之间的 prompt 差,就是它真实占的 token
1327
- // (实测比 chars/4 估算大 1.2~1.4 倍)。测不到才退回「估算 × 标定系数」。
1328
- const estimated = sumHeuristic(nodes, seqs);
1329
- const measured = this.realTokensOf(session, seqs, estimated);
1330
- const coveredTokens = measured ?? Math.round(estimated * scale);
1331
- const summaryTokens = Math.round(this.estimateText(text) * scale);
1332
- const children = this.childBlocksOf(session, seqs, blocks);
1333
- const tier = children.length === 0 ? 1 : Math.max(...children.map((child) => child.tier ?? 1)) + 1;
1334
- const record = {
1335
- blockId: this.allocateBlockId(session, blocks),
1336
- tier,
1337
- covers: seqs,
1338
- childBlockIds: children.map((child) => child.blockId),
1339
- summary: [{ type: 'text', text }],
1340
- coveredTokens,
1341
- summaryTokens,
1342
- kind: children.length === 0 ? 'run' : 'distill',
1343
- origin: { provider: route.provider || 'unknown', model: route.model || 'unknown', turn },
1344
- createdAt: new Date().toISOString(),
1345
- };
1346
- if (!this.isLive(session)) {
1347
- this.debug('会话在蒸馏期间已经关闭,放弃写入(下一回合会重新蒸馏)');
1348
- this.pendingIds.get(session)?.delete(record.blockId);
1349
- return null;
1350
- }
1351
- appendBlock(session, record);
1352
- // 块已进账本,占位的号可以释放了(下一次 nextBlockId 会自然跳过它)。
1353
- this.pendingIds.get(session)?.delete(record.blockId);
1354
- await this.flushSession(agent, session);
1355
- this.stats.distilled += 1;
1356
- this.debug(`写入块 ${record.blockId}(T${record.tier}, ${seqs.length} 条, ${coveredTokens} → ${summaryTokens} tok, ${record.kind})`);
1357
- return record;
1358
- }
1359
-
1360
- /**
1361
- * 观察者这次蒸馏该用哪个模型。留空就跟随当前会话的路由。
1362
- * @param {object} agent
1363
- * @returns {{provider: string, model: string}}
1364
- */
1365
- observerRoute(agent) {
1366
- return {
1367
- provider: this.config.observerProvider || agent.options?.provider || '',
1368
- model: this.config.observerModel || agent.options?.model || '',
1369
- };
1370
- }
1371
-
1372
- /**
1373
- * 让宿主的 summarize 生成摘要;两种情况会绕开它:
1374
- *
1375
- * 1. 观察者配了自己的模型 —— 宿主挑模型的顺序是「引擎的 summarization* 配置 →
1376
- * 会话最近一次请求的路由 → agent.options」,前两级都会盖掉 agent.options,
1377
- * 改 agent 没用,只能临时换掉引擎那份 config。引擎实例属于这个 agent 的
1378
- * context,同一会话的压缩是串行的,这个替换窗口是安全的,拿到结果立刻还原。
1379
- * 2. 观察者配了自己的提示词(中文预设 / 自定义)—— 宿主的指令是写死的常量,
1380
- * 没有参数能换,所以这一次请求由 requestSummary 自己发(形状照抄宿主)。
1381
- *
1382
- * @param {object} engine
1383
- * @param {object} input
1384
- * @param {object} agent
1385
- * @param {AbortSignal} signal
1386
- */
1387
- async summarizeWithRoute(engine, input, agent, signal) {
1388
- const route = this.observerRoute(agent);
1389
- const instruction = summaryInstruction(this.config);
1390
- if (instruction !== null) {
1391
- if (route.provider === '' || route.model === '') {
1392
- // 自定义提示词要自己发请求,必须知道发给谁;路由不全就退回宿主,别把蒸馏搞停。
1393
- this.debug('自定义摘要提示词已启用,但观察者路由不完整,这一次仍走宿主 summarize');
1394
- } else {
1395
- return requestSummary({
1396
- ctx: this.ctx,
1397
- session: agent.session,
1398
- provider: route.provider,
1399
- model: route.model,
1400
- input,
1401
- instruction,
1402
- maxTokens: this.config.summaryMaxTokens,
1403
- signal,
1404
- });
1405
- }
1406
- }
1407
-
1408
- const follows = route.provider === '' || route.model === '';
1409
- const same = agent.options?.provider === route.provider && agent.options?.model === route.model;
1410
- if (follows || same) return engine.summarize(input, agent, signal);
1411
-
1412
- const saved = engine.config;
1413
- engine.config = { ...saved, summarizationProvider: route.provider, summarizationModel: route.model };
1414
- try {
1415
- return await engine.summarize(input, agent, signal);
1416
- } finally {
1417
- engine.config = saved;
1418
- }
1419
- }
1420
-
1421
- /**
1422
- * 一份文本按宿主口径折算成 token 数(与 shadow-price 同一把尺)。
1423
- * 宿主不可用时退回字符估算,绝不抛。
1424
- * @param {string} text
1425
- */
1426
- estimateText(text) {
1427
- try {
1428
- return this.ctx.tokenMeter.estimateMessage({ role: 'user', content: [{ type: 'text', text }] });
1429
- } catch {
1430
- return Math.ceil(text.length / 4);
1431
- }
1432
- }
1433
-
1434
- /**
1435
- * 表层上每个 checkpoint 节点的层级:查 checkpointOwner 反查块记录。
1436
- * @param {object} session
1437
- * @param {Set<number>} checkpointSeqs
1438
- * @param {Array<object>} blocks
1439
- * @returns {Map<number, number>}
1440
- */
1441
- checkpointTiers(session, checkpointSeqs, blocks) {
1442
- const byId = new Map(blocks.map((block) => [block.blockId, block]));
1443
- const owner = checkpointOwner(session);
1444
- const tiers = new Map();
1445
- for (const seq of checkpointSeqs) {
1446
- const block = byId.get(owner.get(seq));
1447
- if (block !== undefined) tiers.set(seq, Number(block.tier ?? 1));
1448
- }
1449
- return tiers;
1450
- }
1451
-
1452
- /**
1453
- * 找出一段历史里「已经是某个块的摘要节点」的成员。
1454
- *
1455
- * 身份查 checkpointOwner(提交记录优先,老会话回落正文块头),不再解析正文。
1456
- * 有子块就说明这一段是「摘要的摘要」——层级 +1,成为 T2。
1457
- *
1458
- * @param {object} session
1459
- * @param {number[]} seqs
1460
- * @param {Array<object>} blocks
1461
- * @returns {Array<object>}
1462
- */
1463
- childBlocksOf(session, seqs, blocks) {
1464
- const byId = new Map(blocks.map((block) => [block.blockId, block]));
1465
- const owner = checkpointOwner(session);
1466
- const found = [];
1467
- const seen = new Set();
1468
- for (const seq of seqs) {
1469
- const blockId = owner.get(seq);
1470
- if (blockId === undefined || seen.has(blockId)) continue;
1471
- const block = byId.get(blockId);
1472
- if (block === undefined) continue;
1473
- seen.add(blockId);
1474
- found.push(block);
1475
- }
1476
- return found;
1477
- }
1478
-
1479
- /**
1480
- * 复刻引擎的 buildSummarizationInput:system 头 + 工具 schema + 区间内逐条消息。
1481
- * 前缀与真实请求一致,供应商侧 KV 缓存才能命中。
1482
- * @param {object} session
1483
- * @param {number[]} seqs
1484
- */
1485
- buildSummaryInput(session, seqs) {
1486
- const header = session.requestHeader();
1487
- const headSeq = session.surface.nodes[0];
1488
- const head = headSeq === undefined ? undefined : session.eventAt(headSeq);
1489
- const system = head?.type === 'system/message' ? session.deriveEventMessage(head) : null;
1490
- const regionMessages = [];
1491
- for (const seq of seqs) {
1492
- const event = session.eventAt(seq);
1493
- if (event === undefined) continue;
1494
- const message = session.deriveEventMessage(event);
1495
- if (message !== null && message !== undefined) regionMessages.push(message);
1496
- }
1497
- return {
1498
- ...(header?.tools === undefined ? {} : { tools: header.tools }),
1499
- messages: system === null ? regionMessages : [system, ...regionMessages],
1500
- };
1501
- }
1502
-
1503
- /** 渲染一段 seq 的原文,用于解压输出。 */
1504
- renderSeqs(session, seqs, { maxCharsPerMessage = 400 } = {}) {
1505
- const lines = [];
1506
- for (const seq of seqs) {
1507
- const event = session.eventAt(seq);
1508
- if (event === undefined) continue;
1509
- const message = session.deriveEventMessage(event);
1510
- if (message === null || message === undefined) continue;
1511
- lines.push(`${refOf(seq)} [${roleLabel(event, message)}] ${describeMessage(message, maxCharsPerMessage)}`);
1512
- }
1513
- return lines;
1514
- }
1515
- }
1516
-
1517
- /**
1518
- * 按节点口径求和。
1519
- *
1520
- * 优先取 `tokens` 而不是 `heuristicTokens`:两者的差别只在图片/文件节点上,
1521
- * `tokens` 用的是路由的真实计价(视觉 token + 投影文本),`heuristicTokens`
1522
- * 只给一个结构化的固定估计 —— 取后者会把图片的真实成本吞掉。
1523
- */
1524
- function sumHeuristic(nodes, seqs) {
1525
- const wanted = new Set(seqs);
1526
- let total = 0;
1527
- for (const node of nodes) if (wanted.has(node.seq)) total += node.tokens ?? node.heuristicTokens;
1528
- return total;
1529
- }
1
+ /**
2
+ * Tower 的核心:提交策略 + 摘要供给 + 观察者。
3
+ *
4
+ * 分工:
5
+ * - 观察者(后台)把早期历史蒸馏成块记录,写进日志,模型看不见;
6
+ * - 提交(请求前)只做查表:把「已经蒸馏好的整块」交给引擎换进表层;
7
+ * - 摘要在提交路径上零 LLM 调用——这是整个方案存在的理由。
8
+ */
9
+ import {
10
+ appendBlock, appendCommit, blockHeader, checkpointIndex, checkpointOwner, closedStepEnd,
11
+ describeMessage, iterateEvents, nextBlockId, readBlocks, refOf, roleLabel, stepStartSeqs,
12
+ } from './core.js';
13
+ import { BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm';
14
+ import { isCompactCheckpointSource, toolPairingBalancedAfter } from '@deepseek-ai/dsh-compaction';
15
+ import { summaryInstruction } from './prompts.js';
16
+ import { requestSummary } from './summarize.js';
17
+ import { headRun, planCommits, planObservation, planTarget, protectedTail, recoveryRatio, selectMergeRun, selectRawRun, usable } from './select.js';
18
+
19
+ /**
20
+ * 把流的终止状态翻成错误:出错/被中断/截断都算失败,正常结束返回 undefined。
21
+ * 与 compaction-basic 的同名私有实现一致。
22
+ * @param {object} finish
23
+ */
24
+ function finishError(finish) {
25
+ if (finish === undefined) return undefined;
26
+ if (finish.kind === 'error' || finish.kind === 'aborted') {
27
+ return new LlmError(finish.failure.message, finish.failure.code, finish.failure);
28
+ }
29
+ if (finish.kind === 'max-tokens') {
30
+ const error = new Error('answer truncated at the token cap');
31
+ error.code = 'MAX_TOKENS';
32
+ return error;
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ /** 安全取服务:未注入或 context 已失效时返回 undefined,不抛。 */
38
+ function captureService(ctx, name) {
39
+ try {
40
+ return ctx?.[name];
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * 一块大约能回收它覆盖内容的多少。
48
+ *
49
+ * 提交一块换掉的是 covered(原文 / 下层摘要),换进来的是 summary(自己的摘要),
50
+ * 所以真实回收 = covered − summary。实测约 92%,这里取 0.8 留余量。
51
+ */
52
+ const RECLAIM_RATE = 0.8;
53
+
54
+ /** 把块记录变成模型可见的消息块。头部 + 正文。 */
55
+ export function summaryBlocks(block, childBlockIds = []) {
56
+ const header = blockHeader({
57
+ blockId: block.blockId,
58
+ tier: block.tier,
59
+ covers: block.covers,
60
+ coveredTokens: block.coveredTokens,
61
+ childBlockIds,
62
+ });
63
+ return [{ type: 'text', text: header }, ...block.summary];
64
+ }
65
+
66
+ /** 摘要记录的纯文本(搜索/UI 用)。 */
67
+ export function blockText(block) {
68
+ return block.summary.map((part) => part.text ?? '').join('\n');
69
+ }
70
+
71
+ export class Tower {
72
+ /**
73
+ * @param {object} ctx 宿主 context(插件级)
74
+ * @param {object} config 已校验的配置
75
+ */
76
+ constructor(ctx, config) {
77
+ this.ctx = ctx;
78
+ this.config = config;
79
+ // 服务引用必须在装载时抓住:回合结束后 context 会变成 inactive,
80
+ // 那时再通过 ctx.sessions 取会抛 "cannot get required service … in inactive context"。
81
+ this.sessions = captureService(ctx, 'sessions');
82
+ /** 宿主投影:工具 schema 的 token 只能从这里拿(工具不是表层节点)。 */
83
+ this.projections = captureService(ctx, 'sessionProjections');
84
+ /** 提交时用于把「选中的整块」传给 summarize:WeakMap<Session, {block, seqs}> */
85
+ this.pending = new WeakMap();
86
+ /** 正在后台蒸馏的会话,避免同一会话同时排两批 */
87
+ this.distilling = new WeakSet();
88
+ /** 已分配但还没写入的块号(并发蒸馏防撞号) */
89
+ this.pendingIds = new WeakMap();
90
+ /** 已被在飞任务占用的 seq:并发蒸馏时防止两个任务挑到同一段材料 */
91
+ this.reserved = new WeakMap();
92
+ /** 上次请求前排程的时间,用来做 5 秒节流(每个 step 都全量测量太贵) */
93
+ this.lastPump = new WeakMap();
94
+ /** 上一次压力判定(面板用来回答「为什么没压」) */
95
+ this.lastPressure = new WeakMap();
96
+ /** 上一次观察判定(面板用来回答「为什么没蒸」) */
97
+ this.lastObservation = new WeakMap();
98
+ /** 诊断计数,便于日志确认补丁真的在工作 */
99
+ this.stats = { distilled: 0, committed: 0, served: 0, passthrough: 0, failures: 0 };
100
+ /**
101
+ * 见过的压缩引擎:WeakMap<Agent, engine>。
102
+ *
103
+ * 压缩后端挂在 preset 的隔离组里,某些组合(例如 web)从 `agent.ctx` 取不到
104
+ * 那个服务;但引擎自己会调用我们的补丁方法,那时 `this` 就是它 —— 顺手记下来,
105
+ * 后台蒸馏与手动操作就不必再猜服务名。
106
+ */
107
+ this.engines = new WeakMap();
108
+ /** 手动要求「立刻提交」的会话:下一个回合开始时执行一次全量提交。 */
109
+ this.commitRequests = new WeakSet();
110
+ /** 事实修正系数:WeakMap<Session, {scale, samples}>(见 calibration) */
111
+ this.fits = new WeakMap();
112
+ /** 已经认领过宿主遗留 checkpoint 的会话(每次进程一次就够)。 */
113
+ this.imported = new WeakSet();
114
+ }
115
+
116
+ /**
117
+ * 认领「宿主压过、账本没记」的段。
118
+ *
119
+ * 装插件之前宿主压缩留下的 checkpoint 没有块号:对账本来说它是一个洞 ——
120
+ * 面板解释不了它,被它盖住的块悄悄失效却还挂着。这里把它登记成一个**只读块**:
121
+ *
122
+ * - `covers` 从事件的 `surfaceOp` 还原(宿主写的是 replace 区间);
123
+ * - 写一条普通的 block 记录 + commit 记录,于是身份、状态、面板全都自动对上;
124
+ * - 但它的 covers 已经不在表层,`usable()` 判 false → **永远不会被再次提交**;
125
+ * - `decompress` 直接读日志,所以这些块的原文仍然取得回来。
126
+ *
127
+ * 幂等:某条 checkpoint 已经有块号(不管是我们提交的还是上次认领的)就跳过。
128
+ * @param {object} agent
129
+ */
130
+ async ensureImported(agent) {
131
+ const session = agent?.session;
132
+ if (session === undefined || session === null) return;
133
+ if (this.imported.has(session)) return;
134
+ try {
135
+ const blocks = readBlocks(session);
136
+ const owner = checkpointOwner(session);
137
+ let added = 0;
138
+ for (const seq of session.surface.nodes) {
139
+ if (owner.has(seq)) continue;
140
+ const event = session.eventAt(seq);
141
+ if (event?.type !== 'user/message') continue;
142
+ const source = event.data?.source;
143
+ if (source === undefined || !isCompactCheckpointSource(source)) continue;
144
+ const op = event.surfaceOp;
145
+ if (op?.op !== 'replace') continue;
146
+ // 宿主写 replace 时同时记了 sourceEventSeqs(被换掉的节点清单),比区间精确;
147
+ // 老格式没有这个字段就退回区间遍历。
148
+ const shadowed = Array.isArray(event.sourceEventSeqs) && event.sourceEventSeqs.length > 0
149
+ ? event.sourceEventSeqs
150
+ : (() => {
151
+ const list = [];
152
+ for (let cursor = op.startSeq; cursor <= op.endSeq; cursor += 1) list.push(cursor);
153
+ return list;
154
+ })();
155
+ const covers = [];
156
+ const children = new Set();
157
+ for (const cursor of shadowed) {
158
+ const covered = session.eventAt(cursor);
159
+ if (covered === undefined) continue;
160
+ if (session.deriveEventMessage(covered) === null) continue;
161
+ covers.push(cursor);
162
+ const child = owner.get(cursor);
163
+ if (child !== undefined) children.add(child);
164
+ }
165
+ if (covers.length === 0) continue;
166
+
167
+ const text = (event.data?.content ?? [])
168
+ .filter((part) => part.type === 'text')
169
+ .map((part) => part.text)
170
+ .join('\n');
171
+ const summaryTokens = Math.max(1, this.estimateText(text));
172
+ let coveredTokens = 0;
173
+ for (const cursor of covers) {
174
+ try {
175
+ coveredTokens += this.ctx.tokenMeter.estimateMessage(session.deriveEventMessage(session.eventAt(cursor)));
176
+ } catch {
177
+ // 估不出来就少算一点:这个数只影响合并配额,不影响正确性。
178
+ }
179
+ }
180
+ const tier = children.size === 0
181
+ ? 1
182
+ : Math.max(...[...children].map((id) => Number(blocks.find((b) => b.blockId === id)?.tier ?? 1))) + 1;
183
+ const blockId = this.allocateBlockId(session, blocks);
184
+ const record = {
185
+ blockId,
186
+ tier,
187
+ kind: 'import',
188
+ covers,
189
+ coveredTokens: Math.max(coveredTokens, summaryTokens),
190
+ summaryTokens,
191
+ childBlockIds: [...children],
192
+ summary: [{ type: 'text', text }],
193
+ origin: { kind: 'foreign', checkpointSeq: seq, compactionId: source?.compactionId ?? null },
194
+ tokensEstimated: true,
195
+ createdAt: new Date().toISOString(),
196
+ };
197
+ appendBlock(session, record);
198
+ appendCommit(session, {
199
+ blockId,
200
+ checkpointSeq: seq,
201
+ ...(typeof source?.compactionId === 'string' ? { compactionId: source.compactionId } : {}),
202
+ });
203
+ blocks.push(record);
204
+ owner.set(seq, blockId);
205
+ added += 1;
206
+ }
207
+ this.imported.add(session);
208
+ if (added > 0) {
209
+ this.debug(`认领了 ${added} 段宿主压缩留下的历史(只读块)`);
210
+ await this.flushSession(agent, session);
211
+ }
212
+ } catch (error) {
213
+ this.debug(`认领宿主 checkpoint 失败:${error?.message ?? error}`);
214
+ }
215
+ }
216
+
217
+ /**
218
+ * 换一份配置(配置界面保存后调用)。
219
+ *
220
+ * 所有读配置的地方都走 `this.config`,所以换掉这个对象即刻生效,
221
+ * 不需要重启宿主,也不需要重建 Tower 实例。
222
+ * @param {object} next 已校验的完整配置
223
+ */
224
+ updateConfig(next) {
225
+ this.config = next;
226
+ // 判定记录里的触发线、单位都是旧参数算出来的。不作废的话,面板会出现
227
+ // 「进度条触发线 471.9K、上次判定触发线 314.6K」这种自相矛盾,一直挂到下次请求。
228
+ this.lastPressure = new WeakMap();
229
+ this.lastObservation = new WeakMap();
230
+ }
231
+
232
+ /** 补丁入口调用:记住「这个 agent 用的是哪个引擎实例」。 */
233
+ rememberEngine(agent, engine) {
234
+ // 按**会话**记,不按 agent 对象记:宿主在不同钩子里传进来的 agent 包装可能不是同一个引用。
235
+ const session = agent?.session;
236
+ if (session !== undefined && session !== null && engine !== undefined && engine !== null) {
237
+ this.engines.set(session, engine);
238
+ }
239
+ }
240
+
241
+
242
+ /**
243
+ * 分配块号。并发蒸馏时多个任务各自持有同一份 blocks 快照,
244
+ * 直接 nextBlockId() 会算出同一个号,所以这里把「已分配但还没写入」的号也占位。
245
+ * @param {object} session
246
+ * @param {Array<object>} blocks
247
+ * @returns {string}
248
+ */
249
+ allocateBlockId(session, blocks) {
250
+ let reserved = this.pendingIds.get(session);
251
+ if (reserved === undefined) {
252
+ reserved = new Set();
253
+ this.pendingIds.set(session, reserved);
254
+ }
255
+ const taken = [...blocks.map((block) => block.blockId), ...reserved].map((blockId) => ({ blockId }));
256
+ const id = nextBlockId(taken);
257
+ reserved.add(id);
258
+ return id;
259
+ }
260
+
261
+ /** 取该 agent 的压缩引擎:先用见过的实例,再回落服务查找。 */
262
+ engineFor(agent) {
263
+ const session = agent?.session;
264
+ const seen = session === undefined || session === null ? undefined : this.engines.get(session);
265
+ if (seen !== undefined) return seen;
266
+ // 两条退路:插件自己的 context(profile 级),以及 agent 的 context。
267
+ const candidates = [this.ctx, agent?.ctx];
268
+ for (const ctx of candidates) {
269
+ let found;
270
+ try {
271
+ found = ctx?.get?.('compaction');
272
+ } catch {
273
+ found = undefined;
274
+ }
275
+ if (found !== undefined && found !== null) {
276
+ if (session !== undefined && session !== null) this.engines.set(session, found);
277
+ return found;
278
+ }
279
+ }
280
+ return undefined;
281
+ }
282
+
283
+ /** 诊断:TOWER_DEBUG=1 时把决策过程写进 stderr(不依赖 logger 是否可用)。 */
284
+ debug(message) {
285
+ if (process.env.TOWER_DEBUG !== '1') return;
286
+ console.error(`tower[debug]: ${message}`);
287
+ }
288
+
289
+ /** 读出某个会话的全部块记录。 */
290
+ blocksOf(session) {
291
+ return readBlocks(session);
292
+ }
293
+
294
+ /**
295
+ * 把刚追加的记录落盘。
296
+ *
297
+ * 宿主的持久化检查点在「每次请求」前后,回合结束后的后台追加不在检查点里;
298
+ * 不主动 flush,进程退出(headless 一次性任务)就会把块丢掉。
299
+ */
300
+ async flushSession(agent, session) {
301
+ for (const [label, service] of [['plugin', this.sessions], ['agent', captureService(agent?.ctx, 'sessions')]]) {
302
+ try {
303
+ if (service && typeof service.flush === 'function') {
304
+ await service.flush(session);
305
+ this.debug(`flush 完成(${label})`);
306
+ return;
307
+ }
308
+ } catch (error) {
309
+ this.debug(`flush 失败(${label}): ${error?.message ?? error}`);
310
+ }
311
+ }
312
+ this.debug('没有 flush 通道,块记录只留在内存里');
313
+ }
314
+
315
+ /**
316
+ * 表层上的 system 节点:恒占窗口、永不压缩,阈值分母要先扣掉。
317
+ * @param {object} session
318
+ * @param {ReadonlyArray<{seq:number}>} nodes
319
+ * @returns {Set<number>}
320
+ */
321
+ systemSeqsOf(session, nodes) {
322
+ const systemSeqs = new Set();
323
+ for (const node of nodes) {
324
+ if (session.eventAt(node.seq)?.type === 'system/message') systemSeqs.add(node.seq);
325
+ }
326
+ return systemSeqs;
327
+ }
328
+
329
+ /**
330
+ * 工具 schema 占多少 token。
331
+ *
332
+ * 工具不是表层节点,唯一的来源是宿主 tokenMeter 的 `contextBreakdown` 投影。
333
+ * 服务不在或读失败就当 0 —— 固定开销只会算少,不会算多。
334
+ * @param {object} session
335
+ * @returns {number}
336
+ */
337
+ toolsTokens(session) {
338
+ const projections = this.projections;
339
+ if (projections === undefined || typeof projections.snapshot !== 'function') return 0;
340
+ try {
341
+ const breakdown = projections.snapshot(session, ['contextBreakdown'])?.values?.contextBreakdown;
342
+ const tokens = Number(breakdown?.toolsTokens ?? 0);
343
+ return Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
344
+ } catch {
345
+ return 0;
346
+ }
347
+ }
348
+
349
+ /**
350
+ * 固定开销:system 提示词 + 工具 schema。
351
+ *
352
+ * 这两块恒占窗口、永远不会被压缩,所以「内容」与「预算」都要先扣掉它们:
353
+ * 阈值按可用预算算,进度条把它们画成最前面那一段。
354
+ *
355
+ * @param {object} session
356
+ * @param {ReadonlyArray<{seq:number, tokens:number}>} nodes
357
+ * @returns {{systemSeqs: Set<number>, system: number, tools: number, total: number}}
358
+ */
359
+ overheadOf(session, nodes) {
360
+ const systemSeqs = this.systemSeqsOf(session, nodes);
361
+ let system = 0;
362
+ for (const node of nodes) if (systemSeqs.has(node.seq)) system += node.tokens ?? 0;
363
+ const tools = this.toolsTokens(session);
364
+ return { systemSeqs, system, tools, total: system + tools };
365
+ }
366
+
367
+ /**
368
+ * 事实修正系数:把 chars/4 的估算折算成 provider 报的真实 token。
369
+ *
370
+ * 宿主没有分词器,只有两种数:provider 每次响应里的真实用量(整条请求一个数),
371
+ * 和每条消息的 chars/4 估算。中文被估算低估三成,实测整条请求的
372
+ * 「真实 / 估算」≈ 1.25,而且会随内容构成漂移。
373
+ *
374
+ * 所以每次拿到真实用量就重算一次并做平滑,再乘到所有估算值上:
375
+ * 阈值、单位、块大小、面板显示全部走同一把尺子。
376
+ *
377
+ * 注意它同时放大了 system + 工具 schema —— 那部分只有 4K 量级,
378
+ * 放大后仍小于真实值(实测 26K),差的 20K 在 1M 窗口里是 2%,不值得再引入第二个参数。
379
+ *
380
+ * @param {object} session
381
+ * @param {object} measurement tokenMeter.measure 的结果
382
+ * @param {object} overhead overheadOf 的结果
383
+ * @returns {number} ≥1 的换算系数;没有真实用量时是 1
384
+ */
385
+ calibration(session, measurement, overhead) {
386
+ let fit = this.fits.get(session);
387
+ if (fit === undefined) {
388
+ fit = { scale: 1, samples: 0 };
389
+ this.fits.set(session, fit);
390
+ }
391
+ const estimated = Number(measurement.surfaceTokens ?? 0) + Number(overhead.tools ?? 0);
392
+ const total = Number(measurement.totalTokens ?? 0);
393
+ if (measurement.baseline?.kind === 'usage' && total > 0 && estimated > 5000) {
394
+ const ratio = total / estimated;
395
+ if (Number.isFinite(ratio) && ratio > 0.5 && ratio < 4) {
396
+ const smoothed = fit.samples === 0 ? ratio : fit.scale * 0.7 + ratio * 0.3;
397
+ // 保留两位:长会话里它会收敛成一个稳定值,面板上的 token 数不会每轮抖一下。
398
+ fit.scale = Math.round(smoothed * 100) / 100;
399
+ fit.samples += 1;
400
+ }
401
+ }
402
+ return fit.scale;
403
+ }
404
+ /**
405
+ * 用 usage 差分测一段材料的真实 token。
406
+ *
407
+ * 第 n 次请求的 prompt 是「此前所有消息 + system + 工具」。所以
408
+ * `prompt(区间后第一条) − prompt(区间前最后一条)` 就是这两次之间新增的消息
409
+ * 一共占了多少真实 token —— 也就是这段材料在请求里真实的花费。
410
+ *
411
+ * 只在两端都找得到、且量级合理时返回;跨过压缩或裁剪的区间会算出负数或者
412
+ * 离谱的倍数,一律返回 null,让调用方退回「估算 × 系数」。
413
+ *
414
+ * @param {object} session
415
+ * @param {number[]} seqs 升序 seq
416
+ * @param {number} estimated 同一段的估算值,用来做量级校验
417
+ * @returns {number|null}
418
+ */
419
+ realTokensOf(session, seqs, estimated) {
420
+ if (seqs.length === 0) return null;
421
+ const first = seqs[0];
422
+ const last = seqs[seqs.length - 1];
423
+ let before = null;
424
+ let after = null;
425
+ for (const event of iterateEvents(session)) {
426
+ if (event.type !== 'assistant/message' || event.data?.usage === undefined) continue;
427
+ if (event.seq < first) before = event;
428
+ else if (event.seq > last) { after = event; break; }
429
+ }
430
+ if (before === null || after === null) return null;
431
+ const promptOf = (event) => {
432
+ const usage = event.data.usage ?? {};
433
+ return Number(usage.inputTokens ?? 0) + Number(usage.cacheReadTokens ?? 0) + Number(usage.cacheWriteTokens ?? 0);
434
+ };
435
+ // 差分把 before 那条 assistant 也算了进去(它在 first 之前),减掉它的输出。
436
+ const real = promptOf(after) - promptOf(before) - Number(before.data.usage?.outputTokens ?? 0);
437
+ if (!Number.isFinite(real) || real <= 0) return null;
438
+ const ratio = real / Math.max(1, estimated);
439
+ if (ratio < 0.4 || ratio > 5) return null;
440
+ return Math.round(real);
441
+ }
442
+
443
+ /**
444
+ * 把表层节点分成三类:system(永不动)、受保护(最近 N 步)、其余。
445
+ * @param {object} session
446
+ * @param {ReadonlyArray<{seq:number}>} nodes
447
+ * @param {Set<number>} [systemSeqs] 已经算过就传进来,省一次扫描
448
+ */
449
+ classify(session, nodes, systemSeqs = this.systemSeqsOf(session, nodes)) {
450
+ const surfaceSeqs = session.surface.nodes;
451
+ const protectedSeqs = protectedTail(surfaceSeqs, stepStartSeqs(session), this.config.retainRecentGroups);
452
+ const checkpointSeqs = new Set();
453
+ for (const seq of surfaceSeqs) {
454
+ const event = session.eventAt(seq);
455
+ const source = event?.type === 'user/message' ? event.data?.source : undefined;
456
+ if (source !== undefined && isCompactCheckpointSource(source)) checkpointSeqs.add(seq);
457
+ }
458
+ return { systemSeqs, protectedSeqs, checkpointSeqs, surfaceSeqs: new Set(surfaceSeqs) };
459
+ }
460
+
461
+ /**
462
+ * 取当前路由的上下文窗口(token)。拿不到就返回 null,调用方放弃压缩。
463
+ * @param {object} agent
464
+ * @param {AbortSignal|undefined} signal
465
+ * @returns {Promise<number|null>}
466
+ */
467
+ /**
468
+ * 当前该用哪个上下文窗口。
469
+ *
470
+ * **优先用会话最近一次请求的路由**,其次才是 agent.options ——
471
+ * 面板(state.js)和提交判定(这里)必须用同一个来源,否则会出现
472
+ * 「面板显示越线了、判定却拿不到窗口直接跳过」这种前后矛盾。
473
+ *
474
+ * @param {object} agent
475
+ * @param {AbortSignal} [signal]
476
+ * @returns {Promise<number|null>}
477
+ */
478
+ async budgetOf(agent, signal) {
479
+ const header = agent.session?.requestHeader?.()?.config;
480
+ if (typeof header?.provider === 'string' && header.provider !== ''
481
+ && typeof header?.model === 'string' && header.model !== '') {
482
+ const routed = await this.budgetOfRoute(header.provider, header.model, signal);
483
+ if (routed !== null) return routed;
484
+ }
485
+ return this.budgetOfRoute(agent.options?.provider, agent.options?.model, signal);
486
+ }
487
+
488
+ /**
489
+ * 按 provider/model 取上下文窗口。拿不到返回 null(调用方据此放弃压缩)。
490
+ * @param {string|undefined} provider
491
+ * @param {string|undefined} model
492
+ * @param {AbortSignal|undefined} signal
493
+ * @returns {Promise<number|null>}
494
+ */
495
+ async budgetOfRoute(provider, model, signal) {
496
+ if (typeof provider !== 'string' || typeof model !== 'string') return null;
497
+ try {
498
+ const info = await this.ctx.llm.resolveModelInfo(provider, model, signal);
499
+ const window = info?.context?.contextWindow;
500
+ return Number.isFinite(window) && window > 0 ? window : null;
501
+ } catch {
502
+ return null;
503
+ }
504
+ }
505
+
506
+ /**
507
+ * 压力触发时决定「压哪一块」。
508
+ * @param {object} agent
509
+ * @param {AbortSignal} [signal]
510
+ * @param {{force?: boolean}} [options] force = 手动要求,忽略触发线、按目标 0 全提交
511
+ * @returns {Promise<{kind:'skip'}|{kind:'no-block'}|{kind:'commit', blocks:object[]}>}
512
+ */
513
+ async planPressure(agent, signal, options = {}) {
514
+ const force = options.force === true;
515
+ const budget = await this.budgetOf(agent, signal);
516
+ if (budget === null) {
517
+ this.lastPressure.set(agent.session, { at: Date.now(), kind: 'no-window' });
518
+ this.debug('量不到上下文窗口,这次不压');
519
+ return { kind: 'no-window' };
520
+ }
521
+ const session = agent.session;
522
+ const measurement = this.ctx.tokenMeter.measure(session);
523
+ const nodes = measurement.nodes;
524
+ // 固定开销(system + 工具 schema)恒占窗口、永不压缩:
525
+ // 分母扣掉它得「可用预算」,分子扣掉它得「内容」,两者才可比。
526
+ const overhead = this.overheadOf(session, nodes);
527
+ // 估算值统一乘上事实修正系数,和 provider 报的真实 token 同一把尺子。
528
+ const scale = this.calibration(session, measurement, overhead);
529
+ const fixed = overhead.total * scale;
530
+ const messages = Math.max(0, Number(measurement.surfaceTokens ?? 0) - overhead.system);
531
+ const usable = Math.max(1, budget - fixed);
532
+ const content = Math.max(0, messages * scale);
533
+ const triggerTokens = Math.round(this.config.triggerRatio * usable);
534
+ const nodeSum = nodes.reduce((sum, node) => sum + (node.tokens ?? 0), 0);
535
+ this.debug(`压力判定:total=${measurement.totalTokens} 固定=${Math.round(fixed)} 内容=${Math.round(content)} 可用=${usable} surface=${measurement.surfaceTokens} 系数=${scale.toFixed(2)} baseline=${measurement.baseline} nodes=${nodes.length} 节点合计=${nodeSum} 触发线=${triggerTokens}${force ? '(手动要求)' : ''}`);
536
+ /** 记下这次判定,面板据此回答「为什么没压」。 */
537
+ const note = (info) => {
538
+ this.lastPressure.set(session, {
539
+ at: Date.now(),
540
+ total: measurement.totalTokens,
541
+ content,
542
+ fixed: Math.round(fixed),
543
+ ratio: scale,
544
+ usable,
545
+ trigger: triggerTokens,
546
+ budget,
547
+ calls: this.stats.pressureCalls ?? 0,
548
+ ...info,
549
+ });
550
+ return info;
551
+ };
552
+ if (force !== true && content < triggerTokens) {
553
+ return note({ kind: 'below-trigger' });
554
+ }
555
+
556
+ const { systemSeqs, protectedSeqs, surfaceSeqs } = this.classify(session, nodes, overhead.systemSeqs);
557
+ const run = headRun(nodes, { systemSeqs, protectedSeqs });
558
+ if (run === null) return note({ kind: 'no-range' });
559
+
560
+ const blocks = this.blocksOf(session);
561
+ const plan = planCommits({
562
+ blocks,
563
+ runSeqs: run.seqs,
564
+ surfaceSeqs,
565
+ contentTokens: content,
566
+ targetTokens: force ? 0 : this.config.targetRatio * usable,
567
+ });
568
+ this.debug(`提交计划:${plan.blocks.length} 块(${plan.blocks.map((b) => `${b.blockId}/T${b.tier}`).join(' ') || '无'}),预计内容 ${content} → ${Math.round(plan.projectedTokens)}`);
569
+ if (plan.blocks.length === 0) {
570
+ return note({ kind: 'no-block', blocks: blocks.length, run: run.seqs.length });
571
+ }
572
+ return note({
573
+ kind: 'commit',
574
+ blocks: plan.blocks,
575
+ blockIds: plan.blocks.map((block) => block.blockId),
576
+ projected: Math.round(plan.projectedTokens),
577
+ });
578
+ }
579
+
580
+ /**
581
+ * 把区间末尾对齐到 step 边界。
582
+ *
583
+ * 宿主的 `compactRegion` 拒绝「end 落在 step 中间」的区间,所以提交前把 end
584
+ * 向后推到该 step 的最后一条表层消息;找不到(比如 end 根本不是任何 step 的成员,
585
+ * 例如 checkpoint 节点)就返回 null,让调用方决定是跳过还是原样传。
586
+ *
587
+ * @param {object} session
588
+ * @param {number} endSeq
589
+ * @param {Set<number>} surface 表层 seq 集合
590
+ * @returns {number|null}
591
+ */
592
+ alignedEnd(session, endSeq, surface) {
593
+ const stepEnd = closedStepEnd(session, endSeq);
594
+ if (stepEnd === null) return null;
595
+ let best = null;
596
+ for (const seq of surface) {
597
+ if (seq < endSeq || seq > stepEnd) continue;
598
+ if (best === null || seq > best) best = seq;
599
+ }
600
+ return best;
601
+ }
602
+
603
+ /**
604
+ * 让一段原材料落在工具配对平衡的位置上:向后补齐同一 step 的尾消息,补不动就剪掉这半个 step。
605
+ *
606
+ * 宿主要的是「工具配对平衡」(工具调用和它的结果不跨切点),step 末尾天然满足。
607
+ * 不补齐的话,切点可能正好落在一个工具调用和它的结果之间,提交时会被宿主拒绝
608
+ * (`not a balanced boundary`)——块白蒸,空间压不下去。
609
+ *
610
+ * @param {object} session
611
+ * @param {number[]} seqs
612
+ * @param {Set<number>} blockers 受保护 / 已覆盖 / checkpoint:跨过去就不许补
613
+ * @returns {number[]} 对齐后的 seq 列表(可能为空)
614
+ */
615
+ alignRun(session, seqs, blockers) {
616
+ const list = seqs.slice();
617
+ const surface = session.surface.nodes.slice().sort((a, b) => a - b);
618
+ for (let guard = 0; guard < 80 && list.length > 0; guard += 1) {
619
+ const last = list[list.length - 1];
620
+ const stepEnd = closedStepEnd(session, last);
621
+ if (stepEnd === null) {
622
+ list.pop();
623
+ continue;
624
+ }
625
+ const extras = surface.filter((seq) => seq > last && seq <= stepEnd);
626
+ if (extras.some((seq) => blockers.has(seq))) {
627
+ list.pop();
628
+ continue;
629
+ }
630
+ return [...list, ...extras];
631
+ }
632
+ return [];
633
+ }
634
+
635
+ /**
636
+ * 在块自己的 covers 里找一个「工具配对平衡的末条」:优先缩,不向后扩。
637
+ *
638
+ * 宿主的提交闸门是**工具配对平衡**,不是 step 边界 —— dsh-compaction 的
639
+ * tool-pairing 文档原话:safe cuts 由 tool-call/result 的内容推出,
640
+ * "rather than step markers"。那句 `would split a step` 的报错文案是旧语义的遗留。
641
+ *
642
+ * 以前按 step 找边界,结果合并块的成员全是 checkpoint 节点、落在 step 之外
643
+ * (compactRegion 就在 step 之间跑),一个都找不到 —— T2/T3 永远提交不了。
644
+ *
645
+ * 为什么只缩不扩:扩展会把没进摘要的消息也吞进 checkpoint —— 那几条既不在块的
646
+ * covers 里(`decompress` 取不到),也不在摘要里,等于**悄悄丢内容**。
647
+ * 缩到块的内部边界顶多让同一条消息被覆盖两次,是可接受的重复,不是丢失。
648
+ *
649
+ * @param {object} session
650
+ * @param {number[]} covers 块覆盖的 seq(升序)
651
+ * @param {Set<number>} surface 表层 seq 集合
652
+ * @returns {number|null} 可安全作为 end 的 seq;找不到就返回 null(这一块先不提交)
653
+ */
654
+ balancedPrefixEnd(session, covers, surface) {
655
+ for (let index = covers.length - 1; index >= 0; index -= 1) {
656
+ const candidate = covers[index];
657
+ if (!surface.has(candidate)) continue;
658
+ try {
659
+ if (toolPairingBalancedAfter(session, candidate)) return candidate;
660
+ } catch {
661
+ // 宿主判不了(事件缺失 / 表层已变)就当不齐,继续往前缩。
662
+ }
663
+ }
664
+ return null;
665
+ }
666
+
667
+ /**
668
+ * 补丁:compactIfNeeded。塔自己决定压不压、压多少,**不再交回宿主**。
669
+ *
670
+ * 交回宿主的代价是它会在塔外压出一段没有块号的 checkpoint:既不能 `decompress`,
671
+ * 也不参与分层,塔的账本从此对不上账。宁可这次不压(请求照发,额度不够就报错),
672
+ * 也不留这种无法追溯的压缩。溢出(context-overflow)不再直接放过:当成一次
673
+ * 强制提交,用手上的块把空间腾出来,腾不动就让请求自己失败。
674
+ */
675
+ async compactIfNeeded(engine, agent, trigger, signal) {
676
+ this.stats.pressureCalls = (this.stats.pressureCalls ?? 0) + 1;
677
+ this.debug(`compactIfNeeded 被调用:trigger=${trigger}`);
678
+ // 先把「宿主压过、账本没记」的段认领成块:否则它们是账本上的洞,
679
+ // 库存与提交计划都会被这段无名历史带偏。
680
+ await this.ensureImported(agent);
681
+ // 手动提交没法在空闲时做:宿主的 compactRegion 要求事件落在一个「打开的回合」里。
682
+ // 所以按钮只排队,等下一回合开始、引擎来问的时候按目标 0 一次提交完。
683
+ const manual = this.commitRequests.has(agent.session);
684
+ const force = manual || trigger !== 'pressure';
685
+ let plan;
686
+ try {
687
+ plan = await this.planPressure(agent, signal, { force });
688
+ } catch (error) {
689
+ this.stats.failures += 1;
690
+ this.ctx.logger?.warn(`tower: pressure planning failed (${String(error)}); leaving this request uncompressed`);
691
+ return null;
692
+ } finally {
693
+ if (manual) this.commitRequests.delete(agent.session);
694
+ }
695
+ this.debug(`pressure 计划:${plan.kind}${plan.blockIds ? ` ${plan.blockIds.join(',')}` : ''}`);
696
+ if (plan.kind === 'below-trigger') return null;
697
+ // 量不到窗口 / 算不出可压区 / 没有就绪块:这次不压。原因已记进 lastPressure,面板会说。
698
+ if (plan.kind !== 'commit') return null;
699
+
700
+ // 一次请求可以连续提交多块:每块自己一对 compaction 事件,区间互不重叠。
701
+ let first = null;
702
+ const results = [];
703
+ let succeeded = 0;
704
+ let lastError = null;
705
+ const surface = new Set(agent.session.surface.nodes);
706
+ for (const block of plan.blocks) {
707
+ // 宿主的闸门是工具配对平衡。优先在块自己的 covers 里缩到平衡点(不丢内容),
708
+ // 缩不出来就跳过这一块 —— 绝不向后扩,那会把没进摘要的消息也盖掉。
709
+ const end = this.balancedPrefixEnd(agent.session, block.covers, surface);
710
+ if (end === null) {
711
+ lastError = `block ${block.blockId} 的 covers 里找不到平衡点,跳过`;
712
+ this.debug(lastError);
713
+ continue;
714
+ }
715
+ this.pending.set(agent.session, { block });
716
+ try {
717
+ const result = await engine.compactRegion(block.covers[0], end, agent, signal);
718
+ if (result !== null) {
719
+ this.stats.committed += 1;
720
+ succeeded += 1;
721
+ results.push(result);
722
+ this.recordCheckpoint(agent.session, block.blockId, result);
723
+ first ??= result;
724
+ }
725
+ } catch (error) {
726
+ this.stats.failures += 1;
727
+ lastError = error?.message ?? String(error);
728
+ this.ctx.logger?.warn(`tower: 提交 ${block.blockId} 失败(${lastError})`);
729
+ this.debug(`提交 ${block.blockId} 失败:${lastError}`);
730
+ break;
731
+ } finally {
732
+ this.pending.delete(agent.session);
733
+ }
734
+ }
735
+ // 把真实结果补回判定记录:只写「计划提交」会让人误以为已经压下去了。
736
+ const previous = this.lastPressure.get(agent.session) ?? {};
737
+ this.lastPressure.set(agent.session, {
738
+ ...previous,
739
+ outcome: { planned: plan.blocks.length, succeeded, error: lastError },
740
+ });
741
+ // 手动压缩(/compact)一次可能提交多块,把总量合起来回报给命令层,
742
+ // 否则它只会说「压了 1 段」。
743
+ if (manual === true && results.length > 1) {
744
+ return {
745
+ ...results[results.length - 1],
746
+ shadowedSeqs: results.flatMap((item) => item.shadowedSeqs ?? []),
747
+ shadowedTokenCount: results.reduce((sum, item) => sum + (item.shadowedTokenCount ?? 0), 0),
748
+ };
749
+ }
750
+ return first;
751
+ }
752
+
753
+ /**
754
+ * `/compact` 的落点:把塔里已经蒸好的块提交进上下文。
755
+ *
756
+ * 装了本插件之后,「压缩」这个动作的语义就是提交块 —— 宿主原来那套
757
+ * 「现场挑一段、现调模型摘要」已经不该再跑了(它压出来的东西没有块号,
758
+ * 账本无法解释)。所以这里不复用原实现,而是走自己的提交路径。
759
+ *
760
+ * 宿主要求手动压缩独占 agent,所以外面套一层 `runMaintenance`。
761
+ * @param {object} engine
762
+ * @param {object} agent
763
+ * @param {AbortSignal} [signal]
764
+ * @returns {Promise<object|null>} 宿主 compactRegion 的结果;没有可提交的块时为 null
765
+ */
766
+ async manualCompact(engine, agent, signal) {
767
+ const withMaintenance = typeof agent?.runMaintenance === 'function'
768
+ ? (task) => agent.runMaintenance(task)
769
+ : (task) => task(signal);
770
+ return withMaintenance(async (agentSignal) => {
771
+ const operation = agentSignal === undefined || signal === undefined
772
+ ? (agentSignal ?? signal)
773
+ : AbortSignal.any([agentSignal, signal]);
774
+ this.commitRequests.add(agent.session);
775
+ try {
776
+ return await this.compactIfNeeded(engine, agent, 'manual', operation);
777
+ } finally {
778
+ this.commitRequests.delete(agent.session);
779
+ }
780
+ });
781
+ }
782
+
783
+ /**
784
+ * 提交成功后,把「块号 ↔ checkpoint 节点」记进账本。
785
+ *
786
+ * 块身份不能靠 checkpoint 正文里的块头反推:格式一改,历史块就全部失联,
787
+ * 库存会把它们重新算成待命,塔又停摆。checkpoint 是宿主在 compactRegion 里
788
+ * 追加的,落在 startSeq…endSeq(compaction/end)之间,source 带 compactionId。
789
+ *
790
+ * @param {object} session
791
+ * @param {string} blockId
792
+ * @param {object} result compactRegion 的返回值
793
+ */
794
+ recordCheckpoint(session, blockId, result) {
795
+ const first = Number(result.startSeq ?? 0);
796
+ const last = Number(result.endSeq ?? session.seq);
797
+ for (let seq = last; seq >= first; seq -= 1) {
798
+ const event = session.eventAt(seq);
799
+ if (event?.type !== 'user/message') continue;
800
+ const source = event.data?.source;
801
+ if (source === undefined || !isCompactCheckpointSource(source)) continue;
802
+ if (result.compactionId !== undefined && source.compactionId !== result.compactionId) continue;
803
+ const data = { blockId, checkpointSeq: event.seq };
804
+ if (result.compactionId !== undefined) data.compactionId = result.compactionId;
805
+ try {
806
+ appendCommit(session, data);
807
+ } catch (error) {
808
+ // 记不上只是退化成正文回落,不能让一次提交被它带翻。
809
+ this.debug(`写提交记录失败(${error?.message ?? error}),该块只能走正文回落`);
810
+ }
811
+ return;
812
+ }
813
+ this.debug(`提交 ${blockId} 后没找到 checkpoint 节点,未写提交记录(该会话只能走正文回落)`);
814
+ }
815
+
816
+ /**
817
+ * 手动要求「立刻提交」。
818
+ *
819
+ * 合成本身可以在空闲时做(只写块记录),但把块换成 checkpoint 必须落在
820
+ * 一个打开的回合里——`compactRegion` 会拒绝 `no open turn`。所以这里只排队,
821
+ * 由下一个回合的 compactIfNeeded(那时有回合)忽略触发线、按目标 0 一次提交完。
822
+ *
823
+ * @param {object} agent
824
+ * @returns {boolean} 是否已排队
825
+ */
826
+ requestCommit(agent) {
827
+ if (agent?.session === undefined || agent.session === null) return false;
828
+ this.commitRequests.add(agent.session);
829
+ this.debug(`已排队手动提交:session=${agent.session.id}`);
830
+ return true;
831
+ }
832
+
833
+ /**
834
+ * 补丁:summarize。有现成块就零成本返回,否则交回宿主现场蒸馏。
835
+ * @returns {Promise<{summary: object[]}>}
836
+ */
837
+ async serveSummary(engine, original, input, agent, signal) {
838
+ const pending = this.pending.get(agent.session);
839
+ if (pending !== undefined) {
840
+ this.pending.delete(agent.session);
841
+ this.stats.served += 1;
842
+ const summary = summaryBlocks(pending.block, pending.block.childBlockIds ?? []);
843
+ this.debug(`serveSummary 返回 ${summary.length} 个内容块: ${JSON.stringify(summary).slice(0, 160)}`);
844
+ // provider/model 是 compaction/summary 事件的必填字段:引擎无条件写入这两个键,
845
+ // 传 undefined 会让 append 以「carries non-JSON-serializable data」拒绝整条事件。
846
+ // 值必须取**块自己的**来源:摘要可能来自观察者路由(observerProvider/observerModel)
847
+ // 或自带提示词那一发,写主模型的名字等于把账记到别人头上。
848
+ return {
849
+ summary,
850
+ provider: pending.block.origin?.provider ?? 'unknown',
851
+ model: pending.block.origin?.model ?? 'unknown',
852
+ };
853
+ }
854
+ this.stats.passthrough += 1;
855
+ return original.call(engine, input, agent, signal);
856
+ }
857
+
858
+ /**
859
+ * 会话是否还在 store 里活着。
860
+ *
861
+ * 后台蒸馏要花几秒,期间宿主可能已经关闭会话;那样写进内存的记录永远落不了盘,
862
+ * 下一步就会重复蒸馏同一段。宁可不写,也不留悬空的块。
863
+ * @param {object} session
864
+ * @returns {boolean}
865
+ */
866
+ isLive(session) {
867
+ const service = this.sessions;
868
+ if (service === undefined || typeof service.get !== 'function') return true;
869
+ try {
870
+ return service.get(session.id) === session;
871
+ } catch {
872
+ return false;
873
+ }
874
+ }
875
+
876
+ /**
877
+ * 观察者(回合尾):按分层库存补蒸一批块,并等它们写完。
878
+ *
879
+ * 之所以在 turn-stopping 上等(而不是 fire-and-forget):宿主的持久化检查点
880
+ * 只在请求前后,回合结束立刻关会话;不等就必然丢块。代价是这个回合晚几秒收尾。
881
+ *
882
+ * 请求前的那一次(pre-step)走同一个 `pump`,但**不等**:那时回合还开着,
883
+ * 块写进内存后由下一次请求的检查点落盘。
884
+ *
885
+ * @param {object} agent
886
+ * @param {number} turn
887
+ * @param {AbortSignal|undefined} outerSignal
888
+ */
889
+ async observe(agent, turn, outerSignal) {
890
+ const session = agent.session;
891
+ this.debug(`turn-stopping 收到:session=${session.id} turn=${turn}`);
892
+ if (!this.isLive(session)) {
893
+ this.debug('会话已不在 store 里,跳过观察');
894
+ return;
895
+ }
896
+ await this.pump(agent, outerSignal, turn, { wait: true });
897
+ }
898
+
899
+ /**
900
+ * 排一批观察任务:分层算缺口 → 从最底层开始发名额 → 并发蒸馏。
901
+ *
902
+ * @param {object} agent
903
+ * @param {AbortSignal|undefined} outerSignal
904
+ * @param {number} turn
905
+ * @param {{wait?: boolean}} options wait=true 时等这批全部结束(回合尾用)
906
+ */
907
+ async pump(agent, outerSignal, turn, options = {}) {
908
+ const session = agent.session;
909
+ if (!this.isLive(session)) return;
910
+ if (this.distilling.has(session)) {
911
+ this.debug('已有一批观察在跑,跳过');
912
+ return;
913
+ }
914
+ if (options.wait !== true) {
915
+ // 每个 step 都跑一遍全量测量太重:请求前的判定做个 5 秒节流。
916
+ const last = this.lastPump.get(session) ?? 0;
917
+ if (Date.now() - last < 5000) return;
918
+ this.lastPump.set(session, Date.now());
919
+ }
920
+ this.distilling.add(session);
921
+ const timeout = this.config.observerTimeoutMs;
922
+ const batch = (async () => {
923
+ try {
924
+ await this.scheduleObservations(agent, outerSignal, turn);
925
+ } catch (error) {
926
+ this.stats.failures += 1;
927
+ this.debug(`观察排程失败:${error?.message ?? error}`);
928
+ }
929
+ })();
930
+ if (options.wait !== true) {
931
+ // 请求前:发起即走,别卡住这次请求。批次结束自己收拾状态。
932
+ batch.finally(() => this.distilling.delete(session));
933
+ return;
934
+ }
935
+ const timer = setTimeout(() => this.debug(`观察批次超过 ${timeout}ms 仍未结束,先放手`), timeout);
936
+ try {
937
+ await batch;
938
+ } finally {
939
+ clearTimeout(timer);
940
+ this.distilling.delete(session);
941
+ }
942
+ }
943
+
944
+ /**
945
+ * 算这批要蒸几块、每层几块,然后并发发出去。
946
+ *
947
+ * 分三步:
948
+ * 1. 量出「每层未提交块还能回收多少 token」(分层库存);
949
+ * 2. 量出「每层现在有几个单位的材料」;
950
+ * 3. 从最底层开始按缺口发名额,发满并发上限就停。
951
+ *
952
+ * @param {object} agent
953
+ * @param {AbortSignal|undefined} signal
954
+ * @param {number} turn
955
+ */
956
+ async scheduleObservations(agent, signal, turn) {
957
+ const session = agent.session;
958
+ const engine = this.engineFor(agent);
959
+ if (engine === undefined || engine === null) {
960
+ this.debug('引擎实例还没露面(首次请求后才有),跳过这次观察');
961
+ this.lastObservation.set(session, { at: Date.now(), kind: 'no-engine' });
962
+ return;
963
+ }
964
+ const budget = await this.budgetOf(agent, signal);
965
+ if (budget === null) {
966
+ this.debug('拿不到上下文窗口,跳过观察');
967
+ this.lastObservation.set(session, { at: Date.now(), kind: 'no-window' });
968
+ return;
969
+ }
970
+
971
+ const measurement = this.ctx.tokenMeter.measure(session);
972
+ const nodes = measurement.nodes;
973
+ // 单位与库存都按「可用预算」算:system 与工具 schema 不参与压缩,也就不该进分母。
974
+ const overhead = this.overheadOf(session, nodes);
975
+ const scale = this.calibration(session, measurement, overhead);
976
+ const blocks = this.blocksOf(session);
977
+ const usable = Math.max(1, budget - overhead.total * scale);
978
+ const { systemSeqs, protectedSeqs, checkpointSeqs } = this.classify(session, nodes, overhead.systemSeqs);
979
+ const coveredSeqs = new Set();
980
+ for (const block of blocks) for (const seq of block.covers) coveredSeqs.add(seq);
981
+ const tierOf = this.checkpointTiers(session, checkpointSeqs, blocks);
982
+ // 在飞任务已经占用的材料,不能再被另一个任务挑中
983
+ const reserved = this.reservedOf(session);
984
+ for (const seq of reserved) coveredSeqs.add(seq);
985
+
986
+ const unit = Math.min(this.config.distillMinRunRatio * usable, this.config.observerMaxTokens);
987
+ // unit 是真实口径,而材料累加的是节点估算值 → 换算成估算口径再比。
988
+ const unitH = Math.max(1, Math.round(unit / scale));
989
+
990
+ // 「内容」与压力判定同口径:表层估算 × 事实修正系数。
991
+ const messages = Math.max(0, Number(measurement.surfaceTokens ?? 0) - overhead.system);
992
+ const content = Math.max(0, messages * scale);
993
+ const emergency = content >= this.config.triggerRatio * usable;
994
+
995
+ // 回收率与备货目标:见 docs/design/replenishment.md
996
+ const r0 = recoveryRatio(blocks, 1, RECLAIM_RATE);
997
+ const target = planTarget({
998
+ usable,
999
+ content,
1000
+ triggerRatio: this.config.triggerRatio,
1001
+ targetRatio: this.config.targetRatio,
1002
+ unitRatio: this.config.distillMinRunRatio,
1003
+ recovery: r0,
1004
+ });
1005
+ const inventory = this.inventoryOf(session, blocks);
1006
+ const materials = this.availableUnits({
1007
+ nodes, systemSeqs, protectedSeqs, coveredSeqs, checkpointSeqs, tierOf, unit: unitH, emergency,
1008
+ });
1009
+ const plan = planObservation({
1010
+ targetTokens: target,
1011
+ inventory,
1012
+ materials,
1013
+ recovery: (level) => (level === 0 ? r0 : recoveryRatio(blocks, level + 1, 0.6)),
1014
+ concurrency: this.config.observerConcurrency,
1015
+ });
1016
+ const need = target - [...inventory.values()].reduce((sum, value) => sum + value, 0);
1017
+ /** 记下这次观察判定,面板据此回答「为什么没蒸」。 */
1018
+ const note = (info) => {
1019
+ this.lastObservation.set(session, {
1020
+ at: Date.now(),
1021
+ unit: Math.round(unit),
1022
+ target,
1023
+ need: Math.max(0, Math.round(need)),
1024
+ protected: protectedSeqs.size,
1025
+ available: [...materials].map(([level, material]) => [level + 1, material.units]),
1026
+ inventory: [...inventory].map(([tier, tokens]) => [tier, Math.round(tokens)]),
1027
+ ...info,
1028
+ });
1029
+ };
1030
+ if (plan.length === 0) {
1031
+ // 没有可蒸材料 或 库存已够:两种都记下来,别让人以为观察者没跑。
1032
+ const stocked = need <= 0;
1033
+ note({ kind: materials.size === 0 ? 'no-material' : stocked ? 'stocked' : 'no-quota' });
1034
+ this.debug(`不补蒸:目标 ${target} 库存 ${[...inventory].map(([t, v]) => `T${t}:${Math.round(v)}`).join(',') || '空'} 材料 ${[...materials].map(([l, m]) => `L${l}:${m.units}`).join(',') || '空'}`);
1035
+ return;
1036
+ }
1037
+ note({ kind: 'launched', plan });
1038
+ this.debug(`本批观察:${plan.map((job) => `T${job.level + 1}×${job.count}`).join(' ')}(目标 ${target} tok)`);
1039
+
1040
+ const jobs = [];
1041
+ for (const job of plan) {
1042
+ for (let index = 0; index < job.count; index += 1) {
1043
+ jobs.push(this.distillOne(agent, signal, turn, job.level + 1, { emergency }));
1044
+ }
1045
+ }
1046
+ const results = await Promise.allSettled(jobs);
1047
+ for (const result of results) {
1048
+ if (result.status === 'rejected') {
1049
+ this.stats.failures += 1;
1050
+ this.debug(`蒸馏失败:${result.reason?.message ?? result.reason}`);
1051
+ }
1052
+ }
1053
+ }
1054
+
1055
+ /** 这个会话上已被在飞任务占用的 seq 集合(懒建)。 */
1056
+ reservedOf(session) {
1057
+ let set = this.reserved.get(session);
1058
+ if (set === undefined) {
1059
+ set = new Set();
1060
+ this.reserved.set(session, set);
1061
+ }
1062
+ return set;
1063
+ }
1064
+
1065
+ /**
1066
+ * 分层库存:每层「已蒸馏、还没进上下文、且**材料还在**」的块能回收多少 token。
1067
+ *
1068
+ * 未提交 = 没被合并进上层(consumed)、没在表层生成 checkpoint(committed),
1069
+ * 且 covers 全部仍在表层(usable)——最后这条与提交计划共用。
1070
+ * 塔外的压缩(宿主的 /compact、或老会话里已有的 checkpoint)会把一段历史换成
1071
+ * 没有块号的 checkpoint,覆盖该段的块从此永远提交不了;若还把它们算成库存,
1072
+ * 观察者会以为库存满了而不再补料。
1073
+ *
1074
+ * 保护区不影响库存:蒸馏不受保护区限制,保护只在提交时起作用,块提前备着等材料滑出去。
1075
+ *
1076
+ * @param {object} session
1077
+ * @param {Array<object>} blocks
1078
+ * @returns {Map<number, number>}
1079
+ */
1080
+ inventoryOf(session, blocks) {
1081
+ const surface = new Set(session.surface.nodes);
1082
+ const index = checkpointIndex(session);
1083
+ const consumed = new Set();
1084
+ for (const block of blocks) for (const child of block.childBlockIds ?? []) consumed.add(child);
1085
+ const inventory = new Map();
1086
+ for (const block of blocks) {
1087
+ if (consumed.has(block.blockId)) continue;
1088
+ const seq = index.get(block.blockId);
1089
+ if (seq !== undefined && surface.has(seq)) continue;
1090
+ if (!usable(block, { surfaceSeqs: surface })) continue;
1091
+ const tier = Number(block.tier ?? 1);
1092
+ const reclaim = Number(block.coveredTokens ?? 0) - Number(block.summaryTokens ?? 0);
1093
+ inventory.set(tier, (inventory.get(tier) ?? 0) + reclaim);
1094
+ }
1095
+ return inventory;
1096
+ }
1097
+
1098
+ /**
1099
+ * 每层现在有几个「单位」的材料可蒸。
1100
+ *
1101
+ * T1 看最早那段连续可蒸原文;T2+ 看同层相邻 checkpoint 攒了多少。
1102
+ * 这是**近似值**:只用来决定这一批发几个名额,不需要精确到个位。
1103
+ *
1104
+ * @returns {Map<number, number>}
1105
+ */
1106
+ availableUnits({ nodes, systemSeqs, protectedSeqs, coveredSeqs, checkpointSeqs, tierOf, unit, emergency }) {
1107
+ const tokensOf = new Map(nodes.map((node) => [node.seq, node.tokens ?? 0]));
1108
+ const materials = new Map();
1109
+
1110
+ // level 0:可蒸原文。**排除保护区** —— 与提交口径保持一致,
1111
+ // 否则蒸出来的块 covers 落在保护区内,会被算进库存却永远提不出来。
1112
+ const rawRun = selectRawRun(nodes, {
1113
+ systemSeqs,
1114
+ protectedSeqs,
1115
+ coveredSeqs,
1116
+ checkpointSeqs,
1117
+ minTokens: emergency ? 1 : unit,
1118
+ maxTokens: Number.MAX_SAFE_INTEGER,
1119
+ });
1120
+ if (rawRun.length > 0) {
1121
+ let raw = 0;
1122
+ for (const seq of rawRun) raw += tokensOf.get(seq) ?? 0;
1123
+ const units = Math.floor(raw / unit) + (emergency && raw % unit > 0 ? 1 : 0);
1124
+ if (units > 0) materials.set(0, { units, unitTokens: unit });
1125
+ }
1126
+
1127
+ // level k:表层按「同层相邻 checkpoint」分组,每组能凑几个单位
1128
+ const groups = new Map();
1129
+ let previous = null;
1130
+ for (const seq of nodes.map((node) => node.seq)) {
1131
+ const tier = checkpointSeqs.has(seq) ? tierOf.get(seq) : undefined;
1132
+ if (tier === undefined) {
1133
+ previous = null;
1134
+ continue;
1135
+ }
1136
+ if (previous === null || previous.tier !== tier) {
1137
+ previous = { tier, tokens: 0, count: 0 };
1138
+ if (!groups.has(tier)) groups.set(tier, []);
1139
+ groups.get(tier).push(previous);
1140
+ }
1141
+ previous.tokens += tokensOf.get(seq) ?? 0;
1142
+ previous.count += 1;
1143
+ }
1144
+ for (const [tier, list] of groups) {
1145
+ let units = 0;
1146
+ for (const group of list) {
1147
+ if (group.count < this.config.mergeMinBlocks) continue;
1148
+ units += Math.floor(group.tokens / unit);
1149
+ if (emergency && group.tokens % unit > 0) units += 1;
1150
+ }
1151
+ if (units > 0) {
1152
+ const existing = materials.get(tier)?.units ?? 0;
1153
+ materials.set(tier, { units: existing + units, unitTokens: unit });
1154
+ }
1155
+ }
1156
+ return materials;
1157
+ }
1158
+
1159
+ /**
1160
+ * 蒸一块:按目标层挑段 → 借宿主的 summarize 生成摘要 → 写块记录。
1161
+ *
1162
+ * @param {object} agent
1163
+ * @param {AbortSignal|undefined} outerSignal
1164
+ * @param {number} turn
1165
+ * @param {number} wantTier 目标层:1 = 蒸原文,≥2 = 合并同层 checkpoint
1166
+ */
1167
+ async distillOne(agent, outerSignal, turn, wantTier, options = {}) {
1168
+ const emergency = options.emergency === true;
1169
+ const session = agent.session;
1170
+ const engine = this.engineFor(agent);
1171
+ if (engine === undefined || engine === null) return;
1172
+ const controller = new AbortController();
1173
+ const timer = setTimeout(() => controller.abort(new Error('tower: distillation timed out')), this.config.observerTimeoutMs);
1174
+ if (outerSignal !== undefined) {
1175
+ if (outerSignal.aborted) controller.abort(outerSignal.reason);
1176
+ else outerSignal.addEventListener('abort', () => controller.abort(outerSignal.reason), { once: true });
1177
+ }
1178
+ const reserved = this.reservedOf(session);
1179
+ let claimed = [];
1180
+ try {
1181
+ const budget = await this.budgetOf(agent, controller.signal);
1182
+ if (budget === null) return;
1183
+ const measurement = this.ctx.tokenMeter.measure(session);
1184
+ const nodes = measurement.nodes;
1185
+ // 与提交端同一分母:可用预算 = 窗口 − 固定开销。
1186
+ const overhead = this.overheadOf(session, nodes);
1187
+ const scale = this.calibration(session, measurement, overhead);
1188
+ const blocks = this.blocksOf(session);
1189
+ const usable = Math.max(1, budget - overhead.total * scale);
1190
+ const { systemSeqs, protectedSeqs, checkpointSeqs } = this.classify(session, nodes, overhead.systemSeqs);
1191
+ const coveredSeqs = new Set();
1192
+ for (const block of blocks) for (const seq of block.covers) coveredSeqs.add(seq);
1193
+ for (const seq of reserved) coveredSeqs.add(seq);
1194
+ const tierOf = this.checkpointTiers(session, checkpointSeqs, blocks);
1195
+ const unit = Math.min(this.config.distillMinRunRatio * usable, this.config.observerMaxTokens);
1196
+ // 选段累加的是节点估算值,门槛是真实口径 → 一起换算到估算口径再比。
1197
+ const unitH = Math.max(1, Math.round(unit / scale));
1198
+ const maxH = Math.max(unitH, Math.round(this.config.observerMaxTokens / scale));
1199
+
1200
+ let seqs = [];
1201
+ if (wantTier <= 1) {
1202
+ // 材料同样避开保护区:块蒸出来就要能提交,蒸在保护区里是白蒸。
1203
+ seqs = selectRawRun(nodes, {
1204
+ systemSeqs, protectedSeqs, coveredSeqs, checkpointSeqs,
1205
+ minTokens: emergency ? 1 : unitH,
1206
+ maxTokens: maxH,
1207
+ stopAtTokens: unitH,
1208
+ flushTail: emergency,
1209
+ });
1210
+ } else {
1211
+ const mergeRun = selectMergeRun(nodes, {
1212
+ protectedSeqs, coveredSeqs, checkpointSeqs, tierOf,
1213
+ // 只从「上一层」取料:想要 T2 就只从 T1 里选。先选再比对的话,
1214
+ // 扫描会先撞上更老的深层 run,对不上目标层就整轮空手,浅层永远轮不到。
1215
+ memberTier: wantTier - 1,
1216
+ minCount: this.config.mergeMinBlocks,
1217
+ minTokens: emergency ? 1 : unitH,
1218
+ maxTokens: maxH,
1219
+ flushTail: emergency,
1220
+ });
1221
+ // 再验一次目标层:memberTier 已经在选料时过滤过,这里防的是 tierOf 缺项。
1222
+ const tier = mergeRun.length === 0 ? undefined : (tierOf.get(mergeRun[0]) ?? 0) + 1;
1223
+ if (tier === wantTier) seqs = mergeRun;
1224
+ }
1225
+ if (seqs.length === 0) {
1226
+ this.debug(`T${wantTier} 这一份没挑到材料(可能刚被别的任务占了)`);
1227
+ return;
1228
+ }
1229
+ // 原材料块的尾巴要落在工具配对平衡的位置上(补齐到 step 末尾正好满足:
1230
+ // step 结束意味着工具调用和结果都齐了)。合并块的材料是 checkpoint 节点,
1231
+ // 它替换的区间两端本来就是平衡的,不需要这一步。
1232
+ if (wantTier <= 1) {
1233
+ const blockers = new Set([...coveredSeqs, ...checkpointSeqs, ...reserved]);
1234
+ const aligned = this.alignRun(session, seqs, blockers);
1235
+ if (aligned.length === 0) {
1236
+ this.debug('对齐 step 边界后没有完整的一段,放弃这一份');
1237
+ return;
1238
+ }
1239
+ seqs = aligned;
1240
+ }
1241
+ claimed = seqs;
1242
+ for (const seq of claimed) reserved.add(seq);
1243
+ await this.materialize(agent, controller.signal, turn, seqs, nodes, blocks, engine, scale);
1244
+ } finally {
1245
+ for (const seq of claimed) reserved.delete(seq);
1246
+ clearTimeout(timer);
1247
+ }
1248
+ }
1249
+
1250
+ /**
1251
+ * 手动合并:把指定的几个块的 checkpoint 节点合成一个新块。
1252
+ * 只接受当前表层上、层级相同的块,避免造出跨层的怪块。
1253
+ *
1254
+ * @param {object} agent
1255
+ * @param {AbortSignal} signal
1256
+ * @param {string[]} blockIds
1257
+ * @param {number} [turn]
1258
+ * @returns {Promise<object|null>} 新块记录,失败返回 null
1259
+ */
1260
+ async mergeBlocks(agent, signal, blockIds, turn = 0) {
1261
+ const session = agent.session;
1262
+ const engine = this.engineFor(agent);
1263
+ if (engine === undefined || engine === null) return null;
1264
+ const blocks = this.blocksOf(session);
1265
+ const byId = new Map(blocks.map((block) => [block.blockId, block]));
1266
+ const wanted = [];
1267
+ for (const id of blockIds) {
1268
+ const block = byId.get(id);
1269
+ if (block === undefined) return null;
1270
+ wanted.push(block);
1271
+ }
1272
+ if (wanted.length < 2) return null;
1273
+ const tier = Number(wanted[0].tier ?? 1);
1274
+ if (wanted.some((block) => Number(block.tier ?? 1) !== tier)) return null;
1275
+
1276
+ const surface = new Set(session.surface.nodes);
1277
+ const index = checkpointIndex(session);
1278
+ const seqs = [];
1279
+ for (const block of wanted) {
1280
+ const seq = index.get(block.blockId);
1281
+ if (seq === undefined || !surface.has(seq)) return null;
1282
+ seqs.push(seq);
1283
+ }
1284
+ seqs.sort((a, b) => a - b);
1285
+
1286
+ const measurement = this.ctx.tokenMeter.measure(session);
1287
+ const nodes = measurement.nodes;
1288
+ const scale = this.calibration(session, measurement, this.overheadOf(session, nodes));
1289
+ return this.materialize(agent, signal, turn, seqs, nodes, blocks, engine, scale);
1290
+ }
1291
+
1292
+ /**
1293
+ * 把选中的一段做成块:借宿主的 summarize 生成摘要 → 写块记录。
1294
+ * 自动蒸馏与手动合并共用这一段,唯一的差别是 seqs 从哪来。
1295
+ *
1296
+ * @param {object} agent
1297
+ * @param {AbortSignal} signal
1298
+ * @param {number} turn
1299
+ * @param {number[]} seqs
1300
+ * @param {Array<{seq:number, tokens:number, heuristicTokens?:number}>} nodes
1301
+ * @param {Array<object>} blocks
1302
+ * @param {object} engine
1303
+ * @param {number} [scale] 事实修正系数(见 calibration)
1304
+ * @returns {Promise<object|null>} 新块记录
1305
+ */
1306
+ async materialize(agent, signal, turn, seqs, nodes, blocks, engine, scale = 1) {
1307
+ const session = agent.session;
1308
+ const input = this.buildSummaryInput(session, seqs);
1309
+ if (input.messages.length === 0) {
1310
+ this.debug('选中段投影不出消息,跳过');
1311
+ return null;
1312
+ }
1313
+ this.debug(`开始蒸馏 ${seqs.length} 条(seq ${seqs[0]}…${seqs[seqs.length - 1]})`);
1314
+
1315
+ const route = this.observerRoute(agent);
1316
+ const produced = await this.summarizeWithRoute(engine, input, agent, signal);
1317
+ const text = (produced?.summary ?? [])
1318
+ .filter((part) => part.type === 'text')
1319
+ .map((part) => part.text)
1320
+ .join('\n')
1321
+ .trim();
1322
+ if (text.length === 0) {
1323
+ this.debug('摘要为空,跳过');
1324
+ return null;
1325
+ }
1326
+
1327
+ // 真值优先:同一段材料在两次请求之间的 prompt 差,就是它真实占的 token
1328
+ // (实测比 chars/4 估算大 1.2~1.4 倍)。测不到才退回「估算 × 标定系数」。
1329
+ const estimated = sumHeuristic(nodes, seqs);
1330
+ const measured = this.realTokensOf(session, seqs, estimated);
1331
+ const coveredTokens = measured ?? Math.round(estimated * scale);
1332
+ const summaryTokens = Math.round(this.estimateText(text) * scale);
1333
+ const children = this.childBlocksOf(session, seqs, blocks);
1334
+ const tier = children.length === 0 ? 1 : Math.max(...children.map((child) => child.tier ?? 1)) + 1;
1335
+ const record = {
1336
+ blockId: this.allocateBlockId(session, blocks),
1337
+ tier,
1338
+ covers: seqs,
1339
+ childBlockIds: children.map((child) => child.blockId),
1340
+ summary: [{ type: 'text', text }],
1341
+ coveredTokens,
1342
+ summaryTokens,
1343
+ kind: children.length === 0 ? 'run' : 'distill',
1344
+ origin: { provider: route.provider || 'unknown', model: route.model || 'unknown', turn },
1345
+ createdAt: new Date().toISOString(),
1346
+ };
1347
+ if (!this.isLive(session)) {
1348
+ this.debug('会话在蒸馏期间已经关闭,放弃写入(下一回合会重新蒸馏)');
1349
+ this.pendingIds.get(session)?.delete(record.blockId);
1350
+ return null;
1351
+ }
1352
+ appendBlock(session, record);
1353
+ // 块已进账本,占位的号可以释放了(下一次 nextBlockId 会自然跳过它)。
1354
+ this.pendingIds.get(session)?.delete(record.blockId);
1355
+ await this.flushSession(agent, session);
1356
+ this.stats.distilled += 1;
1357
+ this.debug(`写入块 ${record.blockId}(T${record.tier}, ${seqs.length} 条, ${coveredTokens} → ${summaryTokens} tok, ${record.kind})`);
1358
+ return record;
1359
+ }
1360
+
1361
+ /**
1362
+ * 观察者这次蒸馏该用哪个模型。留空就跟随当前会话的路由。
1363
+ * @param {object} agent
1364
+ * @returns {{provider: string, model: string}}
1365
+ */
1366
+ observerRoute(agent) {
1367
+ return {
1368
+ provider: this.config.observerProvider || agent.options?.provider || '',
1369
+ model: this.config.observerModel || agent.options?.model || '',
1370
+ };
1371
+ }
1372
+
1373
+ /**
1374
+ * 让宿主的 summarize 生成摘要;两种情况会绕开它:
1375
+ *
1376
+ * 1. 观察者配了自己的模型 —— 宿主挑模型的顺序是「引擎的 summarization* 配置 →
1377
+ * 会话最近一次请求的路由 → agent.options」,前两级都会盖掉 agent.options,
1378
+ * 改 agent 没用,只能临时换掉引擎那份 config。引擎实例属于这个 agent 的
1379
+ * context,同一会话的压缩是串行的,这个替换窗口是安全的,拿到结果立刻还原。
1380
+ * 2. 观察者配了自己的提示词(中文预设 / 自定义)—— 宿主的指令是写死的常量,
1381
+ * 没有参数能换,所以这一次请求由 requestSummary 自己发(形状照抄宿主)。
1382
+ *
1383
+ * @param {object} engine
1384
+ * @param {object} input
1385
+ * @param {object} agent
1386
+ * @param {AbortSignal} signal
1387
+ */
1388
+ async summarizeWithRoute(engine, input, agent, signal) {
1389
+ const route = this.observerRoute(agent);
1390
+ const instruction = summaryInstruction(this.config);
1391
+ if (instruction !== null) {
1392
+ if (route.provider === '' || route.model === '') {
1393
+ // 自定义提示词要自己发请求,必须知道发给谁;路由不全就退回宿主,别把蒸馏搞停。
1394
+ this.debug('自定义摘要提示词已启用,但观察者路由不完整,这一次仍走宿主 summarize');
1395
+ } else {
1396
+ return requestSummary({
1397
+ ctx: this.ctx,
1398
+ session: agent.session,
1399
+ provider: route.provider,
1400
+ model: route.model,
1401
+ input,
1402
+ instruction,
1403
+ maxTokens: this.config.summaryMaxTokens,
1404
+ signal,
1405
+ });
1406
+ }
1407
+ }
1408
+
1409
+ const follows = route.provider === '' || route.model === '';
1410
+ const same = agent.options?.provider === route.provider && agent.options?.model === route.model;
1411
+ if (follows || same) return engine.summarize(input, agent, signal);
1412
+
1413
+ const saved = engine.config;
1414
+ engine.config = { ...saved, summarizationProvider: route.provider, summarizationModel: route.model };
1415
+ try {
1416
+ return await engine.summarize(input, agent, signal);
1417
+ } finally {
1418
+ engine.config = saved;
1419
+ }
1420
+ }
1421
+
1422
+ /**
1423
+ * 一份文本按宿主口径折算成 token 数(与 shadow-price 同一把尺)。
1424
+ * 宿主不可用时退回字符估算,绝不抛。
1425
+ * @param {string} text
1426
+ */
1427
+ estimateText(text) {
1428
+ try {
1429
+ return this.ctx.tokenMeter.estimateMessage({ role: 'user', content: [{ type: 'text', text }] });
1430
+ } catch {
1431
+ return Math.ceil(text.length / 4);
1432
+ }
1433
+ }
1434
+
1435
+ /**
1436
+ * 表层上每个 checkpoint 节点的层级:查 checkpointOwner 反查块记录。
1437
+ * @param {object} session
1438
+ * @param {Set<number>} checkpointSeqs
1439
+ * @param {Array<object>} blocks
1440
+ * @returns {Map<number, number>}
1441
+ */
1442
+ checkpointTiers(session, checkpointSeqs, blocks) {
1443
+ const byId = new Map(blocks.map((block) => [block.blockId, block]));
1444
+ const owner = checkpointOwner(session);
1445
+ const tiers = new Map();
1446
+ for (const seq of checkpointSeqs) {
1447
+ const block = byId.get(owner.get(seq));
1448
+ if (block !== undefined) tiers.set(seq, Number(block.tier ?? 1));
1449
+ }
1450
+ return tiers;
1451
+ }
1452
+
1453
+ /**
1454
+ * 找出一段历史里「已经是某个块的摘要节点」的成员。
1455
+ *
1456
+ * 身份查 checkpointOwner(提交记录优先,老会话回落正文块头),不再解析正文。
1457
+ * 有子块就说明这一段是「摘要的摘要」——层级 +1,成为 T2。
1458
+ *
1459
+ * @param {object} session
1460
+ * @param {number[]} seqs
1461
+ * @param {Array<object>} blocks
1462
+ * @returns {Array<object>}
1463
+ */
1464
+ childBlocksOf(session, seqs, blocks) {
1465
+ const byId = new Map(blocks.map((block) => [block.blockId, block]));
1466
+ const owner = checkpointOwner(session);
1467
+ const found = [];
1468
+ const seen = new Set();
1469
+ for (const seq of seqs) {
1470
+ const blockId = owner.get(seq);
1471
+ if (blockId === undefined || seen.has(blockId)) continue;
1472
+ const block = byId.get(blockId);
1473
+ if (block === undefined) continue;
1474
+ seen.add(blockId);
1475
+ found.push(block);
1476
+ }
1477
+ return found;
1478
+ }
1479
+
1480
+ /**
1481
+ * 复刻引擎的 buildSummarizationInput:system 头 + 工具 schema + 区间内逐条消息。
1482
+ * 前缀与真实请求一致,供应商侧 KV 缓存才能命中。
1483
+ * @param {object} session
1484
+ * @param {number[]} seqs
1485
+ */
1486
+ buildSummaryInput(session, seqs) {
1487
+ const header = session.requestHeader();
1488
+ const headSeq = session.surface.nodes[0];
1489
+ const head = headSeq === undefined ? undefined : session.eventAt(headSeq);
1490
+ const system = head?.type === 'system/message' ? session.deriveEventMessage(head) : null;
1491
+ const regionMessages = [];
1492
+ for (const seq of seqs) {
1493
+ const event = session.eventAt(seq);
1494
+ if (event === undefined) continue;
1495
+ const message = session.deriveEventMessage(event);
1496
+ if (message !== null && message !== undefined) regionMessages.push(message);
1497
+ }
1498
+ return {
1499
+ ...(header?.tools === undefined ? {} : { tools: header.tools }),
1500
+ messages: system === null ? regionMessages : [system, ...regionMessages],
1501
+ };
1502
+ }
1503
+
1504
+ /** 渲染一段 seq 的原文,用于解压输出。 */
1505
+ renderSeqs(session, seqs, { maxCharsPerMessage = 400 } = {}) {
1506
+ const lines = [];
1507
+ for (const seq of seqs) {
1508
+ const event = session.eventAt(seq);
1509
+ if (event === undefined) continue;
1510
+ const message = session.deriveEventMessage(event);
1511
+ if (message === null || message === undefined) continue;
1512
+ lines.push(`${refOf(seq)} [${roleLabel(event, message)}] ${describeMessage(message, maxCharsPerMessage)}`);
1513
+ }
1514
+ return lines;
1515
+ }
1516
+ }
1517
+
1518
+ /**
1519
+ * 按节点口径求和。
1520
+ *
1521
+ * 优先取 `tokens` 而不是 `heuristicTokens`:两者的差别只在图片/文件节点上,
1522
+ * `tokens` 用的是路由的真实计价(视觉 token + 投影文本),`heuristicTokens`
1523
+ * 只给一个结构化的固定估计 —— 取后者会把图片的真实成本吞掉。
1524
+ */
1525
+ function sumHeuristic(nodes, seqs) {
1526
+ const wanted = new Set(seqs);
1527
+ let total = 0;
1528
+ for (const node of nodes) if (wanted.has(node.seq)) total += node.tokens ?? node.heuristicTokens;
1529
+ return total;
1530
+ }