mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 11:06:18 +00:00
* 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.
195 lines
9.6 KiB
Python
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"] == []
|