Merge dd11bd00f2 into 09be5d45d5
This commit is contained in:
commit
99f698187e
@ -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 }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user