langgraph 1.2.6__tar.gz → 1.2.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. {langgraph-1.2.6 → langgraph-1.2.7}/PKG-INFO +1 -1
  2. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/channels/binop.py +17 -3
  3. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/channels/delta.py +1 -3
  4. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/_checkpoint.py +12 -0
  5. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/_loop.py +27 -2
  6. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/types.py +6 -0
  7. {langgraph-1.2.6 → langgraph-1.2.7}/pyproject.toml +1 -1
  8. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_channels.py +131 -0
  9. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_delta_channel_exit_mode.py +19 -0
  10. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_pregel.py +29 -40
  11. {langgraph-1.2.6 → langgraph-1.2.7}/uv.lock +264 -201
  12. {langgraph-1.2.6 → langgraph-1.2.7}/.gitignore +0 -0
  13. {langgraph-1.2.6 → langgraph-1.2.7}/LICENSE +0 -0
  14. {langgraph-1.2.6 → langgraph-1.2.7}/Makefile +0 -0
  15. {langgraph-1.2.6 → langgraph-1.2.7}/README.md +0 -0
  16. {langgraph-1.2.6 → langgraph-1.2.7}/bench/__init__.py +0 -0
  17. {langgraph-1.2.6 → langgraph-1.2.7}/bench/__main__.py +0 -0
  18. {langgraph-1.2.6 → langgraph-1.2.7}/bench/fanout_to_subgraph.py +0 -0
  19. {langgraph-1.2.6 → langgraph-1.2.7}/bench/pydantic_state.py +0 -0
  20. {langgraph-1.2.6 → langgraph-1.2.7}/bench/react_agent.py +0 -0
  21. {langgraph-1.2.6 → langgraph-1.2.7}/bench/sequential.py +0 -0
  22. {langgraph-1.2.6 → langgraph-1.2.7}/bench/serde_allowlist.py +0 -0
  23. {langgraph-1.2.6 → langgraph-1.2.7}/bench/wide_dict.py +0 -0
  24. {langgraph-1.2.6 → langgraph-1.2.7}/bench/wide_state.py +0 -0
  25. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/_internal/__init__.py +0 -0
  26. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/_internal/_cache.py +0 -0
  27. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/_internal/_config.py +0 -0
  28. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/_internal/_constants.py +0 -0
  29. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/_internal/_fields.py +0 -0
  30. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/_internal/_future.py +0 -0
  31. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/_internal/_pydantic.py +0 -0
  32. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/_internal/_queue.py +0 -0
  33. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/_internal/_replay.py +0 -0
  34. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/_internal/_retry.py +0 -0
  35. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/_internal/_runnable.py +0 -0
  36. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/_internal/_scratchpad.py +0 -0
  37. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/_internal/_serde.py +0 -0
  38. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/_internal/_timeout.py +0 -0
  39. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/_internal/_typing.py +0 -0
  40. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/callbacks.py +0 -0
  41. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/channels/__init__.py +0 -0
  42. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/channels/any_value.py +0 -0
  43. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/channels/base.py +0 -0
  44. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/channels/ephemeral_value.py +0 -0
  45. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/channels/last_value.py +0 -0
  46. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/channels/named_barrier_value.py +0 -0
  47. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/channels/topic.py +0 -0
  48. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/channels/untracked_value.py +0 -0
  49. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/config.py +0 -0
  50. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/constants.py +0 -0
  51. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/errors.py +0 -0
  52. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/func/__init__.py +0 -0
  53. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/graph/__init__.py +0 -0
  54. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/graph/_branch.py +0 -0
  55. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/graph/_node.py +0 -0
  56. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/graph/message.py +0 -0
  57. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/graph/state.py +0 -0
  58. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/graph/ui.py +0 -0
  59. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/managed/__init__.py +0 -0
  60. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/managed/base.py +0 -0
  61. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/managed/is_last_step.py +0 -0
  62. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/__init__.py +0 -0
  63. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/_algo.py +0 -0
  64. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/_call.py +0 -0
  65. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/_config.py +0 -0
  66. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/_draw.py +0 -0
  67. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/_executor.py +0 -0
  68. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/_io.py +0 -0
  69. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/_log.py +0 -0
  70. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/_messages.py +0 -0
  71. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/_read.py +0 -0
  72. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/_remote_run_stream.py +0 -0
  73. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/_retry.py +0 -0
  74. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/_runner.py +0 -0
  75. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/_tools.py +0 -0
  76. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/_utils.py +0 -0
  77. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/_validate.py +0 -0
  78. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/_write.py +0 -0
  79. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/debug.py +0 -0
  80. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/main.py +0 -0
  81. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/protocol.py +0 -0
  82. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/remote.py +0 -0
  83. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/pregel/types.py +0 -0
  84. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/py.typed +0 -0
  85. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/runtime.py +0 -0
  86. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/stream/__init__.py +0 -0
  87. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/stream/_convert.py +0 -0
  88. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/stream/_mux.py +0 -0
  89. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/stream/_types.py +0 -0
  90. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/stream/run_stream.py +0 -0
  91. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/stream/stream_channel.py +0 -0
  92. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/stream/transformers.py +0 -0
  93. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/typing.py +0 -0
  94. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/utils/__init__.py +0 -0
  95. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/utils/config.py +0 -0
  96. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/utils/runnable.py +0 -0
  97. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/version.py +0 -0
  98. {langgraph-1.2.6 → langgraph-1.2.7}/langgraph/warnings.py +0 -0
  99. {langgraph-1.2.6 → langgraph-1.2.7}/tests/__init__.py +0 -0
  100. {langgraph-1.2.6 → langgraph-1.2.7}/tests/__snapshots__/test_large_cases.ambr +0 -0
  101. {langgraph-1.2.6 → langgraph-1.2.7}/tests/__snapshots__/test_pregel.ambr +0 -0
  102. {langgraph-1.2.6 → langgraph-1.2.7}/tests/__snapshots__/test_pregel_async.ambr +0 -0
  103. {langgraph-1.2.6 → langgraph-1.2.7}/tests/agents.py +0 -0
  104. {langgraph-1.2.6 → langgraph-1.2.7}/tests/any_int.py +0 -0
  105. {langgraph-1.2.6 → langgraph-1.2.7}/tests/any_str.py +0 -0
  106. {langgraph-1.2.6 → langgraph-1.2.7}/tests/compose-postgres.yml +0 -0
  107. {langgraph-1.2.6 → langgraph-1.2.7}/tests/compose-redis.yml +0 -0
  108. {langgraph-1.2.6 → langgraph-1.2.7}/tests/conftest.py +0 -0
  109. {langgraph-1.2.6 → langgraph-1.2.7}/tests/conftest_checkpointer.py +0 -0
  110. {langgraph-1.2.6 → langgraph-1.2.7}/tests/conftest_store.py +0 -0
  111. {langgraph-1.2.6 → langgraph-1.2.7}/tests/example_app/example_graph.py +0 -0
  112. {langgraph-1.2.6 → langgraph-1.2.7}/tests/example_app/langgraph.json +0 -0
  113. {langgraph-1.2.6 → langgraph-1.2.7}/tests/example_app/requirements.txt +0 -0
  114. {langgraph-1.2.6 → langgraph-1.2.7}/tests/fake_chat.py +0 -0
  115. {langgraph-1.2.6 → langgraph-1.2.7}/tests/fake_tracer.py +0 -0
  116. {langgraph-1.2.6 → langgraph-1.2.7}/tests/memory_assert.py +0 -0
  117. {langgraph-1.2.6 → langgraph-1.2.7}/tests/messages.py +0 -0
  118. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_algo.py +0 -0
  119. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_checkpoint_migration.py +0 -0
  120. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_config_async.py +0 -0
  121. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_delta_channel_benchmark.py +0 -0
  122. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_delta_channel_id_stability.py +0 -0
  123. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_delta_channel_migration.py +0 -0
  124. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_delta_channel_supersteps_bound.py +0 -0
  125. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_delta_channel_update_state.py +0 -0
  126. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_deprecation.py +0 -0
  127. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_graph_callbacks.py +0 -0
  128. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_interleave_arrival_order.py +0 -0
  129. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_interrupt_migration.py +0 -0
  130. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_interruption.py +0 -0
  131. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_large_cases.py +0 -0
  132. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_large_cases_async.py +0 -0
  133. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_managed_values.py +0 -0
  134. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_messages_state.py +0 -0
  135. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_parent_command.py +0 -0
  136. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_parent_command_async.py +0 -0
  137. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_pregel_async.py +0 -0
  138. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_pregel_debug.py +0 -0
  139. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_pregel_stream_events_v3.py +0 -0
  140. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_pydantic.py +0 -0
  141. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_remote_graph.py +0 -0
  142. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_remote_graph_v3.py +0 -0
  143. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_retry.py +0 -0
  144. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_runnable.py +0 -0
  145. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_runtime.py +0 -0
  146. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_serde_allowlist.py +0 -0
  147. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_state.py +0 -0
  148. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_stream_before_builtins.py +0 -0
  149. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_stream_data_transformers.py +0 -0
  150. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_stream_events_v3.py +0 -0
  151. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_stream_events_v3_e2e.py +0 -0
  152. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_stream_events_v3_kwarg_forwarding.py +0 -0
  153. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_stream_lifecycle_transformer.py +0 -0
  154. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_stream_messages_transformer.py +0 -0
  155. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_stream_subgraph_transformer.py +0 -0
  156. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_subgraph_persistence.py +0 -0
  157. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_subgraph_persistence_async.py +0 -0
  158. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_time_travel.py +0 -0
  159. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_time_travel_async.py +0 -0
  160. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_tool_stream_handler.py +0 -0
  161. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_tracing_interops.py +0 -0
  162. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_type_checking.py +0 -0
  163. {langgraph-1.2.6 → langgraph-1.2.7}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph
3
- Version: 1.2.6
3
+ Version: 1.2.7
4
4
  Summary: Building stateful, multi-actor applications with LLMs
5
5
  Project-URL: Homepage, https://docs.langchain.com/oss/python/langgraph/overview
6
6
  Project-URL: Documentation, https://reference.langchain.com/python/langgraph/
@@ -29,11 +29,25 @@ def _strip_extras(t): # type: ignore[no-untyped-def]
29
29
 
30
30
 
31
31
  def _get_overwrite(value: Any) -> tuple[bool, Any]:
32
- """Inspects the given value and returns (is_overwrite, overwrite_value)."""
32
+ """Inspects the given value and returns (is_overwrite, overwrite_value).
33
+
34
+ Recognises three forms:
35
+
36
+ * The typed `Overwrite` dataclass instance.
37
+ * The sentinel-keyed `{"__overwrite__": value}` dict form.
38
+ * The dataclass-erased `{"value": ..., "type": "__overwrite__"}` form that
39
+ results from JSON-serialising an `Overwrite` (e.g. an `orjson`-encoded
40
+ state update routed through the LangGraph API server). This keeps the
41
+ `Overwrite` semantics intact across JSON boundaries that strip dataclass
42
+ types.
43
+ """
33
44
  if isinstance(value, Overwrite):
