"""Canonical serialization for LangChain / LangGraph objects. Provides a single source of truth for converting LangChain message objects, Pydantic models, and LangGraph state dicts into plain JSON-serialisable Python structures. Consumers: ``deerflow.runtime.runs.worker`` (SSE publishing) and ``app.gateway.routers.threads`` (REST responses). """ from __future__ import annotations import dataclasses from typing import Any def serialize_lc_object(obj: Any) -> Any: """Recursively serialize a LangChain object to a JSON-serialisable dict.""" if obj is None: return None if isinstance(obj, (str, int, float, bool)): return obj if isinstance(obj, dict): return {k: serialize_lc_object(v) for k, v in obj.items()} if isinstance(obj, (list, tuple)): return [serialize_lc_object(item) for item in obj] # Pydantic v2 if hasattr(obj, "model_dump"): try: return obj.model_dump() except Exception: pass # Pydantic v1 / older objects if hasattr(obj, "dict"): try: return obj.dict() except Exception: pass # Dataclass instances —— LangGraph 的 ``Interrupt`` 就是 dataclass 没有 # model_dump 也没有 dict 方法;之前 fallback 到 ``str(obj)`` 会把 # ``Interrupt(value=..., id='...')`` 序列化成字符串,下游 SDK # (``@langchain/langgraph-sdk``) 期待 dict 结构 → 直接丢弃 → AI 写作 # 的 4 个干预卡永远不弹(参 ai-writing-优化.md §阶段 3 排查记录)。 if dataclasses.is_dataclass(obj) and not isinstance(obj, type): try: return {k: serialize_lc_object(v) for k, v in dataclasses.asdict(obj).items()} except Exception: pass # Last resort try: return str(obj) except Exception: return repr(obj) def serialize_channel_values(channel_values: dict[str, Any]) -> dict[str, Any]: """Serialize channel values, stripping internal LangGraph keys. ``__pregel_*`` 键始终剥离。``__interrupt__`` 仅在**为空时**剥离 —— 因为 ``@langchain/langgraph-sdk`` 的 ``useStream`` 只在 ``event === "values"`` 通道里读 ``data.__interrupt__`` 并通过 ``stream.interrupt`` 暴露给上层。 AI 写作的 4 个干预卡(material/outline/draft/review)就是靠它触发的; 继续过滤会让前端永远拿不到 interrupt。 主聊天不消费 ``stream.interrupt``,但若我们让一个空数组溜过去,SDK 会 fallback 成 ``{when: "breakpoint"}`` 的伪 interrupt —— 所以仅在数组非空 时保留,两侧都不破坏。 """ result: dict[str, Any] = {} for key, value in channel_values.items(): if key.startswith("__pregel_"): continue if key == "__interrupt__" and not value: continue result[key] = serialize_lc_object(value) return result def serialize_messages_tuple(obj: Any) -> Any: """Serialize a messages-mode tuple ``(chunk, metadata)``.""" if isinstance(obj, tuple) and len(obj) == 2: chunk, metadata = obj return [serialize_lc_object(chunk), metadata if isinstance(metadata, dict) else {}] return serialize_lc_object(obj) def serialize(obj: Any, *, mode: str = "") -> Any: """Serialize LangChain objects with mode-specific handling. * ``messages`` — obj is ``(message_chunk, metadata_dict)`` * ``values`` — obj is the full state dict; ``__pregel_*`` keys stripped * everything else — recursive ``model_dump()`` / ``dict()`` fallback """ if mode == "messages": return serialize_messages_tuple(obj) if mode == "values": return serialize_channel_values(obj) if isinstance(obj, dict) else serialize_lc_object(obj) return serialize_lc_object(obj)