让 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:
1 parent
3514ede639
commit
4ff751b3af
1 file changed
+35
-1
@@ -48,6 +48,14 @@ export interface CompactionOutcome {
|
|||||||
* Returns a compacted message list when the incoming transcript
|
* Returns a compacted message list when the incoming transcript
|
||||||
* exceeds the budget, or `{ changed: false }` when no compaction is
|
* exceeds the budget, or `{ changed: false }` when no compaction is
|
||||||
* needed. The transformContext hook forwards the result verbatim.
|
* 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 {
|
export function compactMessagesIfNeeded(messages: AgentMessage[]): CompactionOutcome {
|
||||||
const estimatedTokens = estimateTokens(messages);
|
const estimatedTokens = estimateTokens(messages);
|
||||||
@@ -58,7 +66,12 @@ export function compactMessagesIfNeeded(messages: AgentMessage[]): CompactionOut
|
|||||||
return { changed: false, messages: null };
|
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 older = messages.slice(0, cutoff);
|
||||||
const recent = messages.slice(cutoff);
|
const recent = messages.slice(cutoff);
|
||||||
|
|
||||||
@@ -72,6 +85,27 @@ export function compactMessagesIfNeeded(messages: AgentMessage[]): CompactionOut
|
|||||||
return { changed: true, messages: [summary, ...recent] };
|
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 {
|
function estimateTokens(messages: AgentMessage[]): number {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
|
|||||||
Reference in new issue
Block a user