让 compactor 尊重 tool_use/tool_result 配对边界 (B3)

cutoff 只允许落在 role==user 的消息上,避免把 assistant 的 toolCall 和下一条
toolResult 切开导致 provider 拿到悬挂 id 崩溃。找不到安全边界就直接不压缩,
let it crash 给上游 token 窗口兜底,不伪造假 tool_result。
This commit is contained in:
ZacharyZhang-NY committed 2026-04-20 10:58:49 -04:00
1 parent 3514ede639
commit 4ff751b3af
1 file changed
+35 -1
+35 -1
View File
@@ -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) {