Skip to content

Commit de0440d

Browse files
author
MuhlmannAI
committed
fix(turns): deduplicate toolResult blocks in mergeConsecutiveUserTurns
When consecutive user messages are merged for Anthropic's alternating turn requirement, duplicate toolResult blocks with the same toolUseId could appear. This causes Anthropic API to reject with: 'each tool_use must have a single result. Found multiple tool_result blocks with id: <id>' Root cause: retry logic ('Continue where you left off') injects a second user message containing the same tool_result. The naive array concat in mergeConsecutiveUserTurns creates duplicates. Fix: filter by seen toolUseId set, keeping the first occurrence.
1 parent ba3df5b commit de0440d

File tree

2 files changed

+89
-1
lines changed

2 files changed

+89
-1
lines changed

src/agents/pi-embedded-helpers.validate-turns.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,4 +511,77 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => {
511511
const result = validateAnthropicTurns(msgs);
512512
expect(result).toHaveLength(3);
513513
});
514+
515+
it("deduplicates toolResult blocks with the same toolUseId when merging consecutive user messages", () => {
516+
// Simulates the "Continue where you left off" retry scenario:
517+
// two consecutive user messages both contain a toolResult for the same tool call
518+
const msgs = asMessages([
519+
{ role: "user", content: [{ type: "text", text: "Do something" }] },
520+
{
521+
role: "assistant",
522+
content: [{ type: "toolUse", id: "tool-abc", name: "read", input: {} }],
523+
},
524+
// First user message with tool result
525+
{
526+
role: "user",
527+
content: [{ type: "toolResult", toolUseId: "tool-abc", text: "file contents" }],
528+
},
529+
// Second user message (retry) with the SAME tool result
530+
{
531+
role: "user",
532+
content: [
533+
{ type: "toolResult", toolUseId: "tool-abc", text: "file contents" },
534+
{ type: "text", text: "Continue where you left off." },
535+
],
536+
},
537+
]);
538+
539+
const result = validateAnthropicTurns(msgs);
540+
541+
// The two consecutive user messages should be merged into one
542+
expect(result).toHaveLength(3); // user, assistant, merged-user
543+
544+
const mergedUser = result[2];
545+
expect(mergedUser.role).toBe("user");
546+
const content = mergedUser.content as Array<{ type: string; toolUseId?: string }>;
547+
548+
// Should have exactly ONE toolResult for "tool-abc", plus the text block
549+
const toolResults = content.filter((b) => b.type === "toolResult");
550+
expect(toolResults).toHaveLength(1);
551+
expect(toolResults[0].toolUseId).toBe("tool-abc");
552+
553+
// The text block should still be there
554+
const textBlocks = content.filter((b) => b.type === "text");
555+
expect(textBlocks.length).toBeGreaterThanOrEqual(1);
556+
});
557+
558+
it("keeps distinct toolResult blocks when merging consecutive user messages", () => {
559+
// Two consecutive user messages with DIFFERENT tool results should both be kept
560+
const msgs = asMessages([
561+
{ role: "user", content: [{ type: "text", text: "Do something" }] },
562+
{
563+
role: "assistant",
564+
content: [
565+
{ type: "toolUse", id: "tool-1", name: "read", input: {} },
566+
{ type: "toolUse", id: "tool-2", name: "exec", input: {} },
567+
],
568+
},
569+
{
570+
role: "user",
571+
content: [{ type: "toolResult", toolUseId: "tool-1", text: "result 1" }],
572+
},
573+
{
574+
role: "user",
575+
content: [{ type: "toolResult", toolUseId: "tool-2", text: "result 2" }],
576+
},
577+
]);
578+
579+
const result = validateAnthropicTurns(msgs);
580+
const mergedUser = result[2];
581+
const content = mergedUser.content as Array<{ type: string; toolUseId?: string }>;
582+
const toolResults = content.filter((b) => b.type === "toolResult");
583+
expect(toolResults).toHaveLength(2);
584+
expect(toolResults[0].toolUseId).toBe("tool-1");
585+
expect(toolResults[1].toolUseId).toBe("tool-2");
586+
});
514587
});

src/agents/pi-embedded-helpers/turns.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,11 +171,26 @@ export function mergeConsecutiveUserTurns(
171171
previous: Extract<AgentMessage, { role: "user" }>,
172172
current: Extract<AgentMessage, { role: "user" }>,
173173
): Extract<AgentMessage, { role: "user" }> {
174-
const mergedContent = [
174+
const rawContent = [
175175
...(Array.isArray(previous.content) ? previous.content : []),
176176
...(Array.isArray(current.content) ? current.content : []),
177177
];
178178

179+
// Deduplicate toolResult blocks by toolUseId to prevent
180+
// "each tool_use must have a single result" Anthropic API errors.
181+
// This can happen when retry logic ("Continue where you left off")
182+
// injects a second user message containing the same tool_result.
183+
const seenToolUseIds = new Set<string>();
184+
const mergedContent = rawContent.filter((block) => {
185+
if (block && block.type === "toolResult" && block.toolUseId) {
186+
if (seenToolUseIds.has(block.toolUseId)) {
187+
return false;
188+
}
189+
seenToolUseIds.add(block.toolUseId);
190+
}
191+
return true;
192+
});
193+
179194
return {
180195
...current,
181196
content: mergedContent,

0 commit comments

Comments
 (0)