From 9a7b1fa12aeb1309ab76dcbc134ec6398e26d2f1 Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 22 Apr 2025 10:15:19 -0700 Subject: [PATCH 01/14] initial pass - surfacing interrupts for stream_mode='values' --- libs/langgraph/langgraph/pregel/__init__.py | 15 ++++++--- libs/langgraph/langgraph/pregel/loop.py | 34 ++++++++++----------- libs/langgraph/langgraph/types.py | 12 +++++++- 3 files changed, 39 insertions(+), 22 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 2b016e587..8c7cd2e28 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -2732,10 +2732,11 @@ class Pregel(PregelProtocol): If stream_mode is not "values", it returns a list of output chunks. """ output_keys = output_keys if output_keys is not None else self.output_channels - if stream_mode == "values": - latest: Union[dict[str, Any], Any] = None - else: - chunks = [] + + latest: Union[dict[str, Any], Any] = None + chunks: list[Union[dict[str, Any], Any]] = [] + interrupts: list[Interrupt] = [] + for chunk in self.stream( input, config, @@ -2748,10 +2749,16 @@ class Pregel(PregelProtocol): **kwargs, ): if stream_mode == "values": + if isinstance(chunk, dict) and (ints := chunk.get(INTERRUPT)) is not None: + interrupts.extend(ints) latest = chunk else: chunks.append(chunk) if stream_mode == "values": + if len(interrupts) > 0: + return { + INTERRUPT: interrupts + } return latest else: return chunks diff --git a/libs/langgraph/langgraph/pregel/loop.py b/libs/langgraph/langgraph/pregel/loop.py index 644fd5a68..01471a1c0 100644 --- a/libs/langgraph/langgraph/pregel/loop.py +++ b/libs/langgraph/langgraph/pregel/loop.py @@ -910,23 +910,23 @@ class PregelLoop(LoopProtocol): ): return if writes[0][0] == INTERRUPT: - self._emit( - "updates", - lambda: iter( - [ - { - INTERRUPT: tuple( - v - for w in writes - if w[0] == INTERRUPT - for v in ( - w[1] if isinstance(w[1], Sequence) else (w[1],) - ) - ) - } - ] - ), - ) + interrupts = [ + { + INTERRUPT: tuple( + v + for w in writes + if w[0] == INTERRUPT + for v in ( + w[1] if isinstance(w[1], Sequence) else (w[1],) + ) + ) + } + ] + stream_modes = self.stream.modes if self.stream else [] + if "updates" in stream_modes: + self._emit("updates", lambda: iter(interrupts)) + elif "values" in stream_modes: + self._emit("values", lambda: iter(interrupts)) elif writes[0][0] != ERROR: self._emit( "updates", diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index 195acd4ff..de3e86ce6 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -24,6 +24,8 @@ from typing_extensions import Self from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointMetadata from langgraph.utils.fields import get_update_as_tuples +import hashlib +import uuid if TYPE_CHECKING: from langgraph.pregel.protocol import PregelProtocol @@ -144,6 +146,13 @@ class Interrupt: when: Literal["during"] = dataclasses.field(default="during", repr=False) + @property + def interrupt_id(self) -> str: + """Generate a unique ID for the interrupt based on its namespace.""" + identifier = uuid.uuid4().bytes if self.ns is None else ''.join(self.ns).encode() + return hashlib.sha256(identifier).hexdigest() + + class StateUpdate(NamedTuple): values: Optional[dict[str, Any]] as_node: Optional[str] = None @@ -483,11 +492,12 @@ def interrupt(value: Any) -> Any: CONFIG_KEY_SEND, NS_SEP, RESUME, + CONF, ) from langgraph.errors import GraphInterrupt from langgraph.utils.config import get_config - conf = get_config()["configurable"] + conf = get_config()[CONF] # track interrupt index scratchpad: PregelScratchpad = conf[CONFIG_KEY_SCRATCHPAD] idx = scratchpad.interrupt_counter() From 35523f4081184e2753d3d25ac0ffd289731e765c Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 22 Apr 2025 10:34:24 -0700 Subject: [PATCH 02/14] linting --- libs/langgraph/langgraph/graph/graph.py | 2 +- libs/langgraph/langgraph/graph/state.py | 2 +- libs/langgraph/langgraph/pregel/__init__.py | 28 +++++++++++-------- libs/langgraph/langgraph/pregel/loop.py | 4 +-- libs/langgraph/langgraph/pregel/read.py | 12 ++++---- libs/langgraph/langgraph/types.py | 11 ++++---- .../tests/test_checkpoint_migration.py | 6 ++-- libs/langgraph/tests/test_large_cases.py | 6 ++-- .../langgraph/tests/test_large_cases_async.py | 2 +- libs/langgraph/tests/test_retry.py | 12 ++++---- 10 files changed, 45 insertions(+), 40 deletions(-) diff --git a/libs/langgraph/langgraph/graph/graph.py b/libs/langgraph/langgraph/graph/graph.py index fa28243fb..07a57d596 100644 --- a/libs/langgraph/langgraph/graph/graph.py +++ b/libs/langgraph/langgraph/graph/graph.py @@ -182,7 +182,7 @@ class Graph: # validate the condition if name in self.branches[source]: raise ValueError( - f"Branch with name `{path.name}` already exists for node " f"`{source}`" + f"Branch with name `{path.name}` already exists for node `{source}`" ) # save it self.branches[source][name] = Branch.from_path(path, path_map, then, False) diff --git a/libs/langgraph/langgraph/graph/state.py b/libs/langgraph/langgraph/graph/state.py index fed12fd8b..0eba0f362 100644 --- a/libs/langgraph/langgraph/graph/state.py +++ b/libs/langgraph/langgraph/graph/state.py @@ -527,7 +527,7 @@ class StateGraph(Graph): # validate the condition if name in self.branches[source]: raise ValueError( - f"Branch with name `{path.name}` already exists for node " f"`{source}`" + f"Branch with name `{path.name}` already exists for node `{source}`" ) # save it self.branches[source][name] = Branch.from_path(path, path_map, then, True) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 8c7cd2e28..757d66d6f 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -108,6 +108,7 @@ from langgraph.store.base import BaseStore from langgraph.types import ( All, Checkpointer, + Interrupt, LoopProtocol, StateSnapshot, StateUpdate, @@ -2749,17 +2750,15 @@ class Pregel(PregelProtocol): **kwargs, ): if stream_mode == "values": - if isinstance(chunk, dict) and (ints := chunk.get(INTERRUPT)) is not None: - interrupts.extend(ints) + if isinstance(chunk, dict): + if (ints := chunk.get(INTERRUPT)) is not None: + interrupts.extend(ints) latest = chunk else: chunks.append(chunk) + if stream_mode == "values": - if len(interrupts) > 0: - return { - INTERRUPT: interrupts - } - return latest + return {INTERRUPT: interrupts} if interrupts else latest else: return chunks @@ -2794,10 +2793,11 @@ class Pregel(PregelProtocol): """ output_keys = output_keys if output_keys is not None else self.output_channels - if stream_mode == "values": - latest: Union[dict[str, Any], Any] = None - else: - chunks = [] + + latest: Union[dict[str, Any], Any] = None + chunks: list[Union[dict[str, Any], Any]] = [] + interrupts: list[Interrupt] = [] + async for chunk in self.astream( input, config, @@ -2810,11 +2810,15 @@ class Pregel(PregelProtocol): **kwargs, ): if stream_mode == "values": + if isinstance(chunk, dict): + if (ints := chunk.get(INTERRUPT)) is not None: + interrupts.extend(ints) latest = chunk else: chunks.append(chunk) + if stream_mode == "values": - return latest + return {INTERRUPT: interrupts} if interrupts else latest else: return chunks diff --git a/libs/langgraph/langgraph/pregel/loop.py b/libs/langgraph/langgraph/pregel/loop.py index 01471a1c0..774941926 100644 --- a/libs/langgraph/langgraph/pregel/loop.py +++ b/libs/langgraph/langgraph/pregel/loop.py @@ -916,9 +916,7 @@ class PregelLoop(LoopProtocol): v for w in writes if w[0] == INTERRUPT - for v in ( - w[1] if isinstance(w[1], Sequence) else (w[1],) - ) + for v in (w[1] if isinstance(w[1], Sequence) else (w[1],)) ) } ] diff --git a/libs/langgraph/langgraph/pregel/read.py b/libs/langgraph/langgraph/pregel/read.py index e57620ec7..606a3892e 100644 --- a/libs/langgraph/langgraph/pregel/read.py +++ b/libs/langgraph/langgraph/pregel/read.py @@ -245,12 +245,12 @@ class PregelNode(Runnable): ) def join(self, channels: Sequence[str]) -> PregelNode: - assert isinstance(channels, list) or isinstance( - channels, tuple - ), "channels must be a list or tuple" - assert isinstance( - self.channels, dict - ), "all channels must be named when using .join()" + assert isinstance(channels, list) or isinstance(channels, tuple), ( + "channels must be a list or tuple" + ) + assert isinstance(self.channels, dict), ( + "all channels must be named when using .join()" + ) return self.copy( update=dict( channels={ diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index de3e86ce6..e7f68276b 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -1,5 +1,7 @@ import dataclasses +import hashlib import sys +import uuid from collections import deque from typing import ( TYPE_CHECKING, @@ -24,8 +26,6 @@ from typing_extensions import Self from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointMetadata from langgraph.utils.fields import get_update_as_tuples -import hashlib -import uuid if TYPE_CHECKING: from langgraph.pregel.protocol import PregelProtocol @@ -145,11 +145,12 @@ class Interrupt: ns: Optional[Sequence[str]] = None when: Literal["during"] = dataclasses.field(default="during", repr=False) - @property def interrupt_id(self) -> str: """Generate a unique ID for the interrupt based on its namespace.""" - identifier = uuid.uuid4().bytes if self.ns is None else ''.join(self.ns).encode() + identifier = ( + uuid.uuid4().bytes if self.ns is None else "".join(self.ns).encode() + ) return hashlib.sha256(identifier).hexdigest() @@ -487,12 +488,12 @@ def interrupt(value: Any) -> Any: GraphInterrupt: On the first invocation within the node, halts execution and surfaces the provided value to the client. """ from langgraph.constants import ( + CONF, CONFIG_KEY_CHECKPOINT_NS, CONFIG_KEY_SCRATCHPAD, CONFIG_KEY_SEND, NS_SEP, RESUME, - CONF, ) from langgraph.errors import GraphInterrupt from langgraph.utils.config import get_config diff --git a/libs/langgraph/tests/test_checkpoint_migration.py b/libs/langgraph/tests/test_checkpoint_migration.py index 67e2efc1e..413e14138 100644 --- a/libs/langgraph/tests/test_checkpoint_migration.py +++ b/libs/langgraph/tests/test_checkpoint_migration.py @@ -1573,9 +1573,9 @@ def test_migrate_checkpoints(source: str, target: str) -> None: migrated["versions_seen"][c][v].split(".")[0] ) # check that the migrated checkpoint matches the target checkpoint - assert ( - migrated == target_checkpoint.checkpoint - ), "Checkpoint mismatch at index {}".format(idx) + assert migrated == target_checkpoint.checkpoint, ( + "Checkpoint mismatch at index {}".format(idx) + ) @NEEDS_CONTEXTVARS diff --git a/libs/langgraph/tests/test_large_cases.py b/libs/langgraph/tests/test_large_cases.py index 7bba55ff5..a876a5864 100644 --- a/libs/langgraph/tests/test_large_cases.py +++ b/libs/langgraph/tests/test_large_cases.py @@ -2829,9 +2829,9 @@ def test_state_graph_packets( # Define decision-making logic def should_continue(data: dict) -> str: assert isinstance(data["session"], httpx.Client) - assert ( - data["something_extra"] == "hi there" - ), "nodes can pass extra data to their cond edges, which isn't saved in state" + assert data["something_extra"] == "hi there", ( + "nodes can pass extra data to their cond edges, which isn't saved in state" + ) # Logic to decide whether to continue in the loop or exit if tool_calls := data["messages"][-1].tool_calls: return [Send("tools", tool_call) for tool_call in tool_calls] diff --git a/libs/langgraph/tests/test_large_cases_async.py b/libs/langgraph/tests/test_large_cases_async.py index 4f5c688aa..0ef1228c0 100644 --- a/libs/langgraph/tests/test_large_cases_async.py +++ b/libs/langgraph/tests/test_large_cases_async.py @@ -3805,7 +3805,7 @@ async def test_in_one_fan_out_out_one_graph_state() -> None: docs: Annotated[list[str], operator.add] async def rewrite_query(data: State) -> State: - return {"query": f'query: {data["query"]}'} + return {"query": f"query: {data['query']}"} async def retriever_one(data: State) -> State: await asyncio.sleep(0.1) diff --git a/libs/langgraph/tests/test_retry.py b/libs/langgraph/tests/test_retry.py index 6ef10d4c7..940d5db18 100644 --- a/libs/langgraph/tests/test_retry.py +++ b/libs/langgraph/tests/test_retry.py @@ -226,9 +226,10 @@ def test_graph_with_jitter_retry_policy(): ) # Test graph execution with mocked random and sleep - with patch("random.uniform", return_value=0.05) as mock_random, patch( - "time.sleep" - ) as mock_sleep: + with ( + patch("random.uniform", return_value=0.05) as mock_random, + patch("time.sleep") as mock_sleep, + ): result = graph.invoke({"foo": ""}) # Verify retry behavior @@ -334,8 +335,9 @@ def test_graph_with_max_attempts_exceeded(): ) # Test graph execution - with patch("time.sleep") as mock_sleep, pytest.raises( - ValueError, match="Always fails" + with ( + patch("time.sleep") as mock_sleep, + pytest.raises(ValueError, match="Always fails"), ): graph.invoke({"foo": ""}) From 8e6c0be48a26c4632f19f5f2fea3fec9349d797d Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 22 Apr 2025 11:28:09 -0700 Subject: [PATCH 03/14] try linting + maybe test fix --- libs/langgraph/langgraph/pregel/__init__.py | 14 +++- libs/langgraph/langgraph/pregel/read.py | 12 ++-- libs/langgraph/poetry.lock | 64 +++++++++---------- .../tests/test_checkpoint_migration.py | 6 +- libs/langgraph/tests/test_large_cases.py | 6 +- 5 files changed, 56 insertions(+), 46 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 757d66d6f..4cdc3eab9 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -2758,7 +2758,12 @@ class Pregel(PregelProtocol): chunks.append(chunk) if stream_mode == "values": - return {INTERRUPT: interrupts} if interrupts else latest + if interrupts: + return { + **latest, + INTERRUPT: interrupts, + } + return latest else: return chunks @@ -2818,7 +2823,12 @@ class Pregel(PregelProtocol): chunks.append(chunk) if stream_mode == "values": - return {INTERRUPT: interrupts} if interrupts else latest + if interrupts: + return { + **latest, + INTERRUPT: interrupts, + } + return latest else: return chunks diff --git a/libs/langgraph/langgraph/pregel/read.py b/libs/langgraph/langgraph/pregel/read.py index 606a3892e..e57620ec7 100644 --- a/libs/langgraph/langgraph/pregel/read.py +++ b/libs/langgraph/langgraph/pregel/read.py @@ -245,12 +245,12 @@ class PregelNode(Runnable): ) def join(self, channels: Sequence[str]) -> PregelNode: - assert isinstance(channels, list) or isinstance(channels, tuple), ( - "channels must be a list or tuple" - ) - assert isinstance(self.channels, dict), ( - "all channels must be named when using .join()" - ) + assert isinstance(channels, list) or isinstance( + channels, tuple + ), "channels must be a list or tuple" + assert isinstance( + self.channels, dict + ), "all channels must be named when using .join()" return self.copy( update=dict( channels={ diff --git a/libs/langgraph/poetry.lock b/libs/langgraph/poetry.lock index 23c064d9d..752d7e102 100644 --- a/libs/langgraph/poetry.lock +++ b/libs/langgraph/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "aiosqlite" @@ -51,7 +51,7 @@ typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} [package.extras] doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21.0b1) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] trio = ["trio (>=0.26.1)"] [[package]] @@ -162,8 +162,8 @@ files = [ six = ">=1.12.0" [package.extras] -astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] -test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] +astroid = ["astroid (>=1,<2) ; python_version < \"3\"", "astroid (>=2,<4) ; python_version >= \"3\""] +test = ["astroid (>=1,<2) ; python_version < \"3\"", "astroid (>=2,<4) ; python_version >= \"3\"", "pytest"] [[package]] name = "async-lru" @@ -193,12 +193,12 @@ files = [ ] [package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\""] [[package]] name = "babel" @@ -569,7 +569,7 @@ files = [ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "debugpy" @@ -675,7 +675,7 @@ files = [ ] [package.extras] -tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich ; python_version >= \"3.11\""] [[package]] name = "fastjsonschema" @@ -758,7 +758,7 @@ idna = "*" sniffio = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -795,12 +795,12 @@ files = [ zipp = ">=3.20" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +test = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy"] [[package]] @@ -1088,7 +1088,7 @@ traitlets = ">=5.3" [package.extras] docs = ["ipykernel", "myst-parser", "pydata-sphinx-theme", "sphinx (>=4)", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] -test = ["coverage", "ipykernel (>=6.14)", "mypy", "paramiko", "pre-commit", "pytest (<8.2.0)", "pytest-cov", "pytest-jupyter[client] (>=0.4.1)", "pytest-timeout"] +test = ["coverage", "ipykernel (>=6.14)", "mypy", "paramiko ; sys_platform == \"win32\"", "pre-commit", "pytest (<8.2.0)", "pytest-cov", "pytest-jupyter[client] (>=0.4.1)", "pytest-timeout"] [[package]] name = "jupyter-console" @@ -1366,7 +1366,7 @@ url = "../checkpoint" [[package]] name = "langgraph-checkpoint-postgres" -version = "2.0.19" +version = "2.0.21" description = "Library with a Postgres implementation of LangGraph checkpoint saver." optional = false python-versions = "^3.9.0,<4.0" @@ -1422,7 +1422,7 @@ url = "../prebuilt" [[package]] name = "langgraph-sdk" -version = "0.1.61" +version = "0.1.63" description = "SDK for interacting with LangGraph API" optional = false python-versions = "^3.9.0,<4.0" @@ -1742,7 +1742,7 @@ tornado = ">=6.2.0" [package.extras] dev = ["hatch", "pre-commit"] docs = ["myst-parser", "nbsphinx", "pydata-sphinx-theme", "sphinx (>=1.3.6)", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] -test = ["importlib-resources (>=5.0)", "ipykernel", "jupyter-server[test] (>=2.4.0,<3)", "jupyterlab-server[test] (>=2.27.1,<3)", "nbval", "pytest (>=7.0)", "pytest-console-scripts", "pytest-timeout", "pytest-tornasync", "requests"] +test = ["importlib-resources (>=5.0) ; python_version < \"3.10\"", "ipykernel", "jupyter-server[test] (>=2.4.0,<3)", "jupyterlab-server[test] (>=2.27.1,<3)", "nbval", "pytest (>=7.0)", "pytest-console-scripts", "pytest-timeout", "pytest-tornasync", "requests"] [[package]] name = "notebook-shim" @@ -2046,8 +2046,8 @@ typing-extensions = {version = ">=4.6", markers = "python_version < \"3.13\""} tzdata = {version = "*", markers = "sys_platform == \"win32\""} [package.extras] -binary = ["psycopg-binary (==3.2.3)"] -c = ["psycopg-c (==3.2.3)"] +binary = ["psycopg-binary (==3.2.3) ; implementation_name != \"pypy\""] +c = ["psycopg-c (==3.2.3) ; implementation_name != \"pypy\""] dev = ["ast-comments (>=1.1.2)", "black (>=24.1.0)", "codespell (>=2.2)", "dnspython (>=2.1)", "flake8 (>=4.0)", "mypy (>=1.11)", "types-setuptools (>=57.4)", "wheel (>=0.37)"] docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"] pool = ["psycopg-pool"] @@ -2264,7 +2264,7 @@ typing-extensions = [ [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and sys_platform == \"win32\""] [[package]] name = "pydantic-core" @@ -2399,7 +2399,7 @@ files = [ psutil = ">=5.9.0" [package.extras] -dev = ["importlib-metadata", "tox"] +dev = ["importlib-metadata ; python_version < \"3.8\"", "tox"] [[package]] name = "pytest" @@ -3037,9 +3037,9 @@ files = [ ] [package.extras] -nativelib = ["pyobjc-framework-Cocoa", "pywin32"] -objc = ["pyobjc-framework-Cocoa"] -win32 = ["pywin32"] +nativelib = ["pyobjc-framework-Cocoa ; sys_platform == \"darwin\"", "pywin32 ; sys_platform == \"win32\""] +objc = ["pyobjc-framework-Cocoa ; sys_platform == \"darwin\""] +win32 = ["pywin32 ; sys_platform == \"win32\""] [[package]] name = "setuptools" @@ -3054,13 +3054,13 @@ files = [ ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] -core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.5.2) ; sys_platform != \"cygwin\""] +core = ["importlib-metadata (>=6) ; python_version < \"3.10\"", "importlib-resources (>=5.10.2) ; python_version < \"3.9\"", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.12.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib-metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.12.*)", "pytest-mypy"] [[package]] name = "six" @@ -3320,7 +3320,7 @@ files = [ ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -3636,11 +3636,11 @@ files = [ ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] [metadata] diff --git a/libs/langgraph/tests/test_checkpoint_migration.py b/libs/langgraph/tests/test_checkpoint_migration.py index 413e14138..67e2efc1e 100644 --- a/libs/langgraph/tests/test_checkpoint_migration.py +++ b/libs/langgraph/tests/test_checkpoint_migration.py @@ -1573,9 +1573,9 @@ def test_migrate_checkpoints(source: str, target: str) -> None: migrated["versions_seen"][c][v].split(".")[0] ) # check that the migrated checkpoint matches the target checkpoint - assert migrated == target_checkpoint.checkpoint, ( - "Checkpoint mismatch at index {}".format(idx) - ) + assert ( + migrated == target_checkpoint.checkpoint + ), "Checkpoint mismatch at index {}".format(idx) @NEEDS_CONTEXTVARS diff --git a/libs/langgraph/tests/test_large_cases.py b/libs/langgraph/tests/test_large_cases.py index a876a5864..7bba55ff5 100644 --- a/libs/langgraph/tests/test_large_cases.py +++ b/libs/langgraph/tests/test_large_cases.py @@ -2829,9 +2829,9 @@ def test_state_graph_packets( # Define decision-making logic def should_continue(data: dict) -> str: assert isinstance(data["session"], httpx.Client) - assert data["something_extra"] == "hi there", ( - "nodes can pass extra data to their cond edges, which isn't saved in state" - ) + assert ( + data["something_extra"] == "hi there" + ), "nodes can pass extra data to their cond edges, which isn't saved in state" # Logic to decide whether to continue in the loop or exit if tool_calls := data["messages"][-1].tool_calls: return [Send("tools", tool_call) for tool_call in tool_calls] From 8506c6655bbbad75ed151e2e0078b40e8877333e Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 22 Apr 2025 11:34:38 -0700 Subject: [PATCH 04/14] more linting --- libs/langgraph/langgraph/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index e7f68276b..d2bb24740 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -498,7 +498,7 @@ def interrupt(value: Any) -> Any: from langgraph.errors import GraphInterrupt from langgraph.utils.config import get_config - conf = get_config()[CONF] + conf = get_config()["configurable"] # track interrupt index scratchpad: PregelScratchpad = conf[CONFIG_KEY_SCRATCHPAD] idx = scratchpad.interrupt_counter() From d5a1bb05f53d74f2871435871974fb69853c704c Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 22 Apr 2025 11:35:56 -0700 Subject: [PATCH 05/14] revert changes to lockfile --- libs/langgraph/poetry.lock | 70 +++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/libs/langgraph/poetry.lock b/libs/langgraph/poetry.lock index 752d7e102..d1c1542f1 100644 --- a/libs/langgraph/poetry.lock +++ b/libs/langgraph/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.0.0 and should not be changed by hand. [[package]] name = "aiosqlite" @@ -51,7 +51,7 @@ typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} [package.extras] doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21.0b1) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] trio = ["trio (>=0.26.1)"] [[package]] @@ -162,8 +162,8 @@ files = [ six = ">=1.12.0" [package.extras] -astroid = ["astroid (>=1,<2) ; python_version < \"3\"", "astroid (>=2,<4) ; python_version >= \"3\""] -test = ["astroid (>=1,<2) ; python_version < \"3\"", "astroid (>=2,<4) ; python_version >= \"3\"", "pytest"] +astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] +test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] [[package]] name = "async-lru" @@ -193,12 +193,12 @@ files = [ ] [package.extras] -benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] -cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] -dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\""] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] [[package]] name = "babel" @@ -569,7 +569,7 @@ files = [ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +toml = ["tomli"] [[package]] name = "debugpy" @@ -675,7 +675,7 @@ files = [ ] [package.extras] -tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich ; python_version >= \"3.11\""] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] [[package]] name = "fastjsonschema" @@ -758,7 +758,7 @@ idna = "*" sniffio = "*" [package.extras] -brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +brotli = ["brotli", "brotlicffi"] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -795,12 +795,12 @@ files = [ zipp = ">=3.20" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy"] [[package]] @@ -1088,7 +1088,7 @@ traitlets = ">=5.3" [package.extras] docs = ["ipykernel", "myst-parser", "pydata-sphinx-theme", "sphinx (>=4)", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] -test = ["coverage", "ipykernel (>=6.14)", "mypy", "paramiko ; sys_platform == \"win32\"", "pre-commit", "pytest (<8.2.0)", "pytest-cov", "pytest-jupyter[client] (>=0.4.1)", "pytest-timeout"] +test = ["coverage", "ipykernel (>=6.14)", "mypy", "paramiko", "pre-commit", "pytest (<8.2.0)", "pytest-cov", "pytest-jupyter[client] (>=0.4.1)", "pytest-timeout"] [[package]] name = "jupyter-console" @@ -1324,14 +1324,14 @@ files = [ [[package]] name = "langchain-core" -version = "0.3.46" +version = "0.3.55" description = "Building applications with LLMs through composability" optional = false python-versions = "<4.0,>=3.9" groups = ["main", "dev"] files = [ - {file = "langchain_core-0.3.46-py3-none-any.whl", hash = "sha256:28b5689fc347975ea520b5364ab4aee5567e661553bbee5e97cabf4596c28ce0"}, - {file = "langchain_core-0.3.46.tar.gz", hash = "sha256:5fca010eeb0a427be5aa8a8525e2112995dde790c584cef165be7c5e0ee1c2b5"}, + {file = "langchain_core-0.3.55-py3-none-any.whl", hash = "sha256:b3cb36bf37755a616158a79866657c6697b43a2f7c69dd723ce425f1c76c1baa"}, + {file = "langchain_core-0.3.55.tar.gz", hash = "sha256:0f2b3e311621116a83510c70b0ac9d959030a0a457a69483535cff18501fedc9"}, ] [package.dependencies] @@ -1366,7 +1366,7 @@ url = "../checkpoint" [[package]] name = "langgraph-checkpoint-postgres" -version = "2.0.21" +version = "2.0.19" description = "Library with a Postgres implementation of LangGraph checkpoint saver." optional = false python-versions = "^3.9.0,<4.0" @@ -1422,7 +1422,7 @@ url = "../prebuilt" [[package]] name = "langgraph-sdk" -version = "0.1.63" +version = "0.1.61" description = "SDK for interacting with LangGraph API" optional = false python-versions = "^3.9.0,<4.0" @@ -1742,7 +1742,7 @@ tornado = ">=6.2.0" [package.extras] dev = ["hatch", "pre-commit"] docs = ["myst-parser", "nbsphinx", "pydata-sphinx-theme", "sphinx (>=1.3.6)", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] -test = ["importlib-resources (>=5.0) ; python_version < \"3.10\"", "ipykernel", "jupyter-server[test] (>=2.4.0,<3)", "jupyterlab-server[test] (>=2.27.1,<3)", "nbval", "pytest (>=7.0)", "pytest-console-scripts", "pytest-timeout", "pytest-tornasync", "requests"] +test = ["importlib-resources (>=5.0)", "ipykernel", "jupyter-server[test] (>=2.4.0,<3)", "jupyterlab-server[test] (>=2.27.1,<3)", "nbval", "pytest (>=7.0)", "pytest-console-scripts", "pytest-timeout", "pytest-tornasync", "requests"] [[package]] name = "notebook-shim" @@ -2046,8 +2046,8 @@ typing-extensions = {version = ">=4.6", markers = "python_version < \"3.13\""} tzdata = {version = "*", markers = "sys_platform == \"win32\""} [package.extras] -binary = ["psycopg-binary (==3.2.3) ; implementation_name != \"pypy\""] -c = ["psycopg-c (==3.2.3) ; implementation_name != \"pypy\""] +binary = ["psycopg-binary (==3.2.3)"] +c = ["psycopg-c (==3.2.3)"] dev = ["ast-comments (>=1.1.2)", "black (>=24.1.0)", "codespell (>=2.2)", "dnspython (>=2.1)", "flake8 (>=4.0)", "mypy (>=1.11)", "types-setuptools (>=57.4)", "wheel (>=0.37)"] docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"] pool = ["psycopg-pool"] @@ -2264,7 +2264,7 @@ typing-extensions = [ [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and sys_platform == \"win32\""] +timezone = ["tzdata"] [[package]] name = "pydantic-core" @@ -2399,7 +2399,7 @@ files = [ psutil = ">=5.9.0" [package.extras] -dev = ["importlib-metadata ; python_version < \"3.8\"", "tox"] +dev = ["importlib-metadata", "tox"] [[package]] name = "pytest" @@ -3037,9 +3037,9 @@ files = [ ] [package.extras] -nativelib = ["pyobjc-framework-Cocoa ; sys_platform == \"darwin\"", "pywin32 ; sys_platform == \"win32\""] -objc = ["pyobjc-framework-Cocoa ; sys_platform == \"darwin\""] -win32 = ["pywin32 ; sys_platform == \"win32\""] +nativelib = ["pyobjc-framework-Cocoa", "pywin32"] +objc = ["pyobjc-framework-Cocoa"] +win32 = ["pywin32"] [[package]] name = "setuptools" @@ -3054,13 +3054,13 @@ files = [ ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.5.2) ; sys_platform != \"cygwin\""] -core = ["importlib-metadata (>=6) ; python_version < \"3.10\"", "importlib-resources (>=5.10.2) ; python_version < \"3.9\"", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib-metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.12.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.12.*)", "pytest-mypy"] [[package]] name = "six" @@ -3320,7 +3320,7 @@ files = [ ] [package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -3636,11 +3636,11 @@ files = [ ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] [metadata] From 85e73653f9a47519957c0c0739fcd8eb76daf337 Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 22 Apr 2025 15:16:34 -0700 Subject: [PATCH 06/14] linting and test fixes --- libs/langgraph/langgraph/pregel/__init__.py | 40 ++++++---- libs/langgraph/langgraph/types.py | 11 +-- libs/langgraph/tests/test_large_cases.py | 44 ++++++++--- libs/langgraph/tests/test_pregel.py | 39 +++++++++- libs/langgraph/tests/test_pregel_async.py | 83 ++++++++++++++++++--- 5 files changed, 170 insertions(+), 47 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index e33df210c..9e7aa561d 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -2806,19 +2806,23 @@ class Pregel(PregelProtocol): **kwargs, ): if stream_mode == "values": - if isinstance(chunk, dict): - if (ints := chunk.get(INTERRUPT)) is not None: - interrupts.extend(ints) - latest = chunk + if ( + isinstance(chunk, dict) + and (ints := chunk.get(INTERRUPT)) is not None + ): + interrupts.extend(ints) + else: + latest = chunk else: chunks.append(chunk) if stream_mode == "values": if interrupts: - return { - **latest, - INTERRUPT: interrupts, - } + return ( + {**latest, INTERRUPT: interrupts} + if isinstance(latest, dict) + else {INTERRUPT: interrupts} + ) return latest else: return chunks @@ -2871,19 +2875,23 @@ class Pregel(PregelProtocol): **kwargs, ): if stream_mode == "values": - if isinstance(chunk, dict): - if (ints := chunk.get(INTERRUPT)) is not None: - interrupts.extend(ints) - latest = chunk + if ( + isinstance(chunk, dict) + and (ints := chunk.get(INTERRUPT)) is not None + ): + interrupts.extend(ints) + else: + latest = chunk else: chunks.append(chunk) if stream_mode == "values": if interrupts: - return { - **latest, - INTERRUPT: interrupts, - } + return ( + {**latest, INTERRUPT: interrupts} + if isinstance(latest, dict) + else {INTERRUPT: interrupts} + ) return latest else: return chunks diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index 7b3cc987e..b2a681cb7 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -1,7 +1,5 @@ import dataclasses -import hashlib import sys -import uuid from collections import deque from collections.abc import Hashable, Sequence from typing import ( @@ -21,6 +19,7 @@ from typing import ( from langchain_core.runnables import Runnable, RunnableConfig from typing_extensions import Self +from xxhash import xxh3_128_hexdigest from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointMetadata from langgraph.utils.fields import get_update_as_tuples @@ -146,10 +145,9 @@ class Interrupt: @property def interrupt_id(self) -> str: """Generate a unique ID for the interrupt based on its namespace.""" - identifier = ( - uuid.uuid4().bytes if self.ns is None else "".join(self.ns).encode() - ) - return hashlib.sha256(identifier).hexdigest() + if self.ns is None: + return "placeholder-id" + return xxh3_128_hexdigest("".join(self.ns).encode()) class StateUpdate(NamedTuple): @@ -486,7 +484,6 @@ def interrupt(value: Any) -> Any: GraphInterrupt: On the first invocation within the node, halts execution and surfaces the provided value to the client. """ from langgraph.constants import ( - CONF, CONFIG_KEY_CHECKPOINT_NS, CONFIG_KEY_SCRATCHPAD, CONFIG_KEY_SEND, diff --git a/libs/langgraph/tests/test_large_cases.py b/libs/langgraph/tests/test_large_cases.py index 3c0e7da87..074978876 100644 --- a/libs/langgraph/tests/test_large_cases.py +++ b/libs/langgraph/tests/test_large_cases.py @@ -5666,6 +5666,9 @@ def test_dynamic_interrupt( ) == { "my_key": "value", "market": "DE", + "__interrupt__": [ + Interrupt(value="Just because...", resumable=True, ns=[AnyStr("tool_two:")]) + ], } assert tool_two_node_count == 1, "interrupts aren't retried" assert len(tracer.runs) == 1 @@ -5712,6 +5715,9 @@ def test_dynamic_interrupt( assert tool_two.invoke({"my_key": "value ⛰️", "market": "DE"}, thread1) == { "my_key": "value ⛰️", "market": "DE", + "__interrupt__": [ + Interrupt(value="Just because...", resumable=True, ns=[AnyStr("tool_two:")]) + ], } if "shallow" not in checkpointer_name: @@ -5840,6 +5846,9 @@ def test_copy_checkpoint( ) == { "my_key": "value one", "market": "DE", + "__interrupt__": [ + Interrupt(value="Just because...", resumable=True, ns=[AnyStr("tool_two:")]) + ], } assert tool_two_node_count == 1, "interrupts aren't retried" assert len(tracer.runs) == 1 @@ -5890,6 +5899,9 @@ def test_copy_checkpoint( assert tool_two.invoke({"my_key": "value ⛰️", "market": "DE"}, thread1) == { "my_key": "value ⛰️ one", "market": "DE", + "__interrupt__": [ + Interrupt(value="Just because...", resumable=True, ns=[AnyStr("tool_two:")]) + ], } if "shallow" not in checkpointer_name: assert [c.metadata for c in tool_two.checkpointer.list(thread1)] == [ @@ -6040,6 +6052,13 @@ def test_dynamic_interrupt_subgraph( ) == { "my_key": "value", "market": "DE", + "__interrupt__": [ + Interrupt( + value="Just because...", + resumable=True, + ns=[AnyStr("tool_two:"), AnyStr("do:")], + ) + ], } assert tool_two_node_count == 1, "interrupts aren't retried" assert len(tracer.runs) == 1 @@ -6086,6 +6105,13 @@ def test_dynamic_interrupt_subgraph( assert tool_two.invoke({"my_key": "value ⛰️", "market": "DE"}, thread1) == { "my_key": "value ⛰️", "market": "DE", + "__interrupt__": [ + Interrupt( + value="Just because...", + resumable=True, + ns=[AnyStr("tool_two:"), AnyStr("do:")], + ) + ], } if "shallow" not in checkpointer_name: @@ -7318,15 +7344,15 @@ def test_send_dedupe_on_resume( graph = builder.compile(checkpointer=checkpointer) thread1 = {"configurable": {"thread_id": "1"}} - assert graph.invoke(["0"], thread1, checkpoint_during=checkpoint_during) == [ - "0", - "1", - "3.1", - "2|Command(goto=Send(node='2', arg=3))", - "2|Command(goto=Send(node='flaky', arg=4))", - "3", - "2|3", - ] + assert graph.invoke(["0"], thread1, checkpoint_during=checkpoint_during) == { + "__interrupt__": [ + Interrupt( + value="Bahh", + resumable=False, + ns=None, + ), + ], + } assert builder.nodes["2"].runnable.func.ticks == 3 assert builder.nodes["flaky"].runnable.func.ticks == 1 # check state diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index ce6474ca1..316d44817 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -3703,7 +3703,16 @@ def test_subgraph_checkpoint_true_interrupt( assert graph.invoke( {"foo": "foo"}, config, checkpoint_during=checkpoint_during - ) == {"foo": "hi! foo"} + ) == { + "foo": "hi! foo", + "__interrupt__": [ + Interrupt( + value="Provide baz value", + resumable=True, + ns=[AnyStr("node_2"), AnyStr("subgraph_node_1:")], + ) + ], + } assert graph.get_state(config, subgraphs=True).tasks[0].state.values == { "bar": "hi! foo" } @@ -5444,7 +5453,7 @@ def test_interrupt_task_functional( config = {"configurable": {"thread_id": "1"}} # First run, interrupted at bar - assert not graph.invoke({"a": ""}, config) + graph.invoke({"a": ""}, config) # Resume with an answer res = graph.invoke(Command(resume="bar"), config) assert res == {"a": "foobar"} @@ -7334,10 +7343,27 @@ def test_interrupt_subgraph_reenter_checkpointer_true( ) config = {"configurable": {"thread_id": "1"}} - assert parent.invoke({"foo": "", "counter": 0}, config) == {"foo": "", "counter": 0} + assert parent.invoke({"foo": "", "counter": 0}, config) == { + "foo": "", + "counter": 0, + "__interrupt__": [ + Interrupt( + value="Provide value", + resumable=True, + ns=[AnyStr("call_subgraph"), AnyStr("subnode_2:")], + ) + ], + } assert parent.invoke(Command(resume="bar"), config) == { "foo": "subgraph_2", "counter": 1, + "__interrupt__": [ + Interrupt( + value="Provide value", + resumable=True, + ns=[AnyStr("call_subgraph"), AnyStr("subnode_2:")], + ) + ], } assert parent.invoke(Command(resume="qux"), config) == { "foo": "subgraph_2|parent", @@ -7362,6 +7388,13 @@ def test_interrupt_subgraph_reenter_checkpointer_true( assert parent.invoke({"foo": "meow", "counter": 0}, config) == { "foo": "meow", "counter": 0, + "__interrupt__": [ + Interrupt( + value="Provide value", + resumable=True, + ns=[AnyStr("call_subgraph"), AnyStr("subnode_2:")], + ) + ], } # confirm that we preserve the state values from the previous invocation assert bar_values == [None, "barbaz", "quxbaz"] diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 1f6b25574..77f530aa4 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -531,6 +531,9 @@ async def test_dynamic_interrupt(checkpointer_name: str) -> None: ) == { "my_key": "value", "market": "DE", + "__interrupt__": [ + Interrupt(value="Just because...", resumable=True, ns=[AnyStr("tool_two:")]) + ], } assert tool_two_node_count == 1, "interrupts aren't retried" assert len(tracer.runs) == 1 @@ -713,6 +716,13 @@ async def test_dynamic_interrupt_subgraph(checkpointer_name: str) -> None: ) == { "my_key": "value", "market": "DE", + "__interrupt__": [ + Interrupt( + value="Just because...", + resumable=True, + ns=[AnyStr("tool_two:"), AnyStr("do:")], + ) + ], } assert tool_two_node_count == 1, "interrupts aren't retried" assert len(tracer.runs) == 1 @@ -903,6 +913,9 @@ async def test_copy_checkpoint(checkpointer_name: str) -> None: ) == { "my_key": "value one", "market": "DE", + "__interrupt__": [ + Interrupt(value="Just because...", resumable=True, ns=[AnyStr("tool_two:")]) + ], } assert tool_two_node_count == 1, "interrupts aren't retried" assert len(tracer.runs) == 1 @@ -964,6 +977,13 @@ async def test_copy_checkpoint(checkpointer_name: str) -> None: ) == { "my_key": "value ⛰️ one", "market": "DE", + "__interrupt__": [ + Interrupt( + value="Just because...", + resumable=True, + ns=[AnyStr("tool_two:")], + ) + ], } if "shallow" not in checkpointer_name: @@ -1108,13 +1128,29 @@ async def test_node_not_cancelled_on_other_node_interrupted( # writes from "awhile" are applied to last chunk assert await graph.ainvoke({"hello": "world"}, thread) == { - "hello": "world again" + "hello": "world again", + "__interrupt__": [ + Interrupt( + value="I am bad", + resumable=True, + ns=[AnyStr("bad:")], + ) + ], } assert not inner_task_cancelled assert awhiles == 1 - assert await graph.ainvoke(None, thread, debug=True) == {"hello": "world again"} + assert await graph.ainvoke(None, thread, debug=True) == { + "hello": "world again", + "__interrupt__": [ + Interrupt( + value="I am bad", + resumable=True, + ns=[AnyStr("bad:")], + ) + ], + } assert not inner_task_cancelled assert awhiles == 1 @@ -2795,15 +2831,15 @@ async def test_send_dedupe_on_resume( thread1 = {"configurable": {"thread_id": "1"}} assert await graph.ainvoke( ["0"], thread1, checkpoint_during=checkpoint_during - ) == [ - "0", - "1", - "3.1", - "2|Command(goto=Send(node='2', arg=3))", - "2|Command(goto=Send(node='flaky', arg=4))", - "3", - "2|3", - ] + ) == { + "__interrupt__": [ + Interrupt( + value="Bahh", + resumable=False, + ns=None, + ), + ], + } assert builder.nodes["2"].runnable.func.ticks == 3 assert builder.nodes["flaky"].runnable.func.ticks == 1 # resume execution @@ -5555,7 +5591,16 @@ async def test_subgraph_checkpoint_true_interrupt( assert await graph.ainvoke( {"foo": "foo"}, config, checkpoint_during=checkpoint_during - ) == {"foo": "hi! foo"} + ) == { + "foo": "hi! foo", + "__interrupt__": [ + Interrupt( + value="Provide baz value", + resumable=True, + ns=[AnyStr("node_2"), AnyStr("subgraph_node_1:")], + ) + ], + } assert (await graph.aget_state(config, subgraphs=True)).tasks[ 0 ].state.values == {"bar": "hi! foo"} @@ -8104,6 +8149,13 @@ async def test_interrupt_subgraph_reenter_checkpointer_true( assert await parent.ainvoke({"foo": "", "counter": 0}, config) == { "foo": "", "counter": 0, + "__interrupt__": [ + Interrupt( + value="Provide value", + resumable=True, + ns=[AnyStr("call_subgraph"), AnyStr("subnode_2:")], + ) + ], } assert await parent.ainvoke(Command(resume="bar"), config) == { "foo": "subgraph_2", @@ -8132,6 +8184,13 @@ async def test_interrupt_subgraph_reenter_checkpointer_true( assert await parent.ainvoke({"foo": "meow", "counter": 0}, config) == { "foo": "meow", "counter": 0, + "__interrupt__": [ + Interrupt( + value="Provide value", + resumable=True, + ns=[AnyStr("call_subgraph"), AnyStr("subnode_2:")], + ) + ], } # confirm that we preserve the state values from the previous invocation assert bar_values == [None, "barbaz", "quxbaz"] From 1251eeaa485f96e8ef8bf8eddaaec9edac328d45 Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 22 Apr 2025 17:21:56 -0700 Subject: [PATCH 07/14] final test fixes, hopefully --- libs/langgraph/tests/test_pregel_async.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 77f530aa4..2b59575b7 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -8160,6 +8160,13 @@ async def test_interrupt_subgraph_reenter_checkpointer_true( assert await parent.ainvoke(Command(resume="bar"), config) == { "foo": "subgraph_2", "counter": 1, + "__interrupt__": [ + Interrupt( + value="Provide value", + resumable=True, + ns=[AnyStr("call_subgraph"), AnyStr("subnode_2")], + ) + ], } assert await parent.ainvoke(Command(resume="qux"), config) == { "foo": "subgraph_2|parent", @@ -8219,7 +8226,7 @@ async def test_handles_multiple_interrupts_from_tasks() -> None: config = {"configurable": {"thread_id": "1"}} result = await program.ainvoke("this is ignored", config=config) - assert result is None + assert "__interrupt__" in result state = await program.aget_state(config=config) assert len(state.tasks[0].interrupts) == 1 @@ -8231,7 +8238,7 @@ async def test_handles_multiple_interrupts_from_tasks() -> None: assert task_interrupt.value == "Hey do you want to add James?" result = await program.ainvoke(Command(resume=True), config=config) - assert result is None + assert "__interrupt__" in result state = await program.aget_state(config=config) assert len(state.tasks[0].interrupts) == 1 From 9e89b685963af64643415232a3ed58348688be38 Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 22 Apr 2025 17:24:54 -0700 Subject: [PATCH 08/14] fixing prebuilt tests --- libs/prebuilt/tests/test_react_agent.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/libs/prebuilt/tests/test_react_agent.py b/libs/prebuilt/tests/test_react_agent.py index 4b469d077..eea4444e9 100644 --- a/libs/prebuilt/tests/test_react_agent.py +++ b/libs/prebuilt/tests/test_react_agent.py @@ -672,7 +672,8 @@ def test_react_agent_parallel_tool_calls( for event in agent.stream( {"messages": [("user", query)]}, config, stream_mode="values" ): - message_types.append([message.type for message in event["messages"]]) + if messages := event.get("messages"): + message_types.append([m.type for m in messages]) if version == "v1": assert message_types == [ @@ -691,7 +692,8 @@ def test_react_agent_parallel_tool_calls( for event in agent.stream( Command(resume={"data": "Hello"}), config, stream_mode="values" ): - message_types.append([message.type for message in event["messages"]]) + if messages := event.get("messages"): + message_types.append([m.type for m in messages]) assert message_types == [ ["human", "ai"], From d6b4ee348fe9c5fc6a1e7e14ebd71596faf80da6 Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Tue, 22 Apr 2025 17:32:13 -0700 Subject: [PATCH 09/14] goodness, last test fix --- libs/langgraph/tests/test_pregel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 316d44817..54a7d07cc 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -5469,9 +5469,9 @@ def test_interrupt_task_functional( return baz_result # First run, interrupted at bar - assert not graph.invoke({"a": ""}, config) + graph.invoke({"a": ""}, config) # Provide resumes - assert not graph.invoke(Command(resume="bar"), config) + graph.invoke(Command(resume="bar"), config) assert graph.invoke(Command(resume="baz"), config) == {"a": "foobarbaz"} From 8fe7c8101fcad223e53ce24b11fac936bd93a37d Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Wed, 23 Apr 2025 11:49:26 -0700 Subject: [PATCH 10/14] more explicit tests --- libs/langgraph/tests/test_pregel.py | 40 +++++++++++++++++++++-- libs/langgraph/tests/test_pregel_async.py | 30 +++++++++++++++-- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 54a7d07cc..5837eb156 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -5422,7 +5422,15 @@ def test_interrupt_functional( config = {"configurable": {"thread_id": "1"}} # First run, interrupted at bar - graph.invoke({"a": ""}, config) + assert graph.invoke({"a": ""}, config) == { + "__interrupt__": [ + Interrupt( + value="Provide value for bar:", + resumable=True, + ns=[AnyStr("graph:")], + ) + ] + } # Resume with an answer res = graph.invoke(Command(resume="bar"), config) assert res == {"a": "foobar", "b": "bar"} @@ -5453,7 +5461,20 @@ def test_interrupt_task_functional( config = {"configurable": {"thread_id": "1"}} # First run, interrupted at bar - graph.invoke({"a": ""}, config) + assert graph.invoke({"a": ""}, config) == { + "__interrupt__": [ + Interrupt( + value="Provide value for bar:", + resumable=True, + ns=[AnyStr("graph:"), AnyStr("bar:")], + ), + Interrupt( + value="Provide value for bar:", + resumable=True, + ns=[AnyStr("graph:"), AnyStr("bar:")], + ), + ] + } # Resume with an answer res = graph.invoke(Command(resume="bar"), config) assert res == {"a": "foobar"} @@ -5469,7 +5490,20 @@ def test_interrupt_task_functional( return baz_result # First run, interrupted at bar - graph.invoke({"a": ""}, config) + assert graph.invoke({"a": ""}, config) == { + "__interrupt__": [ + Interrupt( + value="Provide value for bar:", + resumable=True, + ns=[AnyStr("graph:"), AnyStr("bar:")], + ), + Interrupt( + value="Provide value for bar:", + resumable=True, + ns=[AnyStr("graph:"), AnyStr("bar:")], + ), + ] + } # Provide resumes graph.invoke(Command(resume="bar"), config) assert graph.invoke(Command(resume="baz"), config) == {"a": "foobarbaz"} diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 2b59575b7..0819ea77c 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -8226,7 +8226,20 @@ async def test_handles_multiple_interrupts_from_tasks() -> None: config = {"configurable": {"thread_id": "1"}} result = await program.ainvoke("this is ignored", config=config) - assert "__interrupt__" in result + assert result == { + "__interrupt__": [ + Interrupt( + value="Hey do you want to add James?", + resumable=True, + ns=[AnyStr("program:"), AnyStr("add_participant:")], + ), + Interrupt( + value="Hey do you want to add James?", + resumable=True, + ns=[AnyStr("program:"), AnyStr("add_participant:")], + ), + ] + } state = await program.aget_state(config=config) assert len(state.tasks[0].interrupts) == 1 @@ -8238,7 +8251,20 @@ async def test_handles_multiple_interrupts_from_tasks() -> None: assert task_interrupt.value == "Hey do you want to add James?" result = await program.ainvoke(Command(resume=True), config=config) - assert "__interrupt__" in result + assert result == { + "__interrupt__": [ + Interrupt( + value="Hey do you want to add Will?", + resumable=True, + ns=[AnyStr("program:"), AnyStr("add_participant:")], + ), + Interrupt( + value="Hey do you want to add Will?", + resumable=True, + ns=[AnyStr("program:"), AnyStr("add_participant:")], + ), + ] + } state = await program.aget_state(config=config) assert len(state.tasks[0].interrupts) == 1 From 9f33b4e529bba37aa3b20a3b82e3e5bb58419692 Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Wed, 23 Apr 2025 16:03:08 -0700 Subject: [PATCH 11/14] fix test to reflect bug fix --- libs/langgraph/tests/test_pregel_async.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 2e445f622..2cb578b30 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -8233,11 +8233,6 @@ async def test_handles_multiple_interrupts_from_tasks() -> None: resumable=True, ns=[AnyStr("program:"), AnyStr("add_participant:")], ), - Interrupt( - value="Hey do you want to add James?", - resumable=True, - ns=[AnyStr("program:"), AnyStr("add_participant:")], - ), ] } @@ -8258,11 +8253,6 @@ async def test_handles_multiple_interrupts_from_tasks() -> None: resumable=True, ns=[AnyStr("program:"), AnyStr("add_participant:")], ), - Interrupt( - value="Hey do you want to add Will?", - resumable=True, - ns=[AnyStr("program:"), AnyStr("add_participant:")], - ), ] } From 5929b0b08d2d7c73b82f7a642927af7a550414fd Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Wed, 23 Apr 2025 16:19:46 -0700 Subject: [PATCH 12/14] more test fixes --- libs/langgraph/tests/test_pregel.py | 10 ---------- libs/langgraph/tests/test_pregel_async.py | 10 +++++++++- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 901041795..2e0b91ef3 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -5468,11 +5468,6 @@ def test_interrupt_task_functional( resumable=True, ns=[AnyStr("graph:"), AnyStr("bar:")], ), - Interrupt( - value="Provide value for bar:", - resumable=True, - ns=[AnyStr("graph:"), AnyStr("bar:")], - ), ] } # Resume with an answer @@ -5497,11 +5492,6 @@ def test_interrupt_task_functional( resumable=True, ns=[AnyStr("graph:"), AnyStr("bar:")], ), - Interrupt( - value="Provide value for bar:", - resumable=True, - ns=[AnyStr("graph:"), AnyStr("bar:")], - ), ] } # Provide resumes diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 2cb578b30..df470195c 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -6873,7 +6873,15 @@ async def test_interrupt_task_functional(checkpointer_name: str) -> None: config = {"configurable": {"thread_id": "1"}} # First run, interrupted at bar - await graph.ainvoke({"a": ""}, config) + assert graph.ainvoke({"a": ""}, config) == { + "__interrupt__": [ + Interrupt( + value="Provide value for bar:", + resumable=True, + ns=[AnyStr("graph:"), AnyStr("bar:")], + ), + ] + } # Resume with an answer res = await graph.ainvoke(Command(resume="bar"), config) assert res == {"a": "foobar"} From cd6b086374d7dbf0b6e25d008e6c6e93095e4091 Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Wed, 23 Apr 2025 16:36:37 -0700 Subject: [PATCH 13/14] await --- libs/langgraph/tests/test_pregel_async.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index df470195c..01e354b6b 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -6873,7 +6873,7 @@ async def test_interrupt_task_functional(checkpointer_name: str) -> None: config = {"configurable": {"thread_id": "1"}} # First run, interrupted at bar - assert graph.ainvoke({"a": ""}, config) == { + assert await graph.ainvoke({"a": ""}, config) == { "__interrupt__": [ Interrupt( value="Provide value for bar:", From b81c21f311fd131d5969c33d238be2eeed5cc522 Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Wed, 23 Apr 2025 17:48:47 -0700 Subject: [PATCH 14/14] docs updates --- docs/docs/concepts/human_in_the_loop.md | 33 --------------------- libs/langgraph/langgraph/pregel/__init__.py | 4 +-- libs/langgraph/langgraph/types.py | 2 +- 3 files changed, 3 insertions(+), 36 deletions(-) diff --git a/docs/docs/concepts/human_in_the_loop.md b/docs/docs/concepts/human_in_the_loop.md index 4d667b804..cc045b81b 100644 --- a/docs/docs/concepts/human_in_the_loop.md +++ b/docs/docs/concepts/human_in_the_loop.md @@ -409,39 +409,6 @@ The `Command` primitive provides several options to control and modify the graph By leveraging `Command`, you can resume graph execution, handle user inputs, and dynamically adjust the graph's state. -## Using with `invoke` and `ainvoke` - -When you use `stream` or `astream` to run the graph, you will receive an `Interrupt` event that let you know the `interrupt` was triggered. - -`invoke` and `ainvoke` do not return the interrupt information. To access this information, you must use the [get_state](../reference/graphs.md#langgraph.graph.graph.CompiledGraph.get_state) method to retrieve the graph state after calling `invoke` or `ainvoke`. - -```python -# Run the graph up to the interrupt -result = graph.invoke(inputs, thread_config) -# Get the graph state to get interrupt information. -state = graph.get_state(thread_config) -# Print the state values -print(state.values) -# Print the pending tasks -print(state.tasks) -# Resume the graph with the user's input. -graph.invoke(Command(resume={"age": "25"}), thread_config) -``` - -```pycon -{'foo': 'bar'} # State values -( - PregelTask( - id='5d8ffc92-8011-0c9b-8b59-9d3545b7e553', - name='node_foo', - path=('__pregel_pull', 'node_foo'), - error=None, - interrupts=(Interrupt(value='value_in_interrupt', resumable=True, ns=['node_foo:5d8ffc92-8011-0c9b-8b59-9d3545b7e553'], when='during'),), state=None, - result=None - ), -) # Pending tasks. interrupts -``` - ## How does resuming from an interrupt work? !!! warning diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 9e7aa561d..14e98c460 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -2192,7 +2192,7 @@ class Pregel(PregelProtocol): stream_mode: The mode to stream output, defaults to self.stream_mode. Options are: - - `"values"`: Emit all values in the state after each step. + - `"values"`: Emit all values in the state after each step, including interrupts. When used with functional API, values are emitted once at the end of the workflow. - `"updates"`: Emit only the node or task names and updates returned by the nodes or tasks after each step. If multiple updates are made in the same step (e.g. multiple nodes are run) then those updates are emitted separately. @@ -2479,7 +2479,7 @@ class Pregel(PregelProtocol): stream_mode: The mode to stream output, defaults to self.stream_mode. Options are: - - `"values"`: Emit all values in the state after each step. + - `"values"`: Emit all values in the state after each step, including interrupts. When used with functional API, values are emitted once at the end of the workflow. - `"updates"`: Emit only the node or task names and updates returned by the nodes or tasks after each step. If multiple updates are made in the same step (e.g. multiple nodes are run) then those updates are emitted separately. diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index b2a681cb7..5462bafb2 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -49,7 +49,7 @@ Checkpointer = Union[None, bool, BaseCheckpointSaver] StreamMode = Literal["values", "updates", "debug", "messages", "custom"] """How the stream method should emit outputs. -- `"values"`: Emit all values in the state after each step. +- `"values"`: Emit all values in the state after each step, including interrupts. When used with functional API, values are emitted once at the end of the workflow. - `"updates"`: Emit only the node or task names and updates returned by the nodes or tasks after each step. If multiple updates are made in the same step (e.g. multiple nodes are run) then those updates are emitted separately.