Deduplicate tool results in provider requests
- commit
- 8e8b1daa959d84447c9b7a9bbacb093fbb749a44
- author
- patriceckhart · · 2026-08-27 09:25 UTC
- parent
- 82191b3
- signature
- unsigned
- refs
- tag: v0.3.51
2 files · +45 -7
M packages/provider/provider.go +9 -7
| @@ -77,12 +77,12 @@ type ReasoningBlock struct { | |||
| 77 | 77 | ||
| 78 | 78 | func (ReasoningBlock) isContent() {} | |
| 79 | 79 | ||
| 80 | - | // RepairOrphanedToolResults removes tool_result content blocks (and | |
| 81 | - | // entire messages that become empty) when the matching tool_use ID | |
| 82 | - | // does not appear anywhere in the given messages. Resume tails, | |
| 83 | - | // compaction repair, and provider request builders all need this so | |
| 84 | - | // the upstream API never sees a tool_call_id with no corresponding | |
| 85 | - | // assistant tool_call earlier in the same request. | |
| 80 | + | // RepairOrphanedToolResults removes invalid tool_result content blocks | |
| 81 | + | // and entire messages that become empty. A result is invalid when its | |
| 82 | + | // matching tool_use ID does not appear anywhere in the messages or when | |
| 83 | + | // an earlier result already covers the same ID. Resume tails, compaction | |
| 84 | + | // repair, and provider request builders all need this so the upstream API | |
| 85 | + | // sees exactly one result for each referenced tool call. | |
| 86 | 86 | func RepairOrphanedToolResults(msgs []Message) []Message { | |
| 87 | 87 | useIDs := map[string]bool{} | |
| 88 | 88 | for _, m := range msgs { | |
| @@ -92,14 +92,16 @@ func RepairOrphanedToolResults(msgs []Message) []Message { | |||
| 92 | 92 | } | |
| 93 | 93 | } | |
| 94 | 94 | } | |
| 95 | + | resultIDs := map[string]bool{} | |
| 95 | 96 | out := make([]Message, 0, len(msgs)) | |
| 96 | 97 | for _, m := range msgs { | |
| 97 | 98 | var filtered []Content | |
| 98 | 99 | for _, c := range m.Content { | |
| 99 | 100 | if tr, ok := c.(ToolResultBlock); ok { | |
| 100 | - | if !useIDs[tr.CallID] { | |
| 101 | + | if !useIDs[tr.CallID] || resultIDs[tr.CallID] { | |
| 101 | 102 | continue | |
| 102 | 103 | } | |
| 104 | + | resultIDs[tr.CallID] = true | |
| 103 | 105 | } | |
| 104 | 106 | filtered = append(filtered, c) | |
| 105 | 107 | } | |
| @@ -77,12 +77,12 @@ type ReasoningBlock struct { | |||||
| 77 | 77 | ||||
| 78 | func (ReasoningBlock) isContent() {} | 78 | func (ReasoningBlock) isContent() {} | ||
| 79 | 79 | ||||
| 80 | - | // RepairOrphanedToolResults removes tool_result content blocks (and | 80 | + | // RepairOrphanedToolResults removes invalid tool_result content blocks |
| 81 | - | // entire messages that become empty) when the matching tool_use ID | 81 | + | // and entire messages that become empty. A result is invalid when its |
| 82 | - | // does not appear anywhere in the given messages. Resume tails, | 82 | + | // matching tool_use ID does not appear anywhere in the messages or when |
| 83 | - | // compaction repair, and provider request builders all need this so | 83 | + | // an earlier result already covers the same ID. Resume tails, compaction |
| 84 | - | // the upstream API never sees a tool_call_id with no corresponding | 84 | + | // repair, and provider request builders all need this so the upstream API |
| 85 | - | // assistant tool_call earlier in the same request. | 85 | + | // sees exactly one result for each referenced tool call. |
| 86 | func RepairOrphanedToolResults(msgs []Message) []Message { | 86 | func RepairOrphanedToolResults(msgs []Message) []Message { | ||
| 87 | useIDs := map[string]bool{} | 87 | useIDs := map[string]bool{} | ||
| 88 | for _, m := range msgs { | 88 | for _, m := range msgs { | ||
| @@ -92,14 +92,16 @@ func RepairOrphanedToolResults(msgs []Message) []Message { | |||||
| 92 | } | 92 | } | ||
| 93 | } | 93 | } | ||
| 94 | } | 94 | } | ||
| 95 | + | resultIDs := map[string]bool{} | |||
| 95 | out := make([]Message, 0, len(msgs)) | 96 | out := make([]Message, 0, len(msgs)) | ||
| 96 | for _, m := range msgs { | 97 | for _, m := range msgs { | ||
| 97 | var filtered []Content | 98 | var filtered []Content | ||
| 98 | for _, c := range m.Content { | 99 | for _, c := range m.Content { | ||
| 99 | if tr, ok := c.(ToolResultBlock); ok { | 100 | if tr, ok := c.(ToolResultBlock); ok { | ||
| 100 | - | if !useIDs[tr.CallID] { | 101 | + | if !useIDs[tr.CallID] || resultIDs[tr.CallID] { |
| 101 | continue | 102 | continue | ||
| 102 | } | 103 | } | ||
| 104 | + | resultIDs[tr.CallID] = true | |||
| 103 | } | 105 | } | ||
| 104 | filtered = append(filtered, c) | 106 | filtered = append(filtered, c) | ||
| 105 | } | 107 | } | ||
M packages/provider/provider_test.go +36 -0
| @@ -277,6 +277,42 @@ func TestAnthropicBuildRequestStripsAssistantImages(t *testing.T) { | |||
| 277 | 277 | } | |
| 278 | 278 | } | |
| 279 | 279 | ||
| 280 | + | func TestAnthropicBuildRequestDeduplicatesToolResults(t *testing.T) { | |
| 281 | + | c := NewAnthropic("x", "").(*anthropicClient) | |
| 282 | + | wire, err := c.buildRequest(Request{ | |
| 283 | + | Model: "claude-sonnet-4-5", | |
| 284 | + | Messages: []Message{ | |
| 285 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "read the file"}}}, | |
| 286 | + | {Role: RoleAssistant, Content: []Content{ | |
| 287 | + | ToolCallBlock{ID: "tool-1", Name: "read"}, | |
| 288 | + | }}, | |
| 289 | + | {Role: RoleTool, Content: []Content{ | |
| 290 | + | ToolResultBlock{CallID: "tool-1", Content: []Content{TextBlock{Text: "first result"}}}, | |
| 291 | + | }}, | |
| 292 | + | {Role: RoleTool, Content: []Content{ | |
| 293 | + | ToolResultBlock{CallID: "tool-1", Content: []Content{TextBlock{Text: "duplicate result"}}}, | |
| 294 | + | }}, | |
| 295 | + | }, | |
| 296 | + | }) | |
| 297 | + | if err != nil { | |
| 298 | + | t.Fatal(err) | |
| 299 | + | } | |
| 300 | + | if len(wire.Messages) != 3 { | |
| 301 | + | t.Fatalf("messages=%d, want 3 after dropping duplicate result message", len(wire.Messages)) | |
| 302 | + | } | |
| 303 | + | blocks := wire.Messages[2].Content | |
| 304 | + | if len(blocks) != 1 { | |
| 305 | + | t.Fatalf("tool result blocks=%d, want 1", len(blocks)) | |
| 306 | + | } | |
| 307 | + | result, ok := blocks[0].(anthToolResultBlock) | |
| 308 | + | if !ok { | |
| 309 | + | t.Fatalf("tool result block type=%T", blocks[0]) | |
| 310 | + | } | |
| 311 | + | if string(result.Content) != `"first result"` { | |
| 312 | + | t.Fatalf("tool result content=%s", result.Content) | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | ||
| 280 | 316 | func TestAnthropicStreamHappyPath(t *testing.T) { | |
| 281 | 317 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 282 | 318 | w.Header().Set("content-type", "text/event-stream") | |
| @@ -277,6 +277,42 @@ func TestAnthropicBuildRequestStripsAssistantImages(t *testing.T) { | |||||
| 277 | } | 277 | } | ||
| 278 | } | 278 | } | ||
| 279 | 279 | ||||
| 280 | + | func TestAnthropicBuildRequestDeduplicatesToolResults(t *testing.T) { | |||
| 281 | + | c := NewAnthropic("x", "").(*anthropicClient) | |||
| 282 | + | wire, err := c.buildRequest(Request{ | |||
| 283 | + | Model: "claude-sonnet-4-5", | |||
| 284 | + | Messages: []Message{ | |||
| 285 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "read the file"}}}, | |||
| 286 | + | {Role: RoleAssistant, Content: []Content{ | |||
| 287 | + | ToolCallBlock{ID: "tool-1", Name: "read"}, | |||
| 288 | + | }}, | |||
| 289 | + | {Role: RoleTool, Content: []Content{ | |||
| 290 | + | ToolResultBlock{CallID: "tool-1", Content: []Content{TextBlock{Text: "first result"}}}, | |||
| 291 | + | }}, | |||
| 292 | + | {Role: RoleTool, Content: []Content{ | |||
| 293 | + | ToolResultBlock{CallID: "tool-1", Content: []Content{TextBlock{Text: "duplicate result"}}}, | |||
| 294 | + | }}, | |||
| 295 | + | }, | |||
| 296 | + | }) | |||
| 297 | + | if err != nil { | |||
| 298 | + | t.Fatal(err) | |||
| 299 | + | } | |||
| 300 | + | if len(wire.Messages) != 3 { | |||
| 301 | + | t.Fatalf("messages=%d, want 3 after dropping duplicate result message", len(wire.Messages)) | |||
| 302 | + | } | |||
| 303 | + | blocks := wire.Messages[2].Content | |||
| 304 | + | if len(blocks) != 1 { | |||
| 305 | + | t.Fatalf("tool result blocks=%d, want 1", len(blocks)) | |||
| 306 | + | } | |||
| 307 | + | result, ok := blocks[0].(anthToolResultBlock) | |||
| 308 | + | if !ok { | |||
| 309 | + | t.Fatalf("tool result block type=%T", blocks[0]) | |||
| 310 | + | } | |||
| 311 | + | if string(result.Content) != `"first result"` { | |||
| 312 | + | t.Fatalf("tool result content=%s", result.Content) | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | ||||
| 280 | func TestAnthropicStreamHappyPath(t *testing.T) { | 316 | func TestAnthropicStreamHappyPath(t *testing.T) { | ||
| 281 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 317 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 282 | w.Header().Set("content-type", "text/event-stream") | 318 | w.Header().Set("content-type", "text/event-stream") | ||