mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-10 22:18:59 +00:00
fix(middleware): skip raw tool-call fallback when invalid view carries the same call (#4693)
DanglingToolCallMiddleware._message_tool_calls collected the raw additional_kwargs tool_calls payload whenever structured tool_calls was empty, even when invalid_tool_calls was non-empty. The raw payload is a fallback serialization of the SAME calls — the OpenAI serializer reaches for it only once both structured views are empty, which is exactly the gating _normalize_tool_call_ids documents and implements. Collecting it alongside a same-id invalid entry counted the call twice and emitted two placeholder ToolMessages for one id — the duplicate-id shape strict OpenAI-compatible providers reject with HTTP 400, the failure this middleware exists to prevent. Gate the raw collection on both structured views being empty, aligning _message_tool_calls with _normalize_tool_call_ids.
This commit is contained in:
parent
295d7c2abc
commit
7b57609656
@ -195,7 +195,13 @@ class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
|
||||
normalized.append(normalized_call)
|
||||
|
||||
raw_tool_calls = (getattr(msg, "additional_kwargs", None) or {}).get("tool_calls") or []
|
||||
if not tool_calls:
|
||||
# The raw payload is a fallback serialization of the same calls: the
|
||||
# OpenAI serializer reaches for it only once BOTH structured views are
|
||||
# empty (mirrors the gating in _normalize_tool_call_ids). Collecting
|
||||
# it while invalid_tool_calls is non-empty would count the same call
|
||||
# twice and emit two ToolMessages for one id — the exact duplicate-id
|
||||
# shape strict providers reject.
|
||||
if not tool_calls and not getattr(msg, "invalid_tool_calls", None):
|
||||
for raw_tc in raw_tool_calls:
|
||||
if not isinstance(raw_tc, dict):
|
||||
continue
|
||||
|
||||
@ -229,6 +229,33 @@ class TestBuildPatchedMessagesPatching:
|
||||
assert patched[1].name == "unknown_tool"
|
||||
assert patched[1].status == "error"
|
||||
|
||||
def test_raw_fallback_is_skipped_when_invalid_view_carries_the_same_call(self):
|
||||
# The raw additional_kwargs payload is a fallback serialization of the
|
||||
# same calls; the provider reaches for it only when BOTH structured
|
||||
# views are empty (see _normalize_tool_call_ids). Collecting it while
|
||||
# invalid_tool_calls is non-empty counts the call twice and emits two
|
||||
# ToolMessages for one id — the duplicate-id shape strict providers
|
||||
# reject with HTTP 400.
|
||||
mw = DanglingToolCallMiddleware()
|
||||
msgs = [
|
||||
AIMessage.model_construct(
|
||||
content="",
|
||||
type="ai",
|
||||
tool_calls=[],
|
||||
invalid_tool_calls=[{"id": "call_x", "name": "read_file", "args": None, "error": "parse"}],
|
||||
additional_kwargs={"tool_calls": [{"id": "call_x", "type": "function", "function": {"name": "read_file", "arguments": "{}"}}]},
|
||||
response_metadata={},
|
||||
)
|
||||
]
|
||||
|
||||
patched = mw._build_patched_messages(msgs)
|
||||
|
||||
assert patched is not None
|
||||
tool_messages = [m for m in patched if isinstance(m, ToolMessage)]
|
||||
assert len(tool_messages) == 1
|
||||
assert tool_messages[0].tool_call_id == "call_x"
|
||||
assert tool_messages[0].status == "error"
|
||||
|
||||
def test_existing_tool_result_still_sanitizes_empty_structured_tool_call_name(self):
|
||||
mw = DanglingToolCallMiddleware()
|
||||
msgs = [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user