Fix repeated tool result pairing across providers
- commit
- 573dcec9c73a8ca29516bf9e22a1071cb44a89a9
- author
- patriceckhart · · 2026-09-22 06:23 UTC
- parent
- 97ae0a2
- signature
- unsigned
- refs
- tag: v0.3.85
2 files · +61 -10
M packages/provider/provider.go +12 -10
| @@ -82,30 +82,32 @@ type ReasoningBlock struct { | |||
| 82 | 82 | func (ReasoningBlock) isContent() {} | |
| 83 | 83 | ||
| 84 | 84 | // RepairOrphanedToolResults removes invalid tool_result content blocks | |
| 85 | - | // and entire messages that become empty. A result is invalid when its | |
| 86 | - | // matching tool_use ID does not appear anywhere in the messages or when | |
| 87 | - | // an earlier result already covers the same ID. Resume tails, compaction | |
| 88 | - | // repair, and provider request builders all need this so the upstream API | |
| 89 | - | // sees exactly one result for each referenced tool call. | |
| 85 | + | // and entire messages that become empty. A result is invalid when there | |
| 86 | + | // is no unmatched tool_use with the same ID anywhere in the messages. | |
| 87 | + | // Tool IDs normally are unique, but a provider can reuse one across turns. | |
| 88 | + | // Counting occurrences preserves each valid pair while still dropping | |
| 89 | + | // duplicate results. Resume tails, compaction repair, and provider request | |
| 90 | + | // builders all need this so the upstream API sees one result for each | |
| 91 | + | // referenced tool call. | |
| 90 | 92 | func RepairOrphanedToolResults(msgs []Message) []Message { | |
| 91 | - | useIDs := map[string]bool{} | |
| 93 | + | useCounts := map[string]int{} | |
| 92 | 94 | for _, m := range msgs { | |
| 93 | 95 | for _, c := range m.Content { | |
| 94 | 96 | if tc, ok := c.(ToolCallBlock); ok { | |
| 95 | - | useIDs[tc.ID] = true | |
| 97 | + | useCounts[tc.ID]++ | |
| 96 | 98 | } | |
| 97 | 99 | } | |
| 98 | 100 | } | |
| 99 | - | resultIDs := map[string]bool{} | |
| 101 | + | resultCounts := map[string]int{} | |
| 100 | 102 | out := make([]Message, 0, len(msgs)) | |
| 101 | 103 | for _, m := range msgs { | |
| 102 | 104 | var filtered []Content | |
| 103 | 105 | for _, c := range m.Content { | |
| 104 | 106 | if tr, ok := c.(ToolResultBlock); ok { | |
| 105 | - | if !useIDs[tr.CallID] || resultIDs[tr.CallID] { | |
| 107 | + | if resultCounts[tr.CallID] >= useCounts[tr.CallID] { | |
| 106 | 108 | continue | |
| 107 | 109 | } | |
| 108 | - | resultIDs[tr.CallID] = true | |
| 110 | + | resultCounts[tr.CallID]++ | |
| 109 | 111 | } | |
| 110 | 112 | filtered = append(filtered, c) | |
| 111 | 113 | } | |
| @@ -82,30 +82,32 @@ type ReasoningBlock struct { | |||||
| 82 | func (ReasoningBlock) isContent() {} | 82 | func (ReasoningBlock) isContent() {} | ||
| 83 | 83 | ||||
| 84 | // RepairOrphanedToolResults removes invalid tool_result content blocks | 84 | // RepairOrphanedToolResults removes invalid tool_result content blocks | ||
| 85 | - | // and entire messages that become empty. A result is invalid when its | 85 | + | // and entire messages that become empty. A result is invalid when there |
| 86 | - | // matching tool_use ID does not appear anywhere in the messages or when | 86 | + | // is no unmatched tool_use with the same ID anywhere in the messages. |
| 87 | - | // an earlier result already covers the same ID. Resume tails, compaction | 87 | + | // Tool IDs normally are unique, but a provider can reuse one across turns. |
| 88 | - | // repair, and provider request builders all need this so the upstream API | 88 | + | // Counting occurrences preserves each valid pair while still dropping |
| 89 | - | // sees exactly one result for each referenced tool call. | 89 | + | // duplicate results. Resume tails, compaction repair, and provider request |
| 90 | + | // builders all need this so the upstream API sees one result for each | |||
| 91 | + | // referenced tool call. | |||
| 90 | func RepairOrphanedToolResults(msgs []Message) []Message { | 92 | func RepairOrphanedToolResults(msgs []Message) []Message { | ||
| 91 | - | useIDs := map[string]bool{} | 93 | + | useCounts := map[string]int{} |
| 92 | for _, m := range msgs { | 94 | for _, m := range msgs { | ||
| 93 | for _, c := range m.Content { | 95 | for _, c := range m.Content { | ||
| 94 | if tc, ok := c.(ToolCallBlock); ok { | 96 | if tc, ok := c.(ToolCallBlock); ok { | ||
| 95 | - | useIDs[tc.ID] = true | 97 | + | useCounts[tc.ID]++ |
| 96 | } | 98 | } | ||
| 97 | } | 99 | } | ||
| 98 | } | 100 | } | ||
| 99 | - | resultIDs := map[string]bool{} | 101 | + | resultCounts := map[string]int{} |
| 100 | out := make([]Message, 0, len(msgs)) | 102 | out := make([]Message, 0, len(msgs)) | ||
| 101 | for _, m := range msgs { | 103 | for _, m := range msgs { | ||
| 102 | var filtered []Content | 104 | var filtered []Content | ||
| 103 | for _, c := range m.Content { | 105 | for _, c := range m.Content { | ||
| 104 | if tr, ok := c.(ToolResultBlock); ok { | 106 | if tr, ok := c.(ToolResultBlock); ok { | ||
| 105 | - | if !useIDs[tr.CallID] || resultIDs[tr.CallID] { | 107 | + | if resultCounts[tr.CallID] >= useCounts[tr.CallID] { |
| 106 | continue | 108 | continue | ||
| 107 | } | 109 | } | ||
| 108 | - | resultIDs[tr.CallID] = true | 110 | + | resultCounts[tr.CallID]++ |
| 109 | } | 111 | } | ||
| 110 | filtered = append(filtered, c) | 112 | filtered = append(filtered, c) | ||
| 111 | } | 113 | } | ||
M packages/provider/provider_test.go +49 -0
| @@ -295,6 +295,28 @@ func TestAnthropicBuildRequestStripsAssistantImages(t *testing.T) { | |||
| 295 | 295 | } | |
| 296 | 296 | } | |
| 297 | 297 | ||
| 298 | + | func TestRepairOrphanedToolResultsPreservesRepeatedToolCallPairs(t *testing.T) { | |
| 299 | + | msgs := []Message{ | |
| 300 | + | {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "reused", Name: "read"}}}, | |
| 301 | + | {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "reused", Content: []Content{TextBlock{Text: "first"}}}}}, | |
| 302 | + | {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "reused", Name: "read"}}}, | |
| 303 | + | {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "reused", Content: []Content{TextBlock{Text: "second"}}}}}, | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | got := RepairOrphanedToolResults(msgs) | |
| 307 | + | if len(got) != len(msgs) { | |
| 308 | + | t.Fatalf("messages=%d, want %d valid repeated-ID pairs", len(got), len(msgs)) | |
| 309 | + | } | |
| 310 | + | for _, index := range []int{1, 3} { | |
| 311 | + | if len(got[index].Content) != 1 { | |
| 312 | + | t.Fatalf("message %d content=%d, want 1 tool result", index, len(got[index].Content)) | |
| 313 | + | } | |
| 314 | + | if _, ok := got[index].Content[0].(ToolResultBlock); !ok { | |
| 315 | + | t.Fatalf("message %d content type=%T, want ToolResultBlock", index, got[index].Content[0]) | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | ||
| 298 | 320 | func TestAnthropicBuildRequestDeduplicatesToolResults(t *testing.T) { | |
| 299 | 321 | c := NewAnthropic("x", "").(*anthropicClient) | |
| 300 | 322 | wire, err := c.buildRequest(Request{ | |
| @@ -331,6 +353,33 @@ func TestAnthropicBuildRequestDeduplicatesToolResults(t *testing.T) { | |||
| 331 | 353 | } | |
| 332 | 354 | } | |
| 333 | 355 | ||
| 356 | + | func TestAnthropicBuildRequestPreservesRepeatedToolCallPairs(t *testing.T) { | |
| 357 | + | c := NewAnthropic("x", "").(*anthropicClient) | |
| 358 | + | wire, err := c.buildRequest(Request{ | |
| 359 | + | Model: "claude-sonnet-4-5", | |
| 360 | + | Messages: []Message{ | |
| 361 | + | {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "reused", Name: "read"}}}, | |
| 362 | + | {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "reused", Content: []Content{TextBlock{Text: "first"}}}}}, | |
| 363 | + | {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "reused", Name: "read"}}}, | |
| 364 | + | {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "reused", Content: []Content{TextBlock{Text: "second"}}}}}, | |
| 365 | + | }, | |
| 366 | + | }) | |
| 367 | + | if err != nil { | |
| 368 | + | t.Fatal(err) | |
| 369 | + | } | |
| 370 | + | if len(wire.Messages) != 4 { | |
| 371 | + | t.Fatalf("messages=%d, want both repeated-ID tool result pairs", len(wire.Messages)) | |
| 372 | + | } | |
| 373 | + | for _, index := range []int{1, 3} { | |
| 374 | + | if len(wire.Messages[index].Content) != 1 { | |
| 375 | + | t.Fatalf("message %d content=%d, want 1 tool result", index, len(wire.Messages[index].Content)) | |
| 376 | + | } | |
| 377 | + | if _, ok := wire.Messages[index].Content[0].(anthToolResultBlock); !ok { | |
| 378 | + | t.Fatalf("message %d content type=%T, want anthToolResultBlock", index, wire.Messages[index].Content[0]) | |
| 379 | + | } | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | ||
| 334 | 383 | func TestAnthropicStreamHappyPath(t *testing.T) { | |
| 335 | 384 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 336 | 385 | w.Header().Set("content-type", "text/event-stream") | |
| @@ -295,6 +295,28 @@ func TestAnthropicBuildRequestStripsAssistantImages(t *testing.T) { | |||||
| 295 | } | 295 | } | ||
| 296 | } | 296 | } | ||
| 297 | 297 | ||||
| 298 | + | func TestRepairOrphanedToolResultsPreservesRepeatedToolCallPairs(t *testing.T) { | |||
| 299 | + | msgs := []Message{ | |||
| 300 | + | {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "reused", Name: "read"}}}, | |||
| 301 | + | {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "reused", Content: []Content{TextBlock{Text: "first"}}}}}, | |||
| 302 | + | {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "reused", Name: "read"}}}, | |||
| 303 | + | {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "reused", Content: []Content{TextBlock{Text: "second"}}}}}, | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | got := RepairOrphanedToolResults(msgs) | |||
| 307 | + | if len(got) != len(msgs) { | |||
| 308 | + | t.Fatalf("messages=%d, want %d valid repeated-ID pairs", len(got), len(msgs)) | |||
| 309 | + | } | |||
| 310 | + | for _, index := range []int{1, 3} { | |||
| 311 | + | if len(got[index].Content) != 1 { | |||
| 312 | + | t.Fatalf("message %d content=%d, want 1 tool result", index, len(got[index].Content)) | |||
| 313 | + | } | |||
| 314 | + | if _, ok := got[index].Content[0].(ToolResultBlock); !ok { | |||
| 315 | + | t.Fatalf("message %d content type=%T, want ToolResultBlock", index, got[index].Content[0]) | |||
| 316 | + | } | |||
| 317 | + | } | |||
| 318 | + | } | |||
| 319 | + | ||||
| 298 | func TestAnthropicBuildRequestDeduplicatesToolResults(t *testing.T) { | 320 | func TestAnthropicBuildRequestDeduplicatesToolResults(t *testing.T) { | ||
| 299 | c := NewAnthropic("x", "").(*anthropicClient) | 321 | c := NewAnthropic("x", "").(*anthropicClient) | ||
| 300 | wire, err := c.buildRequest(Request{ | 322 | wire, err := c.buildRequest(Request{ | ||
| @@ -331,6 +353,33 @@ func TestAnthropicBuildRequestDeduplicatesToolResults(t *testing.T) { | |||||
| 331 | } | 353 | } | ||
| 332 | } | 354 | } | ||
| 333 | 355 | ||||
| 356 | + | func TestAnthropicBuildRequestPreservesRepeatedToolCallPairs(t *testing.T) { | |||
| 357 | + | c := NewAnthropic("x", "").(*anthropicClient) | |||
| 358 | + | wire, err := c.buildRequest(Request{ | |||
| 359 | + | Model: "claude-sonnet-4-5", | |||
| 360 | + | Messages: []Message{ | |||
| 361 | + | {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "reused", Name: "read"}}}, | |||
| 362 | + | {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "reused", Content: []Content{TextBlock{Text: "first"}}}}}, | |||
| 363 | + | {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "reused", Name: "read"}}}, | |||
| 364 | + | {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "reused", Content: []Content{TextBlock{Text: "second"}}}}}, | |||
| 365 | + | }, | |||
| 366 | + | }) | |||
| 367 | + | if err != nil { | |||
| 368 | + | t.Fatal(err) | |||
| 369 | + | } | |||
| 370 | + | if len(wire.Messages) != 4 { | |||
| 371 | + | t.Fatalf("messages=%d, want both repeated-ID tool result pairs", len(wire.Messages)) | |||
| 372 | + | } | |||
| 373 | + | for _, index := range []int{1, 3} { | |||
| 374 | + | if len(wire.Messages[index].Content) != 1 { | |||
| 375 | + | t.Fatalf("message %d content=%d, want 1 tool result", index, len(wire.Messages[index].Content)) | |||
| 376 | + | } | |||
| 377 | + | if _, ok := wire.Messages[index].Content[0].(anthToolResultBlock); !ok { | |||
| 378 | + | t.Fatalf("message %d content type=%T, want anthToolResultBlock", index, wire.Messages[index].Content[0]) | |||
| 379 | + | } | |||
| 380 | + | } | |||
| 381 | + | } | |||
| 382 | + | ||||
| 334 | func TestAnthropicStreamHappyPath(t *testing.T) { | 383 | func TestAnthropicStreamHappyPath(t *testing.T) { | ||
| 335 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 384 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 336 | w.Header().Set("content-type", "text/event-stream") | 385 | w.Header().Set("content-type", "text/event-stream") | ||