Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions libs/langgraph/langgraph/channels/binop.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import collections.abc
from collections.abc import Callable, Sequence
from typing import Any, Generic
Expand Down Expand Up @@ -29,11 +29,25 @@


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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe add more details where this is from -- seems like from user hand writing the return from a node?

* 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"]
Comment on lines +49 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Extra-key dicts bypass reducers

This now treats any channel update dict with type == "__overwrite__" and a value key as an overwrite, even when the dict has additional application data. For example, a BinaryOperatorAggregate(dict, operator.or_) update of {"type": "__overwrite__", "value": 2, "b": 3} now replaces the channel with 2 instead of passing the dict to the reducer/merging it. The JSON-erased Overwrite shape produced by the new dataclass field is exactly {"value": ..., "type": "__overwrite__"}, so the discriminator check should be constrained to that exact shape to avoid hijacking legitimate dict values that happen to carry the same reserved-looking fields.

(Refers to lines 49-50)


Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.

Suggested change
if value.get("type") == OVERWRITE and "value" in value:
return True, value["value"]
if len(value) == 2 and value.get("type") == OVERWRITE and "value" in value:
return True, value["value"]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's fine

return False, None


Expand Down
6 changes: 6 additions & 0 deletions libs/langgraph/langgraph/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cursor said we can use type: Literal["__overwrite__"] = OVERWRITE

not sure that is true

"""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)."""
44 changes: 44 additions & 0 deletions libs/langgraph/tests/test_channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading