langgraph 1.2.2__tar.gz → 1.2.4__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.2 → langgraph-1.2.4}/PKG-INFO +2 -2
  2. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/_internal/_config.py +79 -29
  3. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/errors.py +23 -0
  4. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/_messages.py +3 -3
  5. langgraph-1.2.4/langgraph/pregel/_remote_run_stream.py +374 -0
  6. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/_retry.py +57 -1
  7. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/debug.py +26 -3
  8. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/main.py +23 -2
  9. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/remote.py +126 -9
  10. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/stream/_types.py +1 -1
  11. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/stream/transformers.py +105 -7
  12. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/types.py +10 -0
  13. {langgraph-1.2.2 → langgraph-1.2.4}/pyproject.toml +2 -2
  14. langgraph-1.2.4/tests/test_config_async.py +116 -0
  15. langgraph-1.2.4/tests/test_pregel_debug.py +185 -0
  16. langgraph-1.2.4/tests/test_remote_graph_v3.py +658 -0
  17. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_retry.py +140 -1
  18. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_runtime.py +54 -0
  19. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_stream_lifecycle_transformer.py +312 -6
  20. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_utils.py +127 -0
  21. {langgraph-1.2.2 → langgraph-1.2.4}/uv.lock +70 -5
  22. langgraph-1.2.2/tests/test_config_async.py +0 -19
  23. {langgraph-1.2.2 → langgraph-1.2.4}/.gitignore +0 -0
  24. {langgraph-1.2.2 → langgraph-1.2.4}/LICENSE +0 -0
  25. {langgraph-1.2.2 → langgraph-1.2.4}/Makefile +0 -0
  26. {langgraph-1.2.2 → langgraph-1.2.4}/README.md +0 -0
  27. {langgraph-1.2.2 → langgraph-1.2.4}/bench/__init__.py +0 -0
  28. {langgraph-1.2.2 → langgraph-1.2.4}/bench/__main__.py +0 -0
  29. {langgraph-1.2.2 → langgraph-1.2.4}/bench/fanout_to_subgraph.py +0 -0
  30. {langgraph-1.2.2 → langgraph-1.2.4}/bench/pydantic_state.py +0 -0
  31. {langgraph-1.2.2 → langgraph-1.2.4}/bench/react_agent.py +0 -0
  32. {langgraph-1.2.2 → langgraph-1.2.4}/bench/sequential.py +0 -0
  33. {langgraph-1.2.2 → langgraph-1.2.4}/bench/serde_allowlist.py +0 -0
  34. {langgraph-1.2.2 → langgraph-1.2.4}/bench/wide_dict.py +0 -0
  35. {langgraph-1.2.2 → langgraph-1.2.4}/bench/wide_state.py +0 -0
  36. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/_internal/__init__.py +0 -0
  37. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/_internal/_cache.py +0 -0
  38. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/_internal/_constants.py +0 -0
  39. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/_internal/_fields.py +0 -0
  40. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/_internal/_future.py +0 -0
  41. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/_internal/_pydantic.py +0 -0
  42. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/_internal/_queue.py +0 -0
  43. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/_internal/_replay.py +0 -0
  44. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/_internal/_retry.py +0 -0
  45. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/_internal/_runnable.py +0 -0
  46. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/_internal/_scratchpad.py +0 -0
  47. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/_internal/_serde.py +0 -0
  48. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/_internal/_timeout.py +0 -0
  49. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/_internal/_typing.py +0 -0
  50. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/callbacks.py +0 -0
  51. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/channels/__init__.py +0 -0
  52. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/channels/any_value.py +0 -0
  53. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/channels/base.py +0 -0
  54. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/channels/binop.py +0 -0
  55. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/channels/delta.py +0 -0
  56. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/channels/ephemeral_value.py +0 -0
  57. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/channels/last_value.py +0 -0
  58. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/channels/named_barrier_value.py +0 -0
  59. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/channels/topic.py +0 -0
  60. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/channels/untracked_value.py +0 -0
  61. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/config.py +0 -0
  62. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/constants.py +0 -0
  63. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/func/__init__.py +0 -0
  64. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/graph/__init__.py +0 -0
  65. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/graph/_branch.py +0 -0
  66. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/graph/_node.py +0 -0
  67. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/graph/message.py +0 -0
  68. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/graph/state.py +0 -0
  69. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/graph/ui.py +0 -0
  70. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/managed/__init__.py +0 -0
  71. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/managed/base.py +0 -0
  72. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/managed/is_last_step.py +0 -0
  73. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/__init__.py +0 -0
  74. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/_algo.py +0 -0
  75. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/_call.py +0 -0
  76. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/_checkpoint.py +0 -0
  77. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/_config.py +0 -0
  78. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/_draw.py +0 -0
  79. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/_executor.py +0 -0
  80. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/_io.py +0 -0
  81. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/_log.py +0 -0
  82. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/_loop.py +0 -0
  83. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/_read.py +0 -0
  84. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/_runner.py +0 -0
  85. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/_tools.py +0 -0
  86. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/_utils.py +0 -0
  87. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/_validate.py +0 -0
  88. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/_write.py +0 -0
  89. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/protocol.py +0 -0
  90. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/pregel/types.py +0 -0
  91. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/py.typed +0 -0
  92. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/runtime.py +0 -0
  93. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/stream/__init__.py +0 -0
  94. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/stream/_convert.py +0 -0
  95. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/stream/_mux.py +0 -0
  96. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/stream/run_stream.py +0 -0
  97. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/stream/stream_channel.py +0 -0
  98. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/typing.py +0 -0
  99. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/utils/__init__.py +0 -0
  100. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/utils/config.py +0 -0
  101. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/utils/runnable.py +0 -0
  102. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/version.py +0 -0
  103. {langgraph-1.2.2 → langgraph-1.2.4}/langgraph/warnings.py +0 -0
  104. {langgraph-1.2.2 → langgraph-1.2.4}/tests/__init__.py +0 -0
  105. {langgraph-1.2.2 → langgraph-1.2.4}/tests/__snapshots__/test_large_cases.ambr +0 -0
  106. {langgraph-1.2.2 → langgraph-1.2.4}/tests/__snapshots__/test_pregel.ambr +0 -0
  107. {langgraph-1.2.2 → langgraph-1.2.4}/tests/__snapshots__/test_pregel_async.ambr +0 -0
  108. {langgraph-1.2.2 → langgraph-1.2.4}/tests/agents.py +0 -0
  109. {langgraph-1.2.2 → langgraph-1.2.4}/tests/any_int.py +0 -0
  110. {langgraph-1.2.2 → langgraph-1.2.4}/tests/any_str.py +0 -0
  111. {langgraph-1.2.2 → langgraph-1.2.4}/tests/compose-postgres.yml +0 -0
  112. {langgraph-1.2.2 → langgraph-1.2.4}/tests/compose-redis.yml +0 -0
  113. {langgraph-1.2.2 → langgraph-1.2.4}/tests/conftest.py +0 -0
  114. {langgraph-1.2.2 → langgraph-1.2.4}/tests/conftest_checkpointer.py +0 -0
  115. {langgraph-1.2.2 → langgraph-1.2.4}/tests/conftest_store.py +0 -0
  116. {langgraph-1.2.2 → langgraph-1.2.4}/tests/example_app/example_graph.py +0 -0
  117. {langgraph-1.2.2 → langgraph-1.2.4}/tests/example_app/langgraph.json +0 -0
  118. {langgraph-1.2.2 → langgraph-1.2.4}/tests/example_app/requirements.txt +0 -0
  119. {langgraph-1.2.2 → langgraph-1.2.4}/tests/fake_chat.py +0 -0
  120. {langgraph-1.2.2 → langgraph-1.2.4}/tests/fake_tracer.py +0 -0
  121. {langgraph-1.2.2 → langgraph-1.2.4}/tests/memory_assert.py +0 -0
  122. {langgraph-1.2.2 → langgraph-1.2.4}/tests/messages.py +0 -0
  123. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_algo.py +0 -0
  124. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_channels.py +0 -0
  125. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_checkpoint_migration.py +0 -0
  126. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_delta_channel_benchmark.py +0 -0
  127. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_delta_channel_exit_mode.py +0 -0
  128. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_delta_channel_id_stability.py +0 -0
  129. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_delta_channel_migration.py +0 -0
  130. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_delta_channel_supersteps_bound.py +0 -0
  131. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_deprecation.py +0 -0
  132. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_graph_callbacks.py +0 -0
  133. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_interleave_arrival_order.py +0 -0
  134. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_interrupt_migration.py +0 -0
  135. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_interruption.py +0 -0
  136. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_large_cases.py +0 -0
  137. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_large_cases_async.py +0 -0
  138. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_managed_values.py +0 -0
  139. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_messages_state.py +0 -0
  140. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_parent_command.py +0 -0
  141. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_parent_command_async.py +0 -0
  142. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_pregel.py +0 -0
  143. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_pregel_async.py +0 -0
  144. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_pregel_stream_events_v3.py +0 -0
  145. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_pydantic.py +0 -0
  146. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_remote_graph.py +0 -0
  147. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_runnable.py +0 -0
  148. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_serde_allowlist.py +0 -0
  149. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_state.py +0 -0
  150. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_stream_before_builtins.py +0 -0
  151. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_stream_data_transformers.py +0 -0
  152. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_stream_events_v3.py +0 -0
  153. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_stream_events_v3_e2e.py +0 -0
  154. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_stream_events_v3_kwarg_forwarding.py +0 -0
  155. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_stream_messages_transformer.py +0 -0
  156. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_stream_subgraph_transformer.py +0 -0
  157. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_subgraph_persistence.py +0 -0
  158. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_subgraph_persistence_async.py +0 -0
  159. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_time_travel.py +0 -0
  160. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_time_travel_async.py +0 -0
  161. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_tool_stream_handler.py +0 -0
  162. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_tracing_interops.py +0 -0
  163. {langgraph-1.2.2 → langgraph-1.2.4}/tests/test_type_checking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph
3
- Version: 1.2.2
3
+ Version: 1.2.4
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/
@@ -25,7 +25,7 @@ Requires-Python: >=3.10
25
25
  Requires-Dist: langchain-core<2,>=1.4.0
26
26
  Requires-Dist: langgraph-checkpoint<5.0.0,>=4.1.0
27
27
  Requires-Dist: langgraph-prebuilt<1.2.0,>=1.1.0
28
- Requires-Dist: langgraph-sdk<0.4.0,>=0.3.0
28
+ Requires-Dist: langgraph-sdk<0.5.0,>=0.4.2
29
29
  Requires-Dist: pydantic>=2.7.4
30
30
  Requires-Dist: xxhash>=3.5.0
31
31
  Description-Content-Type: text/markdown
@@ -79,6 +79,34 @@ def patch_checkpoint_map(
79
79
  return config
80
80
 
81
81
 
82
+ def _merge_callbacks(base: Callbacks, new: Callbacks) -> Callbacks:
83
+ """Merge two callbacks values (None / list / BaseCallbackManager).
84
+
85
+ Six cases total (3 base types x 2 non-None new types).
86
+ """
87
+ if new is None:
88
+ return base
89
+ if base is None:
90
+ return new.copy() if isinstance(new, (list, BaseCallbackManager)) else new
91
+ if isinstance(new, list):
92
+ if isinstance(base, list):
93
+ return base + new
94
+ if isinstance(base, BaseCallbackManager):
95
+ mngr = base.copy()
96
+ for cb in new:
97
+ mngr.add_handler(cb, inherit=True)
98
+ return mngr
99
+ elif isinstance(new, BaseCallbackManager):
100
+ if isinstance(base, list):
101
+ mngr = new.copy()
102
+ for cb in base:
103
+ mngr.add_handler(cb, inherit=True)
104
+ return mngr
105
+ if isinstance(base, BaseCallbackManager):
106
+ return base.merge(new)
107
+ raise NotImplementedError(f"Unsupported callback types: {type(base)}, {type(new)}")
108
+
109
+
82
110
  def merge_configs(*configs: RunnableConfig | None) -> RunnableConfig:
83
111
  """Merge multiple configs into one.
84
112
 
@@ -113,34 +141,9 @@ def merge_configs(*configs: RunnableConfig | None) -> RunnableConfig:
113
141
  else:
114
142
  base[key] = value
115
143
  elif key == "callbacks":
116
- base_callbacks = base.get("callbacks")
117
- # callbacks can be either None, list[handler] or manager
118
- # so merging two callbacks values has 6 cases
119
- if isinstance(value, list):
120
- if base_callbacks is None:
121
- base["callbacks"] = value.copy()
122
- elif isinstance(base_callbacks, list):
123
- base["callbacks"] = base_callbacks + value
124
- else:
125
- # base_callbacks is a manager
126
- mngr = base_callbacks.copy()
127
- for callback in value:
128
- mngr.add_handler(callback, inherit=True)
129
- base["callbacks"] = mngr
130
- elif isinstance(value, BaseCallbackManager):
131
- # value is a manager
132
- if base_callbacks is None:
133
- base["callbacks"] = value.copy()
134
- elif isinstance(base_callbacks, list):
135
- mngr = value.copy()
136
- for callback in base_callbacks:
137
- mngr.add_handler(callback, inherit=True)
138
- base["callbacks"] = mngr
139
- else:
140
- # base_callbacks is also a manager
141
- base["callbacks"] = base_callbacks.merge(value)
142
- else:
143
- raise NotImplementedError
144
+ base["callbacks"] = _merge_callbacks(
145
+ base.get("callbacks"), cast(Callbacks, value)
146
+ )
144
147
  elif key == "recursion_limit":
145
148
  if config["recursion_limit"] != DEFAULT_RECURSION_LIMIT:
146
149
  base["recursion_limit"] = config["recursion_limit"]
@@ -309,7 +312,40 @@ def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig:
309
312
  for k, v in config.items():
310
313
  if _is_not_empty(v) and k in CONFIG_KEYS:
311
314
  if k == CONF:
312
- empty[k] = cast(dict, v).copy()
315
+ # Shallow-merge configurable dicts across configs so values
316
+ # bound via with_config(...) (e.g. ls_agent_type) are
317
+ # preserved when later configs (e.g. invoke-time) only
318
+ # specify a subset of keys like thread_id.
319
+ existing = empty.get(k)
320
+ empty[k] = (
321
+ {**cast(dict, existing), **cast(dict, v)}
322
+ if existing
323
+ else cast(dict, v).copy()
324
+ )
325
+ elif k == "callbacks":
326
+ empty["callbacks"] = _merge_callbacks(
327
+ empty.get("callbacks"), cast(Callbacks, v)
328
+ )
329
+ elif k == "metadata":
330
+ # Shallow-merge metadata dicts across configs so values
331
+ # bound via with_config(...) (e.g. user_id) are preserved
332
+ # when later configs supply other metadata keys.
333
+ existing = empty.get("metadata")
334
+ empty["metadata"] = (
335
+ {**cast(dict, existing), **cast(dict, v)}
336
+ if existing
337
+ else cast(dict, v).copy()
338
+ )
339
+ elif k == "tags":
340
+ # Concatenate tags across configs so values bound via
341
+ # with_config(...) are preserved when later configs
342
+ # supply additional tags. Matches merge_configs.
343
+ existing_tags: list[str] | None = empty.get("tags")
344
+ empty["tags"] = (
345
+ [*existing_tags, *cast(list, v)]
346
+ if existing_tags
347
+ else list(cast(list, v))
348
+ )
313
349
  else:
314
350
  empty[k] = v # type: ignore[literal-required]
315
351
  for k, v in config.items():
@@ -366,3 +402,17 @@ _PROPAGATE_TO_METADATA = frozenset(
366
402
  "graph_id",
367
403
  )
368
404
  )
405
+
406
+
407
+ def filter_to_user_tags(tags: Sequence[str] | None) -> list[str] | None:
408
+ """Drop langgraph's internal `seq:step:*` bookkeeping tags.
409
+
410
+ `seq:step:N` tags are added internally to mark sequence steps; everything
411
+ else (user-supplied tags and any other framework tags) is kept. Returns the
412
+ surviving tags, or `None` if none remain. Shared by the `messages` and
413
+ `tasks` stream handlers so both surface the same tag set on their metadata.
414
+ """
415
+ if not tags:
416
+ return None
417
+ filtered = [t for t in tags if not t.startswith("seq:step")]
418
+ return filtered or None
@@ -21,6 +21,7 @@ __all__ = (
21
21
  "InvalidUpdateError",
22
22
  "GraphBubbleUp",
23
23
  "GraphInterrupt",
24
+ "NodeCancelledError",
24
25
  "NodeError",
25
26
  "NodeInterrupt",
26
27
  "NodeTimeoutError",
@@ -164,6 +165,28 @@ class NodeError:
164
165
  """Exception raised by the failed node."""
165
166
 
166
167
 
168
+ class NodeCancelledError(Exception):
169
+ """Raised when a node body raises ``asyncio.CancelledError`` itself.
170
+
171
+ ``asyncio.CancelledError`` is a ``BaseException`` and the pregel runner
172
+ treats cancelled task futures as silent tear-down (e.g. when it stops
173
+ sibling tasks after a peer fails). That is the correct behaviour for
174
+ *framework-initiated* cancellation, but a user node that raises
175
+ ``asyncio.CancelledError`` from its own body should surface as a node
176
+ failure, the same way any other exception would.
177
+
178
+ The retry layer converts user-raised ``asyncio.CancelledError`` into this
179
+ type so it flows through the normal error path and the run reports as
180
+ ``error`` instead of silently succeeding.
181
+ """
182
+
183
+ node: str
184
+
185
+ def __init__(self, node: str, message: str | None = None) -> None:
186
+ super().__init__(message or f"Node {node!r} raised asyncio.CancelledError")
187
+ self.node = node
188
+
189
+
167
190
  class NodeTimeoutError(Exception):
168
191
  """Raised when a node invocation exceeds one of its configured timeouts.
169
192
 
@@ -15,6 +15,7 @@ from langchain_core.messages.utils import convert_to_messages
15
15
  from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResult
16
16
  from pydantic import BaseModel
17
17
 
18
+ from langgraph._internal._config import filter_to_user_tags
18
19
  from langgraph._internal._constants import NS_SEP
19
20
  from langgraph.constants import TAG_HIDDEN, TAG_NOSTREAM
20
21
  from langgraph.pregel.protocol import StreamChunk
@@ -143,9 +144,8 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
143
144
  ]
144
145
  if not self.subgraphs and len(ns) > 0 and ns != self.parent_ns:
145
146
  return
146
- if tags:
147
- if filtered_tags := [t for t in tags if not t.startswith("seq:step")]:
148
- metadata["tags"] = filtered_tags
147
+ if (filtered_tags := filter_to_user_tags(tags)) is not None:
148
+ metadata["tags"] = filtered_tags
149
149
  self.metadata[run_id] = (ns, metadata)
150
150
 
151
151
  def on_llm_new_token(
@@ -0,0 +1,374 @@
1
+ # libs/langgraph/langgraph/pregel/_remote_run_stream.py
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import sys
6
+ from collections.abc import AsyncIterator, Iterator, Mapping
7
+ from types import TracebackType
8
+ from typing import Any, cast
9
+
10
+ from langchain_core.runnables import RunnableConfig
11
+ from langgraph_sdk._async.stream import AsyncThreadStream
12
+ from langgraph_sdk._sync.stream import SyncThreadStream
13
+ from langgraph_sdk.client import LangGraphClient, SyncLangGraphClient
14
+ from langgraph_sdk.stream.decoders import DataDecoder
15
+
16
+ from langgraph.types import Command
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def _translate_command_input(input: Any) -> Any:
22
+ """Translate a local `Command` into the v3 wire `input`, else passthrough.
23
+
24
+ The v3 server decides start-vs-resume from thread state (an interrupted
25
+ run or pending interrupts) and, on resume, wraps the whole `input` as
26
+ `{"resume": input}` itself. So a resume `Command` must surface its raw
27
+ `resume` value as the wire `input` (not the serialized dataclass, which
28
+ the server would double-wrap). The v3 `run.start` path has no `goto` /
29
+ `update` channel, so those are rejected.
30
+
31
+ `langgraph_sdk` is upstream of `langgraph`, so this `Command`-aware
32
+ marshalling lives here on the adapter (langgraph) side of the boundary.
33
+ """
34
+ if isinstance(input, Command):
35
+ if input.goto or input.update:
36
+ raise NotImplementedError(
37
+ "RemoteGraph v3 streaming supports `Command(resume=...)` only; "
38
+ "`goto` / `update` are not supported by the v3 `run.start` path."
39
+ )
40
+ return input.resume
41
+ return input
42
+
43
+
44
+ class _ChannelProjection:
45
+ """Decoded projection for a wire channel the SDK doesn't type natively.
46
+
47
+ Subscribes to `channel` and decodes each event's `params["data"]` through the
48
+ SDK's `DataDecoder` — the same decoder the SDK's own plain-payload projections
49
+ (`values` / `updates` / `checkpoints` / `tasks`) use, which yields the item
50
+ shape that local's `UpdatesTransformer` / `CheckpointsTransformer` /
51
+ `TasksTransformer` / `CustomTransformer` push, so iterating this matches the
52
+ corresponding local projection. Iterate with `for` against a sync stream and
53
+ `async for` against an async stream (matching the underlying SDK). Opening
54
+ the subscription requires the stream to be entered (`with` / `async with`).
55
+ """
56
+
57
+ def __init__(self, sdk: AsyncThreadStream | SyncThreadStream, channel: str) -> None:
58
+ self._sdk = sdk
59
+ self._channel = channel
60
+
61
+ def __iter__(self) -> Iterator[Any]:
62
+ # Sync lane: the sync adapter's SDK returns a sync iterator here.
63
+ decoder = DataDecoder(self._channel)
64
+ events = cast(Iterator[Any], self._sdk.subscribe([self._channel]))
65
+ for event in events:
66
+ yield from decoder.feed(event)
67
+
68
+ def __aiter__(self) -> AsyncIterator[Any]:
69
+ return self._aiter()
70
+
71
+ async def _aiter(self) -> AsyncIterator[Any]:
72
+ # Async lane: the async adapter's SDK returns an async iterator here.
73
+ decoder = DataDecoder(self._channel)
74
+ events = cast(AsyncIterator[Any], self._sdk.subscribe([self._channel]))
75
+ async for event in events:
76
+ for item in decoder.feed(event):
77
+ yield item
78
+
79
+
80
+ class _ProjectionRegistry(Mapping[str, Any]):
81
+ """Read-only name -> projection registry mirroring local `GraphRunStream.extensions`.
82
+
83
+ Resolution follows the langchain-protocol wire channels, and every entry
84
+ yields the same decoded item shape local does (`params.data`):
85
+
86
+ - `values` / `messages` / `tool_calls` / `subgraphs` resolve to the SDK's
87
+ decoded typed projections. `tool_calls` is the `tools` channel — tool
88
+ *execution* events, distinct from the tool-call *inputs* inside `messages`.
89
+ - `updates` / `checkpoints` / `tasks` / `custom` have no typed SDK
90
+ projection, so they resolve to a `_ChannelProjection` that subscribes to
91
+ the channel and yields `params.data` — matching the local transformer
92
+ output for those channels.
93
+ - any other name is a specific custom-extension channel
94
+ (`thread.extensions[name]`, i.e. `custom:<name>`).
95
+
96
+ `lifecycle` is intentionally absent: local derives a status payload from it
97
+ rather than yielding `params.data`, and the SDK consumes it as control-plane
98
+ (driving `output` / `interrupted`), so its shape can't be matched — it
99
+ remains reachable via the raw `events` iterator. `debug` is absent too: it
100
+ is not a v3 wire channel.
101
+ """
102
+
103
+ # Channels the SDK decodes into typed projections.
104
+ _TYPED = ("values", "messages", "tool_calls", "subgraphs")
105
+ # Wire channels with no typed SDK projection — decoded here to match local.
106
+ _DECODED = ("updates", "checkpoints", "tasks", "custom")
107
+ _NATIVE = _TYPED + _DECODED
108
+
109
+ def __init__(self, sdk: AsyncThreadStream | SyncThreadStream) -> None:
110
+ self._sdk = sdk
111
+
112
+ def __getitem__(self, name: str) -> Any:
113
+ if name in self._TYPED:
114
+ return getattr(self._sdk, name)
115
+ if name in self._DECODED:
116
+ return _ChannelProjection(self._sdk, name)
117
+ return self._sdk.extensions[name]
118
+
119
+ def __iter__(self) -> Iterator[str]:
120
+ return iter(self._NATIVE)
121
+
122
+ def __len__(self) -> int:
123
+ return len(self._NATIVE)
124
+
125
+
126
+ class _RemoteGraphRunStream:
127
+ """Sync adapter: SyncThreadStream -> GraphRunStream surface."""
128
+
129
+ def __init__(
130
+ self,
131
+ *,
132
+ sync_client: SyncLangGraphClient,
133
+ sdk_thread: SyncThreadStream,
134
+ input: Any,
135
+ config: RunnableConfig | None,
136
+ metadata: dict[str, Any] | None,
137
+ ) -> None:
138
+ self._client = sync_client
139
+ self._sdk = sdk_thread
140
+ self._start_kwargs: dict[str, Any] = {
141
+ "input": _translate_command_input(input),
142
+ "config": config,
143
+ "metadata": metadata,
144
+ }
145
+ self._run_id: str | None = None
146
+ self._closed = False
147
+ self._events_iter: Iterator[Any] | None = None
148
+
149
+ def __enter__(self) -> _RemoteGraphRunStream:
150
+ if self._closed:
151
+ raise RuntimeError("_RemoteGraphRunStream already closed")
152
+ self._sdk.__enter__()
153
+ try:
154
+ result = self._sdk.run.start(**self._start_kwargs)
155
+ except BaseException:
156
+ self._sdk.__exit__(*sys.exc_info())
157
+ raise
158
+ self._run_id = result["run_id"]
159
+ return self
160
+
161
+ def __exit__(
162
+ self,
163
+ exc_type: type[BaseException] | None,
164
+ exc: BaseException | None,
165
+ tb: TracebackType | None,
166
+ ) -> None:
167
+ if self._closed:
168
+ return
169
+ self._closed = True
170
+ self._sdk.__exit__(exc_type, exc, tb)
171
+
172
+ @property
173
+ def output(self) -> Any:
174
+ return self._sdk.output
175
+
176
+ @property
177
+ def interrupted(self) -> bool:
178
+ """Whether the remote run is currently paused at an interrupt.
179
+
180
+ Reads the SDK's current value without blocking. This differs from
181
+ local `GraphRunStream.interrupted`, which drives the run to terminal
182
+ before returning the flag. Sync callers needing a wait-for-interrupt
183
+ pattern should switch to the async API and drain a projection.
184
+ """
185
+ return self._sdk.interrupted
186
+
187
+ @property
188
+ def interrupts(self) -> list[Any]:
189
+ """Current outstanding interrupt payloads (non-blocking snapshot)."""
190
+ return list(self._sdk.interrupts)
191
+
192
+ @property
193
+ def values(self) -> Any:
194
+ """Live state-snapshot projection (mirrors local `run.values`)."""
195
+ return self._sdk.values
196
+
197
+ @property
198
+ def messages(self) -> Any:
199
+ """Live message-stream projection (mirrors local `run.messages`)."""
200
+ return self._sdk.messages
201
+
202
+ @property
203
+ def subgraphs(self) -> Any:
204
+ """Subgraph-handle projection (mirrors local `run.subgraphs`)."""
205
+ return self._sdk.subgraphs
206
+
207
+ @property
208
+ def tool_calls(self) -> Any:
209
+ """Tool-execution projection (the `tools` channel).
210
+
211
+ These are tool *execution* events (started / output / finished),
212
+ distinct from the tool-call *inputs* carried inside `messages`.
213
+ """
214
+ return self._sdk.tool_calls
215
+
216
+ @property
217
+ def extensions(self) -> Mapping[str, Any]:
218
+ """Name -> projection registry (mirrors local `run.extensions`)."""
219
+ return _ProjectionRegistry(self._sdk)
220
+
221
+ def abort(self) -> None:
222
+ if self._closed:
223
+ return
224
+ self._closed = True
225
+ if self._run_id is not None:
226
+ try:
227
+ self._client.runs.cancel(self._sdk.thread_id, self._run_id, wait=False)
228
+ except Exception:
229
+ logger.debug("abort: runs.cancel failed", exc_info=True)
230
+ try:
231
+ self._sdk.close()
232
+ except Exception:
233
+ logger.debug("abort: sdk.close failed", exc_info=True)
234
+
235
+ def __iter__(self) -> Iterator[Any]:
236
+ if self._events_iter is None:
237
+ self._events_iter = iter(self._sdk.events)
238
+ return self._events_iter
239
+
240
+ def interleave(self, *names: str) -> Iterator[tuple[str, Any]]:
241
+ yield from self._sdk.interleave_projections(list(names))
242
+
243
+
244
+ class _AsyncRemoteGraphRunStream:
245
+ """Async adapter: AsyncThreadStream -> AsyncGraphRunStream surface."""
246
+
247
+ def __init__(
248
+ self,
249
+ *,
250
+ client: LangGraphClient,
251
+ sdk_thread: AsyncThreadStream,
252
+ input: Any,
253
+ config: RunnableConfig | None,
254
+ metadata: dict[str, Any] | None,
255
+ ) -> None:
256
+ self._client = client
257
+ self._sdk = sdk_thread
258
+ self._start_kwargs: dict[str, Any] = {
259
+ "input": _translate_command_input(input),
260
+ "config": config,
261
+ "metadata": metadata,
262
+ }
263
+ self._run_id: str | None = None
264
+ self._closed = False
265
+ self._events_aiter: AsyncIterator[Any] | None = None
266
+
267
+ async def __aenter__(self) -> _AsyncRemoteGraphRunStream:
268
+ if self._closed:
269
+ raise RuntimeError("_AsyncRemoteGraphRunStream already closed")
270
+ await self._sdk.__aenter__()
271
+ try:
272
+ result = await self._sdk.run.start(**self._start_kwargs)
273
+ except BaseException:
274
+ await self._sdk.__aexit__(*sys.exc_info())
275
+ raise
276
+ self._run_id = result["run_id"]
277
+ return self
278
+
279
+ async def __aexit__(
280
+ self,
281
+ exc_type: type[BaseException] | None,
282
+ exc: BaseException | None,
283
+ tb: TracebackType | None,
284
+ ) -> None:
285
+ if self._closed:
286
+ return
287
+ self._closed = True
288
+ await self._sdk.__aexit__(exc_type, exc, tb)
289
+
290
+ async def output(self) -> Any:
291
+ """Drive the remote run to completion and return the final state.
292
+
293
+ Awaits the SDK's terminal-state awaitable, matching local
294
+ `AsyncGraphRunStream.output()` (a method, not a property, so
295
+ `run.output` without `await` fails at type-check time rather than
296
+ silently yielding a coroutine).
297
+ """
298
+ return await self._sdk.output
299
+
300
+ async def interrupted(self) -> bool:
301
+ """Whether the remote run is currently paused at an interrupt.
302
+
303
+ Reads the SDK's current value without blocking. This differs from
304
+ local `AsyncGraphRunStream.interrupted()`, which drives the run to
305
+ terminal before returning the flag. Callers that need a
306
+ wait-for-interrupt pattern should drain a projection (e.g.,
307
+ `async for snap in stream._sdk.values`) until the SDK's paused
308
+ sentinel fires, then call this method.
309
+ """
310
+ return self._sdk.interrupted
311
+
312
+ async def interrupts(self) -> list[Any]:
313
+ """Current outstanding interrupt payloads.
314
+
315
+ Non-blocking; reads the SDK's current snapshot. See `interrupted`
316
+ for the divergence from local v3 semantics.
317
+ """
318
+ return list(self._sdk.interrupts)
319
+
320
+ @property
321
+ def values(self) -> Any:
322
+ """Live state-snapshot projection (mirrors local `run.values`)."""
323
+ return self._sdk.values
324
+
325
+ @property
326
+ def messages(self) -> Any:
327
+ """Live message-stream projection (mirrors local `run.messages`)."""
328
+ return self._sdk.messages
329
+
330
+ @property
331
+ def subgraphs(self) -> Any:
332
+ """Subgraph-handle projection (mirrors local `run.subgraphs`)."""
333
+ return self._sdk.subgraphs
334
+
335
+ @property
336
+ def tool_calls(self) -> Any:
337
+ """Tool-execution projection (the `tools` channel).
338
+
339
+ These are tool *execution* events (started / output / finished),
340
+ distinct from the tool-call *inputs* carried inside `messages`.
341
+ """
342
+ return self._sdk.tool_calls
343
+
344
+ @property
345
+ def extensions(self) -> Mapping[str, Any]:
346
+ """Name -> projection registry (mirrors local `run.extensions`)."""
347
+ return _ProjectionRegistry(self._sdk)
348
+
349
+ async def abort(self) -> None:
350
+ if self._closed:
351
+ return
352
+ self._closed = True
353
+ if self._run_id is not None:
354
+ try:
355
+ await self._client.runs.cancel(
356
+ self._sdk.thread_id, self._run_id, wait=False
357
+ )
358
+ except Exception:
359
+ logger.debug("abort: runs.cancel failed", exc_info=True)
360
+ try:
361
+ await self._sdk.close()
362
+ except Exception:
363
+ logger.debug("abort: sdk.close failed", exc_info=True)
364
+
365
+ def __aiter__(self) -> AsyncIterator[Any]:
366
+ if self._events_aiter is None:
367
+ self._events_aiter = self._sdk.events.__aiter__()
368
+ return self._events_aiter
369
+
370
+ # Note: deliberately no `interleave()` on the async adapter. Local
371
+ # `AsyncGraphRunStream` doesn't have one either (async callers compose
372
+ # with `asyncio.gather` / `asyncio.as_completed`). The sync adapter
373
+ # provides `interleave()` because sync callers have no comparable
374
+ # primitive for iterating multiple iterators concurrently.
@@ -37,13 +37,23 @@ from langgraph._internal._constants import (
37
37
  )
38
38
  from langgraph._internal._runnable import create_task_in_config_context
39
39
  from langgraph._internal._timeout import sync_timeout_unsupported
40
- from langgraph.errors import GraphBubbleUp, NodeTimeoutError, ParentCommand
40
+ from langgraph.errors import (
41
+ GraphBubbleUp,
42
+ NodeCancelledError,
43
+ NodeTimeoutError,
44
+ ParentCommand,
45
+ )
41
46
  from langgraph.pregel.protocol import StreamProtocol
42
47
  from langgraph.runtime import ExecutionInfo, Runtime
43
48
  from langgraph.types import Command, PregelExecutableTask, RetryPolicy, TimeoutPolicy
44
49
 
45
50
  logger = logging.getLogger(__name__)
46
51
  SUPPORTS_EXC_NOTES = sys.version_info >= (3, 11)
52
+ # `asyncio.Task.cancelling()` was added in Python 3.11. It reports the number of
53
+ # pending cancel requests on the task: ``0`` means no external code asked us to
54
+ # cancel — so a ``CancelledError`` observed here was raised by the task body
55
+ # itself (the user's node) rather than by pregel cancelling sibling tasks.
56
+ SUPPORTS_TASK_CANCELLING = sys.version_info >= (3, 11)
47
57
 
48
58
 
49
59
  def _timeout_secs(value: float | timedelta) -> float:
@@ -302,6 +312,28 @@ class _IdleProgressCallbackHandler(BaseCallbackHandler):
302
312
  on_custom_event = _touch
303
313
 
304
314
 
315
+ def _is_user_raised_cancelled() -> bool:
316
+ """Return True if the in-flight ``CancelledError`` came from the task body.
317
+
318
+ Pregel cancels sibling tasks via ``task.cancel()`` when a peer fails, which
319
+ increments ``asyncio.Task.cancelling()`` on the target before the cancel
320
+ actually fires. A user node that calls ``raise asyncio.CancelledError()``
321
+ from inside its own body raises while ``cancelling() == 0``, which is the
322
+ signal we use to convert the exception into a regular
323
+ :class:`NodeCancelledError`.
324
+
325
+ Returns ``False`` when we can't tell (``cancelling()`` unavailable, or no
326
+ current task — neither should happen in practice from ``arun_with_retry``)
327
+ so framework-initiated cancellation continues to propagate unchanged.
328
+ """
329
+ if not SUPPORTS_TASK_CANCELLING:
330
+ return False
331
+ current = asyncio.current_task()
332
+ if current is None:
333
+ return False
334
+ return current.cancelling() == 0
335
+
336
+
305
337
  def _drain_cancelled(task: asyncio.Task[Any]) -> None:
306
338
  # Mark the abandoned task's exception as retrieved so asyncio doesn't log it.
307
339
  with suppress(asyncio.CancelledError):
@@ -600,6 +632,12 @@ def run_with_retry(
600
632
  except GraphBubbleUp:
601
633
  # if interrupted, end
602
634
  raise
635
+ except asyncio.CancelledError as exc:
636
+ # A sync node has no asyncio context, so any ``CancelledError`` that
637
+ # reaches here was raised by the node body itself. Surface it as a
638
+ # regular exception so the pregel runner panics the run instead of
639
+ # treating the task as a silent tear-down (LSD-1507).
640
+ raise NodeCancelledError(task.name) from exc
603
641
  except Exception as exc:
604
642
  if SUPPORTS_EXC_NOTES:
605
643
  exc.add_note(f"During task with name '{task.name}' and id '{task.id}'")
@@ -736,6 +774,24 @@ async def arun_with_retry(
736
774
  # if interrupted, end
737
775
  _finish_timed_attempt(config, attempt_ctx)
738
776
  raise
777
+ except asyncio.CancelledError as exc:
778
+ # ``CancelledError`` reaches us in two very different shapes:
779
+ # 1. Pregel cancelled this task because a sibling failed
780
+ # (``asyncio.Task.cancelling() >= 1``). The framework already
781
+ # knows the run is failing and we must let cancellation
782
+ # propagate so the watchdog/cleanup code in the runner sees a
783
+ # cancelled future.
784
+ # 2. The node body itself raised ``asyncio.CancelledError`` (
785
+ # ``cancelling() == 0``). The runner would otherwise treat
786
+ # this as silent tear-down and the run would report
787
+ # ``success`` even though the node failed (LSD-1507). Convert
788
+ # it into :class:`NodeCancelledError` so it follows the same
789
+ # path as any other node failure.
790
+ if _is_user_raised_cancelled():
791
+ _finish_timed_attempt(config, attempt_ctx, exc)
792
+ raise NodeCancelledError(task.name) from exc
793
+ _finish_timed_attempt(config, attempt_ctx, exc)
794
+ raise
739
795
  except Exception as exc:
740
796
  _finish_timed_attempt(config, attempt_ctx, exc)
741
797
  if SUPPORTS_EXC_NOTES: