From 7b576096564475da7a12268610d63954014d89ec Mon Sep 17 00:00:00 2001 From: Baldwinzc <56501736+Baldwinzc@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:07:55 +0800 Subject: [PATCH] fix(middleware): skip raw tool-call fallback when invalid view carries the same call (#4693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../dangling_tool_call_middleware.py | 8 +++++- .../test_dangling_tool_call_middleware.py | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/backend/packages/harness/deerflow/agents/middlewares/dangling_tool_call_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/dangling_tool_call_middleware.py index 48c7f6311..5a5603dd3 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/dangling_tool_call_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/dangling_tool_call_middleware.py @@ -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 diff --git a/backend/tests/test_dangling_tool_call_middleware.py b/backend/tests/test_dangling_tool_call_middleware.py index c46797eb3..b924a87ca 100644 --- a/backend/tests/test_dangling_tool_call_middleware.py +++ b/backend/tests/test_dangling_tool_call_middleware.py @@ -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 = [