Skip to content

Commit b370259

Browse files
committed
fix: filter orphaned tool_results when more results than tool_uses
When there are more tool_result blocks than tool_use blocks in the API conversation history (e.g., 2 tool_results but only 1 tool_use), the previous code would either leave orphaned results unchanged or create duplicates. This change: - Tracks used tool_use IDs to prevent duplicate assignments - Filters out orphaned tool_results that have no corresponding tool_use - Preserves non-tool_result blocks (text, etc.) Fixes the ToolResultIdMismatchError scenario where tool_result IDs like [call_08230257, call_55577629] need to map to a single tool_use ID [call_55577629].
1 parent 47320dc commit b370259

File tree

2 files changed

+127
-29
lines changed

2 files changed

+127
-29
lines changed

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

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

356356
describe("when there are more tool_results than tool_uses", () => {
357-
it("should leave extra tool_results unchanged", () => {
357+
it("should filter out orphaned tool_results with invalid IDs", () => {
358358
const assistantMessage: Anthropic.MessageParam = {
359359
role: "assistant",
360360
content: [
@@ -387,9 +387,96 @@ describe("validateAndFixToolResultIds", () => {
387387

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

src/core/task/validateToolResultIds.ts

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -105,34 +105,45 @@ export function validateAndFixToolResultIds(
105105
// Create a mapping of tool_result IDs to corrected IDs
106106
// Strategy: Match by position (first tool_result -> first tool_use, etc.)
107107
// This handles most cases where the mismatch is due to ID confusion
108-
const correctedContent = userMessage.content.map((block) => {
109-
if (block.type !== "tool_result") {
110-
return block
111-
}
112-
113-
// If the ID is already valid, keep it
114-
if (validToolUseIds.has(block.tool_use_id)) {
115-
return block
116-
}
117-
118-
// Find which tool_result index this block is by comparing references.
119-
// This correctly handles duplicate tool_use_ids - we find the actual block's
120-
// position among all tool_results, not the first block with a matching ID.
121-
const toolResultIndex = toolResults.indexOf(block as Anthropic.ToolResultBlockParam)
122-
123-
// Try to match by position - only fix if there's a corresponding tool_use
124-
if (toolResultIndex !== -1 && toolResultIndex < toolUseBlocks.length) {
125-
const correctId = toolUseBlocks[toolResultIndex].id
126-
return {
127-
...block,
128-
tool_use_id: correctId,
108+
//
109+
// Track which tool_use IDs have been used to prevent duplicates
110+
const usedToolUseIds = new Set<string>()
111+
112+
const correctedContent = userMessage.content
113+
.map((block) => {
114+
if (block.type !== "tool_result") {
115+
return block
129116
}
130-
}
131117

132-
// No corresponding tool_use for this tool_result - leave it unchanged
133-
// This can happen when there are more tool_results than tool_uses
134-
return block
135-
})
118+
// If the ID is already valid and not yet used, keep it
119+
if (validToolUseIds.has(block.tool_use_id) && !usedToolUseIds.has(block.tool_use_id)) {
120+
usedToolUseIds.add(block.tool_use_id)
121+
return block
122+
}
123+
124+
// Find which tool_result index this block is by comparing references.
125+
// This correctly handles duplicate tool_use_ids - we find the actual block's
126+
// position among all tool_results, not the first block with a matching ID.
127+
const toolResultIndex = toolResults.indexOf(block as Anthropic.ToolResultBlockParam)
128+
129+
// Try to match by position - only fix if there's a corresponding tool_use
130+
if (toolResultIndex !== -1 && toolResultIndex < toolUseBlocks.length) {
131+
const correctId = toolUseBlocks[toolResultIndex].id
132+
// Only use this ID if it hasn't been used yet
133+
if (!usedToolUseIds.has(correctId)) {
134+
usedToolUseIds.add(correctId)
135+
return {
136+
...block,
137+
tool_use_id: correctId,
138+
}
139+
}
140+
}
141+
142+
// No corresponding tool_use for this tool_result, or the ID is already used
143+
// Filter out this orphaned tool_result by returning null
144+
return null
145+
})
146+
.filter((block): block is NonNullable<typeof block> => block !== null)
136147

137148
return {
138149
...userMessage,

0 commit comments

Comments
 (0)