From e39255a792dd456bc2385bd6332815d347303a87 Mon Sep 17 00:00:00 2001 From: bracesproul Date: Tue, 7 Jan 2025 10:00:38 -0800 Subject: [PATCH 01/52] feat: Add interrupt schema to library --- libs/langgraph/langgraph/types.py | 63 +++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index fa1adafe6..366394749 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -491,3 +491,66 @@ def interrupt(value: Any) -> Any: ), ) ) + + +class HumanInterruptConfig(TypedDict): + """Configuration that defines what actions are allowed for a human interrupt. + + This controls the available interaction options when the graph is paused for human input. + + Attributes: + allow_ignore (bool): Whether the human can choose to ignore/skip the current step + allow_respond (bool): Whether the human can provide a text response/feedback + allow_edit (bool): Whether the human can edit the provided content/state + allow_accept (bool): Whether the human can accept/approve the current state + """ + allow_ignore: bool + allow_respond: bool + allow_edit: bool + allow_accept: bool + + +class ActionRequest(TypedDict): + """Represents a request for human action within the graph execution. + + Contains the action type and any associated arguments needed for the action. + + Attributes: + action (str): The type or name of action being requested (e.g., "Approve XYZ action") + args (dict): Key-value pairs of arguments needed for the action + """ + action: str + args: dict + + +class HumanInterrupt(TypedDict): + """Represents an interrupt triggered by the graph that requires human intervention. + + This is passed to the `interrupt` function when execution is paused for human input. + + Attributes: + action_request (ActionRequest): The specific action being requested from the human + config (HumanInterruptConfig): Configuration defining what actions are allowed + description (Optional[str]): Optional detailed description of what input is needed + """ + action_request: ActionRequest + config: HumanInterruptConfig + description: Optional[str] + + +class HumanResponse(TypedDict): + """The response provided by a human to an interrupt, which is returned when graph execution resumes. + + Attributes: + type (Literal['accept', 'ignore', 'response', 'edit']): The type of response: + - "accept": Approves the current state without changes + - "ignore": Skips/ignores the current step + - "response": Provides text feedback or instructions + - "edit": Modifies the current state/content + args (Union[None, str, ActionRequest]): The response payload: + - None: For ignore/accept actions + - str: For text responses + - ActionRequest: For edit actions with updated content + """ + type: Literal['accept', 'ignore', 'response', 'edit'] + args: Union[None, str, ActionRequest] \ No newline at end of file From 8534212a25d6e2c6c8c47a7a8ca6538a29816660 Mon Sep 17 00:00:00 2001 From: bracesproul Date: Tue, 7 Jan 2025 10:15:48 -0800 Subject: [PATCH 02/52] cr --- libs/langgraph/langgraph/types.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index 366394749..d6d778e58 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -495,15 +495,16 @@ def interrupt(value: Any) -> Any: class HumanInterruptConfig(TypedDict): """Configuration that defines what actions are allowed for a human interrupt. - + This controls the available interaction options when the graph is paused for human input. Attributes: allow_ignore (bool): Whether the human can choose to ignore/skip the current step - allow_respond (bool): Whether the human can provide a text response/feedback + allow_respond (bool): Whether the human can provide a text response/feedback allow_edit (bool): Whether the human can edit the provided content/state allow_accept (bool): Whether the human can accept/approve the current state """ + allow_ignore: bool allow_respond: bool allow_edit: bool @@ -512,20 +513,21 @@ class HumanInterruptConfig(TypedDict): class ActionRequest(TypedDict): """Represents a request for human action within the graph execution. - + Contains the action type and any associated arguments needed for the action. Attributes: action (str): The type or name of action being requested (e.g., "Approve XYZ action") args (dict): Key-value pairs of arguments needed for the action """ + action: str args: dict class HumanInterrupt(TypedDict): """Represents an interrupt triggered by the graph that requires human intervention. - + This is passed to the `interrupt` function when execution is paused for human input. Attributes: @@ -533,6 +535,7 @@ class HumanInterrupt(TypedDict): config (HumanInterruptConfig): Configuration defining what actions are allowed description (Optional[str]): Optional detailed description of what input is needed """ + action_request: ActionRequest config: HumanInterruptConfig description: Optional[str] @@ -540,7 +543,7 @@ class HumanInterrupt(TypedDict): class HumanResponse(TypedDict): """The response provided by a human to an interrupt, which is returned when graph execution resumes. - + Attributes: type (Literal['accept', 'ignore', 'response', 'edit']): The type of response: - "accept": Approves the current state without changes @@ -552,5 +555,6 @@ class HumanResponse(TypedDict): - str: For text responses - ActionRequest: For edit actions with updated content """ - type: Literal['accept', 'ignore', 'response', 'edit'] - args: Union[None, str, ActionRequest] \ No newline at end of file + + type: Literal["accept", "ignore", "response", "edit"] + args: Union[None, str, ActionRequest] From d27beeed187060256ff5baa88685bb08926077eb Mon Sep 17 00:00:00 2001 From: bracesproul Date: Fri, 10 Jan 2025 10:45:22 -0800 Subject: [PATCH 03/52] move to prebuilt --- .../langgraph/langgraph/prebuilt/interrupt.py | 65 ++++++++++++++++++ libs/langgraph/langgraph/types.py | 66 ------------------- 2 files changed, 65 insertions(+), 66 deletions(-) create mode 100644 libs/langgraph/langgraph/prebuilt/interrupt.py diff --git a/libs/langgraph/langgraph/prebuilt/interrupt.py b/libs/langgraph/langgraph/prebuilt/interrupt.py new file mode 100644 index 000000000..bf440f210 --- /dev/null +++ b/libs/langgraph/langgraph/prebuilt/interrupt.py @@ -0,0 +1,65 @@ +class HumanInterruptConfig(TypedDict): + """Configuration that defines what actions are allowed for a human interrupt. + + This controls the available interaction options when the graph is paused for human input. + + Attributes: + allow_ignore (bool): Whether the human can choose to ignore/skip the current step + allow_respond (bool): Whether the human can provide a text response/feedback + allow_edit (bool): Whether the human can edit the provided content/state + allow_accept (bool): Whether the human can accept/approve the current state + """ + + allow_ignore: bool + allow_respond: bool + allow_edit: bool + allow_accept: bool + + +class ActionRequest(TypedDict): + """Represents a request for human action within the graph execution. + + Contains the action type and any associated arguments needed for the action. + + Attributes: + action (str): The type or name of action being requested (e.g., "Approve XYZ action") + args (dict): Key-value pairs of arguments needed for the action + """ + + action: str + args: dict + + +class HumanInterrupt(TypedDict): + """Represents an interrupt triggered by the graph that requires human intervention. + + This is passed to the `interrupt` function when execution is paused for human input. + + Attributes: + action_request (ActionRequest): The specific action being requested from the human + config (HumanInterruptConfig): Configuration defining what actions are allowed + description (Optional[str]): Optional detailed description of what input is needed + """ + + action_request: ActionRequest + config: HumanInterruptConfig + description: Optional[str] + + +class HumanResponse(TypedDict): + """The response provided by a human to an interrupt, which is returned when graph execution resumes. + + Attributes: + type (Literal['accept', 'ignore', 'response', 'edit']): The type of response: + - "accept": Approves the current state without changes + - "ignore": Skips/ignores the current step + - "response": Provides text feedback or instructions + - "edit": Modifies the current state/content + args (Union[None, str, ActionRequest]): The response payload: + - None: For ignore/accept actions + - str: For text responses + - ActionRequest: For edit actions with updated content + """ + + type: Literal["accept", "ignore", "response", "edit"] + args: Union[None, str, ActionRequest] diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index d6d778e58..9a2c78acd 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -492,69 +492,3 @@ def interrupt(value: Any) -> Any: ) ) - -class HumanInterruptConfig(TypedDict): - """Configuration that defines what actions are allowed for a human interrupt. - - This controls the available interaction options when the graph is paused for human input. - - Attributes: - allow_ignore (bool): Whether the human can choose to ignore/skip the current step - allow_respond (bool): Whether the human can provide a text response/feedback - allow_edit (bool): Whether the human can edit the provided content/state - allow_accept (bool): Whether the human can accept/approve the current state - """ - - allow_ignore: bool - allow_respond: bool - allow_edit: bool - allow_accept: bool - - -class ActionRequest(TypedDict): - """Represents a request for human action within the graph execution. - - Contains the action type and any associated arguments needed for the action. - - Attributes: - action (str): The type or name of action being requested (e.g., "Approve XYZ action") - args (dict): Key-value pairs of arguments needed for the action - """ - - action: str - args: dict - - -class HumanInterrupt(TypedDict): - """Represents an interrupt triggered by the graph that requires human intervention. - - This is passed to the `interrupt` function when execution is paused for human input. - - Attributes: - action_request (ActionRequest): The specific action being requested from the human - config (HumanInterruptConfig): Configuration defining what actions are allowed - description (Optional[str]): Optional detailed description of what input is needed - """ - - action_request: ActionRequest - config: HumanInterruptConfig - description: Optional[str] - - -class HumanResponse(TypedDict): - """The response provided by a human to an interrupt, which is returned when graph execution resumes. - - Attributes: - type (Literal['accept', 'ignore', 'response', 'edit']): The type of response: - - "accept": Approves the current state without changes - - "ignore": Skips/ignores the current step - - "response": Provides text feedback or instructions - - "edit": Modifies the current state/content - args (Union[None, str, ActionRequest]): The response payload: - - None: For ignore/accept actions - - str: For text responses - - ActionRequest: For edit actions with updated content - """ - - type: Literal["accept", "ignore", "response", "edit"] - args: Union[None, str, ActionRequest] From cfb121ee8f7fc2c83b1a3e63eb4c399e58841112 Mon Sep 17 00:00:00 2001 From: bracesproul Date: Fri, 10 Jan 2025 10:45:45 -0800 Subject: [PATCH 04/52] cr --- libs/langgraph/langgraph/types.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index 9a2c78acd..fa1adafe6 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -491,4 +491,3 @@ def interrupt(value: Any) -> Any: ), ) ) - From b52b32b38e4686820e31bffbea69503e7a2db64c Mon Sep 17 00:00:00 2001 From: bracesproul Date: Mon, 13 Jan 2025 13:14:36 -0800 Subject: [PATCH 05/52] format n lint --- libs/langgraph/langgraph/prebuilt/interrupt.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/libs/langgraph/langgraph/prebuilt/interrupt.py b/libs/langgraph/langgraph/prebuilt/interrupt.py index bf440f210..d8bf7e79d 100644 --- a/libs/langgraph/langgraph/prebuilt/interrupt.py +++ b/libs/langgraph/langgraph/prebuilt/interrupt.py @@ -1,3 +1,12 @@ +from typing import ( + Literal, + Optional, + Union, +) + +from typing_extensions import TypedDict + + class HumanInterruptConfig(TypedDict): """Configuration that defines what actions are allowed for a human interrupt. From 0d50c62283847c60fc2d6b8b74b173237a2594ae Mon Sep 17 00:00:00 2001 From: bracesproul Date: Tue, 14 Jan 2025 10:39:57 -0800 Subject: [PATCH 06/52] cr --- .../langgraph/langgraph/prebuilt/interrupt.py | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/libs/langgraph/langgraph/prebuilt/interrupt.py b/libs/langgraph/langgraph/prebuilt/interrupt.py index d8bf7e79d..2ab3ee0d1 100644 --- a/libs/langgraph/langgraph/prebuilt/interrupt.py +++ b/libs/langgraph/langgraph/prebuilt/interrupt.py @@ -13,10 +13,10 @@ class HumanInterruptConfig(TypedDict): This controls the available interaction options when the graph is paused for human input. Attributes: - allow_ignore (bool): Whether the human can choose to ignore/skip the current step - allow_respond (bool): Whether the human can provide a text response/feedback - allow_edit (bool): Whether the human can edit the provided content/state - allow_accept (bool): Whether the human can accept/approve the current state + allow_ignore: Whether the human can choose to ignore/skip the current step + allow_respond: Whether the human can provide a text response/feedback + allow_edit: Whether the human can edit the provided content/state + allow_accept: Whether the human can accept/approve the current state """ allow_ignore: bool @@ -31,8 +31,8 @@ class ActionRequest(TypedDict): Contains the action type and any associated arguments needed for the action. Attributes: - action (str): The type or name of action being requested (e.g., "Approve XYZ action") - args (dict): Key-value pairs of arguments needed for the action + action: The type or name of action being requested (e.g., "Approve XYZ action") + args: Key-value pairs of arguments needed for the action """ action: str @@ -45,9 +45,29 @@ class HumanInterrupt(TypedDict): This is passed to the `interrupt` function when execution is paused for human input. Attributes: - action_request (ActionRequest): The specific action being requested from the human - config (HumanInterruptConfig): Configuration defining what actions are allowed - description (Optional[str]): Optional detailed description of what input is needed + action_request: The specific action being requested from the human + config: Configuration defining what actions are allowed + description: Optional detailed description of what input is needed + + Example: + ```python + # Extract a tool call from the state and create an interrupt request + request = HumanInterrupt( + action_request=ActionRequest( + action="run_command", # The action being requested + args={"command": "ls", "args": ["-l"]} # Arguments for the action + ), + config=HumanInterruptConfig( + allow_ignore=True, # Allow skipping this step + allow_respond=True, # Allow text feedback + allow_edit=False, # Don't allow editing + allow_accept=True # Allow direct acceptance + ), + description="Please review the command before execution" + ) + # Send the interrupt request and get the response + response = interrupt([request])[0] + ``` """ action_request: ActionRequest @@ -59,12 +79,12 @@ class HumanResponse(TypedDict): """The response provided by a human to an interrupt, which is returned when graph execution resumes. Attributes: - type (Literal['accept', 'ignore', 'response', 'edit']): The type of response: + type: The type of response: - "accept": Approves the current state without changes - "ignore": Skips/ignores the current step - "response": Provides text feedback or instructions - "edit": Modifies the current state/content - args (Union[None, str, ActionRequest]): The response payload: + arg: The response payload: - None: For ignore/accept actions - str: For text responses - ActionRequest: For edit actions with updated content From 67a800dd988905dafe9af112114c4183ec1424e4 Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Tue, 14 Jan 2025 15:37:10 -0500 Subject: [PATCH 07/52] update --- libs/langgraph/langgraph/constants.py | 2 + libs/langgraph/langgraph/func/__init__.py | 23 +-------- libs/langgraph/langgraph/pregel/algo.py | 10 ++++ libs/langgraph/langgraph/utils/runnable.py | 59 +++++++++++++++++----- libs/langgraph/tests/test_pregel.py | 24 +++++++++ 5 files changed, 84 insertions(+), 34 deletions(-) diff --git a/libs/langgraph/langgraph/constants.py b/libs/langgraph/langgraph/constants.py index cd847f9be..caba214cb 100644 --- a/libs/langgraph/langgraph/constants.py +++ b/libs/langgraph/langgraph/constants.py @@ -81,6 +81,8 @@ CONFIG_KEY_WRITES = sys.intern("__pregel_writes") # read-only list of existing task writes CONFIG_KEY_SCRATCHPAD = sys.intern("__pregel_scratchpad") # holds a mutable dict for temporary storage scoped to the current task +CONFIG_KEY_END = sys.intern("__pregel_previous") +# holds the previous return value from a stateful Pregel graph. # --- Other constants --- PUSH = sys.intern("__pregel_push") diff --git a/libs/langgraph/langgraph/func/__init__.py b/libs/langgraph/langgraph/func/__init__.py index 62bf138e6..76e97d7e5 100644 --- a/libs/langgraph/langgraph/func/__init__.py +++ b/libs/langgraph/langgraph/func/__init__.py @@ -112,27 +112,8 @@ def entrypoint( store: Optional[BaseStore] = None, ) -> Callable[[types.FunctionType], Pregel]: def _imp(func: types.FunctionType) -> Pregel: - if inspect.isgeneratorfunction(func): - - def gen_wrapper(*args: Any, writer: StreamWriter, **kwargs: Any) -> Any: - for chunk in func(*args, **kwargs): - writer(chunk) - - bound = get_runnable_for_func(gen_wrapper) - stream_mode: StreamMode = "custom" - elif inspect.isasyncgenfunction(func): - - async def agen_wrapper( - *args: Any, writer: StreamWriter, **kwargs: Any - ) -> Any: - async for chunk in func(*args, **kwargs): - writer(chunk) - - bound = get_runnable_for_func(agen_wrapper) - stream_mode = "custom" - else: - bound = get_runnable_for_func(func) - stream_mode = "updates" + bound = get_runnable_for_func(func) + stream_mode: StreamMode = "updates" return Pregel( nodes={ diff --git a/libs/langgraph/langgraph/pregel/algo.py b/libs/langgraph/langgraph/pregel/algo.py index 3adea073a..f532b7bc8 100644 --- a/libs/langgraph/langgraph/pregel/algo.py +++ b/libs/langgraph/langgraph/pregel/algo.py @@ -42,6 +42,7 @@ from langgraph.constants import ( CONFIG_KEY_STORE, CONFIG_KEY_TASK_ID, CONFIG_KEY_WRITES, + CONFIG_KEY_END, EMPTY_SEQ, ERROR, INTERRUPT, @@ -560,6 +561,9 @@ def prepare_single_task( if w[0] in (NULL_TASK_ID, task_id) ], CONFIG_KEY_SCRATCHPAD: {}, + CONFIG_KEY_END: checkpoint["channel_values"].get( + "__end__", None + ), }, ), triggers, @@ -709,6 +713,9 @@ def prepare_single_task( if w[0] in (NULL_TASK_ID, task_id) ], CONFIG_KEY_SCRATCHPAD: {}, + CONFIG_KEY_END: checkpoint["channel_values"].get( + "__end__", None + ), }, ), triggers, @@ -833,6 +840,9 @@ def prepare_single_task( if w[0] in (NULL_TASK_ID, task_id) ], CONFIG_KEY_SCRATCHPAD: {}, + CONFIG_KEY_END: checkpoint["channel_values"].get( + "__end__", None + ), }, ), triggers, diff --git a/libs/langgraph/langgraph/utils/runnable.py b/libs/langgraph/langgraph/utils/runnable.py index 7cd6a85b9..e5109443f 100644 --- a/libs/langgraph/langgraph/utils/runnable.py +++ b/libs/langgraph/langgraph/utils/runnable.py @@ -34,7 +34,12 @@ from langchain_core.runnables.utils import Input from langchain_core.tracers._streaming import _StreamingCallbackHandler from typing_extensions import TypeGuard -from langgraph.constants import CONF, CONFIG_KEY_STORE, CONFIG_KEY_STREAM_WRITER +from langgraph.constants import ( + CONF, + CONFIG_KEY_STORE, + CONFIG_KEY_END, + CONFIG_KEY_STREAM_WRITER, +) from langgraph.store.base import BaseStore from langgraph.types import StreamWriter from langgraph.utils.config import ( @@ -58,6 +63,10 @@ class StrEnum(str, enum.Enum): """A string enum.""" +# Special type to denote any type is accepted +ANY_TYPE = object() + + ASYNCIO_ACCEPTS_CONTEXT = sys.version_info >= (3, 11) KWARGS_CONFIG_KEYS: tuple[tuple[str, tuple[Any, ...], str, Any], ...] = ( @@ -73,9 +82,22 @@ KWARGS_CONFIG_KEYS: tuple[tuple[str, tuple[Any, ...], str, Any], ...] = ( CONFIG_KEY_STORE, inspect.Parameter.empty, ), + ( + sys.intern("previous"), + (ANY_TYPE, ), + CONFIG_KEY_END, + inspect.Parameter.empty, + ), ) """List of kwargs that can be passed to functions, and their corresponding -config keys, default values and type annotations.""" +config keys, default values and type annotations. + +Each tuple contains: +- the name of the kwarg in the function signature +- the type annotation(s) for the kwarg +- the config key to look for the value in +- the default value for the kwarg +""" VALID_KINDS = (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) @@ -122,9 +144,12 @@ class RunnableCallable(Runnable): self.func_accepts: dict[str, bool] = {} for kw, typ, _, _ in KWARGS_CONFIG_KEYS: p = params.get(kw) - self.func_accepts[kw] = ( - p is not None and p.annotation in typ and p.kind in VALID_KINDS - ) + if typ == (ANY_TYPE, ): + self.func_accepts[kw] = p is not None and p.kind in VALID_KINDS + else: + self.func_accepts[kw] = ( + p is not None and p.annotation in typ and p.kind in VALID_KINDS + ) def __repr__(self) -> str: repr_args = { @@ -149,16 +174,20 @@ class RunnableCallable(Runnable): if self.func_accepts_config: kwargs["config"] = config _conf = config[CONF] - for kw, _, ck, defv in KWARGS_CONFIG_KEYS: + for kw, _, config_key, default_value in KWARGS_CONFIG_KEYS: if not self.func_accepts[kw]: continue - if defv is inspect.Parameter.empty and kw not in kwargs and ck not in _conf: + if ( + default_value is inspect.Parameter.empty + and kw not in kwargs + and config_key not in _conf + ): raise ValueError( - f"Missing required config key '{ck}' for '{self.name}'." + f"Missing required config key '{config_key}' for '{self.name}'." ) elif kwargs.get(kw) is None: - kwargs[kw] = _conf.get(ck, defv) + kwargs[kw] = _conf.get(config_key, default_value) context = copy_context() if self.trace: @@ -197,16 +226,20 @@ class RunnableCallable(Runnable): if self.func_accepts_config: kwargs["config"] = config _conf = config[CONF] - for kw, _, ck, defv in KWARGS_CONFIG_KEYS: + for kw, _, config_key, default_value in KWARGS_CONFIG_KEYS: if not self.func_accepts[kw]: continue - if defv is inspect.Parameter.empty and kw not in kwargs and ck not in _conf: + if ( + default_value is inspect.Parameter.empty + and kw not in kwargs + and config_key not in _conf + ): raise ValueError( - f"Missing required config key '{ck}' for '{self.name}'." + f"Missing required config key '{config_key}' for '{self.name}'." ) elif kwargs.get(kw) is None: - kwargs[kw] = _conf.get(ck, defv) + kwargs[kw] = _conf.get(config_key, default_value) context = copy_context() if self.trace: callback_manager = get_async_callback_manager_for_config(config, self.tags) diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index d1f1c66eb..00568da18 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -5300,3 +5300,27 @@ def test_multiple_updates() -> None: {"node_a": [{"foo": "a1"}, {"foo": "a2"}]}, {"node_b": {"foo": "b"}}, ] + + +def test_version_1_of_entrypoint() -> None: + from langgraph.func import entrypoint + from typing import TypedDict, Annotated, NotRequired, Any + + states = [] + + # In this version reducers do not work + @entrypoint(checkpointer=MemorySaver()) + def foo(inputs, *, previous: Any) -> Any: + states.append(previous) + return {"previous": previous, "current": inputs} + + config = {"configurable": {"thread_id": "1"}} + + foo.invoke({"a": "1"}, config) + foo.invoke({"a": "2"}, config) + foo.invoke({"a": "3"}, config) + assert states == [ + None, + {"current": {"a": "1"}, "previous": None}, + {"current": {"a": "2"}, "previous": {"current": {"a": "1"}, "previous": None}}, + ] From 311fe3970ca7c124f076df376c39860b9c5075a6 Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Tue, 14 Jan 2025 15:39:57 -0500 Subject: [PATCH 08/52] x --- libs/langgraph/langgraph/func/__init__.py | 3 +-- libs/langgraph/langgraph/pregel/algo.py | 2 +- libs/langgraph/langgraph/utils/runnable.py | 6 +++--- libs/langgraph/tests/test_pregel.py | 1 - 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/libs/langgraph/langgraph/func/__init__.py b/libs/langgraph/langgraph/func/__init__.py index 76e97d7e5..86c5403f9 100644 --- a/libs/langgraph/langgraph/func/__init__.py +++ b/libs/langgraph/langgraph/func/__init__.py @@ -2,7 +2,6 @@ import asyncio import concurrent import concurrent.futures import functools -import inspect import types from typing import ( Any, @@ -25,7 +24,7 @@ from langgraph.pregel.call import get_runnable_for_func from langgraph.pregel.read import PregelNode from langgraph.pregel.write import ChannelWrite, ChannelWriteEntry from langgraph.store.base import BaseStore -from langgraph.types import RetryPolicy, StreamMode, StreamWriter +from langgraph.types import RetryPolicy, StreamMode P = ParamSpec("P") P1 = TypeVar("P1") diff --git a/libs/langgraph/langgraph/pregel/algo.py b/libs/langgraph/langgraph/pregel/algo.py index f532b7bc8..0d2553240 100644 --- a/libs/langgraph/langgraph/pregel/algo.py +++ b/libs/langgraph/langgraph/pregel/algo.py @@ -36,13 +36,13 @@ from langgraph.constants import ( CONFIG_KEY_CHECKPOINT_MAP, CONFIG_KEY_CHECKPOINT_NS, CONFIG_KEY_CHECKPOINTER, + CONFIG_KEY_END, CONFIG_KEY_READ, CONFIG_KEY_SCRATCHPAD, CONFIG_KEY_SEND, CONFIG_KEY_STORE, CONFIG_KEY_TASK_ID, CONFIG_KEY_WRITES, - CONFIG_KEY_END, EMPTY_SEQ, ERROR, INTERRUPT, diff --git a/libs/langgraph/langgraph/utils/runnable.py b/libs/langgraph/langgraph/utils/runnable.py index e5109443f..d5e6cf1d2 100644 --- a/libs/langgraph/langgraph/utils/runnable.py +++ b/libs/langgraph/langgraph/utils/runnable.py @@ -36,8 +36,8 @@ from typing_extensions import TypeGuard from langgraph.constants import ( CONF, - CONFIG_KEY_STORE, CONFIG_KEY_END, + CONFIG_KEY_STORE, CONFIG_KEY_STREAM_WRITER, ) from langgraph.store.base import BaseStore @@ -84,7 +84,7 @@ KWARGS_CONFIG_KEYS: tuple[tuple[str, tuple[Any, ...], str, Any], ...] = ( ), ( sys.intern("previous"), - (ANY_TYPE, ), + (ANY_TYPE,), CONFIG_KEY_END, inspect.Parameter.empty, ), @@ -144,7 +144,7 @@ class RunnableCallable(Runnable): self.func_accepts: dict[str, bool] = {} for kw, typ, _, _ in KWARGS_CONFIG_KEYS: p = params.get(kw) - if typ == (ANY_TYPE, ): + if typ == (ANY_TYPE,): self.func_accepts[kw] = p is not None and p.kind in VALID_KINDS else: self.func_accepts[kw] = ( diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 00568da18..8cf2e6b4f 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -5304,7 +5304,6 @@ def test_multiple_updates() -> None: def test_version_1_of_entrypoint() -> None: from langgraph.func import entrypoint - from typing import TypedDict, Annotated, NotRequired, Any states = [] From 7603809a9fcdd2782fb93e314e1d336f6e97dd96 Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Tue, 14 Jan 2025 18:06:09 -0500 Subject: [PATCH 09/52] x --- libs/langgraph/langgraph/func/__init__.py | 10 +++- libs/langgraph/tests/test_pregel.py | 72 +++++++++++++++++++++-- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/libs/langgraph/langgraph/func/__init__.py b/libs/langgraph/langgraph/func/__init__.py index 86c5403f9..c7b4d1bb4 100644 --- a/libs/langgraph/langgraph/func/__init__.py +++ b/libs/langgraph/langgraph/func/__init__.py @@ -2,6 +2,7 @@ import asyncio import concurrent import concurrent.futures import functools +import inspect import types from typing import ( Any, @@ -111,8 +112,13 @@ def entrypoint( store: Optional[BaseStore] = None, ) -> Callable[[types.FunctionType], Pregel]: def _imp(func: types.FunctionType) -> Pregel: - bound = get_runnable_for_func(func) - stream_mode: StreamMode = "updates" + if inspect.isgeneratorfunction(func): + raise TypeError("@entrypoint does not support generator functions.") + elif inspect.isasyncgenfunction(func): + raise TypeError("@entrypoint does not support async generator functions.") + else: + bound = get_runnable_for_func(func) + stream_mode: StreamMode = "updates" return Pregel( nodes={ diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 8cf2e6b4f..432eea069 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -15,6 +15,7 @@ from typing import ( Any, Dict, Generator, + Iterable, Iterator, List, Literal, @@ -5302,9 +5303,32 @@ def test_multiple_updates() -> None: ] -def test_version_1_of_entrypoint() -> None: - from langgraph.func import entrypoint +def test_entrypoint_without_checkpointer() -> None: + """Test no checkpointer.""" + states = [] + config = {"configurable": {"thread_id": "1"}} + # Test without previous + @entrypoint() + def foo(inputs: Any) -> Any: + states.append(inputs) + return inputs + + assert foo.invoke({"a": "1"}, config) == {"a": "1"} + + @entrypoint() + def foo(inputs: Any, *, previous: Any) -> Any: + states.append(previous) + return {"previous": previous, "current": inputs} + + assert foo.invoke({"a": "1"}, config) == {"current": {"a": "1"}, "previous": None} + assert foo.invoke({"a": "1"}, config) == {"current": {"a": "1"}, "previous": None} + + +def test_entrypoint_stateful() -> None: + """Test stateful entrypoint invoke.""" + + # Test invoke states = [] # In this version reducers do not work @@ -5315,11 +5339,49 @@ def test_version_1_of_entrypoint() -> None: config = {"configurable": {"thread_id": "1"}} - foo.invoke({"a": "1"}, config) - foo.invoke({"a": "2"}, config) - foo.invoke({"a": "3"}, config) + assert foo.invoke({"a": "1"}, config) == {"current": {"a": "1"}, "previous": None} + assert foo.invoke({"a": "2"}, config) == { + "current": {"a": "2"}, + "previous": {"current": {"a": "1"}, "previous": None}, + } + assert foo.invoke({"a": "3"}, config) == { + "current": {"a": "3"}, + "previous": { + "current": {"a": "2"}, + "previous": {"current": {"a": "1"}, "previous": None}, + }, + } assert states == [ None, {"current": {"a": "1"}, "previous": None}, {"current": {"a": "2"}, "previous": {"current": {"a": "1"}, "previous": None}}, ] + + # Test stream + @entrypoint(checkpointer=MemorySaver()) + def foo(inputs, *, previous: Any) -> Any: + return {"previous": previous, "current": inputs} + + config = {"configurable": {"thread_id": "1"}} + items = [item for item in foo.stream({"a": "1"}, config)] + assert items == [{"foo": {"current": {"a": "1"}, "previous": None}}] + + +async def test_entrypoint_from_generator() -> None: + """@entrypoint does not support sync generators.""" + + with pytest.raises(TypeError): + + @entrypoint(checkpointer=MemorySaver()) + def foo(inputs: Any) -> Iterable[dict]: + yield "a" + + +async def test_entrypoint_from_async_generator() -> None: + """@entrypoint does not support async generators.""" + + with pytest.raises(TypeError): + + @entrypoint(checkpointer=MemorySaver()) + def foo(inputs: Any) -> Iterable[dict]: + yield "a" From 5805e5709af8b46328b5c60fb1d10b146b849cb3 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Tue, 14 Jan 2025 18:16:06 -0800 Subject: [PATCH 10/52] Fix ignored goto when a mixed list of command and state updates is returned from a node --- libs/langgraph/langgraph/graph/state.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/libs/langgraph/langgraph/graph/state.py b/libs/langgraph/langgraph/graph/state.py index 3fbb31229..16bbf7d48 100644 --- a/libs/langgraph/langgraph/graph/state.py +++ b/libs/langgraph/langgraph/graph/state.py @@ -847,14 +847,10 @@ def _control_branch(value: Any) -> Sequence[Union[str, Send]]: commands: list[Command] = [] if isinstance(value, Command): commands.append(value) - elif ( - isinstance(value, (list, tuple)) - and value - and all(isinstance(i, Command) for i in value) - ): - commands.extend(value) - else: - return EMPTY_SEQ + elif isinstance(value, (list, tuple)): + for cmd in value: + if isinstance(cmd, Command): + commands.append(cmd) rtn: list[Union[str, Send]] = [] for command in commands: if command.graph == Command.PARENT: @@ -874,14 +870,10 @@ async def _acontrol_branch(value: Any) -> Sequence[Union[str, Send]]: commands: list[Command] = [] if isinstance(value, Command): commands.append(value) - elif ( - isinstance(value, (list, tuple)) - and value - and all(isinstance(i, Command) for i in value) - ): - commands.extend(value) - else: - return EMPTY_SEQ + elif isinstance(value, (list, tuple)): + for cmd in value: + if isinstance(cmd, Command): + commands.append(cmd) rtn: list[Union[str, Send]] = [] for command in commands: if command.graph == Command.PARENT: From b6fe3937fcb48477200a0dcb1f2d25e37a2adf52 Mon Sep 17 00:00:00 2001 From: Andrew Nguonly Date: Wed, 15 Jan 2025 12:27:53 -0800 Subject: [PATCH 11/52] docs: Add section about Persistence to Cloud SaaS concepts page (#3051) --- docs/docs/concepts/langgraph_cloud.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/docs/concepts/langgraph_cloud.md b/docs/docs/concepts/langgraph_cloud.md index 169953b8b..6d32d74a1 100644 --- a/docs/docs/concepts/langgraph_cloud.md +++ b/docs/docs/concepts/langgraph_cloud.md @@ -6,21 +6,29 @@ ## Overview -LangGraph's Cloud SaaS is a managed service for deploying LangGraph APIs, regardless of its definition or dependencies. The service offers managed implementations of checkpointers and stores, allowing you to focus on building the right cognitive architecture for your use case. By handling scalable & secure infrastructure, LangGraph Cloud offers the fastest path to getting your LangGraph API deployed to production. +LangGraph's Cloud SaaS is a managed service for deploying LangGraph Servers, regardless of its definition or dependencies. The service offers managed implementations of checkpointers and stores, allowing you to focus on building the right cognitive architecture for your use case. By handling scalable & secure infrastructure, LangGraph Cloud SaaS offers the fastest path to getting your LangGraph Server deployed to production. ## Deployment -A **deployment** is an instance of a LangGraph API. A single deployment can have many [revisions](#revision). When a deployment is created, all the necessary infrastructure (e.g. database, containers, secrets store) are automatically provisioned. See the [architecture diagram](#architecture) below for more details. +A **deployment** is an instance of a LangGraph Server. A single deployment can have many [revisions](#revision). When a deployment is created, all the necessary infrastructure (e.g. database, containers, secrets store) are automatically provisioned. See the [architecture diagram](#architecture) below for more details. -See the [how-to guide](../cloud/deployment/cloud.md#create-new-deployment) for creating a new deployment. - -## Resource Allocation +Resource Allocation: | **Deployment Type** | **CPU** | **Memory** | **Scaling** | |---------------------|---------|------------|---------------------| | Development | 1 CPU | 1 GB | Up to 1 container | | Production | 2 CPU | 2 GB | Up to 10 containers | +See the [how-to guide](../cloud/deployment/cloud.md#create-new-deployment) for creating a new deployment. + +## Persistence + +A dedicated database is automatically created for each deployment. The database serves as the [persistence layer](../concepts/persistence.md) for the deployment. + +When defining a graph to be deployed to LangGraph Cloud SaaS, a [checkpointer](../concepts/persistence.md#checkpointer-libraries) should not be configured by the user. Instead, a checkpointer is automatically configured for the graph. + +There is no direct access to the database. All access to the database occurs through the LangGraph Server APIs. + ## Autoscaling `Production` type deployments automatically scale up to 10 containers. Scaling is based on the current request load for a single container. Specifically, the autoscaling implementation scales the deployment so that each container is processing about 10 concurrent requests. For example... From 3626478029cd61155560fdacb65d55f646179b5d Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 15 Jan 2025 13:31:49 -0800 Subject: [PATCH 12/52] Fix unexpected re-use of null resume value by subgraphs - Also stop exposing writes in config, in favor of scratchpad --- libs/langgraph/langgraph/constants.py | 4 +- libs/langgraph/langgraph/pregel/algo.py | 55 +++++---- libs/langgraph/langgraph/pregel/loop.py | 10 ++ libs/langgraph/langgraph/pregel/runner.py | 22 ++-- libs/langgraph/langgraph/types.py | 46 +++---- libs/langgraph/tests/test_pregel.py | 130 ++++++++++++++++++-- libs/langgraph/tests/test_pregel_async.py | 139 ++++++++++++++++++++-- 7 files changed, 321 insertions(+), 85 deletions(-) diff --git a/libs/langgraph/langgraph/constants.py b/libs/langgraph/langgraph/constants.py index cb6834f5f..85c4a890c 100644 --- a/libs/langgraph/langgraph/constants.py +++ b/libs/langgraph/langgraph/constants.py @@ -75,9 +75,7 @@ CONFIG_KEY_CHECKPOINT_ID = sys.intern("checkpoint_id") 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 the value that "answers" an interrupt() call -CONFIG_KEY_WRITES = sys.intern("__pregel_writes") -# read-only list of existing task writes +# holds a callback to be called when a node is finished CONFIG_KEY_SCRATCHPAD = sys.intern("__pregel_scratchpad") # holds a mutable dict for temporary storage scoped to the current task diff --git a/libs/langgraph/langgraph/pregel/algo.py b/libs/langgraph/langgraph/pregel/algo.py index 12a53b8ad..c46138550 100644 --- a/libs/langgraph/langgraph/pregel/algo.py +++ b/libs/langgraph/langgraph/pregel/algo.py @@ -42,10 +42,10 @@ from langgraph.constants import ( CONFIG_KEY_SEND, CONFIG_KEY_STORE, CONFIG_KEY_TASK_ID, - CONFIG_KEY_WRITES, EMPTY_SEQ, ERROR, INTERRUPT, + MISSING, NO_WRITES, NS_END, NS_SEP, @@ -71,6 +71,7 @@ from langgraph.types import ( All, LoopProtocol, PregelExecutableTask, + PregelScratchpad, PregelTask, RetryPolicy, ) @@ -502,13 +503,10 @@ def prepare_single_task( }, CONFIG_KEY_CHECKPOINT_ID: None, CONFIG_KEY_CHECKPOINT_NS: task_checkpoint_ns, - CONFIG_KEY_WRITES: [ - w - for w in pending_writes - + configurable.get(CONFIG_KEY_WRITES, []) - if w[0] in (NULL_TASK_ID, task_id) - ], - CONFIG_KEY_SCRATCHPAD: {}, + CONFIG_KEY_SCRATCHPAD: _scratchpad( + pending_writes, + task_id, + ), }, ), triggers, @@ -614,13 +612,10 @@ def prepare_single_task( }, CONFIG_KEY_CHECKPOINT_ID: None, CONFIG_KEY_CHECKPOINT_NS: task_checkpoint_ns, - CONFIG_KEY_WRITES: [ - w - for w in pending_writes - + configurable.get(CONFIG_KEY_WRITES, []) - if w[0] in (NULL_TASK_ID, task_id) - ], - CONFIG_KEY_SCRATCHPAD: {}, + CONFIG_KEY_SCRATCHPAD: _scratchpad( + pending_writes, + task_id, + ), }, ), triggers, @@ -738,13 +733,10 @@ def prepare_single_task( }, CONFIG_KEY_CHECKPOINT_ID: None, CONFIG_KEY_CHECKPOINT_NS: task_checkpoint_ns, - CONFIG_KEY_WRITES: [ - w - for w in pending_writes - + configurable.get(CONFIG_KEY_WRITES, []) - if w[0] in (NULL_TASK_ID, task_id) - ], - CONFIG_KEY_SCRATCHPAD: {}, + CONFIG_KEY_SCRATCHPAD: _scratchpad( + pending_writes, + task_id, + ), }, ), triggers, @@ -758,6 +750,25 @@ def prepare_single_task( return PregelTask(task_id, name, task_path[:3]) +def _scratchpad( + pending_writes: Sequence[PendingWrite], + task_id: str, +) -> PregelScratchpad: + return PregelScratchpad( + # call + call_counter=0, + # interrupt + interrupt_counter=-1, + resume=next( + (w[2] for w in pending_writes if w[0] == task_id and w[1] == RESUME), [] + ), + null_resume=next( + (w[2] for w in pending_writes if w[0] == NULL_TASK_ID and w[1] == RESUME), + MISSING, + ), + ) + + def _proc_input( proc: PregelNode, managed: ManagedValueMapping, diff --git a/libs/langgraph/langgraph/pregel/loop.py b/libs/langgraph/langgraph/pregel/loop.py index 5fd9b5d96..9a745a2e7 100644 --- a/libs/langgraph/langgraph/pregel/loop.py +++ b/libs/langgraph/langgraph/pregel/loop.py @@ -47,12 +47,14 @@ from langgraph.constants import ( CONFIG_KEY_DELEGATE, CONFIG_KEY_ENSURE_LATEST, CONFIG_KEY_RESUMING, + CONFIG_KEY_SCRATCHPAD, CONFIG_KEY_STREAM, CONFIG_KEY_TASK_ID, EMPTY_SEQ, ERROR, INPUT, INTERRUPT, + MISSING, NS_SEP, NULL_TASK_ID, PUSH, @@ -556,8 +558,16 @@ class PregelLoop(LoopProtocol): ) ) + # take resume value from parent + if scratchpad := configurable.get(CONFIG_KEY_SCRATCHPAD): + if scratchpad["null_resume"] is not MISSING: + self.put_writes(NULL_TASK_ID, [(RESUME, scratchpad["null_resume"])]) # map command to writes if isinstance(self.input, Command): + if self.input.resume is not None and not self.checkpointer: + raise RuntimeError( + "Cannot use Command(resume=...) without checkpointer" + ) writes: defaultdict[str, list[tuple[str, Any]]] = defaultdict(list) # group writes by task ID for tid, c, v in map_command(self.input, self.checkpoint_pending_writes): diff --git a/libs/langgraph/langgraph/pregel/runner.py b/libs/langgraph/langgraph/pregel/runner.py index 790d22a06..d354c7866 100644 --- a/libs/langgraph/langgraph/pregel/runner.py +++ b/libs/langgraph/langgraph/pregel/runner.py @@ -107,12 +107,11 @@ class PregelRunner: elif next_task.writes: # if it already ran, return the result fut = concurrent.futures.Future() - if ( - val := next( - (v for c, v in next_task.writes if c == RETURN), MISSING - ) - ) and val is not MISSING: - fut.set_result(val) + ret = next( + (v for c, v in next_task.writes if c == RETURN), MISSING + ) + if ret is not MISSING: + fut.set_result(ret) elif exc := next( (v for c, v in next_task.writes if c == ERROR), None ): @@ -295,12 +294,11 @@ class PregelRunner: elif next_task.writes: # if it already ran, return the result fut = asyncio.Future() - if ( - val := next( - (v for c, v in next_task.writes if c == RETURN), MISSING - ) - ) and val is not MISSING: - fut.set_result(val) + ret = next( + (v for c, v in next_task.writes if c == RETURN), MISSING + ) + if ret is not MISSING: + fut.set_result(ret) elif exc := next( (v for c, v in next_task.writes if c == ERROR), None ): diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index 0b9fb9b1b..9a94ad85a 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -21,11 +21,7 @@ from typing import ( from langchain_core.runnables import Runnable, RunnableConfig from typing_extensions import Self, TypedDict -from langgraph.checkpoint.base import ( - BaseCheckpointSaver, - CheckpointMetadata, - PendingWrite, -) +from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointMetadata if TYPE_CHECKING: from langgraph.store.base import BaseStore @@ -341,13 +337,13 @@ class LoopProtocol: self.stop = stop -class PregelScratchpad(TypedDict, total=False): - # interrupt - interrupt_counter: int - used_null_resume: bool - resume: list[Any] +class PregelScratchpad(TypedDict): # call call_counter: int + # interrupt + interrupt_counter: int + resume: list[Any] + null_resume: Any def interrupt(value: Any) -> Any: @@ -449,10 +445,8 @@ def interrupt(value: Any) -> Any: CONFIG_KEY_CHECKPOINT_NS, CONFIG_KEY_SCRATCHPAD, CONFIG_KEY_SEND, - CONFIG_KEY_TASK_ID, - CONFIG_KEY_WRITES, + MISSING, NS_SEP, - NULL_TASK_ID, RESUME, ) from langgraph.errors import GraphInterrupt @@ -461,29 +455,21 @@ def interrupt(value: Any) -> Any: conf = get_config()["configurable"] # track interrupt index scratchpad: PregelScratchpad = conf[CONFIG_KEY_SCRATCHPAD] - if "interrupt_counter" not in scratchpad: - scratchpad["interrupt_counter"] = 0 - else: - scratchpad["interrupt_counter"] += 1 + print("interrupt", scratchpad) + scratchpad["interrupt_counter"] += 1 idx = scratchpad["interrupt_counter"] # find previous resume values - task_id = conf[CONFIG_KEY_TASK_ID] - writes: list[PendingWrite] = conf[CONFIG_KEY_WRITES] - scratchpad.setdefault( - "resume", next((w[2] for w in writes if w[0] == task_id and w[1] == RESUME), []) - ) if scratchpad["resume"]: if idx < len(scratchpad["resume"]): return scratchpad["resume"][idx] # find current resume value - if not scratchpad.get("used_null_resume"): - scratchpad["used_null_resume"] = True - for tid, c, v in sorted(writes, key=lambda x: x[0], reverse=True): - if tid == NULL_TASK_ID and c == RESUME: - assert len(scratchpad["resume"]) == idx, (scratchpad["resume"], idx) - scratchpad["resume"].append(v) - conf[CONFIG_KEY_SEND]([(RESUME, scratchpad["resume"])]) - return v + if scratchpad["null_resume"] is not MISSING: + assert len(scratchpad["resume"]) == idx, (scratchpad["resume"], idx) + v = scratchpad["null_resume"] + scratchpad["null_resume"] = MISSING + scratchpad["resume"].append(v) + conf[CONFIG_KEY_SEND]([(RESUME, scratchpad["resume"])]) + return v # no resume value found raise GraphInterrupt( ( diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 12b7c6863..fe1a603da 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -5262,9 +5262,10 @@ def test_multiple_updates() -> None: ] -def test_falsy_return_from_task() -> None: +@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC) +def test_falsy_return_from_task(request: pytest.FixtureRequest, checkpointer_name: str): """Test with a falsy return from a task.""" - checkpointer = MemorySaver() + checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}") @task def falsy_task() -> bool: @@ -5276,17 +5277,18 @@ def test_falsy_return_from_task() -> None: falsy_task().result() interrupt("test") - configurable = {"configurable": {"thread_id": uuid.uuid4()}} + configurable = {"configurable": {"thread_id": str(uuid.uuid4())}} graph.invoke({"a": 5}, configurable) graph.invoke(Command(resume="123"), configurable) -def test_multiple_interrupts_imperative() -> None: +@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC) +def test_multiple_interrupts_imperative( + request: pytest.FixtureRequest, checkpointer_name: str +): """Test multiple interrupts with an imperative API.""" - from langgraph.checkpoint.memory import MemorySaver - from langgraph.func import entrypoint, task + checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}") - checkpointer = MemorySaver() counter = 0 @task @@ -5307,7 +5309,7 @@ def test_multiple_interrupts_imperative() -> None: return {"values": values} - configurable = {"configurable": {"thread_id": uuid.uuid4()}} + configurable = {"configurable": {"thread_id": str(uuid.uuid4())}} graph.invoke({}, configurable) graph.invoke(Command(resume="a"), configurable) graph.invoke(Command(resume="b"), configurable) @@ -5317,3 +5319,115 @@ def test_multiple_interrupts_imperative() -> None: "values": [2, "a", 4, "b", 6, "c"], } assert counter == 3 + + +@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC) +def test_double_interrupt_subgraph( + request: pytest.FixtureRequest, checkpointer_name: str +) -> None: + checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}") + + class AgentState(TypedDict): + input: str + + def node_1(state: AgentState): + result = interrupt("interrupt node 1") + return {"input": result} + + def node_2(state: AgentState): + result = interrupt("interrupt node 2") + return {"input": result} + + subgraph_builder = ( + StateGraph(AgentState) + .add_node("node_1", node_1) + .add_node("node_2", node_2) + .add_edge(START, "node_1") + .add_edge("node_1", "node_2") + .add_edge("node_2", END) + ) + + # invoke the sub graph + subgraph = subgraph_builder.compile(checkpointer=checkpointer) + thread = {"configurable": {"thread_id": str(uuid.uuid4())}} + assert [c for c in subgraph.stream({"input": "test"}, thread)] == [ + { + "__interrupt__": ( + Interrupt( + value="interrupt node 1", + resumable=True, + ns=[AnyStr("node_1:")], + when="during", + ), + ) + }, + ] + # resume from the first interrupt + assert [c for c in subgraph.stream(Command(resume="123"), thread)] == [ + { + "node_1": {"input": "123"}, + }, + { + "__interrupt__": ( + Interrupt( + value="interrupt node 2", + resumable=True, + ns=[AnyStr("node_2:")], + when="during", + ), + ) + }, + ] + # resume from the second interrupt + assert [c for c in subgraph.stream(Command(resume="123"), thread)] == [ + { + "node_2": {"input": "123"}, + }, + ] + + subgraph = subgraph_builder.compile() + + def invoke_sub_agent(state: AgentState): + return subgraph.invoke(state) + + parent_agent = ( + StateGraph(AgentState) + .add_node("invoke_sub_agent", invoke_sub_agent) + .add_edge(START, "invoke_sub_agent") + .add_edge("invoke_sub_agent", END) + .compile(checkpointer=checkpointer) + ) + + assert [c for c in parent_agent.stream({"input": "test"}, thread)] == [ + { + "__interrupt__": ( + Interrupt( + value="interrupt node 1", + resumable=True, + ns=[AnyStr("invoke_sub_agent:"), AnyStr("node_1:")], + when="during", + ), + ) + }, + ] + + # resume from the first interrupt + assert [c for c in parent_agent.stream(Command(resume=True), thread)] == [ + { + "__interrupt__": ( + Interrupt( + value="interrupt node 2", + resumable=True, + ns=[AnyStr("invoke_sub_agent:"), AnyStr("node_2:")], + when="during", + ), + ) + } + ] + + # resume from 2nd interrupt + assert [c for c in parent_agent.stream(Command(resume=True), thread)] == [ + { + "invoke_sub_agent": {"input": True}, + }, + ] diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index c05682f7e..2f7d7a1c4 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -6696,23 +6696,25 @@ async def test_multiple_updates() -> None: sys.version_info < (3, 11), reason="Python 3.11+ is required for async contextvars support", ) -async def test_falsy_return_from_task() -> None: +@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) +async def test_falsy_return_from_task(checkpointer_name: str) -> None: """Test with a falsy return from a task.""" - checkpointer = MemorySaver() @task async def falsy_task() -> bool: return False - @entrypoint(checkpointer=checkpointer) - async def graph(state: dict) -> dict: - """React tool.""" - await falsy_task() - interrupt("test") + async with awith_checkpointer(checkpointer_name) as checkpointer: - configurable = {"configurable": {"thread_id": uuid.uuid4()}} - await graph.ainvoke({"a": 5}, configurable) - await graph.ainvoke(Command(resume="123"), configurable) + @entrypoint(checkpointer=checkpointer) + async def graph(state: dict) -> dict: + """React tool.""" + await falsy_task() + interrupt("test") + + configurable = {"configurable": {"thread_id": str(uuid.uuid4())}} + await graph.ainvoke({"a": 5}, configurable) + await graph.ainvoke(Command(resume="123"), configurable) @pytest.mark.skipif( @@ -6756,3 +6758,120 @@ async def test_multiple_interrupts_imperative(checkpointer_name: str) -> None: "values": [2, "a", 4, "b", 6, "c"], } assert counter == 3 + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Python 3.11+ is required for async contextvars support", +) +@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) +async def test_double_interrupt_subgraph(checkpointer_name: str) -> None: + class AgentState(TypedDict): + input: str + + def node_1(state: AgentState): + result = interrupt("interrupt node 1") + return {"input": result} + + def node_2(state: AgentState): + result = interrupt("interrupt node 2") + return {"input": result} + + subgraph_builder = ( + StateGraph(AgentState) + .add_node("node_1", node_1) + .add_node("node_2", node_2) + .add_edge(START, "node_1") + .add_edge("node_1", "node_2") + .add_edge("node_2", END) + ) + + async with awith_checkpointer(checkpointer_name) as checkpointer: + # invoke the sub graph + subgraph = subgraph_builder.compile(checkpointer=checkpointer) + thread = {"configurable": {"thread_id": str(uuid.uuid4())}} + assert [c async for c in subgraph.astream({"input": "test"}, thread)] == [ + { + "__interrupt__": ( + Interrupt( + value="interrupt node 1", + resumable=True, + ns=[AnyStr("node_1:")], + when="during", + ), + ) + }, + ] + # resume from the first interrupt + assert [c async for c in subgraph.astream(Command(resume="123"), thread)] == [ + { + "node_1": {"input": "123"}, + }, + { + "__interrupt__": ( + Interrupt( + value="interrupt node 2", + resumable=True, + ns=[AnyStr("node_2:")], + when="during", + ), + ) + }, + ] + # resume from the second interrupt + assert [c async for c in subgraph.astream(Command(resume="123"), thread)] == [ + { + "node_2": {"input": "123"}, + }, + ] + + subgraph = subgraph_builder.compile() + + def invoke_sub_agent(state: AgentState): + return subgraph.invoke(state) + + parent_agent = ( + StateGraph(AgentState) + .add_node("invoke_sub_agent", invoke_sub_agent) + .add_edge(START, "invoke_sub_agent") + .add_edge("invoke_sub_agent", END) + .compile(checkpointer=checkpointer) + ) + + assert [c async for c in parent_agent.astream({"input": "test"}, thread)] == [ + { + "__interrupt__": ( + Interrupt( + value="interrupt node 1", + resumable=True, + ns=[AnyStr("invoke_sub_agent:"), AnyStr("node_1:")], + when="during", + ), + ) + }, + ] + + # resume from the first interrupt + assert [ + c async for c in parent_agent.astream(Command(resume=True), thread) + ] == [ + { + "__interrupt__": ( + Interrupt( + value="interrupt node 2", + resumable=True, + ns=[AnyStr("invoke_sub_agent:"), AnyStr("node_2:")], + when="during", + ), + ) + } + ] + + # resume from 2nd interrupt + assert [ + c async for c in parent_agent.astream(Command(resume=True), thread) + ] == [ + { + "invoke_sub_agent": {"input": True}, + }, + ] From c2a57385c0eba320d85eeb7037d6ecdd160dd1be Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 15 Jan 2025 13:35:51 -0800 Subject: [PATCH 13/52] Update tests --- libs/scheduler-kafka/tests/test_subgraph.py | 42 ++++++++++++++++--- .../tests/test_subgraph_sync.py | 42 ++++++++++++++++--- 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/libs/scheduler-kafka/tests/test_subgraph.py b/libs/scheduler-kafka/tests/test_subgraph.py index 1e1f1e396..047d3a7c8 100644 --- a/libs/scheduler-kafka/tests/test_subgraph.py +++ b/libs/scheduler-kafka/tests/test_subgraph.py @@ -197,7 +197,12 @@ async def test_subgraph_w_interrupt( "__pregel_resuming": False, "__pregel_store": None, "__pregel_task_id": history[0].tasks[0].id, - "__pregel_scratchpad": {}, + "__pregel_scratchpad": { + "call_counter": 0, + "interrupt_counter": -1, + "null_resume": None, + "resume": [], + }, "__pregel_writes": AnyList(), "checkpoint_id": None, "checkpoint_map": { @@ -264,7 +269,12 @@ async def test_subgraph_w_interrupt( "__pregel_resuming": False, "__pregel_store": None, "__pregel_task_id": history[0].tasks[0].id, - "__pregel_scratchpad": {}, + "__pregel_scratchpad": { + "call_counter": 0, + "interrupt_counter": -1, + "null_resume": None, + "resume": [], + }, "__pregel_writes": AnyList(), "checkpoint_id": c.config["configurable"]["checkpoint_id"], "checkpoint_map": { @@ -361,7 +371,12 @@ async def test_subgraph_w_interrupt( "__pregel_resuming": False, "__pregel_store": None, "__pregel_task_id": history[0].tasks[0].id, - "__pregel_scratchpad": {}, + "__pregel_scratchpad": { + "call_counter": 0, + "interrupt_counter": -1, + "null_resume": None, + "resume": [], + }, "__pregel_writes": AnyList(), "checkpoint_id": c.config["configurable"]["checkpoint_id"], "checkpoint_map": { @@ -468,7 +483,12 @@ async def test_subgraph_w_interrupt( "__pregel_resuming": True, "__pregel_store": None, "__pregel_task_id": history[1].tasks[0].id, - "__pregel_scratchpad": {}, + "__pregel_scratchpad": { + "call_counter": 0, + "interrupt_counter": -1, + "null_resume": None, + "resume": [], + }, "__pregel_writes": AnyList(), "checkpoint_id": None, "checkpoint_map": { @@ -530,7 +550,12 @@ async def test_subgraph_w_interrupt( "__pregel_resuming": True, "__pregel_store": None, "__pregel_task_id": history[1].tasks[0].id, - "__pregel_scratchpad": {}, + "__pregel_scratchpad": { + "call_counter": 0, + "interrupt_counter": -1, + "null_resume": None, + "resume": [], + }, "__pregel_writes": AnyList(), "checkpoint_id": c.config["configurable"]["checkpoint_id"], "checkpoint_map": { @@ -648,7 +673,12 @@ async def test_subgraph_w_interrupt( "__pregel_resuming": True, "__pregel_store": None, "__pregel_task_id": history[1].tasks[0].id, - "__pregel_scratchpad": {}, + "__pregel_scratchpad": { + "call_counter": 0, + "interrupt_counter": -1, + "null_resume": None, + "resume": [], + }, "__pregel_writes": AnyList(), "checkpoint_id": c.config["configurable"]["checkpoint_id"], "checkpoint_map": { diff --git a/libs/scheduler-kafka/tests/test_subgraph_sync.py b/libs/scheduler-kafka/tests/test_subgraph_sync.py index 210312b3b..84e76b6a4 100644 --- a/libs/scheduler-kafka/tests/test_subgraph_sync.py +++ b/libs/scheduler-kafka/tests/test_subgraph_sync.py @@ -196,7 +196,12 @@ def test_subgraph_w_interrupt( "__pregel_resuming": False, "__pregel_store": None, "__pregel_task_id": history[0].tasks[0].id, - "__pregel_scratchpad": {}, + "__pregel_scratchpad": { + "call_counter": 0, + "interrupt_counter": -1, + "null_resume": None, + "resume": [], + }, "__pregel_writes": AnyList(), "checkpoint_id": None, "checkpoint_map": { @@ -263,7 +268,12 @@ def test_subgraph_w_interrupt( "__pregel_dedupe_tasks": True, "__pregel_resuming": False, "__pregel_task_id": history[0].tasks[0].id, - "__pregel_scratchpad": {}, + "__pregel_scratchpad": { + "call_counter": 0, + "interrupt_counter": -1, + "null_resume": None, + "resume": [], + }, "__pregel_writes": AnyList(), "checkpoint_id": c.config["configurable"]["checkpoint_id"], "checkpoint_map": { @@ -360,7 +370,12 @@ def test_subgraph_w_interrupt( "__pregel_store": None, "__pregel_resuming": False, "__pregel_task_id": history[0].tasks[0].id, - "__pregel_scratchpad": {}, + "__pregel_scratchpad": { + "call_counter": 0, + "interrupt_counter": -1, + "null_resume": None, + "resume": [], + }, "__pregel_writes": AnyList(), "checkpoint_id": c.config["configurable"]["checkpoint_id"], "checkpoint_map": { @@ -466,7 +481,12 @@ def test_subgraph_w_interrupt( "__pregel_store": None, "__pregel_resuming": True, "__pregel_task_id": history[1].tasks[0].id, - "__pregel_scratchpad": {}, + "__pregel_scratchpad": { + "call_counter": 0, + "interrupt_counter": -1, + "null_resume": None, + "resume": [], + }, "__pregel_writes": AnyList(), "checkpoint_id": None, "checkpoint_map": { @@ -528,7 +548,12 @@ def test_subgraph_w_interrupt( "__pregel_store": None, "__pregel_resuming": True, "__pregel_task_id": history[1].tasks[0].id, - "__pregel_scratchpad": {}, + "__pregel_scratchpad": { + "call_counter": 0, + "interrupt_counter": -1, + "null_resume": None, + "resume": [], + }, "__pregel_writes": AnyList(), "checkpoint_id": c.config["configurable"]["checkpoint_id"], "checkpoint_map": { @@ -646,7 +671,12 @@ def test_subgraph_w_interrupt( "__pregel_resuming": True, "__pregel_store": None, "__pregel_task_id": history[1].tasks[0].id, - "__pregel_scratchpad": {}, + "__pregel_scratchpad": { + "call_counter": 0, + "interrupt_counter": -1, + "null_resume": None, + "resume": [], + }, "__pregel_writes": AnyList(), "checkpoint_id": c.config["configurable"]["checkpoint_id"], "checkpoint_map": { From e83660885b423ef7fef5677ac6822d5c14f2f313 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 15 Jan 2025 13:39:44 -0800 Subject: [PATCH 14/52] Lint --- libs/scheduler-kafka/tests/test_subgraph.py | 6 ------ libs/scheduler-kafka/tests/test_subgraph_sync.py | 6 ------ 2 files changed, 12 deletions(-) diff --git a/libs/scheduler-kafka/tests/test_subgraph.py b/libs/scheduler-kafka/tests/test_subgraph.py index 047d3a7c8..88042b28a 100644 --- a/libs/scheduler-kafka/tests/test_subgraph.py +++ b/libs/scheduler-kafka/tests/test_subgraph.py @@ -203,7 +203,6 @@ async def test_subgraph_w_interrupt( "null_resume": None, "resume": [], }, - "__pregel_writes": AnyList(), "checkpoint_id": None, "checkpoint_map": { "": history[0].config["configurable"]["checkpoint_id"] @@ -275,7 +274,6 @@ async def test_subgraph_w_interrupt( "null_resume": None, "resume": [], }, - "__pregel_writes": AnyList(), "checkpoint_id": c.config["configurable"]["checkpoint_id"], "checkpoint_map": { "": history[0].config["configurable"]["checkpoint_id"] @@ -377,7 +375,6 @@ async def test_subgraph_w_interrupt( "null_resume": None, "resume": [], }, - "__pregel_writes": AnyList(), "checkpoint_id": c.config["configurable"]["checkpoint_id"], "checkpoint_map": { "": history[0].config["configurable"]["checkpoint_id"] @@ -489,7 +486,6 @@ async def test_subgraph_w_interrupt( "null_resume": None, "resume": [], }, - "__pregel_writes": AnyList(), "checkpoint_id": None, "checkpoint_map": { "": history[1].config["configurable"]["checkpoint_id"] @@ -556,7 +552,6 @@ async def test_subgraph_w_interrupt( "null_resume": None, "resume": [], }, - "__pregel_writes": AnyList(), "checkpoint_id": c.config["configurable"]["checkpoint_id"], "checkpoint_map": { "": history[1].config["configurable"]["checkpoint_id"] @@ -679,7 +674,6 @@ async def test_subgraph_w_interrupt( "null_resume": None, "resume": [], }, - "__pregel_writes": AnyList(), "checkpoint_id": c.config["configurable"]["checkpoint_id"], "checkpoint_map": { "": history[1].config["configurable"]["checkpoint_id"] diff --git a/libs/scheduler-kafka/tests/test_subgraph_sync.py b/libs/scheduler-kafka/tests/test_subgraph_sync.py index 84e76b6a4..30b70720f 100644 --- a/libs/scheduler-kafka/tests/test_subgraph_sync.py +++ b/libs/scheduler-kafka/tests/test_subgraph_sync.py @@ -202,7 +202,6 @@ def test_subgraph_w_interrupt( "null_resume": None, "resume": [], }, - "__pregel_writes": AnyList(), "checkpoint_id": None, "checkpoint_map": { "": history[0].config["configurable"]["checkpoint_id"] @@ -274,7 +273,6 @@ def test_subgraph_w_interrupt( "null_resume": None, "resume": [], }, - "__pregel_writes": AnyList(), "checkpoint_id": c.config["configurable"]["checkpoint_id"], "checkpoint_map": { "": history[0].config["configurable"]["checkpoint_id"] @@ -376,7 +374,6 @@ def test_subgraph_w_interrupt( "null_resume": None, "resume": [], }, - "__pregel_writes": AnyList(), "checkpoint_id": c.config["configurable"]["checkpoint_id"], "checkpoint_map": { "": history[0].config["configurable"]["checkpoint_id"] @@ -487,7 +484,6 @@ def test_subgraph_w_interrupt( "null_resume": None, "resume": [], }, - "__pregel_writes": AnyList(), "checkpoint_id": None, "checkpoint_map": { "": history[1].config["configurable"]["checkpoint_id"] @@ -554,7 +550,6 @@ def test_subgraph_w_interrupt( "null_resume": None, "resume": [], }, - "__pregel_writes": AnyList(), "checkpoint_id": c.config["configurable"]["checkpoint_id"], "checkpoint_map": { "": history[1].config["configurable"]["checkpoint_id"] @@ -677,7 +672,6 @@ def test_subgraph_w_interrupt( "null_resume": None, "resume": [], }, - "__pregel_writes": AnyList(), "checkpoint_id": c.config["configurable"]["checkpoint_id"], "checkpoint_map": { "": history[1].config["configurable"]["checkpoint_id"] From 144ee31546e936738ac2e14577f68792fd9f33fa Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 15 Jan 2025 13:43:14 -0800 Subject: [PATCH 15/52] Lint --- libs/scheduler-kafka/tests/test_subgraph.py | 2 +- libs/scheduler-kafka/tests/test_subgraph_sync.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/scheduler-kafka/tests/test_subgraph.py b/libs/scheduler-kafka/tests/test_subgraph.py index 88042b28a..55303cba0 100644 --- a/libs/scheduler-kafka/tests/test_subgraph.py +++ b/libs/scheduler-kafka/tests/test_subgraph.py @@ -15,7 +15,7 @@ from langgraph.graph.state import StateGraph from langgraph.pregel import Pregel from langgraph.scheduler.kafka import serde from langgraph.scheduler.kafka.types import MessageToOrchestrator, Topics -from tests.any import AnyDict, AnyList +from tests.any import AnyDict from tests.drain import drain_topics_async from tests.messages import _AnyIdAIMessage, _AnyIdHumanMessage diff --git a/libs/scheduler-kafka/tests/test_subgraph_sync.py b/libs/scheduler-kafka/tests/test_subgraph_sync.py index 30b70720f..a67919dda 100644 --- a/libs/scheduler-kafka/tests/test_subgraph_sync.py +++ b/libs/scheduler-kafka/tests/test_subgraph_sync.py @@ -15,7 +15,7 @@ from langgraph.pregel import Pregel from langgraph.scheduler.kafka import serde from langgraph.scheduler.kafka.default_sync import DefaultProducer from langgraph.scheduler.kafka.types import MessageToOrchestrator, Topics -from tests.any import AnyDict, AnyList +from tests.any import AnyDict from tests.drain import drain_topics from tests.messages import _AnyIdAIMessage, _AnyIdHumanMessage From cd64075928dc177c753e89cffda2dbf6f2a5adb8 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 15 Jan 2025 13:49:33 -0800 Subject: [PATCH 16/52] Lint --- libs/langgraph/langgraph/types.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index 9a94ad85a..076ee82f7 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -455,7 +455,6 @@ def interrupt(value: Any) -> Any: conf = get_config()["configurable"] # track interrupt index scratchpad: PregelScratchpad = conf[CONFIG_KEY_SCRATCHPAD] - print("interrupt", scratchpad) scratchpad["interrupt_counter"] += 1 idx = scratchpad["interrupt_counter"] # find previous resume values From 6b707cbfc5366130da17a562fc8d952ad15cb671 Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Wed, 15 Jan 2025 17:15:56 -0500 Subject: [PATCH 17/52] x --- libs/langgraph/langgraph/func/__init__.py | 98 +++++++++++++++++++++-- libs/langgraph/tests/test_pregel.py | 71 +++++++++++++--- 2 files changed, 153 insertions(+), 16 deletions(-) diff --git a/libs/langgraph/langgraph/func/__init__.py b/libs/langgraph/langgraph/func/__init__.py index c7b4d1bb4..14c69ac89 100644 --- a/libs/langgraph/langgraph/func/__init__.py +++ b/libs/langgraph/langgraph/func/__init__.py @@ -19,13 +19,13 @@ from typing_extensions import ParamSpec from langgraph.channels.ephemeral_value import EphemeralValue from langgraph.channels.last_value import LastValue from langgraph.checkpoint.base import BaseCheckpointSaver -from langgraph.constants import END, START, TAG_HIDDEN +from langgraph.constants import CONF, END, START, TAG_HIDDEN from langgraph.pregel import Pregel from langgraph.pregel.call import get_runnable_for_func from langgraph.pregel.read import PregelNode from langgraph.pregel.write import ChannelWrite, ChannelWriteEntry from langgraph.store.base import BaseStore -from langgraph.types import RetryPolicy, StreamMode +from langgraph.types import RetryPolicy, StreamMode, StreamWriter P = ParamSpec("P") P1 = TypeVar("P1") @@ -112,13 +112,101 @@ def entrypoint( store: Optional[BaseStore] = None, ) -> Callable[[types.FunctionType], Pregel]: def _imp(func: types.FunctionType) -> Pregel: + """Convert a function into a Pregel graph. + + Args: + func: The function to convert. Support both sync and async functions, as well + as generator and async generator functions. + + Returns: + A Pregel graph. + """ if inspect.isgeneratorfunction(func): - raise TypeError("@entrypoint does not support generator functions.") + original_sig = inspect.signature(func) + # Check if original signature has a writer argument with a matching type. + # If not, we'll inject it into the decorator, but not pass it + # to the wrapped function. + if "writer" in original_sig.parameters: + + @functools.wraps(func) + def gen_wrapper(*args: Any, writer: StreamWriter, **kwargs: Any) -> Any: + chunks = [] + for chunk in func(*args, writer=writer, **kwargs): + writer(chunk) + chunks.append(chunk) + return chunks + else: + + @functools.wraps(func) + def gen_wrapper(*args: Any, writer: StreamWriter, **kwargs: Any) -> Any: + chunks = [] + # Do not pass the writer argument to the wrapped function + # as it does not have a matching parameter + for chunk in func(*args, **kwargs): + writer(chunk) + chunks.append(chunk) + return chunks + + # Create a new parameter for the writer argument + extra_param = inspect.Parameter( + "writer", + inspect.Parameter.KEYWORD_ONLY, + # The extra argument is a keyword-only argument + default=lambda _: None, + ) + # Update the function's signature to include the extra argument + new_params = list(original_sig.parameters.values()) + [extra_param] + new_sig = original_sig.replace(parameters=new_params) + # Update the signature of the wrapper function + gen_wrapper.__signature__ = new_sig + bound = get_runnable_for_func(gen_wrapper) + stream_mode: StreamMode = "custom" elif inspect.isasyncgenfunction(func): - raise TypeError("@entrypoint does not support async generator functions.") + original_sig = inspect.signature(func) + # Check if original signature has a writer argument with a matching type. + # If not, we'll inject it into the decorator, but not pass it + # to the wrapped function. + if "writer" in original_sig.parameters: + + @functools.wraps(func) + async def agen_wrapper( + *args: Any, writer: StreamWriter, **kwargs: Any + ) -> Any: + chunks = [] + async for chunk in func(*args, writer=writer, **kwargs): + writer(chunk) + chunks.append(chunk) + return chunks + else: + + @functools.wraps(func) + async def agen_wrapper( + *args: Any, writer: StreamWriter, **kwargs: Any + ) -> Any: + chunks = [] + async for chunk in func(*args, **kwargs): + writer(chunk) + chunks.append(chunk) + return chunks + + # Create a new parameter for the writer argument + extra_param = inspect.Parameter( + "writer", + inspect.Parameter.KEYWORD_ONLY, + # The extra argument is a keyword-only argument + default=lambda _: None, + ) + # Update the function's signature to include the extra argument + new_params = list(original_sig.parameters.values()) + [extra_param] + new_sig = original_sig.replace(parameters=new_params) + # Update the signature of the wrapper function + agen_wrapper.__signature__ = new_sig + + bound = get_runnable_for_func(agen_wrapper) + stream_mode = "custom" else: bound = get_runnable_for_func(func) - stream_mode: StreamMode = "updates" + stream_mode = "updates" return Pregel( nodes={ diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 432eea069..f776a2580 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -5331,7 +5331,6 @@ def test_entrypoint_stateful() -> None: # Test invoke states = [] - # In this version reducers do not work @entrypoint(checkpointer=MemorySaver()) def foo(inputs, *, previous: Any) -> Any: states.append(previous) @@ -5367,21 +5366,71 @@ def test_entrypoint_stateful() -> None: assert items == [{"foo": {"current": {"a": "1"}, "previous": None}}] -async def test_entrypoint_from_generator() -> None: +def test_entrypoint_from_sync_generator() -> None: """@entrypoint does not support sync generators.""" + previous_return_values = [] - with pytest.raises(TypeError): + @entrypoint(checkpointer=MemorySaver()) + def foo(inputs, previous=None) -> Any: + previous_return_values.append(previous) + yield "a" + yield "b" - @entrypoint(checkpointer=MemorySaver()) - def foo(inputs: Any) -> Iterable[dict]: - yield "a" + config = {"configurable": {"thread_id": "1"}} + + assert foo.invoke({"a": "1"}, config) == ["a", "b"] + assert previous_return_values == [None] + assert foo.invoke({"a": "2"}, config) == ["a", "b"] + assert previous_return_values == [None, ["a", "b"]] + + +def test_entrypoint_request_stream_writer() -> None: + """Test using a stream writer with an entrypoint.""" + + @entrypoint(checkpointer=MemorySaver()) + def foo(inputs, writer: StreamWriter) -> Any: + writer("a") + yield "b" + + config = {"configurable": {"thread_id": "1"}} + + # Different invocations + # Are any of these confusing or unexpected? + assert list(foo.invoke({}, config)) == ["b"] + assert list(foo.stream({}, config)) == ["a", "b"] + + # Stream modes + assert list(foo.stream({}, config, stream_mode=["updates"])) == [ + ("updates", {"foo": ["b"]}) + ] + assert list(foo.stream({}, config, stream_mode=["values"])) == [("values", ["b"])] + assert list(foo.stream({}, config, stream_mode=["custom"])) == [ + ( + "custom", + "a", + ), + ( + "custom", + "b", + ), + ] async def test_entrypoint_from_async_generator() -> None: - """@entrypoint does not support async generators.""" + """@entrypoint does not support sync generators.""" + # Test invoke + previous_return_values = [] - with pytest.raises(TypeError): + # In this version reducers do not work + @entrypoint(checkpointer=MemorySaver()) + async def foo(inputs, previous=None) -> Any: + previous_return_values.append(previous) + yield "a" + yield "b" - @entrypoint(checkpointer=MemorySaver()) - def foo(inputs: Any) -> Iterable[dict]: - yield "a" + config = {"configurable": {"thread_id": "1"}} + + assert list(await foo.ainvoke({"a": "1"}, config)) == ["a", "b"] + assert previous_return_values == [None] + assert list(foo.invoke({"a": "2"}, config)) == ["a", "b"] + assert previous_return_values == [None, ["a", "b"]] From 165406295790b40c655e1cc82e6f9e160cac7ab4 Mon Sep 17 00:00:00 2001 From: Chester Curme Date: Wed, 15 Jan 2025 18:14:37 -0500 Subject: [PATCH 18/52] enable navigation.indexes --- docs/mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index a6e159f15..2a5079ae4 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -20,6 +20,7 @@ theme: - content.action.edit - content.tooltips - header.autohide + - navigation.indexes - navigation.expand - navigation.footer - navigation.instant From 5375af782725b382935fd9f752e6787117d97119 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 15 Jan 2025 15:15:44 -0800 Subject: [PATCH 19/52] Add checkpointer=True mode for subgraphs that want to keep state betweenn turns --- libs/langgraph/langgraph/pregel/__init__.py | 9 ++++ libs/langgraph/langgraph/types.py | 8 +-- libs/langgraph/tests/test_pregel.py | 60 +++++++++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index cc8786968..7fd3ce646 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -1455,6 +1455,8 @@ class Pregel(PregelProtocol): checkpointer: Optional[BaseCheckpointSaver] = None elif CONFIG_KEY_CHECKPOINTER in config.get(CONF, {}): checkpointer = config[CONF][CONFIG_KEY_CHECKPOINTER] + elif self.checkpointer is True: + raise RuntimeError("checkpointer=True cannot be used for root graphs.") else: checkpointer = self.checkpointer if checkpointer and not config.get(CONF): @@ -1598,6 +1600,12 @@ class Pregel(PregelProtocol): interrupt_after=interrupt_after, debug=debug, ) + # set up subgraph checkpointing + if self.checkpointer is True: + ns = cast(str, config[CONF][CONFIG_KEY_CHECKPOINT_NS]) + config[CONF][CONFIG_KEY_CHECKPOINT_NS] = NS_SEP.join( + part.split(NS_END)[0] for part in ns.split(NS_SEP) + ) # set up messages stream mode if "messages" in stream_modes: run_manager.inheritable_handlers.append( @@ -1622,6 +1630,7 @@ class Pregel(PregelProtocol): interrupt_after=interrupt_after_, manager=run_manager, debug=debug, + check_subgraphs=self.checkpointer is not True, ) as loop: # create runner runner = PregelRunner( diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index 076ee82f7..3b1bcd213 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -38,9 +38,11 @@ except ImportError: All = Literal["*"] """Special value to indicate that graph should interrupt on all nodes.""" -Checkpointer = Union[None, Literal[False], BaseCheckpointSaver] -"""Type of the checkpointer to use for a subgraph. False disables checkpointing, -even if the parent graph has a checkpointer. None inherits checkpointer.""" +Checkpointer = Union[None, bool, BaseCheckpointSaver] +"""Type of the checkpointer to use for a subgraph. +- True enables persistent checkpointing for this subgraph. +- False disables checkpointing, even if the parent graph has a checkpointer. +- None inherits checkpointer from the parent graph.""" StreamMode = Literal["values", "updates", "debug", "messages", "custom"] """How the stream method should emit outputs. diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index fe1a603da..67536deed 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -3189,6 +3189,66 @@ def test_nested_graph(snapshot: SnapshotAssertion) -> None: ] +@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC) +def test_subgraph_checkpoint_true( + request: pytest.FixtureRequest, checkpointer_name: str +) -> None: + checkpointer = request.getfixturevalue("checkpointer_" + checkpointer_name) + + class InnerState(TypedDict): + my_key: Annotated[str, operator.add] + my_other_key: str + + def inner_1(state: InnerState): + return {"my_key": " got here", "my_other_key": state["my_key"]} + + def inner_2(state: InnerState): + return {"my_key": " and there"} + + inner = StateGraph(InnerState) + inner.add_node("inner_1", inner_1) + inner.add_node("inner_2", inner_2) + inner.add_edge("inner_1", "inner_2") + inner.set_entry_point("inner_1") + inner.set_finish_point("inner_2") + + class State(TypedDict): + my_key: str + + graph = StateGraph(State) + graph.add_node("inner", inner.compile(checkpointer=True)) + graph.add_edge(START, "inner") + graph.add_conditional_edges( + "inner", lambda s: "inner" if s["my_key"].count("there") < 2 else END + ) + app = graph.compile(checkpointer=checkpointer) + + config = {"configurable": {"thread_id": "2"}} + assert [c for c in app.stream({"my_key": ""}, config, subgraphs=True)] == [ + (("inner",), {"inner_1": {"my_key": " got here", "my_other_key": ""}}), + (("inner",), {"inner_2": {"my_key": " and there"}}), + ((), {"inner": {"my_key": " got here and there"}}), + ( + ("inner",), + { + "inner_1": { + "my_key": " got here", + "my_other_key": " got here and there got here and there", + } + }, + ), + (("inner",), {"inner_2": {"my_key": " and there"}}), + ( + (), + { + "inner": { + "my_key": " got here and there got here and there got here and there" + } + }, + ), + ] + + @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC) def test_stream_subgraphs_during_execution( request: pytest.FixtureRequest, checkpointer_name: str From 71fbd6a8b489301e0b17b0937177bd407535f9b8 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 15 Jan 2025 15:29:34 -0800 Subject: [PATCH 20/52] Lint --- libs/langgraph/langgraph/pregel/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 7fd3ce646..b249927b5 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -494,7 +494,9 @@ class Pregel(PregelProtocol): saved.metadata.get("step", -1) + 1, for_execution=True, store=self.store, - checkpointer=self.checkpointer or None, + checkpointer=self.checkpointer + if isinstance(self.checkpointer, BaseCheckpointSaver) + else None, manager=None, ) # get the subgraphs @@ -606,7 +608,9 @@ class Pregel(PregelProtocol): saved.metadata.get("step", -1) + 1, for_execution=True, store=self.store, - checkpointer=self.checkpointer or None, + checkpointer=self.checkpointer + if isinstance(self.checkpointer, BaseCheckpointSaver) + else None, manager=None, ) # get the subgraphs From be8b4a1d7f51ce26f7a2dd0c49161b428e22bd91 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 15 Jan 2025 15:30:29 -0800 Subject: [PATCH 21/52] Lint --- libs/langgraph/langgraph/pregel/__init__.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index b249927b5..d373e2440 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -930,7 +930,9 @@ class Pregel(PregelProtocol): saved.metadata.get("step", -1) + 1, for_execution=True, store=self.store, - checkpointer=self.checkpointer or None, + checkpointer=self.checkpointer + if isinstance(self.checkpointer, BaseCheckpointSaver) + else None, manager=None, ) # apply null writes @@ -1024,7 +1026,9 @@ class Pregel(PregelProtocol): saved.metadata.get("step", -1) + 1, for_execution=True, store=self.store, - checkpointer=self.checkpointer or None, + checkpointer=self.checkpointer + if isinstance(self.checkpointer, BaseCheckpointSaver) + else None, manager=None, ) # apply null writes @@ -1213,7 +1217,9 @@ class Pregel(PregelProtocol): saved.metadata.get("step", -1) + 1, for_execution=True, store=self.store, - checkpointer=self.checkpointer or None, + checkpointer=self.checkpointer + if isinstance(self.checkpointer, BaseCheckpointSaver) + else None, manager=None, ) # apply null writes @@ -1307,7 +1313,9 @@ class Pregel(PregelProtocol): saved.metadata.get("step", -1) + 1, for_execution=True, store=self.store, - checkpointer=self.checkpointer or None, + checkpointer=self.checkpointer + if isinstance(self.checkpointer, BaseCheckpointSaver) + else None, manager=None, ) # apply null writes From 38d9b39f6eaad310d456aaf00ba039605b39916e Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 15 Jan 2025 15:36:03 -0800 Subject: [PATCH 22/52] Add flag --- libs/langgraph/langgraph/pregel/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index d373e2440..102e68be8 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -1870,6 +1870,7 @@ class Pregel(PregelProtocol): interrupt_after=interrupt_after_, manager=run_manager, debug=debug, + check_subgraphs=self.checkpointer is not True, ) as loop: # create runner runner = PregelRunner( From d6492ef048618bbd9713aba2968041fcfa1140ed Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 15 Jan 2025 15:32:48 -0800 Subject: [PATCH 23/52] Add support for multiple subgraphs called in a single node --- libs/langgraph/langgraph/errors.py | 15 ------------- libs/langgraph/langgraph/pregel/algo.py | 2 ++ libs/langgraph/langgraph/pregel/loop.py | 27 ++++++++++++++--------- libs/langgraph/langgraph/pregel/retry.py | 16 +------------- libs/langgraph/langgraph/types.py | 2 ++ libs/langgraph/tests/test_pregel.py | 7 +++--- libs/langgraph/tests/test_pregel_async.py | 7 +++--- 7 files changed, 27 insertions(+), 49 deletions(-) diff --git a/libs/langgraph/langgraph/errors.py b/libs/langgraph/langgraph/errors.py index 0737a31d0..8e78a8784 100644 --- a/libs/langgraph/langgraph/errors.py +++ b/libs/langgraph/langgraph/errors.py @@ -107,18 +107,3 @@ class CheckpointNotLatest(Exception): """Raised when the checkpoint is not the latest version (for distributed mode).""" pass - - -class MultipleSubgraphsError(Exception): - """Raised when multiple subgraphs are called inside the same node. - - Troubleshooting guides: - - - [MULTIPLE_SUBGRAPHS](https://python.langchain.com/docs/troubleshooting/errors/MULTIPLE_SUBGRAPHS) - """ - - pass - - -_SEEN_CHECKPOINT_NS: set[str] = set() -"""Used for subgraph detection.""" diff --git a/libs/langgraph/langgraph/pregel/algo.py b/libs/langgraph/langgraph/pregel/algo.py index c46138550..205793ab4 100644 --- a/libs/langgraph/langgraph/pregel/algo.py +++ b/libs/langgraph/langgraph/pregel/algo.py @@ -766,6 +766,8 @@ def _scratchpad( (w[2] for w in pending_writes if w[0] == NULL_TASK_ID and w[1] == RESUME), MISSING, ), + # subgraph + subgraph_counter=0, ) diff --git a/libs/langgraph/langgraph/pregel/loop.py b/libs/langgraph/langgraph/pregel/loop.py index 9a745a2e7..ff244b7de 100644 --- a/libs/langgraph/langgraph/pregel/loop.py +++ b/libs/langgraph/langgraph/pregel/loop.py @@ -63,12 +63,10 @@ from langgraph.constants import ( TAG_HIDDEN, ) from langgraph.errors import ( - _SEEN_CHECKPOINT_NS, CheckpointNotLatest, EmptyInputError, GraphDelegate, GraphInterrupt, - MultipleSubgraphsError, ) from langgraph.managed.base import ( ManagedValueMapping, @@ -116,6 +114,7 @@ from langgraph.types import ( Command, LoopProtocol, PregelExecutableTask, + PregelScratchpad, StreamChunk, StreamProtocol, ) @@ -230,20 +229,26 @@ class PregelLoop(LoopProtocol): self.debug = debug if self.stream is not None and CONFIG_KEY_STREAM in config[CONF]: self.stream = DuplexStream(self.stream, config[CONF][CONFIG_KEY_STREAM]) + scratchpad: Optional[PregelScratchpad] = config[CONF].get(CONFIG_KEY_SCRATCHPAD) + if scratchpad is not None: + if scratchpad["subgraph_counter"]: + self.config = patch_configurable( + self.config, + { + CONFIG_KEY_CHECKPOINT_NS: NS_SEP.join( + ( + config[CONF][CONFIG_KEY_CHECKPOINT_NS], + str(scratchpad["subgraph_counter"]), + ) + ) + }, + ) + scratchpad["subgraph_counter"] += 1 if not self.is_nested and config[CONF].get(CONFIG_KEY_CHECKPOINT_NS): self.config = patch_configurable( self.config, {CONFIG_KEY_CHECKPOINT_NS: "", CONFIG_KEY_CHECKPOINT_ID: None}, ) - if check_subgraphs and self.is_nested and self.checkpointer is not None: - if self.config[CONF][CONFIG_KEY_CHECKPOINT_NS] in _SEEN_CHECKPOINT_NS: - raise MultipleSubgraphsError( - "Multiple subgraphs called inside the same node\n\n" - "Troubleshooting URL: https://python.langchain.com/docs" - "/troubleshooting/errors/MULTIPLE_SUBGRAPHS/" - ) - else: - _SEEN_CHECKPOINT_NS.add(self.config[CONF][CONFIG_KEY_CHECKPOINT_NS]) if ( CONFIG_KEY_CHECKPOINT_MAP in self.config[CONF] and self.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS) diff --git a/libs/langgraph/langgraph/pregel/retry.py b/libs/langgraph/langgraph/pregel/retry.py index 29faaab21..43e7e8d9e 100644 --- a/libs/langgraph/langgraph/pregel/retry.py +++ b/libs/langgraph/langgraph/pregel/retry.py @@ -12,7 +12,7 @@ from langgraph.constants import ( CONFIG_KEY_RESUMING, NS_SEP, ) -from langgraph.errors import _SEEN_CHECKPOINT_NS, GraphBubbleUp, ParentCommand +from langgraph.errors import GraphBubbleUp, ParentCommand from langgraph.types import Command, PregelExecutableTask, RetryPolicy from langgraph.utils.config import patch_configurable @@ -96,13 +96,6 @@ def run_with_retry( ) # signal subgraphs to resume (if available) config = patch_configurable(config, {CONFIG_KEY_RESUMING: True}) - # clear checkpoint_ns seen (for subgraph detection) - if checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS): - _SEEN_CHECKPOINT_NS.discard(checkpoint_ns) - finally: - # clear checkpoint_ns seen (for subgraph detection) - if checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS): - _SEEN_CHECKPOINT_NS.discard(checkpoint_ns) async def arun_with_retry( @@ -188,10 +181,3 @@ async def arun_with_retry( ) # signal subgraphs to resume (if available) config = patch_configurable(config, {CONFIG_KEY_RESUMING: True}) - # clear checkpoint_ns seen (for subgraph detection) - if checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS): - _SEEN_CHECKPOINT_NS.discard(checkpoint_ns) - finally: - # clear checkpoint_ns seen (for subgraph detection) - if checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS): - _SEEN_CHECKPOINT_NS.discard(checkpoint_ns) diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index 3b1bcd213..09f777d01 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -346,6 +346,8 @@ class PregelScratchpad(TypedDict): interrupt_counter: int resume: list[Any] null_resume: Any + # subgraph + subgraph_counter: int def interrupt(value: Any) -> Any: diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 67536deed..76a067533 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -51,7 +51,7 @@ from langgraph.checkpoint.base import ( ) from langgraph.checkpoint.memory import MemorySaver from langgraph.constants import CONFIG_KEY_NODE_FINISHED, ERROR, PULL, START -from langgraph.errors import InvalidUpdateError, MultipleSubgraphsError +from langgraph.errors import InvalidUpdateError from langgraph.func import entrypoint, task from langgraph.graph import END, Graph, StateGraph from langgraph.graph.message import MessageGraph, MessagesState, add_messages @@ -1745,9 +1745,8 @@ def test_invoke_join_then_call_other_pregel( # add checkpointer app.checkpointer = checkpointer - # subgraph is called twice in the same node, through .map(), so raises - with pytest.raises(MultipleSubgraphsError): - app.invoke([2, 3], {"configurable": {"thread_id": "1"}}) + # subgraph is called twice in the same node, but that works + assert app.invoke([2, 3], {"configurable": {"thread_id": "1"}}) == 27 # set inner graph checkpointer NeverCheckpoint inner_app.checkpointer = False diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 2f7d7a1c4..a6fab066f 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -48,7 +48,7 @@ from langgraph.checkpoint.base import ( ) from langgraph.checkpoint.memory import MemorySaver from langgraph.constants import CONFIG_KEY_NODE_FINISHED, ERROR, PULL, PUSH, START -from langgraph.errors import InvalidUpdateError, MultipleSubgraphsError, NodeInterrupt +from langgraph.errors import InvalidUpdateError, NodeInterrupt from langgraph.func import entrypoint, task from langgraph.graph import END, Graph, StateGraph from langgraph.graph.message import MessagesState, add_messages @@ -4068,9 +4068,8 @@ async def test_invoke_join_then_call_other_pregel( async with awith_checkpointer(checkpointer_name) as checkpointer: # add checkpointer app.checkpointer = checkpointer - # subgraph is called twice in the same node, through .map(), so raises - with pytest.raises(MultipleSubgraphsError): - await app.ainvoke([2, 3], {"configurable": {"thread_id": "1"}}) + # subgraph is called twice, and that works + assert await app.ainvoke([2, 3], {"configurable": {"thread_id": "1"}}) == 27 # set inner graph checkpointer NeverCheckpoint inner_app.checkpointer = False From e8a73e1505502b9c8a7e2984a59cd42d788f2648 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 15 Jan 2025 15:35:06 -0800 Subject: [PATCH 24/52] Remove flag --- libs/langgraph/langgraph/pregel/__init__.py | 1 - libs/langgraph/langgraph/pregel/loop.py | 5 ----- .../langgraph/scheduler/kafka/orchestrator.py | 2 -- 3 files changed, 8 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 102e68be8..0a90aa10f 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -1642,7 +1642,6 @@ class Pregel(PregelProtocol): interrupt_after=interrupt_after_, manager=run_manager, debug=debug, - check_subgraphs=self.checkpointer is not True, ) as loop: # create runner runner = PregelRunner( diff --git a/libs/langgraph/langgraph/pregel/loop.py b/libs/langgraph/langgraph/pregel/loop.py index ff244b7de..0afe62658 100644 --- a/libs/langgraph/langgraph/pregel/loop.py +++ b/libs/langgraph/langgraph/pregel/loop.py @@ -202,7 +202,6 @@ class PregelLoop(LoopProtocol): interrupt_after: Union[All, Sequence[str]] = EMPTY_SEQ, interrupt_before: Union[All, Sequence[str]] = EMPTY_SEQ, manager: Union[None, AsyncParentRunManager, ParentRunManager] = None, - check_subgraphs: bool = True, debug: bool = False, ) -> None: super().__init__( @@ -822,7 +821,6 @@ class SyncPregelLoop(PregelLoop, ContextManager): interrupt_before: Union[All, Sequence[str]] = EMPTY_SEQ, output_keys: Union[str, Sequence[str]] = EMPTY_SEQ, stream_keys: Union[str, Sequence[str]] = EMPTY_SEQ, - check_subgraphs: bool = True, debug: bool = False, ) -> None: super().__init__( @@ -837,7 +835,6 @@ class SyncPregelLoop(PregelLoop, ContextManager): stream_keys=stream_keys, interrupt_after=interrupt_after, interrupt_before=interrupt_before, - check_subgraphs=check_subgraphs, manager=manager, debug=debug, ) @@ -959,7 +956,6 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager): manager: Union[None, AsyncParentRunManager, ParentRunManager] = None, output_keys: Union[str, Sequence[str]] = EMPTY_SEQ, stream_keys: Union[str, Sequence[str]] = EMPTY_SEQ, - check_subgraphs: bool = True, debug: bool = False, ) -> None: super().__init__( @@ -974,7 +970,6 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager): stream_keys=stream_keys, interrupt_after=interrupt_after, interrupt_before=interrupt_before, - check_subgraphs=check_subgraphs, manager=manager, debug=debug, ) diff --git a/libs/scheduler-kafka/langgraph/scheduler/kafka/orchestrator.py b/libs/scheduler-kafka/langgraph/scheduler/kafka/orchestrator.py index 4e5be8470..e3701b529 100644 --- a/libs/scheduler-kafka/langgraph/scheduler/kafka/orchestrator.py +++ b/libs/scheduler-kafka/langgraph/scheduler/kafka/orchestrator.py @@ -163,7 +163,6 @@ class AsyncKafkaOrchestrator(AbstractAsyncContextManager): stream_keys=graph.stream_channels, interrupt_after=graph.interrupt_after_nodes, interrupt_before=graph.interrupt_before_nodes, - check_subgraphs=False, ) as loop: if loop.tick(input_keys=graph.input_channels): # wait for checkpoint to be saved @@ -353,7 +352,6 @@ class KafkaOrchestrator(AbstractContextManager): stream_keys=graph.stream_channels, interrupt_after=graph.interrupt_after_nodes, interrupt_before=graph.interrupt_before_nodes, - check_subgraphs=False, ) as loop: if loop.tick(input_keys=graph.input_channels): # wait for checkpoint to be saved From d402bf73799c33c91659d0d3ede58a79d9d80754 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 15 Jan 2025 15:36:16 -0800 Subject: [PATCH 25/52] Remove flag --- libs/langgraph/langgraph/pregel/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 0a90aa10f..abf51f5be 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -1869,7 +1869,6 @@ class Pregel(PregelProtocol): interrupt_after=interrupt_after_, manager=run_manager, debug=debug, - check_subgraphs=self.checkpointer is not True, ) as loop: # create runner runner = PregelRunner( From f4bd023ab15b2b929f4e082e0b4f0b0a7f54987e Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 15 Jan 2025 16:04:14 -0800 Subject: [PATCH 26/52] Fix --- libs/langgraph/langgraph/pregel/__init__.py | 61 +++++----------- libs/langgraph/langgraph/pregel/retry.py | 10 ++- libs/langgraph/langgraph/utils/config.py | 16 ++++ .../langgraph/scheduler/kafka/executor.py | 73 ++++++++++--------- .../langgraph/scheduler/kafka/orchestrator.py | 24 +++--- 5 files changed, 90 insertions(+), 94 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index abf51f5be..e9ac31502 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -115,6 +115,7 @@ from langgraph.utils.config import ( patch_checkpoint_map, patch_config, patch_configurable, + recast_checkpoint_ns, ) from langgraph.utils.fields import get_enhanced_type_hints from langgraph.utils.pydantic import create_model @@ -694,19 +695,15 @@ class Pregel(PregelProtocol): checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "") ) and CONFIG_KEY_CHECKPOINTER not in config[CONF]: # remove task_ids from checkpoint_ns - recast_checkpoint_ns = NS_SEP.join( - part.split(NS_END)[0] for part in checkpoint_ns.split(NS_SEP) - ) + recast = recast_checkpoint_ns(checkpoint_ns) # find the subgraph with the matching name - for _, pregel in self.get_subgraphs( - namespace=recast_checkpoint_ns, recurse=True - ): + for _, pregel in self.get_subgraphs(namespace=recast, recurse=True): return pregel.get_state( patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}), subgraphs=subgraphs, ) else: - raise ValueError(f"Subgraph {recast_checkpoint_ns} not found") + raise ValueError(f"Subgraph {recast} not found") config = merge_configs(self.config, config) if self.config else config saved = checkpointer.get_tuple(config) @@ -731,19 +728,15 @@ class Pregel(PregelProtocol): checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "") ) and CONFIG_KEY_CHECKPOINTER not in config[CONF]: # remove task_ids from checkpoint_ns - recast_checkpoint_ns = NS_SEP.join( - part.split(NS_END)[0] for part in checkpoint_ns.split(NS_SEP) - ) + recast = recast_checkpoint_ns(checkpoint_ns) # find the subgraph with the matching name - async for _, pregel in self.aget_subgraphs( - namespace=recast_checkpoint_ns, recurse=True - ): + async for _, pregel in self.aget_subgraphs(namespace=recast, recurse=True): return await pregel.aget_state( patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}), subgraphs=subgraphs, ) else: - raise ValueError(f"Subgraph {recast_checkpoint_ns} not found") + raise ValueError(f"Subgraph {recast} not found") config = merge_configs(self.config, config) if self.config else config saved = await checkpointer.aget_tuple(config) @@ -774,13 +767,9 @@ class Pregel(PregelProtocol): checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "") ) and CONFIG_KEY_CHECKPOINTER not in config[CONF]: # remove task_ids from checkpoint_ns - recast_checkpoint_ns = NS_SEP.join( - part.split(NS_END)[0] for part in checkpoint_ns.split(NS_SEP) - ) + recast = recast_checkpoint_ns(checkpoint_ns) # find the subgraph with the matching name - for _, pregel in self.get_subgraphs( - namespace=recast_checkpoint_ns, recurse=True - ): + for _, pregel in self.get_subgraphs(namespace=recast, recurse=True): yield from pregel.get_state_history( patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}), filter=filter, @@ -789,7 +778,7 @@ class Pregel(PregelProtocol): ) return else: - raise ValueError(f"Subgraph {recast_checkpoint_ns} not found") + raise ValueError(f"Subgraph {recast} not found") config = merge_configs( self.config, @@ -824,13 +813,9 @@ class Pregel(PregelProtocol): checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "") ) and CONFIG_KEY_CHECKPOINTER not in config[CONF]: # remove task_ids from checkpoint_ns - recast_checkpoint_ns = NS_SEP.join( - part.split(NS_END)[0] for part in checkpoint_ns.split(NS_SEP) - ) + recast = recast_checkpoint_ns(checkpoint_ns) # find the subgraph with the matching name - async for _, pregel in self.aget_subgraphs( - namespace=recast_checkpoint_ns, recurse=True - ): + async for _, pregel in self.aget_subgraphs(namespace=recast, recurse=True): async for state in pregel.aget_state_history( patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}), filter=filter, @@ -840,7 +825,7 @@ class Pregel(PregelProtocol): yield state return else: - raise ValueError(f"Subgraph {recast_checkpoint_ns} not found") + raise ValueError(f"Subgraph {recast} not found") config = merge_configs( self.config, @@ -879,20 +864,16 @@ class Pregel(PregelProtocol): checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "") ) and CONFIG_KEY_CHECKPOINTER not in config[CONF]: # remove task_ids from checkpoint_ns - recast_checkpoint_ns = NS_SEP.join( - part.split(NS_END)[0] for part in checkpoint_ns.split(NS_SEP) - ) + recast = recast_checkpoint_ns(checkpoint_ns) # find the subgraph with the matching name - for _, pregel in self.get_subgraphs( - namespace=recast_checkpoint_ns, recurse=True - ): + for _, pregel in self.get_subgraphs(namespace=recast, recurse=True): return pregel.update_state( patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}), values, as_node, ) else: - raise ValueError(f"Subgraph {recast_checkpoint_ns} not found") + raise ValueError(f"Subgraph {recast} not found") # get last checkpoint config = ensure_config(self.config, config) @@ -1163,20 +1144,16 @@ class Pregel(PregelProtocol): checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "") ) and CONFIG_KEY_CHECKPOINTER not in config[CONF]: # remove task_ids from checkpoint_ns - recast_checkpoint_ns = NS_SEP.join( - part.split(NS_END)[0] for part in checkpoint_ns.split(NS_SEP) - ) + recast = recast_checkpoint_ns(checkpoint_ns) # find the subgraph with the matching name - async for _, pregel in self.aget_subgraphs( - namespace=recast_checkpoint_ns, recurse=True - ): + async for _, pregel in self.aget_subgraphs(namespace=recast, recurse=True): return await pregel.aupdate_state( patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}), values, as_node, ) else: - raise ValueError(f"Subgraph {recast_checkpoint_ns} not found") + raise ValueError(f"Subgraph {recast} not found") # get last checkpoint config = ensure_config(self.config, config) diff --git a/libs/langgraph/langgraph/pregel/retry.py b/libs/langgraph/langgraph/pregel/retry.py index 43e7e8d9e..6d0e43b54 100644 --- a/libs/langgraph/langgraph/pregel/retry.py +++ b/libs/langgraph/langgraph/pregel/retry.py @@ -48,7 +48,10 @@ def run_with_retry( break elif cmd.graph == Command.PARENT: # this command is for the parent graph, assign it to the parent - parent_ns = NS_SEP.join(ns.split(NS_SEP)[:-1]) + parts = ns.split(NS_SEP) + if parts[-1].isdigit(): + parts.pop() + parent_ns = NS_SEP.join(parts[:-1]) exc.args = (replace(cmd, graph=parent_ns),) # bubble up raise @@ -133,7 +136,10 @@ async def arun_with_retry( break elif cmd.graph == Command.PARENT: # this command is for the parent graph, assign it to the parent - parent_ns = NS_SEP.join(ns.split(NS_SEP)[:-1]) + parts = ns.split(NS_SEP) + if parts[-1].isdigit(): + parts.pop() + parent_ns = NS_SEP.join(parts[:-1]) exc.args = (replace(cmd, graph=parent_ns),) # bubble up raise diff --git a/libs/langgraph/langgraph/utils/config.py b/libs/langgraph/langgraph/utils/config.py index ac803cc35..309c6d6be 100644 --- a/libs/langgraph/langgraph/utils/config.py +++ b/libs/langgraph/langgraph/utils/config.py @@ -23,9 +23,25 @@ from langgraph.constants import ( CONFIG_KEY_CHECKPOINT_ID, CONFIG_KEY_CHECKPOINT_MAP, CONFIG_KEY_CHECKPOINT_NS, + NS_END, + NS_SEP, ) +def recast_checkpoint_ns(ns: str) -> str: + """Remove task IDs from checkpoint namespace. + + Args: + ns (str): The checkpoint namespace with task IDs. + + Returns: + str: The checkpoint namespace without task IDs. + """ + return NS_SEP.join( + part.split(NS_END)[0] for part in ns.split(NS_SEP) if not part.isdigit() + ) + + def patch_configurable( config: Optional[RunnableConfig], patch: dict[str, Any] ) -> RunnableConfig: diff --git a/libs/scheduler-kafka/langgraph/scheduler/kafka/executor.py b/libs/scheduler-kafka/langgraph/scheduler/kafka/executor.py index 970d55be8..fa9a221d0 100644 --- a/libs/scheduler-kafka/langgraph/scheduler/kafka/executor.py +++ b/libs/scheduler-kafka/langgraph/scheduler/kafka/executor.py @@ -1,5 +1,6 @@ import asyncio import concurrent.futures +from collections.abc import Sequence from contextlib import ( AbstractAsyncContextManager, AbstractContextManager, @@ -7,7 +8,7 @@ from contextlib import ( ExitStack, ) from functools import partial -from typing import Any, Optional, Sequence +from typing import Any, Optional from uuid import UUID import orjson @@ -15,7 +16,7 @@ from langchain_core.runnables import RunnableConfig from typing_extensions import Self import langgraph.scheduler.kafka.serde as serde -from langgraph.constants import CONFIG_KEY_DELEGATE, ERROR, NS_END, NS_SEP +from langgraph.constants import CONFIG_KEY_DELEGATE, ERROR from langgraph.errors import CheckpointNotLatest, GraphDelegate, TaskNotFound from langgraph.pregel import Pregel from langgraph.pregel.algo import prepare_single_task @@ -39,7 +40,7 @@ from langgraph.scheduler.kafka.types import ( Topics, ) from langgraph.types import LoopProtocol, PregelExecutableTask, RetryPolicy -from langgraph.utils.config import patch_configurable +from langgraph.utils.config import patch_configurable, recast_checkpoint_ns class AsyncKafkaExecutor(AbstractAsyncContextManager): @@ -165,14 +166,12 @@ class AsyncKafkaExecutor(AbstractAsyncContextManager): # find graph if checkpoint_ns := msg["config"]["configurable"].get("checkpoint_ns"): # remove task_ids from checkpoint_ns - recast_checkpoint_ns = NS_SEP.join( - part.split(NS_END)[0] for part in checkpoint_ns.split(NS_SEP) - ) + recast = recast_checkpoint_ns(checkpoint_ns) # find the subgraph with the matching name - if recast_checkpoint_ns in self.subgraphs: - graph = self.subgraphs[recast_checkpoint_ns] + if recast in self.subgraphs: + graph = self.subgraphs[recast] else: - raise ValueError(f"Subgraph {recast_checkpoint_ns} not found") + raise ValueError(f"Subgraph {recast} not found") else: graph = self.graph # process message @@ -183,16 +182,19 @@ class AsyncKafkaExecutor(AbstractAsyncContextManager): raise RuntimeError("Checkpoint not found") if saved.checkpoint["id"] != msg["config"]["configurable"]["checkpoint_id"]: raise CheckpointNotLatest() - async with AsyncChannelsManager( - graph.channels, - saved.checkpoint, - LoopProtocol( - config=msg["config"], - store=self.graph.store, - step=saved.metadata["step"] + 1, - stop=saved.metadata["step"] + 2, - ), - ) as (channels, managed), AsyncBackgroundExecutor(msg["config"]) as submit: + async with ( + AsyncChannelsManager( + graph.channels, + saved.checkpoint, + LoopProtocol( + config=msg["config"], + store=self.graph.store, + step=saved.metadata["step"] + 1, + stop=saved.metadata["step"] + 2, + ), + ) as (channels, managed), + AsyncBackgroundExecutor(msg["config"]) as submit, + ): if task := await asyncio.to_thread( prepare_single_task, msg["task"]["path"], @@ -378,14 +380,12 @@ class KafkaExecutor(AbstractContextManager): # find graph if checkpoint_ns := msg["config"]["configurable"].get("checkpoint_ns"): # remove task_ids from checkpoint_ns - recast_checkpoint_ns = NS_SEP.join( - part.split(NS_END)[0] for part in checkpoint_ns.split(NS_SEP) - ) + recast = recast_checkpoint_ns(checkpoint_ns) # find the subgraph with the matching name - if recast_checkpoint_ns in self.subgraphs: - graph = self.subgraphs[recast_checkpoint_ns] + if recast in self.subgraphs: + graph = self.subgraphs[recast] else: - raise ValueError(f"Subgraph {recast_checkpoint_ns} not found") + raise ValueError(f"Subgraph {recast} not found") else: graph = self.graph # process message @@ -396,16 +396,19 @@ class KafkaExecutor(AbstractContextManager): raise RuntimeError("Checkpoint not found") if saved.checkpoint["id"] != msg["config"]["configurable"]["checkpoint_id"]: raise CheckpointNotLatest() - with ChannelsManager( - graph.channels, - saved.checkpoint, - LoopProtocol( - config=msg["config"], - store=self.graph.store, - step=saved.metadata["step"] + 1, - stop=saved.metadata["step"] + 2, - ), - ) as (channels, managed), BackgroundExecutor({}) as submit: + with ( + ChannelsManager( + graph.channels, + saved.checkpoint, + LoopProtocol( + config=msg["config"], + store=self.graph.store, + step=saved.metadata["step"] + 1, + stop=saved.metadata["step"] + 2, + ), + ) as (channels, managed), + BackgroundExecutor({}) as submit, + ): if task := prepare_single_task( msg["task"]["path"], msg["task"]["id"], diff --git a/libs/scheduler-kafka/langgraph/scheduler/kafka/orchestrator.py b/libs/scheduler-kafka/langgraph/scheduler/kafka/orchestrator.py index e3701b529..5527ec964 100644 --- a/libs/scheduler-kafka/langgraph/scheduler/kafka/orchestrator.py +++ b/libs/scheduler-kafka/langgraph/scheduler/kafka/orchestrator.py @@ -16,8 +16,6 @@ from langgraph.constants import ( CONFIG_KEY_DEDUPE_TASKS, CONFIG_KEY_ENSURE_LATEST, INTERRUPT, - NS_END, - NS_SEP, SCHEDULED, ) from langgraph.errors import CheckpointNotLatest, GraphInterrupt @@ -37,7 +35,7 @@ from langgraph.scheduler.kafka.types import ( Topics, ) from langgraph.types import RetryPolicy -from langgraph.utils.config import patch_configurable +from langgraph.utils.config import patch_configurable, recast_checkpoint_ns class AsyncKafkaOrchestrator(AbstractAsyncContextManager): @@ -140,14 +138,12 @@ class AsyncKafkaOrchestrator(AbstractAsyncContextManager): # find graph if checkpoint_ns := msg["config"]["configurable"].get("checkpoint_ns"): # remove task_ids from checkpoint_ns - recast_checkpoint_ns = NS_SEP.join( - part.split(NS_END)[0] for part in checkpoint_ns.split(NS_SEP) - ) + recast = recast_checkpoint_ns(checkpoint_ns) # find the subgraph with the matching name - if recast_checkpoint_ns in self.subgraphs: - graph = self.subgraphs[recast_checkpoint_ns] + if recast in self.subgraphs: + graph = self.subgraphs[recast] else: - raise ValueError(f"Subgraph {recast_checkpoint_ns} not found") + raise ValueError(f"Subgraph {recast} not found") else: graph = self.graph # process message @@ -329,14 +325,12 @@ class KafkaOrchestrator(AbstractContextManager): # find graph if checkpoint_ns := msg["config"]["configurable"].get("checkpoint_ns"): # remove task_ids from checkpoint_ns - recast_checkpoint_ns = NS_SEP.join( - part.split(NS_END)[0] for part in checkpoint_ns.split(NS_SEP) - ) + recast = recast_checkpoint_ns(checkpoint_ns) # find the subgraph with the matching name - if recast_checkpoint_ns in self.subgraphs: - graph = self.subgraphs[recast_checkpoint_ns] + if recast in self.subgraphs: + graph = self.subgraphs[recast] else: - raise ValueError(f"Subgraph {recast_checkpoint_ns} not found") + raise ValueError(f"Subgraph {recast} not found") else: graph = self.graph # process message From c26b0e78b6766e59b2c616692a4e447b46fab870 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 15 Jan 2025 17:15:58 -0800 Subject: [PATCH 27/52] Fix kafka lib --- libs/langgraph/langgraph/pregel/algo.py | 2 +- libs/langgraph/langgraph/pregel/loop.py | 2 +- .../langgraph/scheduler/kafka/orchestrator.py | 23 +++++++++++-------- libs/scheduler-kafka/tests/any.py | 8 +++++++ libs/scheduler-kafka/tests/test_subgraph.py | 8 ++++++- .../tests/test_subgraph_sync.py | 8 ++++++- 6 files changed, 37 insertions(+), 14 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/algo.py b/libs/langgraph/langgraph/pregel/algo.py index 205793ab4..39bc9462d 100644 --- a/libs/langgraph/langgraph/pregel/algo.py +++ b/libs/langgraph/langgraph/pregel/algo.py @@ -680,7 +680,7 @@ def prepare_single_task( "langgraph_checkpoint_ns": task_checkpoint_ns, } if task_id_checksum is not None: - assert task_id == task_id_checksum + assert task_id == task_id_checksum, f"{task_id} != {task_id_checksum}" if for_execution: if node := proc.node: if proc.metadata: diff --git a/libs/langgraph/langgraph/pregel/loop.py b/libs/langgraph/langgraph/pregel/loop.py index 0afe62658..1f2fe91a3 100644 --- a/libs/langgraph/langgraph/pregel/loop.py +++ b/libs/langgraph/langgraph/pregel/loop.py @@ -229,7 +229,7 @@ class PregelLoop(LoopProtocol): if self.stream is not None and CONFIG_KEY_STREAM in config[CONF]: self.stream = DuplexStream(self.stream, config[CONF][CONFIG_KEY_STREAM]) scratchpad: Optional[PregelScratchpad] = config[CONF].get(CONFIG_KEY_SCRATCHPAD) - if scratchpad is not None: + if not self.config[CONF].get(CONFIG_KEY_DELEGATE) and scratchpad is not None: if scratchpad["subgraph_counter"]: self.config = patch_configurable( self.config, diff --git a/libs/scheduler-kafka/langgraph/scheduler/kafka/orchestrator.py b/libs/scheduler-kafka/langgraph/scheduler/kafka/orchestrator.py index 5527ec964..57971e175 100644 --- a/libs/scheduler-kafka/langgraph/scheduler/kafka/orchestrator.py +++ b/libs/scheduler-kafka/langgraph/scheduler/kafka/orchestrator.py @@ -13,8 +13,10 @@ from typing_extensions import Self import langgraph.scheduler.kafka.serde as serde from langgraph.constants import ( + CONF, CONFIG_KEY_DEDUPE_TASKS, CONFIG_KEY_ENSURE_LATEST, + CONFIG_KEY_SCRATCHPAD, INTERRUPT, SCHEDULED, ) @@ -168,6 +170,16 @@ class AsyncKafkaOrchestrator(AbstractAsyncContextManager): if new_tasks := [ t for t in loop.tasks.values() if not t.scheduled and not t.writes ]: + config = patch_configurable( + loop.config, + { + **loop.checkpoint_config["configurable"], + CONFIG_KEY_DEDUPE_TASKS: True, + CONFIG_KEY_ENSURE_LATEST: True, + }, + ) + if CONFIG_KEY_SCRATCHPAD in config[CONF]: + config[CONF][CONFIG_KEY_SCRATCHPAD]["subgraph_counter"] = 0 # send messages to executor futures = await asyncio.gather( *( @@ -175,16 +187,7 @@ class AsyncKafkaOrchestrator(AbstractAsyncContextManager): self.topics.executor, value=serde.dumps( MessageToExecutor( - config=patch_configurable( - loop.config, - { - **loop.checkpoint_config[ - "configurable" - ], - CONFIG_KEY_DEDUPE_TASKS: True, - CONFIG_KEY_ENSURE_LATEST: True, - }, - ), + config=config, task=ExecutorTask(id=task.id, path=task.path), finally_send=msg.get("finally_send"), ) diff --git a/libs/scheduler-kafka/tests/any.py b/libs/scheduler-kafka/tests/any.py index 3ea224173..0336d8506 100644 --- a/libs/scheduler-kafka/tests/any.py +++ b/libs/scheduler-kafka/tests/any.py @@ -53,3 +53,11 @@ class AnyList(list): return False else: return True + + +class AnyInt(int): + def __init__(self) -> None: + super().__init__() + + def __eq__(self, other: object) -> bool: + return isinstance(other, int) diff --git a/libs/scheduler-kafka/tests/test_subgraph.py b/libs/scheduler-kafka/tests/test_subgraph.py index 55303cba0..053eaedd0 100644 --- a/libs/scheduler-kafka/tests/test_subgraph.py +++ b/libs/scheduler-kafka/tests/test_subgraph.py @@ -15,7 +15,7 @@ from langgraph.graph.state import StateGraph from langgraph.pregel import Pregel from langgraph.scheduler.kafka import serde from langgraph.scheduler.kafka.types import MessageToOrchestrator, Topics -from tests.any import AnyDict +from tests.any import AnyDict, AnyInt from tests.drain import drain_topics_async from tests.messages import _AnyIdAIMessage, _AnyIdHumanMessage @@ -198,6 +198,7 @@ async def test_subgraph_w_interrupt( "__pregel_store": None, "__pregel_task_id": history[0].tasks[0].id, "__pregel_scratchpad": { + "subgraph_counter": AnyInt(), "call_counter": 0, "interrupt_counter": -1, "null_resume": None, @@ -269,6 +270,7 @@ async def test_subgraph_w_interrupt( "__pregel_store": None, "__pregel_task_id": history[0].tasks[0].id, "__pregel_scratchpad": { + "subgraph_counter": AnyInt(), "call_counter": 0, "interrupt_counter": -1, "null_resume": None, @@ -370,6 +372,7 @@ async def test_subgraph_w_interrupt( "__pregel_store": None, "__pregel_task_id": history[0].tasks[0].id, "__pregel_scratchpad": { + "subgraph_counter": AnyInt(), "call_counter": 0, "interrupt_counter": -1, "null_resume": None, @@ -481,6 +484,7 @@ async def test_subgraph_w_interrupt( "__pregel_store": None, "__pregel_task_id": history[1].tasks[0].id, "__pregel_scratchpad": { + "subgraph_counter": AnyInt(), "call_counter": 0, "interrupt_counter": -1, "null_resume": None, @@ -547,6 +551,7 @@ async def test_subgraph_w_interrupt( "__pregel_store": None, "__pregel_task_id": history[1].tasks[0].id, "__pregel_scratchpad": { + "subgraph_counter": AnyInt(), "call_counter": 0, "interrupt_counter": -1, "null_resume": None, @@ -669,6 +674,7 @@ async def test_subgraph_w_interrupt( "__pregel_store": None, "__pregel_task_id": history[1].tasks[0].id, "__pregel_scratchpad": { + "subgraph_counter": AnyInt(), "call_counter": 0, "interrupt_counter": -1, "null_resume": None, diff --git a/libs/scheduler-kafka/tests/test_subgraph_sync.py b/libs/scheduler-kafka/tests/test_subgraph_sync.py index a67919dda..7d5de920c 100644 --- a/libs/scheduler-kafka/tests/test_subgraph_sync.py +++ b/libs/scheduler-kafka/tests/test_subgraph_sync.py @@ -15,7 +15,7 @@ from langgraph.pregel import Pregel from langgraph.scheduler.kafka import serde from langgraph.scheduler.kafka.default_sync import DefaultProducer from langgraph.scheduler.kafka.types import MessageToOrchestrator, Topics -from tests.any import AnyDict +from tests.any import AnyDict, AnyInt from tests.drain import drain_topics from tests.messages import _AnyIdAIMessage, _AnyIdHumanMessage @@ -197,6 +197,7 @@ def test_subgraph_w_interrupt( "__pregel_store": None, "__pregel_task_id": history[0].tasks[0].id, "__pregel_scratchpad": { + "subgraph_counter": AnyInt(), "call_counter": 0, "interrupt_counter": -1, "null_resume": None, @@ -268,6 +269,7 @@ def test_subgraph_w_interrupt( "__pregel_resuming": False, "__pregel_task_id": history[0].tasks[0].id, "__pregel_scratchpad": { + "subgraph_counter": AnyInt(), "call_counter": 0, "interrupt_counter": -1, "null_resume": None, @@ -369,6 +371,7 @@ def test_subgraph_w_interrupt( "__pregel_resuming": False, "__pregel_task_id": history[0].tasks[0].id, "__pregel_scratchpad": { + "subgraph_counter": AnyInt(), "call_counter": 0, "interrupt_counter": -1, "null_resume": None, @@ -479,6 +482,7 @@ def test_subgraph_w_interrupt( "__pregel_resuming": True, "__pregel_task_id": history[1].tasks[0].id, "__pregel_scratchpad": { + "subgraph_counter": AnyInt(), "call_counter": 0, "interrupt_counter": -1, "null_resume": None, @@ -545,6 +549,7 @@ def test_subgraph_w_interrupt( "__pregel_resuming": True, "__pregel_task_id": history[1].tasks[0].id, "__pregel_scratchpad": { + "subgraph_counter": AnyInt(), "call_counter": 0, "interrupt_counter": -1, "null_resume": None, @@ -667,6 +672,7 @@ def test_subgraph_w_interrupt( "__pregel_store": None, "__pregel_task_id": history[1].tasks[0].id, "__pregel_scratchpad": { + "subgraph_counter": AnyInt(), "call_counter": 0, "interrupt_counter": -1, "null_resume": None, From 6b9369876fc199ee77e2055a6ba96dd2b29f5446 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 15 Jan 2025 17:52:19 -0800 Subject: [PATCH 28/52] Fix sync --- .../langgraph/scheduler/kafka/orchestrator.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/libs/scheduler-kafka/langgraph/scheduler/kafka/orchestrator.py b/libs/scheduler-kafka/langgraph/scheduler/kafka/orchestrator.py index 57971e175..9ed72cd02 100644 --- a/libs/scheduler-kafka/langgraph/scheduler/kafka/orchestrator.py +++ b/libs/scheduler-kafka/langgraph/scheduler/kafka/orchestrator.py @@ -358,20 +358,23 @@ class KafkaOrchestrator(AbstractContextManager): if new_tasks := [ t for t in loop.tasks.values() if not t.scheduled and not t.writes ]: + config = patch_configurable( + loop.config, + { + **loop.checkpoint_config["configurable"], + CONFIG_KEY_DEDUPE_TASKS: True, + CONFIG_KEY_ENSURE_LATEST: True, + }, + ) + if CONFIG_KEY_SCRATCHPAD in config[CONF]: + config[CONF][CONFIG_KEY_SCRATCHPAD]["subgraph_counter"] = 0 # send messages to executor futures = [ self.producer.send( self.topics.executor, value=serde.dumps( MessageToExecutor( - config=patch_configurable( - loop.config, - { - **loop.checkpoint_config["configurable"], - CONFIG_KEY_DEDUPE_TASKS: True, - CONFIG_KEY_ENSURE_LATEST: True, - }, - ), + config=config, task=ExecutorTask(id=task.id, path=task.path), finally_send=msg.get("finally_send"), ) From 7ecad39ecbcf6fa5e815e55c89877369b1a733ca Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Wed, 15 Jan 2025 21:15:05 -0500 Subject: [PATCH 29/52] x --- libs/langgraph/tests/test_pregel.py | 101 ++++++++++++++++++++++++---- 1 file changed, 87 insertions(+), 14 deletions(-) diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 67536deed..fdbd032c4 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -1,3 +1,4 @@ +import asyncio import enum import json import logging @@ -35,6 +36,20 @@ from langchain_core.runnables import ( from langsmith import traceable from pytest_mock import MockerFixture from syrupy import SnapshotAssertion +from tests.agents import AgentAction, AgentFinish +from tests.any_str import AnyStr, AnyVersion, FloatBetween, UnsortedSequence +from tests.conftest import ( + ALL_CHECKPOINTERS_SYNC, + ALL_STORES_SYNC, + REGULAR_CHECKPOINTERS_SYNC, + SHOULD_CHECK_SNAPSHOTS, +) +from tests.memory_assert import MemorySaverAssertCheckpointMetadata +from tests.messages import ( + _AnyIdAIMessage, + _AnyIdHumanMessage, + _AnyIdToolMessage, +) from typing_extensions import TypedDict from langgraph.channels.base import BaseChannel @@ -67,20 +82,6 @@ from langgraph.types import ( StreamWriter, interrupt, ) -from tests.agents import AgentAction, AgentFinish -from tests.any_str import AnyStr, AnyVersion, FloatBetween, UnsortedSequence -from tests.conftest import ( - ALL_CHECKPOINTERS_SYNC, - ALL_STORES_SYNC, - REGULAR_CHECKPOINTERS_SYNC, - SHOULD_CHECK_SNAPSHOTS, -) -from tests.memory_assert import MemorySaverAssertCheckpointMetadata -from tests.messages import ( - _AnyIdAIMessage, - _AnyIdHumanMessage, - _AnyIdToolMessage, -) logger = logging.getLogger(__name__) @@ -5491,3 +5492,75 @@ def test_double_interrupt_subgraph( "invoke_sub_agent": {"input": True}, }, ] + + +def test_sync_streaming_with_functional_api() -> None: + """Test streaming with functional API. + + This test verifies that we're able to stream results as they're being generated + rather than have all the results arrive at once after the graph has completed. + + The time of arrival between the two updates corresponding to the two `slow` tasks + should be greater than the time delay between the two tasks. + """ + + time_delay = 0.01 + + @task() + def slow() -> dict: + time.sleep(time_delay) # Simulate a delay of 10 ms + return {"tic": time.time()} + + @entrypoint() + def graph(inputs: dict) -> list: + first = slow().result() + second = slow().result() + return [first, second] + + arrival_times = [] + + for chunk in graph.stream({}): + if "slow" not in chunk: # We'll just look at the updates from `slow` + continue + arrival_times.append(time.time()) + + assert len(arrival_times) == 2 + delta = arrival_times[1] - arrival_times[0] + # Delta cannot be less than 10 ms if it is streaming as results are generated. + assert delta > time_delay + + +async def test_async_streaming_with_functional_api() -> None: + """Test streaming with functional API. + + This test verifies that we're able to stream results as they're being generated + rather than have all the results arrive at once after the graph has completed. + + The time of arrival between the two updates corresponding to the two `slow` tasks + should be greater than the time delay between the two tasks. + """ + + time_delay = 0.01 + + @task() + async def slow() -> dict: + await asyncio.sleep(time_delay) # Simulate a delay of 10 ms + return {"tic": time.time()} + + @entrypoint() + async def graph(inputs: dict) -> list: + first = await slow() + second = await slow() + return [first, second] + + arrival_times = [] + + async for chunk in graph.astream({}): + if "slow" not in chunk: # We'll just look at the updates from `slow` + continue + arrival_times.append(time.time()) + + assert len(arrival_times) == 2 + delta = arrival_times[1] - arrival_times[0] + # Delta cannot be less than 10 ms if it is streaming as results are generated. + assert delta > time_delay From f9c25bba07cb7c84738c67258930739f1a71c78e Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Wed, 15 Jan 2025 21:15:38 -0500 Subject: [PATCH 30/52] x --- libs/langgraph/tests/test_pregel.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index fdbd032c4..4eae525d1 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -36,20 +36,6 @@ from langchain_core.runnables import ( from langsmith import traceable from pytest_mock import MockerFixture from syrupy import SnapshotAssertion -from tests.agents import AgentAction, AgentFinish -from tests.any_str import AnyStr, AnyVersion, FloatBetween, UnsortedSequence -from tests.conftest import ( - ALL_CHECKPOINTERS_SYNC, - ALL_STORES_SYNC, - REGULAR_CHECKPOINTERS_SYNC, - SHOULD_CHECK_SNAPSHOTS, -) -from tests.memory_assert import MemorySaverAssertCheckpointMetadata -from tests.messages import ( - _AnyIdAIMessage, - _AnyIdHumanMessage, - _AnyIdToolMessage, -) from typing_extensions import TypedDict from langgraph.channels.base import BaseChannel @@ -82,6 +68,20 @@ from langgraph.types import ( StreamWriter, interrupt, ) +from tests.agents import AgentAction, AgentFinish +from tests.any_str import AnyStr, AnyVersion, FloatBetween, UnsortedSequence +from tests.conftest import ( + ALL_CHECKPOINTERS_SYNC, + ALL_STORES_SYNC, + REGULAR_CHECKPOINTERS_SYNC, + SHOULD_CHECK_SNAPSHOTS, +) +from tests.memory_assert import MemorySaverAssertCheckpointMetadata +from tests.messages import ( + _AnyIdAIMessage, + _AnyIdHumanMessage, + _AnyIdToolMessage, +) logger = logging.getLogger(__name__) From 145220f2a81aafe4e0a0eca105379ba087484724 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 15 Jan 2025 18:29:34 -0800 Subject: [PATCH 31/52] Fix --- libs/langgraph/langgraph/func/__init__.py | 1 + libs/langgraph/langgraph/pregel/__init__.py | 20 ++++++- libs/langgraph/tests/test_pregel.py | 65 +++++---------------- libs/langgraph/tests/test_pregel_async.py | 56 ++++++++++++++---- 4 files changed, 79 insertions(+), 63 deletions(-) diff --git a/libs/langgraph/langgraph/func/__init__.py b/libs/langgraph/langgraph/func/__init__.py index d625be560..4749c075f 100644 --- a/libs/langgraph/langgraph/func/__init__.py +++ b/libs/langgraph/langgraph/func/__init__.py @@ -148,6 +148,7 @@ def entrypoint( output_channels=END, stream_channels=END, stream_mode=stream_mode, + stream_eager=True, checkpointer=checkpointer, store=store, ) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 102e68be8..6671bf70a 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -203,6 +203,10 @@ class Pregel(PregelProtocol): stream_mode: StreamMode = "values" """Mode to stream output, defaults to 'values'.""" + stream_eager: bool = False + """Whether to force emitting stream events eagerly, automatically turned on + for stream_mode "messages" and "custom".""" + output_channels: Union[str, Sequence[str]] stream_channels: Optional[Union[str, Sequence[str]]] = None @@ -242,6 +246,7 @@ class Pregel(PregelProtocol): channels: Optional[dict[str, Union[BaseChannel, ManagedValueSpec]]], auto_validate: bool = True, stream_mode: StreamMode = "values", + stream_eager: bool = False, output_channels: Union[str, Sequence[str]], stream_channels: Optional[Union[str, Sequence[str]]] = None, interrupt_after_nodes: Union[All, Sequence[str]] = (), @@ -259,6 +264,7 @@ class Pregel(PregelProtocol): self.nodes = nodes self.channels = channels or {} self.stream_mode = stream_mode + self.stream_eager = stream_eager self.output_channels = output_channels self.stream_channels = stream_channels self.interrupt_after_nodes = interrupt_after_nodes @@ -1655,7 +1661,12 @@ class Pregel(PregelProtocol): if subgraphs: loop.config[CONF][CONFIG_KEY_STREAM] = loop.stream # enable concurrent streaming - if subgraphs or "messages" in stream_modes or "custom" in stream_modes: + if ( + self.stream_eager + or subgraphs + or "messages" in stream_modes + or "custom" in stream_modes + ): # we are careful to have a single waiter live at any one time # because on exit we increment semaphore count by exactly 1 waiter: Optional[concurrent.futures.Future] = None @@ -1886,7 +1897,12 @@ class Pregel(PregelProtocol): stream_put, stream_modes ) # enable concurrent streaming - if subgraphs or "messages" in stream_modes or "custom" in stream_modes: + if ( + self.stream_eager + or subgraphs + or "messages" in stream_modes + or "custom" in stream_modes + ): def get_waiter() -> asyncio.Task[None]: return aioloop.create_task(stream.wait()) diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 4eae525d1..f35442bdf 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -1,4 +1,3 @@ -import asyncio import enum import json import logging @@ -2168,10 +2167,10 @@ def test_in_one_fan_out_state_graph_waiting_edge( @workflow.add_node def rewrite_query(data: State) -> State: - return {"query": f'query: {data["query"]}'} + return {"query": f"query: {data['query']}"} def analyzer_one(data: State) -> State: - return {"query": f'analyzed: {data["query"]}'} + return {"query": f"analyzed: {data['query']}"} def retriever_one(data: State) -> State: return {"docs": ["doc1", "doc2"]} @@ -2308,10 +2307,10 @@ def test_in_one_fan_out_state_graph_waiting_edge_via_branch( docs: Annotated[list[str], sorted_add] def rewrite_query(data: State) -> State: - return {"query": f'query: {data["query"]}'} + return {"query": f"query: {data['query']}"} def analyzer_one(data: State) -> State: - return {"query": f'analyzed: {data["query"]}'} + return {"query": f"analyzed: {data['query']}"} def retriever_one(data: State) -> State: return {"docs": ["doc1", "doc2"]} @@ -2741,11 +2740,11 @@ def test_in_one_fan_out_state_graph_waiting_edge_plus_regular( docs: Annotated[list[str], sorted_add] def rewrite_query(data: State) -> State: - return {"query": f'query: {data["query"]}'} + return {"query": f"query: {data['query']}"} def analyzer_one(data: State) -> State: time.sleep(0.1) - return {"query": f'analyzed: {data["query"]}'} + return {"query": f"analyzed: {data['query']}"} def retriever_one(data: State) -> State: return {"docs": ["doc1", "doc2"]} @@ -2831,10 +2830,10 @@ def test_in_one_fan_out_state_graph_waiting_edge_multiple() -> None: docs: Annotated[list[str], sorted_add] def rewrite_query(data: State) -> State: - return {"query": f'query: {data["query"]}'} + return {"query": f"query: {data['query']}"} def analyzer_one(data: State) -> State: - return {"query": f'analyzed: {data["query"]}'} + return {"query": f"analyzed: {data['query']}"} def retriever_one(data: State) -> State: return {"docs": ["doc1", "doc2"]} @@ -2904,10 +2903,10 @@ def test_callable_in_conditional_edges_with_no_path_map() -> None: query: str def rewrite(data: State) -> State: - return {"query": f'query: {data["query"]}'} + return {"query": f"query: {data['query']}"} def analyze(data: State) -> State: - return {"query": f'analyzed: {data["query"]}'} + return {"query": f"analyzed: {data['query']}"} class ChooseAnalyzer: def __call__(self, data: State) -> str: @@ -2930,10 +2929,10 @@ def test_function_in_conditional_edges_with_no_path_map() -> None: query: str def rewrite(data: State) -> State: - return {"query": f'query: {data["query"]}'} + return {"query": f"query: {data['query']}"} def analyze(data: State) -> State: - return {"query": f'analyzed: {data["query"]}'} + return {"query": f"analyzed: {data['query']}"} def choose_analyzer(data: State) -> str: return "analyzer" @@ -2966,13 +2965,13 @@ def test_in_one_fan_out_state_graph_waiting_edge_multiple_cond_edge() -> None: docs: Annotated[list[str], sorted_add] def rewrite_query(data: State) -> State: - return {"query": f'query: {data["query"]}'} + return {"query": f"query: {data['query']}"} def retriever_picker(data: State) -> list[str]: return ["analyzer_one", "retriever_two"] def analyzer_one(data: State) -> State: - return {"query": f'analyzed: {data["query"]}'} + return {"query": f"analyzed: {data['query']}"} def retriever_one(data: State) -> State: return {"docs": ["doc1", "doc2"]} @@ -5528,39 +5527,3 @@ def test_sync_streaming_with_functional_api() -> None: delta = arrival_times[1] - arrival_times[0] # Delta cannot be less than 10 ms if it is streaming as results are generated. assert delta > time_delay - - -async def test_async_streaming_with_functional_api() -> None: - """Test streaming with functional API. - - This test verifies that we're able to stream results as they're being generated - rather than have all the results arrive at once after the graph has completed. - - The time of arrival between the two updates corresponding to the two `slow` tasks - should be greater than the time delay between the two tasks. - """ - - time_delay = 0.01 - - @task() - async def slow() -> dict: - await asyncio.sleep(time_delay) # Simulate a delay of 10 ms - return {"tic": time.time()} - - @entrypoint() - async def graph(inputs: dict) -> list: - first = await slow() - second = await slow() - return [first, second] - - arrival_times = [] - - async for chunk in graph.astream({}): - if "slow" not in chunk: # We'll just look at the updates from `slow` - continue - arrival_times.append(time.time()) - - assert len(arrival_times) == 2 - delta = arrival_times[1] - arrival_times[0] - # Delta cannot be less than 10 ms if it is streaming as results are generated. - assert delta > time_delay diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 2f7d7a1c4..bdbec78e2 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -4294,10 +4294,10 @@ async def test_in_one_fan_out_state_graph_waiting_edge(checkpointer_name: str) - docs: Annotated[list[str], sorted_add] async def rewrite_query(data: State) -> State: - return {"query": f'query: {data["query"]}'} + return {"query": f"query: {data['query']}"} async def analyzer_one(data: State) -> State: - return {"query": f'analyzed: {data["query"]}'} + return {"query": f"analyzed: {data['query']}"} async def retriever_one(data: State) -> State: return {"docs": ["doc1", "doc2"]} @@ -4384,10 +4384,10 @@ async def test_in_one_fan_out_state_graph_waiting_edge_via_branch( docs: Annotated[list[str], sorted_add] async def rewrite_query(data: State) -> State: - return {"query": f'query: {data["query"]}'} + return {"query": f"query: {data['query']}"} async def analyzer_one(data: State) -> State: - return {"query": f'analyzed: {data["query"]}'} + return {"query": f"analyzed: {data['query']}"} async def retriever_one(data: State) -> State: return {"docs": ["doc1", "doc2"]} @@ -4801,11 +4801,11 @@ async def test_in_one_fan_out_state_graph_waiting_edge_plus_regular( docs: Annotated[list[str], sorted_add] async def rewrite_query(data: State) -> State: - return {"query": f'query: {data["query"]}'} + return {"query": f"query: {data['query']}"} async def analyzer_one(data: State) -> State: await asyncio.sleep(0.1) - return {"query": f'analyzed: {data["query"]}'} + return {"query": f"analyzed: {data['query']}"} async def retriever_one(data: State) -> State: return {"docs": ["doc1", "doc2"]} @@ -4895,10 +4895,10 @@ async def test_in_one_fan_out_state_graph_waiting_edge_multiple() -> None: docs: Annotated[list[str], sorted_add] async def rewrite_query(data: State) -> State: - return {"query": f'query: {data["query"]}'} + return {"query": f"query: {data['query']}"} async def analyzer_one(data: State) -> State: - return {"query": f'analyzed: {data["query"]}'} + return {"query": f"analyzed: {data['query']}"} async def retriever_one(data: State) -> State: return {"docs": ["doc1", "doc2"]} @@ -4979,13 +4979,13 @@ async def test_in_one_fan_out_state_graph_waiting_edge_multiple_cond_edge() -> N docs: Annotated[list[str], sorted_add] async def rewrite_query(data: State) -> State: - return {"query": f'query: {data["query"]}'} + return {"query": f"query: {data['query']}"} async def retriever_picker(data: State) -> list[str]: return ["analyzer_one", "retriever_two"] async def analyzer_one(data: State) -> State: - return {"query": f'analyzed: {data["query"]}'} + return {"query": f"analyzed: {data['query']}"} async def retriever_one(data: State) -> State: return {"docs": ["doc1", "doc2"]} @@ -6875,3 +6875,39 @@ async def test_double_interrupt_subgraph(checkpointer_name: str) -> None: "invoke_sub_agent": {"input": True}, }, ] + + +async def test_async_streaming_with_functional_api() -> None: + """Test streaming with functional API. + + This test verifies that we're able to stream results as they're being generated + rather than have all the results arrive at once after the graph has completed. + + The time of arrival between the two updates corresponding to the two `slow` tasks + should be greater than the time delay between the two tasks. + """ + + time_delay = 0.01 + + @task() + async def slow() -> dict: + await asyncio.sleep(time_delay) # Simulate a delay of 10 ms + return {"tic": asyncio.get_running_loop().time()} + + @entrypoint() + async def graph(inputs: dict) -> list: + first = await slow() + second = await slow() + return [first, second] + + arrival_times = [] + + async for chunk in graph.astream({}): + if "slow" not in chunk: # We'll just look at the updates from `slow` + continue + arrival_times.append(asyncio.get_running_loop().time()) + + assert len(arrival_times) == 2 + delta = arrival_times[1] - arrival_times[0] + # Delta cannot be less than 10 ms if it is streaming as results are generated. + assert delta > time_delay From 0e4dbb4c62dc9542ff6a5728fea5d3a3390c9531 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 15 Jan 2025 18:36:49 -0800 Subject: [PATCH 32/52] Guard --- libs/langgraph/tests/test_pregel_async.py | 81 ++++++----------------- 1 file changed, 21 insertions(+), 60 deletions(-) diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index bdbec78e2..9e984d214 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -89,6 +89,11 @@ logger = logging.getLogger(__name__) pytestmark = pytest.mark.anyio +NEEDS_CONTEXTVARS = pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Python 3.11+ is required for async contextvars support", +) + async def test_checkpoint_errors() -> None: class FaultyGetCheckpointer(MemorySaver): @@ -501,10 +506,7 @@ async def test_node_cancellation_on_other_node_exception_two() -> None: await graph.ainvoke(1) -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="Python 3.11+ is required for async contextvars support", -) +@NEEDS_CONTEXTVARS @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) async def test_dynamic_interrupt(checkpointer_name: str) -> None: class State(TypedDict): @@ -678,10 +680,7 @@ async def test_dynamic_interrupt(checkpointer_name: str) -> None: ) -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="Python 3.11+ is required for async contextvars support", -) +@NEEDS_CONTEXTVARS @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) async def test_dynamic_interrupt_subgraph(checkpointer_name: str) -> None: class SubgraphState(TypedDict): @@ -872,10 +871,7 @@ async def test_dynamic_interrupt_subgraph(checkpointer_name: str) -> None: ) -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="Python 3.11+ is required for async contextvars support", -) +@NEEDS_CONTEXTVARS @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) async def test_copy_checkpoint(checkpointer_name: str) -> None: class State(TypedDict): @@ -1079,10 +1075,7 @@ async def test_copy_checkpoint(checkpointer_name: str) -> None: ) -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="Python 3.11+ is required for async contextvars support", -) +@NEEDS_CONTEXTVARS @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) async def test_node_not_cancelled_on_other_node_interrupted( checkpointer_name: str, @@ -2442,10 +2435,7 @@ async def test_send_sequences(checkpointer_name: str) -> None: ] -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="Python 3.11+ is required for async contextvars support", -) +@NEEDS_CONTEXTVARS @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) async def test_imp_task(checkpointer_name: str) -> None: async with awith_checkpointer(checkpointer_name) as checkpointer: @@ -2493,10 +2483,7 @@ async def test_imp_task(checkpointer_name: str) -> None: assert mapper_calls == 2 -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="Python 3.11+ is required for async contextvars support", -) +@NEEDS_CONTEXTVARS @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) async def test_imp_task_cancel(checkpointer_name: str) -> None: async with awith_checkpointer(checkpointer_name) as checkpointer: @@ -2547,10 +2534,7 @@ async def test_imp_task_cancel(checkpointer_name: str) -> None: assert mapper_cancels == 2 -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="Python 3.11+ is required for async contextvars support", -) +@NEEDS_CONTEXTVARS @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) async def test_imp_sync_from_async(checkpointer_name: str) -> None: async with awith_checkpointer(checkpointer_name) as checkpointer: @@ -2583,10 +2567,7 @@ async def test_imp_sync_from_async(checkpointer_name: str) -> None: ] -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="Python 3.11+ is required for async contextvars support", -) +@NEEDS_CONTEXTVARS @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) async def test_imp_stream_order(checkpointer_name: str) -> None: async with awith_checkpointer(checkpointer_name) as checkpointer: @@ -6117,10 +6098,7 @@ async def test_parent_command(checkpointer_name: str) -> None: ) -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="Python 3.11+ is required for async contextvars support", -) +@NEEDS_CONTEXTVARS @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) async def test_interrupt_subgraph(checkpointer_name: str): class State(TypedDict): @@ -6153,10 +6131,7 @@ async def test_interrupt_subgraph(checkpointer_name: str): assert await graph.ainvoke(Command(resume="bar"), thread1) -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="Python 3.11+ is required for async contextvars support", -) +@NEEDS_CONTEXTVARS @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) async def test_interrupt_multiple(checkpointer_name: str): class State(TypedDict): @@ -6220,10 +6195,7 @@ async def test_interrupt_multiple(checkpointer_name: str): ] -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="Python 3.11+ is required for async contextvars support", -) +@NEEDS_CONTEXTVARS @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) async def test_interrupt_loop(checkpointer_name: str): class State(TypedDict): @@ -6508,10 +6480,7 @@ async def test_parallel_node_execution(): assert duration < 3.0 -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="Python 3.11+ is required for async contextvars support", -) +@NEEDS_CONTEXTVARS @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) async def test_multiple_interrupt_state_persistence(checkpointer_name: str) -> None: """Test that state is preserved correctly across multiple interrupts.""" @@ -6692,10 +6661,7 @@ async def test_multiple_updates() -> None: ] -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="Python 3.11+ is required for async contextvars support", -) +@NEEDS_CONTEXTVARS @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) async def test_falsy_return_from_task(checkpointer_name: str) -> None: """Test with a falsy return from a task.""" @@ -6717,10 +6683,7 @@ async def test_falsy_return_from_task(checkpointer_name: str) -> None: await graph.ainvoke(Command(resume="123"), configurable) -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="Python 3.11+ is required for async contextvars support", -) +@NEEDS_CONTEXTVARS @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) async def test_multiple_interrupts_imperative(checkpointer_name: str) -> None: """Test multiple interrupts with an imperative API.""" @@ -6760,10 +6723,7 @@ async def test_multiple_interrupts_imperative(checkpointer_name: str) -> None: assert counter == 3 -@pytest.mark.skipif( - sys.version_info < (3, 11), - reason="Python 3.11+ is required for async contextvars support", -) +@NEEDS_CONTEXTVARS @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) async def test_double_interrupt_subgraph(checkpointer_name: str) -> None: class AgentState(TypedDict): @@ -6877,6 +6837,7 @@ async def test_double_interrupt_subgraph(checkpointer_name: str) -> None: ] +@NEEDS_CONTEXTVARS async def test_async_streaming_with_functional_api() -> None: """Test streaming with functional API. From 7ecda42b421cae527f440b8395633b830ea04b55 Mon Sep 17 00:00:00 2001 From: Vadym Barda Date: Wed, 15 Jan 2025 22:16:28 -0500 Subject: [PATCH 33/52] checkpoint-postgres: bring back missing migration (#3058) --- libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py index 89275f111..0d901a78c 100644 --- a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py @@ -57,6 +57,9 @@ MIGRATIONS = [ PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) );""", "ALTER TABLE checkpoint_blobs ALTER COLUMN blob DROP not null;", + # NOTE: this is a no-op migration to ensure that the versions in the migrations table are correct. + # This is necessary due to an empty migration previously added to the list. + "SELECT 1;", """ CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoints_thread_id_idx ON checkpoints(thread_id); """, From 89b0be3a7d334bb9dfdf30c1d9a2ecf72ce1a80d Mon Sep 17 00:00:00 2001 From: Vadym Barda Date: Thu, 16 Jan 2025 09:17:43 -0500 Subject: [PATCH 34/52] checkpoint-postgres: release 2.0.13 (#3063) --- libs/checkpoint-postgres/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/checkpoint-postgres/pyproject.toml b/libs/checkpoint-postgres/pyproject.toml index addcdfe20..4a62d75fc 100644 --- a/libs/checkpoint-postgres/pyproject.toml +++ b/libs/checkpoint-postgres/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langgraph-checkpoint-postgres" -version = "2.0.12" +version = "2.0.13" description = "Library with a Postgres implementation of LangGraph checkpoint saver." authors = [] license = "MIT" From adff439d4e64b28f5a54d5809f5b34f4f416d862 Mon Sep 17 00:00:00 2001 From: Vadym Barda Date: Thu, 16 Jan 2025 09:19:25 -0500 Subject: [PATCH 35/52] langgraph: release 0.2.63 (#3064) --- libs/langgraph/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/langgraph/pyproject.toml b/libs/langgraph/pyproject.toml index 04c338c56..c206bcabf 100644 --- a/libs/langgraph/pyproject.toml +++ b/libs/langgraph/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langgraph" -version = "0.2.62" +version = "0.2.63" description = "Building stateful, multi-actor applications with LLMs" authors = [] license = "MIT" From 8b1597a38543534d46e7228938e50670cf0f3083 Mon Sep 17 00:00:00 2001 From: vbarda Date: Thu, 16 Jan 2025 10:13:41 -0500 Subject: [PATCH 36/52] tests: add a test for interrupt() w/ functional API --- libs/langgraph/tests/test_pregel.py | 31 +++++++++++++++++++++++ libs/langgraph/tests/test_pregel_async.py | 27 ++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 64b5076de..a50de8466 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -4902,6 +4902,37 @@ def test_interrupt_loop(request: pytest.FixtureRequest, checkpointer_name: str): ] +@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC) +def test_interrupt_functional( + request: pytest.FixtureRequest, checkpointer_name: str +) -> None: + checkpointer: BaseCheckpointSaver = request.getfixturevalue( + f"checkpointer_{checkpointer_name}" + ) + + @task + def foo(state: dict) -> dict: + return {"a": state["a"] + "foo"} + + @task + def bar(state: dict) -> dict: + value = interrupt("Provide value for bar:") + return {"a": state["a"] + value} + + @entrypoint(checkpointer=checkpointer) + def graph(inputs: dict) -> dict: + fut_foo = foo(inputs) + fut_bar = bar(fut_foo.result()) + return fut_bar.result() + + config = {"configurable": {"thread_id": "1"}} + # First run, interrupted at bar + graph.invoke({"a": ""}, config) + # Resume with an answer + res = graph.invoke(Command(resume="bar"), config) + assert res == {"a": "foobar"} + + def test_root_mixed_return() -> None: def my_node(state: list[str]): return [Command(update=["a"]), ["b"]] diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 558a93709..1662ac478 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -6281,6 +6281,33 @@ async def test_interrupt_loop(checkpointer_name: str): ] +@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) +async def test_interrupt_functional(checkpointer_name: str) -> None: + @task + async def foo(state: dict) -> dict: + return {"a": state["a"] + "foo"} + + @task + async def bar(state: dict) -> dict: + value = interrupt("Provide value for bar:") + return {"a": state["a"] + value} + + async with awith_checkpointer(checkpointer_name) as checkpointer: + + @entrypoint(checkpointer=checkpointer) + async def graph(inputs: dict) -> dict: + foo_result = await foo(inputs) + bar_result = await bar(foo_result) + return bar_result + + config = {"configurable": {"thread_id": "1"}} + # First run, interrupted at bar + await graph.ainvoke({"a": ""}, config) + # Resume with an answer + res = await graph.ainvoke(Command(resume="bar"), config) + assert res == {"a": "foobar"} + + @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) async def test_command_with_static_breakpoints(checkpointer_name: str) -> None: """Test that we can use Command to resume and update with static breakpoints.""" From ba672604a6fd3307ecb2b21c49e065a95ae4d7dd Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Thu, 16 Jan 2025 07:57:00 -0800 Subject: [PATCH 37/52] Implement input/output schemas for imperative api --- libs/langgraph/langgraph/func/__init__.py | 23 ++++++++++++++++++++++- libs/langgraph/tests/test_pregel.py | 11 +++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/libs/langgraph/langgraph/func/__init__.py b/libs/langgraph/langgraph/func/__init__.py index 4749c075f..0e3cd5793 100644 --- a/libs/langgraph/langgraph/func/__init__.py +++ b/libs/langgraph/langgraph/func/__init__.py @@ -112,6 +112,7 @@ def entrypoint( store: Optional[BaseStore] = None, ) -> Callable[[types.FunctionType], Pregel]: def _imp(func: types.FunctionType) -> Pregel: + # wrap generators in a function that writes to StreamWriter if inspect.isgeneratorfunction(func): def gen_wrapper(*args: Any, writer: StreamWriter, **kwargs: Any) -> Any: @@ -134,6 +135,23 @@ def entrypoint( bound = get_runnable_for_func(func) stream_mode = "updates" + # get input and output types + sig = inspect.signature(func) + first_parameter_name = next(iter(sig.parameters.keys()), None) + if not first_parameter_name: + raise ValueError("Entrypoint function must have at least one parameter") + input_type = ( + sig.parameters[first_parameter_name].annotation + if sig.parameters[first_parameter_name].annotation + is not inspect.Signature.empty + else Any + ) + output_type = ( + sig.return_annotation + if sig.return_annotation is not inspect.Signature.empty + else Any + ) + return Pregel( nodes={ func.__name__: PregelNode( @@ -143,7 +161,10 @@ def entrypoint( writers=[ChannelWrite([ChannelWriteEntry(END)], tags=[TAG_HIDDEN])], ) }, - channels={START: EphemeralValue(Any), END: LastValue(Any, END)}, + channels={ + START: EphemeralValue(input_type), + END: LastValue(output_type, END), + }, input_channels=START, output_channels=END, stream_channels=END, diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 64b5076de..905b58b2e 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -1450,6 +1450,17 @@ def test_imp_task(request: pytest.FixtureRequest, checkpointer_name: str) -> Non answer = interrupt("question") return [m + answer for m in mapped] + assert graph.get_input_jsonschema() == { + "type": "array", + "items": {"type": "integer"}, + "title": "LangGraphInput", + } + assert graph.get_output_jsonschema() == { + "type": "array", + "items": {"type": "string"}, + "title": "LangGraphOutput", + } + thread1 = {"configurable": {"thread_id": "1"}} assert [*graph.stream([0, 1], thread1)] == [ {"mapper": "00"}, From 46056363b3649af4ba080ec31ab2aff24179d74c Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Thu, 16 Jan 2025 08:00:08 -0800 Subject: [PATCH 38/52] Make config schema configurable for imperative api --- libs/langgraph/langgraph/func/__init__.py | 2 ++ libs/langgraph/tests/test_pregel.py | 38 ++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/libs/langgraph/langgraph/func/__init__.py b/libs/langgraph/langgraph/func/__init__.py index 0e3cd5793..76dec76d2 100644 --- a/libs/langgraph/langgraph/func/__init__.py +++ b/libs/langgraph/langgraph/func/__init__.py @@ -110,6 +110,7 @@ def entrypoint( *, checkpointer: Optional[BaseCheckpointSaver] = None, store: Optional[BaseStore] = None, + config_schema: Optional[type[Any]] = None, ) -> Callable[[types.FunctionType], Pregel]: def _imp(func: types.FunctionType) -> Pregel: # wrap generators in a function that writes to StreamWriter @@ -172,6 +173,7 @@ def entrypoint( stream_eager=True, checkpointer=checkpointer, store=store, + config_type=config_schema, ) return _imp diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 905b58b2e..4dec2ac05 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -1436,6 +1436,9 @@ def test_imp_task(request: pytest.FixtureRequest, checkpointer_name: str) -> Non checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}") mapper_calls = 0 + class Config: + model: str + @task() def mapper(input: int) -> str: nonlocal mapper_calls @@ -1443,7 +1446,7 @@ def test_imp_task(request: pytest.FixtureRequest, checkpointer_name: str) -> Non time.sleep(input / 100) return str(input) * 2 - @entrypoint(checkpointer=checkpointer) + @entrypoint(checkpointer=checkpointer, config_schema=Config) def graph(input: list[int]) -> list[str]: futures = [mapper(i) for i in input] mapped = [f.result() for f in futures] @@ -1460,6 +1463,39 @@ def test_imp_task(request: pytest.FixtureRequest, checkpointer_name: str) -> Non "items": {"type": "string"}, "title": "LangGraphOutput", } + assert graph.get_config_jsonschema() == { + "$defs": { + "Configurable": { + "properties": { + "model": {"default": None, "title": "Model", "type": "string"}, + "checkpoint_id": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, + "description": "Pass to fetch a past checkpoint. If None, fetches the latest checkpoint.", + "title": "Checkpoint ID", + }, + "checkpoint_ns": { + "default": "", + "description": 'Checkpoint namespace. Denotes the path to the subgraph node the checkpoint originates from, separated by `|` character, e.g. `"child|grandchild"`. Defaults to "" (root graph).', + "title": "Checkpoint NS", + "type": "string", + }, + "thread_id": { + "default": "", + "title": "Thread ID", + "type": "string", + }, + }, + "title": "Configurable", + "type": "object", + } + }, + "properties": { + "configurable": {"$ref": "#/$defs/Configurable", "default": None} + }, + "title": "LangGraphConfig", + "type": "object", + } thread1 = {"configurable": {"thread_id": "1"}} assert [*graph.stream([0, 1], thread1)] == [ From c4460e5dd2e69626ce5a0a58f604aad64c07b15c Mon Sep 17 00:00:00 2001 From: vbarda Date: Thu, 16 Jan 2025 11:16:09 -0500 Subject: [PATCH 39/52] add another test --- libs/langgraph/tests/test_pregel.py | 32 +++++++++++++++++++++++ libs/langgraph/tests/test_pregel_async.py | 30 ++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index a50de8466..83d0f792b 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -4910,6 +4910,38 @@ def test_interrupt_functional( f"checkpointer_{checkpointer_name}" ) + @task + def foo(state: dict) -> dict: + return {"a": state["a"] + "foo"} + + @task + def bar(state: dict) -> dict: + return {"a": state["a"] + "bar", "b": state["b"]} + + @entrypoint(checkpointer=checkpointer) + def graph(inputs: dict) -> dict: + fut_foo = foo(inputs) + value = interrupt("Provide value for bar:") + bar_input = {**fut_foo.result(), "b": value} + fut_bar = bar(bar_input) + return fut_bar.result() + + config = {"configurable": {"thread_id": "1"}} + # First run, interrupted at bar + graph.invoke({"a": ""}, config) + # Resume with an answer + res = graph.invoke(Command(resume="bar"), config) + assert res == {"a": "foobar", "b": "bar"} + + +@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC) +def test_interrupt_task_functional( + request: pytest.FixtureRequest, checkpointer_name: str +) -> None: + checkpointer: BaseCheckpointSaver = request.getfixturevalue( + f"checkpointer_{checkpointer_name}" + ) + @task def foo(state: dict) -> dict: return {"a": state["a"] + "foo"} diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 1662ac478..541781c74 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -6287,6 +6287,34 @@ async def test_interrupt_functional(checkpointer_name: str) -> None: async def foo(state: dict) -> dict: return {"a": state["a"] + "foo"} + @task + async def bar(state: dict) -> dict: + return {"a": state["a"] + "bar", "b": state["b"]} + + async with awith_checkpointer(checkpointer_name) as checkpointer: + + @entrypoint(checkpointer=checkpointer) + async def graph(inputs: dict) -> dict: + foo_result = await foo(inputs) + value = interrupt("Provide value for bar:") + bar_input = {**foo_result, "b": value} + bar_result = await bar(bar_input) + return bar_result + + config = {"configurable": {"thread_id": "1"}} + # First run, interrupted at bar + await graph.ainvoke({"a": ""}, config) + # Resume with an answer + res = await graph.ainvoke(Command(resume="bar"), config) + assert res == {"a": "foobar", "b": "bar"} + + +@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) +async def test_interrupt_task_functional(checkpointer_name: str) -> None: + @task + async def foo(state: dict) -> dict: + return {"a": state["a"] + "foo"} + @task async def bar(state: dict) -> dict: value = interrupt("Provide value for bar:") @@ -6305,7 +6333,7 @@ async def test_interrupt_functional(checkpointer_name: str) -> None: await graph.ainvoke({"a": ""}, config) # Resume with an answer res = await graph.ainvoke(Command(resume="bar"), config) - assert res == {"a": "foobar"} + assert res == {"a": "foobar"} @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) From 8c88e203bc541c50e27a296657aef9941fd5b5f1 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Thu, 16 Jan 2025 09:51:46 -0800 Subject: [PATCH 40/52] Fix --- libs/langgraph/langgraph/pregel/loop.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/loop.py b/libs/langgraph/langgraph/pregel/loop.py index 1f2fe91a3..e92f0c267 100644 --- a/libs/langgraph/langgraph/pregel/loop.py +++ b/libs/langgraph/langgraph/pregel/loop.py @@ -345,11 +345,11 @@ class PregelLoop(LoopProtocol): (PUSH, task.path, write_idx, task.id, call), None, checkpoint=self.checkpoint, - pending_writes=[(task.id, *w) for w in task.writes], + pending_writes=self.checkpoint_pending_writes, processes=self.nodes, channels=self.channels, managed=self.managed, - config=self.config, + config=task.config, step=self.step, for_execution=True, store=self.store, From a16def5140d45be78d0ae70651b71a9c37c7f54e Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Thu, 16 Jan 2025 09:55:04 -0800 Subject: [PATCH 41/52] Lint --- libs/langgraph/tests/test_pregel_async.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 541781c74..6767a1510 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -6281,6 +6281,7 @@ async def test_interrupt_loop(checkpointer_name: str): ] +@NEEDS_CONTEXTVARS @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) async def test_interrupt_functional(checkpointer_name: str) -> None: @task @@ -6309,6 +6310,7 @@ async def test_interrupt_functional(checkpointer_name: str) -> None: assert res == {"a": "foobar", "b": "bar"} +@NEEDS_CONTEXTVARS @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) async def test_interrupt_task_functional(checkpointer_name: str) -> None: @task From 421f7c0238b3a8740ee48c5cdd99e7185ea1e2e5 Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Thu, 16 Jan 2025 13:55:41 -0500 Subject: [PATCH 42/52] x --- libs/langgraph/langgraph/pregel/loop.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/loop.py b/libs/langgraph/langgraph/pregel/loop.py index 1f2fe91a3..e92f0c267 100644 --- a/libs/langgraph/langgraph/pregel/loop.py +++ b/libs/langgraph/langgraph/pregel/loop.py @@ -345,11 +345,11 @@ class PregelLoop(LoopProtocol): (PUSH, task.path, write_idx, task.id, call), None, checkpoint=self.checkpoint, - pending_writes=[(task.id, *w) for w in task.writes], + pending_writes=self.checkpoint_pending_writes, processes=self.nodes, channels=self.channels, managed=self.managed, - config=self.config, + config=task.config, step=self.step, for_execution=True, store=self.store, From e476897177daf306b8a0e280971c27f00bd63b6c Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Thu, 16 Jan 2025 13:58:55 -0500 Subject: [PATCH 43/52] fix merge error --- libs/langgraph/langgraph/pregel/algo.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/langgraph/langgraph/pregel/algo.py b/libs/langgraph/langgraph/pregel/algo.py index 08dcf77e6..d1247dbc4 100644 --- a/libs/langgraph/langgraph/pregel/algo.py +++ b/libs/langgraph/langgraph/pregel/algo.py @@ -507,6 +507,7 @@ def prepare_single_task( CONFIG_KEY_SCRATCHPAD: _scratchpad( pending_writes, task_id, + ), CONFIG_KEY_END: checkpoint["channel_values"].get( "__end__", None ), @@ -745,7 +746,7 @@ def prepare_single_task( ), CONFIG_KEY_END: checkpoint["channel_values"].get( "__end__", None - + ), }, ), triggers, From 582856b30c3c6ec0b9494c291bc9cfb4bac34218 Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Thu, 16 Jan 2025 14:00:42 -0500 Subject: [PATCH 44/52] x --- libs/langgraph/tests/test_pregel.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 75958221f..56d18e1b2 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -5598,6 +5598,34 @@ def test_entrypoint_without_checkpointer() -> None: assert foo.invoke({"a": "1"}, config) == {"current": {"a": "1"}, "previous": None} +async def test_async_entrypoint_without_checkpointer() -> None: + """Test no checkpointer.""" + states = [] + config = {"configurable": {"thread_id": "1"}} + + # Test without previous + @entrypoint() + async def foo(inputs: Any) -> Any: + states.append(inputs) + return inputs + + assert (await foo.ainvoke({"a": "1"}, config)) == {"a": "1"} + + @entrypoint() + async def foo(inputs: Any, *, previous: Any) -> Any: + states.append(previous) + return {"previous": previous, "current": inputs} + + assert (await foo.ainvoke({"a": "1"}, config)) == { + "current": {"a": "1"}, + "previous": None, + } + assert (await foo.ainvoke({"a": "1"}, config)) == { + "current": {"a": "1"}, + "previous": None, + } + + def test_entrypoint_stateful() -> None: """Test stateful entrypoint invoke.""" From 46907b6cf9f619d0685201e947606ec9a79e26a9 Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Thu, 16 Jan 2025 14:04:00 -0500 Subject: [PATCH 45/52] x --- libs/langgraph/tests/test_pregel.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 56d18e1b2..c1575dd7f 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -15,7 +15,6 @@ from typing import ( Any, Dict, Generator, - Iterable, Iterator, List, Literal, From d021f476db8cb0227971d84b215df36a696207c0 Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Thu, 16 Jan 2025 14:23:32 -0500 Subject: [PATCH 46/52] x --- libs/langgraph/langgraph/func/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/langgraph/langgraph/func/__init__.py b/libs/langgraph/langgraph/func/__init__.py index d873fdaed..65b4eb76e 100644 --- a/libs/langgraph/langgraph/func/__init__.py +++ b/libs/langgraph/langgraph/func/__init__.py @@ -122,7 +122,7 @@ def entrypoint( Returns: A Pregel graph. """ - # wrap generators in a function that writes to StreamWriter + # wrap generators in a function that writes to StreamWriter if inspect.isgeneratorfunction(func): original_sig = inspect.signature(func) # Check if original signature has a writer argument with a matching type. From b218cc76a7366e13e0020f7aa20ddb30c6b52689 Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Thu, 16 Jan 2025 14:50:40 -0500 Subject: [PATCH 47/52] type ignore for now --- libs/langgraph/langgraph/func/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/langgraph/langgraph/func/__init__.py b/libs/langgraph/langgraph/func/__init__.py index 65b4eb76e..a051e465e 100644 --- a/libs/langgraph/langgraph/func/__init__.py +++ b/libs/langgraph/langgraph/func/__init__.py @@ -160,7 +160,7 @@ def entrypoint( new_params = list(original_sig.parameters.values()) + [extra_param] new_sig = original_sig.replace(parameters=new_params) # Update the signature of the wrapper function - gen_wrapper.__signature__ = new_sig + gen_wrapper.__signature__ = new_sig # type: ignore bound = get_runnable_for_func(gen_wrapper) stream_mode: StreamMode = "custom" elif inspect.isasyncgenfunction(func): @@ -202,7 +202,7 @@ def entrypoint( new_params = list(original_sig.parameters.values()) + [extra_param] new_sig = original_sig.replace(parameters=new_params) # Update the signature of the wrapper function - agen_wrapper.__signature__ = new_sig + agen_wrapper.__signature__ = new_sig # type: ignore bound = get_runnable_for_func(agen_wrapper) stream_mode = "custom" From e5f0db0af3177d227db7738f3eafe7fee0c14caf Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Thu, 16 Jan 2025 16:09:11 -0500 Subject: [PATCH 48/52] x --- libs/scheduler-kafka/tests/test_subgraph.py | 4 ++++ libs/scheduler-kafka/tests/test_subgraph_sync.py | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/libs/scheduler-kafka/tests/test_subgraph.py b/libs/scheduler-kafka/tests/test_subgraph.py index 053eaedd0..d0588a4fa 100644 --- a/libs/scheduler-kafka/tests/test_subgraph.py +++ b/libs/scheduler-kafka/tests/test_subgraph.py @@ -195,6 +195,7 @@ async def test_subgraph_w_interrupt( "__pregel_ensure_latest": True, "__pregel_dedupe_tasks": True, "__pregel_resuming": False, + "__pregel_previous": None, "__pregel_store": None, "__pregel_task_id": history[0].tasks[0].id, "__pregel_scratchpad": { @@ -267,6 +268,7 @@ async def test_subgraph_w_interrupt( "__pregel_ensure_latest": True, "__pregel_dedupe_tasks": True, "__pregel_resuming": False, + "__pregel_previous": None, "__pregel_store": None, "__pregel_task_id": history[0].tasks[0].id, "__pregel_scratchpad": { @@ -481,6 +483,7 @@ async def test_subgraph_w_interrupt( "__pregel_ensure_latest": True, "__pregel_dedupe_tasks": True, "__pregel_resuming": True, + "__pregel_previous": None, "__pregel_store": None, "__pregel_task_id": history[1].tasks[0].id, "__pregel_scratchpad": { @@ -548,6 +551,7 @@ async def test_subgraph_w_interrupt( "__pregel_ensure_latest": True, "__pregel_dedupe_tasks": True, "__pregel_resuming": True, + "__pregel_previous": None, "__pregel_store": None, "__pregel_task_id": history[1].tasks[0].id, "__pregel_scratchpad": { diff --git a/libs/scheduler-kafka/tests/test_subgraph_sync.py b/libs/scheduler-kafka/tests/test_subgraph_sync.py index 7d5de920c..c2c9a8fc1 100644 --- a/libs/scheduler-kafka/tests/test_subgraph_sync.py +++ b/libs/scheduler-kafka/tests/test_subgraph_sync.py @@ -194,6 +194,7 @@ def test_subgraph_w_interrupt( "__pregel_ensure_latest": True, "__pregel_dedupe_tasks": True, "__pregel_resuming": False, + "__pregel_previous": None, "__pregel_store": None, "__pregel_task_id": history[0].tasks[0].id, "__pregel_scratchpad": { @@ -267,6 +268,7 @@ def test_subgraph_w_interrupt( "__pregel_store": None, "__pregel_dedupe_tasks": True, "__pregel_resuming": False, + "__pregel_previous": None, "__pregel_task_id": history[0].tasks[0].id, "__pregel_scratchpad": { "subgraph_counter": AnyInt(), @@ -370,6 +372,7 @@ def test_subgraph_w_interrupt( "__pregel_store": None, "__pregel_resuming": False, "__pregel_task_id": history[0].tasks[0].id, + "__pregel_previous": None, "__pregel_scratchpad": { "subgraph_counter": AnyInt(), "call_counter": 0, @@ -480,6 +483,7 @@ def test_subgraph_w_interrupt( "__pregel_dedupe_tasks": True, "__pregel_store": None, "__pregel_resuming": True, + "__pregel_previous": None, "__pregel_task_id": history[1].tasks[0].id, "__pregel_scratchpad": { "subgraph_counter": AnyInt(), @@ -547,6 +551,7 @@ def test_subgraph_w_interrupt( "__pregel_dedupe_tasks": True, "__pregel_store": None, "__pregel_resuming": True, + "__pregel_previous": None, "__pregel_task_id": history[1].tasks[0].id, "__pregel_scratchpad": { "subgraph_counter": AnyInt(), @@ -669,6 +674,7 @@ def test_subgraph_w_interrupt( "__pregel_ensure_latest": True, "__pregel_dedupe_tasks": True, "__pregel_resuming": True, + "__pregel_previous": None, "__pregel_store": None, "__pregel_task_id": history[1].tasks[0].id, "__pregel_scratchpad": { From 3a48049194d84fd8e79a291fab7acc8ba279ad1b Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Thu, 16 Jan 2025 16:19:23 -0500 Subject: [PATCH 49/52] x --- libs/scheduler-kafka/tests/test_subgraph.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/scheduler-kafka/tests/test_subgraph.py b/libs/scheduler-kafka/tests/test_subgraph.py index d0588a4fa..c85f335ae 100644 --- a/libs/scheduler-kafka/tests/test_subgraph.py +++ b/libs/scheduler-kafka/tests/test_subgraph.py @@ -371,6 +371,7 @@ async def test_subgraph_w_interrupt( "__pregel_ensure_latest": True, "__pregel_dedupe_tasks": True, "__pregel_resuming": False, + "__pregel_previous": None, "__pregel_store": None, "__pregel_task_id": history[0].tasks[0].id, "__pregel_scratchpad": { From a58f5dacca4cead9f0cec5ab5c1805f355e473c3 Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Thu, 16 Jan 2025 16:26:54 -0500 Subject: [PATCH 50/52] x --- libs/scheduler-kafka/tests/test_subgraph.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/scheduler-kafka/tests/test_subgraph.py b/libs/scheduler-kafka/tests/test_subgraph.py index c85f335ae..2a6c9992a 100644 --- a/libs/scheduler-kafka/tests/test_subgraph.py +++ b/libs/scheduler-kafka/tests/test_subgraph.py @@ -676,6 +676,7 @@ async def test_subgraph_w_interrupt( "__pregel_ensure_latest": True, "__pregel_dedupe_tasks": True, "__pregel_resuming": True, + "__pregel_previous": None, "__pregel_store": None, "__pregel_task_id": history[1].tasks[0].id, "__pregel_scratchpad": { From 002b048674445cecc05382dcecada1a8a5585089 Mon Sep 17 00:00:00 2001 From: Andrew Nguonly Date: Thu, 16 Jan 2025 13:27:23 -0800 Subject: [PATCH 51/52] Add status field to Project object. --- .../cloud/reference/api/openapi_control_plane.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/docs/cloud/reference/api/openapi_control_plane.json b/docs/docs/cloud/reference/api/openapi_control_plane.json index 646ca09b6..871291505 100644 --- a/docs/docs/cloud/reference/api/openapi_control_plane.json +++ b/docs/docs/cloud/reference/api/openapi_control_plane.json @@ -561,6 +561,16 @@ }, "resource": { "$ref": "#/components/schemas/ResourceService" + }, + "status": { + "type": "string", + "enum": [ + "AWAITING_DATABASE", + "READY", + "AWAITING_DELETE", + "UNKNOWN" + ], + "description": "Deployment status of the project.\n\nNon-terminal statuses: `AWAITING_DATABASE`, `AWAITING_DELETE`. All other statuses are terminal." } } }, From 76be64adcb98e086faee2825b92197191227d8ab Mon Sep 17 00:00:00 2001 From: ccurme Date: Thu, 16 Jan 2025 21:41:47 -0500 Subject: [PATCH 52/52] docs[patch]: fix builds (#3074) --- .github/workflows/deploy_docs.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 84de2e178..e051d3750 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -85,7 +85,8 @@ jobs: if [ "${{ github.event_name }}" == "schedule" ] || [ "${{ github.event_name }}" == "workflow_dispatch" ] || ([ "${{ github.event_name }}" == "push" ] && [ "${{ github.ref }}" == "refs/heads/main" ]); then echo "Running link check on all HTML files matching notebooks in docs directory..." poetry run pytest -v \ - --check-links-ignore "https://(api|web|docs|academy)\.smith\.langchain\.com/.*" \ + --check-links-ignore "https://(api|web|docs)\.smith\.langchain\.com/.*" \ + --check-links-ignore "https://academy\.langchain\.com/.*" \ --check-links-ignore "https://x.com/.*" \ --check-links-ignore "https://github\.com/.*" \ --check-links-ignore "http://localhost:8123/.*" \ @@ -106,7 +107,8 @@ jobs: if [ -n "${CHANGED_FILES}" ]; then echo "Running link check on HTML files matching changed notebook files..." poetry run pytest -v \ - --check-links-ignore "https://(api|web|docs|academy)\.smith\.langchain\.com/.*" \ + --check-links-ignore "https://(api|web|docs)\.smith\.langchain\.com/.*" \ + --check-links-ignore "https://academy\.langchain\.com/.*" \ --check-links-ignore "http://localhost:8123/.*" \ --check-links-ignore "http://localhost:2024.*" \ --check-links-ignore "http://127.0.0.1:.*" \