34
45
  return True, value.value
35
- if isinstance(value, dict) and len(value) == 1 and OVERWRITE in value:
36
- return True, value[OVERWRITE]
46
+ if isinstance(value, dict):
47
+ if len(value) == 1 and OVERWRITE in value:
48
+ return True, value[OVERWRITE]
49
+ if value.get("type") == OVERWRITE and "value" in value:
50
+ return True, value["value"]
37
51
  return False, None
38
52
 
39
53
 
@@ -172,13 +172,11 @@ class DeltaChannel(Generic[Value], BaseChannel[Any, Any, Any]):
172
172
  overwrite_idx = i
173
173
  if overwrite_idx is not None:
174
174
  _, overwrite_value = _get_overwrite(values[overwrite_idx])
175
- base = (
175
+ self.value = (
176
176
  _copy.copy(overwrite_value)
177
177
  if overwrite_value is not None
178
178
  else self.typ()
179
179
  )
180
- remaining = [v for i, v in enumerate(values) if i != overwrite_idx]
181
- self.value = self.reducer(base, remaining) if remaining else base
182
180
  return True
183
181
  base = self.typ() if self.value is MISSING else self.value
184
182
  self.value = self.reducer(base, list(values))
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import uuid
3
4
  from collections.abc import Callable, Mapping
4
5
  from datetime import datetime, timezone
5
6
  from typing import Any, cast
@@ -34,6 +35,17 @@ def empty_checkpoint() -> Checkpoint:
34
35
  )
35
36
 
36
37
 
38
+ def exit_delta_task_id(step: int, task_id: str) -> str:
39
+ """Synthetic task id for exit-mode DeltaChannel writes.
40
+
41
+ Embeds the superstep in the first UUID group so `ORDER BY task_id, idx`
42
+ preserves chronological order while remaining a valid RFC UUID (required by
43
+ Postgres `checkpoint_writes.task_id uuid` columns).
44
+ """
45
+ parts = str(uuid.UUID(task_id)).split("-")
46
+ return f"{step:08d}-{parts[1]}-{parts[2]}-{parts[3]}-{parts[4]}"
47
+
48
+
37
49
  def delta_channels_to_snapshot(
38
50
  channels: Mapping[str, BaseChannel],
39
51
  counters_since_delta_snapshot: Mapping[str, tuple[int, int]],
@@ -70,6 +70,7 @@ from langgraph.callbacks import (
70
70
  GraphResumeEvent,
71
71
  )
72
72
  from langgraph.channels.base import BaseChannel
73
+ from langgraph.channels.binop import _get_overwrite
73
74
  from langgraph.channels.delta import DeltaChannel
74
75
  from langgraph.channels.untracked_value import UntrackedValue
75
76
  from langgraph.constants import TAG_HIDDEN
@@ -102,6 +103,7 @@ from langgraph.pregel._checkpoint import (
102
103
  create_checkpoint,
103
104
  delta_channels_to_snapshot,
104
105
  empty_checkpoint,
106
+ exit_delta_task_id,
105
107
  )
106
108
  from langgraph.pregel._executor import (
107
109
  AsyncBackgroundExecutor,
@@ -220,6 +222,11 @@ class PregelLoop:
220
222
  # under the saver's `ORDER BY task_id, idx` sorting.
221
223
  _exit_delta_writes: list[tuple[int, str, str, Any]] | None = None
222
224
 
225
+ # Delta channels that saw an Overwrite since the last checkpoint. These
226
+ # channels must snapshot after live update applies overwrite semantics so
227
+ # sparse replay starts from the same post-overwrite value.
228
+ _delta_channels_with_overwrite: set[str]
229
+
223
230
  # The checkpoint_config that points at the parent loaded at `__enter__`
224
231
  # (or the synthetic-empty checkpoint, on first run). We capture it
225
232
  # eagerly because every `_put_checkpoint` advances `self.checkpoint_config`
@@ -676,6 +683,11 @@ class PregelLoop:
676
683
  def after_tick(self) -> None:
677
684
  # finish superstep
678
685
  writes = [w for t in self.tasks.values() for w in t.writes]
686
+ self._delta_channels_with_overwrite.update(
687
+ ch
688
+ for ch, v in writes
689
+ if isinstance(self.specs.get(ch), DeltaChannel) and _get_overwrite(v)[0]
690
+ )
679
691
  # all tasks have finished
680
692
  self.updated_channels = apply_writes(
681
693
  self.checkpoint,
@@ -979,6 +991,11 @@ class PregelLoop:
979
991
  manager=None,
980
992
  updated_channels=updated_channels,
981
993
  )
994
+ self._delta_channels_with_overwrite.update(
995
+ c
996
+ for c, v in input_writes
997
+ if isinstance(self.specs.get(c), DeltaChannel) and _get_overwrite(v)[0]
998
+ )
982
999
  # apply input writes
983
1000
  updated_channels = apply_writes(
984
1001
  self.checkpoint,
@@ -1119,6 +1136,7 @@ class PregelLoop:
1119
1136
  # create new checkpoint
1120
1137
  channels_to_snapshot = (
1121
1138
  delta_channels_to_snapshot(self.channels, new_counters)
1139
+ | self._delta_channels_with_overwrite
1122
1140
  if do_checkpoint
1123
1141
  else set()
1124
1142
  )
@@ -1135,6 +1153,8 @@ class PregelLoop:
1135
1153
  )
1136
1154
  for k in channels_to_snapshot:
1137
1155
  new_counters[k] = (0, 0)
1156
+ if do_checkpoint:
1157
+ self._delta_channels_with_overwrite.difference_update(channels_to_snapshot)
1138
1158
  non_zero = {k: v for k, v in new_counters.items() if v != (0, 0)}
1139
1159
  if non_zero:
1140
1160
  self.checkpoint_metadata["counters_since_delta_snapshot"] = non_zero
@@ -1217,7 +1237,10 @@ class PregelLoop:
1217
1237
  counters = dict(
1218
1238
  self.checkpoint_metadata.get("counters_since_delta_snapshot") or {}
1219
1239
  )
1220
- channels_to_snapshot = delta_channels_to_snapshot(self.channels, counters)
1240
+ channels_to_snapshot = (
1241
+ delta_channels_to_snapshot(self.channels, counters)
1242
+ | self._delta_channels_with_overwrite
1243
+ )
1221
1244
 
1222
1245
  pending = [
1223
1246
  (step, tid, ch, v)
@@ -1272,7 +1295,7 @@ class PregelLoop:
1272
1295
  },
1273
1296
  )
1274
1297
  for (step, tid), entries in grouped.items():
1275
- synth_tid = f"{step:08d}-{tid}"
1298
+ synth_tid = exit_delta_task_id(step, tid)
1276
1299
  if self.checkpointer_put_writes_accepts_task_path:
1277
1300
  fut = self.submit(
1278
1301
  self.checkpointer_put_writes,
@@ -1661,6 +1684,7 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
1661
1684
  )
1662
1685
  self._delta_write_futs = []
1663
1686
  self._error_handler_write_futs = []
1687
+ self._delta_channels_with_overwrite = set()
1664
1688
  self._exit_delta_writes = (
1665
1689
  [] if self.durability == "exit" and self.checkpointer is not None else None
1666
1690
  )
@@ -1918,6 +1942,7 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
1918
1942
  )
1919
1943
  self._delta_write_futs = []
1920
1944
  self._error_handler_write_futs = []
1945
+ self._delta_channels_with_overwrite = set()
1921
1946
  self._exit_delta_writes = (
1922
1947
  [] if self.durability == "exit" and self.checkpointer is not None else None
1923
1948
  )
@@ -976,3 +976,9 @@ class Overwrite:
976
976
 
977
977
  value: Any
978
978
  """The value to write directly to the channel, bypassing any reducer."""
979
+
980
+ type: Literal["__overwrite__"] = "__overwrite__"
981
+ """Discriminator field. Lets the channel reducer recognise an `Overwrite`
982
+ even after its dataclass form is JSON-serialised and the typed instance
983
+ is lost (e.g. an `orjson`-encoded state update routed through the
984
+ LangGraph API server)."""
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "langgraph"
7
- version = "1.2.6"
7
+ version = "1.2.7"
8
8
  description = "Building stateful, multi-actor applications with LLMs"
9
9
  authors = []
10
10
  requires-python = ">=3.10"
@@ -186,6 +186,50 @@ def test_delta_channel_overwrite() -> None:
186
186
  assert ch.get()[0].content == "new"
187
187
 
188
188
 
189
+ def test_overwrite_dataclass_form_survives_json_roundtrip() -> None:
190
+ """`Overwrite` serialised with `orjson` collapses to a plain dict but
191
+ must still be recognised as an overwrite by the channel reducer.
192
+
193
+ Without the `type` discriminator the dataclass-erased shape (`{"value":
194
+ ...}`) is indistinguishable from a literal channel value, and downstream
195
+ reducers raise `MESSAGE_COERCION_FAILURE` (or similar) on read.
196
+ """
197
+ import orjson
198
+
199
+ from langgraph._internal._constants import OVERWRITE
200
+ from langgraph.channels.binop import _get_overwrite
201
+
202
+ ow = Overwrite(value=[HumanMessage(content="new", id="h2")])
203
+ erased = orjson.loads(orjson.dumps(ow, default=lambda o: o.model_dump()))
204
+
205
+ assert erased["type"] == OVERWRITE
206
+ is_overwrite, value = _get_overwrite(erased)
207
+ assert is_overwrite
208
+ assert isinstance(value, list)
209
+ assert value[0]["content"] == "new"
210
+
211
+
212
+ def test_overwrite_sentinel_dict_still_recognised() -> None:
213
+ """The pre-existing `{"__overwrite__": value}` dict form continues to be
214
+ recognised. This is the canonical sentinel emitted by producers that do
215
+ not have an `Overwrite` dataclass available."""
216
+ from langgraph._internal._constants import OVERWRITE
217
+ from langgraph.channels.binop import _get_overwrite
218
+
219
+ is_overwrite, value = _get_overwrite({OVERWRITE: ["b"]})
220
+ assert is_overwrite
221
+ assert value == ["b"]
222
+
223
+
224
+ def test_overwrite_non_matching_dict_not_recognised() -> None:
225
+ """Dicts that resemble the erased shape but do not carry the
226
+ `__overwrite__` discriminator must not be misclassified as overwrites."""
227
+ from langgraph.channels.binop import _get_overwrite
228
+
229
+ assert _get_overwrite({"value": ["b"]}) == (False, None)
230
+ assert _get_overwrite({"type": "human", "value": "hi"}) == (False, None)
231
+
232
+
189
233
  def test_delta_channel_remove_message_and_replay() -> None:
