From d85e267a83bec2e080eae26e1e3486c39f8020d1 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 21 Aug 2024 16:23:48 -0700 Subject: [PATCH 01/11] Add interrupts and errors to debug stream mode - move remaining output code from Pregel.stream to PregelLoop --- libs/langgraph/langgraph/pregel/__init__.py | 122 +++++++--------- libs/langgraph/langgraph/pregel/debug.py | 16 +- libs/langgraph/langgraph/pregel/io.py | 19 ++- libs/langgraph/langgraph/pregel/loop.py | 36 ++++- libs/langgraph/tests/test_pregel.py | 23 ++- libs/langgraph/tests/test_pregel_async.py | 153 +++++++++++++++++++- 6 files changed, 272 insertions(+), 97 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 6e4d3e002..563f097f8 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -76,16 +76,12 @@ from langgraph.pregel.algo import ( should_interrupt, ) from langgraph.pregel.debug import ( - map_debug_task_results, print_step_checkpoint, print_step_tasks, print_step_writes, tasks_w_writes, ) -from langgraph.pregel.io import ( - map_output_updates, - read_channels, -) +from langgraph.pregel.io import read_channels from langgraph.pregel.loop import AsyncPregelLoop, SyncPregelLoop from langgraph.pregel.manager import AsyncChannelsManager, ChannelsManager from langgraph.pregel.read import PregelNode @@ -962,6 +958,7 @@ class Pregel( nodes=self.nodes, specs=self.channels, output_keys=output_keys, + stream_keys=self.stream_channels_asis, ) as loop: # Similarly to Bulk Synchronous Parallel / Pregel model # computation proceeds in steps, while there are channel updates @@ -970,7 +967,6 @@ class Pregel( # with channel updates applied only at the transition between steps while loop.tick( input_keys=self.input_channels, - stream_keys=self.stream_channels_asis, interrupt_before=interrupt_before, interrupt_after=interrupt_after, manager=run_manager, @@ -1039,26 +1035,17 @@ class Pregel( else: # save task writes to checkpointer loop.put_writes(task.id, task.writes) - # yield updates output for the finished task - if "updates" in stream_modes: - yield from _with_mode( - "updates", - isinstance(stream_mode, list), - map_output_updates(output_keys, [task]), - ) - if "debug" in stream_modes: - yield from _with_mode( - "debug", - isinstance(stream_mode, list), - map_debug_task_results( - loop.step, - [task], - self.stream_channels_list, - ), - ) else: # remove references to loop vars del fut, task + # emit output + while loop.stream: + mode, payload = loop.stream.popleft() + if mode in stream_modes: + if isinstance(stream_mode, list): + yield (mode, payload) + else: + yield payload if _should_stop_others(done): break @@ -1073,21 +1060,21 @@ class Pregel( [w for t in loop.tasks for w in t.writes], self.stream_channels_list, ) - # emit output - while loop.stream: - mode, payload = loop.stream.popleft() - if mode in stream_modes: - if isinstance(stream_mode, list): - yield (mode, payload) - else: - yield payload - # handle exit - if loop.status == "out_of_steps": - raise GraphRecursionError( - f"Recursion limit of {config['recursion_limit']} reached " - "without hitting a stop condition. You can increase the " - "limit by setting the `recursion_limit` config key." - ) + # emit output + while loop.stream: + mode, payload = loop.stream.popleft() + if mode in stream_modes: + if isinstance(stream_mode, list): + yield (mode, payload) + else: + yield payload + # handle exit + if loop.status == "out_of_steps": + raise GraphRecursionError( + f"Recursion limit of {config['recursion_limit']} reached " + "without hitting a stop condition. You can increase the " + "limit by setting the `recursion_limit` config key." + ) # set final channel values as run output run_manager.on_chain_end(loop.output) except BaseException as e: @@ -1219,6 +1206,7 @@ class Pregel( nodes=self.nodes, specs=self.channels, output_keys=output_keys, + stream_keys=self.stream_channels_asis, ) as loop: aioloop = asyncio.get_event_loop() # Similarly to Bulk Synchronous Parallel / Pregel model @@ -1228,7 +1216,6 @@ class Pregel( # with channel updates applied only at the transition between steps while loop.tick( input_keys=self.input_channels, - stream_keys=self.stream_channels_asis, interrupt_before=interrupt_before, interrupt_after=interrupt_after, manager=run_manager, @@ -1298,28 +1285,17 @@ class Pregel( else: # save task writes to checkpointer loop.put_writes(task.id, task.writes) - # yield updates output for the finished task - if "updates" in stream_modes: - for chunk in _with_mode( - "updates", - isinstance(stream_mode, list), - map_output_updates(output_keys, [task]), - ): - yield chunk - if "debug" in stream_modes: - for chunk in _with_mode( - "debug", - isinstance(stream_mode, list), - map_debug_task_results( - loop.step, - [task], - self.stream_channels_list, - ), - ): - yield chunk else: # remove references to loop vars del fut, task + # emit output + while loop.stream: + mode, payload = loop.stream.popleft() + if mode in stream_modes: + if isinstance(stream_mode, list): + yield (mode, payload) + else: + yield payload if _should_stop_others(done): break @@ -1334,21 +1310,21 @@ class Pregel( [w for t in loop.tasks for w in t.writes], self.stream_channels_list, ) - # emit output - while loop.stream: - mode, payload = loop.stream.popleft() - if mode in stream_modes: - if isinstance(stream_mode, list): - yield (mode, payload) - else: - yield payload - # handle exit - if loop.status == "out_of_steps": - raise GraphRecursionError( - f"Recursion limit of {config['recursion_limit']} reached " - "without hitting a stop condition. You can increase the " - "limit by setting the `recursion_limit` config key." - ) + # emit output + while loop.stream: + mode, payload = loop.stream.popleft() + if mode in stream_modes: + if isinstance(stream_mode, list): + yield (mode, payload) + else: + yield payload + # handle exit + if loop.status == "out_of_steps": + raise GraphRecursionError( + f"Recursion limit of {config['recursion_limit']} reached " + "without hitting a stop condition. You can increase the " + "limit by setting the `recursion_limit` config key." + ) # set final channel values as run output await run_manager.on_chain_end(loop.output) except BaseException as e: diff --git a/libs/langgraph/langgraph/pregel/debug.py b/libs/langgraph/langgraph/pregel/debug.py index 5102cd8c5..72b6cdbe5 100644 --- a/libs/langgraph/langgraph/pregel/debug.py +++ b/libs/langgraph/langgraph/pregel/debug.py @@ -1,5 +1,6 @@ import json from collections import defaultdict +from dataclasses import asdict from datetime import datetime, timezone from pprint import pformat from typing import Any, Iterator, Literal, Mapping, Optional, Sequence, TypedDict, Union @@ -25,6 +26,8 @@ class TaskPayload(TypedDict): class TaskResultPayload(TypedDict): id: str name: str + error: Optional[str] + interrupts: list[dict] result: list[tuple[str, Any]] @@ -97,11 +100,14 @@ def map_debug_tasks( def map_debug_task_results( step: int, - tasks: list[PregelExecutableTask], - stream_channels_list: Sequence[str], + tasks: list[tuple[PregelExecutableTask, Sequence[tuple[str, Any]]]], + stream_keys: Union[str, Sequence[str]], ) -> Iterator[DebugOutputTaskResult]: + stream_channels_list = ( + [stream_keys] if isinstance(stream_keys, str) else stream_keys + ) ts = datetime.now(timezone.utc).isoformat() - for name, _, _, writes, config, _, _, _ in tasks: + for (name, _, _, _, config, _, _, _), writes in tasks: if config is not None and TAG_HIDDEN in config.get("tags", []): continue @@ -116,7 +122,9 @@ def map_debug_task_results( "payload": { "id": str(uuid5(TASK_NAMESPACE, json.dumps((name, step, metadata)))), "name": name, + "error": next((w[1] for w in writes if w[0] == ERROR), None), "result": [w for w in writes if w[0] in stream_channels_list], + "interrupts": [asdict(w[1]) for w in writes if w[0] == INTERRUPT], }, } @@ -150,7 +158,7 @@ def map_debug_checkpoint( else { "id": t.id, "name": t.name, - "interrupts": t.interrupts, + "interrupts": tuple(asdict(i) for i in t.interrupts), } for t in tasks_w_writes(tasks, pending_writes) ], diff --git a/libs/langgraph/langgraph/pregel/io.py b/libs/langgraph/langgraph/pregel/io.py index 53e77557e..6c9205248 100644 --- a/libs/langgraph/langgraph/pregel/io.py +++ b/libs/langgraph/langgraph/pregel/io.py @@ -3,7 +3,7 @@ from typing import Any, Iterator, Mapping, Optional, Sequence, TypeVar, Union from langchain_core.runnables.utils import AddableDict from langgraph.channels.base import BaseChannel, EmptyChannelError -from langgraph.constants import TAG_HIDDEN +from langgraph.constants import ERROR, INTERRUPT, TAG_HIDDEN from langgraph.pregel.log import logger from langgraph.pregel.types import PregelExecutableTask @@ -95,19 +95,22 @@ class AddableUpdatesDict(AddableDict): def map_output_updates( output_channels: Union[str, Sequence[str]], - tasks: list[PregelExecutableTask], + tasks: list[tuple[PregelExecutableTask, Sequence[tuple[str, Any]]]], ) -> Iterator[dict[str, Union[Any, dict[str, Any]]]]: """Map pending writes (a sequence of tuples (channel, value)) to output chunk.""" output_tasks = [ - t for t in tasks if not t.config or TAG_HIDDEN not in t.config.get("tags") + (t, ww) + for t, ww in tasks + if (not t.config or TAG_HIDDEN not in t.config.get("tags")) + and all(k not in (ERROR, INTERRUPT) for k, _ in ww) ] if not output_tasks: return if isinstance(output_channels, str): updated = [ (task.name, value) - for task in output_tasks - for chan, value in task.writes + for task, writes in output_tasks + for chan, value in writes if chan == output_channels ] else: @@ -116,10 +119,10 @@ def map_output_updates( task.name, {chan: value for chan, value in task.writes if chan in output_channels}, ) - for task in output_tasks - if any(chan in output_channels for chan, _ in task.writes) + for task, writes in output_tasks + if any(chan in output_channels for chan, _ in writes) ] - grouped = {t.name: [] for t in output_tasks} + grouped = {t.name: [] for t, _ in output_tasks} for node, value in updated: grouped[node].append(value) for node, value in grouped.items(): diff --git a/libs/langgraph/langgraph/pregel/loop.py b/libs/langgraph/langgraph/pregel/loop.py index aa256b2f7..11b937eea 100644 --- a/libs/langgraph/langgraph/pregel/loop.py +++ b/libs/langgraph/langgraph/pregel/loop.py @@ -56,7 +56,11 @@ from langgraph.pregel.algo import ( prepare_next_tasks, should_interrupt, ) -from langgraph.pregel.debug import map_debug_checkpoint, map_debug_tasks +from langgraph.pregel.debug import ( + map_debug_checkpoint, + map_debug_task_results, + map_debug_tasks, +) from langgraph.pregel.executor import ( AsyncBackgroundExecutor, BackgroundExecutor, @@ -90,6 +94,7 @@ class PregelLoop: nodes: Mapping[str, PregelNode] specs: Mapping[str, Union[BaseChannel, ManagedValueSpec]] output_keys: Union[str, Sequence[str]] + stream_keys: Union[str, Sequence[str]] is_nested: bool checkpointer_get_next_version: Callable[[Optional[V]], V] @@ -138,6 +143,7 @@ class PregelLoop: nodes: Mapping[str, PregelNode], specs: Mapping[str, Union[BaseChannel, ManagedValueSpec]], output_keys: Union[str, Sequence[str]], + stream_keys: Union[str, Sequence[str]], ) -> None: self.stream = deque() self.input = input @@ -147,6 +153,7 @@ class PregelLoop: self.nodes = nodes self.specs = specs self.output_keys = output_keys + self.stream_keys = stream_keys self.is_nested = CONFIG_KEY_READ in self.config.get("configurable", {}) def mark_tasks_scheduled(self, tasks: Sequence[PregelExecutableTask]) -> None: @@ -172,12 +179,24 @@ class PregelLoop: writes, task_id, ) + if task := next((t for t in self.tasks if t.id == task_id), None): + self.stream.extend( + ("updates", v) + for v in map_output_updates(self.output_keys, [(task, writes)]) + ) + self.stream.extend( + ("debug", v) + for v in map_debug_task_results( + self.step, [(task, writes)], self.stream_keys + ) + ) + else: + raise RuntimeError def tick( self, *, input_keys: Union[str, Sequence[str]], - stream_keys: Union[str, Sequence[str]] = EMPTY_SEQ, interrupt_after: Sequence[str] = EMPTY_SEQ, interrupt_before: Sequence[str] = EMPTY_SEQ, manager: Union[None, AsyncParentRunManager, ParentRunManager] = None, @@ -213,7 +232,11 @@ class PregelLoop: self._put_checkpoint( { "source": "loop", - "writes": single(map_output_updates(self.output_keys, self.tasks)), + "writes": single( + map_output_updates( + self.output_keys, [(t, t.writes) for t in self.tasks] + ) + ), } ) # after execution, check if we should interrupt @@ -256,7 +279,7 @@ class PregelLoop: self.step - 1, # printing checkpoint for previous step self.checkpoint_config, self.channels, - stream_keys, + self.stream_keys, self.checkpoint_metadata, self.checkpoint, self.tasks, @@ -281,7 +304,6 @@ class PregelLoop: if all(task.writes for task in self.tasks): return self.tick( input_keys=input_keys, - stream_keys=stream_keys, interrupt_after=interrupt_after, interrupt_before=interrupt_before, manager=manager, @@ -435,6 +457,7 @@ class SyncPregelLoop(PregelLoop, ContextManager): nodes: Mapping[str, PregelNode], specs: Mapping[str, Union[BaseChannel, ManagedValueSpec]], output_keys: Union[str, Sequence[str]] = EMPTY_SEQ, + stream_keys: Union[str, Sequence[str]] = EMPTY_SEQ, ) -> None: super().__init__( input, @@ -444,6 +467,7 @@ class SyncPregelLoop(PregelLoop, ContextManager): nodes=nodes, specs=specs, output_keys=output_keys, + stream_keys=stream_keys, ) self.stack = ExitStack() if checkpointer: @@ -522,6 +546,7 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager): nodes: Mapping[str, PregelNode], specs: Mapping[str, Union[BaseChannel, ManagedValueSpec]], output_keys: Union[str, Sequence[str]] = EMPTY_SEQ, + stream_keys: Union[str, Sequence[str]] = EMPTY_SEQ, ) -> None: super().__init__( input, @@ -531,6 +556,7 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager): nodes=nodes, specs=specs, output_keys=output_keys, + stream_keys=stream_keys, ) self.store = AsyncBatchedStore(self.store) if self.store else None self.stack = AsyncExitStack() diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 74897efcc..c1fc68c2a 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -769,7 +769,7 @@ def test_invoke_two_processes_in_out_interrupt( ), ] - # forking from any previous checkpoint w/out forking should do nothing + # re-running from any previous checkpoint w/out forking should do nothing assert [c for c in app.stream(None, history[0].config, stream_mode="updates")] == [] assert [c for c in app.stream(None, history[1].config, stream_mode="updates")] == [] assert [c for c in app.stream(None, history[2].config, stream_mode="updates")] == [] @@ -1033,6 +1033,8 @@ def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None: "id": "2687f72c-e3a8-5f6f-9afa-047cbf24e923", "name": "one", "result": [("inbox", 3)], + "error": None, + "interrupts": [], }, }, { @@ -1043,6 +1045,8 @@ def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None: "id": "18f52f6a-828d-58a1-a501-53cc0c7af33e", "name": "two", "result": [("output", 13)], + "error": None, + "interrupts": [], }, }, { @@ -1064,6 +1068,8 @@ def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None: "id": "871d6e74-7bb3-565f-a4fe-cef4b8f19b62", "name": "two", "result": [("output", 4)], + "error": None, + "interrupts": [], }, }, ] @@ -1744,7 +1750,6 @@ def test_channel_enter_exit_timing(mocker: MockerFixture) -> None: assert cleanup.call_count == 0 for i, chunk in enumerate(app.stream(2)): assert setup.call_count == 1, "Expected setup to be called once" - assert cleanup.call_count == 0, "Expected cleanup to not be called yet" if i == 0: assert chunk == {"inbox": [3]} elif i == 1: @@ -6017,6 +6022,8 @@ def test_in_one_fan_out_out_one_graph_state() -> None: "id": "592f3430-c17c-5d1c-831f-fecebb2c05bf", "name": "rewrite_query", "result": [("query", "query: what is weather in sf")], + "error": None, + "interrupts": [], }, }, ), @@ -6071,6 +6078,8 @@ def test_in_one_fan_out_out_one_graph_state() -> None: "id": "96965ed0-2c10-52a1-86eb-081ba6de73b2", "name": "retriever_two", "result": [("docs", ["doc3", "doc4"])], + "error": None, + "interrupts": [], }, }, ), @@ -6088,6 +6097,8 @@ def test_in_one_fan_out_out_one_graph_state() -> None: "id": "7db5e9d8-e132-5079-ab99-ced15e67d48b", "name": "retriever_one", "result": [("docs", ["doc1", "doc2"])], + "error": None, + "interrupts": [], }, }, ), @@ -6127,6 +6138,8 @@ def test_in_one_fan_out_out_one_graph_state() -> None: "id": "8959fb57-d0f5-5725-9ac4-ec1c554fb0a0", "name": "qa", "result": [("answer", "doc1,doc2,doc3,doc4")], + "error": None, + "interrupts": [], }, }, ), @@ -6544,6 +6557,8 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: "id": "7b7b0713-e958-5d07-803c-c9910a7cc162", "name": "prepare", "result": [("my_key", " prepared")], + "error": None, + "interrupts": [], }, }, { @@ -6596,6 +6611,8 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: "id": "dd9f2fa5-ccfa-5d12-81ec-942563056a08", "name": "tool_two_slow", "result": [("my_key", " slow")], + "error": None, + "interrupts": [], }, }, { @@ -6646,6 +6663,8 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: "id": "9b590c54-15ef-54b1-83a7-140d27b0bc52", "name": "finish", "result": [("my_key", " finished")], + "error": None, + "interrupts": [], }, }, { diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index ef78c8bdb..692d101e0 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -1278,6 +1278,8 @@ async def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None: "id": "2687f72c-e3a8-5f6f-9afa-047cbf24e923", "name": "one", "result": [("inbox", 3)], + "error": None, + "interrupts": [], }, }, { @@ -1288,6 +1290,8 @@ async def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None: "id": "18f52f6a-828d-58a1-a501-53cc0c7af33e", "name": "two", "result": [("output", 13)], + "error": None, + "interrupts": [], }, }, { @@ -1309,6 +1313,8 @@ async def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None: "id": "871d6e74-7bb3-565f-a4fe-cef4b8f19b62", "name": "two", "result": [("output", 4)], + "error": None, + "interrupts": [], }, }, ] @@ -1978,7 +1984,6 @@ async def test_channel_enter_exit_timing(mocker: MockerFixture) -> None: assert setup_sync.call_count == 0, "Sync context manager should not be used" assert cleanup_sync.call_count == 0, "Sync context manager should not be used" assert setup_async.call_count == 1, "Expected setup to be called once" - assert cleanup_async.call_count == 0, "Expected cleanup to not be called yet" if i == 0: assert chunk == {"inbox": [3]} elif i == 1: @@ -4744,6 +4749,8 @@ async def test_in_one_fan_out_out_one_graph_state() -> None: "id": "592f3430-c17c-5d1c-831f-fecebb2c05bf", "name": "rewrite_query", "result": [("query", "query: what is weather in sf")], + "error": None, + "interrupts": [], }, }, ), @@ -4798,6 +4805,8 @@ async def test_in_one_fan_out_out_one_graph_state() -> None: "id": "96965ed0-2c10-52a1-86eb-081ba6de73b2", "name": "retriever_two", "result": [("docs", ["doc3", "doc4"])], + "error": None, + "interrupts": [], }, }, ), @@ -4815,6 +4824,8 @@ async def test_in_one_fan_out_out_one_graph_state() -> None: "id": "7db5e9d8-e132-5079-ab99-ced15e67d48b", "name": "retriever_one", "result": [("docs", ["doc1", "doc2"])], + "error": None, + "interrupts": [], }, }, ), @@ -4854,6 +4865,8 @@ async def test_in_one_fan_out_out_one_graph_state() -> None: "id": "8959fb57-d0f5-5725-9ac4-ec1c554fb0a0", "name": "qa", "result": [("answer", "doc1,doc2,doc3,doc4")], + "error": None, + "interrupts": [], }, }, ), @@ -5218,6 +5231,8 @@ async def test_branch_then() -> None: "id": "7b7b0713-e958-5d07-803c-c9910a7cc162", "name": "prepare", "result": [("my_key", " prepared")], + "error": None, + "interrupts": [], }, }, { @@ -5270,6 +5285,8 @@ async def test_branch_then() -> None: "id": "dd9f2fa5-ccfa-5d12-81ec-942563056a08", "name": "tool_two_slow", "result": [("my_key", " slow")], + "error": None, + "interrupts": [], }, }, { @@ -5320,6 +5337,8 @@ async def test_branch_then() -> None: "id": "9b590c54-15ef-54b1-83a7-140d27b0bc52", "name": "finish", "result": [("my_key", " finished")], + "error": None, + "interrupts": [], }, }, { @@ -5363,10 +5382,134 @@ async def test_branch_then() -> None: thread1 = {"configurable": {"thread_id": "1"}} # stop when about to enter node - assert await tool_two.ainvoke({"my_key": "value", "market": "DE"}, thread1) == { - "my_key": "value prepared", - "market": "DE", - } + assert [ + c + async for c in tool_two.astream( + {"my_key": "value", "market": "DE"}, thread1, stream_mode="debug" + ) + ] == [ + { + "type": "checkpoint", + "timestamp": AnyStr(), + "step": -1, + "payload": { + "config": { + "tags": [], + "metadata": {"thread_id": "1"}, + "callbacks": None, + "recursion_limit": 25, + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + }, + }, + "values": {"my_key": ""}, + "metadata": { + "source": "input", + "step": -1, + "writes": {"my_key": "value", "market": "DE"}, + }, + "next": ["__start__"], + "tasks": [{"id": AnyStr(), "name": "__start__", "interrupts": ()}], + }, + }, + { + "type": "checkpoint", + "timestamp": AnyStr(), + "step": 0, + "payload": { + "config": { + "tags": [], + "metadata": {"thread_id": "1"}, + "callbacks": None, + "recursion_limit": 25, + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + }, + }, + "values": { + "my_key": "value", + "market": "DE", + }, + "metadata": { + "source": "loop", + "step": 0, + "writes": None, + }, + "next": ["prepare"], + "tasks": [{"id": AnyStr(), "name": "prepare", "interrupts": ()}], + }, + }, + { + "type": "task", + "timestamp": AnyStr(), + "step": 1, + "payload": { + "id": "ca572c3b-b805-5fc6-a19e-3d79f52dde70", + "name": "prepare", + "input": {"my_key": "value", "market": "DE"}, + "triggers": ["start:prepare"], + }, + }, + { + "type": "task_result", + "timestamp": AnyStr(), + "step": 1, + "payload": { + "id": "ca572c3b-b805-5fc6-a19e-3d79f52dde70", + "name": "prepare", + "result": [("my_key", " prepared")], + "error": None, + "interrupts": [], + }, + }, + { + "type": "checkpoint", + "timestamp": AnyStr(), + "step": 1, + "payload": { + "config": { + "tags": [], + "metadata": {"thread_id": "1"}, + "callbacks": None, + "recursion_limit": 25, + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + }, + }, + "values": { + "my_key": "value prepared", + "market": "DE", + }, + "metadata": { + "source": "loop", + "step": 1, + "writes": {"prepare": {"my_key": " prepared"}}, + }, + "next": ["tool_two_slow"], + "tasks": [ + {"id": AnyStr(), "name": "tool_two_slow", "interrupts": ()} + ], + }, + }, + { + "type": "task_result", + "timestamp": AnyStr(), + "step": 2, + "payload": { + "id": "054b0ced-4546-58f4-bee5-548f029e1a8e", + "name": "tool_two_slow", + "result": [], + "error": None, + "interrupts": [{"when": "before", "value": None}], + }, + }, + ] assert await tool_two.aget_state(thread1) == StateSnapshot( values={"my_key": "value prepared", "market": "DE"}, tasks=( From d67419522dfa5350115198b8576f198cd06155fe Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 21 Aug 2024 16:27:23 -0700 Subject: [PATCH 02/11] Remove error --- libs/langgraph/langgraph/pregel/loop.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/loop.py b/libs/langgraph/langgraph/pregel/loop.py index 11b937eea..061b932c0 100644 --- a/libs/langgraph/langgraph/pregel/loop.py +++ b/libs/langgraph/langgraph/pregel/loop.py @@ -190,8 +190,6 @@ class PregelLoop: self.step, [(task, writes)], self.stream_keys ) ) - else: - raise RuntimeError def tick( self, From a545a70afb08f7458411bdc53284ed640e77494b Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 21 Aug 2024 16:33:30 -0700 Subject: [PATCH 03/11] lib0.2.10 --- 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 1df951ee4..9b38e6049 100644 --- a/libs/langgraph/pyproject.toml +++ b/libs/langgraph/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langgraph" -version = "0.2.9" +version = "0.2.10" description = "Building stateful, multi-actor applications with LLMs" authors = [] license = "MIT" From a9a59dd4e44d22f0c48301c06553e6ae92eece8f Mon Sep 17 00:00:00 2001 From: Harrison Chase Date: Thu, 22 Aug 2024 06:29:23 -0700 Subject: [PATCH 04/11] fix notebook (#1422) --- .../information-gather-prompting.ipynb | 144 ++++++++++++------ 1 file changed, 97 insertions(+), 47 deletions(-) diff --git a/examples/chatbots/information-gather-prompting.ipynb b/examples/chatbots/information-gather-prompting.ipynb index bc1248cda..ad4cdfe35 100644 --- a/examples/chatbots/information-gather-prompting.ipynb +++ b/examples/chatbots/information-gather-prompting.ipynb @@ -42,7 +42,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 21, "id": "5f795b78-004d-40ca-95d6-069f67e4f9c9", "metadata": {}, "outputs": [], @@ -77,7 +77,11 @@ "llm = ChatOpenAI(temperature=0)\n", "llm_with_tool = llm.bind_tools([PromptInstructions])\n", "\n", - "chain = get_messages_info | llm_with_tool" + "\n", + "def info_chain(state):\n", + " messages = get_messages_info(state['messages'])\n", + " response = llm_with_tool.invoke(messages)\n", + " return {\"messages\": [response]}" ] }, { @@ -93,7 +97,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 22, "id": "ca9a0234-bbeb-4bff-8276-8dde499c3390", "metadata": {}, "outputs": [], @@ -121,7 +125,10 @@ " return [SystemMessage(content=prompt_system.format(reqs=tool_call))] + other_msgs\n", "\n", "\n", - "prompt_gen_chain = get_prompt_messages | llm" + "def prompt_gen_chain(state):\n", + " messages = get_prompt_messages(state['messages'])\n", + " response = llm.invoke(messages)\n", + " return {\"messages\": [response]}" ] }, { @@ -140,7 +147,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 23, "id": "74f29e15-20e2-420c-a450-84e929f16e4e", "metadata": {}, "outputs": [], @@ -150,7 +157,8 @@ "from langgraph.graph import END\n", "\n", "\n", - "def get_state(messages) -> Literal[\"add_tool_message\", \"info\", \"__end__\"]:\n", + "def get_state(state) -> Literal[\"add_tool_message\", \"info\", \"__end__\"]:\n", + " messages = state['messages']\n", " if isinstance(messages[-1], AIMessage) and messages[-1].tool_calls:\n", " return \"add_tool_message\"\n", " elif not isinstance(messages[-1], HumanMessage):\n", @@ -171,7 +179,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 28, "id": "59d9d6b4-dce4-43cc-9a1a-61a7912ed5b8", "metadata": {}, "outputs": [], @@ -187,15 +195,15 @@ "\n", "memory = MemorySaver()\n", "workflow = StateGraph(State)\n", - "workflow.add_node(\"info\", chain)\n", + "workflow.add_node(\"info\", info_chain)\n", "workflow.add_node(\"prompt\", prompt_gen_chain)\n", "\n", "\n", "@workflow.add_node\n", - "def add_tool_message(state: list):\n", - " return ToolMessage(\n", - " content=\"Prompt generated!\", tool_call_id=state[-1].tool_calls[0][\"id\"]\n", - " )\n", + "def add_tool_message(state: State):\n", + " return {\"messages\": [ToolMessage(\n", + " content=\"Prompt generated!\", tool_call_id=state['messages'][-1].tool_calls[0][\"id\"]\n", + " )]}\n", "\n", "\n", "workflow.add_conditional_edges(\"info\", get_state)\n", @@ -207,13 +215,13 @@ }, { "cell_type": "code", - "execution_count": 38, + "execution_count": 29, "id": "1b1613e0", "metadata": {}, "outputs": [ { "data": { - "image/jpeg": "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", + "image/jpeg": "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", "text/plain": [ "" ] @@ -240,63 +248,105 @@ }, { "cell_type": "code", - "execution_count": 41, + "execution_count": 30, "id": "25793988-45a2-4e65-b33c-64e72aadb10e", "metadata": {}, "outputs": [ + { + "name": "stdin", + "output_type": "stream", + "text": [ + "User (q/Q to quit): hi\n" + ] + }, { "name": "stdout", "output_type": "stream", "text": [ "==================================\u001b[1m Ai Message \u001b[0m==================================\n", "\n", - "Hello! How can I assist you today?\n", + "Hello! How can I assist you today?\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "User (q/Q to quit): rag prompt\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ "==================================\u001b[1m Ai Message \u001b[0m==================================\n", "\n", - "Sure! I can help you with that. To create an extraction prompt, I need some information from you. Could you please provide the following details:\n", + "Sure! I can help you create a prompt template. To get started, could you please provide me with the following information:\n", "\n", "1. What is the objective of the prompt?\n", "2. What variables will be passed into the prompt template?\n", "3. Any constraints for what the output should NOT do?\n", "4. Any requirements that the output MUST adhere to?\n", "\n", - "Once I have this information, I can create the extraction prompt for you.\n", + "Once I have this information, I can assist you in creating the prompt template.\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "User (q/Q to quit): 1 rag, 2 none, 3 no, 4 no\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ "==================================\u001b[1m Ai Message \u001b[0m==================================\n", - "\n", - "Great! To create an extraction prompt for filling out a CSAT (Customer Satisfaction) survey, I will need the following information:\n", - "\n", - "1. Objective: To gather feedback on customer satisfaction.\n", - "2. Variables: Customer name, Date of interaction, Service provided, Rating (scale of 1-5), Comments.\n", - "3. Constraints: The output should not include any personally identifiable information (PII) of the customer.\n", - "4. Requirements: The output must include a structured format with fields for each variable mentioned above.\n", - "\n", - "With this information, I will proceed to create the extraction prompt template for filling out a CSAT survey. Let's get started!\n", "Tool Calls:\n", - " PromptInstructions (call_aU48Bjo7X29tXfRtCcrXkrqq)\n", - " Call ID: call_aU48Bjo7X29tXfRtCcrXkrqq\n", + " PromptInstructions (call_7qkSORledsemoCnK8A3RKvAb)\n", + " Call ID: call_7qkSORledsemoCnK8A3RKvAb\n", " Args:\n", - " objective: To gather feedback on customer satisfaction.\n", - " variables: ['Customer name', 'Date of interaction', 'Service provided', 'Rating (scale of 1-5)', 'Comments']\n", - " constraints: ['The output should not include any personally identifiable information (PII) of the customer.']\n", - " requirements: ['The output must include a structured format with fields for each variable mentioned above.']\n", + " objective: rag\n", + " variables: ['none']\n", + " constraints: ['no']\n", + " requirements: ['no']\n", "=================================\u001b[1m Tool Message \u001b[0m=================================\n", "\n", "Prompt generated!\n", "==================================\u001b[1m Ai Message \u001b[0m==================================\n", "\n", - "Please provide feedback on your recent interaction with our service. Your input is valuable to us in improving our services.\n", - "\n", - "Customer name: \n", - "Date of interaction: \n", - "Service provided: \n", - "Rating (scale of 1-5): \n", - "Comments: \n", - "\n", - "Please note that the output should not include any personally identifiable information (PII) of the customer. Your feedback will be kept confidential and used for internal evaluation purposes only. Thank you for taking the time to share your thoughts with us.\n", - "Done!\n", + "Please write a response using the RAG (Red, Amber, Green) rating system.\n", + "Done!\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "User (q/Q to quit): red\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ "==================================\u001b[1m Ai Message \u001b[0m==================================\n", "\n", - "I'm glad you found it helpful! If you need any more assistance or have any other requests, feel free to let me know. Have a great day!\n", + "Thank you for providing the response. If you need any more assistance, feel free to ask!\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "User (q/Q to quit): q\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ "AI: Byebye\n" ] } @@ -312,9 +362,9 @@ " break\n", " output = None\n", " for output in graph.stream(\n", - " [HumanMessage(content=user)], config=config, stream_mode=\"updates\"\n", + " {\"messages\": [HumanMessage(content=user)]}, config=config, stream_mode=\"updates\"\n", " ):\n", - " last_message = next(iter(output.values()))\n", + " last_message = next(iter(output.values()))['messages'][-1]\n", " last_message.pretty_print()\n", "\n", " if output and \"prompt\" in output:\n", @@ -344,7 +394,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.2" + "version": "3.11.1" } }, "nbformat": 4, From 9a306ce9319d6ae599a99d7046a310579c91e41e Mon Sep 17 00:00:00 2001 From: vbarda Date: Thu, 22 Aug 2024 11:47:36 -0400 Subject: [PATCH 05/11] langgraph: check for empty checkpoint when creating snapshot tasks --- libs/langgraph/langgraph/pregel/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 563f097f8..270a3c7ba 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -371,7 +371,7 @@ class Pregel( saved.metadata if saved else None, saved.checkpoint["ts"] if saved else None, saved.parent_config if saved else None, - tasks_w_writes(next_tasks, saved.pending_writes), + tasks_w_writes(next_tasks, saved.pending_writes if saved else None), ) async def aget_state(self, config: RunnableConfig) -> StateSnapshot: @@ -402,7 +402,7 @@ class Pregel( saved.metadata if saved else None, saved.checkpoint["ts"] if saved else None, saved.parent_config if saved else None, - tasks_w_writes(next_tasks, saved.pending_writes), + tasks_w_writes(next_tasks, saved.pending_writes if saved else None), ) def get_state_history( From 7ec3c0e827a6bfa574bde1d2b09f203296dbac68 Mon Sep 17 00:00:00 2001 From: Vadym Barda Date: Thu, 22 Aug 2024 11:54:47 -0400 Subject: [PATCH 06/11] langgraph: release 0.2.11 (#1432) --- 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 9b38e6049..1c3b20e9b 100644 --- a/libs/langgraph/pyproject.toml +++ b/libs/langgraph/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langgraph" -version = "0.2.10" +version = "0.2.11" description = "Building stateful, multi-actor applications with LLMs" authors = [] license = "MIT" From 4e2b508ebb91bf517625c89c0d0aaa6e5aef722c Mon Sep 17 00:00:00 2001 From: Hassan Memon Date: Thu, 22 Aug 2024 21:27:23 +0500 Subject: [PATCH 07/11] docs: remove extra line from generate function (#1392) --- examples/rag/langgraph_agentic_rag.ipynb | 316 ++++++++++++++++++++++- 1 file changed, 306 insertions(+), 10 deletions(-) diff --git a/examples/rag/langgraph_agentic_rag.ipynb b/examples/rag/langgraph_agentic_rag.ipynb index 486c6caed..73d02f14a 100644 --- a/examples/rag/langgraph_agentic_rag.ipynb +++ b/examples/rag/langgraph_agentic_rag.ipynb @@ -20,7 +20,10 @@ "id": "969fb438", "metadata": {}, "outputs": [], - "source": ["%%capture --no-stderr\n%pip install -U --quiet langchain-community tiktoken langchain-openai langchainhub chromadb langchain langgraph langchain-text-splitters"] + "source": [ + "%%capture --no-stderr\n", + "%pip install -U --quiet langchain-community tiktoken langchain-openai langchainhub chromadb langchain langgraph langchain-text-splitters" + ] }, { "cell_type": "code", @@ -28,7 +31,22 @@ "id": "e4958a8c", "metadata": {}, "outputs": [], - "source": ["import getpass\nimport os\n\n\ndef _set_env(key: str):\n if key not in os.environ:\n os.environ[key] = getpass.getpass(f\"{key}:\")\n\n\n_set_env(\"OPENAI_API_KEY\")\n\n# (Optional) For tracing\nos.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n_set_env(\"LANGCHAIN_API_KEY\")"] + "source": [ + "import getpass\n", + "import os\n", + "\n", + "\n", + "def _set_env(key: str):\n", + " if key not in os.environ:\n", + " os.environ[key] = getpass.getpass(f\"{key}:\")\n", + "\n", + "\n", + "_set_env(\"OPENAI_API_KEY\")\n", + "\n", + "# (Optional) For tracing\n", + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "_set_env(\"LANGCHAIN_API_KEY\")" + ] }, { "cell_type": "markdown", @@ -46,7 +64,34 @@ "id": "e50c9efe-4abe-42fa-b35a-05eeeede9ec6", "metadata": {}, "outputs": [], - "source": ["from langchain_community.document_loaders import WebBaseLoader\nfrom langchain_community.vectorstores import Chroma\nfrom langchain_openai import OpenAIEmbeddings\nfrom langchain_text_splitters import RecursiveCharacterTextSplitter\n\nurls = [\n \"https://lilianweng.github.io/posts/2023-06-23-agent/\",\n \"https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/\",\n \"https://lilianweng.github.io/posts/2023-10-25-adv-attack-llm/\",\n]\n\ndocs = [WebBaseLoader(url).load() for url in urls]\ndocs_list = [item for sublist in docs for item in sublist]\n\ntext_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(\n chunk_size=100, chunk_overlap=50\n)\ndoc_splits = text_splitter.split_documents(docs_list)\n\n# Add to vectorDB\nvectorstore = Chroma.from_documents(\n documents=doc_splits,\n collection_name=\"rag-chroma\",\n embedding=OpenAIEmbeddings(),\n)\nretriever = vectorstore.as_retriever()"] + "source": [ + "from langchain_community.document_loaders import WebBaseLoader\n", + "from langchain_community.vectorstores import Chroma\n", + "from langchain_openai import OpenAIEmbeddings\n", + "from langchain_text_splitters import RecursiveCharacterTextSplitter\n", + "\n", + "urls = [\n", + " \"https://lilianweng.github.io/posts/2023-06-23-agent/\",\n", + " \"https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/\",\n", + " \"https://lilianweng.github.io/posts/2023-10-25-adv-attack-llm/\",\n", + "]\n", + "\n", + "docs = [WebBaseLoader(url).load() for url in urls]\n", + "docs_list = [item for sublist in docs for item in sublist]\n", + "\n", + "text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(\n", + " chunk_size=100, chunk_overlap=50\n", + ")\n", + "doc_splits = text_splitter.split_documents(docs_list)\n", + "\n", + "# Add to vectorDB\n", + "vectorstore = Chroma.from_documents(\n", + " documents=doc_splits,\n", + " collection_name=\"rag-chroma\",\n", + " embedding=OpenAIEmbeddings(),\n", + ")\n", + "retriever = vectorstore.as_retriever()" + ] }, { "cell_type": "markdown", @@ -62,7 +107,17 @@ "id": "0b97bdd8-d7e3-444d-ac96-5ef4725f9048", "metadata": {}, "outputs": [], - "source": ["from langchain.tools.retriever import create_retriever_tool\n\nretriever_tool = create_retriever_tool(\n retriever,\n \"retrieve_blog_posts\",\n \"Search and return information about Lilian Weng blog posts on LLM agents, prompt engineering, and adversarial attacks on LLMs.\",\n)\n\ntools = [retriever_tool]"] + "source": [ + "from langchain.tools.retriever import create_retriever_tool\n", + "\n", + "retriever_tool = create_retriever_tool(\n", + " retriever,\n", + " \"retrieve_blog_posts\",\n", + " \"Search and return information about Lilian Weng blog posts on LLM agents, prompt engineering, and adversarial attacks on LLMs.\",\n", + ")\n", + "\n", + "tools = [retriever_tool]" + ] }, { "cell_type": "markdown", @@ -86,7 +141,19 @@ "id": "0e378706-47d5-425a-8ba0-57b9acffbd0c", "metadata": {}, "outputs": [], - "source": ["from typing import Annotated, Sequence, TypedDict\n\nfrom langchain_core.messages import BaseMessage\n\nfrom langgraph.graph.message import add_messages\n\n\nclass AgentState(TypedDict):\n # The add_messages function defines how an update should be processed\n # Default is to replace. add_messages says \"append\"\n messages: Annotated[Sequence[BaseMessage], add_messages]"] + "source": [ + "from typing import Annotated, Sequence, TypedDict\n", + "\n", + "from langchain_core.messages import BaseMessage\n", + "\n", + "from langgraph.graph.message import add_messages\n", + "\n", + "\n", + "class AgentState(TypedDict):\n", + " # The add_messages function defines how an update should be processed\n", + " # Default is to replace. add_messages says \"append\"\n", + " messages: Annotated[Sequence[BaseMessage], add_messages]" + ] }, { "attachments": { @@ -129,7 +196,173 @@ ] } ], - "source": ["from typing import Annotated, Literal, Sequence, TypedDict\n\nfrom langchain import hub\nfrom langchain_core.messages import BaseMessage, HumanMessage\nfrom langchain_core.output_parsers import StrOutputParser\nfrom langchain_core.prompts import PromptTemplate\nfrom langchain_core.pydantic_v1 import BaseModel, Field\nfrom langchain_openai import ChatOpenAI\n\nfrom langgraph.prebuilt import tools_condition\n\n### Edges\n\n\ndef grade_documents(state) -> Literal[\"generate\", \"rewrite\"]:\n \"\"\"\n Determines whether the retrieved documents are relevant to the question.\n\n Args:\n state (messages): The current state\n\n Returns:\n str: A decision for whether the documents are relevant or not\n \"\"\"\n\n print(\"---CHECK RELEVANCE---\")\n\n # Data model\n class grade(BaseModel):\n \"\"\"Binary score for relevance check.\"\"\"\n\n binary_score: str = Field(description=\"Relevance score 'yes' or 'no'\")\n\n # LLM\n model = ChatOpenAI(temperature=0, model=\"gpt-4-0125-preview\", streaming=True)\n\n # LLM with tool and validation\n llm_with_tool = model.with_structured_output(grade)\n\n # Prompt\n prompt = PromptTemplate(\n template=\"\"\"You are a grader assessing relevance of a retrieved document to a user question. \\n \n Here is the retrieved document: \\n\\n {context} \\n\\n\n Here is the user question: {question} \\n\n If the document contains keyword(s) or semantic meaning related to the user question, grade it as relevant. \\n\n Give a binary score 'yes' or 'no' score to indicate whether the document is relevant to the question.\"\"\",\n input_variables=[\"context\", \"question\"],\n )\n\n # Chain\n chain = prompt | llm_with_tool\n\n messages = state[\"messages\"]\n last_message = messages[-1]\n\n question = messages[0].content\n docs = last_message.content\n\n scored_result = chain.invoke({\"question\": question, \"context\": docs})\n\n score = scored_result.binary_score\n\n if score == \"yes\":\n print(\"---DECISION: DOCS RELEVANT---\")\n return \"generate\"\n\n else:\n print(\"---DECISION: DOCS NOT RELEVANT---\")\n print(score)\n return \"rewrite\"\n\n\n### Nodes\n\n\ndef agent(state):\n \"\"\"\n Invokes the agent model to generate a response based on the current state. Given\n the question, it will decide to retrieve using the retriever tool, or simply end.\n\n Args:\n state (messages): The current state\n\n Returns:\n dict: The updated state with the agent response appended to messages\n \"\"\"\n print(\"---CALL AGENT---\")\n messages = state[\"messages\"]\n model = ChatOpenAI(temperature=0, streaming=True, model=\"gpt-4-turbo\")\n model = model.bind_tools(tools)\n response = model.invoke(messages)\n # We return a list, because this will get added to the existing list\n return {\"messages\": [response]}\n\n\ndef rewrite(state):\n \"\"\"\n Transform the query to produce a better question.\n\n Args:\n state (messages): The current state\n\n Returns:\n dict: The updated state with re-phrased question\n \"\"\"\n\n print(\"---TRANSFORM QUERY---\")\n messages = state[\"messages\"]\n question = messages[0].content\n\n msg = [\n HumanMessage(\n content=f\"\"\" \\n \n Look at the input and try to reason about the underlying semantic intent / meaning. \\n \n Here is the initial question:\n \\n ------- \\n\n {question} \n \\n ------- \\n\n Formulate an improved question: \"\"\",\n )\n ]\n\n # Grader\n model = ChatOpenAI(temperature=0, model=\"gpt-4-0125-preview\", streaming=True)\n response = model.invoke(msg)\n return {\"messages\": [response]}\n\n\ndef generate(state):\n \"\"\"\n Generate answer\n\n Args:\n state (messages): The current state\n\n Returns:\n dict: The updated state with re-phrased question\n \"\"\"\n print(\"---GENERATE---\")\n messages = state[\"messages\"]\n question = messages[0].content\n last_message = messages[-1]\n\n question = messages[0].content\n docs = last_message.content\n\n # Prompt\n prompt = hub.pull(\"rlm/rag-prompt\")\n\n # LLM\n llm = ChatOpenAI(model_name=\"gpt-3.5-turbo\", temperature=0, streaming=True)\n\n # Post-processing\n def format_docs(docs):\n return \"\\n\\n\".join(doc.page_content for doc in docs)\n\n # Chain\n rag_chain = prompt | llm | StrOutputParser()\n\n # Run\n response = rag_chain.invoke({\"context\": docs, \"question\": question})\n return {\"messages\": [response]}\n\n\nprint(\"*\" * 20 + \"Prompt[rlm/rag-prompt]\" + \"*\" * 20)\nprompt = hub.pull(\"rlm/rag-prompt\").pretty_print() # Show what the prompt looks like"] + "source": [ + "from typing import Annotated, Literal, Sequence, TypedDict\n", + "\n", + "from langchain import hub\n", + "from langchain_core.messages import BaseMessage, HumanMessage\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "from langchain_core.prompts import PromptTemplate\n", + "from langchain_core.pydantic_v1 import BaseModel, Field\n", + "from langchain_openai import ChatOpenAI\n", + "\n", + "from langgraph.prebuilt import tools_condition\n", + "\n", + "### Edges\n", + "\n", + "\n", + "def grade_documents(state) -> Literal[\"generate\", \"rewrite\"]:\n", + " \"\"\"\n", + " Determines whether the retrieved documents are relevant to the question.\n", + "\n", + " Args:\n", + " state (messages): The current state\n", + "\n", + " Returns:\n", + " str: A decision for whether the documents are relevant or not\n", + " \"\"\"\n", + "\n", + " print(\"---CHECK RELEVANCE---\")\n", + "\n", + " # Data model\n", + " class grade(BaseModel):\n", + " \"\"\"Binary score for relevance check.\"\"\"\n", + "\n", + " binary_score: str = Field(description=\"Relevance score 'yes' or 'no'\")\n", + "\n", + " # LLM\n", + " model = ChatOpenAI(temperature=0, model=\"gpt-4-0125-preview\", streaming=True)\n", + "\n", + " # LLM with tool and validation\n", + " llm_with_tool = model.with_structured_output(grade)\n", + "\n", + " # Prompt\n", + " prompt = PromptTemplate(\n", + " template=\"\"\"You are a grader assessing relevance of a retrieved document to a user question. \\n \n", + " Here is the retrieved document: \\n\\n {context} \\n\\n\n", + " Here is the user question: {question} \\n\n", + " If the document contains keyword(s) or semantic meaning related to the user question, grade it as relevant. \\n\n", + " Give a binary score 'yes' or 'no' score to indicate whether the document is relevant to the question.\"\"\",\n", + " input_variables=[\"context\", \"question\"],\n", + " )\n", + "\n", + " # Chain\n", + " chain = prompt | llm_with_tool\n", + "\n", + " messages = state[\"messages\"]\n", + " last_message = messages[-1]\n", + "\n", + " question = messages[0].content\n", + " docs = last_message.content\n", + "\n", + " scored_result = chain.invoke({\"question\": question, \"context\": docs})\n", + "\n", + " score = scored_result.binary_score\n", + "\n", + " if score == \"yes\":\n", + " print(\"---DECISION: DOCS RELEVANT---\")\n", + " return \"generate\"\n", + "\n", + " else:\n", + " print(\"---DECISION: DOCS NOT RELEVANT---\")\n", + " print(score)\n", + " return \"rewrite\"\n", + "\n", + "\n", + "### Nodes\n", + "\n", + "\n", + "def agent(state):\n", + " \"\"\"\n", + " Invokes the agent model to generate a response based on the current state. Given\n", + " the question, it will decide to retrieve using the retriever tool, or simply end.\n", + "\n", + " Args:\n", + " state (messages): The current state\n", + "\n", + " Returns:\n", + " dict: The updated state with the agent response appended to messages\n", + " \"\"\"\n", + " print(\"---CALL AGENT---\")\n", + " messages = state[\"messages\"]\n", + " model = ChatOpenAI(temperature=0, streaming=True, model=\"gpt-4-turbo\")\n", + " model = model.bind_tools(tools)\n", + " response = model.invoke(messages)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [response]}\n", + "\n", + "\n", + "def rewrite(state):\n", + " \"\"\"\n", + " Transform the query to produce a better question.\n", + "\n", + " Args:\n", + " state (messages): The current state\n", + "\n", + " Returns:\n", + " dict: The updated state with re-phrased question\n", + " \"\"\"\n", + "\n", + " print(\"---TRANSFORM QUERY---\")\n", + " messages = state[\"messages\"]\n", + " question = messages[0].content\n", + "\n", + " msg = [\n", + " HumanMessage(\n", + " content=f\"\"\" \\n \n", + " Look at the input and try to reason about the underlying semantic intent / meaning. \\n \n", + " Here is the initial question:\n", + " \\n ------- \\n\n", + " {question} \n", + " \\n ------- \\n\n", + " Formulate an improved question: \"\"\",\n", + " )\n", + " ]\n", + "\n", + " # Grader\n", + " model = ChatOpenAI(temperature=0, model=\"gpt-4-0125-preview\", streaming=True)\n", + " response = model.invoke(msg)\n", + " return {\"messages\": [response]}\n", + "\n", + "\n", + "def generate(state):\n", + " \"\"\"\n", + " Generate answer\n", + "\n", + " Args:\n", + " state (messages): The current state\n", + "\n", + " Returns:\n", + " dict: The updated state with re-phrased question\n", + " \"\"\"\n", + " print(\"---GENERATE---\")\n", + " messages = state[\"messages\"]\n", + " question = messages[0].content\n", + " last_message = messages[-1]\n", + "\n", + " docs = last_message.content\n", + "\n", + " # Prompt\n", + " prompt = hub.pull(\"rlm/rag-prompt\")\n", + "\n", + " # LLM\n", + " llm = ChatOpenAI(model_name=\"gpt-3.5-turbo\", temperature=0, streaming=True)\n", + "\n", + " # Post-processing\n", + " def format_docs(docs):\n", + " return \"\\n\\n\".join(doc.page_content for doc in docs)\n", + "\n", + " # Chain\n", + " rag_chain = prompt | llm | StrOutputParser()\n", + "\n", + " # Run\n", + " response = rag_chain.invoke({\"context\": docs, \"question\": question})\n", + " return {\"messages\": [response]}\n", + "\n", + "\n", + "print(\"*\" * 20 + \"Prompt[rlm/rag-prompt]\" + \"*\" * 20)\n", + "prompt = hub.pull(\"rlm/rag-prompt\").pretty_print() # Show what the prompt looks like" + ] }, { "cell_type": "markdown", @@ -150,7 +383,48 @@ "id": "8718a37f-83c2-4f16-9850-e61e0f49c3d4", "metadata": {}, "outputs": [], - "source": ["from langgraph.graph import END, StateGraph, START\nfrom langgraph.prebuilt import ToolNode\n\n# Define a new graph\nworkflow = StateGraph(AgentState)\n\n# Define the nodes we will cycle between\nworkflow.add_node(\"agent\", agent) # agent\nretrieve = ToolNode([retriever_tool])\nworkflow.add_node(\"retrieve\", retrieve) # retrieval\nworkflow.add_node(\"rewrite\", rewrite) # Re-writing the question\nworkflow.add_node(\n \"generate\", generate\n) # Generating a response after we know the documents are relevant\n# Call agent node to decide to retrieve or not\nworkflow.add_edge(START, \"agent\")\n\n# Decide whether to retrieve\nworkflow.add_conditional_edges(\n \"agent\",\n # Assess agent decision\n tools_condition,\n {\n # Translate the condition outputs to nodes in our graph\n \"tools\": \"retrieve\",\n END: END,\n },\n)\n\n# Edges taken after the `action` node is called.\nworkflow.add_conditional_edges(\n \"retrieve\",\n # Assess agent decision\n grade_documents,\n)\nworkflow.add_edge(\"generate\", END)\nworkflow.add_edge(\"rewrite\", \"agent\")\n\n# Compile\ngraph = workflow.compile()"] + "source": [ + "from langgraph.graph import END, StateGraph, START\n", + "from langgraph.prebuilt import ToolNode\n", + "\n", + "# Define a new graph\n", + "workflow = StateGraph(AgentState)\n", + "\n", + "# Define the nodes we will cycle between\n", + "workflow.add_node(\"agent\", agent) # agent\n", + "retrieve = ToolNode([retriever_tool])\n", + "workflow.add_node(\"retrieve\", retrieve) # retrieval\n", + "workflow.add_node(\"rewrite\", rewrite) # Re-writing the question\n", + "workflow.add_node(\n", + " \"generate\", generate\n", + ") # Generating a response after we know the documents are relevant\n", + "# Call agent node to decide to retrieve or not\n", + "workflow.add_edge(START, \"agent\")\n", + "\n", + "# Decide whether to retrieve\n", + "workflow.add_conditional_edges(\n", + " \"agent\",\n", + " # Assess agent decision\n", + " tools_condition,\n", + " {\n", + " # Translate the condition outputs to nodes in our graph\n", + " \"tools\": \"retrieve\",\n", + " END: END,\n", + " },\n", + ")\n", + "\n", + "# Edges taken after the `action` node is called.\n", + "workflow.add_conditional_edges(\n", + " \"retrieve\",\n", + " # Assess agent decision\n", + " grade_documents,\n", + ")\n", + "workflow.add_edge(\"generate\", END)\n", + "workflow.add_edge(\"rewrite\", \"agent\")\n", + "\n", + "# Compile\n", + "graph = workflow.compile()" + ] }, { "cell_type": "code", @@ -169,7 +443,15 @@ "output_type": "display_data" } ], - "source": ["from IPython.display import Image, display\n\ntry:\n display(Image(graph.get_graph(xray=True).draw_mermaid_png()))\nexcept Exception:\n # This requires some extra dependencies and is optional\n pass"] + "source": [ + "from IPython.display import Image, display\n", + "\n", + "try:\n", + " display(Image(graph.get_graph(xray=True).draw_mermaid_png()))\n", + "except Exception:\n", + " # This requires some extra dependencies and is optional\n", + " pass" + ] }, { "cell_type": "code", @@ -203,7 +485,21 @@ ] } ], - "source": ["import pprint\n\ninputs = {\n \"messages\": [\n (\"user\", \"What does Lilian Weng say about the types of agent memory?\"),\n ]\n}\nfor output in graph.stream(inputs):\n for key, value in output.items():\n pprint.pprint(f\"Output from node '{key}':\")\n pprint.pprint(\"---\")\n pprint.pprint(value, indent=2, width=80, depth=None)\n pprint.pprint(\"\\n---\\n\")"] + "source": [ + "import pprint\n", + "\n", + "inputs = {\n", + " \"messages\": [\n", + " (\"user\", \"What does Lilian Weng say about the types of agent memory?\"),\n", + " ]\n", + "}\n", + "for output in graph.stream(inputs):\n", + " for key, value in output.items():\n", + " pprint.pprint(f\"Output from node '{key}':\")\n", + " pprint.pprint(\"---\")\n", + " pprint.pprint(value, indent=2, width=80, depth=None)\n", + " pprint.pprint(\"\\n---\\n\")" + ] }, { "cell_type": "code", @@ -211,7 +507,7 @@ "id": "189333cc-5d34-4869-9f9b-741210e1096f", "metadata": {}, "outputs": [], - "source": [""] + "source": [] } ], "metadata": { From 22f5367af73fa5aa79ba0c378564d1d23bb08d9e Mon Sep 17 00:00:00 2001 From: gbaian10 <34255899+gbaian10@users.noreply.github.com> Date: Fri, 23 Aug 2024 00:32:21 +0800 Subject: [PATCH 08/11] langgraph: fix add_node input schema error (#1332) --- libs/langgraph/langgraph/graph/state.py | 12 ++++--- libs/langgraph/tests/test_state.py | 42 ++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/libs/langgraph/langgraph/graph/state.py b/libs/langgraph/langgraph/graph/state.py index d846bc8d3..3ff07b15c 100644 --- a/libs/langgraph/langgraph/graph/state.py +++ b/libs/langgraph/langgraph/graph/state.py @@ -1,3 +1,4 @@ +import inspect import logging import typing import warnings @@ -330,10 +331,13 @@ class StateGraph(Graph): hints := get_type_hints(action.__call__) or get_type_hints(action) ): if input is None: - input_hint = hints[list(hints.keys())[0]] - if isinstance(input_hint, type) and get_type_hints(input_hint): - input = input_hint - except TypeError: + first_parameter_name = next( + iter(inspect.signature(action).parameters.keys()) + ) + if input_hint := hints.get(first_parameter_name): + if isinstance(input_hint, type) and get_type_hints(input_hint): + input = input_hint + except (TypeError, StopIteration): pass if input is not None: self._add_schema(input) diff --git a/libs/langgraph/tests/test_state.py b/libs/langgraph/tests/test_state.py index ecf35eb2a..4088e38c9 100644 --- a/libs/langgraph/tests/test_state.py +++ b/libs/langgraph/tests/test_state.py @@ -2,10 +2,11 @@ from typing import Annotated as Annotated2 from typing import Any import pytest +from langchain_core.runnables import RunnableConfig from pydantic.v1 import BaseModel from typing_extensions import Annotated, TypedDict -from langgraph.graph.state import _warn_invalid_state_schema +from langgraph.graph.state import StateGraph, _warn_invalid_state_schema class State(BaseModel): @@ -46,3 +47,42 @@ def test_doesnt_warn_valid_schema(schema: Any): # Assert the function does not raise a warning with pytest.warns(None): _warn_invalid_state_schema(schema) + + +def test_state_schema_with_type_hint(): + class InputState(TypedDict): + question: str + + class OutputState(TypedDict): + input_state: InputState + + def complete_hint(state: InputState) -> OutputState: + return {"input_state": state} + + def miss_first_hint(state, config: RunnableConfig) -> OutputState: + return {"input_state": state} + + def only_return_hint(state, config) -> OutputState: + return {"input_state": state} + + def miss_all_hint(state, config): + return {"input_state": state} + + graph = StateGraph(input=InputState, output=OutputState) + actions = [complete_hint, miss_first_hint, only_return_hint, miss_all_hint] + + for action in actions: + graph.add_node(action) + + graph.set_entry_point(actions[0].__name__) + for i in range(len(actions) - 1): + graph.add_edge(actions[i].__name__, actions[i + 1].__name__) + graph.set_finish_point(actions[-1].__name__) + + graph = graph.compile() + + input_state = InputState(question="Hello World!") + output_state = OutputState(input_state=input_state) + for i, c in enumerate(graph.stream(input_state, stream_mode="updates")): + node_name = actions[i].__name__ + assert c[node_name] == output_state From 078f9f7275be0951447beb83a6213f6c8cbbaf6c Mon Sep 17 00:00:00 2001 From: Vadym Barda Date: Thu, 22 Aug 2024 14:56:34 -0400 Subject: [PATCH 09/11] docs: fix manage conversation history how-to (#1438) --- examples/memory/manage-conversation-history.ipynb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/memory/manage-conversation-history.ipynb b/examples/memory/manage-conversation-history.ipynb index 066d38ad6..559112a4a 100644 --- a/examples/memory/manage-conversation-history.ipynb +++ b/examples/memory/manage-conversation-history.ipynb @@ -268,8 +268,8 @@ "\n", "\n", "def filter_messages(messages: list):\n", - " # This is very simple helper function which only ever uses the last two messages\n", - " return messages[-2:]\n", + " # This is very simple helper function which only ever uses the last message\n", + " return messages[-1:]\n", "\n", "\n", "# Define the function that calls the model\n", @@ -372,9 +372,9 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "langgraph-example-dev", "language": "python", - "name": "python3" + "name": "langgraph-example-dev" }, "language_info": { "codemirror_mode": { @@ -386,7 +386,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.1" + "version": "3.11.9" } }, "nbformat": 4, From 38daba5259de8d80700a8d669b1c73a0ef783547 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Thu, 22 Aug 2024 11:59:29 -0700 Subject: [PATCH 10/11] Better error messages for invalid update in all channel types (#1437) * langgraph: support multiple edges for Topic channel annotations * remove support_multiple_edges * Better error messages for invalid update in all channel types --------- Co-authored-by: vbarda --- libs/langgraph/langgraph/channels/base.py | 26 +++++++++++++--- libs/langgraph/langgraph/channels/context.py | 4 ++- .../channels/dynamic_barrier_value.py | 2 +- .../langgraph/channels/ephemeral_value.py | 2 +- .../langgraph/channels/last_value.py | 4 ++- .../langgraph/channels/named_barrier_value.py | 4 ++- .../langgraph/channels/untracked_value.py | 2 +- libs/langgraph/langgraph/graph/graph.py | 6 ++-- libs/langgraph/langgraph/graph/state.py | 11 +++---- libs/langgraph/langgraph/pregel/algo.py | 7 +---- libs/langgraph/langgraph/pregel/manager.py | 6 ++-- libs/langgraph/tests/test_pregel.py | 30 +++++++++++++++++++ 12 files changed, 79 insertions(+), 25 deletions(-) diff --git a/libs/langgraph/langgraph/channels/base.py b/libs/langgraph/langgraph/channels/base.py index fe47f0d8f..885698743 100644 --- a/libs/langgraph/langgraph/channels/base.py +++ b/libs/langgraph/langgraph/channels/base.py @@ -2,9 +2,9 @@ from abc import ABC, abstractmethod from contextlib import asynccontextmanager, contextmanager from typing import ( Any, - AsyncGenerator, - Generator, + AsyncIterator, Generic, + Iterator, Optional, Sequence, TypeVar, @@ -21,6 +21,8 @@ C = TypeVar("C") class BaseChannel(Generic[Value, Update, C], ABC): + key: str = "" + @property @abstractmethod def ValueType(self) -> Any: @@ -43,19 +45,35 @@ class BaseChannel(Generic[Value, Update, C], ABC): @abstractmethod def from_checkpoint( self, checkpoint: Optional[C], config: RunnableConfig - ) -> Generator[Self, None, None]: + ) -> Iterator[Self]: """Return a new identical channel, optionally initialized from a checkpoint. If the checkpoint contains complex data structures, they should be copied.""" + @contextmanager + def from_checkpoint_named( + self, checkpoint: Optional[C], config: RunnableConfig + ) -> Iterator[Self]: + with self.from_checkpoint(checkpoint, config) as value: + value.key = self.key + yield value + @asynccontextmanager async def afrom_checkpoint( self, checkpoint: Optional[C], config: RunnableConfig - ) -> AsyncGenerator[Self, None]: + ) -> AsyncIterator[Self]: """Return a new identical channel, optionally initialized from a checkpoint. If the checkpoint contains complex data structures, they should be copied.""" with self.from_checkpoint(checkpoint, config) as value: yield value + @asynccontextmanager + async def afrom_checkpoint_named( + self, checkpoint: Optional[C], config: RunnableConfig + ) -> AsyncIterator[Self]: + async with self.afrom_checkpoint(checkpoint, config) as value: + value.key = self.key + yield value + # state methods @abstractmethod diff --git a/libs/langgraph/langgraph/channels/context.py b/libs/langgraph/langgraph/channels/context.py index 914de9348..b48260b40 100644 --- a/libs/langgraph/langgraph/channels/context.py +++ b/libs/langgraph/langgraph/channels/context.py @@ -112,7 +112,9 @@ class Context(Generic[Value], BaseChannel[Value, None, None]): def update(self, values: Sequence[None]) -> bool: if values: - raise InvalidUpdateError("Context channel does not accept writes.") + raise InvalidUpdateError( + f"At key '{self.key}': Context channel does not accept writes." + ) return False def get(self) -> Value: diff --git a/libs/langgraph/langgraph/channels/dynamic_barrier_value.py b/libs/langgraph/langgraph/channels/dynamic_barrier_value.py index bb0d447fa..64406b8f8 100644 --- a/libs/langgraph/langgraph/channels/dynamic_barrier_value.py +++ b/libs/langgraph/langgraph/channels/dynamic_barrier_value.py @@ -69,7 +69,7 @@ class DynamicBarrierValue( if wait_for_names := [v for v in values if isinstance(v, WaitForNames)]: if len(wait_for_names) > 1: raise InvalidUpdateError( - "Received multiple WaitForNames updates in the same step." + f"At key '{self.key}': Received multiple WaitForNames updates in the same step." ) self.names = wait_for_names[0].names return True diff --git a/libs/langgraph/langgraph/channels/ephemeral_value.py b/libs/langgraph/langgraph/channels/ephemeral_value.py index 15e11550d..4e7f2ed63 100644 --- a/libs/langgraph/langgraph/channels/ephemeral_value.py +++ b/libs/langgraph/langgraph/channels/ephemeral_value.py @@ -58,7 +58,7 @@ class EphemeralValue(Generic[Value], BaseChannel[Value, Value, Value]): return False if len(values) != 1 and self.guard: raise InvalidUpdateError( - "EphemeralValue can only receive one value per step." + f"At key '{self.key}': EphemeralValue(guard=True) can receive only one value per step. Use guard=False if you want to store any one of multiple values." ) self.value = values[-1] diff --git a/libs/langgraph/langgraph/channels/last_value.py b/libs/langgraph/langgraph/channels/last_value.py index a207ebce3..e74580d6a 100644 --- a/libs/langgraph/langgraph/channels/last_value.py +++ b/libs/langgraph/langgraph/channels/last_value.py @@ -52,7 +52,9 @@ class LastValue(Generic[Value], BaseChannel[Value, Value, Value]): if len(values) == 0: return False if len(values) != 1: - raise InvalidUpdateError("LastValue can only receive one value per step.") + raise InvalidUpdateError( + f"At key '{self.key}': Can receive only one value per step. Use an Annotated key to handle multiple values." + ) self.value = values[-1] return True diff --git a/libs/langgraph/langgraph/channels/named_barrier_value.py b/libs/langgraph/langgraph/channels/named_barrier_value.py index bdfd4660b..023f54e6c 100644 --- a/libs/langgraph/langgraph/channels/named_barrier_value.py +++ b/libs/langgraph/langgraph/channels/named_barrier_value.py @@ -53,7 +53,9 @@ class NamedBarrierValue(Generic[Value], BaseChannel[Value, Value, set[Value]]): self.seen.add(value) updated = True else: - raise InvalidUpdateError(f"Value {value} not in {self.names}") + raise InvalidUpdateError( + f"At key '{self.key}': Value {value} not in {self.names}" + ) return updated def get(self) -> Value: diff --git a/libs/langgraph/langgraph/channels/untracked_value.py b/libs/langgraph/langgraph/channels/untracked_value.py index 989bba35e..a112b0e81 100644 --- a/libs/langgraph/langgraph/channels/untracked_value.py +++ b/libs/langgraph/langgraph/channels/untracked_value.py @@ -49,7 +49,7 @@ class UntrackedValue(Generic[Value], BaseChannel[Value, Value, Value]): return False if len(values) != 1 and self.guard: raise InvalidUpdateError( - "UntrackedValue can only receive one value per step." + f"At key '{self.key}': UntrackedValue(guard=True) can receive only one value per step. Use guard=False if you want to store any one of multiple values." ) self.value = values[-1] diff --git a/libs/langgraph/langgraph/graph/graph.py b/libs/langgraph/langgraph/graph/graph.py index dc927446d..d5eeb88e2 100644 --- a/libs/langgraph/langgraph/graph/graph.py +++ b/libs/langgraph/langgraph/graph/graph.py @@ -192,12 +192,14 @@ class Graph: raise ValueError("END cannot be a start node") if end_key == START: raise ValueError("START cannot be an end node") - if not self.support_multiple_edges and start_key in set( + + # run this validation only for non-StateGraph graphs + if not hasattr(self, "channels") and start_key in set( start for start, _ in self.edges ): raise ValueError( f"Already found path for node '{start_key}'.\n" - "For multiple edges, use StateGraph with an annotated state key." + "For multiple edges, use StateGraph with an Annotated state key." ) self.edges.add((start_key, end_key)) diff --git a/libs/langgraph/langgraph/graph/state.py b/libs/langgraph/langgraph/graph/state.py index 3ff07b15c..7228c5fa2 100644 --- a/libs/langgraph/langgraph/graph/state.py +++ b/libs/langgraph/langgraph/graph/state.py @@ -193,10 +193,6 @@ class StateGraph(Graph): ) else: self.managed[key] = managed - if any( - isinstance(c, BinaryOperatorAggregate) for c in self.channels.values() - ): - self.support_multiple_edges = True @overload def add_node( @@ -723,10 +719,15 @@ def _get_channel( else: raise ValueError(f"This {annotation} not allowed in this position") elif channel := _is_field_channel(annotation): + channel.key = name return channel elif channel := _is_field_binop(annotation): + channel.key = name return channel - return LastValue(annotation) + + fallback = LastValue(annotation) + fallback.key = name + return fallback def _is_field_channel(typ: Type[Any]) -> Optional[BaseChannel]: diff --git a/libs/langgraph/langgraph/pregel/algo.py b/libs/langgraph/langgraph/pregel/algo.py index 28a8dcd5d..1d3f57040 100644 --- a/libs/langgraph/langgraph/pregel/algo.py +++ b/libs/langgraph/langgraph/pregel/algo.py @@ -198,12 +198,7 @@ def apply_writes( updated_channels: set[str] = set() for chan, vals in pending_writes_by_channel.items(): if chan in channels: - try: - updated = channels[chan].update(vals) - except InvalidUpdateError as e: - raise InvalidUpdateError( - f"Invalid update for channel {chan} with values {vals}" - ) from e + updated = channels[chan].update(vals) if updated and get_next_version is not None: checkpoint["channel_versions"][chan] = get_next_version( max_version, channels[chan] diff --git a/libs/langgraph/langgraph/pregel/manager.py b/libs/langgraph/langgraph/pregel/manager.py index 437019113..849395c50 100644 --- a/libs/langgraph/langgraph/pregel/manager.py +++ b/libs/langgraph/langgraph/pregel/manager.py @@ -41,7 +41,7 @@ def ChannelsManager( yield ( { k: stack.enter_context( - v.from_checkpoint(checkpoint["channel_values"].get(k), config) + v.from_checkpoint_named(checkpoint["channel_values"].get(k), config) ) for k, v in channel_specs.items() }, @@ -95,7 +95,9 @@ async def AsyncChannelsManager( # channels: enter each channel with checkpoint { k: await stack.enter_async_context( - v.afrom_checkpoint(checkpoint["channel_values"].get(k), config) + v.afrom_checkpoint_named( + checkpoint["channel_values"].get(k), config + ) ) for k, v in channel_specs.items() }, diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index c1fc68c2a..3e40f31fc 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -198,6 +198,21 @@ def test_graph_validation() -> None: with pytest.raises(ValueError, match="Invalid reducer"): StateGraph(BadReducerState) + def node_b(state: State) -> State: + return {"hello": "world"} + + builder = StateGraph(State) + builder.add_node("a", node_b) + builder.add_node("b", node_b) + builder.add_node("c", node_b) + builder.set_entry_point("a") + builder.add_edge("a", "b") + builder.add_edge("a", "c") + graph = builder.compile() + + with pytest.raises(InvalidUpdateError, match="At key 'hello'"): + graph.invoke({"hello": "there"}) + def test_checkpoint_errors() -> None: class FaultyGetCheckpointer(MemorySaver): @@ -1197,6 +1212,21 @@ def test_invoke_two_processes_two_in_two_out_invalid(mocker: MockerFixture) -> N # LastValue channels can only be updated once per iteration app.invoke(2) + class State(TypedDict): + hello: str + + def my_node(input: State) -> State: + return {"hello": "world"} + + builder = StateGraph(State) + builder.add_node("one", my_node) + builder.add_node("two", my_node) + builder.set_conditional_entry_point(lambda _: ["one", "two"]) + + graph = builder.compile() + with pytest.raises(InvalidUpdateError, match="At key 'hello'"): + graph.invoke({"hello": "there"}, debug=True) + def test_invoke_two_processes_two_in_two_out_valid(mocker: MockerFixture) -> None: add_one = mocker.Mock(side_effect=lambda x: x + 1) From a261e1a49761d976d783bcc0d96b3f9b6e105e52 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Thu, 22 Aug 2024 12:04:42 -0700 Subject: [PATCH 11/11] Fix semantics of put_writes/list (#1436) * Fix semantics of put_writes/list - put_writes(error) should not prevent saving future successful if task is retried successfully - put_writes(writes) should be a no-op if non-error writes already exist for that task (this prevents tasks executed more than once from modifying writes previously saved / acted on) - checkpoints should not include channel default values (ie those without a version) - list() should fetch and return writes for each checkpoint * Lint * Rm print * Fix import * Lint --- .../langgraph/checkpoint/postgres/__init__.py | 11 +- .../langgraph/checkpoint/postgres/aio.py | 11 +- .../langgraph/checkpoint/postgres/base.py | 13 +- .../langgraph/checkpoint/sqlite/__init__.py | 27 +- .../langgraph/checkpoint/sqlite/aio.py | 29 +- .../langgraph/checkpoint/base/__init__.py | 14 + .../langgraph/checkpoint/memory/__init__.py | 24 +- .../langgraph/checkpoint/serde/types.py | 2 + libs/langgraph/langgraph/errors.py | 4 +- libs/langgraph/langgraph/pregel/__init__.py | 66 +--- libs/langgraph/langgraph/pregel/loop.py | 17 +- libs/langgraph/tests/any_str.py | 35 ++ libs/langgraph/tests/test_pregel.py | 237 +++++++++---- libs/langgraph/tests/test_pregel_async.py | 333 +++++++++++------- 14 files changed, 480 insertions(+), 343 deletions(-) diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py index 02d5880a0..433bc231d 100644 --- a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py @@ -150,6 +150,7 @@ class PostgresSaver(BasePostgresSaver): } if value["parent_checkpoint_id"] else None, + self._load_writes(value["pending_writes"]), ) def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]: @@ -317,16 +318,6 @@ class PostgresSaver(BasePostgresSaver): task_id (str): Identifier for the task creating the writes. """ with self._cursor(pipeline=True) as cur: - cur.execute( - self.DELETE_WRITES_SQL, - ( - config["configurable"]["thread_id"], - config["configurable"]["checkpoint_ns"], - config["configurable"]["checkpoint_id"], - task_id, - len(writes), - ), - ) cur.executemany( self.UPSERT_CHECKPOINT_WRITES_SQL, self._dump_writes( diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py index 7ddb81237..79ae1ddf7 100644 --- a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py @@ -135,6 +135,7 @@ class AsyncPostgresSaver(BasePostgresSaver): } if value["parent_checkpoint_id"] else None, + await asyncio.to_thread(self._load_writes, value["pending_writes"]), ) async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]: @@ -273,16 +274,6 @@ class AsyncPostgresSaver(BasePostgresSaver): task_id (str): Identifier for the task creating the writes. """ async with self._cursor(pipeline=True) as cur: - await cur.execute( - self.DELETE_WRITES_SQL, - ( - config["configurable"]["thread_id"], - config["configurable"]["checkpoint_ns"], - config["configurable"]["checkpoint_id"], - task_id, - len(writes), - ), - ) await cur.executemany( self.UPSERT_CHECKPOINT_WRITES_SQL, await asyncio.to_thread( diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py index 506a13a27..f53bee256 100644 --- a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py @@ -6,6 +6,7 @@ from langchain_core.runnables import RunnableConfig from psycopg.types.json import Jsonb from langgraph.checkpoint.base import ( + WRITES_IDX_MAP, BaseCheckpointSaver, Checkpoint, EmptyChannelError, @@ -105,15 +106,6 @@ UPSERT_CHECKPOINT_WRITES_SQL = """ ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) DO NOTHING """ -DELETE_WRITES_SQL = """ - DELETE FROM checkpoint_writes - WHERE thread_id = %s - AND checkpoint_ns = %s - AND checkpoint_id = %s - AND task_id = %s - AND idx >= %s -""" - class BasePostgresSaver(BaseCheckpointSaver): SELECT_SQL = SELECT_SQL @@ -121,7 +113,6 @@ class BasePostgresSaver(BaseCheckpointSaver): UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL - DELETE_WRITES_SQL = DELETE_WRITES_SQL jsonplus_serde = JsonPlusSerializer() @@ -210,7 +201,7 @@ class BasePostgresSaver(BaseCheckpointSaver): checkpoint_ns, checkpoint_id, task_id, - idx, + WRITES_IDX_MAP.get(channel, idx), channel, *self.serde.dumps_typed(value), ) diff --git a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py index 9bf4139ad..4767bfb70 100644 --- a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py +++ b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py @@ -1,12 +1,13 @@ import sqlite3 import threading -from contextlib import contextmanager +from contextlib import closing, contextmanager from hashlib import md5 from typing import Any, AsyncIterator, Dict, Iterator, Optional, Sequence, Tuple from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.base import ( + WRITES_IDX_MAP, BaseCheckpointSaver, ChannelVersions, Checkpoint, @@ -318,7 +319,7 @@ class SqliteSaver(BaseCheckpointSaver): ORDER BY checkpoint_id DESC""" if limit: query += f" LIMIT {limit}" - with self.cursor(transaction=False) as cur: + with self.cursor(transaction=False) as cur, closing(self.conn.cursor()) as wcur: cur.execute(query, param_values) for ( thread_id, @@ -329,6 +330,10 @@ class SqliteSaver(BaseCheckpointSaver): checkpoint, metadata, ) in cur: + wcur.execute( + "SELECT task_id, channel, type, value FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?", + (thread_id, checkpoint_ns, checkpoint_id), + ) yield CheckpointTuple( { "configurable": { @@ -350,6 +355,10 @@ class SqliteSaver(BaseCheckpointSaver): if parent_checkpoint_id else None ), + [ + (task_id, channel, self.serde.loads_typed((type, value))) + for task_id, channel, type, value in wcur + ], ) def put( @@ -424,25 +433,15 @@ class SqliteSaver(BaseCheckpointSaver): task_id (str): Identifier for the task creating the writes. """ with self.lock, self.cursor() as cur: - cur.execute( - "DELETE FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? AND task_id = ? AND idx >= ?", - ( - str(config["configurable"]["thread_id"]), - str(config["configurable"]["checkpoint_ns"]), - str(config["configurable"]["checkpoint_id"]), - task_id, - len(writes), - ), - ) cur.executemany( - "INSERT OR REPLACE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT OR IGNORE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [ ( str(config["configurable"]["thread_id"]), str(config["configurable"]["checkpoint_ns"]), str(config["configurable"]["checkpoint_id"]), task_id, - idx, + WRITES_IDX_MAP.get(channel, idx), channel, *self.serde.dumps_typed(value), ) diff --git a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py index 56d14f613..85869d3f0 100644 --- a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py +++ b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py @@ -16,6 +16,7 @@ import aiosqlite from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.base import ( + WRITES_IDX_MAP, BaseCheckpointSaver, ChannelVersions, Checkpoint, @@ -329,14 +330,14 @@ class AsyncSqliteSaver(BaseCheckpointSaver): AsyncIterator[CheckpointTuple]: An asynchronous iterator of matching checkpoint tuples. """ await self.setup() - where, param_values = search_where(config, filter, before) + where, params = search_where(config, filter, before) query = f"""SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata FROM checkpoints {where} ORDER BY checkpoint_id DESC""" if limit: query += f" LIMIT {limit}" - async with self.conn.execute(query, param_values) as cursor: + async with self.conn.execute(query, params) as cur, self.conn.cursor() as wcur: async for ( thread_id, checkpoint_ns, @@ -345,7 +346,11 @@ class AsyncSqliteSaver(BaseCheckpointSaver): type, checkpoint, metadata, - ) in cursor: + ) in cur: + await wcur.execute( + "SELECT task_id, channel, type, value FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?", + (thread_id, checkpoint_ns, checkpoint_id), + ) yield CheckpointTuple( { "configurable": { @@ -367,6 +372,10 @@ class AsyncSqliteSaver(BaseCheckpointSaver): if parent_checkpoint_id else None ), + [ + (task_id, channel, self.serde.loads_typed((type, value))) + async for task_id, channel, type, value in wcur + ], ) async def aput( @@ -433,25 +442,15 @@ class AsyncSqliteSaver(BaseCheckpointSaver): """ await self.setup() async with self.conn.cursor() as cur: - await cur.execute( - "DELETE FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? AND task_id = ? AND idx >= ?", - ( - str(config["configurable"]["thread_id"]), - str(config["configurable"]["checkpoint_ns"]), - str(config["configurable"]["checkpoint_id"]), - task_id, - len(writes), - ), - ) await cur.executemany( - "INSERT OR REPLACE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT OR IGNORE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [ ( str(config["configurable"]["thread_id"]), str(config["configurable"]["checkpoint_ns"]), str(config["configurable"]["checkpoint_id"]), task_id, - idx, + WRITES_IDX_MAP.get(channel, idx), channel, *self.serde.dumps_typed(value), ) diff --git a/libs/checkpoint/langgraph/checkpoint/base/__init__.py b/libs/checkpoint/langgraph/checkpoint/base/__init__.py index 86c8b0eec..21d021ad5 100644 --- a/libs/checkpoint/langgraph/checkpoint/base/__init__.py +++ b/libs/checkpoint/langgraph/checkpoint/base/__init__.py @@ -22,6 +22,7 @@ from langgraph.checkpoint.base.id import uuid6 from langgraph.checkpoint.serde.base import SerializerProtocol, maybe_add_typed_methods from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer from langgraph.checkpoint.serde.types import ( + ERROR, ChannelProtocol, SendProtocol, ) @@ -98,6 +99,7 @@ class Checkpoint(TypedDict): Cleared by the next checkpoint.""" current_tasks: Dict[str, TaskInfo] """Map from task ID to task info.""" + # TODO remove this def empty_checkpoint() -> Checkpoint: @@ -140,6 +142,8 @@ def create_checkpoint( else: values: dict[str, Any] = {} for k, v in channels.items(): + if k not in checkpoint["channel_versions"]: + continue try: values[k] = v.checkpoint() except EmptyChannelError: @@ -437,3 +441,13 @@ def get_checkpoint_id(config: RunnableConfig) -> Optional[str]: return config["configurable"].get( "checkpoint_id", config["configurable"].get("thread_ts") ) + + +""" +Mapping from error type to error index. +Regular writes just map to their index in the list of writes being saved. +Special writes (e.g. errors) map to negative indices, to avoid those writes from +saving regular writes. +Each Checkpointer implementation should use this mapping in put_writes. +""" +WRITES_IDX_MAP = {ERROR: -1} diff --git a/libs/checkpoint/langgraph/checkpoint/memory/__init__.py b/libs/checkpoint/langgraph/checkpoint/memory/__init__.py index 3e8d328e6..52102ffef 100644 --- a/libs/checkpoint/langgraph/checkpoint/memory/__init__.py +++ b/libs/checkpoint/langgraph/checkpoint/memory/__init__.py @@ -8,6 +8,7 @@ from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Tuple from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.base import ( + WRITES_IDX_MAP, BaseCheckpointSaver, ChannelVersions, Checkpoint, @@ -52,6 +53,9 @@ class MemorySaver( # thread ID -> checkpoint NS -> checkpoint ID -> checkpoint mapping storage: defaultdict[str, dict[str, dict[str, tuple[bytes, bytes, Optional[str]]]]] + writes: defaultdict[ + tuple[str, str, str], dict[tuple[str, int], tuple[str, str, bytes]] + ] def __init__( self, @@ -60,7 +64,7 @@ class MemorySaver( ) -> None: super().__init__(serde=serde) self.storage = defaultdict(lambda: defaultdict(dict)) - self.writes = defaultdict(list) + self.writes = defaultdict(dict) def __enter__(self) -> "MemorySaver": return self @@ -103,7 +107,7 @@ class MemorySaver( if checkpoint_id := get_checkpoint_id(config): if saved := self.storage[thread_id][checkpoint_ns].get(checkpoint_id): checkpoint, metadata, parent_checkpoint_id = saved - writes = self.writes[(thread_id, checkpoint_ns, checkpoint_id)] + writes = self.writes[(thread_id, checkpoint_ns, checkpoint_id)].values() return CheckpointTuple( config=config, checkpoint=self.serde.loads_typed(checkpoint), @@ -125,7 +129,7 @@ class MemorySaver( if checkpoints := self.storage[thread_id][checkpoint_ns]: checkpoint_id = max(checkpoints.keys()) checkpoint, metadata, parent_checkpoint_id = checkpoints[checkpoint_id] - writes = self.writes[(thread_id, checkpoint_ns, checkpoint_id)] + writes = self.writes[(thread_id, checkpoint_ns, checkpoint_id)].values() return CheckpointTuple( config={ "configurable": { @@ -204,6 +208,8 @@ class MemorySaver( elif limit is not None: limit -= 1 + writes = self.writes[(thread_id, checkpoint_ns, checkpoint_id)].values() + yield CheckpointTuple( config={ "configurable": { @@ -223,6 +229,9 @@ class MemorySaver( } if parent_checkpoint_id else None, + pending_writes=[ + (id, c, self.serde.loads_typed(v)) for id, c, v in writes + ], ) def put( @@ -287,11 +296,10 @@ class MemorySaver( thread_id = config["configurable"]["thread_id"] checkpoint_ns = config["configurable"]["checkpoint_ns"] checkpoint_id = config["configurable"]["checkpoint_id"] - key = (thread_id, checkpoint_ns, checkpoint_id) - self.writes[key] = [w for w in self.writes[key] if w[0] != task_id] - self.writes[key].extend( - [(task_id, c, self.serde.dumps_typed(v)) for c, v in writes] - ) + outer_key = (thread_id, checkpoint_ns, checkpoint_id) + for idx, (c, v) in enumerate(writes): + inner_key = (task_id, WRITES_IDX_MAP.get(c, idx)) + self.writes[outer_key][inner_key] = (task_id, c, self.serde.dumps_typed(v)) async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]: """Asynchronous version of get_tuple. diff --git a/libs/checkpoint/langgraph/checkpoint/serde/types.py b/libs/checkpoint/langgraph/checkpoint/serde/types.py index de61ef78b..d166a6a0d 100644 --- a/libs/checkpoint/langgraph/checkpoint/serde/types.py +++ b/libs/checkpoint/langgraph/checkpoint/serde/types.py @@ -12,6 +12,8 @@ from typing import ( from langchain_core.runnables import RunnableConfig from typing_extensions import Self +ERROR = "__error__" + Value = TypeVar("Value") Update = TypeVar("Update") C = TypeVar("C") diff --git a/libs/langgraph/langgraph/errors.py b/libs/langgraph/langgraph/errors.py index 00dc36e58..27a7689fa 100644 --- a/libs/langgraph/langgraph/errors.py +++ b/libs/langgraph/langgraph/errors.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Sequence from langgraph.checkpoint.base import EmptyChannelError from langgraph.constants import Interrupt @@ -32,7 +32,7 @@ class InvalidUpdateError(Exception): class GraphInterrupt(Exception): """Raised when a subgraph is interrupted.""" - def __init__(self, interrupts: list[Interrupt]) -> None: + def __init__(self, interrupts: Sequence[Interrupt] = ()) -> None: super().__init__(interrupts) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 270a3c7ba..c06f2bce3 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -65,16 +65,10 @@ from langgraph.constants import ( CONFIG_KEY_SEND, ERROR, INTERRUPT, - Interrupt, ) from langgraph.errors import GraphInterrupt, GraphRecursionError, InvalidUpdateError from langgraph.managed.base import ManagedValueSpec -from langgraph.pregel.algo import ( - apply_writes, - local_read, - prepare_next_tasks, - should_interrupt, -) +from langgraph.pregel.algo import apply_writes, local_read, prepare_next_tasks from langgraph.pregel.debug import ( print_step_checkpoint, print_step_tasks, @@ -570,7 +564,7 @@ class Pregel( # update channels with ChannelsManager(self.channels, checkpoint, config) as ( channels, - managed, + _, ): # create task to run all writers of the chosen node writers = self.nodes[as_node].get_writers() @@ -606,31 +600,6 @@ class Pregel( checkpoint, channels, [task], self.checkpointer.get_next_version ), "Can't write to SharedValues from update_state" checkpoint = create_checkpoint(checkpoint, channels, step + 1) - # check interrupt before - if tasks := should_interrupt( - checkpoint, - self.interrupt_before_nodes, - prepare_next_tasks( - checkpoint, - self.nodes, - channels, - managed, - config, - step + 2, - for_execution=False, - ), - ): - for t in tasks: - self.checkpointer.put_writes( - { - "configurable": { - **checkpoint_config["configurable"], - "checkpoint_id": checkpoint["id"], - } - }, - [(INTERRUPT, Interrupt("before"))], - t.id, - ) return self.checkpointer.put( checkpoint_config, checkpoint, @@ -713,7 +682,7 @@ class Pregel( # update channels, acting as the chosen node async with AsyncChannelsManager(self.channels, checkpoint, config) as ( channels, - managed, + _, ): # create task to run all writers of the chosen node writers = self.nodes[as_node].get_writers() @@ -749,35 +718,6 @@ class Pregel( checkpoint, channels, [task], self.checkpointer.get_next_version ), "Can't write to SharedValues from update_state" checkpoint = create_checkpoint(checkpoint, channels, step + 1) - # check interrupt before - if tasks := should_interrupt( - checkpoint, - self.interrupt_before_nodes, - prepare_next_tasks( - checkpoint, - self.nodes, - channels, - managed, - config, - step + 2, - for_execution=False, - ), - ): - await asyncio.gather( - *( - self.checkpointer.aput_writes( - { - "configurable": { - **checkpoint_config["configurable"], - "checkpoint_id": checkpoint["id"], - } - }, - [(INTERRUPT, Interrupt("before"))], - t.id, - ) - for t in tasks - ) - ) return await self.checkpointer.aput( checkpoint_config, checkpoint, diff --git a/libs/langgraph/langgraph/pregel/loop.py b/libs/langgraph/langgraph/pregel/loop.py index 061b932c0..a501232a9 100644 --- a/libs/langgraph/langgraph/pregel/loop.py +++ b/libs/langgraph/langgraph/pregel/loop.py @@ -41,7 +41,6 @@ from langgraph.constants import ( ERROR, INPUT, INTERRUPT, - Interrupt, ) from langgraph.errors import EmptyInputError, GraphInterrupt from langgraph.managed.base import ( @@ -162,6 +161,8 @@ class PregelLoop: def put_writes(self, task_id: str, writes: Sequence[tuple[str, Any]]) -> None: """Put writes for a task, to be read by the next tick.""" + if not writes: + return self.checkpoint_pending_writes.extend((task_id, k, v) for k, v in writes) if self.checkpointer_put_writes is not None: self.submit( @@ -238,13 +239,10 @@ class PregelLoop: } ) # after execution, check if we should interrupt - if tasks := should_interrupt(self.checkpoint, interrupt_after, self.tasks): + if should_interrupt(self.checkpoint, interrupt_after, self.tasks): self.status = "interrupt_after" - interrupts = [(t.id, Interrupt("after")) for t in tasks] - for tid, interrupt in interrupts: - self.put_writes(tid, [(INTERRUPT, interrupt)]) if self.is_nested: - raise GraphInterrupt([i[1] for i in interrupts]) + raise GraphInterrupt() else: return False else: @@ -308,13 +306,10 @@ class PregelLoop: ) # before execution, check if we should interrupt - if tasks := should_interrupt(self.checkpoint, interrupt_before, self.tasks): + if should_interrupt(self.checkpoint, interrupt_before, self.tasks): self.status = "interrupt_before" - interrupts = [(t.id, Interrupt("before")) for t in tasks] - for tid, interrupt in interrupts: - self.put_writes(tid, [(INTERRUPT, interrupt)]) if self.is_nested: - raise GraphInterrupt([i[1] for i in interrupts]) + raise GraphInterrupt() else: return False diff --git a/libs/langgraph/tests/any_str.py b/libs/langgraph/tests/any_str.py index 9aa030d3b..a98962cdc 100644 --- a/libs/langgraph/tests/any_str.py +++ b/libs/langgraph/tests/any_str.py @@ -1,3 +1,6 @@ +from typing import Any, Sequence + + class AnyStr(str): def __init__(self) -> None: super().__init__() @@ -9,6 +12,17 @@ class AnyStr(str): return hash(str(self)) +class AnyVersion: + def __init__(self) -> None: + super().__init__() + + def __eq__(self, other: object) -> bool: + return isinstance(other, (str, int, float)) + + def __hash__(self) -> int: + return hash(str(self)) + + class ExceptionLike: def __init__(self, exc: Exception) -> None: self.exc = exc @@ -22,3 +36,24 @@ class ExceptionLike: def __hash__(self) -> int: return hash((self.exc.__class__, str(self.exc))) + + def __repr__(self) -> str: + return str(self.exc) + + +class UnsortedSequence: + def __init__(self, *values: Any) -> None: + self.seq = values + + def __eq__(self, value: object) -> bool: + return ( + isinstance(value, Sequence) + and len(self.seq) == len(value) + and all(a in value for a in self.seq) + ) + + def __hash__(self) -> int: + return hash(frozenset(self.seq)) + + def __repr__(self) -> str: + return repr(self.seq) diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 3e40f31fc..56e7ed5a3 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -67,7 +67,7 @@ from langgraph.pregel import Channel, GraphRecursionError, Pregel, StateSnapshot from langgraph.pregel.retry import RetryPolicy from langgraph.pregel.types import PregelTask from langgraph.store.memory import MemoryStore -from tests.any_str import AnyStr, ExceptionLike +from tests.any_str import AnyStr, AnyVersion, ExceptionLike, UnsortedSequence from tests.fake_tracer import FakeTracer from tests.memory_assert import ( MemorySaverAssertCheckpointMetadata, @@ -1411,6 +1411,147 @@ def test_pending_writes_resume( # both the pending write and the new write were applied, 1 + 2 + 3 = 6 assert graph.invoke(None, thread1) == {"value": 6} + # check all final checkpoints + checkpoints = [c for c in checkpointer.list(thread1)] + # we should have 3 + assert len(checkpoints) == 3 + # the last one not too interesting for this test + assert checkpoints[0] == CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + checkpoint={ + "v": 1, + "id": AnyStr(), + "ts": AnyStr(), + "current_tasks": {}, + "pending_sends": [], + "versions_seen": { + "one": { + "start:one": AnyVersion(), + }, + "two": { + "start:two": AnyVersion(), + }, + "__input__": {}, + "__start__": { + "__start__": AnyVersion(), + }, + "__interrupt__": { + "value": AnyVersion(), + "__start__": AnyVersion(), + "start:one": AnyVersion(), + "start:two": AnyVersion(), + }, + }, + "channel_versions": { + "one": AnyVersion(), + "two": AnyVersion(), + "value": AnyVersion(), + "__start__": AnyVersion(), + "start:one": AnyVersion(), + "start:two": AnyVersion(), + }, + "channel_values": {"one": "one", "two": "two", "value": 6}, + }, + metadata={ + "step": 1, + "source": "loop", + "writes": {"one": {"value": 2}, "two": {"value": 3}}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": checkpoints[1].config["configurable"]["checkpoint_id"], + } + }, + pending_writes=[], + ) + # the previous one we assert that pending writes contains both + # - original error + # - successful writes from resuming after preventing error + assert checkpoints[1] == CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + checkpoint={ + "v": 1, + "id": AnyStr(), + "ts": AnyStr(), + "current_tasks": {}, + "pending_sends": [], + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": AnyVersion(), + }, + }, + "channel_versions": { + "value": AnyVersion(), + "__start__": AnyVersion(), + "start:one": AnyVersion(), + "start:two": AnyVersion(), + }, + "channel_values": { + "value": 1, + "start:one": "__start__", + "start:two": "__start__", + }, + }, + metadata={"step": 0, "source": "loop", "writes": None}, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": checkpoints[2].config["configurable"]["checkpoint_id"], + } + }, + pending_writes=UnsortedSequence( + (AnyStr(), "one", "one"), + (AnyStr(), "value", 2), + (AnyStr(), "__error__", ExceptionLike(ConnectionError("I'm not good"))), + (AnyStr(), "two", "two"), + (AnyStr(), "value", 3), + ), + ) + assert checkpoints[2] == CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + checkpoint={ + "v": 1, + "id": AnyStr(), + "ts": AnyStr(), + "current_tasks": {}, + "pending_sends": [], + "versions_seen": {"__input__": {}}, + "channel_versions": { + "__start__": AnyVersion(), + }, + "channel_values": {"__start__": {"value": 1}}, + }, + metadata={"step": -1, "source": "input", "writes": {"value": 1}}, + parent_config=None, + pending_writes=UnsortedSequence( + (AnyStr(), "value", 1), + (AnyStr(), "start:one", "__start__"), + (AnyStr(), "start:two", "__start__"), + ), + ) + def test_cond_edge_after_send() -> None: class Node: @@ -2235,7 +2376,7 @@ def test_conditional_graph(snapshot: SnapshotAssertion) -> None: ), }, }, - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -2281,7 +2422,7 @@ def test_conditional_graph(snapshot: SnapshotAssertion) -> None: "input": "what is weather in sf", }, }, - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -2442,7 +2583,7 @@ def test_conditional_graph(snapshot: SnapshotAssertion) -> None: ), }, }, - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -3043,7 +3184,7 @@ def test_conditional_state_graph( ), "intermediate_steps": [], }, - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -3083,7 +3224,7 @@ def test_conditional_state_graph( ), "intermediate_steps": [], }, - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -3192,7 +3333,7 @@ def test_conditional_state_graph( values={ "intermediate_steps": [], }, - tasks=(PregelTask(AnyStr(), "agent", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "agent"),), next=("agent",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -3217,7 +3358,7 @@ def test_conditional_state_graph( ), "intermediate_steps": [], }, - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -3270,7 +3411,7 @@ def test_conditional_state_graph( ) ], }, - tasks=(PregelTask(AnyStr(), "agent", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "agent"),), next=("agent",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -4872,13 +5013,7 @@ def test_message_graph( id="ai1", ), ], - tasks=( - PregelTask( - AnyStr(), - "tools", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -4923,7 +5058,7 @@ def test_message_graph( ], ), ], - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -5005,7 +5140,7 @@ def test_message_graph( id="ai2", ), ], - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -5597,13 +5732,7 @@ def test_root_graph( id="ai1", ), ], - tasks=( - PregelTask( - AnyStr(), - "tools", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -5648,7 +5777,7 @@ def test_root_graph( ], ), ], - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -5730,7 +5859,7 @@ def test_root_graph( id="ai2", ), ], - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -6340,13 +6469,7 @@ def test_start_branch_then(snapshot: SnapshotAssertion) -> None: ] assert tool_two.get_state(thread1) == StateSnapshot( values={"my_key": "value ⛰️", "market": "DE"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_slow", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_slow"),), next=("tool_two_slow",), config=tool_two.checkpointer.get_tuple(thread1).config, created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"], @@ -6380,13 +6503,7 @@ def test_start_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread2) == StateSnapshot( values={"my_key": "value", "market": "US"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_fast", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_fast"),), next=("tool_two_fast",), config=tool_two.checkpointer.get_tuple(thread2).config, created_at=tool_two.checkpointer.get_tuple(thread2).checkpoint["ts"], @@ -6420,13 +6537,7 @@ def test_start_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread3) == StateSnapshot( values={"my_key": "value", "market": "US"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_fast", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_fast"),), next=("tool_two_fast",), config=tool_two.checkpointer.get_tuple(thread3).config, created_at=tool_two.checkpointer.get_tuple(thread3).checkpoint["ts"], @@ -6437,13 +6548,7 @@ def test_start_branch_then(snapshot: SnapshotAssertion) -> None: tool_two.update_state(thread3, {"my_key": "key"}) # appends to my_key assert tool_two.get_state(thread3) == StateSnapshot( values={"my_key": "valuekey", "market": "US"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_fast", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_fast"),), next=("tool_two_fast",), config=tool_two.checkpointer.get_tuple(thread3).config, created_at=tool_two.checkpointer.get_tuple(thread3).checkpoint["ts"], @@ -6744,13 +6849,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread1) == StateSnapshot( values={"my_key": "value prepared", "market": "DE"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_slow", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_slow"),), next=("tool_two_slow",), config=tool_two.checkpointer.get_tuple(thread1).config, created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"], @@ -6788,13 +6887,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread2) == StateSnapshot( values={"my_key": "value prepared", "market": "US"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_fast", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_fast"),), next=("tool_two_fast",), config=tool_two.checkpointer.get_tuple(thread2).config, created_at=tool_two.checkpointer.get_tuple(thread2).checkpoint["ts"], @@ -6841,7 +6934,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: "my_key": "value prepared slow", "market": "DE", }, - tasks=(PregelTask(AnyStr(), "finish", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "finish"),), next=("finish",), config=tool_two.checkpointer.get_tuple(thread1).config, created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"], @@ -6860,7 +6953,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: "my_key": "value prepared slower", "market": "DE", }, - tasks=(PregelTask(AnyStr(), "finish", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "finish"),), next=("finish",), config=tool_two.checkpointer.get_tuple(thread1).config, created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"], @@ -7117,7 +7210,7 @@ def test_in_one_fan_out_state_graph_waiting_edge(snapshot: SnapshotAssertion) -> "query": "analyzed: query: what is weather in sf", "docs": ["doc1", "doc2", "doc3", "doc4", "doc5"], }, - tasks=(PregelTask(AnyStr(), "qa", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "qa"),), next=("qa",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 692d101e0..0308deb54 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -62,7 +62,7 @@ from langgraph.pregel import Channel, GraphRecursionError, Pregel, StateSnapshot from langgraph.pregel.retry import RetryPolicy from langgraph.pregel.types import PregelTask from langgraph.store.memory import MemoryStore -from tests.any_str import AnyStr, ExceptionLike +from tests.any_str import AnyStr, AnyVersion, ExceptionLike, UnsortedSequence from tests.fake_tracer import FakeTracer from tests.memory_assert import ( MemorySaverAssertCheckpointMetadata, @@ -207,7 +207,15 @@ async def test_node_cancellation_on_other_node_exception() -> None: assert inner_task_cancelled -async def test_dynamic_interrupt(snapshot: SnapshotAssertion) -> None: +@pytest.mark.parametrize( + "checkpointer_name", + ["memory", "sqlite_aio", "postgres_aio", "postgres_aio_pipe"], +) +async def test_dynamic_interrupt( + checkpointer_name: str, snapshot: SnapshotAssertion, request: pytest.FixtureRequest +) -> None: + checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}") + class State(TypedDict): my_key: Annotated[str, operator.add] market: str @@ -245,51 +253,49 @@ async def test_dynamic_interrupt(snapshot: SnapshotAssertion) -> None: "market": "US", } - async with AsyncSqliteSaver.from_conn_string(":memory:") as saver: - tool_two = tool_two_graph.compile(checkpointer=saver) + tool_two = tool_two_graph.compile(checkpointer=checkpointer) - # missing thread_id - with pytest.raises(ValueError, match="thread_id"): - await tool_two.ainvoke({"my_key": "value", "market": "DE"}) + # missing thread_id + with pytest.raises(ValueError, match="thread_id"): + await tool_two.ainvoke({"my_key": "value", "market": "DE"}) - thread1 = {"configurable": {"thread_id": "1"}} - # stop when about to enter node - assert await tool_two.ainvoke( - {"my_key": "value ⛰️", "market": "DE"}, thread1 - ) == { - "my_key": "value ⛰️", - "market": "DE", - } - assert [c.metadata async for c in tool_two.checkpointer.alist(thread1)] == [ - { - "source": "loop", - "step": 0, - "writes": None, - }, - { - "source": "input", - "step": -1, - "writes": {"my_key": "value ⛰️", "market": "DE"}, - }, - ] - tup = await tool_two.checkpointer.aget_tuple(thread1) - assert await tool_two.aget_state(thread1) == StateSnapshot( - values={"my_key": "value ⛰️", "market": "DE"}, - next=("tool_two",), - tasks=( - PregelTask( - AnyStr(), - "tool_two", - interrupts=(Interrupt("during", "Just because..."),), - ), + thread1 = {"configurable": {"thread_id": "1"}} + # stop when about to enter node + assert await tool_two.ainvoke({"my_key": "value ⛰️", "market": "DE"}, thread1) == { + "my_key": "value ⛰️", + "market": "DE", + } + assert [c.metadata async for c in tool_two.checkpointer.alist(thread1)] == [ + { + "source": "loop", + "step": 0, + "writes": None, + }, + { + "source": "input", + "step": -1, + "writes": {"my_key": "value ⛰️", "market": "DE"}, + }, + ] + tup = await tool_two.checkpointer.aget_tuple(thread1) + assert await tool_two.aget_state(thread1) == StateSnapshot( + values={"my_key": "value ⛰️", "market": "DE"}, + next=("tool_two",), + tasks=( + PregelTask( + AnyStr(), + "tool_two", + interrupts=(Interrupt("during", "Just because..."),), ), - config=tup.config, - created_at=tup.checkpoint["ts"], - metadata={"source": "loop", "step": 0, "writes": None}, - parent_config=[ - c async for c in tool_two.checkpointer.alist(thread1, limit=2) - ][-1].config, - ) + ), + config=tup.config, + created_at=tup.checkpoint["ts"], + metadata={"source": "loop", "step": 0, "writes": None}, + parent_config=[c async for c in tool_two.checkpointer.alist(thread1, limit=2)][ + -1 + ].config, + ) + # TODO use aget_state_history @pytest.mark.parametrize( @@ -1609,6 +1615,10 @@ async def test_pending_writes_resume( error_write = next(w for w in checkpoint.pending_writes if w[1] == ERROR) assert error_write[0] != non_error_writes[0][0] + # TODO arguably this shouldn't even run the failed task again, + # and should require empty update_state (ie new checkpoint_id) + # in order to try again + # resume execution with pytest.raises(ValueError, match="I'm not good"): await graph.ainvoke(None, thread1) @@ -1627,6 +1637,147 @@ async def test_pending_writes_resume( # both the pending write and the new write were applied, 1 + 2 + 3 = 6 assert await graph.ainvoke(None, thread1) == {"value": 6} + # check all final checkpoints + checkpoints = [c async for c in checkpointer.alist(thread1)] + # we should have 3 + assert len(checkpoints) == 3 + # the last one not too interesting for this test + assert checkpoints[0] == CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + checkpoint={ + "v": 1, + "id": AnyStr(), + "ts": AnyStr(), + "current_tasks": {}, + "pending_sends": [], + "versions_seen": { + "one": { + "start:one": AnyVersion(), + }, + "two": { + "start:two": AnyVersion(), + }, + "__input__": {}, + "__start__": { + "__start__": AnyVersion(), + }, + "__interrupt__": { + "value": AnyVersion(), + "__start__": AnyVersion(), + "start:one": AnyVersion(), + "start:two": AnyVersion(), + }, + }, + "channel_versions": { + "one": AnyVersion(), + "two": AnyVersion(), + "value": AnyVersion(), + "__start__": AnyVersion(), + "start:one": AnyVersion(), + "start:two": AnyVersion(), + }, + "channel_values": {"one": "one", "two": "two", "value": 6}, + }, + metadata={ + "step": 1, + "source": "loop", + "writes": {"one": {"value": 2}, "two": {"value": 3}}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": checkpoints[1].config["configurable"]["checkpoint_id"], + } + }, + pending_writes=[], + ) + # the previous one we assert that pending writes contains both + # - original error + # - successful writes from resuming after preventing error + assert checkpoints[1] == CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + checkpoint={ + "v": 1, + "id": AnyStr(), + "ts": AnyStr(), + "current_tasks": {}, + "pending_sends": [], + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": AnyVersion(), + }, + }, + "channel_versions": { + "value": AnyVersion(), + "__start__": AnyVersion(), + "start:one": AnyVersion(), + "start:two": AnyVersion(), + }, + "channel_values": { + "value": 1, + "start:one": "__start__", + "start:two": "__start__", + }, + }, + metadata={"step": 0, "source": "loop", "writes": None}, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": checkpoints[2].config["configurable"]["checkpoint_id"], + } + }, + pending_writes=UnsortedSequence( + (AnyStr(), "one", "one"), + (AnyStr(), "value", 2), + (AnyStr(), "__error__", ExceptionLike(ValueError("I'm not good"))), + (AnyStr(), "two", "two"), + (AnyStr(), "value", 3), + ), + ) + assert checkpoints[2] == CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + checkpoint={ + "v": 1, + "id": AnyStr(), + "ts": AnyStr(), + "current_tasks": {}, + "pending_sends": [], + "versions_seen": {"__input__": {}}, + "channel_versions": { + "__start__": AnyVersion(), + }, + "channel_values": {"__start__": {"value": 1}}, + }, + metadata={"step": -1, "source": "input", "writes": {"value": 1}}, + parent_config=None, + pending_writes=UnsortedSequence( + (AnyStr(), "value", 1), + (AnyStr(), "start:one", "__start__"), + (AnyStr(), "start:two", "__start__"), + ), + ) + async def test_cond_edge_after_send() -> None: class Node: @@ -2516,13 +2667,7 @@ async def test_conditional_graph() -> None: ), }, }, - tasks=( - PregelTask( - AnyStr(), - "tools", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -2572,13 +2717,7 @@ async def test_conditional_graph() -> None: "input": "what is weather in sf", }, }, - tasks=( - PregelTask( - AnyStr(), - "tools", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -2750,13 +2889,7 @@ async def test_conditional_graph() -> None: ), }, }, - tasks=( - PregelTask( - AnyStr(), - "tools", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -3324,13 +3457,7 @@ async def test_conditional_graph_state(mocker: MockerFixture) -> None: ), "intermediate_steps": [], }, - tasks=( - PregelTask( - AnyStr(), - "tools", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -3374,7 +3501,7 @@ async def test_conditional_graph_state(mocker: MockerFixture) -> None: ), "intermediate_steps": [], }, - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -4957,13 +5084,7 @@ async def test_start_branch_then() -> None: ] assert await tool_two.aget_state(thread1) == StateSnapshot( values={"my_key": "value", "market": "DE"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_slow", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_slow"),), next=("tool_two_slow",), config=(await tool_two.checkpointer.aget_tuple(thread1)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread1)).checkpoint[ @@ -5005,13 +5126,7 @@ async def test_start_branch_then() -> None: } assert await tool_two.aget_state(thread2) == StateSnapshot( values={"my_key": "value", "market": "US"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_fast", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_fast"),), next=("tool_two_fast",), config=(await tool_two.checkpointer.aget_tuple(thread2)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread2)).checkpoint[ @@ -5053,13 +5168,7 @@ async def test_start_branch_then() -> None: } assert await tool_two.aget_state(thread3) == StateSnapshot( values={"my_key": "value", "market": "US"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_fast", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_fast"),), next=("tool_two_fast",), config=(await tool_two.checkpointer.aget_tuple(thread3)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread3)).checkpoint[ @@ -5074,13 +5183,7 @@ async def test_start_branch_then() -> None: await tool_two.aupdate_state(thread3, {"my_key": "key"}) # appends to my_key assert await tool_two.aget_state(thread3) == StateSnapshot( values={"my_key": "valuekey", "market": "US"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_fast", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_fast"),), next=("tool_two_fast",), config=(await tool_two.checkpointer.aget_tuple(thread3)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread3)).checkpoint[ @@ -5497,28 +5600,10 @@ async def test_branch_then() -> None: ], }, }, - { - "type": "task_result", - "timestamp": AnyStr(), - "step": 2, - "payload": { - "id": "054b0ced-4546-58f4-bee5-548f029e1a8e", - "name": "tool_two_slow", - "result": [], - "error": None, - "interrupts": [{"when": "before", "value": None}], - }, - }, ] assert await tool_two.aget_state(thread1) == StateSnapshot( values={"my_key": "value prepared", "market": "DE"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_slow", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_slow"),), next=("tool_two_slow",), config=(await tool_two.checkpointer.aget_tuple(thread1)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread1)).checkpoint[ @@ -5564,13 +5649,7 @@ async def test_branch_then() -> None: } assert await tool_two.aget_state(thread2) == StateSnapshot( values={"my_key": "value prepared", "market": "US"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_fast", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_fast"),), next=("tool_two_fast",), config=(await tool_two.checkpointer.aget_tuple(thread2)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread2)).checkpoint[