dsh-compaction-tower 0.0.0-stage → 0.1.39

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