fix(provider): deduplicate tool results and merge consecutive tool_result blocks for Anthropic API (#1793)

Anthropic API returns 400 when multiple tool_result blocks share the same
tool_use_id, or when consecutive tool results are sent as separate user
messages. This fix:

1. Adds ToolCallID deduplication in sanitizeHistoryForProvider (context.go)
   to drop duplicate tool results before sending to any provider.
2. Merges consecutive tool result messages into a single user message with
   multiple tool_result content blocks in Anthropic's buildRequestBody,
   for both "user" (with ToolCallID) and "tool" role messages.
3. Adds tests for both behaviors.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Liqiang Liu
2026-03-23 17:24:46 +08:00
committed by GitHub
parent e7ee80ff32
commit f81b44bf19
4 changed files with 156 additions and 16 deletions
+13
View File
@@ -678,8 +678,21 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
// like DeepSeek that enforce: "An assistant message with 'tool_calls' must
// be followed by tool messages responding to each 'tool_call_id'."
final := make([]providers.Message, 0, len(sanitized))
seenToolCallID := make(map[string]bool)
for i := 0; i < len(sanitized); i++ {
msg := sanitized[i]
// Deduplicate tool results by ToolCallID
if msg.Role == "tool" && msg.ToolCallID != "" {
if seenToolCallID[msg.ToolCallID] {
logger.DebugCF("agent", "Dropping duplicate tool result", map[string]any{
"tool_call_id": msg.ToolCallID,
})
continue
}
seenToolCallID[msg.ToolCallID] = true
}
if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
// Collect expected tool_call IDs
expected := make(map[string]bool, len(msg.ToolCalls))
+25
View File
@@ -188,6 +188,31 @@ func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) {
assertRoles(t, result, "user", "assistant", "user", "assistant")
}
func TestSanitizeHistoryForProvider_DuplicateToolResults(t *testing.T) {
history := []providers.Message{
msg("user", "do something"),
assistantWithTools("A", "B"),
toolResult("A"),
toolResult("B"),
toolResult("A"), // duplicate
toolResult("B"), // duplicate
msg("assistant", "done"),
}
result := sanitizeHistoryForProvider(history)
if len(result) != 5 {
t.Fatalf("expected 5 messages, got %d: %+v", len(result), roles(result))
}
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant")
// Verify the kept tool results have the correct IDs
if result[2].ToolCallID != "A" {
t.Errorf("expected tool result A, got %q", result[2].ToolCallID)
}
if result[3].ToolCallID != "B" {
t.Errorf("expected tool result B, got %q", result[3].ToolCallID)
}
}
func roles(msgs []providers.Message) []string {
r := make([]string, len(msgs))
for i, m := range msgs {