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:
+2
-84
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
from collections import deque
|
||||
from contextlib import suppress
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
@@ -374,8 +373,7 @@ 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._dedupe_tool_calls(messages)
|
||||
messages_for_model = self._drop_orphan_tool_results(messages_for_model)
|
||||
messages_for_model = self._drop_orphan_tool_results(messages)
|
||||
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)
|
||||
@@ -390,8 +388,7 @@ class AgentRunner:
|
||||
spec.session_key or "default",
|
||||
)
|
||||
try:
|
||||
messages_for_model = self._dedupe_tool_calls(messages)
|
||||
messages_for_model = self._drop_orphan_tool_results(messages_for_model)
|
||||
messages_for_model = self._drop_orphan_tool_results(messages)
|
||||
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
||||
except Exception:
|
||||
messages_for_model = messages
|
||||
@@ -1358,85 +1355,6 @@ class AgentRunner:
|
||||
return truncate_text(content, spec.max_tool_result_chars)
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def _dedupe_tool_calls(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Make duplicate tool_call ids unique while keeping matching results paired.
|
||||
|
||||
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. Some
|
||||
Anthropic-compatible providers also reuse ids for distinct parallel
|
||||
calls, so preserve every call and rewrite later duplicates instead of
|
||||
silently dropping work. Tool results are remapped in call order.
|
||||
"""
|
||||
seen_call_ids: set[str] = set()
|
||||
duplicate_counts: dict[str, int] = {}
|
||||
pending_result_ids: dict[str, deque[str]] = {}
|
||||
updated: list[dict[str, Any]] | None = None
|
||||
|
||||
def _unique_id(raw_id: str) -> str:
|
||||
duplicate_counts[raw_id] = duplicate_counts.get(raw_id, 1) + 1
|
||||
suffix = duplicate_counts[raw_id]
|
||||
while True:
|
||||
candidate = f"{raw_id}__dedupe_{suffix}"
|
||||
if candidate not in seen_call_ids:
|
||||
return candidate
|
||||
suffix += 1
|
||||
|
||||
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 not tid:
|
||||
kept.append(tc)
|
||||
continue
|
||||
raw_id = str(tid)
|
||||
mapped_id = raw_id
|
||||
if raw_id in seen_call_ids:
|
||||
mapped_id = _unique_id(raw_id)
|
||||
changed = True
|
||||
seen_call_ids.add(mapped_id)
|
||||
pending_result_ids.setdefault(raw_id, deque()).append(mapped_id)
|
||||
if mapped_id != tid:
|
||||
tc = dict(tc)
|
||||
tc["id"] = mapped_id
|
||||
changed = True
|
||||
kept.append(tc)
|
||||
if changed:
|
||||
replacement = dict(msg)
|
||||
replacement["tool_calls"] = kept
|
||||
elif role == "tool":
|
||||
tid = msg.get("tool_call_id")
|
||||
if tid:
|
||||
raw_id = str(tid)
|
||||
queue = pending_result_ids.get(raw_id)
|
||||
if not queue:
|
||||
drop = True
|
||||
else:
|
||||
mapped_id = queue.popleft()
|
||||
if not queue:
|
||||
pending_result_ids.pop(raw_id, None)
|
||||
if mapped_id != tid:
|
||||
replacement = dict(msg)
|
||||
replacement["tool_call_id"] = mapped_id
|
||||
|
||||
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]],
|
||||
|
||||
@@ -7,6 +7,7 @@ import hashlib
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
from collections import deque
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
@@ -156,6 +157,42 @@ class AnthropicProvider(LLMProvider):
|
||||
"""Return ``(system, anthropic_messages)``."""
|
||||
system: str | list[dict[str, Any]] = ""
|
||||
raw: list[dict[str, Any]] = []
|
||||
seen_tool_ids: set[str] = set()
|
||||
duplicate_counts: dict[str, int] = {}
|
||||
pending_tool_ids: dict[str, deque[str]] = {}
|
||||
|
||||
def unique_tool_id(value: Any) -> str:
|
||||
raw_id = str(value) if value else _gen_tool_id()
|
||||
mapped_id = _sanitize_tool_id(raw_id)
|
||||
if mapped_id and mapped_id not in seen_tool_ids:
|
||||
seen_tool_ids.add(mapped_id)
|
||||
if value:
|
||||
pending_tool_ids.setdefault(str(value), deque()).append(mapped_id)
|
||||
return mapped_id
|
||||
|
||||
seed = mapped_id or _gen_tool_id()
|
||||
duplicate_counts[seed] = duplicate_counts.get(seed, 1) + 1
|
||||
suffix = duplicate_counts[seed]
|
||||
while True:
|
||||
candidate = f"{seed}__dedupe_{suffix}"
|
||||
if candidate not in seen_tool_ids:
|
||||
seen_tool_ids.add(candidate)
|
||||
if value:
|
||||
pending_tool_ids.setdefault(str(value), deque()).append(candidate)
|
||||
return candidate
|
||||
suffix += 1
|
||||
|
||||
def map_tool_result_id(value: Any) -> str:
|
||||
if not value:
|
||||
return _sanitize_tool_id(value or "")
|
||||
raw_id = str(value)
|
||||
queue = pending_tool_ids.get(raw_id)
|
||||
if queue:
|
||||
mapped_id = queue.popleft()
|
||||
if not queue:
|
||||
pending_tool_ids.pop(raw_id, None)
|
||||
return mapped_id
|
||||
return _sanitize_tool_id(raw_id)
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
@@ -166,7 +203,7 @@ class AnthropicProvider(LLMProvider):
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
block = self._tool_result_block(msg)
|
||||
block = self._tool_result_block(msg, map_tool_result_id=map_tool_result_id)
|
||||
if raw and raw[-1]["role"] == "user":
|
||||
prev_c = raw[-1]["content"]
|
||||
if isinstance(prev_c, list):
|
||||
@@ -180,7 +217,10 @@ class AnthropicProvider(LLMProvider):
|
||||
continue
|
||||
|
||||
if role == "assistant":
|
||||
raw.append({"role": "assistant", "content": self._assistant_blocks(msg)})
|
||||
raw.append({
|
||||
"role": "assistant",
|
||||
"content": self._assistant_blocks(msg, map_tool_id=unique_tool_id),
|
||||
})
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
@@ -193,11 +233,20 @@ class AnthropicProvider(LLMProvider):
|
||||
return system, self._merge_consecutive(raw)
|
||||
|
||||
@staticmethod
|
||||
def _tool_result_block(msg: dict[str, Any]) -> dict[str, Any]:
|
||||
def _tool_result_block(
|
||||
msg: dict[str, Any],
|
||||
*,
|
||||
map_tool_result_id: Callable[[Any], str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
content = msg.get("content")
|
||||
tool_call_id = msg.get("tool_call_id", "")
|
||||
block: dict[str, Any] = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": _sanitize_tool_id(msg.get("tool_call_id", "")),
|
||||
"tool_use_id": (
|
||||
map_tool_result_id(tool_call_id)
|
||||
if map_tool_result_id is not None
|
||||
else _sanitize_tool_id(tool_call_id)
|
||||
),
|
||||
}
|
||||
if isinstance(content, list):
|
||||
block["content"] = AnthropicProvider._convert_user_content(content)
|
||||
@@ -208,7 +257,11 @@ class AnthropicProvider(LLMProvider):
|
||||
return block
|
||||
|
||||
@staticmethod
|
||||
def _assistant_blocks(msg: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
def _assistant_blocks(
|
||||
msg: dict[str, Any],
|
||||
*,
|
||||
map_tool_id: Callable[[Any], str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
blocks: list[dict[str, Any]] = []
|
||||
content = msg.get("content")
|
||||
|
||||
@@ -231,9 +284,10 @@ class AnthropicProvider(LLMProvider):
|
||||
continue
|
||||
func = tc.get("function", {})
|
||||
args = func.get("arguments", "{}")
|
||||
raw_id = tc.get("id") or _gen_tool_id()
|
||||
blocks.append({
|
||||
"type": "tool_use",
|
||||
"id": _sanitize_tool_id(tc.get("id") or _gen_tool_id()),
|
||||
"id": map_tool_id(raw_id) if map_tool_id is not None else _sanitize_tool_id(raw_id),
|
||||
"name": func.get("name", ""),
|
||||
"input": tool_arguments_object_for_replay(args),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user