fix: keep duplicate id repair in Anthropic provider
maintainer edit: move duplicate tool_use history repair out of AgentRunner and into Anthropic message conversion, reusing the OpenAI-compatible queue-mapping approach locally without broadening the shared runner path.
This commit is contained in:
@@ -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
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
|
||||
@@ -184,64 +184,6 @@ async def test_backfill_missing_tool_results_inserts_error():
|
||||
assert backfilled[0]["name"] == "read_file"
|
||||
|
||||
|
||||
def test_dedupe_tool_calls_remaps_duplicate_ids():
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "a", "type": "function", "function": {"name": "x", "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"},
|
||||
{"role": "tool", "tool_call_id": "b", "name": "y", "content": "rb-remapped"},
|
||||
]
|
||||
|
||||
cleaned = AgentRunner._dedupe_tool_calls(messages)
|
||||
|
||||
assert cleaned == [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "a", "type": "function", "function": {"name": "x", "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"},
|
||||
]
|
||||
|
||||
|
||||
def test_dedupe_tool_calls_noop_when_unique():
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "a", "type": "function", "function": {"name": "x", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "a", "name": "x", "content": "ra"},
|
||||
]
|
||||
|
||||
# No duplicates -> identical list object returned (cheap no-op path).
|
||||
assert AgentRunner._dedupe_tool_calls(messages) is messages
|
||||
|
||||
|
||||
def test_drop_orphan_tool_results_removes_unmatched_tool_messages():
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
|
||||
@@ -136,6 +136,52 @@ def test_anthropic_sanitized_tool_ids_avoid_simple_collisions():
|
||||
assert all(all(ch.isalnum() or ch in "_-" for ch in tool_id) for tool_id in ids)
|
||||
|
||||
|
||||
def test_anthropic_convert_messages_remaps_duplicate_history_tool_ids():
|
||||
provider = AnthropicProvider.__new__(AnthropicProvider)
|
||||
|
||||
_system, messages = provider._convert_messages([
|
||||
{"role": "user", "content": "check both files"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "toolu_same",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": '{"path":"a.txt"}'},
|
||||
},
|
||||
{
|
||||
"id": "toolu_same",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": '{"path":"b.txt"}'},
|
||||
},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "toolu_same", "name": "read_file", "content": "a"},
|
||||
{"role": "tool", "tool_call_id": "toolu_same", "name": "read_file", "content": "b"},
|
||||
])
|
||||
|
||||
tool_uses = [
|
||||
block
|
||||
for block in messages[1]["content"]
|
||||
if isinstance(block, dict) and block.get("type") == "tool_use"
|
||||
]
|
||||
tool_results = [
|
||||
block
|
||||
for block in messages[2]["content"]
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result"
|
||||
]
|
||||
tool_use_ids = [block["id"] for block in tool_uses]
|
||||
tool_result_ids = [block["tool_use_id"] for block in tool_results]
|
||||
|
||||
assert len(tool_use_ids) == 2
|
||||
assert tool_use_ids[0] == "toolu_same"
|
||||
assert tool_use_ids[1] == "toolu_same__dedupe_2"
|
||||
assert tool_result_ids == tool_use_ids
|
||||
assert tool_uses[0]["input"] == {"path": "a.txt"}
|
||||
assert tool_uses[1]["input"] == {"path": "b.txt"}
|
||||
|
||||
|
||||
def test_anthropic_parse_response_remaps_duplicate_tool_use_ids():
|
||||
response = SimpleNamespace(
|
||||
content=[
|
||||
|
||||
Reference in New Issue
Block a user