From 1b5ca0a1b1e1889879e43536fc5efd0739a3c479 Mon Sep 17 00:00:00 2001 From: Sydney Runkle <54324534+sydney-runkle@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:02:08 -0400 Subject: [PATCH] fix(langgraph): Make `Overwrite` survive JSON roundtrips (#8127) Add a `type: Literal["__overwrite__"]` discriminator field to `Overwrite` and teach `_get_overwrite()` to recognise the dataclass-erased `{"value": ..., "type": "__overwrite__"}` form. This keeps `Overwrite` semantics intact across JSON boundaries that strip dataclass types (e.g. `langgraph-api.serde.json_dumpb`), without requiring callers to switch to the dict sentinel form. This was an oversight in the initial `Overwrite` implementation, it should be json serializable out of the box. Fixing langchain-ai/deepagents#3789 --- 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 5735ac65d..58346198c 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 4471074cc..ac9aa9b00 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -976,3 +976,9 @@ class Overwrite: 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 9dfa7158a..d8f32e587 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)