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:
parent
01e0d3a320
commit
79b5a840f2
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -24,6 +24,7 @@ export {
|
|||||||
isFailoverErrorMessage,
|
isFailoverErrorMessage,
|
||||||
isImageDimensionErrorMessage,
|
isImageDimensionErrorMessage,
|
||||||
isImageSizeError,
|
isImageSizeError,
|
||||||
|
isOrphanToolResultError,
|
||||||
isOverloadedErrorMessage,
|
isOverloadedErrorMessage,
|
||||||
isRawApiErrorPayload,
|
isRawApiErrorPayload,
|
||||||
isRateLimitAssistantError,
|
isRateLimitAssistantError,
|
||||||
|
|||||||
@ -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 ?? "");
|
||||||
|
|||||||
@ -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}`,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user