Skip to content

Commit 8a68b04

Browse files
authored
fix: filter orphaned tool_results when more results than tool_uses (#10027)
1 parent 51dbccf commit 8a68b04

File tree

2 files changed

+141
-43
lines changed

2 files changed

+141
-43
lines changed

src/core/task/__tests__/validateToolResultIds.spec.ts

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ describe("validateAndFixToolResultIds", () => {
358358
})
359359

360360
describe("when there are more tool_results than tool_uses", () => {
361-
it("should leave extra tool_results unchanged", () => {
361+
it("should filter out orphaned tool_results with invalid IDs", () => {
362362
const assistantMessage: Anthropic.MessageParam = {
363363
role: "assistant",
364364
content: [
@@ -391,9 +391,96 @@ describe("validateAndFixToolResultIds", () => {
391391

392392
expect(Array.isArray(result.content)).toBe(true)
393393
const resultContent = result.content as Anthropic.ToolResultBlockParam[]
394+
// Only one tool_result should remain - the first one gets fixed to tool-1
395+
expect(resultContent.length).toBe(1)
394396
expect(resultContent[0].tool_use_id).toBe("tool-1")
395-
// Extra tool_result should remain unchanged
396-
expect(resultContent[1].tool_use_id).toBe("extra-id")
397+
})
398+
399+
it("should filter out duplicate tool_results when one already has a valid ID", () => {
400+
// This is the exact scenario from the PostHog error:
401+
// 2 tool_results (call_08230257, call_55577629), 1 tool_use (call_55577629)
402+
const assistantMessage: Anthropic.MessageParam = {
403+
role: "assistant",
404+
content: [
405+
{
406+
type: "tool_use",
407+
id: "call_55577629",
408+
name: "read_file",
409+
input: { path: "test.txt" },
410+
},
411+
],
412+
}
413+
414+
const userMessage: Anthropic.MessageParam = {
415+
role: "user",
416+
content: [
417+
{
418+
type: "tool_result",
419+
tool_use_id: "call_08230257", // Invalid ID
420+
content: "Content from first result",
421+
},
422+
{
423+
type: "tool_result",
424+
tool_use_id: "call_55577629", // Valid ID
425+
content: "Content from second result",
426+
},
427+
],
428+
}
429+
430+
const result = validateAndFixToolResultIds(userMessage, [assistantMessage])
431+
432+
expect(Array.isArray(result.content)).toBe(true)
433+
const resultContent = result.content as Anthropic.ToolResultBlockParam[]
434+
// Should only keep one tool_result since there's only one tool_use
435+
// The first invalid one gets fixed to the valid ID, then the second one
436+
// (which already has that ID) becomes a duplicate and is filtered out
437+
expect(resultContent.length).toBe(1)
438+
expect(resultContent[0].tool_use_id).toBe("call_55577629")
439+
})
440+
441+
it("should preserve text blocks while filtering orphaned tool_results", () => {
442+
const assistantMessage: Anthropic.MessageParam = {
443+
role: "assistant",
444+
content: [
445+
{
446+
type: "tool_use",
447+
id: "tool-1",
448+
name: "read_file",
449+
input: { path: "test.txt" },
450+
},
451+
],
452+
}
453+
454+
const userMessage: Anthropic.MessageParam = {
455+
role: "user",
456+
content: [
457+
{
458+
type: "tool_result",
459+
tool_use_id: "wrong-1",
460+
content: "Content 1",
461+
},
462+
{
463+
type: "text",
464+
text: "Some additional context",
465+
},
466+
{
467+
type: "tool_result",
468+
tool_use_id: "extra-id",
469+
content: "Content 2",
470+
},
471+
],
472+
}
473+
474+
const result = validateAndFixToolResultIds(userMessage, [assistantMessage])
475+
476+
expect(Array.isArray(result.content)).toBe(true)
477+
const resultContent = result.content as Array<Anthropic.ToolResultBlockParam | Anthropic.TextBlockParam>
478+
// Should have tool_result + text block, orphaned tool_result filtered out
479+
expect(resultContent.length).toBe(2)
480+
expect(resultContent[0].type).toBe("tool_result")
481+
expect((resultContent[0] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-1")
482+
expect(resultContent[1].type).toBe("text")
483+
expect((resultContent[1] as Anthropic.TextBlockParam).text).toBe("Some additional context")
397484
})
398485
})
399486

src/core/task/validateToolResultIds.ts

Lines changed: 51 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -139,35 +139,48 @@ export function validateAndFixToolResultIds(
139139
)
140140
}
141141

142-
// Start with corrected content - fix invalid IDs
143-
let correctedContent = userMessage.content.map((block) => {
144-
if (block.type !== "tool_result") {
145-
return block
146-
}
147-
148-
// If the ID is already valid, keep it
149-
if (validToolUseIds.has(block.tool_use_id)) {
150-
return block
151-
}
152-
153-
// Find which tool_result index this block is by comparing references.
154-
// This correctly handles duplicate tool_use_ids - we find the actual block's
155-
// position among all tool_results, not the first block with a matching ID.
156-
const toolResultIndex = toolResults.indexOf(block as Anthropic.ToolResultBlockParam)
157-
158-
// Try to match by position - only fix if there's a corresponding tool_use
159-
if (toolResultIndex !== -1 && toolResultIndex < toolUseBlocks.length) {
160-
const correctId = toolUseBlocks[toolResultIndex].id
161-
return {
162-
...block,
163-
tool_use_id: correctId,
142+
// Create a mapping of tool_result IDs to corrected IDs
143+
// Strategy: Match by position (first tool_result -> first tool_use, etc.)
144+
// This handles most cases where the mismatch is due to ID confusion
145+
//
146+
// Track which tool_use IDs have been used to prevent duplicates
147+
const usedToolUseIds = new Set<string>()
148+
149+
const correctedContent = userMessage.content
150+
.map((block) => {
151+
if (block.type !== "tool_result") {
152+
return block
164153
}
165-
}
166154

167-
// No corresponding tool_use for this tool_result - leave it unchanged
168-
// This can happen when there are more tool_results than tool_uses
169-
return block
170-
})
155+
// If the ID is already valid and not yet used, keep it
156+
if (validToolUseIds.has(block.tool_use_id) && !usedToolUseIds.has(block.tool_use_id)) {
157+
usedToolUseIds.add(block.tool_use_id)
158+
return block
159+
}
160+
161+
// Find which tool_result index this block is by comparing references.
162+
// This correctly handles duplicate tool_use_ids - we find the actual block's
163+
// position among all tool_results, not the first block with a matching ID.
164+
const toolResultIndex = toolResults.indexOf(block as Anthropic.ToolResultBlockParam)
165+
166+
// Try to match by position - only fix if there's a corresponding tool_use
167+
if (toolResultIndex !== -1 && toolResultIndex < toolUseBlocks.length) {
168+
const correctId = toolUseBlocks[toolResultIndex].id
169+
// Only use this ID if it hasn't been used yet
170+
if (!usedToolUseIds.has(correctId)) {
171+
usedToolUseIds.add(correctId)
172+
return {
173+
...block,
174+
tool_use_id: correctId,
175+
}
176+
}
177+
}
178+
179+
// No corresponding tool_use for this tool_result, or the ID is already used
180+
// Filter out this orphaned tool_result by returning null
181+
return null
182+
})
183+
.filter((block): block is NonNullable<typeof block> => block !== null)
171184

172185
// Add missing tool_result blocks for any tool_use that doesn't have one
173186
// After the ID correction above, recalculate which tool_use IDs are now covered
@@ -179,21 +192,19 @@ export function validateAndFixToolResultIds(
179192

180193
const stillMissingToolUseIds = toolUseBlocks.filter((toolUse) => !coveredToolUseIds.has(toolUse.id))
181194

182-
if (stillMissingToolUseIds.length > 0) {
183-
// Add placeholder tool_result blocks for missing tool_use IDs
184-
const missingToolResults: Anthropic.ToolResultBlockParam[] = stillMissingToolUseIds.map((toolUse) => ({
185-
type: "tool_result" as const,
186-
tool_use_id: toolUse.id,
187-
content: "Tool execution was interrupted before completion.",
188-
}))
189-
190-
// Insert missing tool_results at the beginning of the content array
191-
// This ensures they come before any text blocks that may summarize the results
192-
correctedContent = [...missingToolResults, ...correctedContent]
193-
}
195+
// Build final content: add missing tool_results at the beginning if any
196+
const missingToolResults: Anthropic.ToolResultBlockParam[] = stillMissingToolUseIds.map((toolUse) => ({
197+
type: "tool_result" as const,
198+
tool_use_id: toolUse.id,
199+
content: "Tool execution was interrupted before completion.",
200+
}))
201+
202+
// Insert missing tool_results at the beginning of the content array
203+
// This ensures they come before any text blocks that may summarize the results
204+
const finalContent = missingToolResults.length > 0 ? [...missingToolResults, ...correctedContent] : correctedContent
194205

195206
return {
196207
...userMessage,
197-
content: correctedContent,
208+
content: finalContent,
198209
}
199210
}

0 commit comments

Comments
 (0)