From 6689e2d3774c2dd868cc2d7cf8946cc56b6829c4 Mon Sep 17 00:00:00 2001 From: Teddy Yan Date: Sun, 21 Jun 2026 22:08:27 +0000 Subject: [PATCH] fix(providers): dedupe tool_use ids to prevent Anthropic 400s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- nanobot/agent/runner.py | 58 ++++++++++++++++++++++++- nanobot/providers/anthropic_provider.py | 10 +++++ tests/agent/test_runner_governance.py | 57 ++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index e3398ecb..df1001d5 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -373,7 +373,8 @@ class AgentRunner: # may repair or compact historical messages for the model, but # those synthetic edits must not shift the append boundary used # 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._microcompact(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", ) 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) except Exception: messages_for_model = messages @@ -1355,6 +1357,58 @@ class AgentRunner: return truncate_text(content, spec.max_tool_result_chars) 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 def _drop_orphan_tool_results( messages: list[dict[str, Any]], diff --git a/nanobot/providers/anthropic_provider.py b/nanobot/providers/anthropic_provider.py index 58d4265a..3d0c5322 100644 --- a/nanobot/providers/anthropic_provider.py +++ b/nanobot/providers/anthropic_provider.py @@ -10,6 +10,8 @@ import string from collections.abc import Awaitable, Callable from typing import Any +from loguru import logger + from nanobot.providers.base import ( LLMProvider, LLMResponse, @@ -522,11 +524,19 @@ class AnthropicProvider(LLMProvider): content_parts: list[str] = [] tool_calls: list[ToolCallRequest] = [] thinking_blocks: list[dict[str, Any]] = [] + seen_tool_ids: set[str] = set() for block in response.content: if block.type == "text": content_parts.append(block.text) 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( id=block.id, name=block.name, diff --git a/tests/agent/test_runner_governance.py b/tests/agent/test_runner_governance.py index 901afc71..fbf79f90 100644 --- a/tests/agent/test_runner_governance.py +++ b/tests/agent/test_runner_governance.py @@ -184,6 +184,63 @@ async def test_backfill_missing_tool_results_inserts_error(): 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(): from nanobot.agent.runner import AgentRunner