From 4ff751b3af188400143dd5e66cd32bc911bf8f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 20 Apr 2026 10:58:49 -0400 Subject: [PATCH] =?UTF-8?q?=E8=AE=A9=20compactor=20=E5=B0=8A=E9=87=8D=20to?= =?UTF-8?q?ol=5Fuse/tool=5Fresult=20=E9=85=8D=E5=AF=B9=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=20(B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cutoff 只允许落在 role==user 的消息上,避免把 assistant 的 toolCall 和下一条 toolResult 切开导致 provider 拿到悬挂 id 崩溃。找不到安全边界就直接不压缩, let it crash 给上游 token 窗口兜底,不伪造假 tool_result。 --- src/main/agent-runtime/context-compactor.ts | 36 ++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/main/agent-runtime/context-compactor.ts b/src/main/agent-runtime/context-compactor.ts index a5f50637..0b89bd9e 100644 --- a/src/main/agent-runtime/context-compactor.ts +++ b/src/main/agent-runtime/context-compactor.ts @@ -48,6 +48,14 @@ export interface CompactionOutcome { * Returns a compacted message list when the incoming transcript * exceeds the budget, or `{ changed: false }` when no compaction is * needed. The transformContext hook forwards the result verbatim. + * + * Cutoff must land on a user-turn boundary so we never orphan a + * `toolResult` from its originating assistant `toolCall`. pi-ai's + * message shape pairs assistant-with-toolCall blocks against standalone + * `{ role: "toolResult", toolCallId }` messages — cutting mid-pair + * would leave the provider with a dangling id and crash the next call. + * When no safe boundary exists within reach, we decline to compact and + * let the upstream token budget handle it. */ export function compactMessagesIfNeeded(messages: AgentMessage[]): CompactionOutcome { const estimatedTokens = estimateTokens(messages); @@ -58,7 +66,12 @@ export function compactMessagesIfNeeded(messages: AgentMessage[]): CompactionOut return { changed: false, messages: null }; } - const cutoff = messages.length - RECENT_MESSAGE_TAIL; + const desiredCutoff = messages.length - RECENT_MESSAGE_TAIL; + const cutoff = findSafeCutoff(messages, desiredCutoff); + if (cutoff === null) { + return { changed: false, messages: null }; + } + const older = messages.slice(0, cutoff); const recent = messages.slice(cutoff); @@ -72,6 +85,27 @@ export function compactMessagesIfNeeded(messages: AgentMessage[]): CompactionOut return { changed: true, messages: [summary, ...recent] }; } +/** + * Walk back from `desiredCutoff` until we find an index where the + * message at that position starts a clean user turn. A user turn is + * identifiable by `role === "user"`; anything else (`assistant`, + * `toolResult`, or custom app message) can reference or be referenced + * by neighbouring messages and must stay grouped. + * + * Returns `null` when no safe boundary exists in [1, desiredCutoff] — + * e.g. an entirely tool-chained transcript. Returning null signals "do + * not compact"; callers must pass the transcript through unchanged. + */ +function findSafeCutoff(messages: AgentMessage[], desiredCutoff: number): number | null { + for (let i = desiredCutoff; i >= 1; i--) { + const raw = messages[i] as MessageLike | undefined; + if (raw && raw.role === "user") { + return i; + } + } + return null; +} + function estimateTokens(messages: AgentMessage[]): number { let total = 0; for (const message of messages) {