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,
|
||||
isImageDimensionErrorMessage,
|
||||
isImageSizeError,
|
||||
isOrphanToolResultError,
|
||||
isOverloadedErrorMessage,
|
||||
isRawApiErrorPayload,
|
||||
isRateLimitAssistantError,
|
||||
|
||||
@ -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 ?? "");
|
||||
|
||||
@ -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}`,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user