Skip to content

Commit 5d1106f

Browse files
netbrahPalanisamy, Dinesh
authored andcommitted
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
1 parent 160e2d9 commit 5d1106f

2 files changed

Lines changed: 335 additions & 1 deletion

File tree

litellm/litellm_core_utils/prompt_templates/factory.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2221,6 +2221,11 @@ def sanitize_messages_for_tool_calling(
22212221
Case C: Empty text content
22222222
- Replace empty or whitespace-only text content with a placeholder message.
22232223
2224+
Case D: Duplicate tool_result for same tool_use (duplicate results)
2225+
- If multiple tool messages reference the same tool_call_id, keep only the last
2226+
occurrence. Anthropic requires exactly one tool_result per tool_use and rejects
2227+
with: "each tool_use must have a single result".
2228+
22242229
This function operates on OpenAI format messages before they are converted to
22252230
provider-specific formats.
22262231
"""
@@ -2256,6 +2261,49 @@ def sanitize_messages_for_tool_calling(
22562261
sanitized_messages.append(current_message)
22572262
i += 1
22582263

2264+
# Case D: Deduplicate tool results with the same tool_call_id.
2265+
# Anthropic requires exactly one tool_result per tool_use. Session history
2266+
# (e.g. from conversation resume) can contain duplicate tool_result messages
2267+
# for the same tool_call_id. Keep only the last occurrence *within each
2268+
# contiguous block of tool results following an assistant message*. This
2269+
# avoids dropping results from earlier turns if a tool_call_id is reused.
2270+
#
2271+
# NOTE: This intentionally keeps the *last* occurrence (most complete for
2272+
# session-resume duplicates), unlike _deduplicate_bedrock_content_blocks
2273+
# which keeps the *first*. The Bedrock case handles provider-side content
2274+
# block duplication where the first is authoritative; here the duplicate
2275+
# arises from history replay where the last entry is the final state.
2276+
duplicates_to_remove: Set[int] = set()
2277+
seen_in_block: Dict[str, int] = {} # tool_call_id -> index (reset per block)
2278+
for idx, msg in enumerate(sanitized_messages):
2279+
role = msg.get("role")
2280+
tcid = msg.get("tool_call_id") if role in ["tool", "function"] else None
2281+
if tcid:
2282+
if tcid in seen_in_block:
2283+
# Mark the earlier occurrence for removal (keep latest)
2284+
duplicates_to_remove.add(seen_in_block[tcid])
2285+
verbose_logger.warning(
2286+
"sanitize_messages_for_tool_calling: dropping duplicate "
2287+
"tool_result with tool_call_id=%s. This may indicate "
2288+
"duplicate tool messages in conversation history.",
2289+
tcid,
2290+
)
2291+
seen_in_block[tcid] = idx
2292+
elif role not in ("tool", "function"):
2293+
# Non-tool message (user, assistant, system) marks a
2294+
# conversational-turn boundary — reset tracking.
2295+
# Tool/function messages with no tool_call_id are malformed;
2296+
# they should NOT reset the block because they don't represent
2297+
# a turn boundary and would mask real within-block duplicates.
2298+
seen_in_block = {}
2299+
2300+
if duplicates_to_remove:
2301+
sanitized_messages = [
2302+
msg
2303+
for idx, msg in enumerate(sanitized_messages)
2304+
if idx not in duplicates_to_remove
2305+
]
2306+
22592307
return sanitized_messages
22602308

22612309

tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py

Lines changed: 287 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
BedrockImageProcessor,
1111
_convert_to_bedrock_tool_call_invoke,
1212
ollama_pt,
13+
sanitize_messages_for_tool_calling,
1314
)
1415

1516

@@ -1179,7 +1180,7 @@ def test_bedrock_tools_pt_does_not_handle_system_tool():
11791180
System tools (nova_grounding) should be added via web_search_options,
11801181
not via the tools parameter directly.
11811182
"""
1182-
1183+
11831184
from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_tools_pt
11841185

11851186
# Regular function tools should still work
@@ -1741,3 +1742,288 @@ def test_bedrock_tool_call_invoke_multiple_normal_tools():
17411742
assert len(result) == 2
17421743
assert result[0]["toolUse"]["toolUseId"] == "call_1"
17431744
assert result[1]["toolUse"]["toolUseId"] == "call_2"
1745+
1746+
1747+
# ========================================================================
1748+
# Tool result deduplication tests (Case D in sanitize_messages_for_tool_calling)
1749+
# ========================================================================
1750+
1751+
1752+
def test_sanitize_messages_deduplicates_tool_results():
1753+
"""
1754+
Anthropic requires exactly one tool_result per tool_use. When conversation
1755+
history (e.g. from session resume) contains duplicate tool result messages
1756+
with the same tool_call_id, sanitize_messages_for_tool_calling should keep
1757+
only the last occurrence.
1758+
1759+
Without this fix, Anthropic rejects with:
1760+
each tool_use must have a single result. Found multiple tool_result
1761+
blocks with id: <id>
1762+
"""
1763+
original = litellm.modify_params
1764+
litellm.modify_params = True
1765+
try:
1766+
messages = [
1767+
{"role": "user", "content": "What's the weather?"},
1768+
{
1769+
"role": "assistant",
1770+
"content": None,
1771+
"tool_calls": [
1772+
{
1773+
"id": "call_abc123",
1774+
"type": "function",
1775+
"function": {
1776+
"name": "get_weather",
1777+
"arguments": '{"city": "NYC"}',
1778+
},
1779+
}
1780+
],
1781+
},
1782+
# First tool result (stale/duplicate)
1783+
{
1784+
"role": "tool",
1785+
"tool_call_id": "call_abc123",
1786+
"content": "Partial result...",
1787+
},
1788+
# Second tool result (final/complete — should be kept)
1789+
{
1790+
"role": "tool",
1791+
"tool_call_id": "call_abc123",
1792+
"content": '{"temperature": 72, "condition": "sunny"}',
1793+
},
1794+
]
1795+
1796+
result = sanitize_messages_for_tool_calling(messages)
1797+
1798+
# Count tool messages with this ID — should be exactly 1
1799+
tool_results = [
1800+
m for m in result if m.get("role") == "tool" and m.get("tool_call_id") == "call_abc123"
1801+
]
1802+
assert len(tool_results) == 1
1803+
# Should keep the LAST occurrence (most complete)
1804+
assert tool_results[0]["content"] == '{"temperature": 72, "condition": "sunny"}'
1805+
finally:
1806+
litellm.modify_params = original
1807+
1808+
1809+
def test_sanitize_messages_preserves_unique_tool_results():
1810+
"""
1811+
When each tool_call_id has exactly one tool_result, no deduplication should
1812+
occur. Messages should pass through unchanged.
1813+
"""
1814+
original = litellm.modify_params
1815+
litellm.modify_params = True
1816+
try:
1817+
messages = [
1818+
{"role": "user", "content": "Get weather for two cities"},
1819+
{
1820+
"role": "assistant",
1821+
"content": None,
1822+
"tool_calls": [
1823+
{
1824+
"id": "call_1",
1825+
"type": "function",
1826+
"function": {
1827+
"name": "get_weather",
1828+
"arguments": '{"city": "NYC"}',
1829+
},
1830+
},
1831+
{
1832+
"id": "call_2",
1833+
"type": "function",
1834+
"function": {
1835+
"name": "get_weather",
1836+
"arguments": '{"city": "LA"}',
1837+
},
1838+
},
1839+
],
1840+
},
1841+
{"role": "tool", "tool_call_id": "call_1", "content": "72F"},
1842+
{"role": "tool", "tool_call_id": "call_2", "content": "85F"},
1843+
]
1844+
1845+
result = sanitize_messages_for_tool_calling(messages)
1846+
1847+
tool_results = [m for m in result if m.get("role") == "tool"]
1848+
assert len(tool_results) == 2
1849+
assert tool_results[0]["tool_call_id"] == "call_1"
1850+
assert tool_results[0]["content"] == "72F"
1851+
assert tool_results[1]["tool_call_id"] == "call_2"
1852+
assert tool_results[1]["content"] == "85F"
1853+
finally:
1854+
litellm.modify_params = original
1855+
1856+
1857+
def test_sanitize_messages_dedup_disabled_when_modify_params_false():
1858+
"""
1859+
When litellm.modify_params is False, messages should be returned as-is
1860+
even if they contain duplicate tool results.
1861+
"""
1862+
original = litellm.modify_params
1863+
litellm.modify_params = False
1864+
try:
1865+
messages = [
1866+
{"role": "user", "content": "Test"},
1867+
{
1868+
"role": "assistant",
1869+
"content": None,
1870+
"tool_calls": [
1871+
{
1872+
"id": "call_dup",
1873+
"type": "function",
1874+
"function": {"name": "test", "arguments": "{}"},
1875+
}
1876+
],
1877+
},
1878+
{"role": "tool", "tool_call_id": "call_dup", "content": "first"},
1879+
{"role": "tool", "tool_call_id": "call_dup", "content": "second"},
1880+
]
1881+
1882+
result = sanitize_messages_for_tool_calling(messages)
1883+
1884+
# Should be unchanged — no sanitization when modify_params=False
1885+
assert result == messages
1886+
finally:
1887+
litellm.modify_params = original
1888+
1889+
1890+
def test_sanitize_messages_dedup_scoped_per_turn_preserves_cross_turn():
1891+
"""
1892+
When the same tool_call_id appears in two different assistant turns
1893+
(separated by a user message), both tool results must be preserved.
1894+
Deduplication should only apply within a single contiguous tool-result
1895+
block, not globally across the conversation.
1896+
1897+
Without per-turn scoping this would incorrectly drop the first tool result,
1898+
leaving the first assistant message without its required result (which
1899+
Anthropic would reject).
1900+
"""
1901+
original = litellm.modify_params
1902+
litellm.modify_params = True
1903+
try:
1904+
messages = [
1905+
{"role": "user", "content": "First question"},
1906+
{
1907+
"role": "assistant",
1908+
"content": None,
1909+
"tool_calls": [
1910+
{
1911+
"id": "call_X",
1912+
"type": "function",
1913+
"function": {"name": "lookup", "arguments": '{"q": "a"}'},
1914+
}
1915+
],
1916+
},
1917+
{"role": "tool", "tool_call_id": "call_X", "content": "result_turn_1"},
1918+
{"role": "user", "content": "Second question"},
1919+
{
1920+
"role": "assistant",
1921+
"content": None,
1922+
"tool_calls": [
1923+
{
1924+
"id": "call_X",
1925+
"type": "function",
1926+
"function": {"name": "lookup", "arguments": '{"q": "b"}'},
1927+
}
1928+
],
1929+
},
1930+
{"role": "tool", "tool_call_id": "call_X", "content": "result_turn_2"},
1931+
]
1932+
1933+
result = sanitize_messages_for_tool_calling(messages)
1934+
1935+
# Both tool results must survive — one per turn
1936+
tool_results = [
1937+
m for m in result
1938+
if m.get("role") == "tool" and m.get("tool_call_id") == "call_X"
1939+
]
1940+
assert len(tool_results) == 2, (
1941+
f"Expected 2 tool results (one per turn), got {len(tool_results)}. "
1942+
"Dedup may be global instead of per-turn scoped."
1943+
)
1944+
assert tool_results[0]["content"] == "result_turn_1"
1945+
assert tool_results[1]["content"] == "result_turn_2"
1946+
finally:
1947+
litellm.modify_params = original
1948+
1949+
1950+
def test_sanitize_messages_combined_case_a_and_case_d():
1951+
"""
1952+
Combined Case A + Case D: an assistant message has two tool_calls —
1953+
one with a missing result (Case A should inject a dummy) and one with
1954+
duplicate results (Case D should deduplicate to keep only the last).
1955+
1956+
This validates that both sanitization passes compose correctly without
1957+
interfering with each other.
1958+
"""
1959+
original = litellm.modify_params
1960+
litellm.modify_params = True
1961+
try:
1962+
messages = [
1963+
{"role": "user", "content": "Do two things"},
1964+
{
1965+
"role": "assistant",
1966+
"content": None,
1967+
"tool_calls": [
1968+
{
1969+
"id": "call_missing",
1970+
"type": "function",
1971+
"function": {"name": "tool_a", "arguments": "{}"},
1972+
},
1973+
{
1974+
"id": "call_duped",
1975+
"type": "function",
1976+
"function": {"name": "tool_b", "arguments": '{"q": "x"}'},
1977+
},
1978+
],
1979+
},
1980+
# No result for call_missing — Case A should inject a dummy
1981+
# Duplicate results for call_duped — Case D should keep last
1982+
{"role": "tool", "tool_call_id": "call_duped", "content": "stale_result"},
1983+
{"role": "tool", "tool_call_id": "call_duped", "content": "fresh_result"},
1984+
{"role": "user", "content": "Now summarize"},
1985+
]
1986+
1987+
result = sanitize_messages_for_tool_calling(messages)
1988+
1989+
# Collect tool results from the output
1990+
tool_results = [m for m in result if m.get("role") in ("tool", "function")]
1991+
1992+
# Case A: call_missing should have a dummy result injected
1993+
missing_results = [
1994+
m for m in tool_results if m.get("tool_call_id") == "call_missing"
1995+
]
1996+
assert len(missing_results) == 1, (
1997+
f"Expected 1 dummy result for call_missing (Case A), got {len(missing_results)}"
1998+
)
1999+
2000+
# Case D: call_duped should have exactly 1 result (the fresh one)
2001+
duped_results = [
2002+
m for m in tool_results if m.get("tool_call_id") == "call_duped"
2003+
]
2004+
assert len(duped_results) == 1, (
2005+
f"Expected 1 result for call_duped after dedup (Case D), got {len(duped_results)}"
2006+
)
2007+
assert duped_results[0]["content"] == "fresh_result", (
2008+
f"Expected last-wins 'fresh_result', got '{duped_results[0]['content']}'"
2009+
)
2010+
2011+
# Verify tool results immediately follow the assistant message
2012+
asst_idx = next(
2013+
i for i, m in enumerate(result) if m.get("role") == "assistant"
2014+
)
2015+
tool_msgs_after_asst = [
2016+
m
2017+
for m in result[asst_idx + 1 :]
2018+
if m.get("role") in ("tool", "function")
2019+
]
2020+
assert len(tool_msgs_after_asst) == 2, (
2021+
f"Expected 2 tool results after assistant, got {len(tool_msgs_after_asst)}"
2022+
)
2023+
# Both tool_call_ids should be present (order may vary)
2024+
tool_ids = {m["tool_call_id"] for m in tool_msgs_after_asst}
2025+
assert tool_ids == {"call_missing", "call_duped"}, (
2026+
f"Expected tool_call_ids {{call_missing, call_duped}}, got {tool_ids}"
2027+
)
2028+
finally:
2029+
litellm.modify_params = original

0 commit comments

Comments
 (0)