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.
This commit is contained in:
viettranx committed 2026-03-20 06:39:58 +07:00
1 parent 55bc752773
commit cc43f78b4b
2 files changed
+60 -5

No files matched your search

+8 -5
View File
@@ -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)
+52
View File
@@ -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"},