diff --git a/src/agents/pi-embedded-helpers.isorphantoolresulterror.test.ts b/src/agents/pi-embedded-helpers.isorphantoolresulterror.test.ts new file mode 100644 index 000000000..b8a06c64d --- /dev/null +++ b/src/agents/pi-embedded-helpers.isorphantoolresulterror.test.ts @@ -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); + }); +}); diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 88443756f..a8ab64353 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -24,6 +24,7 @@ export { isFailoverErrorMessage, isImageDimensionErrorMessage, isImageSizeError, + isOrphanToolResultError, isOverloadedErrorMessage, isRawApiErrorPayload, isRateLimitAssistantError, diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 849c4293e..c379ef22f 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -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 ?? ""); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 46a53bd8f..e3530e62f 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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}`,