diff --git a/libs/langgraph/langgraph/channels/delta.py b/libs/langgraph/langgraph/channels/delta.py index 7edb98e43..eb93e7079 100644 --- a/libs/langgraph/langgraph/channels/delta.py +++ b/libs/langgraph/langgraph/channels/delta.py @@ -86,19 +86,19 @@ class DeltaChannel(Generic[Value], BaseChannel[Any, Any, Any]): return self.typ def copy(self) -> Self: - new = DeltaChannel(self.operator, snapshot_every=self.snapshot_every) + new: DeltaChannel[Value] = DeltaChannel( + self.operator, snapshot_every=self.snapshot_every + ) new.typ = self.typ new.key = self.key - new.value = ( - self.value - if self.value is MISSING - else _copy.copy(self.value) - ) + new.value = self.value if self.value is MISSING else _copy.copy(self.value) new._writes_since_snapshot = self._writes_since_snapshot return new def from_checkpoint(self, checkpoint: Any) -> Self: - new = DeltaChannel(self.operator, snapshot_every=self.snapshot_every) + new: DeltaChannel[Value] = DeltaChannel( + self.operator, snapshot_every=self.snapshot_every + ) new.typ = self.typ new.key = self.key if checkpoint is MISSING: diff --git a/libs/langgraph/langgraph/graph/state.py b/libs/langgraph/langgraph/graph/state.py index 5d3540fb0..78cdbb10a 100644 --- a/libs/langgraph/langgraph/graph/state.py +++ b/libs/langgraph/langgraph/graph/state.py @@ -1671,7 +1671,15 @@ def _is_field_channel(typ: type[Any]) -> BaseChannel | None: for item in meta: if isinstance(item, BaseChannel): if isinstance(item, DeltaChannel) and hasattr(typ, "__origin__"): - outer = _strip_extras(typ.__origin__) + origin = typ.__origin__ + # Unwrap parameterized Required[X]/NotRequired[X] to X + # (e.g. Annotated[NotRequired[dict[...]], ...]). + if hasattr(origin, "__origin__") and origin.__origin__ in ( + Required, + NotRequired, + ): + origin = origin.__args__[0] + outer = _strip_extras(origin) if outer in ( collections.abc.Sequence, collections.abc.MutableSequence, diff --git a/libs/langgraph/tests/test_channels.py b/libs/langgraph/tests/test_channels.py index 21d13e619..63a7d986c 100644 --- a/libs/langgraph/tests/test_channels.py +++ b/libs/langgraph/tests/test_channels.py @@ -438,6 +438,7 @@ def test_delta_channel_dict_reducer_overwrite_in_update() -> None: def test_delta_channel_dict_reducer_overwrite_in_writes_replay() -> None: """Overwrite(dict) embedded in DeltaChannelWrites must reconstruct as dict.""" from langgraph.checkpoint.base import DeltaChannelWrites + from langgraph.types import Overwrite def merge_dicts(left: dict, right: dict) -> dict: @@ -479,6 +480,36 @@ def test_delta_channel_dict_reducer_snapshot_write_preserves_shape() -> None: assert isinstance(w.value, dict) +def test_delta_channel_dict_reducer_with_notrequired_annotation() -> None: + """DeltaChannel infers dict type through `Annotated[NotRequired[dict[...]], ch]`. + + This is the shape the deepagents filesystem middleware uses for its + `files` field; without unwrapping NotRequired we'd fall through to `list` + and blow up on the first dict operator call. + """ + from typing import Annotated + + from typing_extensions import NotRequired + + from langgraph.channels.delta import DeltaChannel + from langgraph.graph.state import _get_channel + + def merge_dicts(left: dict | None, right: dict) -> dict: + if left is None: + return dict(right) + return {**left, **right} + + annotation = Annotated[ + NotRequired[dict[str, int]], + DeltaChannel(merge_dicts), + ] + ch = _get_channel("files", annotation).from_checkpoint(MISSING) + assert ch.get() == {} + ch.update([{"a": 1}]) + ch.update([{"b": 2}]) + assert ch.get() == {"a": 1, "b": 2} + + def test_delta_channel_dict_reducer_end_to_end_filesystem() -> None: """End-to-end: graph with dict-reducer (filesystem-style) channel wrapped in DeltaChannel. @@ -487,12 +518,13 @@ def test_delta_channel_dict_reducer_end_to_end_filesystem() -> None: """ from typing import Annotated - from langgraph.channels.delta import DeltaChannel from langgraph.checkpoint.base import DELTA_SENTINEL, DeltaChannelWrites from langgraph.checkpoint.memory import InMemorySaver - from langgraph.graph import START, StateGraph from typing_extensions import TypedDict + from langgraph.channels.delta import DeltaChannel + from langgraph.graph import START, StateGraph + def merge_files(left: dict | None, right: dict) -> dict: if left is None: return {k: v for k, v in right.items() if v is not None}