From cc43f78b4b6c475e8d7898e0039af3e9a01791cc Mon Sep 17 00:00:00 2001 From: viettranx Date: Fri, 20 Mar 2026 06:39:58 +0700 Subject: [PATCH] fix(agent): correct within-turn duplicate tool call ID pairing in sanitizeHistory Replace single-value idRemap with queue-based idQueue so multiple tool results sharing the same original ID pair correctly in encounter order. Previously the map overwrote on duplicates, causing the second result to be dropped and a synthetic placeholder to be emitted instead. --- internal/agent/loop_history.go | 13 +++++--- internal/agent/loop_history_test.go | 52 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index 08cee423..f470d2ac 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -408,9 +408,10 @@ func sanitizeHistory(msgs []providers.Message) ([]providers.Message, int) { msg.ToolCalls = make([]providers.ToolCall, len(oldCalls)) copy(msg.ToolCalls, oldCalls) - // Dedup: rewrite any ID that was already seen in an earlier turn. - // Maps original ID → rewritten ID for pairing with tool results below. - idRemap := make(map[string]string, len(msg.ToolCalls)) + // Dedup: rewrite any ID that was already seen in an earlier turn or + // within the same turn. Uses a queue per original ID so multiple tool + // results with the same raw ID pair correctly in encounter order. + idQueue := make(map[string][]string, len(msg.ToolCalls)) // origID → []newID expectedIDs := make(map[string]bool, len(msg.ToolCalls)) for j := range msg.ToolCalls { origID := msg.ToolCalls[j].ID @@ -421,7 +422,7 @@ func sanitizeHistory(msgs []providers.Message) ([]providers.Message, int) { } msg.ToolCalls[j].ID = newID globalSeen[newID] = true - idRemap[origID] = newID + idQueue[origID] = append(idQueue[origID], newID) expectedIDs[newID] = true } @@ -431,7 +432,9 @@ func sanitizeHistory(msgs []providers.Message) ([]providers.Message, int) { for i+1 < len(msgs) && msgs[i+1].Role == "tool" { i++ toolMsg := msgs[i] - if newID, ok := idRemap[toolMsg.ToolCallID]; ok && expectedIDs[newID] { + if queue, ok := idQueue[toolMsg.ToolCallID]; ok && len(queue) > 0 { + newID := queue[0] + idQueue[toolMsg.ToolCallID] = queue[1:] toolMsg.ToolCallID = newID result = append(result, toolMsg) delete(expectedIDs, newID) diff --git a/internal/agent/loop_history_test.go b/internal/agent/loop_history_test.go index 7e863d82..189a452b 100644 --- a/internal/agent/loop_history_test.go +++ b/internal/agent/loop_history_test.go @@ -243,6 +243,58 @@ func TestSanitizeHistory_DedupsDuplicateIDsAcrossTurns(t *testing.T) { } } +func TestSanitizeHistory_DedupsDuplicateIDsWithinTurn(t *testing.T) { + msgs := []providers.Message{ + {Role: "user", Content: "do two things"}, + {Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{ + {ID: "call_abc", Name: "read_file"}, + {ID: "call_abc", Name: "write_file"}, // same ID within turn + }}, + {Role: "tool", Content: "result1", ToolCallID: "call_abc"}, + {Role: "tool", Content: "result2", ToolCallID: "call_abc"}, + {Role: "assistant", Content: "done"}, + } + got, dropped := sanitizeHistory(msgs) + if dropped != 0 { + t.Errorf("expected 0 dropped, got %d", dropped) + } + + // Both tool results must be present and paired correctly + toolResults := 0 + for _, m := range got { + if m.Role == "tool" { + toolResults++ + } + } + if toolResults != 2 { + t.Errorf("expected 2 tool results, got %d", toolResults) + } + + // All tool call IDs must be unique + seen := make(map[string]bool) + for _, m := range got { + for _, tc := range m.ToolCalls { + if seen[tc.ID] { + t.Errorf("duplicate tool call ID: %s", tc.ID) + } + seen[tc.ID] = true + } + } + + // Each tool result ID must match a tool call ID + callIDs := make(map[string]bool) + for _, m := range got { + for _, tc := range m.ToolCalls { + callIDs[tc.ID] = true + } + } + for _, m := range got { + if m.Role == "tool" && !callIDs[m.ToolCallID] { + t.Errorf("tool result ID %s has no matching tool call", m.ToolCallID) + } + } +} + func TestSanitizeHistory_NoDedupWhenIDsUnique(t *testing.T) { msgs := []providers.Message{ {Role: "user", Content: "hello"},