diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index ccc63ec7f..d9422f969 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -109,4 +109,97 @@ describe("sanitizeToolUseResultPairing", () => { expect(out.some((m) => m.role === "toolResult")).toBe(false); expect(out.map((m) => m.role)).toEqual(["user", "assistant"]); }); + + it("deduplicates tool_use IDs across assistant messages", () => { + // This test ensures that duplicate tool_use IDs in different assistant messages + // are remapped to unique IDs (Anthropic requires unique tool_use IDs) + const input = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "first result" }], + isError: false, + }, + { role: "user", content: "do it again" }, + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], // Duplicate ID + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "second result" }], + isError: false, + }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(input); + + // Both tool calls should exist but with unique IDs + const assistants = out.filter((m) => m.role === "assistant") as Array<{ + content?: Array<{ type?: string; id?: string }>; + }>; + expect(assistants).toHaveLength(2); + + const firstToolCallId = assistants[0]?.content?.[0]?.id; + const secondToolCallId = assistants[1]?.content?.[0]?.id; + + // First ID should remain unchanged + expect(firstToolCallId).toBe("call_1"); + // Second ID should be remapped to be unique + expect(secondToolCallId).not.toBe("call_1"); + expect(secondToolCallId).toBe("call_1_2"); + + // Tool results should have matching IDs + const results = out.filter((m) => m.role === "toolResult") as Array<{ + toolCallId?: string; + }>; + expect(results).toHaveLength(2); + expect(results[0]?.toolCallId).toBe(firstToolCallId); + expect(results[1]?.toolCallId).toBe(secondToolCallId); + }); + + it("handles toolUse type blocks with duplicate IDs", () => { + const input = [ + { + role: "assistant", + content: [{ type: "toolUse", id: "toolu_1", name: "exec", input: {} }], + }, + { + role: "toolResult", + toolCallId: "toolu_1", + toolName: "exec", + content: [{ type: "text", text: "ok" }], + isError: false, + }, + { role: "user", content: "again" }, + { + role: "assistant", + content: [{ type: "toolUse", id: "toolu_1", name: "exec", input: {} }], // Duplicate + }, + { + role: "toolResult", + toolCallId: "toolu_1", + toolName: "exec", + content: [{ type: "text", text: "ok again" }], + isError: false, + }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(input); + + const assistants = out.filter((m) => m.role === "assistant") as Array<{ + content?: Array<{ type?: string; id?: string }>; + }>; + const ids = assistants.map((a) => a.content?.[0]?.id); + + // All IDs should be unique + expect(new Set(ids).size).toBe(ids.length); + }); }); diff --git a/src/agents/session-transcript-repair.ts b/src/agents/session-transcript-repair.ts index 2728bb89c..9b534f70d 100644 --- a/src/agents/session-transcript-repair.ts +++ b/src/agents/session-transcript-repair.ts @@ -75,9 +75,11 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep // - moving matching toolResult messages directly after their assistant toolCall turn // - inserting synthetic error toolResults for missing ids // - dropping duplicate toolResults for the same id (anywhere in the transcript) + // - deduplicating tool_use IDs in assistant messages (Anthropic requires unique IDs) const out: AgentMessage[] = []; const added: Array> = []; const seenToolResultIds = new Set(); + const seenToolUseIds = new Set(); let droppedDuplicateCount = 0; let droppedOrphanCount = 0; let moved = false; @@ -94,6 +96,23 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep out.push(msg); }; + // Deduplicate tool_use IDs in assistant messages by generating unique IDs for collisions + const deduplicateToolUseId = (id: string): string => { + if (!seenToolUseIds.has(id)) { + seenToolUseIds.add(id); + return id; + } + // Generate a unique ID by appending a counter + let counter = 2; + let newId = `${id}_${counter}`; + while (seenToolUseIds.has(newId)) { + counter += 1; + newId = `${id}_${counter}`; + } + seenToolUseIds.add(newId); + return newId; + }; + for (let i = 0; i < messages.length; i += 1) { const msg = messages[i] as AgentMessage; if (!msg || typeof msg !== "object") { @@ -122,7 +141,43 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep continue; } - const toolCallIds = new Set(toolCalls.map((t) => t.id)); + // Check for duplicate tool_use IDs and remap them if necessary + const idRemapping = new Map(); + let assistantNeedsRewrite = false; + for (const call of toolCalls) { + const newId = deduplicateToolUseId(call.id); + if (newId !== call.id) { + idRemapping.set(call.id, newId); + assistantNeedsRewrite = true; + changed = true; + } + } + + // Rewrite assistant message if any tool_use IDs were deduplicated + let processedAssistant = assistant; + if (assistantNeedsRewrite && Array.isArray(assistant.content)) { + const newContent = assistant.content.map((block) => { + if (!block || typeof block !== "object") return block; + const rec = block as { type?: unknown; id?: unknown }; + if ( + (rec.type === "toolCall" || rec.type === "toolUse" || rec.type === "functionCall") && + typeof rec.id === "string" && + idRemapping.has(rec.id) + ) { + return { ...(block as unknown as Record), id: idRemapping.get(rec.id) }; + } + return block; + }); + processedAssistant = { ...assistant, content: newContent as typeof assistant.content }; + } + + // Update toolCalls with remapped IDs for matching + const effectiveToolCalls = toolCalls.map((call) => ({ + ...call, + id: idRemapping.get(call.id) ?? call.id, + originalId: call.id, + })); + const toolCallIds = new Set(effectiveToolCalls.map((t) => t.originalId)); const spanResultsById = new Map>(); const remainder: AgentMessage[] = []; @@ -163,17 +218,27 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep } } - out.push(msg); + out.push(processedAssistant); if (spanResultsById.size > 0 && remainder.length > 0) { moved = true; changed = true; } - for (const call of toolCalls) { - const existing = spanResultsById.get(call.id); + for (const call of effectiveToolCalls) { + const existing = spanResultsById.get(call.originalId); if (existing) { - pushToolResult(existing); + // Remap toolResult ID if the tool_use ID was deduplicated + const remappedId = idRemapping.get(call.originalId); + if (remappedId) { + const remappedResult = { + ...existing, + toolCallId: remappedId, + } as Extract; + pushToolResult(remappedResult); + } else { + pushToolResult(existing); + } } else { const missing = makeMissingToolResult({ toolCallId: call.id, diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 9ae14d38f..1f135a64b 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -85,7 +85,7 @@ export function resolveTranscriptPolicy(params: { const needsNonImageSanitize = isGoogle || isAnthropic || isMistral || isOpenRouterGemini; - const sanitizeToolCallIds = isGoogle || isMistral; + const sanitizeToolCallIds = isGoogle || isMistral || isAnthropic; const toolCallIdMode: ToolCallIdMode | undefined = isMistral ? "strict9" : sanitizeToolCallIds