mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
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:
parent
160e2d9642
commit
5d1106f018
2 changed files with 335 additions and 1 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue