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:
Baldwinzc 2026-08-08 21:07:55 +08:00 committed by GitHub
parent 295d7c2abc
commit 7b57609656
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 34 additions and 1 deletions

View File

@ -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

View File

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