190
234
  """RemoveMessage must round-trip correctly when writes are replayed."""
191
235
  spec = DeltaChannel(_messages_delta_reducer, list)
@@ -397,6 +441,93 @@ def test_delta_channel_inmemory_saver_assembles_writes() -> None:
397
441
  assert len(state.values["messages"]) == 4 # 2 human + 2 AI
398
442
 
399
443
 
444
+ def test_delta_channel_overwrite_superstep_snapshots() -> None:
445
+ def reducer(state: list[str], writes: Sequence[list[str]]) -> list[str]:
446
+ result = list(state)
447
+ for write in writes:
448
+ result.extend(write)
449
+ return result
450
+
451
+ class State(TypedDict):
452
+ items: Annotated[
453
+ list[str], DeltaChannel(reducer, list, snapshot_frequency=1000)
454
+ ]
455
+
456
+ def node_a(state: State) -> dict:
457
+ return {"items": ["a"]}
458
+
459
+ def node_b(state: State) -> dict:
460
+ return {"items": Overwrite(["b"])}
461
+
462
+ def node_c(state: State) -> dict:
463
+ return {"items": ["c"]}
464
+
465
+ builder = StateGraph(State)
466
+ builder.add_node("node_a", node_a)
467
+ builder.add_node("node_b", node_b)
468
+ builder.add_node("node_c", node_c)
469
+ builder.add_edge(START, "node_a")
470
+ builder.add_edge("node_a", "node_b")
471
+ builder.add_edge("node_a", "node_c")
472
+
473
+ saver = InMemorySaver()
474
+ graph = builder.compile(checkpointer=saver)
475
+ config = {"configurable": {"thread_id": "overwrite-snapshot"}}
476
+
477
+ result = graph.invoke({"items": ["START"]}, config)
478
+ assert result == {"items": ["b"]}
479
+
480
+ saved = saver.get_tuple(config)
481
+ assert saved is not None
482
+ snapshot = saved.checkpoint["channel_values"].get("items")
483
+ assert isinstance(snapshot, _DeltaSnapshot)
484
+ assert snapshot.value == ["b"]
485
+ assert saved.metadata.get("counters_since_delta_snapshot", {}).get("items") is None
486
+
487
+
488
+ def test_delta_channel_replay_after_overwrite_snapshot() -> None:
489
+ def reducer(state: list[str], writes: Sequence[list[str]]) -> list[str]:
490
+ result = list(state)
491
+ for write in writes:
492
+ result.extend(write)
493
+ return result
494
+
495
+ class State(TypedDict):
496
+ items: Annotated[
497
+ list[str], DeltaChannel(reducer, list, snapshot_frequency=1000)
498
+ ]
499
+
500
+ calls = 0
501
+
502
+ def node(state: State) -> dict:
503
+ nonlocal calls
504
+ calls += 1
505
+ if calls == 1:
506
+ return {"items": Overwrite(["reset"])}
507
+ return {"items": ["after"]}
508
+
509
+ builder = StateGraph(State)
510
+ builder.add_node("node", node)
511
+ builder.add_edge(START, "node")
512
+
513
+ saver = InMemorySaver()
514
+ graph = builder.compile(checkpointer=saver)
515
+ config = {"configurable": {"thread_id": "overwrite-replay"}}
516
+
517
+ assert graph.invoke({"items": ["before"]}, config) == {"items": ["reset"]}
518
+ first_saved = saver.get_tuple(config)
519
+ assert first_saved is not None
520
+ assert isinstance(
521
+ first_saved.checkpoint["channel_values"].get("items"), _DeltaSnapshot
522
+ )
523
+
524
+ assert graph.invoke({"items": []}, config) == {"items": ["reset", "after"]}
525
+ second_saved = saver.get_tuple(config)
526
+ assert second_saved is not None
527
+ assert "items" not in second_saved.checkpoint["channel_values"]
528
+ assert graph.get_state(config).values == {"items": ["reset", "after"]}
529
+
530
+
400
531
  # ---------------------------------------------------------------------------
