langgraph 1.2.2__tar.gz → 1.2.3__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.
- {langgraph-1.2.2 → langgraph-1.2.3}/PKG-INFO +2 -2
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/_internal/_config.py +79 -29
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/errors.py +23 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/_messages.py +3 -3
- langgraph-1.2.3/langgraph/pregel/_remote_run_stream.py +374 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/_retry.py +57 -1
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/debug.py +26 -3
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/remote.py +126 -9
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/stream/_types.py +1 -1
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/stream/transformers.py +99 -6
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/types.py +10 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/pyproject.toml +2 -2
- langgraph-1.2.3/tests/test_config_async.py +116 -0
- langgraph-1.2.3/tests/test_pregel_debug.py +185 -0
- langgraph-1.2.3/tests/test_remote_graph_v3.py +658 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_retry.py +140 -1
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_stream_lifecycle_transformer.py +280 -6
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_utils.py +127 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/uv.lock +70 -5
- langgraph-1.2.2/tests/test_config_async.py +0 -19
- {langgraph-1.2.2 → langgraph-1.2.3}/.gitignore +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/LICENSE +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/Makefile +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/README.md +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/bench/__init__.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/bench/__main__.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/bench/fanout_to_subgraph.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/bench/pydantic_state.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/bench/react_agent.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/bench/sequential.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/bench/serde_allowlist.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/bench/wide_dict.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/bench/wide_state.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/_internal/__init__.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/_internal/_cache.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/_internal/_constants.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/_internal/_fields.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/_internal/_future.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/_internal/_pydantic.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/_internal/_queue.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/_internal/_replay.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/_internal/_retry.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/_internal/_runnable.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/_internal/_scratchpad.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/_internal/_serde.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/_internal/_timeout.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/_internal/_typing.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/callbacks.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/channels/__init__.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/channels/any_value.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/channels/base.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/channels/binop.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/channels/delta.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/channels/ephemeral_value.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/channels/last_value.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/channels/named_barrier_value.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/channels/topic.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/channels/untracked_value.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/config.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/constants.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/func/__init__.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/graph/__init__.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/graph/_branch.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/graph/_node.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/graph/message.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/graph/state.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/graph/ui.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/managed/__init__.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/managed/base.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/managed/is_last_step.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/__init__.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/_algo.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/_call.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/_checkpoint.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/_config.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/_draw.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/_executor.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/_io.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/_log.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/_loop.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/_read.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/_runner.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/_tools.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/_utils.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/_validate.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/_write.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/main.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/protocol.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/pregel/types.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/py.typed +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/runtime.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/stream/__init__.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/stream/_convert.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/stream/_mux.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/stream/run_stream.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/stream/stream_channel.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/typing.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/utils/__init__.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/utils/config.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/utils/runnable.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/version.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/langgraph/warnings.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/__init__.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/__snapshots__/test_large_cases.ambr +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/__snapshots__/test_pregel.ambr +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/__snapshots__/test_pregel_async.ambr +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/agents.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/any_int.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/any_str.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/compose-postgres.yml +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/compose-redis.yml +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/conftest.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/conftest_checkpointer.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/conftest_store.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/example_app/example_graph.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/example_app/langgraph.json +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/example_app/requirements.txt +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/fake_chat.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/fake_tracer.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/memory_assert.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/messages.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_algo.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_channels.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_checkpoint_migration.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_delta_channel_benchmark.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_delta_channel_exit_mode.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_delta_channel_id_stability.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_delta_channel_migration.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_delta_channel_supersteps_bound.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_deprecation.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_graph_callbacks.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_interleave_arrival_order.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_interrupt_migration.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_interruption.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_large_cases.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_large_cases_async.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_managed_values.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_messages_state.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_parent_command.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_parent_command_async.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_pregel.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_pregel_async.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_pregel_stream_events_v3.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_pydantic.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_remote_graph.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_runnable.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_runtime.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_serde_allowlist.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_state.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_stream_before_builtins.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_stream_data_transformers.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_stream_events_v3.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_stream_events_v3_e2e.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_stream_events_v3_kwarg_forwarding.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_stream_messages_transformer.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_stream_subgraph_transformer.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_subgraph_persistence.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_subgraph_persistence_async.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_time_travel.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_time_travel_async.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_tool_stream_handler.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/tests/test_tracing_interops.py +0 -0
- {langgraph-1.2.2 → langgraph-1.2.3}/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.
|
|
3
|
+
Version: 1.2.3
|
|
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.
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|