This commit is contained in:
Marcelo Mendes 2026-01-30 23:49:54 +08:00 committed by GitHub
commit 99f698187e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 166 additions and 8 deletions

View File

@ -76,7 +76,7 @@ describe("sanitizeSessionHistory", () => {
);
});
it("does not sanitize tool call ids for non-Google APIs", async () => {
it("sanitizes tool call ids for Anthropic APIs", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
await sanitizeSessionHistory({
@ -90,7 +90,7 @@ describe("sanitizeSessionHistory", () => {
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
mockMessages,
"session:history",
expect.objectContaining({ sanitizeMode: "full", sanitizeToolCallIds: false }),
expect.objectContaining({ sanitizeMode: "full", sanitizeToolCallIds: true }),
);
});

View File

@ -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);
});
});

View File

@ -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<Extract<AgentMessage, { role: "toolResult" }>> = [];
const seenToolResultIds = new Set<string>();
const seenToolUseIds = new Set<string>();
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<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 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<AgentMessage, { role: "toolResult" }>;
pushToolResult(remappedResult);
} else {
pushToolResult(existing);
}
} else {
const missing = makeMissingToolResult({
toolCallId: call.id,

View File

@ -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