fix(history): re-repair tool_use/tool_result pairing after truncation

limitHistoryTurns() can slice in the middle of a tool_use/tool_result
pair, leaving orphaned tool_result blocks that the Anthropic API
rejects with "unexpected tool_use_id found in tool_result blocks".

Call sanitizeToolUseResultPairing() after truncation (only when
truncation actually removed messages) to repair any broken pairs.
Applied to both attempt.ts and compact.ts code paths.

Fixes #4367
This commit is contained in:
Ayush Ojha 2026-01-30 00:34:43 -08:00
parent 9025da2296
commit adfd063b37
2 changed files with 12 additions and 2 deletions

View File

@ -58,6 +58,7 @@ import {
sanitizeToolsForGoogle,
} from "./google.js";
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js";
import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js";
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
import { log } from "./logger.js";
import { buildModelAliasLines, resolveModel } from "./model.js";
@ -417,10 +418,14 @@ export async function compactEmbeddedPiSessionDirect(
const validated = transcriptPolicy.validateAnthropicTurns
? validateAnthropicTurns(validatedGemini)
: validatedGemini;
const limited = limitHistoryTurns(
const truncated = limitHistoryTurns(
validated,
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
);
// Re-repair tool_use/tool_result pairing after truncation, since
// limitHistoryTurns may slice in the middle of a paired exchange.
const limited =
truncated.length < validated.length ? sanitizeToolUseResultPairing(truncated) : truncated;
if (limited.length > 0) {
session.agent.replaceMessages(limited);
}

View File

@ -62,6 +62,7 @@ import {
sanitizeSessionHistory,
sanitizeToolsForGoogle,
} from "../google.js";
import { sanitizeToolUseResultPairing } from "../../session-transcript-repair.js";
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "../history.js";
import { log } from "../logger.js";
import { buildModelAliasLines } from "../model.js";
@ -531,10 +532,14 @@ export async function runEmbeddedAttempt(
const validated = transcriptPolicy.validateAnthropicTurns
? validateAnthropicTurns(validatedGemini)
: validatedGemini;
const limited = limitHistoryTurns(
const truncated = limitHistoryTurns(
validated,
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
);
// Re-repair tool_use/tool_result pairing after truncation, since
// limitHistoryTurns may slice in the middle of a paired exchange.
const limited =
truncated.length < validated.length ? sanitizeToolUseResultPairing(truncated) : truncated;
cacheTrace?.recordStage("session:limited", { messages: limited });
if (limited.length > 0) {
activeSession.agent.replaceMessages(limited);