mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-12 20:57:52 +02:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user