fix: preserve duplicate-id tool calls

maintainer edit: remap duplicate tool_use/tool_call ids instead of dropping later calls, so Anthropic-compatible providers that reuse ids for distinct parallel tool calls keep all requested work while still sending unique ids.
This commit is contained in:
chengyongru
2026-06-24 10:21:48 +08:00
committed by Xubin Ren
parent 6b8e832ba5
commit 853aecdb97
4 changed files with 93 additions and 28 deletions
+9 -8
View File
@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest
from nanobot.providers.base import LLMResponse
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@@ -184,7 +184,7 @@ async def test_backfill_missing_tool_results_inserts_error():
assert backfilled[0]["name"] == "read_file"
def test_dedupe_tool_calls_removes_duplicate_ids():
def test_dedupe_tool_calls_remaps_duplicate_ids():
from nanobot.agent.runner import AgentRunner
messages = [
@@ -194,15 +194,14 @@ def test_dedupe_tool_calls_removes_duplicate_ids():
"content": "",
"tool_calls": [
{"id": "a", "type": "function", "function": {"name": "x", "arguments": "{}"}},
{"id": "b", "type": "function", "function": {"name": "y", "arguments": "{}"}},
# Duplicate of "b" — would trigger "tool_use ids must be unique".
{"id": "b", "type": "function", "function": {"name": "y", "arguments": "{}"}},
{"id": "b", "type": "function", "function": {"name": "y", "arguments": '{"path":"a.txt"}'}},
# Duplicate id with different arguments should be preserved, not dropped.
{"id": "b", "type": "function", "function": {"name": "y", "arguments": '{"path":"b.txt"}'}},
],
},
{"role": "tool", "tool_call_id": "a", "name": "x", "content": "ra"},
{"role": "tool", "tool_call_id": "b", "name": "y", "content": "rb"},
# Duplicate result for "b".
{"role": "tool", "tool_call_id": "b", "name": "y", "content": "rb-dup"},
{"role": "tool", "tool_call_id": "b", "name": "y", "content": "rb-remapped"},
]
cleaned = AgentRunner._dedupe_tool_calls(messages)
@@ -214,11 +213,13 @@ def test_dedupe_tool_calls_removes_duplicate_ids():
"content": "",
"tool_calls": [
{"id": "a", "type": "function", "function": {"name": "x", "arguments": "{}"}},
{"id": "b", "type": "function", "function": {"name": "y", "arguments": "{}"}},
{"id": "b", "type": "function", "function": {"name": "y", "arguments": '{"path":"a.txt"}'}},
{"id": "b__dedupe_2", "type": "function", "function": {"name": "y", "arguments": '{"path":"b.txt"}'}},
],
},
{"role": "tool", "tool_call_id": "a", "name": "x", "content": "ra"},
{"role": "tool", "tool_call_id": "b", "name": "y", "content": "rb"},
{"role": "tool", "tool_call_id": "b__dedupe_2", "name": "y", "content": "rb-remapped"},
]
@@ -10,6 +10,8 @@ Also tests that bare dicts without a "type" field are coerced to text
blocks, fixing Anthropic "content.0.type: Field required" rejections (#3993).
"""
from types import SimpleNamespace
from nanobot.providers.anthropic_provider import AnthropicProvider
@@ -132,3 +134,33 @@ def test_anthropic_sanitized_tool_ids_avoid_simple_collisions():
ids = [block["id"] for block in blocks if block["type"] == "tool_use"]
assert len(ids) == len(set(ids)) == 2
assert all(all(ch.isalnum() or ch in "_-" for ch in tool_id) for tool_id in ids)
def test_anthropic_parse_response_remaps_duplicate_tool_use_ids():
response = SimpleNamespace(
content=[
SimpleNamespace(
type="tool_use",
id="toolu_same",
name="read_file",
input={"path": "a.txt"},
),
SimpleNamespace(
type="tool_use",
id="toolu_same",
name="read_file",
input={"path": "b.txt"},
),
],
stop_reason="tool_use",
usage=None,
)
result = AnthropicProvider._parse_response(response)
assert len(result.tool_calls) == 2
assert result.tool_calls[0].id == "toolu_same"
assert result.tool_calls[0].arguments == {"path": "a.txt"}
assert result.tool_calls[1].id != "toolu_same"
assert result.tool_calls[1].id.startswith("toolu_")
assert result.tool_calls[1].arguments == {"path": "b.txt"}