diff --git a/packages/dmoss-agent/src/context/compaction-prompts.ts b/packages/dmoss-agent/src/context/compaction-prompts.ts index 81c93ce..ecef3c7 100644 --- a/packages/dmoss-agent/src/context/compaction-prompts.ts +++ b/packages/dmoss-agent/src/context/compaction-prompts.ts @@ -18,6 +18,8 @@ export const SUMMARIZATION_SYSTEM_PROMPT = `你是上下文摘要助手。你的 - 用户明确批准或拒绝的结论(例如同意改某路径、否决某方案)必须点名写出对象(路径/命令/工具名),不得只写「已确认」。 - 仍待用户拍板或工具链在等待的事项,单独记在对应章节,避免后续模型误当成已完成。 - 保留精确的文件路径、函数名、命令行、HTTP/API 端点、错误栈/退出码、版本号、哈希、端口号、IP——后续模型靠这些字面量继续排查。 +- 事实强度必须分级:用户明确确认、工具结果、官方文档/源码中的内容可写为“已验证”;assistant 旧回答、记忆、未带来源的产品定义/设备状态/版本/路径只能写为“历史说法/待核对”,不得升级为当前事实。 +- 对产品介绍、设备状态、价格/渠道、版本、当前路径、执行结果这类易变信息,摘要应保留“需要用工具或官方来源复核”的要求,而不是把旧回答压成结论。 - 对话主要使用中文时,摘要主体用中文;标识符、路径、日志片段保持原文,不要翻译。 - 对于嵌入式 / 机器人开发场景,优先保留:设备 ID 或别名、型号、IP/主机、SSH 是否通、设备端 Agent 版本或安装与否、加速器/部署相关路径与状态、最近一次失败命令或 stdout/stderr 的关键行(可截断但保留头尾特征)。 - 若输入里已经包含此前的“上下文检查点摘要”或“CONTEXT CHECKPOINT”,必须抽取其中的历史主线(做过什么、关键决策和原因、结果、方向)写入新的“## 0. 历史脉络”中;每次压缩追加 3-5 句,禁止把早期压缩的关键决策静默丢弃。`; @@ -52,7 +54,7 @@ export const SUMMARIZATION_PROMPT = `以上消息是一段对话,请生成结 [已列出但尚未动手的事项] ## 6. 设备与环境状态 -[设备标识、IP/主机、型号、SSH/凭据是否就绪、设备端 Agent 服务状态、重要工作目录] +[设备标识、IP/主机、型号、SSH/凭据是否就绪、设备端 Agent 服务状态、重要工作目录;未由工具/用户明确确认的状态必须标「未验证/历史说法」] ## 7. 关键文件与路径 [读写过的配置文件、模型、脚本路径;与问题直接相关的绝对路径;写明“为什么读/改这个文件”和仍需依赖的符号/函数名] @@ -65,6 +67,7 @@ export const SUMMARIZATION_PROMPT = `以上消息是一段对话,请生成结 写作要求:宁可多一行具体名词,也不要用「之前讨论过」类模糊指代。工具调用只需保留对后续有意义的名称与参数片段,不要把整段 JSON 原文塞进摘要。 +重要事实边界:摘要是后续模型的弱上下文检查点,不是事实数据库。assistant 历史回答中的产品定义、设备判断、版本/路径/命令成功与否,除非被工具结果、用户确认或官方来源支撑,否则必须标「待核对」,并在第 8 或第 9 节写出复核入口。 压缩质量红线(必须满足): - 摘要必须能让后续模型不重新收集同一批上下文即可继续:至少保留“当前最小工作集”(文件路径 + 符号/函数 + 已验证结论)。 @@ -77,7 +80,7 @@ export const UPDATE_SUMMARIZATION_PROMPT = `以上消息是需要纳入已有摘 先在 中分析新对话带来了哪些变化,然后在 中输出更新后的完整摘要。 请在保留已有摘要信息的前提下进行更新,使用相同的段落格式(每段无内容写「(无)」)。规则: -- 默认保留旧摘要中的约束与事实,除非新对话**明确**推翻;推翻时写「更新:…(旧:…)」,不要静默删除。 +- 默认保留旧摘要中的用户约束与已验证事实,除非新对话**明确**推翻;旧摘要里没有来源支撑的“事实”只能作为「历史说法/待核对」保留,不能在更新时升级成当前事实。推翻时写「更新:…(旧:…)」,不要静默删除。 - 如果旧摘要中有“## 0. 历史脉络”或此前压缩摘要,必须保留其关键主线,并追加本次压缩前的新里程碑;每次追加 3-5 句即可,但不得删除早期关键决策。 - 追加新进展、新决策、新错误;用户新增的禁止/必须句并入第 2 节对应条目。 - 已完成的事项从「当前进行中」移到「已完成的工作」;新待办写入第 5 节。 diff --git a/packages/dmoss-agent/src/context/compaction.ts b/packages/dmoss-agent/src/context/compaction.ts index 150239f..cf1afda 100644 --- a/packages/dmoss-agent/src/context/compaction.ts +++ b/packages/dmoss-agent/src/context/compaction.ts @@ -285,8 +285,23 @@ export type SummarizeFn = (params: { system: string; userPrompt: string; maxTokens: number; + abortSignal?: AbortSignal; }) => Promise; +function throwIfCompactionAborted(signal?: AbortSignal): void { + if (!signal?.aborted) { + return; + } + if (signal.reason instanceof Error) { + throw signal.reason; + } + throw new Error( + signal.reason === undefined + ? "compaction aborted" + : `compaction aborted: ${String(signal.reason)}`, + ); +} + function normalizeParts(parts: number, messageCount: number): number { if (!Number.isFinite(parts) || parts <= 1) { return 1; @@ -451,7 +466,9 @@ async function generateSummary(params: { maxTokens: number; customInstructions?: string; previousSummary?: string; + abortSignal?: AbortSignal; }): Promise { + throwIfCompactionAborted(params.abortSignal); let basePrompt = params.previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; if (params.customInstructions) { basePrompt = `${basePrompt}\n\nAdditional focus: ${params.customInstructions}`; @@ -467,8 +484,10 @@ async function generateSummary(params: { system: SUMMARIZATION_SYSTEM_PROMPT, userPrompt: prompt, maxTokens: params.maxTokens, + abortSignal: params.abortSignal, }); + throwIfCompactionAborted(params.abortSignal); // 提取 标签内容(如果 LLM 遵循了两段式输出格式) return extractSummaryTag(raw); } @@ -480,6 +499,7 @@ async function summarizeChunks(params: { maxChunkTokens: number; customInstructions?: string; previousSummary?: string; + abortSignal?: AbortSignal; }): Promise { if (params.messages.length === 0) { return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK; @@ -493,6 +513,7 @@ async function summarizeChunks(params: { maxTokens: params.maxTokens, customInstructions: params.customInstructions, previousSummary: summary, + abortSignal: params.abortSignal, }); } return summary ?? DEFAULT_SUMMARY_FALLBACK; @@ -506,6 +527,7 @@ async function summarizeWithFallback(params: { contextWindow: number; customInstructions?: string; previousSummary?: string; + abortSignal?: AbortSignal; }): Promise { if (params.messages.length === 0) { return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK; @@ -514,6 +536,7 @@ async function summarizeWithFallback(params: { try { return await summarizeChunks(params); } catch (e) { + throwIfCompactionAborted(params.abortSignal); log.warn('summarizeChunks failed, falling back to smaller chunks', { error: e instanceof Error ? e.message : String(e), }); @@ -539,6 +562,7 @@ async function summarizeWithFallback(params: { const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : ""; return partial + notes; } catch (e) { + throwIfCompactionAborted(params.abortSignal); log.warn('smaller-chunks fallback also failed', { error: e instanceof Error ? e.message : String(e), }); @@ -559,7 +583,9 @@ export async function summarizeInStages(params: { previousSummary?: string; parts?: number; minMessagesForSplit?: number; + abortSignal?: AbortSignal; }): Promise { + throwIfCompactionAborted(params.abortSignal); const { messages } = params; if (messages.length === 0) { return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK; @@ -580,6 +606,7 @@ export async function summarizeInStages(params: { const partialSummaries: string[] = []; for (const chunk of splits) { + throwIfCompactionAborted(params.abortSignal); partialSummaries.push( await summarizeWithFallback({ ...params, @@ -676,7 +703,9 @@ export async function buildCompactionSummary(params: { maxTokens?: number; reserveTokens?: number; customInstructions?: string; + abortSignal?: AbortSignal; }): Promise { + throwIfCompactionAborted(params.abortSignal); if (params.messages.length === 0) { return DEFAULT_SUMMARY_FALLBACK; } @@ -692,6 +721,7 @@ export async function buildCompactionSummary(params: { maxChunkTokens, contextWindow: params.contextWindowTokens, customInstructions: params.customInstructions, + abortSignal: params.abortSignal, }); } @@ -703,6 +733,7 @@ async function runRemoteCompaction(params: { customInstructions?: string; droppedMessages: Message[]; systemPrompt?: string; + abortSignal?: AbortSignal; }): Promise { const { hybridCompact } = await import("./remote-compaction.js"); const hybrid = await hybridCompact( @@ -712,6 +743,7 @@ async function runRemoteCompaction(params: { contextWindowTokens: params.contextWindowTokens, reserveTokens: params.reserveTokens, customInstructions: params.customInstructions, + abortSignal: params.abortSignal, }, params.droppedMessages, params.systemPrompt, @@ -727,6 +759,7 @@ async function runLlmCompaction(params: { maxTokens?: number; reserveTokens: number; customInstructions?: string; + abortSignal?: AbortSignal; }): Promise { return buildCompactionSummary({ summarize: params.summarize, @@ -735,6 +768,7 @@ async function runLlmCompaction(params: { maxTokens: params.maxTokens, reserveTokens: params.reserveTokens, customInstructions: params.customInstructions, + abortSignal: params.abortSignal, }); } @@ -752,6 +786,7 @@ export async function compactHistoryIfNeeded(params: { remoteCompactProvider?: RemoteCompactProvider; customInstructions?: string; includeThinking?: boolean; + abortSignal?: AbortSignal; /** M3: Optional workspace directory for file ops scope isolation. */ workspaceDir?: string; }): Promise<{ @@ -760,6 +795,7 @@ export async function compactHistoryIfNeeded(params: { pruneResult: PruneResult; degraded?: boolean; }> { + throwIfCompactionAborted(params.abortSignal); const charsPerUnitBase = Math.max(1, params.charsPerTokenUnit ?? CHARS_PER_TOKEN_ESTIMATE); const estimateOptions = { includeThinking: params.includeThinking }; const rawTotalChars = estimateMessagesChars(params.messages, estimateOptions) + (params.systemPrompt?.length ?? 0); @@ -821,6 +857,7 @@ export async function compactHistoryIfNeeded(params: { if (!shouldCompact) { return { pruneResult }; } + throwIfCompactionAborted(params.abortSignal); if (pruneResult.droppedMessages.length === 0) { const totalTokens = estimateMessagesTokens(params.messages, estimateOptions); @@ -867,6 +904,7 @@ export async function compactHistoryIfNeeded(params: { customInstructions: params.customInstructions, droppedMessages: pruneResult.droppedMessages, systemPrompt: params.systemPrompt, + abortSignal: params.abortSignal, }); } else { summary = await runLlmCompaction({ @@ -876,9 +914,11 @@ export async function compactHistoryIfNeeded(params: { maxTokens: params.maxTokens, reserveTokens: resolvedSettings.reserveTokens, customInstructions: params.customInstructions, + abortSignal: params.abortSignal, }); } } catch (err) { + throwIfCompactionAborted(params.abortSignal); log.warn("LLM compaction failed; using deterministic fallback summary", { error: err instanceof Error ? err.message : String(err), }); @@ -899,6 +939,7 @@ export async function compactHistoryIfNeeded(params: { ); degraded = true; } + throwIfCompactionAborted(params.abortSignal); summary = mergePriorCompactionSummaries(summary, priorCompactionSummaries); const fileOps = createFileOps(); for (const message of pruneResult.droppedMessages) { diff --git a/packages/dmoss-agent/src/context/deterministic-summary.ts b/packages/dmoss-agent/src/context/deterministic-summary.ts index 2e76c55..38875d0 100644 --- a/packages/dmoss-agent/src/context/deterministic-summary.ts +++ b/packages/dmoss-agent/src/context/deterministic-summary.ts @@ -126,7 +126,7 @@ export function buildDeterministicCompactionSummary( "## 1. 主要目标", primaryGoal, "## 2. 关键决策与约束", - "保留摘录中的用户原话、路径、命令、错误和工具调用参数;不要把未确认事项当作已完成。", + "保留摘录中的用户原话、路径、命令、错误和工具调用参数;不要把未确认事项当作已完成。assistant 历史回答只是历史说法,除非摘录中有工具结果或用户确认支撑,否则继续前需要复核。", "## 3. 已完成的工作", "见第 9 节中 assistant/tool_result 摘录。", "## 4. 当前进行中", @@ -134,7 +134,7 @@ export function buildDeterministicCompactionSummary( "## 5. 待办事项", "若保留尾部消息没有明确下一步,应先复述理解并请求用户确认。", "## 6. 设备与环境状态", - "未由本地规则可靠判定;以保留尾部消息和后续工具查询为准。", + "未由本地规则可靠判定;以保留尾部消息和后续工具查询为准。设备当前状态、路径、版本与执行结果必须重新用工具确认。", "## 7. 关键文件与路径", "见第 9 节工具调用参数与消息摘录中的 path/file_path/cmd/url 字面量。", "## 8. 错误与问题", diff --git a/packages/dmoss-agent/src/context/remote-compaction.ts b/packages/dmoss-agent/src/context/remote-compaction.ts index 2456e43..1b1d179 100644 --- a/packages/dmoss-agent/src/context/remote-compaction.ts +++ b/packages/dmoss-agent/src/context/remote-compaction.ts @@ -336,6 +336,7 @@ export async function hybridCompact( maxTokens: maxOutputTokens, reserveTokens, customInstructions: config.customInstructions, + abortSignal: config.abortSignal, }); const summaryTokens = Math.ceil(summary.length / 4); diff --git a/packages/dmoss-agent/src/context/summary-checkpoint-merge.ts b/packages/dmoss-agent/src/context/summary-checkpoint-merge.ts index 5750460..2d0728a 100644 --- a/packages/dmoss-agent/src/context/summary-checkpoint-merge.ts +++ b/packages/dmoss-agent/src/context/summary-checkpoint-merge.ts @@ -54,7 +54,7 @@ export function mergePriorCompactionSummaries(summary: string, priorSummaries: s MERGED_PRIOR_SUMMARY_MAX_CHARS, ); return [ - "## 已合并的早期检查点", + "## 已合并的早期检查点(弱上下文,未标明来源的事实需复核)", mergedPrior, "## 本次压缩新增摘要", summary.trim(), diff --git a/packages/dmoss-agent/src/core/agent/dmoss-agent.ts b/packages/dmoss-agent/src/core/agent/dmoss-agent.ts index af899cf..e829487 100644 --- a/packages/dmoss-agent/src/core/agent/dmoss-agent.ts +++ b/packages/dmoss-agent/src/core/agent/dmoss-agent.ts @@ -498,6 +498,7 @@ export class DmossAgent { systemPrompt: params.system, messages: [{ role: 'user', content: params.userPrompt }], maxTokens: params.maxTokens, + abortSignal: params.abortSignal, }); return resp.content .filter((b): b is { type: 'text'; text: string } => b.type === 'text') @@ -793,7 +794,7 @@ export class DmossAgent { // Type bridge: InternalMessage and LLMMessage have compatible runtime shapes but different type definitions due to module boundaries await store.replaceMessages(key, nextMessages as unknown as LLMMessage[]); }, - prepareCompaction: async ({ messages: compactMessages, forceCompaction }) => { + prepareCompaction: async ({ messages: compactMessages, forceCompaction, includeThinking, abortSignal }) => { const compactResult = await compactHistoryIfNeeded({ summarize, messages: compactMessages, @@ -804,6 +805,8 @@ export class DmossAgent { charsPerTokenUnit: resolveContextCharsPerTokenUnit(), forceCompaction, remoteCompactProvider: this.remoteCompactProvider, + includeThinking, + abortSignal, }); if (!compactResult.summary || !compactResult.summaryMessage) return {}; return { diff --git a/packages/dmoss-agent/src/core/llm/summarization-strategy.ts b/packages/dmoss-agent/src/core/llm/summarization-strategy.ts index 13e6c32..f0c1274 100644 --- a/packages/dmoss-agent/src/core/llm/summarization-strategy.ts +++ b/packages/dmoss-agent/src/core/llm/summarization-strategy.ts @@ -70,6 +70,7 @@ export function createSummarizeFnFromLlmProvider(params: { systemPrompt: request.system, messages: [{ role: 'user', content: request.userPrompt }], maxTokens: request.maxTokens, + abortSignal: request.abortSignal, }); return response.content .filter((block): block is { type: 'text'; text: string } => block.type === 'text') @@ -97,6 +98,7 @@ export function createClientLlmSummarizationStrategy(params: { maxTokens: input.maxTokens, skipLlmCompaction: input.skipLlmCompaction, forceCompaction: input.forceCompaction, + abortSignal: input.abortSignal, }); if (result.summary && result.summaryMessage) { diff --git a/packages/dmoss-agent/src/core/loop/agent-loop-compaction.ts b/packages/dmoss-agent/src/core/loop/agent-loop-compaction.ts index bb85b2e..8c615ba 100644 --- a/packages/dmoss-agent/src/core/loop/agent-loop-compaction.ts +++ b/packages/dmoss-agent/src/core/loop/agent-loop-compaction.ts @@ -7,6 +7,7 @@ import { } from './compact-hooks.js'; import type { Message } from '../session/session-jsonl.js'; import { summarizeDroppedMessages } from './agent-loop-context-prep.js'; +import { runWithCompactionPrepareTimeout } from './compaction-timeout.js'; export interface AgentLoopPrepareCompaction { (params: { @@ -78,6 +79,9 @@ async function runCompactionCore( errorLabel, } = params; + let preHookRan = false; + let postHookRan = false; + try { await compactHooks?.runPreHooks({ sessionKey, @@ -85,15 +89,20 @@ async function runCompactionCore( messages: currentMessages, reason: hookReason, }); + preHookRan = true; - const prep = await prepareCompaction({ - messages: currentMessages, - sessionKey, - runId, - forceCompaction, - includeThinking, - abortSignal, - }); + const prep = await runWithCompactionPrepareTimeout( + (prepareAbortSignal) => + prepareCompaction({ + messages: currentMessages, + sessionKey, + runId, + forceCompaction, + includeThinking, + abortSignal: prepareAbortSignal, + }), + { abortSignal, label: errorLabel }, + ); const checkpointOutline = prep.checkpointOutline ?? buildCompactionCheckpointOutline(prep.summary); @@ -108,6 +117,7 @@ async function runCompactionCore( success: Boolean(prep.summary && prep.summaryMessage), ...(checkpointOutline ? { checkpointOutline } : {}), }); + postHookRan = true; if (!prep.summary || !prep.summaryMessage) { return { attempted: true, succeeded: false, retrySameTurn: false }; @@ -162,6 +172,22 @@ async function runCompactionCore( compactionSummary, }; } catch (error) { + if (preHookRan && !postHookRan) { + try { + await compactHooks?.runPostHooks({ + sessionKey, + runId, + summaryChars: 0, + droppedMessages: 0, + reason: hookReason, + success: false, + }); + } catch (hookError) { + onWarn?.(`${errorLabel} compaction post hook failed`, { + error: describeError(hookError), + }); + } + } onWarn?.(`${errorLabel} compaction failed`, { error: describeError(error), }); diff --git a/packages/dmoss-agent/src/core/loop/agent-loop-context-prep.ts b/packages/dmoss-agent/src/core/loop/agent-loop-context-prep.ts index 8f6f291..38cf603 100644 --- a/packages/dmoss-agent/src/core/loop/agent-loop-context-prep.ts +++ b/packages/dmoss-agent/src/core/loop/agent-loop-context-prep.ts @@ -161,6 +161,7 @@ export interface PrepareTurnContextParams { sessionKey: string; runId: string; forceCompaction?: boolean; + includeThinking?: boolean; abortSignal?: AbortSignal; }) => Promise<{ summary?: string; diff --git a/packages/dmoss-agent/src/core/loop/agent-loop-llm-call.ts b/packages/dmoss-agent/src/core/loop/agent-loop-llm-call.ts index a9214bc..899d27b 100644 --- a/packages/dmoss-agent/src/core/loop/agent-loop-llm-call.ts +++ b/packages/dmoss-agent/src/core/loop/agent-loop-llm-call.ts @@ -57,6 +57,7 @@ export interface ExecuteLlmTurnParams { sessionKey: string; runId: string; forceCompaction?: boolean; + includeThinking?: boolean; abortSignal?: AbortSignal; }) => Promise<{ summary?: string; diff --git a/packages/dmoss-agent/src/core/loop/agent-loop-types.ts b/packages/dmoss-agent/src/core/loop/agent-loop-types.ts index f582fd5..c2946b8 100644 --- a/packages/dmoss-agent/src/core/loop/agent-loop-types.ts +++ b/packages/dmoss-agent/src/core/loop/agent-loop-types.ts @@ -120,6 +120,7 @@ export interface AgentLoopDeps { sessionKey: string; runId: string; forceCompaction?: boolean; + includeThinking?: boolean; abortSignal?: AbortSignal; }) => Promise<{ summary?: string; diff --git a/packages/dmoss-agent/src/core/loop/compaction-timeout.ts b/packages/dmoss-agent/src/core/loop/compaction-timeout.ts new file mode 100644 index 0000000..be820c5 --- /dev/null +++ b/packages/dmoss-agent/src/core/loop/compaction-timeout.ts @@ -0,0 +1,63 @@ +const DEFAULT_COMPACTION_PREPARE_TIMEOUT_MS = 30_000; + +export function resolveCompactionPrepareTimeoutMs(): number { + const raw = Number(process.env.DMOSS_COMPACTION_PREPARE_TIMEOUT_MS); + if (Number.isFinite(raw) && raw > 0) { + return Math.max(1, Math.floor(raw)); + } + return DEFAULT_COMPACTION_PREPARE_TIMEOUT_MS; +} + +function toAbortError(label: string, reason?: unknown): Error { + if (reason instanceof Error) { + return reason; + } + const suffix = reason === undefined ? "" : `: ${String(reason)}`; + return new Error(`${label} compaction prepare aborted${suffix}`); +} + +export async function runWithCompactionPrepareTimeout( + task: (abortSignal?: AbortSignal) => Promise, + options: { + abortSignal?: AbortSignal; + timeoutMs?: number; + label?: string; + } = {}, +): Promise { + const label = options.label ?? "context"; + const timeoutMs = options.timeoutMs ?? resolveCompactionPrepareTimeoutMs(); + + if (options.abortSignal?.aborted) { + throw toAbortError(label, options.abortSignal.reason); + } + + const timeoutController = new AbortController(); + const timeoutError = new Error( + `${label} compaction prepare timed out after ${timeoutMs}ms`, + ); + const signal = options.abortSignal + ? AbortSignal.any([options.abortSignal, timeoutController.signal]) + : timeoutController.signal; + if (signal.aborted) { + throw toAbortError(label, signal.reason); + } + + let onAbort: (() => void) | undefined; + const abortPromise = new Promise((_, reject) => { + onAbort = () => reject(toAbortError(label, signal.reason)); + signal.addEventListener("abort", onAbort, { once: true }); + }); + + const timer = setTimeout(() => { + timeoutController.abort(timeoutError); + }, timeoutMs); + + try { + return await Promise.race([task(signal), abortPromise]); + } finally { + clearTimeout(timer); + if (onAbort) { + signal.removeEventListener("abort", onAbort); + } + } +} diff --git a/packages/dmoss-agent/src/core/loop/overflow-recovery.ts b/packages/dmoss-agent/src/core/loop/overflow-recovery.ts index 5a93eed..69fe699 100644 --- a/packages/dmoss-agent/src/core/loop/overflow-recovery.ts +++ b/packages/dmoss-agent/src/core/loop/overflow-recovery.ts @@ -28,6 +28,7 @@ import { microcompact } from '../../context/microcompact.js'; import { estimateMessagesChars, estimateMessagesTokens } from '../../context/tokens.js'; import { describeError } from '../../provider/errors.js'; import { getRootLogger } from '../../logger.js'; +import { runWithCompactionPrepareTimeout } from './compaction-timeout.js'; const log = getRootLogger().child('agent:overflow'); @@ -460,6 +461,8 @@ export async function runOverflowRecovery( skipFusedLlmSummarize(state); } else { state.compactionOverflowRetries++; + let preHookRan = false; + let postHookRan = false; try { await compactHooks?.runPreHooks({ sessionKey, @@ -467,13 +470,18 @@ export async function runOverflowRecovery( messages: currentMessages, reason: 'overflow', }); - const overflowPrep = await prepareCompaction({ - messages: currentMessages, - sessionKey, - runId, - forceCompaction: true, - abortSignal, - }); + preHookRan = true; + const overflowPrep = await runWithCompactionPrepareTimeout( + (prepareAbortSignal) => + prepareCompaction({ + messages: currentMessages, + sessionKey, + runId, + forceCompaction: true, + abortSignal: prepareAbortSignal, + }), + { abortSignal, label: 'overflow' }, + ); const checkpointOutline = overflowPrep.checkpointOutline ?? buildCompactionCheckpointOutline(overflowPrep.summary); const droppedMessages = Math.max(0, Number(overflowPrep.droppedMessages ?? 0)); @@ -486,6 +494,7 @@ export async function runOverflowRecovery( success: Boolean(overflowPrep.summary && overflowPrep.summaryMessage), ...(checkpointOutline ? { checkpointOutline } : {}), }); + postHookRan = true; if (overflowPrep.summary && overflowPrep.summaryMessage) { // If aborted after prepareCompaction returned, do NOT mutate currentMessages. if (abortSignal?.aborted) { @@ -508,6 +517,22 @@ export async function runOverflowRecovery( : { kind: 'retry-same-turn', replacedSummaryMessage: overflowPrep.summaryMessage }; } } catch (compactErr) { + if (preHookRan && !postHookRan) { + try { + await compactHooks?.runPostHooks({ + sessionKey, + runId, + summaryChars: 0, + droppedMessages: 0, + reason: 'overflow', + success: false, + }); + } catch (hookErr) { + log.warn('post compaction hook failed during overflow recovery', { + error: describeError(hookErr), + }); + } + } const failureStreak = markLlmCompactionFailed(state); log.warn('prepareCompaction failed during overflow recovery', { error: describeError(compactErr),