401
532
  # DeltaChannel — dict reducer
402
533
  # ---------------------------------------------------------------------------
@@ -6,6 +6,7 @@ channel), lazy stub creation when no parent exists, and proper read-path
6
6
  reconstruction via ancestor walks.
7
7
  """
8
8
 
9
+ import uuid
9
10
  from typing import Annotated, Any
10
11
 
11
12
  import pytest
@@ -17,10 +18,28 @@ from typing_extensions import TypedDict
17
18
  from langgraph.channels.delta import DeltaChannel
18
19
  from langgraph.graph import START, StateGraph
19
20
  from langgraph.graph.message import _messages_delta_reducer
21
+ from langgraph.pregel._checkpoint import exit_delta_task_id
20
22
 
21
23
  pytestmark = pytest.mark.anyio
22
24
 
23
25
 
26
+ def test_exit_delta_task_id_is_valid_uuid_and_ordered() -> None:
27
+ """Exit-mode synthetic task ids must parse as UUID and sort by superstep."""
28
+ tid = "4f7226e4-0270-bf16-1ef8-fb321bef9f3d"
29
+ id1 = exit_delta_task_id(1, tid)
30
+ id7 = exit_delta_task_id(7, tid)
31
+
32
+ uuid.UUID(id1)
33
+ uuid.UUID(id7)
34
+ assert id1 < id7
35
+ assert id1.split("-")[0] == "00000001"
36
+ assert id7.split("-")[0] == "00000007"
37
+ assert id1.endswith("-0270-bf16-1ef8-fb321bef9f3d")
38
+
39
+ with pytest.raises(ValueError):
40
+ uuid.UUID(f"00000001-{tid}")
41
+
42
+
24
43
  def _build_graph(
25
44
  checkpointer: InMemorySaver,
26
45
  *,
@@ -2755,23 +2755,26 @@ def test_in_one_fan_out_state_graph_waiting_edge_plus_regular(
2755
2755
  "answer": "doc1,doc2,doc3,doc4",
2756
2756
  }
2757
2757
 
2758
- assert [*app.stream({"query": "what is weather in sf"})] in (
2759
- [
2760
- {"rewrite_query": {"query": "query: what is weather in sf"}},
2761
- {"qa": {"answer": ""}},
2762
- {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}},
2763
- {"retriever_two": {"docs": ["doc3", "doc4"]}},
2764
- {"retriever_one": {"docs": ["doc1", "doc2"]}},
2765
- {"qa": {"answer": "doc1,doc2,doc3,doc4"}},
2766
- ],
2767
- [
2768
- {"rewrite_query": {"query": "query: what is weather in sf"}},
2769
- {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}},
2770
- {"qa": {"answer": ""}},
2771
- {"retriever_two": {"docs": ["doc3", "doc4"]}},
2772
- {"retriever_one": {"docs": ["doc1", "doc2"]}},
2773
- {"qa": {"answer": "doc1,doc2,doc3,doc4"}},
2774
- ],
2758
+ rewrite = {"rewrite_query": {"query": "query: what is weather in sf"}}
2759
+ analyzer = {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}}
2760
+ empty_qa = {"qa": {"answer": ""}}
2761
+ retriever_one_result = {"retriever_one": {"docs": ["doc1", "doc2"]}}
2762
+ retriever_two_result = {"retriever_two": {"docs": ["doc3", "doc4"]}}
2763
+
2764
+ def assert_valid_stream_order(
2765
+ chunks: list[dict[str, Any]], terminal: dict[str, Any]
2766
+ ) -> None:
2767
+ assert chunks[0] == rewrite
2768
+ assert chunks[-1] == terminal
2769
+ middle = chunks[1:-1]
2770
+ assert sorted(middle, key=repr) == sorted(
2771
+ [empty_qa, analyzer, retriever_one_result, retriever_two_result], key=repr
2772
+ )
2773
+ assert middle.index(analyzer) < middle.index(retriever_one_result)
2774
+
2775
+ assert_valid_stream_order(
2776
+ [*app.stream({"query": "what is weather in sf"})],
2777
+ {"qa": {"answer": "doc1,doc2,doc3,doc4"}},
2775
2778
  )
2776
2779
 
2777
2780
  app_w_interrupt = workflow.compile(
@@ -2780,25 +2783,9 @@ def test_in_one_fan_out_state_graph_waiting_edge_plus_regular(
2780
2783
  )
2781
2784
  config = {"configurable": {"thread_id": "1"}}
2782
2785
 
2783
- assert [
2784
- c for c in app_w_interrupt.stream({"query": "what is weather in sf"}, config)
2785
- ] in (
2786
- [
2787
- {"rewrite_query": {"query": "query: what is weather in sf"}},
2788
- {"qa": {"answer": ""}},
2789
- {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}},
2790
- {"retriever_two": {"docs": ["doc3", "doc4"]}},
2791
- {"retriever_one": {"docs": ["doc1", "doc2"]}},
2792
- {"__interrupt__": ()},
2793
- ],
2794
- [
2795
- {"rewrite_query": {"query": "query: what is weather in sf"}},
2796
- {"analyzer_one": {"query": "analyzed: query: what is weather in sf"}},
2797
- {"qa": {"answer": ""}},
2798
- {"retriever_two": {"docs": ["doc3", "doc4"]}},
2799
- {"retriever_one": {"docs": ["doc1", "doc2"]}},
2800
- {"__interrupt__": ()},
2801
- ],
2786
+ assert_valid_stream_order(
2787
+ [c for c in app_w_interrupt.stream({"query": "what is weather in sf"}, config)],
2788
+ {"__interrupt__": ()},
2802
2789
  )
2803
2790
 
2804
2791
  assert [c for c in app_w_interrupt.stream(None, config)] == [
@@ -6291,14 +6278,15 @@ def test_sync_streaming_with_functional_api() -> None:
6291
6278
  rather than have all the results arrive at once after the graph has completed.
6292
6279
 
6293
6280
  The time of arrival between the two updates corresponding to the two `slow` tasks
6294
- should be greater than the time delay between the two tasks.
6281
+ should be roughly the task delay. If results are buffered until graph completion,
6282
+ the two updates arrive back-to-back instead.
6295
6283
  """
6296
6284
 
6297
6285
  time_delay = 0.05
6298
6286
 
6299
6287
  @task()
6300
6288
  def slow() -> dict:
6301
- time.sleep(time_delay) # Simulate a delay of 10 ms
6289
+ time.sleep(time_delay)
6302
6290
  return {"tic": time.monotonic()}
6303
6291
 
6304
6292
  @entrypoint()
@@ -6316,8 +6304,9 @@ def test_sync_streaming_with_functional_api() -> None:
6316
6304
 
6317
6305
  assert len(arrival_times) == 2
6318
6306
  delta = arrival_times[1] - arrival_times[0]
6319
- # Delta cannot be less than 10 ms if it is streaming as results are generated.
6320
- assert delta > time_delay
6307
+ # Allow a small amount of scheduler jitter while still verifying the chunks
6308
+ # arrived separately rather than back-to-back after graph completion.
6309
+ assert delta > time_delay * 0.8
6321
6310
 
6322
6311
 
6323
6312
  def test_entrypoint_without_checkpointer() -> None: