fix: auto-repair and retry on orphan tool_result errors

When the Anthropic API rejects a request with 'unexpected tool_use_id found
in tool_result blocks', this indicates corrupted session history where a
tool_result references a tool_use that doesn't exist in the previous message.

This can happen due to:
- Race conditions in message processing
- Partial saves during crashes
- History truncation that removes tool_use but keeps tool_result

Changes:
- Add isOrphanToolResultError() to detect this specific error pattern
- Add retry logic in runEmbeddedAttempt: when this error is caught,
  repair the transcript using repairToolUseResultPairing() and retry once
- Log repair details (orphans dropped, duplicates dropped, synthetic results added)

This allows sessions to self-recover from corrupted history without requiring
a manual session reset.

Closes #XXXX
This commit is contained in:
Sam Hotchkiss 2026-01-28 06:33:42 -07:00
parent 01e0d3a320
commit 79b5a840f2
4 changed files with 93 additions and 6 deletions

View File

@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { isOrphanToolResultError } from "./pi-embedded-helpers.js";
describe("isOrphanToolResultError", () => {
it("detects 'unexpected tool_use_id' error message", () => {
const error =
"LLM request rejected: messages.60.content.1: unexpected tool_use_id found in tool_result blocks: toolu_01KTTwhMaCYW8oiwZMxx6WDt. Each tool_result block must have a corresponding tool_use block in the previous message.";
expect(isOrphanToolResultError(error)).toBe(true);
});
it("detects tool_use_id with tool_result in message", () => {
const error = "Invalid request: tool_use_id in tool_result does not match any tool_use";
expect(isOrphanToolResultError(error)).toBe(true);
});
it("detects 'tool_result does not have corresponding tool_use' pattern", () => {
const error =
"Error: tool_result block does not have a corresponding tool_use block in previous message";
expect(isOrphanToolResultError(error)).toBe(true);
});
it("returns false for unrelated errors", () => {
expect(isOrphanToolResultError("rate limit exceeded")).toBe(false);
expect(isOrphanToolResultError("authentication failed")).toBe(false);
expect(isOrphanToolResultError("context window exceeded")).toBe(false);
});
it("returns false for empty or null input", () => {
expect(isOrphanToolResultError("")).toBe(false);
expect(isOrphanToolResultError(null as unknown as string)).toBe(false);
expect(isOrphanToolResultError(undefined as unknown as string)).toBe(false);
});
it("is case-insensitive", () => {
const error = "UNEXPECTED TOOL_USE_ID found in TOOL_RESULT blocks";
expect(isOrphanToolResultError(error)).toBe(true);
});
});

View File

@ -24,6 +24,7 @@ export {
isFailoverErrorMessage, isFailoverErrorMessage,
isImageDimensionErrorMessage, isImageDimensionErrorMessage,
isImageSizeError, isImageSizeError,
isOrphanToolResultError,
isOverloadedErrorMessage, isOverloadedErrorMessage,
isRawApiErrorPayload, isRawApiErrorPayload,
isRateLimitAssistantError, isRateLimitAssistantError,

View File

@ -491,6 +491,24 @@ export function isCloudCodeAssistFormatError(raw: string): boolean {
return !isImageDimensionErrorMessage(raw) && matchesErrorPatterns(raw, ERROR_PATTERNS.format); return !isImageDimensionErrorMessage(raw) && matchesErrorPatterns(raw, ERROR_PATTERNS.format);
} }
/**
* Detects the specific "unexpected tool_use_id found in tool_result blocks" error
* from Anthropic. This error indicates orphaned tool_result entries in the transcript
* that reference tool_use IDs not present in the previous assistant message.
*
* When detected, the session history should be repaired using repairToolUseResultPairing()
* and the request retried.
*/
export function isOrphanToolResultError(raw: string): boolean {
if (!raw) return false;
const lower = raw.toLowerCase();
return (
lower.includes("unexpected tool_use_id") ||
(lower.includes("tool_use_id") && lower.includes("tool_result")) ||
/tool_result.*does not have.*corresponding.*tool_use/i.test(raw)
);
}
export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean { export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean {
if (!msg || msg.stopReason !== "error") return false; if (!msg || msg.stopReason !== "error") return false;
return isAuthErrorMessage(msg.errorMessage ?? ""); return isAuthErrorMessage(msg.errorMessage ?? "");

View File

@ -29,10 +29,12 @@ import { resolveMoltbotDocsPath } from "../../docs-path.js";
import { resolveModelAuthMode } from "../../model-auth.js"; import { resolveModelAuthMode } from "../../model-auth.js";
import { import {
isCloudCodeAssistFormatError, isCloudCodeAssistFormatError,
isOrphanToolResultError,
resolveBootstrapMaxChars, resolveBootstrapMaxChars,
validateAnthropicTurns, validateAnthropicTurns,
validateGeminiTurns, validateGeminiTurns,
} from "../../pi-embedded-helpers.js"; } from "../../pi-embedded-helpers.js";
import { repairToolUseResultPairing } from "../../session-transcript-repair.js";
import { subscribeEmbeddedPiSession } from "../../pi-embedded-subscribe.js"; import { subscribeEmbeddedPiSession } from "../../pi-embedded-subscribe.js";
import { import {
ensurePiCompactionReserveTokens, ensurePiCompactionReserveTokens,
@ -778,13 +780,41 @@ export async function runEmbeddedAttempt(
// Only pass images option if there are actually images to pass // Only pass images option if there are actually images to pass
// This avoids potential issues with models that don't expect the images parameter // This avoids potential issues with models that don't expect the images parameter
if (imageResult.images.length > 0) { const runPrompt = async () => {
await abortable(activeSession.prompt(effectivePrompt, { images: imageResult.images })); if (imageResult.images.length > 0) {
} else { await abortable(activeSession.prompt(effectivePrompt, { images: imageResult.images }));
await abortable(activeSession.prompt(effectivePrompt)); } else {
await abortable(activeSession.prompt(effectivePrompt));
}
};
try {
await runPrompt();
} catch (initialErr) {
// Check if error is due to orphaned tool_result entries in transcript.
// If so, repair the history and retry once before failing.
const errMsg = initialErr instanceof Error ? initialErr.message : String(initialErr);
if (isOrphanToolResultError(errMsg)) {
log.warn(
`Orphan tool_result error detected, repairing transcript and retrying: runId=${params.runId} sessionId=${params.sessionId}`,
);
try {
const currentMessages = activeSession.messages;
const repaired = repairToolUseResultPairing(currentMessages);
if (repaired.messages !== currentMessages) {
activeSession.agent.replaceMessages(repaired.messages);
log.debug(
`Transcript repair applied: dropped=${repaired.droppedOrphanCount} orphans, ${repaired.droppedDuplicateCount} duplicates, added=${repaired.added.length} synthetic results`,
);
}
await runPrompt();
} catch (retryErr) {
promptError = retryErr;
}
} else {
promptError = initialErr;
}
} }
} catch (err) {
promptError = err;
} finally { } finally {
log.debug( log.debug(
`embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`, `embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`,