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 = [