dsh-auto-compaction-engine 0.0.0-stage → 0.2.1

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/README.md CHANGED
@@ -1,3 +1,9 @@
1
- # Temporary Holding Version
1
+ # dsh-auto-compaction-engine
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 引擎包(提供 `ctx.compaction`)。完整说明见仓库根目录 README:
4
+
5
+ https://github.com/1010231693/dsh-auto-compaction#readme
6
+
7
+ 在 Agent 预设里替换 `compaction-basic` 那一行即可使用;自带 `scripts/preset-scope.mjs` 做可逆切换。
8
+
9
+ > 0.2.0 起引擎改名为 `dsh-auto-compaction-engine`;`dsh-auto-compaction` 现在是设置页/状态提示的 bundle 包。
package/lib/index.js ADDED
@@ -0,0 +1,651 @@
1
+ /**
2
+ * dsh-handoff-compaction
3
+ *
4
+ * Idle-time handoff compaction for deepseek-harness.
5
+ *
6
+ * At a configurable share of the model window (default 90%) this backend waits
7
+ * for the agent to go idle, then asks the routed model — replaying the current
8
+ * conversation as the prompt prefix so the provider's cache is reused — for a
9
+ * handoff checkpoint, and replaces the oldest compactable span with that one
10
+ * message. Selection comes from the stock backend, so a recent tail is kept
11
+ * verbatim (retainRatio 0.16 of the window by default) and the cut never splits
12
+ * a tool-call from its result; only the head of the history becomes the
13
+ * checkpoint.
14
+ *
15
+ * It subclasses the stock basic backend so selection, tool-pair balance,
16
+ * durable compaction markers, flushes and the idle admission reservation are
17
+ * the proven implementation; only the trigger point, the summarization
18
+ * template and the idle gate differ.
19
+ */
20
+ import z from "@deepseek-ai/schemastery";
21
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { BlockAssembler, LlmError, contentHasImage } from "@deepseek-ai/dsh-llm";
25
+ import BasicCompactionEngine from "@deepseek-ai/dsh-compaction-basic";
26
+ import { decideTrigger, shouldAttempt } from "./plan.js";
27
+ import { writeStatus } from "./status.js";
28
+ import {
29
+ continuationInstruction,
30
+ expectedSections,
31
+ looksTruncated,
32
+ mergeSummary,
33
+ rewriteInstruction,
34
+ stripTruncationMarkers,
35
+ validateSummary,
36
+ } from "./summary.js";
37
+
38
+ const LOG = "[handoff]";
39
+ const MAX_SUMMARY_ATTEMPTS = 3;
40
+
41
+ const SETTINGS_DIR = join(homedir(), ".dsh-handoff-compaction");
42
+ // Test harnesses point this at a missing file so the live sidecar cannot leak
43
+ // into a case that expects the configured ratio.
44
+ const SETTINGS_FILE = process.env.DSH_HANDOFF_SETTINGS_FILE ?? join(SETTINGS_DIR, "settings.json");
45
+ const DEFAULT_TEMPLATE_FILE = join(SETTINGS_DIR, "template.md");
46
+
47
+ /**
48
+ * Live settings written by the companion UI package (dsh-handoff-compaction-ui).
49
+ * Read fresh on every decision, cached by mtime so a turn never blocks on disk.
50
+ */
51
+ let settingsCache = { mtimeMs: -1, value: undefined };
52
+ function readSettings() {
53
+ try {
54
+ const stat = statSync(SETTINGS_FILE);
55
+ if (stat.mtimeMs === settingsCache.mtimeMs) return settingsCache.value;
56
+ const parsed = JSON.parse(readFileSync(SETTINGS_FILE, "utf8"));
57
+ settingsCache = { mtimeMs: stat.mtimeMs, value: parsed && typeof parsed === "object" ? parsed : undefined };
58
+ return settingsCache.value;
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+
64
+ function templatePathFrom(configured) {
65
+ if (typeof configured === "string" && configured.trim().length > 0) return configured.trim();
66
+ const env = process.env.DSH_HANDOFF_TEMPLATE;
67
+ if (typeof env === "string" && env.trim().length > 0) return env.trim();
68
+ return DEFAULT_TEMPLATE_FILE;
69
+ }
70
+
71
+ /** Read the handoff template, falling back to the built-in text. */
72
+ function readTemplate(filePath, autoCreate) {
73
+ try {
74
+ if (!existsSync(filePath)) {
75
+ if (!autoCreate) return { text: undefined, source: "builtin (file missing)" };
76
+ mkdirSync(join(filePath, ".."), { recursive: true });
77
+ writeFileSync(filePath, BUILTIN_INSTRUCTION + "\n", "utf8");
78
+ return { text: BUILTIN_INSTRUCTION, source: "builtin (written to " + filePath + ")" };
79
+ }
80
+ const text = readFileSync(filePath, "utf8").replace(/^\uFEFF/, "");
81
+ if (text.trim().length < 20) return { text: undefined, source: "builtin (file empty)" };
82
+ return { text, source: filePath };
83
+ } catch (error) {
84
+ return { text: undefined, source: `builtin (read failed: ${error instanceof Error ? error.message : String(error)})` };
85
+ }
86
+ }
87
+
88
+
89
+ const BUILTIN_INSTRUCTION = [
90
+ "你现在充当这个 AI 编程助手的压缩引擎。请把上面的对话压缩成一份结构化检查点,让另一个模型能在不丢失关键上下文的前提下接手继续这项工作。",
91
+ "",
92
+ "严格按下面给出的 Markdown 结构输出:每个小节都要保留,顺序不要变。用简短的要点,不要写成成段的散文。空的小节写「(无)」——绝不允许删掉某个小节。",
93
+ "",
94
+ "## 用户主要请求与意图",
95
+ "- [用户的原始目标以及后来的演变;措辞要紧之处逐字引用]",
96
+ "",
97
+ "## 关键技术概念",
98
+ "- [当前涉及的技术、框架、模式与约定]",
99
+ "",
100
+ "## 文件与代码",
101
+ "- [精确路径:为什么重要,关键改动或代码片段]",
102
+ "",
103
+ "## 报错与修复",
104
+ "- [错误:是怎么解决的,以及与之相关的用户反馈]",
105
+ "",
106
+ "## 未完成的任务",
107
+ "- [明确要求过但尚未完成的工作]",
108
+ "",
109
+ "## 当前工作",
110
+ "- [在这个检查点上具体正在做什么]",
111
+ "",
112
+ "## 下一步",
113
+ "- [紧接的单个动作,须与最近一次请求直接对齐;没有则写「(无)」]",
114
+ "",
115
+ "## 关键上下文",
116
+ "- [决策及其理由、约束条件、用户偏好、尚未解决的问题、继续所需的资料]",
117
+ "",
118
+ "规则:",
119
+ "- 用简洁的中文工程表述书写。精确保留文件路径、命令、报错串、标识符、数值、函数签名与语法片段。",
120
+ "- 忠实保留用户的反馈与明确指令,尤其是纠正。",
121
+ "- 不要提到本摘要请求,也不要说上下文被压缩过。",
122
+ "- 只输出检查点正文:不要调用任何工具,也不要执行任何其他动作。",
123
+ "- 如果对话里已经含有 <compacted-summary> 块,那是一份 PRIOR 检查点。不要把它逐字抄到新的摘要里:保留仍然成立的事实,删掉过时的,并把更新的信息按同样的结构合并进这一份摘要。",
124
+ ].join("\n");
125
+
126
+ function finishError(finish) {
127
+ switch (finish.kind) {
128
+ case "error":
129
+ case "aborted":
130
+ return new LlmError(finish.failure.message, finish.failure.code, finish.failure);
131
+ case "max-tokens": {
132
+ const error = new Error("handoff summary truncated at the token cap (incomplete checkpoint)");
133
+ error.code = "MAX_TOKENS";
134
+ return error;
135
+ }
136
+ default:
137
+ return undefined;
138
+ }
139
+ }
140
+
141
+ function mergeUsage(left, right) {
142
+ if (left === undefined) return right;
143
+ if (right === undefined) return left;
144
+ const merged = { ...left };
145
+ for (const key of Object.keys(right)) {
146
+ const value = right[key];
147
+ if (typeof value === "number" && typeof merged[key] === "number") merged[key] += value;
148
+ else if (merged[key] === undefined) merged[key] = value;
149
+ }
150
+ return merged;
151
+ }
152
+
153
+ function errorCodeOf(error) {
154
+ return error !== null && typeof error === "object" && typeof error.code === "string" ? error.code : "";
155
+ }
156
+
157
+ export default class HandoffCompaction extends BasicCompactionEngine {
158
+ static inject = ["llm", "tokenMeter", "sessions"];
159
+
160
+ static Config = z.object({
161
+ thresholdRatio: z.number(),
162
+ headroomTokens: z.number(),
163
+ retainRatio: z.number(),
164
+ retainTokens: z.number(),
165
+ summarizationProvider: z.string(),
166
+ summarizationModel: z.string(),
167
+ maxTokens: z.number(),
168
+ compactionRetries: z.number(),
169
+ maxOverflowRetries: z.number(),
170
+ contextWindowOverride: z.number(),
171
+ reservedCompletionTokensOverride: z.number(),
172
+ idleDelayMs: z.number(),
173
+ minIntervalMs: z.number(),
174
+ minTokenGrowth: z.number(),
175
+ timeoutMs: z.number(),
176
+ dryRun: z.boolean(),
177
+ handoffInstruction: z.string(),
178
+ traceFile: z.string(),
179
+ traceConsole: z.boolean(),
180
+ templateFile: z.string(),
181
+ presetId: z.string(),
182
+ autoCreateTemplate: z.boolean(),
183
+ });
184
+
185
+ constructor(ctx, config = {}) {
186
+ const {
187
+ contextWindowOverride,
188
+ reservedCompletionTokensOverride,
189
+ idleDelayMs,
190
+ minIntervalMs,
191
+ minTokenGrowth,
192
+ timeoutMs,
193
+ dryRun,
194
+ handoffInstruction,
195
+ traceFile,
196
+ traceConsole,
197
+ templateFile,
198
+ presetId,
199
+ autoCreateTemplate,
200
+ ...base
201
+ } = config;
202
+ // The stock trigger is registered so the plugin switch can hand compaction
203
+ // back to the native engine live; compactIfNeeded() suppresses it while the
204
+ // handoff path is enabled.
205
+ super(ctx, { ...base, auto: true });
206
+ this.handoff = {
207
+ instruction: typeof handoffInstruction === "string" && handoffInstruction.trim().length > 0 ? handoffInstruction : undefined,
208
+ contextWindowOverride: contextWindowOverride ?? 0,
209
+ reservedOverride: reservedCompletionTokensOverride ?? -1,
210
+ idleDelayMs: idleDelayMs ?? 1_500,
211
+ minIntervalMs: minIntervalMs ?? 60_000,
212
+ minTokenGrowth: minTokenGrowth ?? 2_048,
213
+ timeoutMs: timeoutMs ?? 600_000,
214
+ dryRun: dryRun ?? false,
215
+ templateFile: templatePathFrom(templateFile ?? readSettings()?.templateFile),
216
+ presetId: typeof presetId === "string" ? presetId : "",
217
+ autoCreateTemplate: autoCreateTemplate !== false,
218
+ };
219
+ this.handoff.traceFile = typeof traceFile === "string" ? traceFile : "";
220
+ this.handoff.traceConsole = traceConsole === true;
221
+ // Materialise the template so the settings card can point at a real file.
222
+ try { readTemplate(this.handoff.templateFile, this.handoff.autoCreateTemplate); } catch { /* never block construction */ }
223
+ this.attempts = new WeakMap();
224
+ this.running = new WeakSet();
225
+ this.timers = new WeakMap();
226
+ this.agents = new WeakMap();
227
+ ctx.on("agent/status", ({ agent, status }) => {
228
+ if (agent?.session) this.agents.set(agent.session, agent);
229
+ if (status === "idle") this.scheduleCheck(agent, "agent/status idle");
230
+ });
231
+ // A finished turn is the gap the user described; agent/status is not the only
232
+ // place DSH reports it, so both paths are wired.
233
+ ctx.on("session/event", (session, event) => {
234
+ if (event?.type !== "turn/end") return;
235
+ const owner = this.agents.get(session);
236
+ if (owner !== undefined) this.scheduleCheck(owner, "turn/end");
237
+ });
238
+ this.log(`ready (threshold ${this.describeThreshold()}, dryRun=${this.handoff.dryRun}, trace=${this.handoff.traceFile || (this.handoff.traceConsole ? "console" : "off")})`);
239
+ }
240
+
241
+ describeThreshold() {
242
+ return `${Math.round((this.config.thresholdRatio ?? 0.9) * 100)}%`;
243
+ }
244
+
245
+ /**
246
+ * Settings switch. Missing settings or a missing field keeps the plugin on;
247
+ * only an explicit `enabled: false` hands compaction back to the stock engine.
248
+ */
249
+ handoffEnabled() {
250
+ const live = readSettings();
251
+ return !(live !== undefined && live.enabled === false);
252
+ }
253
+
254
+ /**
255
+ * The base registers its between-step pressure / overflow triggers whenever
256
+ * `auto` is on. While the handoff path is enabled they must stay inert, so
257
+ * the plugin still only compacts from its idle gate. Disabled: delegate, and
258
+ * DSH's native compaction behaves exactly as without the plugin.
259
+ */
260
+ async compactIfNeeded(agent, trigger, signal) {
261
+ if (this.handoffEnabled()) return null;
262
+ return super.compactIfNeeded(agent, trigger, signal);
263
+ }
264
+
265
+ log(message, level = "info") {
266
+ const line = `${new Date().toISOString()} ${LOG} ${message}`;
267
+ if (this.handoff?.traceConsole === true) {
268
+ try { console.error(line); } catch { /* trace must never break the turn */ }
269
+ }
270
+ const file = this.handoff?.traceFile;
271
+ if (typeof file === "string" && file.length > 0) {
272
+ try { appendFileSync(file, line + "\n"); } catch { /* trace must never break the turn */ }
273
+ }
274
+ const write = this.ctx.logger?.[level] ?? this.ctx.logger?.info;
275
+ if (typeof write === "function") write.call(this.ctx.logger, `${LOG} ${message}`);
276
+ }
277
+
278
+ /** Debounce the idle signal so a turn that immediately resumes does not trigger work. */
279
+ scheduleCheck(agent, why = "idle") {
280
+ const previous = this.timers.get(agent);
281
+ if (previous !== undefined) clearTimeout(previous);
282
+ const timer = setTimeout(() => {
283
+ this.timers.delete(agent);
284
+ void this.checkAndHandoff(agent, why);
285
+ }, this.handoff.idleDelayMs);
286
+ if (typeof timer.unref === "function") timer.unref();
287
+ this.timers.set(agent, timer);
288
+ }
289
+
290
+ routedTarget(agent) {
291
+ const routed = agent.session?.requestHeader?.()?.config;
292
+ if (routed?.provider && routed?.model) return { provider: routed.provider, model: routed.model };
293
+ const options = agent.options ?? {};
294
+ if (options.provider && options.model) return { provider: options.provider, model: options.model };
295
+ return undefined;
296
+ }
297
+
298
+ async checkAndHandoff(agent, why = "idle") {
299
+ if (this.running.has(agent)) return;
300
+ if (!this.handoffEnabled()) return;
301
+ try {
302
+ const session = agent.session;
303
+ if (!session) return;
304
+ const measurement = this.ctx.tokenMeter.measure(session);
305
+ const target = this.routedTarget(agent);
306
+ if (target === undefined) return this.log(`${why}: no routed provider/model yet; skipping`);
307
+
308
+ const info = await this.ctx.llm.resolveModelInfo(target.provider, target.model);
309
+ const headerMaxTokens = session.requestHeader?.()?.config?.maxTokens;
310
+ const reserved = this.handoff.reservedOverride >= 0
311
+ ? this.handoff.reservedOverride
312
+ : (headerMaxTokens ?? info.defaultMaxTokens ?? 0);
313
+ const contextWindow = this.handoff.contextWindowOverride > 0
314
+ ? this.handoff.contextWindowOverride
315
+ : info.context?.contextWindow;
316
+
317
+ const live = readSettings();
318
+ const scope = live?.scope === "preset" ? "preset" : "global";
319
+ const selectedPresets = Array.isArray(live?.presets) ? live.presets : [];
320
+ if (scope === "preset" && this.handoff.presetId !== "" && !selectedPresets.includes(this.handoff.presetId)) {
321
+ return this.log(`${why}: skipped by scope (preset "${this.handoff.presetId}" not selected)`);
322
+ }
323
+ const liveRatio = typeof live?.thresholdRatio === "number" && live.thresholdRatio > 0 && live.thresholdRatio <= 1
324
+ ? live.thresholdRatio
325
+ : undefined;
326
+
327
+ const decision = decideTrigger({
328
+ totalTokens: measurement.totalTokens,
329
+ contextWindow,
330
+ reservedCompletionTokens: reserved,
331
+ headroomTokens: this.config.headroomTokens ?? 0,
332
+ thresholdRatio: liveRatio ?? this.config.thresholdRatio ?? 0.9,
333
+ });
334
+ if (!decision.trigger) {
335
+ return this.log(`${why}: ${measurement.totalTokens}/${decision.thresholdTokens ?? "?"} tokens${decision.ok === false ? ` (${decision.reason})` : ""}`);
336
+ }
337
+
338
+ const last = this.attempts.get(agent);
339
+ if (!shouldAttempt({
340
+ now: Date.now(),
341
+ last,
342
+ tokens: measurement.totalTokens,
343
+ thresholdTokens: decision.thresholdTokens,
344
+ minIntervalMs: this.handoff.minIntervalMs,
345
+ minTokenGrowth: this.handoff.minTokenGrowth,
346
+ })) {
347
+ return this.log(`over threshold (${measurement.totalTokens} >= ${decision.thresholdTokens}) but holding: retry gate`);
348
+ }
349
+
350
+ this.attempts.set(agent, { at: Date.now(), tokens: measurement.totalTokens });
351
+ if (this.handoff.dryRun) {
352
+ return this.log(`DRY-RUN: would hand off now (${measurement.totalTokens} >= ${decision.thresholdTokens} of an assumed ${contextWindow}-token window)`);
353
+ }
354
+
355
+ this.running.add(agent);
356
+ writeStatus(session.id, {
357
+ phase: "running",
358
+ step: "prepare",
359
+ startedAt: Date.now(),
360
+ totalTokens: measurement.totalTokens,
361
+ thresholdTokens: decision.thresholdTokens,
362
+ contextWindow,
363
+ timeoutMs: this.handoff.timeoutMs,
364
+ provider: target.provider,
365
+ model: target.model,
366
+ });
367
+ try {
368
+ const result = await this.compactNow(agent, AbortSignal.timeout(this.handoff.timeoutMs));
369
+ if (result === null || result === undefined) {
370
+ writeStatus(session.id, {
371
+ phase: "done",
372
+ step: "done",
373
+ completedAt: Date.now(),
374
+ shadowedNodes: 0,
375
+ shadowedTokens: 0,
376
+ note: "no compactable range",
377
+ });
378
+ this.log("idle: no compactable range; nothing replaced", "warn");
379
+ return;
380
+ }
381
+ writeStatus(session.id, {
382
+ phase: "done",
383
+ step: "done",
384
+ completedAt: Date.now(),
385
+ shadowedNodes: result.shadowedSeqs.length,
386
+ shadowedTokens: result.shadowedTokenCount,
387
+ missingSections: [],
388
+ retrying: false,
389
+ });
390
+ this.log(`committed: replaced ${result.shadowedSeqs.length} node(s) (~${result.shadowedTokenCount} tokens) with the handoff checkpoint`);
391
+ } catch (error) {
392
+ const message = error instanceof Error ? error.message : String(error);
393
+ writeStatus(agent.session?.id, {
394
+ phase: "failed",
395
+ step: "failed",
396
+ failedAt: Date.now(),
397
+ error: message,
398
+ errorCode: errorCodeOf(error),
399
+ });
400
+ throw error;
401
+ } finally {
402
+ this.running.delete(agent);
403
+ }
404
+ } catch (error) {
405
+ const message = error instanceof Error ? error.message : String(error);
406
+ writeStatus(agent.session?.id, {
407
+ phase: "failed",
408
+ step: "failed",
409
+ failedAt: Date.now(),
410
+ error: message,
411
+ errorCode: errorCodeOf(error),
412
+ });
413
+ this.log(`failed: ${message}`, "warn");
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Wrap the stock transaction so manual /compact and automatic idle
419
+ * compaction share the same status sidecar. The base method still owns the
420
+ * surface lock, summarization and commit.
421
+ */
422
+ async compactNow(agent, signal, sourceCommandId) {
423
+ // Manual /compact while the plugin is off stays a stock native compaction:
424
+ // no handoff template, no plugin status sidecar.
425
+ if (!this.handoffEnabled()) return super.compactNow(agent, signal, sourceCommandId);
426
+ const sessionId = agent?.session?.id;
427
+ let totalTokens;
428
+ try { totalTokens = this.ctx.tokenMeter.measure(agent.session).totalTokens; } catch { totalTokens = undefined; }
429
+ writeStatus(sessionId, {
430
+ phase: "running",
431
+ step: "prepare",
432
+ startedAt: Date.now(),
433
+ timeoutMs: this.handoff.timeoutMs,
434
+ ...(typeof totalTokens === "number" ? { totalTokens } : {}),
435
+ retrying: false,
436
+ writing: false,
437
+ missingSections: [],
438
+ error: "",
439
+ errorCode: "",
440
+ });
441
+ try {
442
+ const result = await super.compactNow(agent, signal, sourceCommandId);
443
+ writeStatus(sessionId, {
444
+ phase: "done",
445
+ step: "done",
446
+ completedAt: Date.now(),
447
+ shadowedNodes: Array.isArray(result?.shadowedSeqs) ? result.shadowedSeqs.length : 0,
448
+ shadowedTokens: typeof result?.shadowedTokenCount === "number" ? result.shadowedTokenCount : 0,
449
+ missingSections: [],
450
+ retrying: false,
451
+ writing: false,
452
+ ...(result === null || result === undefined ? { note: "no compactable range" } : {}),
453
+ });
454
+ return result;
455
+ } catch (error) {
456
+ const message = error instanceof Error ? error.message : String(error);
457
+ writeStatus(sessionId, {
458
+ phase: "failed",
459
+ step: "failed",
460
+ failedAt: Date.now(),
461
+ error: message,
462
+ errorCode: errorCodeOf(error),
463
+ });
464
+ throw error;
465
+ }
466
+ }
467
+
468
+ /** Handoff template instead of the stock compaction instruction. */
469
+ async summarize(input, agent, signal) {
470
+ // Native pressure/overflow compaction runs through this same hook, so the
471
+ // stock prompt must come back the moment the plugin is switched off.
472
+ if (!this.handoffEnabled()) return super.summarize(input, agent, signal);
473
+ const latest = agent.session.requestHeader?.()?.config;
474
+ const configured = this.config.summarizationProvider && this.config.summarizationModel
475
+ ? { provider: this.config.summarizationProvider, model: this.config.summarizationModel }
476
+ : undefined;
477
+ const agentTarget = agent.options?.provider && agent.options?.model
478
+ ? { provider: agent.options.provider, model: agent.options.model }
479
+ : undefined;
480
+ const target = configured ?? latest ?? agentTarget;
481
+ if (target === undefined) throw new Error("no provider/model available for the handoff summary");
482
+ // Replaying the prefix only reuses the provider cache when the auxiliary
483
+ // request carries the same routing knobs as the routed one. Strata writes
484
+ // the reasoning directive into the prefix, so a missing effort breaks the
485
+ // cache a few thousand tokens in. Only inherit it when the summary target
486
+ // is the same model that produced the latest request.
487
+ const sameTarget = latest !== undefined && target.provider === latest.provider && target.model === latest.model;
488
+ const reasoningEffort = sameTarget ? latest.reasoningEffort : undefined;
489
+
490
+ const resolved = this.handoff.instruction === undefined
491
+ ? readTemplate(this.handoff.templateFile, this.handoff.autoCreateTemplate)
492
+ : { text: this.handoff.instruction, source: "config handoffInstruction" };
493
+ const instruction = typeof resolved.text === "string" && resolved.text.trim().length > 0
494
+ ? resolved.text
495
+ : BUILTIN_INSTRUCTION;
496
+ this.log(`template: ${resolved.source}${resolved.text === undefined ? " (fallback)" : ""}`);
497
+
498
+ const expected = expectedSections(instruction);
499
+ const baseMessages = [
500
+ ...input.messages,
501
+ Object.freeze({ role: "user", content: [{ type: "text", text: instruction }] }),
502
+ ];
503
+ const baseOptions = {
504
+ provider: target.provider,
505
+ model: target.model,
506
+ toolHistory: agent.session.toolHistory(),
507
+ ...(input.tools === undefined ? {} : { tools: [...input.tools] }),
508
+ maxTokens: this.config.maxTokens,
509
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
510
+ sessionId: agent.session.id,
511
+ purpose: "compaction",
512
+ ...(signal === undefined ? {} : { signal }),
513
+ };
514
+ const sessionId = agent.session?.id;
515
+
516
+ let combined = "";
517
+ let rawOutput = [];
518
+ let usage;
519
+ let validation = {
520
+ ok: expected.length === 0,
521
+ missing: [...expected],
522
+ truncated: false,
523
+ endsWithHeading: false,
524
+ reason: "not attempted",
525
+ };
526
+ const attempts = [];
527
+
528
+ let liveTotalTokens;
529
+ try { liveTotalTokens = this.ctx.tokenMeter.measure(agent.session).totalTokens; } catch { liveTotalTokens = undefined; }
530
+ writeStatus(sessionId, {
531
+ phase: "running",
532
+ step: "summary",
533
+ summaryAttempt: 1,
534
+ retrying: false,
535
+ writing: false,
536
+ missingSections: [],
537
+ ...(typeof liveTotalTokens === "number" ? { totalTokens: liveTotalTokens } : {}),
538
+ });
539
+
540
+ for (let attempt = 1; attempt <= MAX_SUMMARY_ATTEMPTS; attempt += 1) {
541
+ signal?.throwIfAborted?.();
542
+ const messages = attempt === 1
543
+ ? baseMessages
544
+ : attempt === 2
545
+ ? [
546
+ ...baseMessages,
547
+ Object.freeze({ role: "assistant", content: [{ type: "text", text: combined }] }),
548
+ Object.freeze({ role: "user", content: [{ type: "text", text: continuationInstruction(validation) }] }),
549
+ ]
550
+ : [
551
+ ...baseMessages,
552
+ Object.freeze({
553
+ role: "user",
554
+ content: [{ type: "text", text: rewriteInstruction(validation, this.config.maxTokens) }],
555
+ }),
556
+ ];
557
+
558
+ const result = await this.runSummaryRequest(baseOptions, messages);
559
+ const truncatedNow = looksTruncated(result.text);
560
+ const clean = stripTruncationMarkers(result.text);
561
+ combined = attempt === 1
562
+ ? clean
563
+ : attempt === 2
564
+ ? mergeSummary(combined, clean)
565
+ : clean;
566
+ rawOutput = result.rawOutput;
567
+ usage = mergeUsage(usage, result.usage);
568
+ validation = validateSummary(combined, expected);
569
+ if (truncatedNow) {
570
+ validation = {
571
+ ...validation,
572
+ ok: false,
573
+ truncated: true,
574
+ reason: validation.reason.length > 0 ? `${validation.reason}; truncation marker` : "truncation marker",
575
+ };
576
+ }
577
+ attempts.push({
578
+ attempt,
579
+ finish: result.finishKind,
580
+ chars: result.text.length,
581
+ ok: validation.ok,
582
+ });
583
+ if (validation.ok) break;
584
+ if (attempt < MAX_SUMMARY_ATTEMPTS) {
585
+ const next = attempt + 1;
586
+ this.log(`summary incomplete after attempt ${attempt}: ${validation.reason}; ${attempt === 1 ? "continuing" : "rewriting"}`);
587
+ writeStatus(sessionId, {
588
+ phase: "running",
589
+ step: "summary",
590
+ summaryAttempt: next,
591
+ retrying: true,
592
+ writing: next === MAX_SUMMARY_ATTEMPTS,
593
+ missingSections: validation.missing,
594
+ summaryChars: combined.length,
595
+ summaryAttempts: attempts,
596
+ });
597
+ }
598
+ }
599
+
600
+ if (!validation.ok) {
601
+ const error = new Error(`handoff summary incomplete after ${attempts.length} attempt(s): ${validation.reason}`);
602
+ error.code = "INCOMPLETE_SUMMARY";
603
+ error.attempts = attempts;
604
+ throw error;
605
+ }
606
+
607
+ const finalText = combined.trim();
608
+ if (finalText.length === 0) throw new Error("handoff summary produced no text content");
609
+ writeStatus(sessionId, {
610
+ phase: "running",
611
+ step: "commit",
612
+ summaryAttempt: attempts.length,
613
+ retrying: false,
614
+ writing: false,
615
+ missingSections: [],
616
+ summaryChars: finalText.length,
617
+ summaryAttempts: attempts,
618
+ });
619
+
620
+ return {
621
+ summary: [{ type: "text", text: finalText }],
622
+ rawOutput,
623
+ llmStreamCall: true,
624
+ provider: target.provider,
625
+ model: target.model,
626
+ maxTokens: this.config.maxTokens,
627
+ ...(usage === undefined ? {} : { usage }),
628
+ };
629
+ }
630
+
631
+ /** One summary LLM call; max-tokens is returned as a partial, not thrown. */
632
+ async runSummaryRequest(baseOptions, messages) {
633
+ const assembler = new BlockAssembler();
634
+ for await (const chunk of this.ctx.llm.stream({ ...baseOptions, messages })) assembler.push(chunk);
635
+ const finish = assembler.finish ?? { kind: "stop" };
636
+ if (finish.kind === "error" || finish.kind === "aborted") {
637
+ const error = finishError(finish);
638
+ if (error !== undefined) throw error;
639
+ }
640
+ const rawOutput = assembler.blocks();
641
+ if (contentHasImage(rawOutput)) throw new LlmError("handoff summary cannot contain image output", "UNSUPPORTED_CONTENT");
642
+ const text = rawOutput.filter((block) => block.type === "text").map((block) => block.text).join("");
643
+ if (text.trim().length === 0) throw new Error("handoff summary produced no text content");
644
+ return {
645
+ rawOutput,
646
+ text,
647
+ finishKind: finish.kind,
648
+ usage: assembler.usage,
649
+ };
650
+ }
651
+ }