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,
isImageDimensionErrorMessage,
isImageSizeError,
isOrphanToolResultError,
isOverloadedErrorMessage,
isRawApiErrorPayload,
isRateLimitAssistantError,

View File

@ -491,6 +491,24 @@ export function isCloudCodeAssistFormatError(raw: string): boolean {
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 {
if (!msg || msg.stopReason !== "error") return false;
return isAuthErrorMessage(msg.errorMessage ?? "");

View File

@ -29,10 +29,12 @@ import { resolveMoltbotDocsPath } from "../../docs-path.js";
import { resolveModelAuthMode } from "../../model-auth.js";
import {
isCloudCodeAssistFormatError,
isOrphanToolResultError,
resolveBootstrapMaxChars,
validateAnthropicTurns,
validateGeminiTurns,
} from "../../pi-embedded-helpers.js";
import { repairToolUseResultPairing } from "../../session-transcript-repair.js";
import { subscribeEmbeddedPiSession } from "../../pi-embedded-subscribe.js";
import {
ensurePiCompactionReserveTokens,
@ -778,13 +780,41 @@ export async function runEmbeddedAttempt(
// Only pass images option if there are actually images to pass
// This avoids potential issues with models that don't expect the images parameter
if (imageResult.images.length > 0) {
await abortable(activeSession.prompt(effectivePrompt, { images: imageResult.images }));
} else {
await abortable(activeSession.prompt(effectivePrompt));
const runPrompt = async () => {
if (imageResult.images.length > 0) {
await abortable(activeSession.prompt(effectivePrompt, { images: imageResult.images }));
} 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 {
log.debug(
`embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`,