Hyeonsang Cho f7f4a022e6
fix(agents): remove provider tool-call blocks when guards strip calls (#5447)
* fix(agents): remove provider tool-call blocks when guards strip calls

Token-budget and loop-detection hard stops, subagent-limit truncation,
and safety-finish-reason suppression removed calls from tool_calls and
the raw additional_kwargs payload, but left the provider's own
tool-call blocks in AIMessage.content. Provider adapters re-serialize
those blocks: langchain_anthropic sends a tool_use block whose id is
not in tool_calls, and the OpenAI Responses input builder sends every
function_call block. ChatAnthropic stores any tool-calling response as
a block list, so a guard firing on a Claude tool call always left a
tool_use without a tool_result. A truncated subagent call failed the
next model request of the same run; a hard stop was checkpointed under
the same message id and failed every later turn of the thread.

clone_ai_message_with_tool_calls now trims content tool-call blocks to
the calls that remain on the message: tool_use and LangChain v1
tool_call/tool_call_chunk by id, Responses function_call and
custom_tool_call by call_id (their id is the fc_ item id), Google GenAI
function_call by id, and id-less blocks by name in order. Blocks for
calls still on invalid_tool_calls stay, because
DanglingToolCallMiddleware answers those calls with placeholder
results. The token-budget and loop-detection hard stops now build their
messages through the helper instead of their own copies, and
ClarificationMiddleware drops its private filter, which matched
Responses blocks by item id.

* docs(changelog): reference #5447 in the orphaned tool-call block entry

* fix(agents): skip id-matched calls in the id-less block budget

The name budget for id-less content tool-call blocks counted every
retained call, including calls whose own id-bearing block had already
matched. In mixed-shape content, a retained call "a" with a
function_call block carrying id "a" also let a same-named id-less block
survive, leaving the unpaired block this helper exists to remove.

Collect the retained ids that id-bearing blocks matched first, and build
the name budget only from retained calls outside that set. Content with
no id-bearing blocks keeps the full budget, so the Gemini path is
unchanged.
2026-09-15 22:22:12 +08:00

130 lines
5.6 KiB
Python

"""Helpers for keeping AIMessage tool-call metadata consistent."""
from __future__ import annotations
from collections import Counter
from typing import Any
from langchain_core.messages import AIMessage
# Content block types that provider adapters re-serialize as tool calls,
# mapped to the keys (in priority order) holding the id that pairs with
# ``AIMessage.tool_calls[*]["id"]``. These mirror the content surfaces in
# ``tool_call_args``. Anthropic re-emits a ``tool_use`` block whose id is
# absent from ``tool_calls``; the OpenAI Responses input builder re-emits
# every ``function_call``/``custom_tool_call`` block, whose ``id`` is the
# ``fc_…`` item id and whose ``call_id`` is the tool-call id; Google GenAI
# ``function_call`` blocks carry the tool-call id in ``id``, or none at all;
# LangChain v1 ``tool_call``/``tool_call_chunk`` blocks convert back to any of
# these shapes.
_CONTENT_TOOL_CALL_ID_KEYS: dict[str, tuple[str, ...]] = {
"tool_use": ("id",),
"function_call": ("call_id", "id"),
"custom_tool_call": ("call_id",),
"tool_call": ("id",),
"tool_call_chunk": ("id",),
}
def _raw_tool_call_id(raw_tool_call: Any) -> str | None:
if not isinstance(raw_tool_call, dict):
return None
raw_id = raw_tool_call.get("id")
return raw_id if isinstance(raw_id, str) and raw_id else None
def _content_block_call_id(block: dict[str, Any], id_keys: tuple[str, ...]) -> str | None:
for key in id_keys:
value = block.get(key)
if isinstance(value, str) and value:
return value
return None
def _tool_call_block_id_keys(block: Any) -> tuple[str, ...] | None:
block_type = block.get("type") if isinstance(block, dict) else None
return _CONTENT_TOOL_CALL_ID_KEYS.get(block_type) if isinstance(block_type, str) else None
def _sync_content_tool_call_blocks(content: Any, retained_calls: list[dict[str, Any]]) -> Any:
"""Drop content tool-call blocks whose call is no longer on the message.
A block left behind is sent as a tool call with no matching tool result,
which Anthropic and the OpenAI Responses API reject on every later request
for the thread. Blocks without an id (Gemini-style ``function_call``) pair
by name, in order, with the retained calls that no id-bearing block already
matched. Returns ``content`` itself when no block is dropped.
"""
if not isinstance(content, list):
return content
retained_ids = {call["id"] for call in retained_calls if isinstance(call.get("id"), str) and call["id"]}
# (block, is a tool-call block, the call id it carries or None when id-less)
entries: list[tuple[Any, bool, str | None]] = []
for block in content:
id_keys = _tool_call_block_id_keys(block)
entries.append((block, id_keys is not None, _content_block_call_id(block, id_keys) if id_keys is not None else None))
matched_ids = {call_id for _, _, call_id in entries if call_id in retained_ids}
idless_budget = Counter(call["name"] for call in retained_calls if isinstance(call.get("name"), str) and not (isinstance(call.get("id"), str) and call["id"] in matched_ids))
synced: list[Any] = []
for block, is_tool_call_block, call_id in entries:
if not is_tool_call_block:
synced.append(block)
continue
if call_id is not None:
if call_id in retained_ids:
synced.append(block)
continue
name = block.get("name")
if isinstance(name, str) and idless_budget[name] > 0:
idless_budget[name] -= 1
synced.append(block)
return content if len(synced) == len(content) else synced
def clone_ai_message_with_tool_calls(
message: AIMessage,
tool_calls: list[dict[str, Any]],
*,
content: Any | None = None,
) -> AIMessage:
"""Clone an AIMessage while keeping every provider tool-call surface in sync.
Besides ``tool_calls``, the raw ``additional_kwargs`` payload and the
provider tool-call blocks in ``content`` (including a caller-supplied
``content``) are trimmed to the retained calls. Blocks for calls still on
``invalid_tool_calls`` are kept, because ``DanglingToolCallMiddleware``
answers those calls with placeholder tool results.
"""
kept_ids = {tc["id"] for tc in tool_calls if isinstance(tc.get("id"), str) and tc["id"]}
update: dict[str, Any] = {"tool_calls": tool_calls}
invalid_tool_calls = [tc for tc in (getattr(message, "invalid_tool_calls", None) or []) if isinstance(tc, dict)]
source_content = content if content is not None else message.content
synced_content = _sync_content_tool_call_blocks(source_content, [*tool_calls, *invalid_tool_calls])
if content is not None or synced_content is not message.content:
update["content"] = synced_content
additional_kwargs = dict(getattr(message, "additional_kwargs", {}) or {})
raw_tool_calls = additional_kwargs.get("tool_calls")
if isinstance(raw_tool_calls, list):
synced_raw_tool_calls = [raw_tc for raw_tc in raw_tool_calls if _raw_tool_call_id(raw_tc) in kept_ids]
if synced_raw_tool_calls:
additional_kwargs["tool_calls"] = synced_raw_tool_calls
else:
additional_kwargs.pop("tool_calls", None)
if not tool_calls:
additional_kwargs.pop("function_call", None)
update["additional_kwargs"] = additional_kwargs
response_metadata = dict(getattr(message, "response_metadata", {}) or {})
if not tool_calls and response_metadata.get("finish_reason") == "tool_calls":
response_metadata["finish_reason"] = "stop"
update["response_metadata"] = response_metadata
return message.model_copy(update=update)