From f6a4d9392f520f28b281cf6dd6aa68d617a047db Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Wed, 17 Jun 2026 16:44:54 -0400 Subject: [PATCH] Make Overwrite survive JSON roundtrips --- libs/langgraph/langgraph/channels/binop.py | 20 ++++++++-- libs/langgraph/langgraph/types.py | 6 +++ libs/langgraph/tests/test_channels.py | 44 ++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/libs/langgraph/langgraph/channels/binop.py b/libs/langgraph/langgraph/channels/binop.py index 5735ac65da1..58346198c92 100644 --- a/libs/langgraph/langgraph/channels/binop.py +++ b/libs/langgraph/langgraph/channels/binop.py @@ -29,11 +29,25 @@ def _strip_extras(t): # type: ignore[no-untyped-def] def _get_overwrite(value: Any) -> tuple[bool, Any]: - """Inspects the given value and returns (is_overwrite, overwrite_value).""" + """Inspects the given value and returns (is_overwrite, overwrite_value). + + Recognises three forms: + + * The typed `Overwrite` dataclass instance. + * The sentinel-keyed `{"__overwrite__": value}` dict form. + * The dataclass-erased `{"value": ..., "type": "__overwrite__"}` form that + results from JSON-serialising an `Overwrite` (e.g. an `orjson`-encoded + state update routed through the LangGraph API server). This keeps the + `Overwrite` semantics intact across JSON boundaries that strip dataclass + types. + """ if isinstance(value, Overwrite): return True, value.value - if isinstance(value, dict) and len(value) == 1 and OVERWRITE in value: - return True, value[OVERWRITE] + if isinstance(value, dict): + if len(value) == 1 and OVERWRITE in value: + return True, value[OVERWRITE] + if value.get("type") == OVERWRITE and "value" in value: + return True, value["value"] return False, None diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index 4471074cc30..ac9aa9b0056 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -976,3 +976,9 @@ def node_b(state: State): value: Any """The value to write directly to the channel, bypassing any reducer.""" + + type: Literal["__overwrite__"] = "__overwrite__" + """Discriminator field. Lets the channel reducer recognise an `Overwrite` + even after its dataclass form is JSON-serialised and the typed instance + is lost (e.g. an `orjson`-encoded state update routed through the + LangGraph API server).""" diff --git a/libs/langgraph/tests/test_channels.py b/libs/langgraph/tests/test_channels.py index 9dfa7158a41..d8f32e587e5 100644 --- a/libs/langgraph/tests/test_channels.py +++ b/libs/langgraph/tests/test_channels.py @@ -186,6 +186,50 @@ def test_delta_channel_overwrite() -> None: assert ch.get()[0].content == "new" +def test_overwrite_dataclass_form_survives_json_roundtrip() -> None: + """`Overwrite` serialised with `orjson` collapses to a plain dict but + must still be recognised as an overwrite by the channel reducer. + + Without the `type` discriminator the dataclass-erased shape (`{"value": + ...}`) is indistinguishable from a literal channel value, and downstream + reducers raise `MESSAGE_COERCION_FAILURE` (or similar) on read. + """ + import orjson + + from langgraph._internal._constants import OVERWRITE + from langgraph.channels.binop import _get_overwrite + + ow = Overwrite(value=[HumanMessage(content="new", id="h2")]) + erased = orjson.loads(orjson.dumps(ow, default=lambda o: o.model_dump())) + + assert erased["type"] == OVERWRITE + is_overwrite, value = _get_overwrite(erased) + assert is_overwrite + assert isinstance(value, list) + assert value[0]["content"] == "new" + + +def test_overwrite_sentinel_dict_still_recognised() -> None: + """The pre-existing `{"__overwrite__": value}` dict form continues to be + recognised. This is the canonical sentinel emitted by producers that do + not have an `Overwrite` dataclass available.""" + from langgraph._internal._constants import OVERWRITE + from langgraph.channels.binop import _get_overwrite + + is_overwrite, value = _get_overwrite({OVERWRITE: ["b"]}) + assert is_overwrite + assert value == ["b"] + + +def test_overwrite_non_matching_dict_not_recognised() -> None: + """Dicts that resemble the erased shape but do not carry the + `__overwrite__` discriminator must not be misclassified as overwrites.""" + from langgraph.channels.binop import _get_overwrite + + assert _get_overwrite({"value": ["b"]}) == (False, None) + assert _get_overwrite({"type": "human", "value": "hi"}) == (False, None) + + def test_delta_channel_remove_message_and_replay() -> None: """RemoveMessage must round-trip correctly when writes are replayed.""" spec = DeltaChannel(_messages_delta_reducer, list)