Merge branch 'main' into delta-channel-writes-based

This commit is contained in:
Sydney Runkle
2026-04-29 17:15:31 -04:00
committed by GitHub
31 changed files with 3050 additions and 49 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
<a href="https://opensource.org/licenses/MIT" target="_blank"><img src="https://img.shields.io/pypi/l/langgraph" alt="PyPI - License"></a>
<a href="https://pypistats.org/packages/langgraph" target="_blank"><img src="https://img.shields.io/pepy/dt/langgraph" alt="PyPI - Downloads"></a>
<a href="https://pypi.org/project/langgraph/" target="_blank"><img src="https://img.shields.io/pypi/v/langgraph.svg?label=%20" alt="Version"></a>
<a href="https://x.com/langchain" target="_blank"><img src="https://img.shields.io/twitter/url/https/twitter.com/langchain.svg?style=social&label=Follow%20%40LangChain" alt="Twitter / X"></a>
<a href="https://x.com/langchain_oss" target="_blank"><img src="https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain" alt="Twitter / X"></a>
</div>
<br>
+1 -1
View File
@@ -20,7 +20,7 @@ dependencies = [
[project.urls]
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres"
Twitter = "https://x.com/LangChain"
Twitter = "https://x.com/langchain_oss"
Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
+1 -1
View File
@@ -19,7 +19,7 @@ dependencies = [
[project.urls]
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-sqlite"
Twitter = "https://x.com/LangChain"
Twitter = "https://x.com/langchain_oss"
Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
@@ -73,6 +73,7 @@ SAFE_MSGPACK_TYPES: frozenset[tuple[str, ...]] = frozenset(
("langchain_core.documents.base", "Document"),
# langgraph
("langgraph.types", "Send"),
("langgraph.types", "TimeoutPolicy"),
("langgraph.types", "Interrupt"),
("langgraph.types", "Command"),
("langgraph.types", "StateSnapshot"),
@@ -502,10 +502,13 @@ def _msgpack_default(obj: Any) -> str | ormsgpack.Ext:
),
)
elif isinstance(obj, SendProtocol):
args: tuple[Any, ...] = (obj.node, obj.arg)
if (timeout := getattr(obj, "timeout", None)) is not None:
args = (obj.node, obj.arg, timeout)
return ormsgpack.Ext(
EXT_CONSTRUCTOR_POS_ARGS,
_msgpack_enc(
(obj.__class__.__module__, obj.__class__.__name__, (obj.node, obj.arg)),
(obj.__class__.__module__, obj.__class__.__name__, args),
),
)
elif dataclasses.is_dataclass(obj):
@@ -556,6 +559,15 @@ def _msgpack_default(obj: Any) -> str | ormsgpack.Ext:
raise TypeError(f"Object of type {obj.__class__.__name__} is not serializable")
def _send_from_args(args: Sequence[Any]) -> Any:
# ya we have a cyclic import here ¯\_(ツ)_/¯
from langgraph.types import Send # type: ignore
if len(args) == 2:
return Send(*args)
return Send(args[0], args[1], timeout=args[2])
def _create_msgpack_ext_hook(
allowed_modules: set[tuple[str, ...]] | Literal[True] | None,
) -> Callable[[int, bytes], Any]:
@@ -673,6 +685,8 @@ def _create_msgpack_ext_hook(
)
if not _check_allowed(tup[0], tup[1]):
return tup[2]
if tup[0] == "langgraph.types" and tup[1] == "Send":
return _send_from_args(tup[2])
# module, name, args
return getattr(importlib.import_module(tup[0]), tup[1])(*tup[2])
except Exception:
@@ -786,9 +800,7 @@ def _msgpack_ext_hook_to_json(code: int, data: bytes) -> Any:
option=ormsgpack.OPT_NON_STR_KEYS,
)
if tup[0] == "langgraph.types" and tup[1] == "Send":
from langgraph.types import Send # type: ignore
return Send(*tup[2])
return _send_from_args(tup[2])
# module, name, args
return tup[2]
except Exception:
+1 -1
View File
@@ -18,7 +18,7 @@ dependencies = [
[project.urls]
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint"
Twitter = "https://x.com/LangChain"
Twitter = "https://x.com/langchain_oss"
Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
+1 -1
View File
@@ -29,7 +29,7 @@ inmem = [
[project.urls]
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/cli"
Twitter = "https://x.com/LangChain"
Twitter = "https://x.com/langchain_oss"
Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
+1 -1
View File
@@ -18,7 +18,7 @@
<a href="https://pypi.org/project/langgraph/" target="_blank"><img src="https://img.shields.io/pypi/v/langgraph.svg?label=%20" alt="Version"></a>
<a href="https://github.com/langchain-ai/langgraph/issues" target="_blank"><img src="https://img.shields.io/github/issues-raw/langchain-ai/langgraph" alt="Open Issues"></a>
<a href="https://docs.langchain.com/oss/python/langgraph/overview" target="_blank"><img src="https://img.shields.io/badge/docs-latest-blue" alt="Docs"></a>
<a href="https://x.com/langchain" target="_blank"><img src="https://img.shields.io/twitter/url/https/twitter.com/langchain.svg?style=social&label=Follow%20%40LangChain" alt="Twitter / X"></a>
<a href="https://x.com/langchain_oss" target="_blank"><img src="https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain" alt="Twitter / X"></a>
</div>
<br>
@@ -56,6 +56,8 @@ CONFIG_KEY_CHECKPOINT_NS = sys.intern("checkpoint_ns")
# holds the current checkpoint_ns, "" for root graph
CONFIG_KEY_NODE_FINISHED = sys.intern("__pregel_node_finished")
# holds a callback to be called when a node is finished
CONFIG_KEY_TIMED_ATTEMPT_OBSERVER = sys.intern("__pregel_timed_attempt_observer")
# holds a callback to be called when an idle-timed node attempt starts or finishes
CONFIG_KEY_SCRATCHPAD = sys.intern("__pregel_scratchpad")
# holds a mutable dict for temporary storage scoped to the current task
CONFIG_KEY_RUNNER_SUBMIT = sys.intern("__pregel_runner_submit")
@@ -109,6 +111,7 @@ RESERVED = {
CONFIG_KEY_CHECKPOINT_MAP,
CONFIG_KEY_CHECKPOINT_ID,
CONFIG_KEY_CHECKPOINT_NS,
CONFIG_KEY_TIMED_ATTEMPT_OBSERVER,
CONFIG_KEY_RESUME_MAP,
CONFIG_KEY_STREAM_MESSAGES_V2,
# other constants
@@ -117,6 +117,19 @@ def set_config_context(
ctx.run(_unset_config_context, config_token, run)
def create_task_in_config_context(
coro_factory: Callable[[], Coroutine[Any, Any, Any]], config: RunnableConfig
) -> asyncio.Task[Any]:
"""Create an asyncio.Task that inherits `config` as the child runnable context.
`asyncio.create_task` snapshots the current contextvars onto the new task,
so calling `create_task` while the config context is set ensures the task
sees `config` via `var_child_runnable_config` and any tracing parent.
"""
with set_config_context(config) as context:
return context.run(lambda: asyncio.create_task(coro_factory()))
# Before Python 3.11 native StrEnum is not available
class StrEnum(str, enum.Enum):
"""A string enum."""
@@ -0,0 +1,25 @@
from __future__ import annotations
from datetime import timedelta
from typing import Literal
from langgraph.types import TimeoutPolicy
_SYNC_TIMEOUT_PREFIX = (
"Node timeouts are only supported for async nodes because sync Python "
"execution cannot be safely cancelled in-process."
)
def coerce_timeout_policy(
value: float | timedelta | TimeoutPolicy | None,
) -> TimeoutPolicy | None:
"""Normalize a timeout value to positive-second policy fields."""
return TimeoutPolicy.coerce(value)
def sync_timeout_unsupported(
name: str, *, kind: Literal["Node", "Task"] = "Node"
) -> ValueError:
"""Build the canonical error for using `timeout` with a sync target."""
return ValueError(f"{_SYNC_TIMEOUT_PREFIX} {kind} {name!r} is sync.")
+57 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Sequence
from enum import Enum
from typing import Any
from typing import Any, Literal
from warnings import warn
# EmptyChannelError is re-exported from langgraph.channels.base
@@ -20,6 +20,7 @@ __all__ = (
"GraphBubbleUp",
"GraphInterrupt",
"NodeInterrupt",
"NodeTimeoutError",
"ParentCommand",
"EmptyInputError",
"TaskNotFound",
@@ -125,3 +126,58 @@ class TaskNotFound(Exception):
"""Raised when the executor is unable to find a task (for distributed mode)."""
pass
class NodeTimeoutError(TimeoutError):
"""Raised when a node invocation exceeds one of its configured timeouts.
Subclasses the built-in `TimeoutError`, so existing `except TimeoutError`
handlers keep working. If the node has a `retry_policy` whose `retry_on`
permits `TimeoutError`, the attempt will be retried.
Both `idle_timeout` and `run_timeout` reflect the configured policy at the
time of the failure (each is `None` if not configured). `kind` and
`timeout` identify which one fired.
"""
node: str
timeout: float
run_timeout: float | None
idle_timeout: float | None
elapsed: float
kind: Literal["idle", "run"]
def __init__(
self,
node: str,
elapsed: float,
*,
kind: Literal["idle", "run"],
idle_timeout: float | None = None,
run_timeout: float | None = None,
) -> None:
if kind == "idle":
if idle_timeout is None:
raise ValueError("idle_timeout is required when kind='idle'")
message = (
f"Node '{node}' exceeded its idle timeout of "
f"{idle_timeout:.3f}s without making progress "
f"(elapsed: {elapsed:.3f}s)."
)
self.timeout = idle_timeout
elif kind == "run":
if run_timeout is None:
raise ValueError("run_timeout is required when kind='run'")
message = (
f"Node '{node}' exceeded its run timeout of "
f"{run_timeout:.3f}s (elapsed: {elapsed:.3f}s)."
)
self.timeout = run_timeout
else:
raise ValueError("kind must be 'idle' or 'run'")
super().__init__(message)
self.node = node
self.elapsed = elapsed
self.kind = kind
self.idle_timeout = idle_timeout
self.run_timeout = run_timeout
+51 -6
View File
@@ -5,6 +5,7 @@ import inspect
import warnings
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass
from datetime import timedelta
from typing import (
Any,
Generic,
@@ -22,6 +23,11 @@ from typing_extensions import Unpack
from langgraph._internal import _serde
from langgraph._internal._constants import CACHE_NS_WRITES, PREVIOUS
from langgraph._internal._runnable import is_async_callable
from langgraph._internal._timeout import (
coerce_timeout_policy,
sync_timeout_unsupported,
)
from langgraph._internal._typing import MISSING, DeprecatedKwargs
from langgraph.channels.ephemeral_value import EphemeralValue
from langgraph.channels.last_value import LastValue
@@ -31,13 +37,19 @@ from langgraph.pregel._call import (
P,
SyncAsyncFuture,
T,
call,
_call_with_options,
get_runnable_for_entrypoint,
identifier,
)
from langgraph.pregel._read import PregelNode
from langgraph.pregel._write import ChannelWrite, ChannelWriteEntry
from langgraph.types import _DC_KWARGS, CachePolicy, RetryPolicy, StreamMode
from langgraph.types import (
_DC_KWARGS,
CachePolicy,
RetryPolicy,
StreamMode,
TimeoutPolicy,
)
from langgraph.typing import ContextT
from langgraph.warnings import LangGraphDeprecatedSinceV05, LangGraphDeprecatedSinceV10
@@ -51,6 +63,7 @@ class _TaskFunction(Generic[P, T]):
*,
retry_policy: Sequence[RetryPolicy],
cache_policy: CachePolicy[Callable[P, str | bytes]] | None = None,
timeout: TimeoutPolicy | None = None,
name: str | None = None,
) -> None:
if name is not None:
@@ -67,15 +80,17 @@ class _TaskFunction(Generic[P, T]):
self.func = func
self.retry_policy = retry_policy
self.cache_policy = cache_policy
self.timeout = timeout
functools.update_wrapper(self, func)
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> SyncAsyncFuture[T]:
return call(
return _call_with_options(
self.func,
args,
kwargs,
retry_policy=self.retry_policy,
cache_policy=self.cache_policy,
*args,
**kwargs,
timeout=self.timeout,
)
def clear_cache(self, cache: BaseCache) -> None:
@@ -98,6 +113,7 @@ def task(
name: str | None = None,
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy[Callable[P, str | bytes]] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Callable[
[Callable[P, Awaitable[T]] | Callable[P, T]],
@@ -119,6 +135,7 @@ def task(
name: str | None = None,
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy[Callable[P, str | bytes]] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> (
Callable[[Callable[P, Awaitable[T]] | Callable[P, T]], _TaskFunction[P, T]]
@@ -142,6 +159,14 @@ def task(
name: An optional name for the task. If not provided, the function name will be used.
retry_policy: An optional retry policy (or list of policies) to use for the task in case of a failure.
cache_policy: An optional cache policy to use for the task. This allows caching of the task results.
timeout: Timeout for each task attempt. A number or `timedelta` is a hard
wall-clock cap and is not refreshed. Use `TimeoutPolicy` to configure
both a wall-clock `run_timeout` and an `idle_timeout` refreshed by
progress signals. For long-running work that doesn't naturally emit
progress, call `runtime.heartbeat()` from inside the task. When the
timeout fires, `NodeTimeoutError` is raised and the retry policy (if
any) decides whether to retry. Supported only for async tasks; sync
tasks cannot be safely cancelled in-process.
Returns:
A callable function when used as a decorator.
@@ -196,6 +221,7 @@ def task(
)
if retry_policy is None:
retry_policy = retry # type: ignore[assignment]
timeout_policy = coerce_timeout_policy(timeout)
retry_policies: Sequence[RetryPolicy] = (
()
@@ -208,8 +234,15 @@ def task(
def decorator(
func: Callable[P, Awaitable[T]] | Callable[P, T],
) -> Callable[P, SyncAsyncFuture[T]]:
if timeout_policy is not None and not is_async_callable(func):
name_ = name or getattr(func, "__name__", func.__class__.__name__)
raise sync_timeout_unsupported(str(name_), kind="Task")
return _TaskFunction(
func, retry_policy=retry_policies, cache_policy=cache_policy, name=name
func,
retry_policy=retry_policies,
cache_policy=cache_policy,
timeout=timeout_policy,
name=name,
)
if __func_or_none__ is not None:
@@ -268,6 +301,15 @@ class entrypoint(Generic[ContextT]):
passed to the workflow.
cache_policy: A cache policy to use for caching the results of the workflow.
retry_policy: A retry policy (or list of policies) to use for the workflow in case of a failure.
timeout: Timeout for each workflow attempt. A number or `timedelta` is a
hard wall-clock cap and is not refreshed. Use `TimeoutPolicy` to
configure both a wall-clock `run_timeout` and an `idle_timeout`
refreshed by progress signals. For long-running work that doesn't
naturally emit progress, call `runtime.heartbeat()` from inside the
workflow. When the timeout fires, `NodeTimeoutError` is raised and
the retry policy (if any) decides whether to retry. Supported only
for async workflows; sync workflows cannot be safely cancelled
in-process.
!!! warning "`config_schema` Deprecated"
The `config_schema` parameter is deprecated in v0.6.0 and support will be removed in v2.0.0.
@@ -400,6 +442,7 @@ class entrypoint(Generic[ContextT]):
context_schema: type[ContextT] | None = None,
cache_policy: CachePolicy | None = None,
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> None:
"""Initialize the entrypoint decorator."""
@@ -426,6 +469,7 @@ class entrypoint(Generic[ContextT]):
self.cache = cache
self.cache_policy = cache_policy
self.retry_policy = retry_policy
self.timeout = coerce_timeout_policy(timeout)
self.context_schema = context_schema
@dataclass(**_DC_KWARGS)
@@ -535,6 +579,7 @@ class entrypoint(Generic[ContextT]):
bound=bound,
triggers=[START],
channels=START,
timeout=self.timeout,
writers=[
ChannelWrite(
[
+2 -1
View File
@@ -9,7 +9,7 @@ from langgraph.store.base import BaseStore
from langgraph._internal._typing import EMPTY_SEQ
from langgraph.runtime import Runtime
from langgraph.types import CachePolicy, RetryPolicy, StreamWriter
from langgraph.types import CachePolicy, RetryPolicy, StreamWriter, TimeoutPolicy
from langgraph.typing import ContextT, NodeInputT, NodeInputT_contra
@@ -90,3 +90,4 @@ class StateNodeSpec(Generic[NodeInputT, ContextT]):
cache_policy: CachePolicy | None
ends: tuple[str, ...] | dict[str, str] | None = EMPTY_SEQ
defer: bool = False
timeout: TimeoutPolicy | None = None
+21
View File
@@ -7,6 +7,7 @@ import warnings
from collections import defaultdict
from collections.abc import Awaitable, Callable, Hashable, Sequence
from dataclasses import is_dataclass
from datetime import timedelta
from functools import partial
from inspect import isclass, isfunction, ismethod, signature
from types import FunctionType
@@ -45,6 +46,7 @@ from langgraph._internal._fields import (
)
from langgraph._internal._pydantic import create_model
from langgraph._internal._runnable import coerce_to_runnable
from langgraph._internal._timeout import coerce_timeout_policy
from langgraph._internal._typing import EMPTY_SEQ, MISSING, DeprecatedKwargs
from langgraph.channels.base import BaseChannel
from langgraph.channels.binop import BinaryOperatorAggregate
@@ -82,6 +84,7 @@ from langgraph.types import (
Command,
RetryPolicy,
Send,
TimeoutPolicy,
ensure_valid_checkpointer,
)
from langgraph.typing import ContextT, InputT, NodeInputT, OutputT, StateT
@@ -301,6 +304,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
destinations: dict[str, str] | tuple[str, ...] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Self:
"""Add a new node to the `StateGraph`, input schema is inferred as the state schema.
@@ -368,6 +372,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
destinations: dict[str, str] | tuple[str, ...] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Self:
"""Add a new node to the `StateGraph` where input schema is specified.
@@ -440,6 +445,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
destinations: dict[str, str] | tuple[str, ...] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Self:
"""Add a new node to the `StateGraph`, input schema is inferred as the state schema.
@@ -507,6 +513,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
destinations: dict[str, str] | tuple[str, ...] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Self:
"""Add a new node to the `StateGraph`, input schema is specified.
@@ -581,6 +588,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
destinations: dict[str, str] | tuple[str, ...] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
**kwargs: Unpack[DeprecatedKwargs],
) -> Self:
"""Add a new node to the `StateGraph`.
@@ -610,6 +618,14 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
!!! warning
This is only used for graph rendering and doesn't have any effect on the graph execution.
timeout: Timeout for each node attempt. A number or `timedelta` is
a hard wall-clock cap and is not refreshed. Use `TimeoutPolicy`
to configure both a wall-clock `run_timeout` and an
`idle_timeout` refreshed by progress signals. When exceeded, a
[`NodeTimeoutError`][langgraph.errors.NodeTimeoutError] is raised
and the retry policy (if any) decides whether to retry. Timeouts
are supported only for async nodes; sync nodes cannot be safely
cancelled in-process.
Example:
```python
@@ -663,6 +679,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
)
if input_schema is None:
input_schema = cast(type[NodeInputT] | None, input_)
timeout = coerce_timeout_policy(timeout)
if not isinstance(node, str):
action = node
@@ -758,6 +775,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
cache_policy=cache_policy,
ends=ends,
defer=defer,
timeout=timeout,
)
elif inferred_input_schema is not None:
self.nodes[node] = StateNodeSpec(
@@ -768,6 +786,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
cache_policy=cache_policy,
ends=ends,
defer=defer,
timeout=timeout,
)
else:
self.nodes[node] = StateNodeSpec[StateT, ContextT](
@@ -778,6 +797,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
cache_policy=cache_policy,
ends=ends,
defer=defer,
timeout=timeout,
)
input_schema = input_schema or inferred_input_schema
@@ -1343,6 +1363,7 @@ class CompiledStateGraph(
retry_policy=node.retry_policy,
cache_policy=node.cache_policy,
bound=node.runnable, # type: ignore[arg-type]
timeout=node.timeout,
)
else:
raise RuntimeError
+16 -2
View File
@@ -80,6 +80,7 @@ from langgraph.types import (
PregelTask,
RetryPolicy,
Send,
TimeoutPolicy,
)
GetNextVersion = Callable[[V | None, None], V]
@@ -114,13 +115,21 @@ class PregelTaskWrites(NamedTuple):
class Call:
__slots__ = ("func", "input", "retry_policy", "cache_policy", "callbacks")
__slots__ = (
"func",
"input",
"retry_policy",
"cache_policy",
"callbacks",
"timeout",
)
func: Callable
input: tuple[tuple[Any, ...], dict[str, Any]]
retry_policy: Sequence[RetryPolicy] | None
cache_policy: CachePolicy | None
callbacks: Callbacks
timeout: TimeoutPolicy | None
def __init__(
self,
@@ -130,12 +139,14 @@ class Call:
retry_policy: Sequence[RetryPolicy] | None,
cache_policy: CachePolicy | None,
callbacks: Callbacks,
timeout: TimeoutPolicy | None = None,
) -> None:
self.func = func
self.input = input
self.retry_policy = retry_policy
self.cache_policy = cache_policy
self.callbacks = callbacks
self.timeout = timeout
def should_interrupt(
@@ -733,6 +744,7 @@ def prepare_single_task(
task_path[:3],
writers=proc.flat_writers,
subgraphs=proc.subgraphs,
timeout=proc.timeout,
)
else:
return PregelTask(task_id, name, task_path[:3])
@@ -870,6 +882,7 @@ def prepare_push_task_functional(
cache_key,
task_id,
in_progress_task_path,
timeout=call.timeout,
)
else:
return PregelTask(task_id, name, in_progress_task_path)
@@ -1041,6 +1054,7 @@ def prepare_push_task_send(
translated_task_path,
writers=proc.flat_writers,
subgraphs=proc.subgraphs,
timeout=packet.timeout if packet.timeout is not None else proc.timeout,
)
else:
return PregelTask(task_id, packet.node, translated_task_path)
@@ -1255,4 +1269,4 @@ def sanitize_untracked_values_in_send(
for k, v in packet.arg.items()
if not isinstance(channels.get(k), UntrackedValue)
}
return Send(node=packet.node, arg=sanitized_arg)
return Send(node=packet.node, arg=sanitized_arg, timeout=packet.timeout)
+30 -1
View File
@@ -8,6 +8,7 @@ import inspect
import sys
import types
from collections.abc import Awaitable, Callable, Generator, Sequence
from datetime import timedelta
from typing import Any, Generic, TypeVar, cast
from langchain_core.runnables import Runnable
@@ -20,9 +21,13 @@ from langgraph._internal._runnable import (
is_async_callable,
run_in_executor,
)
from langgraph._internal._timeout import (
coerce_timeout_policy,
sync_timeout_unsupported,
)
from langgraph.config import get_config
from langgraph.pregel._write import ChannelWrite, ChannelWriteEntry
from langgraph.types import CachePolicy, RetryPolicy
from langgraph.types import CachePolicy, RetryPolicy, TimeoutPolicy
##
# Utilities borrowed from cloudpickle.
@@ -255,8 +260,31 @@ def call(
*args: Any,
retry_policy: Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
**kwargs: Any,
) -> SyncAsyncFuture[T]:
return _call_with_options(
func,
args,
kwargs,
retry_policy=retry_policy,
cache_policy=cache_policy,
timeout=coerce_timeout_policy(timeout),
)
def _call_with_options(
func: Callable[P, Awaitable[T]] | Callable[P, T],
args: tuple[Any, ...],
kwargs: dict[str, Any],
*,
retry_policy: Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
timeout: TimeoutPolicy | None = None,
) -> SyncAsyncFuture[T]:
if timeout is not None and not is_async_callable(func):
name = getattr(func, "__name__", func.__class__.__name__)
raise sync_timeout_unsupported(name, kind="Task")
config = get_config()
impl = config[CONF][CONFIG_KEY_CALL]
fut = impl(
@@ -265,5 +293,6 @@ def call(
retry_policy=retry_policy,
cache_policy=cache_policy,
callbacks=config["callbacks"],
timeout=timeout,
)
return fut
+12 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence
from datetime import timedelta
from functools import cached_property
from typing import (
Any,
@@ -11,10 +12,11 @@ from langchain_core.runnables import Runnable, RunnableConfig
from langgraph._internal._config import merge_configs
from langgraph._internal._constants import CONF, CONFIG_KEY_READ
from langgraph._internal._runnable import RunnableCallable, RunnableSeq
from langgraph._internal._timeout import coerce_timeout_policy
from langgraph.pregel._utils import find_subgraph_pregel
from langgraph.pregel._write import ChannelWrite
from langgraph.pregel.protocol import PregelProtocol
from langgraph.types import CachePolicy, RetryPolicy
from langgraph.types import CachePolicy, RetryPolicy, TimeoutPolicy
READ_TYPE = Callable[[str | Sequence[str], bool], Any | dict[str, Any]]
INPUT_CACHE_KEY_TYPE = tuple[Callable[..., Any], tuple[str, ...]]
@@ -123,6 +125,13 @@ class PregelNode:
cache_policy: CachePolicy | None
"""The cache policy to use when invoking the node."""
timeout: TimeoutPolicy | None
"""Timeout policy for a single invocation.
If exceeded, `NodeTimeoutError` is raised and the retry policy (if any)
decides whether to retry. Supported only for async nodes.
"""
tags: Sequence[str] | None
"""Tags to attach to the node for tracing."""
@@ -145,6 +154,7 @@ class PregelNode:
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
subgraphs: Sequence[PregelProtocol] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
) -> None:
self.channels = channels
self.triggers = list(triggers)
@@ -156,6 +166,7 @@ class PregelNode:
self.retry_policy = (retry_policy,)
else:
self.retry_policy = retry_policy
self.timeout = coerce_timeout_policy(timeout)
self.tags = tags
self.metadata = metadata
if subgraphs is not None:
+495 -15
View File
@@ -4,32 +4,487 @@ import asyncio
import logging
import random
import sys
import threading
import time
import weakref
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import replace
from typing import Any
from contextlib import suppress
from dataclasses import dataclass, replace
from datetime import datetime, timedelta, timezone
from typing import Any, Literal, NamedTuple
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.runnables import RunnableConfig
from langgraph._internal._config import patch_configurable, recast_checkpoint_ns
from langgraph._internal._config import (
merge_configs,
patch_configurable,
recast_checkpoint_ns,
)
from langgraph._internal._constants import (
CONF,
CONFIG_KEY_CALL,
CONFIG_KEY_CHECKPOINT_ID,
CONFIG_KEY_CHECKPOINT_NS,
CONFIG_KEY_RESUMING,
CONFIG_KEY_RUNTIME,
CONFIG_KEY_SEND,
CONFIG_KEY_STREAM,
CONFIG_KEY_TASK_ID,
CONFIG_KEY_THREAD_ID,
CONFIG_KEY_TIMED_ATTEMPT_OBSERVER,
NS_SEP,
)
from langgraph.errors import GraphBubbleUp, ParentCommand
from langgraph._internal._runnable import create_task_in_config_context
from langgraph._internal._timeout import sync_timeout_unsupported
from langgraph.errors import GraphBubbleUp, NodeTimeoutError, ParentCommand
from langgraph.pregel.protocol import StreamProtocol
from langgraph.runtime import ExecutionInfo, Runtime
from langgraph.types import Command, PregelExecutableTask, RetryPolicy
from langgraph.types import Command, PregelExecutableTask, RetryPolicy, TimeoutPolicy
logger = logging.getLogger(__name__)
SUPPORTS_EXC_NOTES = sys.version_info >= (3, 11)
def _timeout_secs(value: float | timedelta) -> float:
return value.total_seconds() if isinstance(value, timedelta) else value
@dataclass(frozen=True, slots=True)
class _ResolvedTimeout:
run_timeout_secs: float | None
idle_timeout_secs: float | None
refresh_on: Literal["auto", "heartbeat"] | None
def _resolve_timeout(timeout: TimeoutPolicy) -> _ResolvedTimeout:
idle_timeout_secs = (
_timeout_secs(timeout.idle_timeout)
if timeout.idle_timeout is not None
else None
)
return _ResolvedTimeout(
run_timeout_secs=(
_timeout_secs(timeout.run_timeout)
if timeout.run_timeout is not None
else None
),
idle_timeout_secs=idle_timeout_secs,
refresh_on=timeout.refresh_on if idle_timeout_secs is not None else None,
)
class _AttemptContext(NamedTuple):
"""Immutable per-attempt metadata shared across start/progress/finish events.
Built once at attempt start and referenced (not copied) by every emitted
`_AttemptEvent`, so per-event allocation is just the small event wrapper.
Intentionally underscore-prefixed: this and `_AttemptEvent` are part of an
internal observer contract consumed by langgraph-server. Do not move to
`langgraph.types` — server imports them by this path.
"""
task_id: str
task_name: str
attempt: int
run_id: str | None
thread_id: str | None
checkpoint_ns: str | None
started_at: datetime
run_timeout_secs: float | None
idle_timeout_secs: float | None
refresh_on: Literal["auto", "heartbeat"] | None
@dataclass(frozen=True, slots=True)
class _AttemptEvent:
"""One lifecycle event for a timed attempt.
Holds a reference to the shared `_AttemptContext` and the event-specific
fields. The observer must treat this and `context` as read-only — they
are reused across all events for the same attempt.
"""
context: _AttemptContext
event: Literal["start", "progress", "finish"]
progress_at: datetime | None = None
finished_at: datetime | None = None
status: Literal["success", "error"] | None = None
error_type: str | None = None
error_message: str | None = None
class _TimedAttemptScope:
"""Guarded-config window for timed attempts.
The wrapped config marks writes, stream events, runtime stream writer calls,
child task scheduling, and any LangChain callback event emitted under the
node's run as observable progress when `refresh_on="auto"`.
`runtime.heartbeat()` exposes a manual progress signal for work that doesn't
otherwise emit any of these, and is the only progress signal when
`refresh_on="heartbeat"`.
Guarded writes are serialized with `close()` so cancelled background tasks
cannot persist writes past the timeout boundary. Stream/custom output is
best-effort: it is dropped after close is observed, but callbacks run outside
the lock because they may contain arbitrary user/runtime code.
"""
__slots__ = (
"__weakref__",
"_active",
"_last_progress",
"_last_progress_emit",
"_lock",
"_on_progress",
"_progress_min_interval",
"_refresh_on",
)
def __init__(
self,
on_progress: Callable[[], None] | None = None,
progress_min_interval: float = 0.0,
refresh_on: Literal["auto", "heartbeat"] | None = None,
) -> None:
self._active = True
self._last_progress = time.monotonic()
self._lock = threading.Lock()
self._on_progress = on_progress
self._progress_min_interval = progress_min_interval
self._refresh_on = refresh_on
# `-inf` so the first touch always passes the rate-limit gate.
self._last_progress_emit: float = float("-inf")
def wrap_config(self, config: RunnableConfig) -> RunnableConfig:
configurable = config.get(CONF, {})
patch: dict[str, Any] = {}
if (send := configurable.get(CONFIG_KEY_SEND)) is not None:
patch[CONFIG_KEY_SEND] = self._guard_send(send)
if (stream := configurable.get(CONFIG_KEY_STREAM)) is not None:
patch[CONFIG_KEY_STREAM] = self._guard_stream(stream)
if (call := configurable.get(CONFIG_KEY_CALL)) is not None:
patch[CONFIG_KEY_CALL] = self._guard_call(call)
if isinstance(runtime := configurable.get(CONFIG_KEY_RUNTIME), Runtime):
if self._refresh_on is not None:
patch[CONFIG_KEY_RUNTIME] = runtime.override(
stream_writer=self._guard_stream_writer(runtime.stream_writer),
heartbeat=self.touch,
)
else:
patch[CONFIG_KEY_RUNTIME] = runtime.override(
stream_writer=self._guard_stream_writer(runtime.stream_writer)
)
new_config = patch_configurable(config, patch) if patch else config
if self._refresh_on == "auto":
return merge_configs(
new_config, {"callbacks": [_IdleProgressCallbackHandler(self)]}
)
return new_config
def touch(self) -> None:
# Avoid locking this hot progress path. We accept a small race window in
# timestamp ordering because idle_timeout is expected to be coarse compared
# with scheduler/thread timing.
now = time.monotonic()
self._last_progress = now
if self._on_progress is None:
return
# Best-effort rate limit: a benign race may emit a duplicate progress
# event under heavy concurrency, which observers must already tolerate
# (callbacks fire from arbitrary threads).
if now - self._last_progress_emit < self._progress_min_interval:
return
self._last_progress_emit = now
self._on_progress()
def close(self) -> None:
with self._lock:
self._active = False
async def wait_for_idle_timeout(self, idle_timeout_s: float) -> None:
while True:
with self._lock:
if not self._active:
return
remaining = self._last_progress + idle_timeout_s - time.monotonic()
if remaining <= 0:
raise asyncio.TimeoutError
await asyncio.sleep(remaining)
def _guard_send(
self, send: Callable[[Sequence[tuple[str, Any]]], None]
) -> Callable[[Sequence[tuple[str, Any]]], None]:
def guarded_send(writes: Sequence[tuple[str, Any]]) -> None:
with self._lock:
if self._active:
if writes and self._refresh_on == "auto":
self._last_progress = time.monotonic()
send(writes)
return guarded_send
def _guard_stream(self, stream: StreamProtocol) -> StreamProtocol:
# No lock: stream callbacks fire from the event loop only, so the
# active-check + write happen atomically between awaits.
def guarded_stream(chunk: tuple[tuple[str, ...], str, Any]) -> None:
if not self._active:
return
if self._refresh_on == "auto":
self._last_progress = time.monotonic()
stream(chunk)
return StreamProtocol(guarded_stream, stream.modes)
def _guard_call(self, call: Callable[..., Any]) -> Callable[..., Any]:
# No lock: child-task scheduling happens from the event loop only.
def guarded_call(*args: Any, **kwargs: Any) -> Any:
if not self._active:
raise asyncio.CancelledError
if self._refresh_on == "auto":
self._last_progress = time.monotonic()
return call(*args, **kwargs)
return guarded_call
def _guard_stream_writer(
self, stream_writer: Callable[[Any], None]
) -> Callable[[Any], None]:
def guarded_stream_writer(chunk: Any) -> None:
with self._lock:
if not self._active:
return
if self._refresh_on == "auto":
self._last_progress = time.monotonic()
stream_writer(chunk)
return guarded_stream_writer
class _IdleProgressCallbackHandler(BaseCallbackHandler):
"""Resets the idle timeout clock on any LangChain callback event.
Inherits via `config["callbacks"]`, so it sees only events emitted by
runs descended from the node's attempt — sibling nodes do not bleed
through. Holds the scope by weakref so a child manager that outlives
the attempt cannot keep the scope alive.
"""
# Run inline so progress is recorded in callback emission order;
# thread-pool dispatch would introduce extra reordering.
run_inline = True
def __init__(self, scope: _TimedAttemptScope) -> None:
self._scope_ref = weakref.ref(scope)
def _touch(self, *args: Any, **kwargs: Any) -> None:
if (scope := self._scope_ref()) is not None:
scope.touch()
on_llm_start = _touch
on_chat_model_start = _touch
on_llm_new_token = _touch
on_llm_end = _touch
on_llm_error = _touch
on_chain_start = _touch
on_chain_end = _touch
on_chain_error = _touch
on_tool_start = _touch
on_tool_end = _touch
on_tool_error = _touch
on_retriever_start = _touch
on_retriever_end = _touch
on_retriever_error = _touch
on_agent_action = _touch
on_agent_finish = _touch
on_text = _touch
on_retry = _touch
on_custom_event = _touch
def _drain_cancelled(task: asyncio.Task[Any]) -> None:
# Mark the abandoned task's exception as retrieved so asyncio doesn't log it.
with suppress(asyncio.CancelledError):
task.exception()
def _start_timed_attempt(
task: PregelExecutableTask, config: RunnableConfig, timeout: _ResolvedTimeout
) -> _AttemptContext | None:
configurable = config.get(CONF, {})
callback = configurable.get(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER)
if callback is None:
return None
runtime = configurable.get(CONFIG_KEY_RUNTIME)
execution_info = runtime.execution_info if isinstance(runtime, Runtime) else None
context = _AttemptContext(
task_id=task.id,
task_name=task.name,
attempt=execution_info.node_attempt if execution_info is not None else 1,
run_id=execution_info.run_id if execution_info is not None else None,
thread_id=execution_info.thread_id if execution_info is not None else None,
checkpoint_ns=(
execution_info.checkpoint_ns if execution_info is not None else None
),
started_at=datetime.now(timezone.utc),
run_timeout_secs=timeout.run_timeout_secs,
idle_timeout_secs=timeout.idle_timeout_secs,
refresh_on=timeout.refresh_on,
)
_dispatch_observer(callback, _AttemptEvent(context=context, event="start"))
return context
def _finish_timed_attempt(
config: RunnableConfig,
context: _AttemptContext | None,
error: BaseException | None = None,
) -> None:
if context is None:
return
callback = config.get(CONF, {}).get(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER)
if callback is None:
return
_dispatch_observer(
callback,
_AttemptEvent(
context=context,
event="finish",
finished_at=datetime.now(timezone.utc),
status="error" if error is not None else "success",
error_type=type(error).__name__ if error is not None else None,
error_message=str(error) if error is not None else None,
),
)
def _emit_progress(
callback: Callable[[_AttemptEvent], None],
context: _AttemptContext,
) -> None:
_dispatch_observer(
callback,
_AttemptEvent(
context=context,
event="progress",
progress_at=datetime.now(timezone.utc),
),
)
def _dispatch_observer(
callback: Callable[[_AttemptEvent], None],
event: _AttemptEvent,
) -> None:
try:
callback(event)
except Exception:
logger.warning("Timed attempt observer failed", exc_info=True)
async def _run_timeout_watchdog(run_timeout_s: float) -> None:
await asyncio.sleep(run_timeout_s)
raise asyncio.TimeoutError
async def _arun_with_timeout(
task: PregelExecutableTask,
config: RunnableConfig,
timeout: _ResolvedTimeout,
attempt_ctx: _AttemptContext | None,
*,
stream: bool,
) -> Any:
run_timeout_s = timeout.run_timeout_secs
idle_timeout_s = timeout.idle_timeout_secs
on_progress: Callable[[], None] | None = None
if attempt_ctx is not None:
callback = config.get(CONF, {}).get(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER)
if callback is not None and idle_timeout_s is not None:
on_progress = lambda: _emit_progress(callback, attempt_ctx) # noqa: E731
scope = _TimedAttemptScope(
on_progress=on_progress,
# Cap progress emission at ~4 events per idle window so token-rate
# callbacks don't flood the observer.
progress_min_interval=idle_timeout_s / 4 if idle_timeout_s is not None else 0.0,
refresh_on=timeout.refresh_on,
)
scoped_config = scope.wrap_config(config)
start = time.monotonic()
if stream:
# Yielded chunks count as progress only under `refresh_on="auto"`.
# `refresh_on="heartbeat"` is the strict mode where only explicit
# `runtime.heartbeat()` calls reset the idle clock.
async def run() -> Any:
async for _ in task.proc.astream(task.input, scoped_config):
if timeout.refresh_on == "auto":
scope.touch()
else:
async def run() -> Any:
return await task.proc.ainvoke(task.input, scoped_config)
bg = create_task_in_config_context(run, scoped_config)
watchdogs: dict[asyncio.Task[None], Literal["idle", "run"]] = {}
if idle_timeout_s is not None:
watchdogs[asyncio.create_task(scope.wait_for_idle_timeout(idle_timeout_s))] = (
"idle"
)
if run_timeout_s is not None:
watchdogs[asyncio.create_task(_run_timeout_watchdog(run_timeout_s))] = "run"
try:
done, _ = await asyncio.wait(
{bg, *watchdogs}, return_when=asyncio.FIRST_COMPLETED
)
if bg in done:
# Task completed in time.
for watchdog in watchdogs:
watchdog.cancel()
# FIRST_COMPLETED can return both; a watchdog may have
# already raised TimeoutError before we cancelled it.
for watchdog in watchdogs:
with suppress(asyncio.CancelledError, asyncio.TimeoutError):
await watchdog
return await bg
# bg was not in `done`, so every member of `done` is one of our
# watchdogs. Only a watchdog's TimeoutError converts to
# NodeTimeoutError; any TimeoutError raised by the proc itself
# propagates unchanged.
for watchdog in done:
kind = watchdogs[watchdog]
try:
await watchdog
except asyncio.TimeoutError as exc:
elapsed = time.monotonic() - start
scope.close()
task.writes.clear()
bg.cancel()
bg.add_done_callback(_drain_cancelled)
raise NodeTimeoutError(
task.name,
elapsed,
kind=kind,
idle_timeout=idle_timeout_s,
run_timeout=run_timeout_s,
) from exc
raise RuntimeError(
f"{kind} timeout watchdog completed without raising TimeoutError"
)
raise RuntimeError("timeout wait completed without task or watchdog")
except asyncio.CancelledError:
scope.close()
bg.cancel()
for watchdog in watchdogs:
watchdog.cancel()
bg.add_done_callback(_drain_cancelled)
raise
finally:
scope.close()
for watchdog in watchdogs:
watchdog.cancel()
def _ensure_execution_info(
runtime: Runtime, config: RunnableConfig, task: PregelExecutableTask
) -> Runtime:
@@ -90,6 +545,10 @@ def run_with_retry(
) -> None:
"""Run a task with retries."""
retry_policy = task.retry_policy or retry_policy
if task.timeout is not None:
# `validate_timeout_supported` catches sync nodes at compile time;
# this is a runtime safety net for paths that may bypass that validation.
raise sync_timeout_unsupported(task.name)
attempts = 0
node_first_attempt_time = time.time()
config = task.config
@@ -195,6 +654,9 @@ async def arun_with_retry(
) -> None:
"""Run a task asynchronously with retries."""
retry_policy = task.retry_policy or retry_policy
resolved_timeout = (
_resolve_timeout(task.timeout) if task.timeout is not None else None
)
attempts = 0
node_first_attempt_time = time.time()
config = task.config
@@ -229,35 +691,53 @@ async def arun_with_retry(
)
},
)
attempt_ctx = (
_start_timed_attempt(task, config, resolved_timeout)
if resolved_timeout is not None
else None
)
try:
# clear any writes from previous attempts
task.writes.clear()
# run the task
if resolved_timeout is None:
if stream:
async for _ in task.proc.astream(task.input, config):
pass
break
return await task.proc.ainvoke(task.input, config)
result = await _arun_with_timeout(
task, config, resolved_timeout, attempt_ctx, stream=stream
)
_finish_timed_attempt(config, attempt_ctx)
if stream:
async for _ in task.proc.astream(task.input, config):
pass
# if successful, end
break
else:
return await task.proc.ainvoke(task.input, config)
return result
except ParentCommand as exc:
ns: str = config[CONF][CONFIG_KEY_CHECKPOINT_NS]
cmd = exc.args[0]
# strip task_ids from namespace for comparison (ns format: "node1|node2:task_id")
if cmd.graph in (ns, recast_checkpoint_ns(ns), task.name):
# this command is for the current graph, handle it
for w in task.writers:
w.invoke(cmd, config)
try:
# this command is for the current graph, handle it
for w in task.writers:
w.invoke(cmd, config)
except Exception as writer_exc:
_finish_timed_attempt(config, attempt_ctx, writer_exc)
raise
_finish_timed_attempt(config, attempt_ctx)
break
elif cmd.graph == Command.PARENT:
# this command is for the parent graph, assign it to the parent.
exc.args = (replace(cmd, graph=_checkpoint_ns_for_parent_command(ns)),)
# bubble up
_finish_timed_attempt(config, attempt_ctx)
# bubble up the exception to the parent graph
raise
except GraphBubbleUp:
# if interrupted, end
_finish_timed_attempt(config, attempt_ctx)
raise
except Exception as exc:
_finish_timed_attempt(config, attempt_ctx, exc)
if SUPPORTS_EXC_NOTES:
exc.add_note(f"During task with name '{task.name}' and id '{task.id}'")
if not retry_policy:
@@ -46,6 +46,7 @@ from langgraph.types import (
CachePolicy,
PregelExecutableTask,
RetryPolicy,
TimeoutPolicy,
)
F = TypeVar("F", concurrent.futures.Future, asyncio.Future)
@@ -537,6 +538,7 @@ def _call(
*,
retry_policy: Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
timeout: TimeoutPolicy | None = None,
callbacks: Callbacks = None,
futures: weakref.ref[FuturesDict],
schedule_task: Callable[
@@ -560,6 +562,7 @@ def _call(
retry_policy=retry_policy,
cache_policy=cache_policy,
callbacks=callbacks,
timeout=timeout,
),
):
if fut := next(
@@ -624,6 +627,7 @@ def _acall(
*,
retry_policy: Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
timeout: TimeoutPolicy | None = None,
callbacks: Callbacks = None,
# injected dependencies
futures: weakref.ref[FuturesDict],
@@ -657,6 +661,7 @@ def _acall(
input,
retry_policy=retry_policy,
cache_policy=cache_policy,
timeout=timeout,
callbacks=callbacks,
futures=futures,
schedule_task=schedule_task,
@@ -678,6 +683,7 @@ async def _acall_impl(
*,
retry_policy: Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
timeout: TimeoutPolicy | None = None,
callbacks: Callbacks = None,
# injected dependencies
futures: weakref.ref[FuturesDict[asyncio.Future, asyncio.Event]],
@@ -703,6 +709,7 @@ async def _acall_impl(
retry_policy=retry_policy,
cache_policy=cache_policy,
callbacks=callbacks,
timeout=timeout,
),
):
if fut := next(
+75 -2
View File
@@ -4,16 +4,27 @@ import ast
import inspect
import re
import textwrap
from collections.abc import Callable
from collections.abc import Callable, Sequence
from functools import partial
from typing import Any
from langchain_core.runnables import Runnable, RunnableLambda, RunnableSequence
from langchain_core.runnables import (
Runnable,
RunnableLambda,
RunnableParallel,
RunnableSequence,
)
from langchain_core.runnables.base import RunnableBindingBase
from langchain_core.runnables.config import run_in_executor
from langgraph.checkpoint.base import ChannelVersions
from typing_extensions import override
from langgraph._internal._runnable import RunnableCallable, RunnableSeq
from langgraph._internal._timeout import sync_timeout_unsupported
from langgraph.pregel.protocol import PregelProtocol
_SEQUENCE_TYPES = (RunnableSeq, RunnableSequence)
def get_new_channel_versions(
previous_versions: ChannelVersions, current_versions: ChannelVersions
@@ -64,6 +75,68 @@ def find_subgraph_pregel(candidate: Runnable) -> PregelProtocol | None:
return None
def _sequence_steps(runnable: Runnable) -> Sequence[Runnable] | None:
if isinstance(runnable, _SEQUENCE_TYPES):
return runnable.steps
return None
def _parallel_steps(runnable: Runnable) -> Sequence[Runnable] | None:
if isinstance(runnable, RunnableParallel):
return tuple(runnable.steps__.values())
return None
def _has_method_override(runnable: Runnable, method_name: str) -> bool:
method = getattr(type(runnable), method_name, None)
return method is not None and method is not getattr(Runnable, method_name)
def _is_executor_backed_afunc(afunc: Callable[..., Any] | None) -> bool:
return isinstance(afunc, partial) and afunc.func is run_in_executor
def _has_native_async(runnable: Runnable) -> bool:
if isinstance(runnable, RunnableCallable):
return runnable.afunc is not None and not _is_executor_backed_afunc(
runnable.afunc
)
if isinstance(runnable, RunnableLambda):
return bool(getattr(runnable, "afunc", False))
return _has_method_override(runnable, "ainvoke")
def _runnable_has_native_async(runnable: Runnable) -> bool:
"""Return whether a runnable can be idle-timed without known sync code.
For custom runnable subclasses, an `ainvoke` override is treated as the
async contract. We do not introspect whether that implementation delegates
to blocking work internally — e.g. a subclass whose `ainvoke` calls
`asyncio.to_thread(self.invoke, ...)` will pass this check but the wrapped
sync work is still uncancellable. Idle-timeout enforcement on such a
runnable will fire `NodeTimeoutError` correctly, but the background thread
will keep running until its sync work returns.
"""
while isinstance(runnable, RunnableBindingBase):
runnable = runnable.bound
steps = _sequence_steps(runnable)
if steps is None:
steps = _parallel_steps(runnable)
if steps is not None:
return all(_runnable_has_native_async(step) for step in steps)
# Raw callables and the common composition wrappers created by graph
# builders fall through here. We do not exhaustively unwrap every Runnable
# wrapper — wrappers that provide `ainvoke` are treated as owning the async
# contract.
return _has_native_async(runnable)
def validate_timeout_supported(runnable: Runnable, *, name: str) -> None:
if not _runnable_has_native_async(runnable):
raise sync_timeout_unsupported(name)
def get_function_nonlocals(func: Callable) -> list[Any]:
"""Get the nonlocal variables accessed by a function.
+19 -1
View File
@@ -17,6 +17,7 @@ from collections.abc import (
Sequence,
)
from dataclasses import is_dataclass, replace
from datetime import timedelta
from functools import partial
from inspect import isclass
from typing import (
@@ -96,6 +97,7 @@ from langgraph._internal._runnable import (
RunnableSeq,
coerce_to_runnable,
)
from langgraph._internal._timeout import coerce_timeout_policy
from langgraph._internal._typing import MISSING, DeprecatedKwargs
from langgraph.callbacks import (
GraphInterruptEvent,
@@ -143,7 +145,10 @@ from langgraph.pregel._read import DEFAULT_BOUND, PregelNode
from langgraph.pregel._retry import RetryPolicy
from langgraph.pregel._runner import PregelRunner
from langgraph.pregel._tools import StreamToolCallHandler
from langgraph.pregel._utils import get_new_channel_versions
from langgraph.pregel._utils import (
get_new_channel_versions,
validate_timeout_supported,
)
from langgraph.pregel._validate import validate_graph, validate_keys
from langgraph.pregel._write import ChannelWrite, ChannelWriteEntry
from langgraph.pregel.debug import get_bolded_text, get_colored_text, tasks_w_writes
@@ -176,6 +181,7 @@ from langgraph.types import (
StateUpdate,
StreamMode,
StreamPart,
TimeoutPolicy,
ensure_valid_checkpointer,
)
from langgraph.typing import ContextT, InputT, OutputT, StateT
@@ -201,6 +207,7 @@ class NodeBuilder:
"_bound",
"_retry_policy",
"_cache_policy",
"_timeout",
)
_channels: str | list[str]
@@ -211,6 +218,7 @@ class NodeBuilder:
_bound: Runnable
_retry_policy: list[RetryPolicy]
_cache_policy: CachePolicy | None
_timeout: TimeoutPolicy | None
def __init__(
self,
@@ -223,6 +231,7 @@ class NodeBuilder:
self._bound = DEFAULT_BOUND
self._retry_policy = []
self._cache_policy = None
self._timeout = None
def subscribe_only(
self,
@@ -341,6 +350,11 @@ class NodeBuilder:
self._cache_policy = policy
return self
def set_timeout(self, timeout: float | timedelta | TimeoutPolicy | None) -> Self:
"""Set the per-attempt timeout policy for this node."""
self._timeout = coerce_timeout_policy(timeout)
return self
def build(self) -> PregelNode:
"""Builds the node."""
return PregelNode(
@@ -352,6 +366,7 @@ class NodeBuilder:
bound=self._bound,
retry_policy=self._retry_policy,
cache_policy=self._cache_policy,
timeout=self._timeout,
)
@@ -888,6 +903,9 @@ class Pregel(
)
def validate(self) -> Self:
for name, node in self.nodes.items():
if node.timeout is not None:
validate_timeout_supported(node.node or node.bound, name=name)
validate_graph(
self.nodes,
{k: v for k, v in self.channels.items() if isinstance(v, BaseChannel)},
+19
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field, replace
from typing import Any, Generic, cast
@@ -77,10 +78,14 @@ class ServerInfo:
def _no_op_stream_writer(_: Any) -> None: ...
def _no_op_heartbeat() -> None: ...
class _RuntimeOverrides(TypedDict, Generic[ContextT], total=False):
context: ContextT
store: BaseStore | None
stream_writer: StreamWriter
heartbeat: Callable[[], None]
previous: Any
execution_info: ExecutionInfo
server_info: ServerInfo | None
@@ -171,6 +176,16 @@ class Runtime(Generic[ContextT]):
stream_writer: StreamWriter = field(default=_no_op_stream_writer)
"""Function that writes to the custom stream."""
heartbeat: Callable[[], None] = field(default=_no_op_heartbeat)
"""Record progress for the current node's `idle_timeout`.
Call this from inside long-running work that does not naturally emit
writes, stream chunks, child tasks, or LangChain callback events, to
prevent the node from being treated as idle. It is also the only
progress signal honored under `TimeoutPolicy(refresh_on="heartbeat")`.
Outside an idle-timed attempt this is a no-op.
"""
previous: Any = field(default=None)
"""The previous return value for the given thread.
@@ -196,6 +211,9 @@ class Runtime(Generic[ContextT]):
stream_writer=other.stream_writer
if other.stream_writer is not _no_op_stream_writer
else self.stream_writer,
heartbeat=other.heartbeat
if other.heartbeat is not _no_op_heartbeat
else self.heartbeat,
previous=self.previous if other.previous is None else other.previous,
execution_info=other.execution_info or self.execution_info,
server_info=other.server_info or self.server_info,
@@ -222,6 +240,7 @@ DEFAULT_RUNTIME = Runtime(
context=None,
store=None,
stream_writer=_no_op_stream_writer,
heartbeat=_no_op_heartbeat,
previous=None,
execution_info=None,
)
@@ -14,15 +14,23 @@ from langgraph.stream.run_stream import (
)
from langgraph.stream.stream_channel import StreamChannel
from langgraph.stream.transformers import (
CheckpointsTransformer,
CustomTransformer,
DebugTransformer,
LifecyclePayload,
LifecycleTransformer,
SubgraphStatus,
SubgraphTransformer,
TasksTransformer,
UpdatesTransformer,
)
__all__ = [
"AsyncGraphRunStream",
"AsyncSubgraphRunStream",
"CheckpointsTransformer",
"CustomTransformer",
"DebugTransformer",
"GraphRunStream",
"LifecyclePayload",
"LifecycleTransformer",
@@ -32,4 +40,6 @@ __all__ = [
"SubgraphRunStream",
"SubgraphStatus",
"SubgraphTransformer",
"TasksTransformer",
"UpdatesTransformer",
]
@@ -82,6 +82,76 @@ class ValuesTransformer(StreamTransformer):
return True
class CustomTransformer(StreamTransformer):
"""Capture custom events as a drainable stream of arbitrary payloads.
Nodes emit custom data via `get_stream_writer()`. This transformer
surfaces those events on `run.custom` as a `StreamChannel[Any]`,
preserving payloads in arrival order.
Only events at the run's own scope are captured; custom data from
deeper subgraphs is available on the respective subgraph handle's
`.custom` projection.
Native transformer `run.custom` is a direct attribute.
"""
_native = True
required_stream_modes = ("custom",)
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope)
self._log: StreamChannel[Any] = StreamChannel()
self._scope_list: list[str] = list(scope)
def init(self) -> dict[str, Any]:
return {"custom": self._log}
def process(self, event: ProtocolEvent) -> bool:
if event["method"] != "custom":
return True
params = event["params"]
if params["namespace"] != self._scope_list:
return True
self._log.push(params["data"])
return True
class UpdatesTransformer(StreamTransformer):
"""Capture updates events as a drainable stream of node outputs.
Surfaces `stream_mode="updates"` data on `run.updates` as a
`StreamChannel[dict[str, Any]]`. Each item is a dict mapping a node
(or task) name to the update it returned after a step.
Only events at the run's own scope are captured; updates from deeper
subgraphs are available on the respective subgraph handle's
`.updates` projection.
Native transformer `run.updates` is a direct attribute.
"""
_native = True
required_stream_modes = ("updates",)
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope)
self._log: StreamChannel[dict[str, Any]] = StreamChannel()
self._scope_list: list[str] = list(scope)
def init(self) -> dict[str, Any]:
return {"updates": self._log}
def process(self, event: ProtocolEvent) -> bool:
if event["method"] != "updates":
return True
params = event["params"]
if params["namespace"] != self._scope_list:
return True
self._log.push(params["data"])
return True
class MessagesTransformer(StreamTransformer):
"""Capture messages events as ChatModelStream objects.
@@ -741,3 +811,118 @@ class SubgraphTransformer(_TasksLifecycleBase):
handle.path,
exc_info=True,
)
class CheckpointsTransformer(StreamTransformer):
"""Capture checkpoint events as a drainable stream.
Surfaces `stream_mode="checkpoints"` data on `run.checkpoints` as
a `StreamChannel[dict[str, Any]]`. Each item is in the same format
as returned by `get_state()`.
Checkpoint events are only emitted when a checkpointer is configured
on the graph. When no checkpointer is present, the projection exists
but receives no events.
Only events at the run's own scope are captured; checkpoint data from
deeper subgraphs is available on the respective subgraph handle's
`.checkpoints` projection.
Native transformer `run.checkpoints` is a direct attribute.
"""
_native = True
required_stream_modes = ("checkpoints",)
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope)
self._log: StreamChannel[dict[str, Any]] = StreamChannel()
self._scope_list: list[str] = list(scope)
def init(self) -> dict[str, Any]:
return {"checkpoints": self._log}
def process(self, event: ProtocolEvent) -> bool:
if event["method"] != "checkpoints":
return True
params = event["params"]
if params["namespace"] != self._scope_list:
return True
self._log.push(params["data"])
return True
class DebugTransformer(StreamTransformer):
"""Capture debug events as a drainable stream.
Surfaces `stream_mode="debug"` data on `run.debug` as a
`StreamChannel[dict[str, Any]]`. Each item is a debug event with
step-level detail (checkpoint snapshots, task payloads, and
task results wrapped with step number and timestamp).
Only events at the run's own scope are captured; debug data from
deeper subgraphs is available on the respective subgraph handle's
`.debug` projection.
Native transformer `run.debug` is a direct attribute.
"""
_native = True
required_stream_modes = ("debug",)
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope)
self._log: StreamChannel[dict[str, Any]] = StreamChannel()
self._scope_list: list[str] = list(scope)
def init(self) -> dict[str, Any]:
return {"debug": self._log}
def process(self, event: ProtocolEvent) -> bool:
if event["method"] != "debug":
return True
params = event["params"]
if params["namespace"] != self._scope_list:
return True
self._log.push(params["data"])
return True
class TasksTransformer(StreamTransformer):
"""Capture raw task events as a drainable stream.
Surfaces `stream_mode="tasks"` data on `run.tasks` as a
`StreamChannel[dict[str, Any]]`. Each item is a task payload
(start or result).
`LifecycleTransformer` and `SubgraphTransformer` also consume
`tasks` events for subgraph discovery and lifecycle tracking.
This transformer captures the raw payloads independently for
consumers who need task-level detail.
Only events at the run's own scope are captured; task data from
deeper subgraphs is available on the respective subgraph handle's
`.tasks` projection.
Native transformer `run.tasks` is a direct attribute.
"""
_native = True
required_stream_modes = ("tasks",)
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope)
self._log: StreamChannel[dict[str, Any]] = StreamChannel()
self._scope_list: list[str] = list(scope)
def init(self) -> dict[str, Any]:
return {"tasks": self._log}
def process(self, event: ProtocolEvent) -> bool:
if event["method"] != "tasks":
return True
params = event["params"]
if params["namespace"] != self._scope_list:
return True
self._log.push(params["data"])
return True
+100 -4
View File
@@ -4,6 +4,7 @@ import sys
from collections import deque
from collections.abc import Callable, Hashable, Sequence
from dataclasses import asdict, dataclass
from datetime import timedelta
from typing import (
TYPE_CHECKING,
Any,
@@ -67,6 +68,7 @@ __all__ = (
"CheckpointPayload",
"DebugPayload",
"RetryPolicy",
"TimeoutPolicy",
"CachePolicy",
"Interrupt",
"StateUpdate",
@@ -423,6 +425,83 @@ class RetryPolicy(NamedTuple):
"""List of exception classes that should trigger a retry, or a callable that returns `True` for exceptions that should trigger a retry."""
def _coerce_timeout_seconds(
value: float | timedelta | None, *, field: str
) -> float | None:
if value is None:
return None
seconds = value.total_seconds() if isinstance(value, timedelta) else float(value)
if seconds <= 0:
raise ValueError(f"{field} must be greater than 0")
return seconds
@dataclass(**_DC_KWARGS)
class TimeoutPolicy:
"""Configuration for timing out node attempts.
!!! note "Cooperative cancellation"
Timeouts rely on asyncio cancellation. If your node uses synchronous
time.sleep() or other CPU-bound work that blocks the GIL, the timeout will not
be fired until after the event loop has been released.
!!! note "Inline callback dispatch"
Under `refresh_on="auto"`, an internal handler refreshes the timeout on any
callback event that occurs in the execution of the node or its nested descendants.
"""
run_timeout: float | timedelta | None = None
"""Hard wall-clock cap (in seconds) for a single node attempt.
This timeout is never refreshed by progress signals or `runtime.heartbeat()`.
"""
idle_timeout: float | timedelta | None = None
"""Maximum time (in seconds) a single node attempt may go without observable progress."""
refresh_on: Literal["auto", "heartbeat"] = "auto"
"""Which signals refresh `idle_timeout`.
`"auto"` refreshes on standard graph progress signals and explicit heartbeats.
`"heartbeat"` refreshes only on explicit `runtime.heartbeat()` calls.
"""
@classmethod
def coerce(
cls, value: float | timedelta | TimeoutPolicy | None
) -> TimeoutPolicy | None:
"""Normalize a timeout value to positive-second policy fields."""
if value is None:
return None
if isinstance(value, TimeoutPolicy):
# Fast path: a policy already produced by coerce() has float
# timeouts and a validated refresh_on, so we can return it as-is.
# `frozen=True` makes this safe to share.
rt, it = value.run_timeout, value.idle_timeout
if (
value.refresh_on in ("auto", "heartbeat")
and (rt is None or (type(rt) is float and rt > 0))
and (it is None or (type(it) is float and it > 0))
and (rt is not None or it is not None)
):
return value
else:
value = cls(run_timeout=value)
if value.refresh_on not in ("auto", "heartbeat"):
raise ValueError("refresh_on must be 'auto' or 'heartbeat'")
run_timeout = _coerce_timeout_seconds(value.run_timeout, field="run_timeout")
idle_timeout = _coerce_timeout_seconds(value.idle_timeout, field="idle_timeout")
if run_timeout is None and idle_timeout is None:
return None
return cls(
run_timeout=run_timeout,
idle_timeout=idle_timeout,
refresh_on=value.refresh_on,
)
KeyFuncT = TypeVar("KeyFuncT", bound=Callable[..., str | bytes])
@@ -548,6 +627,7 @@ class PregelExecutableTask:
path: tuple[str | int | tuple, ...]
writers: Sequence[Runnable] = ()
subgraphs: Sequence[PregelProtocol] = ()
timeout: TimeoutPolicy | None = None
class StateSnapshot(NamedTuple):
@@ -587,6 +667,8 @@ class Send:
Attributes:
node (str): The name of the target node to send the message to.
arg (Any): The state or message to send to the target node.
timeout (TimeoutPolicy | None): Optional timeout policy for this specific
pushed task. If omitted, the target node's timeout policy is used.
!!! example
@@ -616,33 +698,47 @@ class Send:
```
"""
__slots__ = ("node", "arg")
__slots__ = ("node", "arg", "timeout")
node: str
arg: Any
timeout: TimeoutPolicy | None
def __init__(self, /, node: str, arg: Any) -> None:
def __init__(
self,
/,
node: str,
arg: Any,
*,
timeout: float | timedelta | TimeoutPolicy | None = None,
) -> None:
"""
Initialize a new instance of the `Send` class.
Args:
node: The name of the target node to send the message to.
arg: The state or message to send to the target node.
timeout: Optional timeout policy for this specific pushed task. A
number or `timedelta` is treated as a hard `run_timeout`.
"""
self.node = node
self.arg = arg
self.timeout = TimeoutPolicy.coerce(timeout)
def __hash__(self) -> int:
return hash((self.node, self.arg))
return hash((self.node, self.arg, self.timeout))
def __repr__(self) -> str:
return f"Send(node={self.node!r}, arg={self.arg!r})"
if self.timeout is None:
return f"Send(node={self.node!r}, arg={self.arg!r})"
return f"Send(node={self.node!r}, arg={self.arg!r}, timeout={self.timeout!r})"
def __eq__(self, value: object) -> bool:
return (
isinstance(value, Send)
and self.node == value.node
and self.arg == value.arg
and self.timeout == value.timeout
)
+1 -1
View File
@@ -38,7 +38,7 @@ Homepage = "https://docs.langchain.com/oss/python/langgraph/overview"
Documentation = "https://reference.langchain.com/python/langgraph/"
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/langgraph"
Changelog = "https://github.com/langchain-ai/langgraph/releases"
Twitter = "https://x.com/LangChain"
Twitter = "https://x.com/langchain_oss"
Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,694 @@
"""Tests for CustomTransformer, UpdatesTransformer, CheckpointsTransformer, DebugTransformer, TasksTransformer.
These transformers capture raw protocol events for their respective stream
modes and expose them as native projections on the run stream (run.custom,
run.updates, run.checkpoints, run.debug, run.tasks). Tests dispatch synthetic
protocol events through a StreamMux to isolate transformer logic; the final
group exercises real graphs through stream_v2.
"""
from __future__ import annotations
import operator
import time
from typing import Annotated, Any
from typing_extensions import TypedDict
from langgraph.constants import END, START
from langgraph.graph import StateGraph
from langgraph.stream._mux import StreamMux
from langgraph.stream.stream_channel import StreamChannel
from langgraph.stream.transformers import (
CheckpointsTransformer,
CustomTransformer,
DebugTransformer,
LifecycleTransformer,
TasksTransformer,
UpdatesTransformer,
)
TS = int(time.time() * 1000)
def _custom_event(namespace: list[str], data: Any) -> dict[str, Any]:
return {
"type": "event",
"method": "custom",
"params": {"namespace": namespace, "timestamp": TS, "data": data},
}
def _checkpoints_event(namespace: list[str], data: Any) -> dict[str, Any]:
return {
"type": "event",
"method": "checkpoints",
"params": {"namespace": namespace, "timestamp": TS, "data": data},
}
def _debug_event(namespace: list[str], data: Any) -> dict[str, Any]:
return {
"type": "event",
"method": "debug",
"params": {"namespace": namespace, "timestamp": TS, "data": data},
}
def _tasks_event(namespace: list[str], data: Any) -> dict[str, Any]:
return {
"type": "event",
"method": "tasks",
"params": {"namespace": namespace, "timestamp": TS, "data": data},
}
def _updates_event(namespace: list[str], data: Any) -> dict[str, Any]:
return {
"type": "event",
"method": "updates",
"params": {"namespace": namespace, "timestamp": TS, "data": data},
}
def _arm(mux: StreamMux, transformer: Any) -> None:
"""Force projection logs to accept pushes (skip lazy-subscribe gate)."""
mux._events._subscribed = True
transformer._log._subscribed = True
def _drain(transformer: Any) -> list[Any]:
return list(transformer._log._items)
# ---------------------------------------------------------------------------
# CustomTransformer
# ---------------------------------------------------------------------------
def test_custom_captures_root_scope_events() -> None:
t = CustomTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_custom_event([], {"status": "processing"}))
mux.push(_custom_event([], {"status": "done"}))
items = _drain(t)
assert items == [{"status": "processing"}, {"status": "done"}]
def test_custom_ignores_subgraph_scope_events() -> None:
t = CustomTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_custom_event(["subgraph:abc"], {"from": "child"}))
assert _drain(t) == []
def test_custom_scoped_transformer_captures_own_scope() -> None:
t = CustomTransformer(scope=("agent:abc",))
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_custom_event([], {"from": "root"}))
mux.push(_custom_event(["agent:abc"], {"from": "self"}))
mux.push(_custom_event(["agent:abc", "deep:def"], {"from": "child"}))
items = _drain(t)
assert items == [{"from": "self"}]
def test_custom_preserves_any_payload_type() -> None:
t = CustomTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_custom_event([], "string_payload"))
mux.push(_custom_event([], 42))
mux.push(_custom_event([], [1, 2, 3]))
assert _drain(t) == ["string_payload", 42, [1, 2, 3]]
def test_custom_does_not_suppress_from_main_log() -> None:
t = CustomTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_custom_event([], "data"))
methods = [evt["method"] for evt in mux._events._items]
assert "custom" in methods
def test_custom_ignores_other_methods() -> None:
t = CustomTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(
{
"type": "event",
"method": "values",
"params": {"namespace": [], "timestamp": TS, "data": {}},
}
)
assert _drain(t) == []
def test_custom_required_stream_modes() -> None:
assert CustomTransformer.required_stream_modes == ("custom",)
def test_custom_is_native() -> None:
assert getattr(CustomTransformer, "_native", False) is True
def test_custom_init_returns_correct_key() -> None:
t = CustomTransformer()
projection = t.init()
assert "custom" in projection
assert isinstance(projection["custom"], StreamChannel)
# ---------------------------------------------------------------------------
# CheckpointsTransformer
# ---------------------------------------------------------------------------
def test_checkpoints_captures_root_scope_events() -> None:
t = CheckpointsTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
checkpoint_data = {"values": {"x": 1}, "next": ["node_b"]}
mux.push(_checkpoints_event([], checkpoint_data))
items = _drain(t)
assert items == [checkpoint_data]
def test_checkpoints_ignores_subgraph_events() -> None:
t = CheckpointsTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_checkpoints_event(["child:abc"], {"values": {"x": 1}}))
assert _drain(t) == []
def test_checkpoints_scoped_transformer() -> None:
t = CheckpointsTransformer(scope=("sub:abc",))
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_checkpoints_event([], {"from": "root"}))
mux.push(_checkpoints_event(["sub:abc"], {"from": "self"}))
assert _drain(t) == [{"from": "self"}]
def test_checkpoints_does_not_suppress_from_main_log() -> None:
t = CheckpointsTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_checkpoints_event([], {"values": {}}))
methods = [evt["method"] for evt in mux._events._items]
assert "checkpoints" in methods
def test_checkpoints_required_stream_modes() -> None:
assert CheckpointsTransformer.required_stream_modes == ("checkpoints",)
def test_checkpoints_is_native() -> None:
assert getattr(CheckpointsTransformer, "_native", False) is True
# ---------------------------------------------------------------------------
# DebugTransformer
# ---------------------------------------------------------------------------
def test_debug_captures_root_scope_events() -> None:
t = DebugTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
debug_data = {
"step": 0,
"type": "checkpoint",
"timestamp": "2026-01-01T00:00:00Z",
"payload": {"values": {"x": 1}},
}
mux.push(_debug_event([], debug_data))
items = _drain(t)
assert items == [debug_data]
def test_debug_ignores_subgraph_events() -> None:
t = DebugTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_debug_event(["child:abc"], {"step": 0, "type": "task"}))
assert _drain(t) == []
def test_debug_captures_multiple_event_types() -> None:
t = DebugTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_debug_event([], {"step": 0, "type": "checkpoint", "payload": {}}))
mux.push(_debug_event([], {"step": 1, "type": "task", "payload": {}}))
mux.push(_debug_event([], {"step": 1, "type": "task_result", "payload": {}}))
items = _drain(t)
assert len(items) == 3
assert [d["type"] for d in items] == ["checkpoint", "task", "task_result"]
def test_debug_does_not_suppress_from_main_log() -> None:
t = DebugTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_debug_event([], {"step": 0}))
methods = [evt["method"] for evt in mux._events._items]
assert "debug" in methods
def test_debug_required_stream_modes() -> None:
assert DebugTransformer.required_stream_modes == ("debug",)
def test_debug_is_native() -> None:
assert getattr(DebugTransformer, "_native", False) is True
# ---------------------------------------------------------------------------
# TasksTransformer
# ---------------------------------------------------------------------------
def test_tasks_captures_root_scope_events() -> None:
t = TasksTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
task_start = {"id": "t1", "name": "my_node", "input": None, "triggers": []}
mux.push(_tasks_event([], task_start))
items = _drain(t)
assert items == [task_start]
def test_tasks_captures_start_and_result() -> None:
t = TasksTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
start = {"id": "t1", "name": "a", "input": None, "triggers": []}
result = {"id": "t1", "name": "a", "result": {"output": 42}, "error": None}
mux.push(_tasks_event([], start))
mux.push(_tasks_event([], result))
items = _drain(t)
assert items == [start, result]
def test_tasks_ignores_subgraph_events() -> None:
t = TasksTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_tasks_event(["child:abc"], {"id": "t1", "name": "x"}))
assert _drain(t) == []
def test_tasks_scoped_transformer() -> None:
t = TasksTransformer(scope=("agent:abc",))
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_tasks_event([], {"id": "t1"}))
mux.push(_tasks_event(["agent:abc"], {"id": "t2"}))
mux.push(_tasks_event(["agent:abc", "deep:def"], {"id": "t3"}))
assert _drain(t) == [{"id": "t2"}]
def test_tasks_does_not_suppress_from_main_log() -> None:
"""TasksTransformer returns True — it doesn't suppress tasks events.
(LifecycleTransformer suppresses them, but that's independent.)
"""
t = TasksTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_tasks_event([], {"id": "t1"}))
methods = [evt["method"] for evt in mux._events._items]
assert "tasks" in methods
def test_tasks_required_stream_modes() -> None:
assert TasksTransformer.required_stream_modes == ("tasks",)
def test_tasks_is_native() -> None:
assert getattr(TasksTransformer, "_native", False) is True
# ---------------------------------------------------------------------------
# UpdatesTransformer
# ---------------------------------------------------------------------------
def test_updates_captures_root_scope_events() -> None:
t = UpdatesTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
update = {"my_node": {"value": "hello!"}}
mux.push(_updates_event([], update))
items = _drain(t)
assert items == [update]
def test_updates_captures_multiple_steps() -> None:
t = UpdatesTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_updates_event([], {"node_a": {"x": 1}}))
mux.push(_updates_event([], {"node_b": {"x": 2}}))
items = _drain(t)
assert items == [{"node_a": {"x": 1}}, {"node_b": {"x": 2}}]
def test_updates_ignores_subgraph_events() -> None:
t = UpdatesTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_updates_event(["child:abc"], {"inner_node": {"v": 1}}))
assert _drain(t) == []
def test_updates_scoped_transformer() -> None:
t = UpdatesTransformer(scope=("agent:abc",))
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_updates_event([], {"from": "root"}))
mux.push(_updates_event(["agent:abc"], {"from": "self"}))
assert _drain(t) == [{"from": "self"}]
def test_updates_does_not_suppress_from_main_log() -> None:
t = UpdatesTransformer()
mux = StreamMux([t], is_async=False)
_arm(mux, t)
mux.push(_updates_event([], {"n": {}}))
methods = [evt["method"] for evt in mux._events._items]
assert "updates" in methods
def test_updates_required_stream_modes() -> None:
assert UpdatesTransformer.required_stream_modes == ("updates",)
def test_updates_is_native() -> None:
assert getattr(UpdatesTransformer, "_native", False) is True
# ---------------------------------------------------------------------------
# Cross-transformer: unrelated events pass through
# ---------------------------------------------------------------------------
def test_unrelated_events_ignored_by_all() -> None:
"""Non-matching method events don't land in any transformer's log."""
transformers = [
CustomTransformer(),
UpdatesTransformer(),
CheckpointsTransformer(),
DebugTransformer(),
TasksTransformer(),
]
mux = StreamMux(transformers, is_async=False)
mux._events._subscribed = True
for t in transformers:
t._log._subscribed = True
mux.push(
{
"type": "event",
"method": "values",
"params": {"namespace": [], "timestamp": TS, "data": {"x": 1}},
}
)
for t in transformers:
assert list(t._log._items) == []
# ---------------------------------------------------------------------------
# End-to-end: real graphs through stream_v2
# ---------------------------------------------------------------------------
class _State(TypedDict):
value: str
items: Annotated[list[str], operator.add]
def _my_node(state: _State) -> dict[str, Any]:
from langgraph.config import get_stream_writer
writer = get_stream_writer()
writer({"status": "working", "node": "my_node"})
return {"value": state["value"] + "!", "items": ["done"]}
def _make_simple_graph() -> Any:
builder = StateGraph(_State, input_schema=_State)
builder.add_node("my_node", _my_node)
builder.add_edge(START, "my_node")
builder.add_edge("my_node", END)
return builder.compile()
def test_stream_v2_custom_projection_opt_in() -> None:
"""run.custom surfaces get_stream_writer() payloads when opted in."""
graph = _make_simple_graph()
run = graph.stream_v2(
{"value": "hello", "items": []}, transformers=[CustomTransformer]
)
custom_events = list(run.custom)
assert len(custom_events) >= 1
assert any(e.get("status") == "working" for e in custom_events)
def test_stream_v2_custom_and_values_coexist() -> None:
"""Both run.custom and run.values work in the same run."""
graph = _make_simple_graph()
run = graph.stream_v2(
{"value": "hello", "items": []}, transformers=[CustomTransformer]
)
custom_events = list(run.custom)
assert run.output is not None
assert run.output["value"] == "hello!"
assert len(custom_events) >= 1
def test_stream_v2_tasks_projection_opt_in() -> None:
"""run.tasks surfaces raw task events when opted in via transformers=."""
graph = _make_simple_graph()
run = graph.stream_v2({"value": "x", "items": []}, transformers=[TasksTransformer])
tasks_events = list(run.tasks)
assert len(tasks_events) >= 1
names = [t.get("name") for t in tasks_events if "name" in t]
assert "my_node" in names
def test_stream_v2_debug_projection_opt_in() -> None:
"""run.debug surfaces debug events when opted in via transformers=."""
graph = _make_simple_graph()
run = graph.stream_v2({"value": "x", "items": []}, transformers=[DebugTransformer])
debug_events = list(run.debug)
assert len(debug_events) >= 1
types = {d.get("type") for d in debug_events}
assert types & {"checkpoint", "task", "task_result"}
def test_stream_v2_updates_projection_opt_in() -> None:
"""run.updates surfaces node output dicts when opted in via transformers=."""
graph = _make_simple_graph()
run = graph.stream_v2(
{"value": "x", "items": []}, transformers=[UpdatesTransformer]
)
updates = list(run.updates)
assert len(updates) >= 1
node_names = {k for u in updates for k in u if k != "__interrupt__"}
assert "my_node" in node_names
def test_stream_v2_all_transformers_interleaved() -> None:
"""All five transformers registered together, consumed via interleave."""
graph = _make_simple_graph()
run = graph.stream_v2(
{"value": "x", "items": []},
transformers=[
CustomTransformer,
UpdatesTransformer,
CheckpointsTransformer,
DebugTransformer,
TasksTransformer,
],
)
collected: dict[str, list[Any]] = {
"custom": [],
"updates": [],
"debug": [],
"tasks": [],
}
for name, item in run.interleave("custom", "updates", "debug", "tasks"):
collected[name].append(item)
assert len(collected["custom"]) >= 1
assert len(collected["updates"]) >= 1
assert len(collected["tasks"]) >= 1
assert len(collected["debug"]) >= 1
types = {d.get("type") for d in collected["debug"]}
assert types & {"checkpoint", "task", "task_result"}
node_names = {k for u in collected["updates"] for k in u if k != "__interrupt__"}
assert "my_node" in node_names
assert run.output is not None
assert run.output["value"] == "x!"
def test_stream_v2_all_transformers_with_checkpointer() -> None:
"""All transformers with a checkpointer — run.checkpoints populated."""
from langgraph.checkpoint.memory import InMemorySaver
builder = StateGraph(_State, input_schema=_State)
builder.add_node("my_node", _my_node)
builder.add_edge(START, "my_node")
builder.add_edge("my_node", END)
graph = builder.compile(checkpointer=InMemorySaver())
run = graph.stream_v2(
{"value": "x", "items": []},
config={"configurable": {"thread_id": "test-all"}},
transformers=[
CustomTransformer,
UpdatesTransformer,
CheckpointsTransformer,
DebugTransformer,
TasksTransformer,
],
)
collected: dict[str, list[Any]] = {
"custom": [],
"updates": [],
"checkpoints": [],
"debug": [],
"tasks": [],
}
for name, item in run.interleave(
"custom", "updates", "checkpoints", "debug", "tasks"
):
collected[name].append(item)
assert len(collected["checkpoints"]) >= 1
assert len(collected["custom"]) >= 1
def test_stream_v2_checkpoints_projection_opt_in() -> None:
"""run.checkpoints surfaces checkpoint data when opted in with a checkpointer."""
from langgraph.checkpoint.memory import InMemorySaver
builder = StateGraph(_State, input_schema=_State)
builder.add_node("my_node", _my_node)
builder.add_edge(START, "my_node")
builder.add_edge("my_node", END)
graph = builder.compile(checkpointer=InMemorySaver())
run = graph.stream_v2(
{"value": "x", "items": []},
config={"configurable": {"thread_id": "test-ckpt-standalone"}},
transformers=[CheckpointsTransformer],
)
checkpoints = list(run.checkpoints)
assert len(checkpoints) >= 1
# ---------------------------------------------------------------------------
# TasksTransformer + LifecycleTransformer co-registration
# ---------------------------------------------------------------------------
def test_tasks_and_lifecycle_coregistration() -> None:
"""When both are in the same StreamMux, LifecycleTransformer suppresses
tasks events from the main log (returns False) while TasksTransformer
still captures them into its own log.
"""
lifecycle = LifecycleTransformer()
tasks = TasksTransformer()
mux = StreamMux([lifecycle, tasks], is_async=False)
mux._events._subscribed = True
tasks._log._subscribed = True
lifecycle._channel._subscribed = True
task_data = {"id": "t1", "name": "my_node", "input": None, "triggers": []}
mux.push(_tasks_event([], task_data))
assert _drain(tasks) == [task_data]
methods = [evt["method"] for evt in mux._events._items]
assert "tasks" not in methods
def test_tasks_and_lifecycle_coregistration_e2e() -> None:
"""E2e: TasksTransformer captures task events even when LifecycleTransformer
is present and suppressing them from the main log.
"""
graph = _make_simple_graph()
run = graph.stream_v2(
{"value": "x", "items": []},
transformers=[TasksTransformer],
)
tasks_events = list(run.tasks)
assert len(tasks_events) >= 1
names = [t.get("name") for t in tasks_events if "name" in t]
assert "my_node" in names
+1 -1
View File
@@ -30,7 +30,7 @@ dependencies = [
[project.urls]
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/prebuilt"
Twitter = "https://x.com/LangChain"
Twitter = "https://x.com/langchain_oss"
Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
+1 -1
View File
@@ -18,7 +18,7 @@ path = "langgraph_sdk/__init__.py"
[project.urls]
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/sdk-py"
Twitter = "https://x.com/LangChain"
Twitter = "https://x.com/langchain_oss"
Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"