mirror of
https://github.com/hwchase17/langchain.git
synced 2026-07-12 11:21:37 +00:00
fix(core): strip heavy payload fields from content-block-start
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.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user