fix: deduplicate tool_use IDs and enable sanitization for Anthropic
Anthropic API rejects requests with duplicate tool_use IDs across messages. This can happen when: 1. Session transcripts have multiple assistant messages with the same tool_use ID 2. IDs contain special characters that weren't being sanitized for Anthropic Changes: - Add deduplication logic in repairToolUseResultPairing() to detect and rename duplicate tool_use IDs in assistant messages (e.g., call_1 -> call_1_2) - Update corresponding toolResult IDs to match the remapped tool_use IDs - Enable sanitizeToolCallIds for Anthropic provider (was only Google/Mistral) - Add tests for deduplication scenarios Fixes error: "messages.X.content.Y: tool_use ids must be unique"
This commit is contained in:
parent
da71eaebd2
commit
e2ed93f3db
@ -109,4 +109,97 @@ describe("sanitizeToolUseResultPairing", () => {
|
|||||||
expect(out.some((m) => m.role === "toolResult")).toBe(false);
|
expect(out.some((m) => m.role === "toolResult")).toBe(false);
|
||||||
expect(out.map((m) => m.role)).toEqual(["user", "assistant"]);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -75,9 +75,11 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep
|
|||||||
// - moving matching toolResult messages directly after their assistant toolCall turn
|
// - moving matching toolResult messages directly after their assistant toolCall turn
|
||||||
// - inserting synthetic error toolResults for missing ids
|
// - inserting synthetic error toolResults for missing ids
|
||||||
// - dropping duplicate toolResults for the same id (anywhere in the transcript)
|
// - 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 out: AgentMessage[] = [];
|
||||||
const added: Array<Extract<AgentMessage, { role: "toolResult" }>> = [];
|
const added: Array<Extract<AgentMessage, { role: "toolResult" }>> = [];
|
||||||
const seenToolResultIds = new Set<string>();
|
const seenToolResultIds = new Set<string>();
|
||||||
|
const seenToolUseIds = new Set<string>();
|
||||||
let droppedDuplicateCount = 0;
|
let droppedDuplicateCount = 0;
|
||||||
let droppedOrphanCount = 0;
|
let droppedOrphanCount = 0;
|
||||||
let moved = false;
|
let moved = false;
|
||||||
@ -94,6 +96,23 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep
|
|||||||
out.push(msg);
|
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) {
|
for (let i = 0; i < messages.length; i += 1) {
|
||||||
const msg = messages[i] as AgentMessage;
|
const msg = messages[i] as AgentMessage;
|
||||||
if (!msg || typeof msg !== "object") {
|
if (!msg || typeof msg !== "object") {
|
||||||
@ -122,7 +141,43 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep
|
|||||||
continue;
|
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<string, string>();
|
||||||
|
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<string, unknown>), 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<string, Extract<AgentMessage, { role: "toolResult" }>>();
|
const spanResultsById = new Map<string, Extract<AgentMessage, { role: "toolResult" }>>();
|
||||||
const remainder: AgentMessage[] = [];
|
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) {
|
if (spanResultsById.size > 0 && remainder.length > 0) {
|
||||||
moved = true;
|
moved = true;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const call of toolCalls) {
|
for (const call of effectiveToolCalls) {
|
||||||
const existing = spanResultsById.get(call.id);
|
const existing = spanResultsById.get(call.originalId);
|
||||||
if (existing) {
|
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<AgentMessage, { role: "toolResult" }>;
|
||||||
|
pushToolResult(remappedResult);
|
||||||
|
} else {
|
||||||
|
pushToolResult(existing);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const missing = makeMissingToolResult({
|
const missing = makeMissingToolResult({
|
||||||
toolCallId: call.id,
|
toolCallId: call.id,
|
||||||
|
|||||||
@ -85,7 +85,7 @@ export function resolveTranscriptPolicy(params: {
|
|||||||
|
|
||||||
const needsNonImageSanitize = isGoogle || isAnthropic || isMistral || isOpenRouterGemini;
|
const needsNonImageSanitize = isGoogle || isAnthropic || isMistral || isOpenRouterGemini;
|
||||||
|
|
||||||
const sanitizeToolCallIds = isGoogle || isMistral;
|
const sanitizeToolCallIds = isGoogle || isMistral || isAnthropic;
|
||||||
const toolCallIdMode: ToolCallIdMode | undefined = isMistral
|
const toolCallIdMode: ToolCallIdMode | undefined = isMistral
|
||||||
? "strict9"
|
? "strict9"
|
||||||
: sanitizeToolCallIds
|
: sanitizeToolCallIds
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user