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:
Nick Hollon
2026-04-21 11:52:22 -04:00
parent 0640e55c3a
commit 2f14205acb
2 changed files with 114 additions and 4 deletions

View File

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

View File

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