From 2f14205acbf2017a46983c2f44375c878aeba51c Mon Sep 17 00:00:00 2001 From: Nick Hollon Date: Tue, 21 Apr 2026 11:52:22 -0400 Subject: [PATCH] fix(core): strip heavy payload fields from content-block-start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-contained content blocks (image, audio, video, file, non_standard, finalized tool_call) were emitting their full payload on both content-block-start and content-block-finish, doubling wire bandwidth and JSON parse cost when providers emit large inline base64 media. Emit a minimal skeleton on content-block-start — correlation fields (id, name, toolCallId) and small metadata (mime_type, url, status) are preserved; heavy fields (data, args, output, transcript, value) are stripped and carried by content-block-finish only. Required CDDL fields get minimal placeholders so start still validates. --- .../language_models/_compat_bridge.py | 27 +++++- .../language_models/test_compat_bridge.py | 91 +++++++++++++++++++ 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/libs/core/langchain_core/language_models/_compat_bridge.py b/libs/core/langchain_core/language_models/_compat_bridge.py index eb659928cd3..e1967563430 100644 --- a/libs/core/langchain_core/language_models/_compat_bridge.py +++ b/libs/core/langchain_core/language_models/_compat_bridge.py @@ -149,14 +149,23 @@ def _iter_protocol_blocks(msg: BaseMessage) -> list[tuple[int, CompatBlock]]: # --------------------------------------------------------------------------- +# Fields that can carry large payloads (inline base64 media, parsed args, +# arbitrary dicts). Stripped from `content-block-start` for self-contained +# block types so the payload rides on `content-block-finish` alone instead +# of being serialized twice on the wire. +_HEAVY_FIELDS = frozenset({"args", "data", "output", "transcript", "value"}) + + def _start_skeleton(block: CompatBlock) -> ContentBlock: """Empty-content placeholder for the `content-block-start` event. Deltaable block types (text, reasoning, the `_chunk` tool variants) get an empty payload so the lifecycle's "start" signal is distinct - from the first incremental delta. Self-contained or already-finalized - block types pass through unchanged — their `start` event is also - their only content-bearing event. + from the first incremental delta. Self-contained types (image, + audio, video, file, non_standard, finalized tool calls) drop their + heavy payload fields; those are carried by `content-block-finish`. + Correlation fields (id, name, toolCallId) and small metadata + (mime_type, url, status, …) are preserved on the start event. """ btype = block.get("type", "text") if btype == "text": @@ -180,7 +189,17 @@ def _start_skeleton(block: CompatBlock) -> ContentBlock: if block.get("name") is not None: s_skel["name"] = block["name"] return s_skel - return _to_protocol_block(block) + + stripped: CompatBlock = {k: v for k, v in block.items() if k not in _HEAVY_FIELDS} + # Restore required-but-heavy fields with minimal placeholders so the + # start event still validates against the CDDL shape of the block type. + if btype in ("tool_call", "server_tool_call"): + stripped["args"] = {} + elif btype == "server_tool_call_result": + stripped["output"] = None + elif btype == "non_standard": + stripped["value"] = {} + return _to_protocol_block(stripped) def _should_emit_delta(block: CompatBlock) -> bool: diff --git a/libs/core/tests/unit_tests/language_models/test_compat_bridge.py b/libs/core/tests/unit_tests/language_models/test_compat_bridge.py index 6e3d8473734..341f24f98d1 100644 --- a/libs/core/tests/unit_tests/language_models/test_compat_bridge.py +++ b/libs/core/tests/unit_tests/language_models/test_compat_bridge.py @@ -482,6 +482,97 @@ def test_message_to_events_message_id_override() -> None: assert start["message_id"] == "msg-override" +def test_message_to_events_self_contained_start_strips_heavy_fields() -> None: + """`content-block-start` must not duplicate heavy payload fields. + + For image/audio/video/file/non_standard and finalized tool_call blocks, + the large payload (base64 `data`, parsed `args`, arbitrary `value`) + should appear only on `content-block-finish`, not on `content-block-start`. + Start preserves correlation and small metadata fields. + """ + msg = AIMessage( + content=[ + { + "type": "image", + "id": "img-1", + "mime_type": "image/png", + "data": "A" * 1024, + }, + { + "type": "audio", + "id": "aud-1", + "mime_type": "audio/mp3", + "data": "B" * 1024, + "transcript": "hello", + }, + { + "type": "non_standard", + "id": "ns-1", + "value": {"big": "C" * 1024}, + }, + ], + id="msg-heavy", + ) + events = list(message_to_events(msg)) + + starts = [e for e in events if e["event"] == "content-block-start"] + assert [s["content_block"]["type"] for s in starts] == [ + "image", + "audio", + "non_standard", + ] + + image_start = starts[0]["content_block"] + assert image_start["id"] == "img-1" + assert image_start["mime_type"] == "image/png" + assert "data" not in image_start + + audio_start = starts[1]["content_block"] + assert audio_start["id"] == "aud-1" + assert audio_start["mime_type"] == "audio/mp3" + assert "data" not in audio_start + assert "transcript" not in audio_start + + ns_start = starts[2]["content_block"] + assert ns_start["type"] == "non_standard" + assert ns_start["value"] == {} + + finishes = [e for e in events if e["event"] == "content-block-finish"] + assert finishes[0]["content_block"]["data"] == "A" * 1024 + assert finishes[1]["content_block"]["data"] == "B" * 1024 + assert finishes[1]["content_block"]["transcript"] == "hello" + assert finishes[2]["content_block"]["value"] == {"big": "C" * 1024} + + +def test_message_to_events_finalized_tool_call_start_strips_args() -> None: + """Finalized `tool_call` keeps id/name on start but not parsed args.""" + msg = AIMessage( + content="", + id="msg-tc", + tool_calls=[ + { + "id": "tc1", + "name": "search", + "args": {"q": "big payload " * 100}, + "type": "tool_call", + }, + ], + ) + events = list(message_to_events(msg)) + + starts = [e for e in events if e["event"] == "content-block-start"] + assert len(starts) == 1 + tc_start = starts[0]["content_block"] + assert tc_start["type"] == "tool_call" + assert tc_start["id"] == "tc1" + assert tc_start["name"] == "search" + assert tc_start["args"] == {} + + finishes = [e for e in events if e["event"] == "content-block-finish"] + tc_finish = cast("ToolCallBlock", finishes[0]["content_block"]) + assert tc_finish["args"] == {"q": "big payload " * 100} + + @pytest.mark.asyncio async def test_amessage_to_events_matches_sync() -> None: msg = AIMessage(