mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-21 07:02:25 +02:00
Merge branch 'main' into delta-channel-writes-based
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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/"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/"
|
||||
|
||||
|
||||
@@ -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/"
|
||||
|
||||
|
||||
@@ -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.")
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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/"
|
||||
|
||||
|
||||
@@ -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/"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user