deer-flow/backend/tests/test_tool_call_metadata.py
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

195 lines
9.6 KiB
Python

"""Tests for keeping AIMessage tool-call surfaces consistent when calls are removed.
Provider adapters re-serialize tool calls from ``AIMessage.content`` blocks, not
only from ``tool_calls``. A guard that strips a call from ``tool_calls`` but
leaves its content block behind sends the provider a tool call with no matching
tool result, which Anthropic and the OpenAI Responses API reject on every later
request for that thread.
"""
import pytest
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_openai import ChatOpenAI
from deerflow.agents.middlewares.tool_call_metadata import clone_ai_message_with_tool_calls
from deerflow.models.claude_provider import ClaudeChatModel
# DeerFlow deployments use ClaudeChatModel, which post-processes the payload
# built by the upstream ChatAnthropic formatter; pin both.
_ANTHROPIC_MODEL_CLASSES = pytest.mark.parametrize("model_class", [ChatAnthropic, ClaudeChatModel], ids=["ChatAnthropic", "ClaudeChatModel"])
def _call(call_id: str, name: str = "bash") -> dict:
return {"id": call_id, "name": name, "args": {}}
def _tool_use(call_id: str, name: str = "bash") -> dict:
return {"type": "tool_use", "id": call_id, "name": name, "input": {}}
class TestContentToolCallBlockSync:
def test_drops_anthropic_tool_use_blocks_for_removed_calls(self):
message = AIMessage(
content=[{"type": "text", "text": "running"}, _tool_use("a"), _tool_use("b")],
tool_calls=[_call("a"), _call("b")],
)
cloned = clone_ai_message_with_tool_calls(message, [_call("a")])
assert cloned.content == [{"type": "text", "text": "running"}, _tool_use("a")]
assert [tc["id"] for tc in cloned.tool_calls] == ["a"]
def test_clearing_every_call_keeps_non_tool_call_blocks(self):
thinking = {"type": "thinking", "thinking": "hmm", "signature": "sig"}
server_tool = {"type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": {}}
message = AIMessage(content=[thinking, server_tool, _tool_use("a")], tool_calls=[_call("a")])
cloned = clone_ai_message_with_tool_calls(message, [])
assert cloned.content == [thinking, server_tool]
def test_filters_caller_supplied_content(self):
message = AIMessage(content=[_tool_use("a")], tool_calls=[_call("a")])
stop_note = {"type": "text", "text": "stopped"}
cloned = clone_ai_message_with_tool_calls(message, [], content=[*message.content, stop_note])
assert cloned.content == [stop_note]
def test_openai_responses_blocks_match_by_call_id_not_item_id(self):
kept = {"type": "function_call", "id": "fc_1", "call_id": "a", "name": "bash", "arguments": "{}"}
dropped = {"type": "function_call", "id": "fc_2", "call_id": "b", "name": "bash", "arguments": "{}"}
dropped_custom = {"type": "custom_tool_call", "id": "ctc_3", "call_id": "c", "name": "patch", "input": "x"}
message = AIMessage(
content=[kept, dropped, dropped_custom],
tool_calls=[_call("a"), _call("b"), _call("c", "patch")],
)
cloned = clone_ai_message_with_tool_calls(message, [_call("a")])
assert cloned.content == [kept]
def test_langchain_standard_tool_call_blocks_match_by_id(self):
kept = {"type": "tool_call", "id": "a", "name": "bash", "args": {}}
dropped = {"type": "tool_call", "id": "b", "name": "bash", "args": {}}
dropped_chunk = {"type": "tool_call_chunk", "id": "c", "name": "bash", "args": "{}", "index": 2}
message = AIMessage(content=[kept, dropped, dropped_chunk], tool_calls=[_call("a"), _call("b"), _call("c")])
cloned = clone_ai_message_with_tool_calls(message, [_call("a")])
assert cloned.content == [kept]
def test_google_genai_function_call_blocks_match_by_id(self):
# Keep the second of two same-named calls: an id match keeps its own
# block, where falling back to name order would keep the first one.
dropped = {"type": "function_call", "id": "a", "name": "bash", "args": {}}
kept = {"type": "function_call", "id": "b", "name": "bash", "args": {}}
message = AIMessage(content=[dropped, kept], tool_calls=[_call("a"), _call("b")])
cloned = clone_ai_message_with_tool_calls(message, [_call("b")])
assert cloned.content == [kept]
def test_idless_blocks_keep_only_as_many_per_name_as_retained_calls(self):
# Gemini-style blocks carry no id, so they pair with retained calls by
# name, in order: truncating four same-named calls to three keeps three.
blocks = [{"type": "function_call", "name": "task", "args": {"n": n}} for n in range(4)]
calls = [_call(f"t{n}", "task") for n in range(4)]
message = AIMessage(content=list(blocks), tool_calls=calls)
cloned = clone_ai_message_with_tool_calls(message, calls[:3])
assert cloned.content == blocks[:3]
def test_idless_budget_skips_calls_already_paired_by_id(self):
# A retained call whose own id-bearing block matched must not also let
# a same-named id-less block survive on the name budget.
with_id = {"type": "function_call", "id": "a", "name": "bash", "args": {}}
idless = {"type": "function_call", "name": "bash", "args": {}}
message = AIMessage(content=[with_id, idless], tool_calls=[_call("a"), _call("b")])
assert clone_ai_message_with_tool_calls(message, [_call("a")]).content == [with_id]
assert clone_ai_message_with_tool_calls(message, [_call("a"), _call("b")]).content is message.content
def test_keeps_blocks_for_calls_that_remain_invalid_tool_calls(self):
# DanglingToolCallMiddleware answers invalid_tool_calls with a placeholder
# ToolMessage, so their content block must stay to pair with it.
message = AIMessage(
content=[_tool_use("bad"), _tool_use("a")],
tool_calls=[_call("a")],
invalid_tool_calls=[{"type": "invalid_tool_call", "id": "bad", "name": "bash", "args": "{", "error": "parse"}],
)
cloned = clone_ai_message_with_tool_calls(message, [])
assert cloned.content == [_tool_use("bad")]
assert [tc["id"] for tc in cloned.invalid_tool_calls] == ["bad"]
def test_leaves_content_untouched_when_every_block_is_still_paired(self):
content = [{"type": "text", "text": "hi"}, _tool_use("a")]
message = AIMessage(content=content, tool_calls=[_call("a")])
cloned = clone_ai_message_with_tool_calls(message, [_call("a")])
assert cloned.content is message.content
def test_string_content_is_unchanged(self):
message = AIMessage(content="plain", tool_calls=[_call("a")])
assert clone_ai_message_with_tool_calls(message, []).content == "plain"
def _anthropic_payload_turns(model_class: type[ChatAnthropic], messages: list) -> list[tuple[str, list[str]]]:
payload = model_class(model="claude-sonnet-4-5", anthropic_api_key="sk-ant-offline")._get_request_payload(messages)
turns = []
for turn in payload["messages"]:
blocks = turn["content"] if isinstance(turn["content"], list) else [{"type": "text"}]
turns.append((turn["role"], [block.get("id") or block.get("tool_use_id") or block["type"] for block in blocks if block["type"] in ("tool_use", "tool_result")]))
return turns
class TestProviderRequestContract:
"""Drive the real provider request builders offline; no network is used."""
@_ANTHROPIC_MODEL_CLASSES
def test_anthropic_request_pairs_every_tool_use_after_calls_are_cleared(self, model_class):
message = AIMessage(
content=[{"type": "text", "text": "reading"}, _tool_use("toolu_a")],
tool_calls=[_call("toolu_a")],
response_metadata={"model_provider": "anthropic"},
)
stopped = clone_ai_message_with_tool_calls(message, [], content=[*message.content, {"type": "text", "text": "stopped"}])
turns = _anthropic_payload_turns(model_class, [HumanMessage("hi"), stopped, HumanMessage("continue")])
assert turns == [("user", []), ("assistant", []), ("user", [])]
@_ANTHROPIC_MODEL_CLASSES
def test_anthropic_request_pairs_every_tool_use_after_calls_are_truncated(self, model_class):
calls = [_call(f"toolu_{n}", "task") for n in range(3)]
message = AIMessage(
content=[_tool_use(call["id"], "task") for call in calls],
tool_calls=calls,
response_metadata={"model_provider": "anthropic"},
)
truncated = clone_ai_message_with_tool_calls(message, calls[:2])
results = [ToolMessage(content="ok", tool_call_id=call["id"]) for call in truncated.tool_calls]
turns = _anthropic_payload_turns(model_class, [HumanMessage("go"), truncated, *results])
assert turns == [("user", []), ("assistant", ["toolu_0", "toolu_1"]), ("user", ["toolu_0", "toolu_1"])]
def test_openai_responses_request_drops_function_call_after_calls_are_cleared(self):
message = AIMessage(
content=[{"type": "function_call", "id": "fc_1", "call_id": "call_a", "name": "bash", "arguments": "{}"}],
tool_calls=[_call("call_a")],
response_metadata={"model_provider": "openai"},
)
stopped = clone_ai_message_with_tool_calls(message, [], content=[*message.content, {"type": "text", "text": "stopped"}])
llm = ChatOpenAI(model="gpt-5", api_key="sk-offline", use_responses_api=True, output_version="responses/v1")
payload = llm._get_request_payload([HumanMessage("hi"), stopped, HumanMessage("continue")])
assert [item for item in payload["input"] if item.get("type") == "function_call"] == []