fix(anthropic): deduplicate tool_result messages by tool_call_id

Anthropic requires exactly one tool_result per tool_use. When
conversation history (e.g. from session resume/checkpoint restore)
contains duplicate tool result messages with the same tool_call_id,
the API rejects with: 'each tool_use must have a single result.
Found multiple tool_result blocks with id: <id>'.

This is already handled for Bedrock via _deduplicate_bedrock_tool_content()
but was missing from the Anthropic direct and Vertex AI partner paths,
which share sanitize_messages_for_tool_calling().

Fix: Add Case D to sanitize_messages_for_tool_calling() — after the
existing orphan detection passes, scan for duplicate tool_call_ids
and keep only the last occurrence (most complete result).

Added 3 unit tests: dedup with duplicates, no-op with unique IDs,
and behavior when modify_params=False.

Related issues: #11804, #11029, #6836, #1782, #151
This commit is contained in:
netbrah 2026-03-08 07:31:26 -04:00 committed by Palanisamy, Dinesh
parent 160e2d9642
commit 5d1106f018
2 changed files with 335 additions and 1 deletions

View file

@ -2221,6 +2221,11 @@ def sanitize_messages_for_tool_calling(
Case C: Empty text content
- Replace empty or whitespace-only text content with a placeholder message.
Case D: Duplicate tool_result for same tool_use (duplicate results)
- If multiple tool messages reference the same tool_call_id, keep only the last
occurrence. Anthropic requires exactly one tool_result per tool_use and rejects
with: "each tool_use must have a single result".
This function operates on OpenAI format messages before they are converted to
provider-specific formats.
"""
@ -2256,6 +2261,49 @@ def sanitize_messages_for_tool_calling(
sanitized_messages.append(current_message)
i += 1
# Case D: Deduplicate tool results with the same tool_call_id.
# Anthropic requires exactly one tool_result per tool_use. Session history
# (e.g. from conversation resume) can contain duplicate tool_result messages
# for the same tool_call_id. Keep only the last occurrence *within each
# contiguous block of tool results following an assistant message*. This
# avoids dropping results from earlier turns if a tool_call_id is reused.
#
# NOTE: This intentionally keeps the *last* occurrence (most complete for
# session-resume duplicates), unlike _deduplicate_bedrock_content_blocks
# which keeps the *first*. The Bedrock case handles provider-side content
# block duplication where the first is authoritative; here the duplicate
# arises from history replay where the last entry is the final state.
duplicates_to_remove: Set[int] = set()
seen_in_block: Dict[str, int] = {} # tool_call_id -> index (reset per block)
for idx, msg in enumerate(sanitized_messages):
role = msg.get("role")
tcid = msg.get("tool_call_id") if role in ["tool", "function"] else None
if tcid:
if tcid in seen_in_block:
# Mark the earlier occurrence for removal (keep latest)
duplicates_to_remove.add(seen_in_block[tcid])
verbose_logger.warning(
"sanitize_messages_for_tool_calling: dropping duplicate "
"tool_result with tool_call_id=%s. This may indicate "
"duplicate tool messages in conversation history.",
tcid,
)
seen_in_block[tcid] = idx
elif role not in ("tool", "function"):
# Non-tool message (user, assistant, system) marks a
# conversational-turn boundary — reset tracking.
# Tool/function messages with no tool_call_id are malformed;
# they should NOT reset the block because they don't represent
# a turn boundary and would mask real within-block duplicates.
seen_in_block = {}
if duplicates_to_remove:
sanitized_messages = [
msg
for idx, msg in enumerate(sanitized_messages)
if idx not in duplicates_to_remove
]
return sanitized_messages

View file

@ -10,6 +10,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
BedrockImageProcessor,
_convert_to_bedrock_tool_call_invoke,
ollama_pt,
sanitize_messages_for_tool_calling,
)
@ -1179,7 +1180,7 @@ def test_bedrock_tools_pt_does_not_handle_system_tool():
System tools (nova_grounding) should be added via web_search_options,
not via the tools parameter directly.
"""
from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_tools_pt
# Regular function tools should still work
@ -1741,3 +1742,288 @@ def test_bedrock_tool_call_invoke_multiple_normal_tools():
assert len(result) == 2
assert result[0]["toolUse"]["toolUseId"] == "call_1"
assert result[1]["toolUse"]["toolUseId"] == "call_2"
# ========================================================================
# Tool result deduplication tests (Case D in sanitize_messages_for_tool_calling)
# ========================================================================
def test_sanitize_messages_deduplicates_tool_results():
"""
Anthropic requires exactly one tool_result per tool_use. When conversation
history (e.g. from session resume) contains duplicate tool result messages
with the same tool_call_id, sanitize_messages_for_tool_calling should keep
only the last occurrence.
Without this fix, Anthropic rejects with:
each tool_use must have a single result. Found multiple tool_result
blocks with id: <id>
"""
original = litellm.modify_params
litellm.modify_params = True
try:
messages = [
{"role": "user", "content": "What's the weather?"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city": "NYC"}',
},
}
],
},
# First tool result (stale/duplicate)
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": "Partial result...",
},
# Second tool result (final/complete — should be kept)
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": '{"temperature": 72, "condition": "sunny"}',
},
]
result = sanitize_messages_for_tool_calling(messages)
# Count tool messages with this ID — should be exactly 1
tool_results = [
m for m in result if m.get("role") == "tool" and m.get("tool_call_id") == "call_abc123"
]
assert len(tool_results) == 1
# Should keep the LAST occurrence (most complete)
assert tool_results[0]["content"] == '{"temperature": 72, "condition": "sunny"}'
finally:
litellm.modify_params = original
def test_sanitize_messages_preserves_unique_tool_results():
"""
When each tool_call_id has exactly one tool_result, no deduplication should
occur. Messages should pass through unchanged.
"""
original = litellm.modify_params
litellm.modify_params = True
try:
messages = [
{"role": "user", "content": "Get weather for two cities"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city": "NYC"}',
},
},
{
"id": "call_2",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city": "LA"}',
},
},
],
},
{"role": "tool", "tool_call_id": "call_1", "content": "72F"},
{"role": "tool", "tool_call_id": "call_2", "content": "85F"},
]
result = sanitize_messages_for_tool_calling(messages)
tool_results = [m for m in result if m.get("role") == "tool"]
assert len(tool_results) == 2
assert tool_results[0]["tool_call_id"] == "call_1"
assert tool_results[0]["content"] == "72F"
assert tool_results[1]["tool_call_id"] == "call_2"
assert tool_results[1]["content"] == "85F"
finally:
litellm.modify_params = original
def test_sanitize_messages_dedup_disabled_when_modify_params_false():
"""
When litellm.modify_params is False, messages should be returned as-is
even if they contain duplicate tool results.
"""
original = litellm.modify_params
litellm.modify_params = False
try:
messages = [
{"role": "user", "content": "Test"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_dup",
"type": "function",
"function": {"name": "test", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "call_dup", "content": "first"},
{"role": "tool", "tool_call_id": "call_dup", "content": "second"},
]
result = sanitize_messages_for_tool_calling(messages)
# Should be unchanged — no sanitization when modify_params=False
assert result == messages
finally:
litellm.modify_params = original
def test_sanitize_messages_dedup_scoped_per_turn_preserves_cross_turn():
"""
When the same tool_call_id appears in two different assistant turns
(separated by a user message), both tool results must be preserved.
Deduplication should only apply within a single contiguous tool-result
block, not globally across the conversation.
Without per-turn scoping this would incorrectly drop the first tool result,
leaving the first assistant message without its required result (which
Anthropic would reject).
"""
original = litellm.modify_params
litellm.modify_params = True
try:
messages = [
{"role": "user", "content": "First question"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_X",
"type": "function",
"function": {"name": "lookup", "arguments": '{"q": "a"}'},
}
],
},
{"role": "tool", "tool_call_id": "call_X", "content": "result_turn_1"},
{"role": "user", "content": "Second question"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_X",
"type": "function",
"function": {"name": "lookup", "arguments": '{"q": "b"}'},
}
],
},
{"role": "tool", "tool_call_id": "call_X", "content": "result_turn_2"},
]
result = sanitize_messages_for_tool_calling(messages)
# Both tool results must survive — one per turn
tool_results = [
m for m in result
if m.get("role") == "tool" and m.get("tool_call_id") == "call_X"
]
assert len(tool_results) == 2, (
f"Expected 2 tool results (one per turn), got {len(tool_results)}. "
"Dedup may be global instead of per-turn scoped."
)
assert tool_results[0]["content"] == "result_turn_1"
assert tool_results[1]["content"] == "result_turn_2"
finally:
litellm.modify_params = original
def test_sanitize_messages_combined_case_a_and_case_d():
"""
Combined Case A + Case D: an assistant message has two tool_calls
one with a missing result (Case A should inject a dummy) and one with
duplicate results (Case D should deduplicate to keep only the last).
This validates that both sanitization passes compose correctly without
interfering with each other.
"""
original = litellm.modify_params
litellm.modify_params = True
try:
messages = [
{"role": "user", "content": "Do two things"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_missing",
"type": "function",
"function": {"name": "tool_a", "arguments": "{}"},
},
{
"id": "call_duped",
"type": "function",
"function": {"name": "tool_b", "arguments": '{"q": "x"}'},
},
],
},
# No result for call_missing — Case A should inject a dummy
# Duplicate results for call_duped — Case D should keep last
{"role": "tool", "tool_call_id": "call_duped", "content": "stale_result"},
{"role": "tool", "tool_call_id": "call_duped", "content": "fresh_result"},
{"role": "user", "content": "Now summarize"},
]
result = sanitize_messages_for_tool_calling(messages)
# Collect tool results from the output
tool_results = [m for m in result if m.get("role") in ("tool", "function")]
# Case A: call_missing should have a dummy result injected
missing_results = [
m for m in tool_results if m.get("tool_call_id") == "call_missing"
]
assert len(missing_results) == 1, (
f"Expected 1 dummy result for call_missing (Case A), got {len(missing_results)}"
)
# Case D: call_duped should have exactly 1 result (the fresh one)
duped_results = [
m for m in tool_results if m.get("tool_call_id") == "call_duped"
]
assert len(duped_results) == 1, (
f"Expected 1 result for call_duped after dedup (Case D), got {len(duped_results)}"
)
assert duped_results[0]["content"] == "fresh_result", (
f"Expected last-wins 'fresh_result', got '{duped_results[0]['content']}'"
)
# Verify tool results immediately follow the assistant message
asst_idx = next(
i for i, m in enumerate(result) if m.get("role") == "assistant"
)
tool_msgs_after_asst = [
m
for m in result[asst_idx + 1 :]
if m.get("role") in ("tool", "function")
]
assert len(tool_msgs_after_asst) == 2, (
f"Expected 2 tool results after assistant, got {len(tool_msgs_after_asst)}"
)
# Both tool_call_ids should be present (order may vary)
tool_ids = {m["tool_call_id"] for m in tool_msgs_after_asst}
assert tool_ids == {"call_missing", "call_duped"}, (
f"Expected tool_call_ids {{call_missing, call_duped}}, got {tool_ids}"
)
finally:
litellm.modify_params = original