mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 09:28:34 +00:00
fix(providers): dedupe tool_use ids to prevent Anthropic 400s
Anthropic rejects any request where two tool_use blocks share an id
("messages.N.content.M: tool_use ids must be unique"). A mis-assembled
stream could surface the same tool_use block twice in one assistant turn;
the runner persisted it verbatim, so the malformed message was re-sent on
every subsequent turn and permanently bricked the session — the agent
silently stopped replying.
Fix at two layers:
- AnthropicProvider._parse_response: drop duplicate tool_use ids (keep
first) as the response enters nanobot, so corruption is never persisted.
- AgentRunner._dedup_tool_calls: a new context-governance pass that dedupes
assistant tool_calls and tool results by id before each send, healing any
history that was already corrupted.
Add regression tests covering both the dedup and the no-op fast path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
160cec2396
commit
6689e2d377
@ -373,7 +373,8 @@ class AgentRunner:
|
|||||||
# may repair or compact historical messages for the model, but
|
# may repair or compact historical messages for the model, but
|
||||||
# those synthetic edits must not shift the append boundary used
|
# those synthetic edits must not shift the append boundary used
|
||||||
# later when the caller saves only the new turn.
|
# later when the caller saves only the new turn.
|
||||||
messages_for_model = self._drop_orphan_tool_results(messages)
|
messages_for_model = self._dedup_tool_calls(messages)
|
||||||
|
messages_for_model = self._drop_orphan_tool_results(messages_for_model)
|
||||||
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
||||||
messages_for_model = self._microcompact(messages_for_model)
|
messages_for_model = self._microcompact(messages_for_model)
|
||||||
messages_for_model = self._apply_tool_result_budget(spec, messages_for_model)
|
messages_for_model = self._apply_tool_result_budget(spec, messages_for_model)
|
||||||
@ -388,7 +389,8 @@ class AgentRunner:
|
|||||||
spec.session_key or "default",
|
spec.session_key or "default",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
messages_for_model = self._drop_orphan_tool_results(messages)
|
messages_for_model = self._dedup_tool_calls(messages)
|
||||||
|
messages_for_model = self._drop_orphan_tool_results(messages_for_model)
|
||||||
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
||||||
except Exception:
|
except Exception:
|
||||||
messages_for_model = messages
|
messages_for_model = messages
|
||||||
@ -1355,6 +1357,58 @@ class AgentRunner:
|
|||||||
return truncate_text(content, spec.max_tool_result_chars)
|
return truncate_text(content, spec.max_tool_result_chars)
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _dedup_tool_calls(
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Remove duplicate tool_call / tool_result ids from the history.
|
||||||
|
|
||||||
|
Anthropic rejects any request where two tool_use blocks share an id
|
||||||
|
("tool_use ids must be unique"). An accidental duplicate (e.g. from a
|
||||||
|
mis-assembled stream) otherwise poisons the session permanently, since
|
||||||
|
the bad message is re-sent on every turn. Keep the first occurrence of
|
||||||
|
each id across all assistant tool_calls, and likewise keep only the
|
||||||
|
first tool result per id so pairing stays 1:1.
|
||||||
|
"""
|
||||||
|
seen_call_ids: set[str] = set()
|
||||||
|
seen_result_ids: set[str] = set()
|
||||||
|
updated: list[dict[str, Any]] | None = None
|
||||||
|
for idx, msg in enumerate(messages):
|
||||||
|
role = msg.get("role")
|
||||||
|
replacement: dict[str, Any] | None = None
|
||||||
|
drop = False
|
||||||
|
|
||||||
|
if role == "assistant" and msg.get("tool_calls"):
|
||||||
|
kept = []
|
||||||
|
changed = False
|
||||||
|
for tc in msg.get("tool_calls") or []:
|
||||||
|
tid = tc.get("id") if isinstance(tc, dict) else None
|
||||||
|
if tid and tid in seen_call_ids:
|
||||||
|
changed = True
|
||||||
|
continue
|
||||||
|
if tid:
|
||||||
|
seen_call_ids.add(tid)
|
||||||
|
kept.append(tc)
|
||||||
|
if changed:
|
||||||
|
replacement = dict(msg)
|
||||||
|
replacement["tool_calls"] = kept
|
||||||
|
elif role == "tool":
|
||||||
|
tid = msg.get("tool_call_id")
|
||||||
|
if tid:
|
||||||
|
if str(tid) in seen_result_ids:
|
||||||
|
drop = True
|
||||||
|
else:
|
||||||
|
seen_result_ids.add(str(tid))
|
||||||
|
|
||||||
|
if (replacement is not None or drop) and updated is None:
|
||||||
|
updated = [dict(m) for m in messages[:idx]]
|
||||||
|
if drop:
|
||||||
|
continue
|
||||||
|
if updated is not None:
|
||||||
|
updated.append(replacement if replacement is not None else dict(msg))
|
||||||
|
|
||||||
|
return messages if updated is None else updated
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _drop_orphan_tool_results(
|
def _drop_orphan_tool_results(
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import string
|
|||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.providers.base import (
|
from nanobot.providers.base import (
|
||||||
LLMProvider,
|
LLMProvider,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
@ -522,11 +524,19 @@ class AnthropicProvider(LLMProvider):
|
|||||||
content_parts: list[str] = []
|
content_parts: list[str] = []
|
||||||
tool_calls: list[ToolCallRequest] = []
|
tool_calls: list[ToolCallRequest] = []
|
||||||
thinking_blocks: list[dict[str, Any]] = []
|
thinking_blocks: list[dict[str, Any]] = []
|
||||||
|
seen_tool_ids: set[str] = set()
|
||||||
|
|
||||||
for block in response.content:
|
for block in response.content:
|
||||||
if block.type == "text":
|
if block.type == "text":
|
||||||
content_parts.append(block.text)
|
content_parts.append(block.text)
|
||||||
elif block.type == "tool_use":
|
elif block.type == "tool_use":
|
||||||
|
# Anthropic requires every tool_use id to be unique. A mis-assembled
|
||||||
|
# stream can occasionally surface the same block twice; keep the first
|
||||||
|
# and drop the duplicate so it never poisons the persisted history.
|
||||||
|
if block.id in seen_tool_ids:
|
||||||
|
logger.warning("dropping duplicate tool_use id from response: {}", block.id)
|
||||||
|
continue
|
||||||
|
seen_tool_ids.add(block.id)
|
||||||
tool_calls.append(ToolCallRequest(
|
tool_calls.append(ToolCallRequest(
|
||||||
id=block.id,
|
id=block.id,
|
||||||
name=block.name,
|
name=block.name,
|
||||||
|
|||||||
@ -184,6 +184,63 @@ async def test_backfill_missing_tool_results_inserts_error():
|
|||||||
assert backfilled[0]["name"] == "read_file"
|
assert backfilled[0]["name"] == "read_file"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_tool_calls_removes_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": "{}"}},
|
||||||
|
# Duplicate of "b" — would trigger "tool_use ids must be unique".
|
||||||
|
{"id": "b", "type": "function", "function": {"name": "y", "arguments": "{}"}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"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"},
|
||||||
|
]
|
||||||
|
|
||||||
|
cleaned = AgentRunner._dedup_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": "{}"}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"role": "tool", "tool_call_id": "a", "name": "x", "content": "ra"},
|
||||||
|
{"role": "tool", "tool_call_id": "b", "name": "y", "content": "rb"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_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._dedup_tool_calls(messages) is messages
|
||||||
|
|
||||||
|
|
||||||
def test_drop_orphan_tool_results_removes_unmatched_tool_messages():
|
def test_drop_orphan_tool_results_removes_unmatched_tool_messages():
|
||||||
from nanobot.agent.runner import AgentRunner
|
from nanobot.agent.runner import AgentRunner
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user