Catch specific exceptions instead of Exception to avoid masking bugs
except Exception:
1"""Compat bridge: convert `AIMessageChunk` streams to protocol events.23The bridge trusts `AIMessageChunk.content_blocks` as the single4protocol view of any chunk. That property runs the three-tier lookup5(`output_version == "v1"` short-circuit, registered translator, or6best-effort parsing) and returns a `list[ContentBlock]` for every7well-formed message — whether the provider is a registered partner, an8unregistered community model, or not tagged at all.910Per-chunk `content_blocks` output is a **delta slice**, not accumulated11state: providers in this ecosystem emit SSE-style chunks that each carry12their own increment. The bridge therefore forwards each slice straight13through as a `content-block-delta` event, and accumulates per-index14state only so the final `content-block-finish` event can report a15finalized block (e.g. `tool_call_chunk` args parsed to a dict).1617Lifecycle::1819 message-start20 -> content-block-start (first time each index is observed)21 -> content-block-delta* (per chunk, carrying the slice)22 -> content-block-finish (finalized block)23 -> message-finish2425Public API:2627- `chunks_to_events` / `achunks_to_events` — for live streams where28 chunks arrive over time.29- `message_to_events` / `amessage_to_events` — for replaying a finalized30 `AIMessage` (cache hit, checkpoint restore, graph-node return value)31 as a synthetic event lifecycle.32"""3334from __future__ import annotations3536import json37from typing import TYPE_CHECKING, Any, cast3839from langchain_protocol.protocol import (40 ContentBlock,41 ContentBlockDeltaData,42 ContentBlockFinishData,43 ContentBlockStartData,44 FinalizedContentBlock,45 InvalidToolCall,46 MessageFinishData,47 MessageMetadata,48 MessagesData,49 MessageStartData,50 ReasoningContentBlock,51 ServerToolCall,52 ServerToolCallChunk,53 TextContentBlock,54 ToolCall,55 ToolCallChunk,56 UsageInfo,57)5859from langchain_core.messages import AIMessageChunk, BaseMessage60from langchain_core.utils._merge import merge_dicts6162if TYPE_CHECKING:63 from collections.abc import AsyncIterator, Iterator6465 from langchain_protocol.protocol import (66 BlockDelta,67 BlockDeltaFields,68 ContentBlockDelta,69 DataDelta,70 ReasoningDelta,71 TextDelta,72 )7374 from langchain_core.outputs import ChatGenerationChunk757677CompatBlock = dict[str, Any]78"""Internal working type for a content block.7980The bridge works with plain dicts internally because two separate but81structurally similar `ContentBlock` Unions exist — one in82`langchain_core.messages.content` (returned by `msg.content_blocks`),83one in `langchain_protocol.protocol` (the wire/event shape). They are84not mypy-compatible despite being near-isomorphic. Passing through85`dict[str, Any]` launders between them. See `_to_protocol_block` for86the single seam where the laundering cast lives.87"""888990# ---------------------------------------------------------------------------91# Type laundering between core and protocol `ContentBlock` unions92# ---------------------------------------------------------------------------939495def _to_protocol_block(block: CompatBlock) -> ContentBlock:96 """Narrow an internal working dict to a protocol `ContentBlock`.9798 Single seam between the two `ContentBlock` type systems:99 `langchain_core.messages.content` (what `msg.content_blocks`100 returns) and `langchain_protocol.protocol` (what event payloads101 require). The two Unions overlap structurally but are nominally102 distinct to mypy, so we launder through `dict[str, Any]`. When the103 Unions are unified, this helper and its finalized counterpart can be104 deleted.105 """106 return cast("ContentBlock", block)107108109def _to_finalized_block(block: CompatBlock) -> FinalizedContentBlock:110 """Counterpart of `_to_protocol_block` for finalized blocks."""111 return cast("FinalizedContentBlock", block)112113114def _to_block_delta_fields(block: CompatBlock) -> BlockDeltaFields:115 """Narrow an internal working dict to protocol block-delta fields."""116 return cast("BlockDeltaFields", block)117118119def _to_content_delta(block: CompatBlock) -> ContentBlockDelta:120 """Convert a content-block slice/snapshot to an explicit protocol delta."""121 btype = block.get("type")122 if btype == "text":123 return cast("TextDelta", {"type": "text-delta", "text": block.get("text", "")})124 if btype == "reasoning":125 return cast(126 "ReasoningDelta",127 {128 "type": "reasoning-delta",129 "reasoning": block.get("reasoning", ""),130 },131 )132 if "data" in block:133 delta = cast("DataDelta", {"type": "data-delta", "data": block.get("data", "")})134 if block.get("encoding") == "base64":135 delta["encoding"] = "base64"136 return delta137 return cast(138 "BlockDelta",139 {140 "type": "block-delta",141 "fields": _to_block_delta_fields(block),142 },143 )144145146# ---------------------------------------------------------------------------147# Block iteration148# ---------------------------------------------------------------------------149150151def _iter_protocol_blocks(msg: BaseMessage) -> list[tuple[Any, CompatBlock]]:152 """Read per-chunk protocol blocks from `msg.content_blocks`.153154 Returns `(key, block)` pairs. The key is the block's stable identifier155 across the stream: the block's `index` field when present (can be an156 int or a string — some providers use string identifiers like157 `"lc_rs_305f30"`), or the positional index within the message as a158 fallback. Callers are responsible for allocating wire-level `uint`159 indices; this helper only surfaces the source-side identity.160161 For finalized `AIMessage`, also surfaces `invalid_tool_calls`162 — which `AIMessage.content_blocks` currently omits from its return163 value even though they are a defined protocol block type.164165 The positional fallback is a known fragility: when a provider emits166 blocks without an `index` field (e.g. Anthropic's `_stream` with167 `coerce_content_to_string=True`, where text chunks lose their168 source-side index), every such chunk gets positional key 0 and169 successive chunks merge into one block. This works correctly for170 single-type streams (pure-text responses merge cleanly) because all171 chunks share the same key and the open-block logic collapses them.172 It would miscategorise a stream that mixed indexed structured173 blocks with non-indexed coerced-text blocks, since an indexed174 block with `index == 0` would collide with the anonymous text175 block's positional-0 key. In the anthropic integration this176 cannot currently occur: coerce-to-string mode is only selected177 when no tools, thinking, or documents are present, and any of178 those flips the stream to structured mode where every block179 carries an integer index. A native `_stream_chat_model_events`180 hook per provider (or a bridge-level "continue the open block when181 the source has no identity" rule) would close the gap if another182 integration ever emits mixed content.183 """184 try:185 raw = msg.content_blocks186 except Exception:187 return []188189 result: list[tuple[Any, CompatBlock]] = []190 for i, block in enumerate(raw):191 if not isinstance(block, dict):192 continue193 explicit_idx = block.get("index")194 if explicit_idx is None:195 # No source-side identity. Bucket by (sentinel, block type,196 # positional `i`) so two blocks of different types at the197 # same position across chunks (e.g. Gemini emitting a198 # reasoning block in one chunk and a `tool_call` in the199 # next, both at positional 0 because each chunk carries one200 # block) get distinct wire blocks. Without this, the second201 # type's incoming block hits `_accumulate`'s self-contained202 # `else` branch and clobbers the first. Same-type chunks203 # still share the bucket and merge cleanly, which is what204 # streaming text / reasoning relies on.205 key: Any = ("__lc_no_index__", block.get("type"), i)206 else:207 key = explicit_idx208 result.append((key, dict(block)))209210 if not isinstance(msg, AIMessageChunk):211 # Finalized AIMessage: pull invalid_tool_calls from the dedicated212 # field — AIMessage.content_blocks does not currently include them.213 for itc in getattr(msg, "invalid_tool_calls", None) or []:214 itc_block: CompatBlock = {"type": "invalid_tool_call"}215 for key_name in ("id", "name", "args", "error"):216 if itc.get(key_name) is not None:217 itc_block[key_name] = itc[key_name]218 result.append((len(result), itc_block))219220 return result221222223# ---------------------------------------------------------------------------224# Per-block helpers225# ---------------------------------------------------------------------------226227228# Fields that can carry large payloads (inline base64 media, parsed args,229# arbitrary dicts). Stripped from `content-block-start` for self-contained230# block types so the payload rides on `content-block-finish` alone instead231# of being serialized twice on the wire.232_HEAVY_FIELDS = frozenset({"args", "data", "output", "transcript", "value"})233234235def _start_skeleton(block: CompatBlock) -> ContentBlock:236 """Empty-content placeholder for the `content-block-start` event.237238 Deltaable block types (text, reasoning, the `_chunk` tool variants)239 get an empty payload so the lifecycle's "start" signal is distinct240 from the first incremental delta. Self-contained types (image,241 audio, video, file, non_standard, finalized tool calls) drop their242 heavy payload fields; those are carried by `content-block-finish`.243 Correlation fields (id, name, toolCallId) and small metadata244 (mime_type, url, status, …) are preserved on the start event.245 """246 btype = block.get("type", "text")247 if btype == "text":248 return TextContentBlock(type="text", text="")249 if btype == "reasoning":250 return ReasoningContentBlock(type="reasoning", reasoning="")251 if btype == "tool_call_chunk":252 return ToolCallChunk(253 type="tool_call_chunk",254 id=block.get("id"),255 name=block.get("name"),256 args="",257 )258 if btype == "server_tool_call_chunk":259 s_skel = ServerToolCallChunk(260 type="server_tool_call_chunk",261 args="",262 )263 if block.get("id") is not None:264 s_skel["id"] = block["id"]265 if block.get("name") is not None:266 s_skel["name"] = block["name"]267 return s_skel268269 stripped: CompatBlock = {k: v for k, v in block.items() if k not in _HEAVY_FIELDS}270 # Restore required-but-heavy fields with minimal placeholders so the271 # start event still validates against the CDDL shape of the block type.272 if btype in ("tool_call", "server_tool_call"):273 stripped["args"] = {}274 elif btype == "non_standard":275 stripped["value"] = {}276 return _to_protocol_block(stripped)277278279def _should_emit_delta(block: CompatBlock) -> bool:280 """Whether a per-chunk block carries content worth a delta event.281282 Deltaable types emit only when they have fresh content. Self-contained283 / already-finalized types skip the delta entirely — the `finish`284 event carries them.285 """286 btype = block.get("type")287 if btype == "text":288 return bool(block.get("text"))289 if btype == "reasoning":290 return bool(block.get("reasoning"))291 if btype in ("tool_call_chunk", "server_tool_call_chunk"):292 return bool(293 block.get("args") or block.get("id") or block.get("name"),294 )295 if "data" in block:296 return bool(block.get("data"))297 return False298299300def _accumulate(state: CompatBlock | None, delta: CompatBlock) -> CompatBlock:301 """Merge a per-chunk delta slice into accumulated per-index state.302303 Used only for the finalization pass — live delta events are emitted304 directly from the per-chunk block, without round-tripping through305 accumulated state.306 """307 if state is None:308 return dict(delta)309 btype = state.get("type")310 dtype = delta.get("type")311 if btype == "text" and dtype == "text":312 state["text"] = state.get("text", "") + delta.get("text", "")313 # Providers may send non-text fields (like `id`, or annotations)314 # on later deltas. Merging (not replacing) keeps earlier keys315 # intact while picking up these late-arriving fields.316 for key, value in delta.items():317 if key in ("type", "text") or value is None:318 continue319 if key == "extras" and isinstance(value, dict):320 state["extras"] = {**(state.get("extras") or {}), **value}321 else:322 state[key] = value323 elif btype == "reasoning" and dtype == "reasoning":324 state["reasoning"] = state.get("reasoning", "") + delta.get("reasoning", "")325 # Providers may ship non-text fields on later deltas. Claude's326 # `signature_delta` arrives after the reasoning text, surfaced327 # as `extras.signature`; merging (not replacing) keeps earlier328 # keys intact.329 for key, value in delta.items():330 if key in ("type", "reasoning") or value is None:331 continue332 if key == "extras" and isinstance(value, dict):333 state["extras"] = {**(state.get("extras") or {}), **value}334 else:335 state[key] = value336 elif btype in ("tool_call_chunk", "server_tool_call_chunk") and dtype == btype:337 state["args"] = (state.get("args", "") or "") + (delta.get("args") or "")338 if delta.get("id") is not None:339 state["id"] = delta["id"]340 if delta.get("name") is not None:341 state["name"] = delta["name"]342 elif btype == dtype and "data" in delta:343 state["data"] = (state.get("data", "") or "") + (delta.get("data") or "")344 for key, value in delta.items():345 if key in ("type", "data") or value is None:346 continue347 if key == "extras" and isinstance(value, dict):348 state["extras"] = {**(state.get("extras") or {}), **value}349 else:350 state[key] = value351 else:352 # Self-contained or already-finalized types: replace wholesale.353 state.clear()354 state.update(delta)355 return state356357358def finalize_tool_call_chunk(359 *,360 raw_args: str | None,361 id_: str | None,362 name: str | None,363 extras: dict[str, Any],364 finalized_type: str,365) -> FinalizedContentBlock:366 """Parse accumulated tool-chunk args into a finalized block.367368 Shared between the compat bridge's `_finalize_block` and the369 `ChatModelStream` end-of-stream sweep. Parses `raw_args` as JSON:370 on success builds the requested finalized type (`tool_call` or371 `server_tool_call`) with provider-specific fields (`extras`)372 preserved; on failure falls back to `invalid_tool_call` carrying373 the raw string so downstream consumers can still introspect the374 malformed payload.375376 Args:377 raw_args: Accumulated partial-JSON string; `None` or empty378 treated as `{}`.379 id_: Tool-call id collected across chunks.380 name: Tool name collected across chunks.381 extras: Provider-specific fields to carry onto the finalized382 block. Callers are responsible for having already dropped383 keys they don't want propagated (notably `type`, `id`,384 `name`, `args`, and `index` on client-side `tool_call`).385 finalized_type: `"tool_call"` or `"server_tool_call"`.386387 Returns:388 A `ToolCall`, `ServerToolCall`, or `InvalidToolCall` — the389 latter when `raw_args` is non-empty but not valid JSON.390 """391 raw = raw_args or "{}"392 try:393 parsed = json.loads(raw) if raw else {}394 except (json.JSONDecodeError, TypeError):395 invalid = InvalidToolCall(396 type="invalid_tool_call",397 id=id_,398 name=name,399 args=raw,400 error="Failed to parse tool call arguments as JSON",401 )402 invalid.update(extras) # type: ignore[typeddict-item]403 return invalid404 if finalized_type == "tool_call":405 finalized_tc = ToolCall(406 type="tool_call",407 id=id_ or "",408 name=name or "",409 args=parsed,410 )411 finalized_tc.update(extras) # type: ignore[typeddict-item]412 return finalized_tc413 finalized_stc = ServerToolCall(414 type="server_tool_call",415 id=id_ or "",416 name=name or "",417 args=parsed,418 )419 finalized_stc.update(extras) # type: ignore[typeddict-item]420 return finalized_stc421422423def _finalize_block(block: CompatBlock) -> FinalizedContentBlock:424 """Promote chunk variants to their finalized form.425426 `tool_call_chunk` becomes `tool_call` — or `invalid_tool_call`427 if the accumulated `args` don't parse as JSON.428 `server_tool_call_chunk` becomes `server_tool_call` under the same429 rule. Everything else passes through: text/reasoning blocks carry430 their accumulated snapshot, and self-contained types are already in431 their terminal shape.432 """433 btype = block.get("type")434 if btype in ("tool_call_chunk", "server_tool_call_chunk"):435 # Carry provider-specific fields from the accumulated chunk onto436 # the finalized block. Drop the chunk-only keys we rewrite437 # explicitly. `index` is stripped on client-side438 # `tool_call` / `invalid_tool_call` finalizations to match v1439 # (`AIMessage.init_tool_calls` rebuilds tool_call blocks without440 # `index`), preventing `merge_lists` from re-merging further441 # chunks into an already-parsed args dict. `server_tool_call`442 # retains `index` because v1's `init_server_tool_calls`443 # finalizes in-place and preserves it.444 client_tool_call = btype == "tool_call_chunk"445 extras_drop = {"type", "id", "name", "args"}446 if client_tool_call:447 extras_drop = extras_drop | {"index"}448 extras = {449 k: v for k, v in block.items() if k not in extras_drop and v is not None450 }451 return finalize_tool_call_chunk(452 raw_args=block.get("args"),453 id_=block.get("id"),454 name=block.get("name"),455 extras=extras,456 finalized_type="tool_call" if client_tool_call else "server_tool_call",457 )458 return _to_finalized_block(block)459460461# ---------------------------------------------------------------------------462# Metadata, usage, finish-reason463# ---------------------------------------------------------------------------464465466def _extract_start_metadata(response_metadata: dict[str, Any]) -> MessageMetadata:467 """Pull provider/model hints for the `message-start` event."""468 metadata: MessageMetadata = {}469 if "model_provider" in response_metadata:470 metadata["provider"] = response_metadata["model_provider"]471 if "model_name" in response_metadata:472 metadata["model"] = response_metadata["model_name"]473 return metadata474475476def _accumulate_usage(477 current: dict[str, Any] | None, delta: Any478) -> dict[str, Any] | None:479 """Sum usage counts and merge detail dicts across chunks."""480 if not isinstance(delta, dict):481 return current482 if current is None:483 return dict(delta)484 for key in ("input_tokens", "output_tokens", "total_tokens", "cached_tokens"):485 if key in delta:486 current[key] = current.get(key, 0) + delta[key]487 for detail_key in ("input_token_details", "output_token_details"):488 if detail_key in delta and isinstance(delta[detail_key], dict):489 if detail_key not in current:490 current[detail_key] = {}491 current[detail_key].update(delta[detail_key])492 return current493494495def _to_protocol_usage(usage: dict[str, Any] | None) -> UsageInfo | None:496 """Convert accumulated usage to the protocol's `UsageInfo` shape."""497 if usage is None:498 return None499 result: dict[str, Any] = {}500 for key in ("input_tokens", "output_tokens", "total_tokens", "cached_tokens"):501 if key in usage:502 result[key] = usage[key]503 return cast("UsageInfo", result) if result else None504505506# ---------------------------------------------------------------------------507# Event builders508# ---------------------------------------------------------------------------509510511def _build_message_start(512 msg: BaseMessage,513 message_id: str | None,514) -> MessageStartData:515 start_data = MessageStartData(event="message-start", role="ai", id="")516 resolved_id = message_id if message_id is not None else getattr(msg, "id", None)517 if resolved_id:518 start_data["id"] = resolved_id519 start_metadata = _extract_start_metadata(msg.response_metadata or {})520 if start_metadata:521 start_data["metadata"] = start_metadata522 return start_data523524525def _build_message_finish(526 *,527 usage: dict[str, Any] | None,528 response_metadata: dict[str, Any] | None,529 additional_kwargs: dict[str, Any] | None = None,530) -> MessageFinishData:531 # Protocol 0.0.9 removed the top-level `reason` field from532 # `MessageFinishData`; the provider's raw `finish_reason` /533 # `stop_reason` now rides inside `metadata` alongside other534 # response metadata. Pass it through unchanged.535 finish_data: dict[str, Any] = {"event": "message-finish"}536 usage_info = _to_protocol_usage(usage)537 if usage_info is not None:538 finish_data["usage"] = usage_info539 if response_metadata:540 finish_data["metadata"] = dict(response_metadata)541 # `additional_kwargs` is an off-spec extension on the message-finish542 # event (parallel to `metadata`, which `MessageFinishData` also doesn't543 # formally declare but the consumer reads). It carries provider-side544 # kwargs that don't map onto a typed protocol field — notably Gemini's545 # `__gemini_function_call_thought_signatures__`, which the model546 # requires on follow-up turns to replay prior thinking. Without this,547 # streaming-assembled messages would silently drop data that548 # `ainvoke` preserves, breaking multi-turn streaming flows.549 if additional_kwargs:550 finish_data["additional_kwargs"] = dict(additional_kwargs)551 return cast("MessageFinishData", finish_data)552553554def _finalize_and_build_finish(555 wire_idx: int,556 block: CompatBlock,557) -> MessagesData:558 """Finalize a block and wrap it in a `content-block-finish` event."""559 return ContentBlockFinishData(560 event="content-block-finish",561 index=wire_idx,562 content=_finalize_block(block),563 )564565566# ---------------------------------------------------------------------------567# Main generators568# ---------------------------------------------------------------------------569570571def chunks_to_events(572 chunks: Iterator[ChatGenerationChunk],573 *,574 message_id: str | None = None,575) -> Iterator[MessagesData]:576 """Convert a stream of `ChatGenerationChunk` to protocol events.577578 Blocks are tracked independently by source-side identifier. Providers579 such as Anthropic can interleave parallel tool-call chunks by index, so580 each first-seen block gets a `content-block-start`, deltas keep their581 stable wire index, and all open blocks are finalized at message end.582 Source-side identifiers (from the block's `index` field, which may be583 int or string) are translated to sequential `uint` wire indices.584585 Args:586 chunks: Iterator of `ChatGenerationChunk` from `_stream()`.587 message_id: Optional stable message ID.588589 Yields:590 `MessagesData` lifecycle events.591 """592 started = False593 blocks: dict[Any, tuple[int, CompatBlock]] = {}594 next_wire_idx = 0595 usage: dict[str, Any] | None = None596 response_metadata: dict[str, Any] = {}597 additional_kwargs: dict[str, Any] = {}598599 for chunk in chunks:600 msg = chunk.message601 if not isinstance(msg, AIMessageChunk):602 continue603604 # The v1 `stream()` wrapper merges `generation_info` into605 # `response_metadata` before yielding (`chat_models.py` via606 # `_gen_info_and_msg_metadata`). We bypass that wrapper by reading607 # `_stream` directly, so reproduce the merge here with the same608 # priority: `generation_info` first, then `message.response_metadata`609 # overlays. This is how provider fields like `model_name`,610 # `system_fingerprint`, and `finish_reason` reach the bridge when611 # a provider emits them via `generation_info` instead of the612 # message's `response_metadata`.613 merged_rm: dict[str, Any] = {614 **(chunk.generation_info or {}),615 **(msg.response_metadata or {}),616 }617 if merged_rm:618 response_metadata.update(merged_rm)619620 # Carry chunks' `additional_kwargs` through to the assembled621 # message. Provider-side fields that don't map onto a typed622 # protocol block (e.g. Gemini's per-tool-call thought signatures)623 # live here on non-streaming `ainvoke` results; dropping them on624 # the streaming path silently diverges multi-turn behavior. Use625 # `merge_dicts` because the same key can arrive in pieces across626 # chunks (e.g. an accumulating `function_call`), matching how627 # `AIMessageChunk` merges itself.628 if msg.additional_kwargs:629 additional_kwargs = merge_dicts(additional_kwargs, msg.additional_kwargs)630631 if not started:632 started = True633 yield _build_message_start(msg, message_id)634635 for key, block in _iter_protocol_blocks(msg):636 if key not in blocks:637 wire_idx = next_wire_idx638 next_wire_idx += 1639 blocks[key] = (wire_idx, dict(block))640 yield ContentBlockStartData(641 event="content-block-start",642 index=wire_idx,643 content=_start_skeleton(block),644 )645 else:646 wire_idx, existing = blocks[key]647 blocks[key] = (wire_idx, _accumulate(existing, block))648 if _should_emit_delta(block):649 wire_idx, current = blocks[key]650 is_block_delta = block.get("type") in (651 "tool_call_chunk",652 "server_tool_call_chunk",653 )654 delta_source = current if is_block_delta else block655 yield ContentBlockDeltaData(656 event="content-block-delta",657 index=wire_idx,658 delta=_to_content_delta(delta_source or block),659 )660661 if msg.usage_metadata:662 usage = _accumulate_usage(usage, msg.usage_metadata)663664 if not started:665 return666667 for wire_idx, block in blocks.values():668 yield _finalize_and_build_finish(wire_idx, block)669670 yield _build_message_finish(671 usage=usage,672 response_metadata=response_metadata,673 additional_kwargs=additional_kwargs,674 )675676677async def achunks_to_events(678 chunks: AsyncIterator[ChatGenerationChunk],679 *,680 message_id: str | None = None,681) -> AsyncIterator[MessagesData]:682 """Async variant of `chunks_to_events`."""683 started = False684 blocks: dict[Any, tuple[int, CompatBlock]] = {}685 next_wire_idx = 0686 usage: dict[str, Any] | None = None687 response_metadata: dict[str, Any] = {}688 additional_kwargs: dict[str, Any] = {}689690 async for chunk in chunks:691 msg = chunk.message692 if not isinstance(msg, AIMessageChunk):693 continue694695 # See sync twin for rationale: merge `generation_info` into the696 # accumulated `response_metadata` with the same priority as the697 # v1 `stream()` wrapper.698 merged_rm: dict[str, Any] = {699 **(chunk.generation_info or {}),700 **(msg.response_metadata or {}),701 }702 if merged_rm:703 response_metadata.update(merged_rm)704705 # See sync twin: carry chunk `additional_kwargs` through so706 # provider-specific data (e.g. Gemini thought signatures) reaches707 # the assembled message instead of being dropped.708 if msg.additional_kwargs:709 additional_kwargs = merge_dicts(additional_kwargs, msg.additional_kwargs)710711 if not started:712 started = True713 yield _build_message_start(msg, message_id)714715 for key, block in _iter_protocol_blocks(msg):716 if key not in blocks:717 wire_idx = next_wire_idx718 next_wire_idx += 1719 blocks[key] = (wire_idx, dict(block))720 yield ContentBlockStartData(721 event="content-block-start",722 index=wire_idx,723 content=_start_skeleton(block),724 )725 else:726 wire_idx, existing = blocks[key]727 blocks[key] = (wire_idx, _accumulate(existing, block))728 if _should_emit_delta(block):729 wire_idx, current = blocks[key]730 is_block_delta = block.get("type") in (731 "tool_call_chunk",732 "server_tool_call_chunk",733 )734 delta_source = current if is_block_delta else block735 yield ContentBlockDeltaData(736 event="content-block-delta",737 index=wire_idx,738 delta=_to_content_delta(delta_source or block),739 )740741 if msg.usage_metadata:742 usage = _accumulate_usage(usage, msg.usage_metadata)743744 if not started:745 return746747 for wire_idx, block in blocks.values():748 yield _finalize_and_build_finish(wire_idx, block)749750 yield _build_message_finish(751 usage=usage,752 response_metadata=response_metadata,753 additional_kwargs=additional_kwargs,754 )755756757def message_to_events(758 msg: BaseMessage,759 *,760 message_id: str | None = None,761) -> Iterator[MessagesData]:762 """Replay a finalized message as a synthetic event lifecycle.763764 For a message returned whole (from a graph node, checkpoint, or765 cache), produce the same `message-start` / per-block /766 `message-finish` event stream a live call would produce. Consumers767 downstream see a uniform event shape regardless of source.768769 Text and reasoning blocks emit a single `content-block-delta` with770 the full accumulated content. Already-finalized blocks (tool_call,771 server_tool_call, image, etc.) skip the delta and rely on the772 `content-block-finish` event alone.773774 Args:775 msg: The finalized message — typically an `AIMessage`.776 message_id: Optional stable message ID; falls back to `msg.id`.777778 Yields:779 `MessagesData` lifecycle events.780 """781 response_metadata = msg.response_metadata or {}782 yield _build_message_start(msg, message_id)783784 for wire_idx, (_key, block) in enumerate(_iter_protocol_blocks(msg)):785 yield ContentBlockStartData(786 event="content-block-start",787 index=wire_idx,788 content=_start_skeleton(block),789 )790 if _should_emit_delta(block):791 yield ContentBlockDeltaData(792 event="content-block-delta",793 index=wire_idx,794 delta=_to_content_delta(block),795 )796 yield ContentBlockFinishData(797 event="content-block-finish",798 index=wire_idx,799 content=_finalize_block(block),800 )801802 yield _build_message_finish(803 usage=getattr(msg, "usage_metadata", None),804 response_metadata=response_metadata,805 )806807808async def amessage_to_events(809 msg: BaseMessage,810 *,811 message_id: str | None = None,812) -> AsyncIterator[MessagesData]:813 """Async variant of `message_to_events`."""814 for event in message_to_events(msg, message_id=message_id):815 yield event816817818__all__ = [819 "CompatBlock",820 "achunks_to_events",821 "amessage_to_events",822 "chunks_to_events",823 "finalize_tool_call_chunk",824 "message_to_events",825]
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.