From 1a728a93c603260a2e9f8842ec91843dc4f1bcae Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Mon, 17 Mar 2025 17:22:03 +0100 Subject: [PATCH 01/32] feat(sdk-js): add bulkUpdateState method --- libs/sdk-js/src/client.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/libs/sdk-js/src/client.ts b/libs/sdk-js/src/client.ts index 84ef3d44c..4fef0bc71 100644 --- a/libs/sdk-js/src/client.ts +++ b/libs/sdk-js/src/client.ts @@ -638,6 +638,33 @@ export class ThreadsClient< ); } + /** + * Create a new thread from a batch states. + */ + async bulkUpdateState( + supersteps: Array<{ updates: Array<{ values: unknown; asNode: string }> }>, + options: { + threadId?: string; + metadata?: Metadata; + ifExists?: OnConflictBehavior; + }, + ): Promise> { + return this.fetch>("/threads/state/batch", { + method: "POST", + json: { + supersteps: supersteps.map((s) => ({ + updates: s.updates.map((u) => ({ + values: u.values, + as_node: u.asNode, + })), + })), + thread_id: options.threadId, + metadata: options.metadata, + if_exists: options.ifExists, + }, + }); + } + /** * Patch the metadata of a thread. * From 7ae47396305f00d11350fecee3a1c42bb6b8025a Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Mon, 17 Mar 2025 18:07:14 +0100 Subject: [PATCH 02/32] Make options optional --- libs/sdk-js/src/client.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/sdk-js/src/client.ts b/libs/sdk-js/src/client.ts index 4fef0bc71..00f340204 100644 --- a/libs/sdk-js/src/client.ts +++ b/libs/sdk-js/src/client.ts @@ -643,7 +643,7 @@ export class ThreadsClient< */ async bulkUpdateState( supersteps: Array<{ updates: Array<{ values: unknown; asNode: string }> }>, - options: { + options?: { threadId?: string; metadata?: Metadata; ifExists?: OnConflictBehavior; @@ -658,9 +658,9 @@ export class ThreadsClient< as_node: u.asNode, })), })), - thread_id: options.threadId, - metadata: options.metadata, - if_exists: options.ifExists, + thread_id: options?.threadId, + metadata: options?.metadata, + if_exists: options?.ifExists, }, }); } From 5104e31e35b5af64a1fca5dfeb69eaa550bd54ae Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Mon, 17 Mar 2025 18:14:33 +0100 Subject: [PATCH 03/32] feat(sdk-js): add bulkUpdateState method --- libs/sdk-js/src/client.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libs/sdk-js/src/client.ts b/libs/sdk-js/src/client.ts index 00f340204..ede19852f 100644 --- a/libs/sdk-js/src/client.ts +++ b/libs/sdk-js/src/client.ts @@ -644,6 +644,7 @@ export class ThreadsClient< async bulkUpdateState( supersteps: Array<{ updates: Array<{ values: unknown; asNode: string }> }>, options?: { + graphId?: string; threadId?: string; metadata?: Metadata; ifExists?: OnConflictBehavior; @@ -659,7 +660,10 @@ export class ThreadsClient< })), })), thread_id: options?.threadId, - metadata: options?.metadata, + metadata: { + ...options?.metadata, + graph_id: options?.graphId, + }, if_exists: options?.ifExists, }, }); From 4bff1df4b0ddb23743e750f6ac0c92a8c6c6f549 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Mon, 17 Mar 2025 18:14:51 +0100 Subject: [PATCH 04/32] Bump to 0.0.58 --- libs/sdk-js/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/sdk-js/package.json b/libs/sdk-js/package.json index 2200fab34..a29a438b0 100644 --- a/libs/sdk-js/package.json +++ b/libs/sdk-js/package.json @@ -1,6 +1,6 @@ { "name": "@langchain/langgraph-sdk", - "version": "0.0.57", + "version": "0.0.58", "description": "Client library for interacting with the LangGraph API", "type": "module", "packageManager": "yarn@1.22.19", From b1a25abc73b0dc77bce2cce2dd919ed8bc5b7b7c Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Tue, 18 Mar 2025 21:22:40 +0100 Subject: [PATCH 05/32] Add command --- libs/sdk-js/src/client.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/sdk-js/src/client.ts b/libs/sdk-js/src/client.ts index ede19852f..7efc2e423 100644 --- a/libs/sdk-js/src/client.ts +++ b/libs/sdk-js/src/client.ts @@ -30,6 +30,7 @@ import type { StreamEvent, CronsCreatePayload, OnConflictBehavior, + Command, } from "./types.js"; import { mergeSignals } from "./utils/signals.js"; import { getEnvironmentVariable } from "./utils/env.js"; @@ -642,7 +643,7 @@ export class ThreadsClient< * Create a new thread from a batch states. */ async bulkUpdateState( - supersteps: Array<{ updates: Array<{ values: unknown; asNode: string }> }>, + supersteps: Array<{ updates: Array<{ values: unknown; command?: Command; asNode: string }> }>, options?: { graphId?: string; threadId?: string; @@ -656,6 +657,7 @@ export class ThreadsClient< supersteps: supersteps.map((s) => ({ updates: s.updates.map((u) => ({ values: u.values, + command: u.command, as_node: u.asNode, })), })), From a541376d109fb0d3ec6d83372fd2515134132fd2 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Tue, 18 Mar 2025 21:17:26 -0700 Subject: [PATCH 06/32] Store all triggers in task - These are used to update seen version --- libs/langgraph/langgraph/pregel/algo.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/algo.py b/libs/langgraph/langgraph/pregel/algo.py index 027f51ee7..db56828f9 100644 --- a/libs/langgraph/langgraph/pregel/algo.py +++ b/libs/langgraph/langgraph/pregel/algo.py @@ -517,7 +517,7 @@ def prepare_single_task( CONFIG_KEY_CHECKPOINT_ID: None, CONFIG_KEY_CHECKPOINT_NS: task_checkpoint_ns, CONFIG_KEY_SCRATCHPAD: _scratchpad( - config, + config[CONF].get(CONFIG_KEY_SCRATCHPAD), pending_writes, task_id, ), @@ -627,7 +627,7 @@ def prepare_single_task( CONFIG_KEY_CHECKPOINT_ID: None, CONFIG_KEY_CHECKPOINT_NS: task_checkpoint_ns, CONFIG_KEY_SCRATCHPAD: _scratchpad( - config, + config[CONF].get(CONFIG_KEY_SCRATCHPAD), pending_writes, task_id, ), @@ -721,7 +721,7 @@ def prepare_single_task( CONFIG_KEY_SEND: partial( local_write, writes.extend, - processes.keys(), + tuple(processes.keys()), ), CONFIG_KEY_READ: partial( local_read, @@ -730,7 +730,7 @@ def prepare_single_task( channels, managed, PregelTaskWrites( - task_path[:3], name, writes, triggers + task_path[:3], name, writes, proc.triggers ), config, ), @@ -748,7 +748,7 @@ def prepare_single_task( CONFIG_KEY_CHECKPOINT_ID: None, CONFIG_KEY_CHECKPOINT_NS: task_checkpoint_ns, CONFIG_KEY_SCRATCHPAD: _scratchpad( - config, + config[CONF].get(CONFIG_KEY_SCRATCHPAD), pending_writes, task_id, ), @@ -757,7 +757,7 @@ def prepare_single_task( ), }, ), - triggers, + proc.triggers, proc.retry_policy, None, task_id, @@ -799,7 +799,7 @@ def _triggers( def _scratchpad( - config: RunnableConfig, + parent_scratchpad: Optional[PregelScratchpad], pending_writes: list[PendingWrite], task_id: str, ) -> PregelScratchpad: @@ -808,9 +808,6 @@ def _scratchpad( null_resume_write = next( (w for w in pending_writes if w[0] == NULL_TASK_ID and w[1] == RESUME), None ) - parent_scratchpad: Optional[PregelScratchpad] = config[CONF].get( - CONFIG_KEY_SCRATCHPAD - ) def get_null_resume(consume: bool = False) -> Any: if null_resume_write is None: From e6726802f7fcc4cc59cec958c4e4bab62315761a Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Fri, 7 Mar 2025 19:21:35 +0100 Subject: [PATCH 07/32] feat(pregel): add bulk update state method This method is useful for recreating a thread from a list of checkpoint writes. A new method is needed to clone a checkpoint that has been created from multiple writes (functional API, map-reduce) Port of https://github.com/langchain-ai/langgraphjs/pull/969 --- libs/langgraph/langgraph/pregel/__init__.py | 425 +++++++++++++------- libs/langgraph/langgraph/pregel/protocol.py | 16 +- libs/langgraph/langgraph/pregel/types.py | 2 + libs/langgraph/langgraph/types.py | 5 + 4 files changed, 292 insertions(+), 156 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index f6479422c..e84c0b208 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -106,6 +106,7 @@ from langgraph.pregel.write import ChannelWrite, ChannelWriteEntry from langgraph.store.base import BaseStore from langgraph.types import ( All, + BulkUpdate, Checkpointer, LoopProtocol, StateSnapshot, @@ -1162,22 +1163,22 @@ class Pregel(PregelProtocol): checkpoint_tuple.config, checkpoint_tuple ) - def update_state( + def bulk_update_state( self, config: RunnableConfig, - values: Optional[Union[dict[str, Any], Any]], - as_node: Optional[str] = None, + updates: list[BulkUpdate], ) -> RunnableConfig: - """Update the state of the graph with the given values, as if they came from - node `as_node`. If `as_node` is not provided, it will be set to the last node - that updated the state, if not ambiguous. - """ + """Apply updates to the graph state in bulk. Requires a checkpointer to be set.""" + checkpointer: Optional[BaseCheckpointSaver] = ensure_config(config)[CONF].get( CONFIG_KEY_CHECKPOINTER, self.checkpointer ) if not checkpointer: raise ValueError("No checkpointer set") + if len(updates) == 0: + raise ValueError("No updates provided") + # delegate to subgraph if ( checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "") @@ -1186,10 +1187,9 @@ class Pregel(PregelProtocol): recast = recast_checkpoint_ns(checkpoint_ns) # find the subgraph with the matching name for _, pregel in self.get_subgraphs(namespace=recast, recurse=True): - return pregel.update_state( + return pregel.bulk_update_state( patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}), - values, - as_node, + updates, ) else: raise ValueError(f"Subgraph {recast} not found") @@ -1216,8 +1216,15 @@ class Pregel(PregelProtocol): checkpoint, LoopProtocol(config=config, step=step + 1, stop=step + 2), ) as (channels, managed): + values, as_node = updates[0] + # no values as END, just clear all tasks if values is None and as_node == END: + if len(updates) > 1: + raise ValueError( + "Cannot apply multiple updates when clearing state" + ) + if saved is not None: # tasks for this checkpoint next_tasks = prepare_next_tasks( @@ -1276,6 +1283,11 @@ class Pregel(PregelProtocol): ) # no values, empty checkpoint if values is None and as_node is None: + if len(updates) > 1: + raise ValueError( + "Cannot create empty checkpoint with multiple updates" + ) + next_checkpoint = create_checkpoint(checkpoint, None, step) # copy checkpoint next_config = checkpointer.put( @@ -1295,6 +1307,9 @@ class Pregel(PregelProtocol): ) # no values, copy checkpoint if values is None and as_node == "__copy__": + if len(updates) > 1: + raise ValueError("Cannot copy checkpoint with multiple updates") + next_checkpoint = create_checkpoint(checkpoint, None, step) # copy checkpoint next_config = checkpointer.put( @@ -1354,79 +1369,106 @@ class Pregel(PregelProtocol): next_tasks[tid].writes.append((k, v)) if tasks := [t for t in next_tasks.values() if t.writes]: apply_writes(checkpoint, channels, tasks, None) - # find last node that updated the state, if not provided - if as_node is None and not any( - v for vv in checkpoint["versions_seen"].values() for v in vv.values() - ): - if ( - isinstance(self.input_channels, str) - and self.input_channels in self.nodes + valid_updates: list[tuple[Optional[dict[str, Any]], str]] = [] + if len(updates) == 1: + values, as_node = updates[0] + + # find last node that updated the state, if not provided + if as_node is None and not any( + v + for vv in checkpoint["versions_seen"].values() + for v in vv.values() ): - as_node = self.input_channels - elif as_node is None: - last_seen_by_node = sorted( - (v, n) - for n, seen in checkpoint["versions_seen"].items() - if n in self.nodes - for v in seen.values() + if ( + isinstance(self.input_channels, str) + and self.input_channels in self.nodes + ): + as_node = self.input_channels + elif as_node is None: + last_seen_by_node = sorted( + (v, n) + for n, seen in checkpoint["versions_seen"].items() + if n in self.nodes + for v in seen.values() + ) + # if two nodes updated the state at the same time, it's ambiguous + if last_seen_by_node: + if len(last_seen_by_node) == 1: + as_node = last_seen_by_node[0][1] + elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]: + as_node = last_seen_by_node[-1][1] + if as_node is None: + raise InvalidUpdateError("Ambiguous update, specify as_node") + if as_node not in self.nodes: + raise InvalidUpdateError(f"Node {as_node} does not exist") + + valid_updates.append((values, as_node)) + else: + for values, as_node in updates: + if as_node is None: + raise InvalidUpdateError( + "as_node is required when applying multiple updates" + ) + + if as_node not in self.nodes: + raise InvalidUpdateError(f"Node {as_node} does not exist") + + valid_updates.append((values, as_node)) + + tasks: list[tuple[str, PregelTaskWrites]] = [] + for values, as_node in valid_updates: + # create task to run all writers of the chosen node + writers = self.nodes[as_node].flat_writers + if not writers: + raise InvalidUpdateError(f"Node {as_node} has no writers") + writes: deque[tuple[str, Any]] = deque() + task = PregelTaskWrites((), as_node, writes, [INTERRUPT]) + task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT)) + tasks.append((task_id, task)) + + for _, task in tasks: + run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] + # execute task + run.invoke( + values, + patch_config( + config, + run_name=self.name + "UpdateState", + configurable={ + # deque.extend is thread-safe + CONFIG_KEY_SEND: partial( + local_write, + writes.extend, + self.nodes.keys(), + ), + CONFIG_KEY_READ: partial( + local_read, + step + 1, + checkpoint, + channels, + managed, + task, + config, + ), + }, + ), ) - # if two nodes updated the state at the same time, it's ambiguous - if last_seen_by_node: - if len(last_seen_by_node) == 1: - as_node = last_seen_by_node[0][1] - elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]: - as_node = last_seen_by_node[-1][1] - if as_node is None: - raise InvalidUpdateError("Ambiguous update, specify as_node") - if as_node not in self.nodes: - raise InvalidUpdateError(f"Node {as_node} does not exist") - # create task to run all writers of the chosen node - writers = self.nodes[as_node].flat_writers - if not writers: - raise InvalidUpdateError(f"Node {as_node} has no writers") - writes: deque[tuple[str, Any]] = deque() - task = PregelTaskWrites((), as_node, writes, [INTERRUPT]) - task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT)) - run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] - # execute task - run.invoke( - values, - patch_config( - config, - run_name=self.name + "UpdateState", - configurable={ - # deque.extend is thread-safe - CONFIG_KEY_SEND: partial( - local_write, - writes.extend, - self.nodes.keys(), - ), - CONFIG_KEY_READ: partial( - local_read, - step + 1, - checkpoint, - channels, - managed, - task, - config, - ), - }, - ), - ) + # save task writes - # channel writes are saved to current checkpoint - # push writes are saved to next checkpoint - channel_writes, push_writes = ( - [w for w in task.writes if w[0] != PUSH], - [w for w in task.writes if w[0] == PUSH], - ) - if saved and channel_writes: - checkpointer.put_writes(checkpoint_config, channel_writes, task_id) + for _, task in tasks: + channel_writes = [w for w in task.writes if w[0] != PUSH] + + # channel writes are saved to current checkpoint + if saved and channel_writes: + checkpointer.put_writes(checkpoint_config, channel_writes, task_id) + # apply to checkpoint and save mv_writes = apply_writes( - checkpoint, channels, [task], checkpointer.get_next_version + checkpoint, channels, tasks, checkpointer.get_next_version ) + assert not mv_writes, "Can't write to SharedValues from update_state" + checkpoint = create_checkpoint(checkpoint, channels, step + 1) next_config = checkpointer.put( checkpoint_config, @@ -1435,23 +1477,27 @@ class Pregel(PregelProtocol): **checkpoint_metadata, "source": "update", "step": step + 1, - "writes": {as_node: values}, + "writes": {k: v for k, v in valid_updates}, "parents": saved.metadata.get("parents", {}) if saved else {}, }, get_new_channel_versions( checkpoint_previous_versions, checkpoint["channel_versions"] ), ) - if push_writes: - checkpointer.put_writes(next_config, push_writes, task_id) + + for _, task in tasks: + if push_writes := [w for w in task.writes if w[0] == PUSH]: + checkpointer.put_writes(next_config, push_writes, task_id) + return patch_checkpoint_map(next_config, saved.metadata if saved else None) - async def aupdate_state( + async def abulk_update_state( self, config: RunnableConfig, - values: dict[str, Any] | Any, - as_node: Optional[str] = None, + updates: list[BulkUpdate], ) -> RunnableConfig: + """Apply updates to the graph state in bulk. Requires a checkpointer to be set.""" + """Update the state of the graph asynchronously with the given values, as if they came from node `as_node`. If `as_node` is not provided, it will be set to the last node that updated the state, if not ambiguous. @@ -1462,6 +1508,9 @@ class Pregel(PregelProtocol): if not checkpointer: raise ValueError("No checkpointer set") + if len(updates) == 0: + raise ValueError("No updates provided") + # delegate to subgraph if ( checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "") @@ -1470,10 +1519,9 @@ class Pregel(PregelProtocol): recast = recast_checkpoint_ns(checkpoint_ns) # find the subgraph with the matching name async for _, pregel in self.aget_subgraphs(namespace=recast, recurse=True): - return await pregel.aupdate_state( + return await pregel.abulk_update_state( patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}), - values, - as_node, + updates, ) else: raise ValueError(f"Subgraph {recast} not found") @@ -1503,8 +1551,15 @@ class Pregel(PregelProtocol): channels, managed, ): + values, as_node = updates[0] + # no values, just clear all tasks if values is None and as_node == END: + if len(updates) > 1: + raise ValueError( + "Cannot apply multiple updates when clearing state" + ) + if saved is not None: # tasks for this checkpoint next_tasks = prepare_next_tasks( @@ -1563,6 +1618,11 @@ class Pregel(PregelProtocol): ) # no values, empty checkpoint if values is None and as_node is None: + if len(updates) > 1: + raise ValueError( + "Cannot create empty checkpoint with multiple updates" + ) + next_checkpoint = create_checkpoint(checkpoint, None, step) # copy checkpoint next_config = await checkpointer.aput( @@ -1582,6 +1642,9 @@ class Pregel(PregelProtocol): ) # no values, copy checkpoint if values is None and as_node == "__copy__": + if len(updates) > 1: + raise ValueError("Cannot copy checkpoint with multiple updates") + next_checkpoint = create_checkpoint(checkpoint, None, step) # copy checkpoint next_config = await checkpointer.aput( @@ -1640,74 +1703,99 @@ class Pregel(PregelProtocol): next_tasks[tid].writes.append((k, v)) if tasks := [t for t in next_tasks.values() if t.writes]: apply_writes(checkpoint, channels, tasks, None) - # find last node that updated the state, if not provided - if as_node is None and not saved: - if ( - isinstance(self.input_channels, str) - and self.input_channels in self.nodes - ): - as_node = self.input_channels - elif as_node is None: - last_seen_by_node = sorted( - (v, n) - for n, seen in checkpoint["versions_seen"].items() - if n in self.nodes - for v in seen.values() + valid_updates: list[tuple[Optional[dict[str, Any]], str]] = [] + + if len(updates) == 1: + values, as_node = updates[0] + + # find last node that updated the state, if not provided + if as_node is None and not saved: + if ( + isinstance(self.input_channels, str) + and self.input_channels in self.nodes + ): + as_node = self.input_channels + elif as_node is None: + last_seen_by_node = sorted( + (v, n) + for n, seen in checkpoint["versions_seen"].items() + if n in self.nodes + for v in seen.values() + ) + # if two nodes updated the state at the same time, it's ambiguous + if last_seen_by_node: + if len(last_seen_by_node) == 1: + as_node = last_seen_by_node[0][1] + elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]: + as_node = last_seen_by_node[-1][1] + if as_node is None: + raise InvalidUpdateError("Ambiguous update, specify as_node") + + if as_node not in self.nodes: + raise InvalidUpdateError(f"Node {as_node} does not exist") + + valid_updates.append((values, as_node)) + else: + for values, as_node in updates: + if as_node is None: + raise InvalidUpdateError( + "as_node is required when applying multiple updates" + ) + + if as_node not in self.nodes: + raise InvalidUpdateError(f"Node {as_node} does not exist") + + valid_updates.append((values, as_node)) + + tasks: list[tuple[str, PregelTaskWrites]] = [] + + for values, as_node in valid_updates: + # create task to run all writers of the chosen node + writers = self.nodes[as_node].flat_writers + if not writers: + raise InvalidUpdateError(f"Node {as_node} has no writers") + writes: deque[tuple[str, Any]] = deque() + task = PregelTaskWrites((), as_node, writes, [INTERRUPT]) + task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT)) + tasks.append((task_id, task)) + + for _, task in tasks: + run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] + # execute task + await run.ainvoke( + values, + patch_config( + config, + run_name=self.name + "UpdateState", + configurable={ + # deque.extend is thread-safe + CONFIG_KEY_SEND: partial( + local_write, + writes.extend, + self.nodes.keys(), + ), + CONFIG_KEY_READ: partial( + local_read, + step + 1, + checkpoint, + channels, + managed, + task, + config, + ), + }, + ), ) - # if two nodes updated the state at the same time, it's ambiguous - if last_seen_by_node: - if len(last_seen_by_node) == 1: - as_node = last_seen_by_node[0][1] - elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]: - as_node = last_seen_by_node[-1][1] - if as_node is None: - raise InvalidUpdateError("Ambiguous update, specify as_node") - if as_node not in self.nodes: - raise InvalidUpdateError(f"Node {as_node} does not exist") - # create task to run all writers of the chosen node - writers = self.nodes[as_node].flat_writers - if not writers: - raise InvalidUpdateError(f"Node {as_node} has no writers") - writes: deque[tuple[str, Any]] = deque() - task = PregelTaskWrites((), as_node, writes, [INTERRUPT]) - task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT)) - run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] - # execute task - await run.ainvoke( - values, - patch_config( - config, - run_name=self.name + "UpdateState", - configurable={ - # deque.extend is thread-safe - CONFIG_KEY_SEND: partial( - local_write, - writes.extend, - self.nodes.keys(), - ), - CONFIG_KEY_READ: partial( - local_read, - step + 1, - checkpoint, - channels, - managed, - task, - config, - ), - }, - ), - ) + # save task writes - # channel writes are saved to current checkpoint - # push writes are saved to next checkpoint - channel_writes, push_writes = ( - [w for w in task.writes if w[0] != PUSH], - [w for w in task.writes if w[0] == PUSH], - ) - if saved and channel_writes: - await checkpointer.aput_writes( - checkpoint_config, channel_writes, task_id - ) + for _, task in tasks: + # channel writes are saved to current checkpoint + channel_writes = [w for w in task.writes if w[0] != PUSH] + if saved and channel_writes: + await checkpointer.aput_writes( + checkpoint_config, channel_writes, task_id + ) + # apply to checkpoint and save mv_writes = apply_writes( checkpoint, channels, [task], checkpointer.get_next_version @@ -1722,18 +1810,45 @@ class Pregel(PregelProtocol): **checkpoint_metadata, "source": "update", "step": step + 1, - "writes": {as_node: values}, + "writes": {k: v for k, v in valid_updates}, "parents": saved.metadata.get("parents", {}) if saved else {}, }, get_new_channel_versions( checkpoint_previous_versions, checkpoint["channel_versions"] ), ) - # save push writes - if push_writes: - await checkpointer.aput_writes(next_config, push_writes, task_id) + + for _, task in tasks: + # save push writes + if push_writes := [w for w in task.writes if w[0] == PUSH]: + await checkpointer.aput_writes(next_config, push_writes, task_id) + return patch_checkpoint_map(next_config, saved.metadata if saved else None) + def update_state( + self, + config: RunnableConfig, + values: Optional[Union[dict[str, Any], Any]], + as_node: Optional[str] = None, + ) -> RunnableConfig: + """Update the state of the graph with the given values, as if they came from + node `as_node`. If `as_node` is not provided, it will be set to the last node + that updated the state, if not ambiguous. + """ + return self.bulk_update_state(config, [(values, as_node)]) + + async def aupdate_state( + self, + config: RunnableConfig, + values: dict[str, Any] | Any, + as_node: Optional[str] = None, + ) -> RunnableConfig: + """Update the state of the graph asynchronously with the given values, as if they came from + node `as_node`. If `as_node` is not provided, it will be set to the last node + that updated the state, if not ambiguous. + """ + return await self.bulk_update_state(config, [(values, as_node)]) + def _defaults( self, config: RunnableConfig, diff --git a/libs/langgraph/langgraph/pregel/protocol.py b/libs/langgraph/langgraph/pregel/protocol.py index ac046e949..43dd55652 100644 --- a/libs/langgraph/langgraph/pregel/protocol.py +++ b/libs/langgraph/langgraph/pregel/protocol.py @@ -12,7 +12,7 @@ from langchain_core.runnables import Runnable, RunnableConfig from langchain_core.runnables.graph import Graph as DrawableGraph from typing_extensions import Self -from langgraph.pregel.types import All, StateSnapshot, StreamMode +from langgraph.pregel.types import All, BulkUpdate, StateSnapshot, StreamMode class PregelProtocol( @@ -69,6 +69,20 @@ class PregelProtocol( limit: Optional[int] = None, ) -> AsyncIterator[StateSnapshot]: ... + @abstractmethod + def bulk_update_state( + self, + config: RunnableConfig, + updates: Sequence[BulkUpdate], + ) -> RunnableConfig: ... + + @abstractmethod + async def abulk_update_state( + self, + config: RunnableConfig, + updates: Sequence[BulkUpdate], + ) -> RunnableConfig: ... + @abstractmethod def update_state( self, diff --git a/libs/langgraph/langgraph/pregel/types.py b/libs/langgraph/langgraph/pregel/types.py index 7a72b88c9..251d59034 100644 --- a/libs/langgraph/langgraph/pregel/types.py +++ b/libs/langgraph/langgraph/pregel/types.py @@ -2,6 +2,7 @@ from langgraph.types import ( All, + BulkUpdate, CachePolicy, PregelExecutableTask, PregelTask, @@ -14,6 +15,7 @@ from langgraph.types import ( __all__ = [ "All", + "BulkUpdate", "CachePolicy", "PregelExecutableTask", "PregelTask", diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index 4c8cf6da4..0d530ddb5 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -133,6 +133,11 @@ class Interrupt: when: Literal["during"] = dataclasses.field(default="during", repr=False) +class BulkUpdate(NamedTuple): + values: dict[str, Any] | None + as_node: Optional[str] + + class PregelTask(NamedTuple): id: str name: str From 1e751a22566e30638221f3865eeb3de5ea685068 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Fri, 7 Mar 2025 19:23:29 +0100 Subject: [PATCH 08/32] Fix typo --- 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 e84c0b208..5b37e80fd 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -1485,7 +1485,7 @@ class Pregel(PregelProtocol): ), ) - for _, task in tasks: + for task_id, task in tasks: if push_writes := [w for w in task.writes if w[0] == PUSH]: checkpointer.put_writes(next_config, push_writes, task_id) @@ -1818,7 +1818,7 @@ class Pregel(PregelProtocol): ), ) - for _, task in tasks: + for task_id, task in tasks: # save push writes if push_writes := [w for w in task.writes if w[0] == PUSH]: await checkpointer.aput_writes(next_config, push_writes, task_id) From 764929afd93e80ca7571d9e6fda6ea6df7450d9f Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Fri, 7 Mar 2025 19:32:24 +0100 Subject: [PATCH 09/32] Fix lint issues --- libs/langgraph/langgraph/pregel/__init__.py | 34 ++++++++++++--------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 5b37e80fd..69d69bfab 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -1415,7 +1415,7 @@ class Pregel(PregelProtocol): valid_updates.append((values, as_node)) - tasks: list[tuple[str, PregelTaskWrites]] = [] + run_tasks: list[tuple[str, PregelTaskWrites]] = [] for values, as_node in valid_updates: # create task to run all writers of the chosen node writers = self.nodes[as_node].flat_writers @@ -1424,9 +1424,9 @@ class Pregel(PregelProtocol): writes: deque[tuple[str, Any]] = deque() task = PregelTaskWrites((), as_node, writes, [INTERRUPT]) task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT)) - tasks.append((task_id, task)) + run_tasks.append((task_id, task)) - for _, task in tasks: + for _, task in run_tasks: run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] # execute task run.invoke( @@ -1455,7 +1455,7 @@ class Pregel(PregelProtocol): ) # save task writes - for _, task in tasks: + for _, task in run_tasks: channel_writes = [w for w in task.writes if w[0] != PUSH] # channel writes are saved to current checkpoint @@ -1464,7 +1464,10 @@ class Pregel(PregelProtocol): # apply to checkpoint and save mv_writes = apply_writes( - checkpoint, channels, tasks, checkpointer.get_next_version + checkpoint, + channels, + [t for _, t in run_tasks], + checkpointer.get_next_version, ) assert not mv_writes, "Can't write to SharedValues from update_state" @@ -1485,7 +1488,7 @@ class Pregel(PregelProtocol): ), ) - for task_id, task in tasks: + for task_id, task in run_tasks: if push_writes := [w for w in task.writes if w[0] == PUSH]: checkpointer.put_writes(next_config, push_writes, task_id) @@ -1747,7 +1750,7 @@ class Pregel(PregelProtocol): valid_updates.append((values, as_node)) - tasks: list[tuple[str, PregelTaskWrites]] = [] + run_tasks: list[tuple[str, PregelTaskWrites]] = [] for values, as_node in valid_updates: # create task to run all writers of the chosen node @@ -1757,9 +1760,9 @@ class Pregel(PregelProtocol): writes: deque[tuple[str, Any]] = deque() task = PregelTaskWrites((), as_node, writes, [INTERRUPT]) task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT)) - tasks.append((task_id, task)) + run_tasks.append((task_id, task)) - for _, task in tasks: + for _, task in run_tasks: run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] # execute task await run.ainvoke( @@ -1788,7 +1791,7 @@ class Pregel(PregelProtocol): ) # save task writes - for _, task in tasks: + for _, task in run_tasks: # channel writes are saved to current checkpoint channel_writes = [w for w in task.writes if w[0] != PUSH] if saved and channel_writes: @@ -1798,7 +1801,10 @@ class Pregel(PregelProtocol): # apply to checkpoint and save mv_writes = apply_writes( - checkpoint, channels, [task], checkpointer.get_next_version + checkpoint, + channels, + [t for _, t in run_tasks], + checkpointer.get_next_version, ) assert not mv_writes, "Can't write to SharedValues from update_state" checkpoint = create_checkpoint(checkpoint, channels, step + 1) @@ -1818,7 +1824,7 @@ class Pregel(PregelProtocol): ), ) - for task_id, task in tasks: + for task_id, task in run_tasks: # save push writes if push_writes := [w for w in task.writes if w[0] == PUSH]: await checkpointer.aput_writes(next_config, push_writes, task_id) @@ -1835,7 +1841,7 @@ class Pregel(PregelProtocol): node `as_node`. If `as_node` is not provided, it will be set to the last node that updated the state, if not ambiguous. """ - return self.bulk_update_state(config, [(values, as_node)]) + return self.bulk_update_state(config, [BulkUpdate(values, as_node)]) async def aupdate_state( self, @@ -1847,7 +1853,7 @@ class Pregel(PregelProtocol): node `as_node`. If `as_node` is not provided, it will be set to the last node that updated the state, if not ambiguous. """ - return await self.bulk_update_state(config, [(values, as_node)]) + return await self.abulk_update_state(config, [BulkUpdate(values, as_node)]) def _defaults( self, From 792cd805a745d35593f54940c41c1c9ca0ffb70f Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Fri, 7 Mar 2025 19:41:05 +0100 Subject: [PATCH 10/32] Fix tests --- libs/langgraph/langgraph/pregel/__init__.py | 52 ++++++++++----------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 69d69bfab..a37f1d951 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -1369,7 +1369,7 @@ class Pregel(PregelProtocol): next_tasks[tid].writes.append((k, v)) if tasks := [t for t in next_tasks.values() if t.writes]: apply_writes(checkpoint, channels, tasks, None) - valid_updates: list[tuple[Optional[dict[str, Any]], str]] = [] + valid_updates: list[tuple[str, Optional[dict[str, Any]]]] = [] if len(updates) == 1: values, as_node = updates[0] @@ -1402,7 +1402,7 @@ class Pregel(PregelProtocol): if as_node not in self.nodes: raise InvalidUpdateError(f"Node {as_node} does not exist") - valid_updates.append((values, as_node)) + valid_updates.append((as_node, values)) else: for values, as_node in updates: if as_node is None: @@ -1413,10 +1413,12 @@ class Pregel(PregelProtocol): if as_node not in self.nodes: raise InvalidUpdateError(f"Node {as_node} does not exist") - valid_updates.append((values, as_node)) + valid_updates.append((as_node, values)) - run_tasks: list[tuple[str, PregelTaskWrites]] = [] - for values, as_node in valid_updates: + run_tasks: list[PregelTaskWrites] = [] + run_task_ids: list[str] = [] + + for as_node, values in valid_updates: # create task to run all writers of the chosen node writers = self.nodes[as_node].flat_writers if not writers: @@ -1424,9 +1426,10 @@ class Pregel(PregelProtocol): writes: deque[tuple[str, Any]] = deque() task = PregelTaskWrites((), as_node, writes, [INTERRUPT]) task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT)) - run_tasks.append((task_id, task)) + run_tasks.append(task) + run_task_ids.append(task_id) - for _, task in run_tasks: + for task in run_tasks: run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] # execute task run.invoke( @@ -1455,7 +1458,7 @@ class Pregel(PregelProtocol): ) # save task writes - for _, task in run_tasks: + for task in run_tasks: channel_writes = [w for w in task.writes if w[0] != PUSH] # channel writes are saved to current checkpoint @@ -1464,10 +1467,7 @@ class Pregel(PregelProtocol): # apply to checkpoint and save mv_writes = apply_writes( - checkpoint, - channels, - [t for _, t in run_tasks], - checkpointer.get_next_version, + checkpoint, channels, run_tasks, checkpointer.get_next_version ) assert not mv_writes, "Can't write to SharedValues from update_state" @@ -1488,7 +1488,8 @@ class Pregel(PregelProtocol): ), ) - for task_id, task in run_tasks: + for task_id, task in zip(run_task_ids, run_tasks): + # save push writes if push_writes := [w for w in task.writes if w[0] == PUSH]: checkpointer.put_writes(next_config, push_writes, task_id) @@ -1706,7 +1707,7 @@ class Pregel(PregelProtocol): next_tasks[tid].writes.append((k, v)) if tasks := [t for t in next_tasks.values() if t.writes]: apply_writes(checkpoint, channels, tasks, None) - valid_updates: list[tuple[Optional[dict[str, Any]], str]] = [] + valid_updates: list[tuple[str, Optional[dict[str, Any]]]] = [] if len(updates) == 1: values, as_node = updates[0] @@ -1737,7 +1738,7 @@ class Pregel(PregelProtocol): if as_node not in self.nodes: raise InvalidUpdateError(f"Node {as_node} does not exist") - valid_updates.append((values, as_node)) + valid_updates.append((as_node, values)) else: for values, as_node in updates: if as_node is None: @@ -1748,11 +1749,12 @@ class Pregel(PregelProtocol): if as_node not in self.nodes: raise InvalidUpdateError(f"Node {as_node} does not exist") - valid_updates.append((values, as_node)) + valid_updates.append((as_node, values)) - run_tasks: list[tuple[str, PregelTaskWrites]] = [] + run_tasks: list[PregelTaskWrites] = [] + run_task_ids: list[str] = [] - for values, as_node in valid_updates: + for as_node, values in valid_updates: # create task to run all writers of the chosen node writers = self.nodes[as_node].flat_writers if not writers: @@ -1760,9 +1762,10 @@ class Pregel(PregelProtocol): writes: deque[tuple[str, Any]] = deque() task = PregelTaskWrites((), as_node, writes, [INTERRUPT]) task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT)) - run_tasks.append((task_id, task)) + run_tasks.append(task) + run_task_ids.append(task_id) - for _, task in run_tasks: + for task in run_tasks: run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] # execute task await run.ainvoke( @@ -1791,7 +1794,7 @@ class Pregel(PregelProtocol): ) # save task writes - for _, task in run_tasks: + for task in run_tasks: # channel writes are saved to current checkpoint channel_writes = [w for w in task.writes if w[0] != PUSH] if saved and channel_writes: @@ -1801,10 +1804,7 @@ class Pregel(PregelProtocol): # apply to checkpoint and save mv_writes = apply_writes( - checkpoint, - channels, - [t for _, t in run_tasks], - checkpointer.get_next_version, + checkpoint, channels, run_tasks, checkpointer.get_next_version ) assert not mv_writes, "Can't write to SharedValues from update_state" checkpoint = create_checkpoint(checkpoint, channels, step + 1) @@ -1824,7 +1824,7 @@ class Pregel(PregelProtocol): ), ) - for task_id, task in run_tasks: + for task_id, task in zip(run_task_ids, run_tasks): # save push writes if push_writes := [w for w in task.writes if w[0] == PUSH]: await checkpointer.aput_writes(next_config, push_writes, task_id) From c34c7987633d437dd3bcd5f52dc79b6e0fb7d0f8 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Fri, 7 Mar 2025 19:57:59 +0100 Subject: [PATCH 11/32] Add tests --- libs/langgraph/langgraph/pregel/__init__.py | 20 +++--- libs/langgraph/langgraph/types.py | 2 +- libs/langgraph/tests/test_pregel.py | 67 +++++++++++++++++++++ libs/langgraph/tests/test_pregel_async.py | 67 +++++++++++++++++++++ 4 files changed, 147 insertions(+), 9 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index a37f1d951..e9b92ac37 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -1221,7 +1221,7 @@ class Pregel(PregelProtocol): # no values as END, just clear all tasks if values is None and as_node == END: if len(updates) > 1: - raise ValueError( + raise InvalidUpdateError( "Cannot apply multiple updates when clearing state" ) @@ -1284,7 +1284,7 @@ class Pregel(PregelProtocol): # no values, empty checkpoint if values is None and as_node is None: if len(updates) > 1: - raise ValueError( + raise InvalidUpdateError( "Cannot create empty checkpoint with multiple updates" ) @@ -1308,7 +1308,9 @@ class Pregel(PregelProtocol): # no values, copy checkpoint if values is None and as_node == "__copy__": if len(updates) > 1: - raise ValueError("Cannot copy checkpoint with multiple updates") + raise InvalidUpdateError( + "Cannot copy checkpoint with multiple updates" + ) next_checkpoint = create_checkpoint(checkpoint, None, step) # copy checkpoint @@ -1429,8 +1431,8 @@ class Pregel(PregelProtocol): run_tasks.append(task) run_task_ids.append(task_id) - for task in run_tasks: run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] + # execute task run.invoke( values, @@ -1560,7 +1562,7 @@ class Pregel(PregelProtocol): # no values, just clear all tasks if values is None and as_node == END: if len(updates) > 1: - raise ValueError( + raise InvalidUpdateError( "Cannot apply multiple updates when clearing state" ) @@ -1623,7 +1625,7 @@ class Pregel(PregelProtocol): # no values, empty checkpoint if values is None and as_node is None: if len(updates) > 1: - raise ValueError( + raise InvalidUpdateError( "Cannot create empty checkpoint with multiple updates" ) @@ -1647,7 +1649,9 @@ class Pregel(PregelProtocol): # no values, copy checkpoint if values is None and as_node == "__copy__": if len(updates) > 1: - raise ValueError("Cannot copy checkpoint with multiple updates") + raise InvalidUpdateError( + "Cannot copy checkpoint with multiple updates" + ) next_checkpoint = create_checkpoint(checkpoint, None, step) # copy checkpoint @@ -1765,8 +1769,8 @@ class Pregel(PregelProtocol): run_tasks.append(task) run_task_ids.append(task_id) - for task in run_tasks: run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] + # execute task await run.ainvoke( values, diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index 0d530ddb5..4cca670d6 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -135,7 +135,7 @@ class Interrupt: class BulkUpdate(NamedTuple): values: dict[str, Any] | None - as_node: Optional[str] + as_node: Optional[str] = None class PregelTask(NamedTuple): diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index d8d51a8a0..8bbeb3a3b 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -7613,3 +7613,70 @@ def test_parallel_interrupts_double( assert invokes == 5 assert len(events) == 5 + + +@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC) +def test_bulk_state_updates( + request: pytest.FixtureRequest, checkpointer_name: str +) -> None: + checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}") + + class State(TypedDict): + foo: str + baz: str + + def node_a(state: State) -> State: + return {"foo": "bar"} + + def node_b(state: State) -> State: + return {"baz": "qux"} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .compile(checkpointer=checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # First update with node_a + graph.bulk_update_state(config, [({"foo": "bar"}, "node_a")]) + + # Then bulk update with both nodes + graph.bulk_update_state( + config, + [ + ({"foo": "updated"}, "node_a"), + ({"baz": "new"}, "node_b"), + ], + ) + + state = graph.get_state(config) + assert state.values == {"foo": "updated", "baz": "new"} + + # Check if there are only two checkpoints + checkpoints = list(checkpointer.list({"configurable": {"thread_id": "1"}})) + assert len(checkpoints) == 2 + assert checkpoints[0].metadata["writes"] == { + "node_a": {"foo": "updated"}, + "node_b": {"baz": "new"}, + } + assert checkpoints[1].metadata["writes"] == {"node_a": {"foo": "bar"}} + + # Should raise error if updating without as_node + with pytest.raises(InvalidUpdateError): + graph.bulk_update_state( + config, + [({"foo": "error"}, None), ({"bar": "error"}, None)], + ) + + # Should raise if no updates are provided + with pytest.raises(ValueError, match="No updates provided"): + graph.bulk_update_state(config, []) + + # Should raise if __end__ or __copy__ update is applied in bulk + with pytest.raises(InvalidUpdateError): + graph.bulk_update_state(config, [(None, "__end__"), (None, "__copy__")]) diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 60cbd2547..f6f38300a 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -7838,3 +7838,70 @@ async def test_handles_multiple_interrupts_from_tasks() -> None: assert len(result) == 2 assert result[0] == "Added James!" assert result[1] == "Added Will!" + + +@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) +async def test_bulk_state_updates( + request: pytest.FixtureRequest, checkpointer_name: str +) -> None: + checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}") + + class State(TypedDict): + foo: str + baz: str + + def node_a(state: State) -> State: + return {"foo": "bar"} + + def node_b(state: State) -> State: + return {"baz": "qux"} + + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .compile(checkpointer=checkpointer) + ) + + config = {"configurable": {"thread_id": "1"}} + + # First update with node_a + await graph.abulk_update_state(config, [({"foo": "bar"}, "node_a")]) + + # Then bulk update with both nodes + await graph.abulk_update_state( + config, + [ + ({"foo": "updated"}, "node_a"), + ({"baz": "new"}, "node_b"), + ], + ) + + state = await graph.aget_state(config) + assert state.values == {"foo": "updated", "baz": "new"} + + # Check if there are only two checkpoints + checkpoints = list(checkpointer.list({"configurable": {"thread_id": "1"}})) + assert len(checkpoints) == 2 + assert checkpoints[0].metadata["writes"] == { + "node_a": {"foo": "updated"}, + "node_b": {"baz": "new"}, + } + assert checkpoints[1].metadata["writes"] == {"node_a": {"foo": "bar"}} + + # Should raise error if updating without as_node + with pytest.raises(InvalidUpdateError): + await graph.abulk_update_state( + config, + [({"foo": "error"}, None), ({"bar": "error"}, None)], + ) + + # Should raise if no updates are provided + with pytest.raises(ValueError, match="No updates provided"): + await graph.abulk_update_state(config, []) + + # Should raise if __end__ or __copy__ update is applied in bulk + with pytest.raises(InvalidUpdateError): + await graph.abulk_update_state(config, [(None, "__end__"), (None, "__copy__")]) From b7fb8e6afb09f4dcc361eb6d464fe5ba37698090 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Fri, 7 Mar 2025 19:59:47 +0100 Subject: [PATCH 12/32] Fix types --- libs/langgraph/langgraph/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index 4cca670d6..7861ab482 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -134,7 +134,7 @@ class Interrupt: class BulkUpdate(NamedTuple): - values: dict[str, Any] | None + values: Optional[dict[str, Any]] as_node: Optional[str] = None From 68a5c3f4c7b79d21ae4fa6b76a2f48fc721e8e84 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Fri, 7 Mar 2025 20:13:18 +0100 Subject: [PATCH 13/32] Implement batch events for RemotePregel --- libs/langgraph/langgraph/pregel/remote.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/libs/langgraph/langgraph/pregel/remote.py b/libs/langgraph/langgraph/pregel/remote.py index 6ba547b14..64fb4edb6 100644 --- a/libs/langgraph/langgraph/pregel/remote.py +++ b/libs/langgraph/langgraph/pregel/remote.py @@ -457,6 +457,20 @@ class RemoteGraph(PregelProtocol): for state in states: yield self._create_state_snapshot(state) + def bulk_update_state( + self, + config: RunnableConfig, + updates: list[tuple[Optional[dict[str, Any]], Optional[str]]], + ) -> RunnableConfig: + raise NotImplementedError + + async def abulk_update_state( + self, + config: RunnableConfig, + updates: list[tuple[Optional[dict[str, Any]], Optional[str]]], + ) -> RunnableConfig: + raise NotImplementedError + def update_state( self, config: RunnableConfig, From 5bfb3bb88246add31e4e2c94c989ba0a27bf5869 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Fri, 7 Mar 2025 20:15:24 +0100 Subject: [PATCH 14/32] Avoid running with shallow checkpointer --- libs/langgraph/tests/test_pregel.py | 2 +- libs/langgraph/tests/test_pregel_async.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 8bbeb3a3b..26234c27c 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -7615,7 +7615,7 @@ def test_parallel_interrupts_double( assert len(events) == 5 -@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC) +@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_SYNC) def test_bulk_state_updates( request: pytest.FixtureRequest, checkpointer_name: str ) -> None: diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index f6f38300a..d251b4070 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -7840,7 +7840,7 @@ async def test_handles_multiple_interrupts_from_tasks() -> None: assert result[1] == "Added Will!" -@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC) +@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC) async def test_bulk_state_updates( request: pytest.FixtureRequest, checkpointer_name: str ) -> None: From c758954519623a362fdaf7d53fe6283ab16c861b Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Mon, 10 Mar 2025 17:50:29 +0100 Subject: [PATCH 15/32] Use awith_checkpointer instead --- libs/langgraph/tests/test_pregel_async.py | 111 +++++++++++----------- 1 file changed, 56 insertions(+), 55 deletions(-) diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index d251b4070..b77cf39a4 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -7844,64 +7844,65 @@ async def test_handles_multiple_interrupts_from_tasks() -> None: async def test_bulk_state_updates( request: pytest.FixtureRequest, checkpointer_name: str ) -> None: - checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}") + async with awith_checkpointer(checkpointer_name) as checkpointer: + class State(TypedDict): + foo: str + baz: str - class State(TypedDict): - foo: str - baz: str + def node_a(state: State) -> State: + return {"foo": "bar"} - def node_a(state: State) -> State: - return {"foo": "bar"} + def node_b(state: State) -> State: + return {"baz": "qux"} - def node_b(state: State) -> State: - return {"baz": "qux"} - - graph = ( - StateGraph(State) - .add_node("node_a", node_a) - .add_node("node_b", node_b) - .add_edge(START, "node_a") - .add_edge("node_a", "node_b") - .compile(checkpointer=checkpointer) - ) - - config = {"configurable": {"thread_id": "1"}} - - # First update with node_a - await graph.abulk_update_state(config, [({"foo": "bar"}, "node_a")]) - - # Then bulk update with both nodes - await graph.abulk_update_state( - config, - [ - ({"foo": "updated"}, "node_a"), - ({"baz": "new"}, "node_b"), - ], - ) - - state = await graph.aget_state(config) - assert state.values == {"foo": "updated", "baz": "new"} - - # Check if there are only two checkpoints - checkpoints = list(checkpointer.list({"configurable": {"thread_id": "1"}})) - assert len(checkpoints) == 2 - assert checkpoints[0].metadata["writes"] == { - "node_a": {"foo": "updated"}, - "node_b": {"baz": "new"}, - } - assert checkpoints[1].metadata["writes"] == {"node_a": {"foo": "bar"}} - - # Should raise error if updating without as_node - with pytest.raises(InvalidUpdateError): - await graph.abulk_update_state( - config, - [({"foo": "error"}, None), ({"bar": "error"}, None)], + graph = ( + StateGraph(State) + .add_node("node_a", node_a) + .add_node("node_b", node_b) + .add_edge(START, "node_a") + .add_edge("node_a", "node_b") + .compile(checkpointer=checkpointer) ) - # Should raise if no updates are provided - with pytest.raises(ValueError, match="No updates provided"): - await graph.abulk_update_state(config, []) + config = {"configurable": {"thread_id": "1"}} - # Should raise if __end__ or __copy__ update is applied in bulk - with pytest.raises(InvalidUpdateError): - await graph.abulk_update_state(config, [(None, "__end__"), (None, "__copy__")]) + # First update with node_a + await graph.abulk_update_state(config, [({"foo": "bar"}, "node_a")]) + + # Then bulk update with both nodes + await graph.abulk_update_state( + config, + [ + ({"foo": "updated"}, "node_a"), + ({"baz": "new"}, "node_b"), + ], + ) + + state = await graph.aget_state(config) + assert state.values == {"foo": "updated", "baz": "new"} + + # Check if there are only two checkpoints + checkpoints = list(checkpointer.list({"configurable": {"thread_id": "1"}})) + assert len(checkpoints) == 2 + assert checkpoints[0].metadata["writes"] == { + "node_a": {"foo": "updated"}, + "node_b": {"baz": "new"}, + } + assert checkpoints[1].metadata["writes"] == {"node_a": {"foo": "bar"}} + + # Should raise error if updating without as_node + with pytest.raises(InvalidUpdateError): + await graph.abulk_update_state( + config, + [({"foo": "error"}, None), ({"bar": "error"}, None)], + ) + + # Should raise if no updates are provided + with pytest.raises(ValueError, match="No updates provided"): + await graph.abulk_update_state(config, []) + + # Should raise if __end__ or __copy__ update is applied in bulk + with pytest.raises(InvalidUpdateError): + await graph.abulk_update_state( + config, [(None, "__end__"), (None, "__copy__")] + ) From 199ab464292e45db96b031a6693ffb309c1fb980 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Mon, 10 Mar 2025 18:10:07 +0100 Subject: [PATCH 16/32] Avoid using checkpointer.list in async --- libs/langgraph/tests/test_pregel_async.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index b77cf39a4..e6f541ae7 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -7841,10 +7841,9 @@ async def test_handles_multiple_interrupts_from_tasks() -> None: @pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC) -async def test_bulk_state_updates( - request: pytest.FixtureRequest, checkpointer_name: str -) -> None: +async def test_bulk_state_updates(checkpointer_name: str) -> None: async with awith_checkpointer(checkpointer_name) as checkpointer: + class State(TypedDict): foo: str baz: str @@ -7882,7 +7881,9 @@ async def test_bulk_state_updates( assert state.values == {"foo": "updated", "baz": "new"} # Check if there are only two checkpoints - checkpoints = list(checkpointer.list({"configurable": {"thread_id": "1"}})) + checkpoints = [ + c async for c in checkpointer.alist({"configurable": {"thread_id": "1"}}) + ] assert len(checkpoints) == 2 assert checkpoints[0].metadata["writes"] == { "node_a": {"foo": "updated"}, From 4b1d6d2aebb84b8ed439b877681bfc5d9717ae1a Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Mon, 10 Mar 2025 19:22:10 +0100 Subject: [PATCH 17/32] Rename to StateUpdate --- libs/langgraph/langgraph/pregel/__init__.py | 10 +++++----- libs/langgraph/langgraph/pregel/protocol.py | 6 +++--- libs/langgraph/langgraph/pregel/types.py | 4 ++-- libs/langgraph/langgraph/types.py | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index e9b92ac37..7ce7eeebb 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -106,10 +106,10 @@ from langgraph.pregel.write import ChannelWrite, ChannelWriteEntry from langgraph.store.base import BaseStore from langgraph.types import ( All, - BulkUpdate, Checkpointer, LoopProtocol, StateSnapshot, + StateUpdate, StreamChunk, StreamMode, ) @@ -1166,7 +1166,7 @@ class Pregel(PregelProtocol): def bulk_update_state( self, config: RunnableConfig, - updates: list[BulkUpdate], + updates: list[StateUpdate], ) -> RunnableConfig: """Apply updates to the graph state in bulk. Requires a checkpointer to be set.""" @@ -1500,7 +1500,7 @@ class Pregel(PregelProtocol): async def abulk_update_state( self, config: RunnableConfig, - updates: list[BulkUpdate], + updates: list[StateUpdate], ) -> RunnableConfig: """Apply updates to the graph state in bulk. Requires a checkpointer to be set.""" @@ -1845,7 +1845,7 @@ class Pregel(PregelProtocol): node `as_node`. If `as_node` is not provided, it will be set to the last node that updated the state, if not ambiguous. """ - return self.bulk_update_state(config, [BulkUpdate(values, as_node)]) + return self.bulk_update_state(config, [StateUpdate(values, as_node)]) async def aupdate_state( self, @@ -1857,7 +1857,7 @@ class Pregel(PregelProtocol): node `as_node`. If `as_node` is not provided, it will be set to the last node that updated the state, if not ambiguous. """ - return await self.abulk_update_state(config, [BulkUpdate(values, as_node)]) + return await self.abulk_update_state(config, [StateUpdate(values, as_node)]) def _defaults( self, diff --git a/libs/langgraph/langgraph/pregel/protocol.py b/libs/langgraph/langgraph/pregel/protocol.py index 43dd55652..9a5f56b76 100644 --- a/libs/langgraph/langgraph/pregel/protocol.py +++ b/libs/langgraph/langgraph/pregel/protocol.py @@ -12,7 +12,7 @@ from langchain_core.runnables import Runnable, RunnableConfig from langchain_core.runnables.graph import Graph as DrawableGraph from typing_extensions import Self -from langgraph.pregel.types import All, BulkUpdate, StateSnapshot, StreamMode +from langgraph.pregel.types import All, StateSnapshot, StateUpdate, StreamMode class PregelProtocol( @@ -73,14 +73,14 @@ class PregelProtocol( def bulk_update_state( self, config: RunnableConfig, - updates: Sequence[BulkUpdate], + updates: Sequence[StateUpdate], ) -> RunnableConfig: ... @abstractmethod async def abulk_update_state( self, config: RunnableConfig, - updates: Sequence[BulkUpdate], + updates: Sequence[StateUpdate], ) -> RunnableConfig: ... @abstractmethod diff --git a/libs/langgraph/langgraph/pregel/types.py b/libs/langgraph/langgraph/pregel/types.py index 251d59034..212c0c4af 100644 --- a/libs/langgraph/langgraph/pregel/types.py +++ b/libs/langgraph/langgraph/pregel/types.py @@ -2,12 +2,12 @@ from langgraph.types import ( All, - BulkUpdate, CachePolicy, PregelExecutableTask, PregelTask, RetryPolicy, StateSnapshot, + StateUpdate, StreamMode, StreamWriter, default_retry_on, @@ -15,7 +15,7 @@ from langgraph.types import ( __all__ = [ "All", - "BulkUpdate", + "StateUpdate", "CachePolicy", "PregelExecutableTask", "PregelTask", diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index 7861ab482..00339119d 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -133,7 +133,7 @@ class Interrupt: when: Literal["during"] = dataclasses.field(default="during", repr=False) -class BulkUpdate(NamedTuple): +class StateUpdate(NamedTuple): values: Optional[dict[str, Any]] as_node: Optional[str] = None From 8ffe9634b7172f96680b5c0c8b14cad9ee669dcc Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Mon, 10 Mar 2025 19:42:02 +0100 Subject: [PATCH 18/32] Clearer breakdown --- 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 7ce7eeebb..18541289a 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -1482,7 +1482,7 @@ class Pregel(PregelProtocol): **checkpoint_metadata, "source": "update", "step": step + 1, - "writes": {k: v for k, v in valid_updates}, + "writes": {as_node: values for as_node, values in valid_updates}, "parents": saved.metadata.get("parents", {}) if saved else {}, }, get_new_channel_versions( @@ -1820,7 +1820,7 @@ class Pregel(PregelProtocol): **checkpoint_metadata, "source": "update", "step": step + 1, - "writes": {k: v for k, v in valid_updates}, + "writes": {as_node: values for as_node, values in valid_updates}, "parents": saved.metadata.get("parents", {}) if saved else {}, }, get_new_channel_versions( From 62aa66cd4bb0e84c5ebedaa40f4e1092a8664c31 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Mon, 10 Mar 2025 19:46:55 +0100 Subject: [PATCH 19/32] Add better docstrings --- libs/langgraph/langgraph/pregel/__init__.py | 30 +++++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 18541289a..0a8213da4 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -1168,7 +1168,19 @@ class Pregel(PregelProtocol): config: RunnableConfig, updates: list[StateUpdate], ) -> RunnableConfig: - """Apply updates to the graph state in bulk. Requires a checkpointer to be set.""" + """Apply updates to the graph state in bulk. Requires a checkpointer to be set. + + Args: + config: The config to apply the updates to. + updates: A list of updates to apply to the graph state. Each update is a tuple of the form `(values, as_node)`. + + Raises: + ValueError: If no checkpointer is set or no updates are provided. + InvalidUpdateError: If an invalid update is provided. + + Returns: + RunnableConfig: The updated config. + """ checkpointer: Optional[BaseCheckpointSaver] = ensure_config(config)[CONF].get( CONFIG_KEY_CHECKPOINTER, self.checkpointer @@ -1502,12 +1514,20 @@ class Pregel(PregelProtocol): config: RunnableConfig, updates: list[StateUpdate], ) -> RunnableConfig: - """Apply updates to the graph state in bulk. Requires a checkpointer to be set.""" + """Apply updates to the graph state in bulk. Requires a checkpointer to be set. - """Update the state of the graph asynchronously with the given values, as if they came from - node `as_node`. If `as_node` is not provided, it will be set to the last node - that updated the state, if not ambiguous. + Args: + config: The config to apply the updates to. + updates: A list of updates to apply to the graph state. Each update is a tuple of the form `(values, as_node)`. + + Raises: + ValueError: If no checkpointer is set or no updates are provided. + InvalidUpdateError: If an invalid update is provided. + + Returns: + RunnableConfig: The updated config. """ + checkpointer: Optional[BaseCheckpointSaver] = ensure_config(config)[CONF].get( CONFIG_KEY_CHECKPOINTER, self.checkpointer ) From 06f2eef74c8adf36c7421b075481888e2b12063a Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Mon, 10 Mar 2025 19:52:32 +0100 Subject: [PATCH 20/32] Fix bug with stale task_id --- 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 0a8213da4..964a860f7 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -1472,7 +1472,7 @@ class Pregel(PregelProtocol): ) # save task writes - for task in run_tasks: + for task_id, task in zip(run_task_ids, run_tasks): channel_writes = [w for w in task.writes if w[0] != PUSH] # channel writes are saved to current checkpoint @@ -1818,7 +1818,7 @@ class Pregel(PregelProtocol): ) # save task writes - for task in run_tasks: + for task_id, task in zip(run_task_ids, run_tasks): # channel writes are saved to current checkpoint channel_writes = [w for w in task.writes if w[0] != PUSH] if saved and channel_writes: From 2223c82606917fe6be51fb1e74d621c4da33814d Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Fri, 14 Mar 2025 01:52:18 +0100 Subject: [PATCH 21/32] Update to match JS --- libs/langgraph/langgraph/pregel/__init__.py | 869 +++++++++++++------- libs/langgraph/tests/test_pregel.py | 68 +- libs/langgraph/tests/test_pregel_async.py | 66 +- 3 files changed, 674 insertions(+), 329 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 964a860f7..687f59161 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -1166,13 +1166,14 @@ class Pregel(PregelProtocol): def bulk_update_state( self, config: RunnableConfig, - updates: list[StateUpdate], + supersteps: Sequence[Sequence[StateUpdate]], ) -> RunnableConfig: """Apply updates to the graph state in bulk. Requires a checkpointer to be set. Args: config: The config to apply the updates to. - updates: A list of updates to apply to the graph state. Each update is a tuple of the form `(values, as_node)`. + supersteps: A list of supersteps, each including a list of updates to apply sequentially to a graph state. + Each update is a tuple of the form `(values, as_node)`. Raises: ValueError: If no checkpointer is set or no updates are provided. @@ -1188,7 +1189,10 @@ class Pregel(PregelProtocol): if not checkpointer: raise ValueError("No checkpointer set") - if len(updates) == 0: + if len(supersteps) == 0: + raise ValueError("No supersteps provided") + + if any(len(u) == 0 for u in supersteps): raise ValueError("No updates provided") # delegate to subgraph @@ -1201,47 +1205,170 @@ class Pregel(PregelProtocol): for _, pregel in self.get_subgraphs(namespace=recast, recurse=True): return pregel.bulk_update_state( patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}), - updates, + supersteps, ) else: raise ValueError(f"Subgraph {recast} not found") - # get last checkpoint - config = ensure_config(self.config, config) - saved = checkpointer.get_tuple(config) - checkpoint = copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint() - checkpoint_previous_versions = ( - saved.checkpoint["channel_versions"].copy() if saved else {} - ) - step = saved.metadata.get("step", -1) if saved else -1 - # merge configurable fields with previous checkpoint config - checkpoint_config = patch_configurable( - config, - {CONFIG_KEY_CHECKPOINT_NS: config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")}, - ) - checkpoint_metadata = config["metadata"] - if saved: - checkpoint_config = patch_configurable(config, saved.config[CONF]) - checkpoint_metadata = {**saved.metadata, **checkpoint_metadata} - with ChannelsManager( - self.channels, - checkpoint, - LoopProtocol(config=config, step=step + 1, stop=step + 2), - ) as (channels, managed): - values, as_node = updates[0] - - # no values as END, just clear all tasks - if values is None and as_node == END: - if len(updates) > 1: - raise InvalidUpdateError( - "Cannot apply multiple updates when clearing state" + def perform_superstep( + input_config: RunnableConfig, updates: Sequence[StateUpdate] + ) -> RunnableConfig: + # get last checkpoint + config = ensure_config(self.config, input_config) + saved = checkpointer.get_tuple(config) + checkpoint = ( + copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint() + ) + checkpoint_previous_versions = ( + saved.checkpoint["channel_versions"].copy() if saved else {} + ) + step = saved.metadata.get("step", -1) if saved else -1 + # merge configurable fields with previous checkpoint config + checkpoint_config = patch_configurable( + config, + { + CONFIG_KEY_CHECKPOINT_NS: config[CONF].get( + CONFIG_KEY_CHECKPOINT_NS, "" ) + }, + ) + checkpoint_metadata = config["metadata"] + if saved: + checkpoint_config = patch_configurable(config, saved.config[CONF]) + checkpoint_metadata = {**saved.metadata, **checkpoint_metadata} + with ChannelsManager( + self.channels, + checkpoint, + LoopProtocol(config=config, step=step + 1, stop=step + 2), + ) as (channels, managed): + values, as_node = updates[0] - if saved is not None: + # no values as END, just clear all tasks + if values is None and as_node == END: + if len(updates) > 1: + raise InvalidUpdateError( + "Cannot apply multiple updates when clearing state" + ) + + if saved is not None: + # tasks for this checkpoint + next_tasks = prepare_next_tasks( + checkpoint, + saved.pending_writes or [], + self.nodes, + channels, + managed, + saved.config, + saved.metadata.get("step", -1) + 1, + for_execution=True, + store=self.store, + checkpointer=self.checkpointer + if isinstance(self.checkpointer, BaseCheckpointSaver) + else None, + manager=None, + ) + # apply null writes + if null_writes := [ + w[1:] + for w in saved.pending_writes or [] + if w[0] == NULL_TASK_ID + ]: + apply_writes( + saved.checkpoint, + channels, + [PregelTaskWrites((), INPUT, null_writes, [])], + None, + ) + # apply writes from tasks that already ran + for tid, k, v in saved.pending_writes or []: + if k in (ERROR, INTERRUPT, SCHEDULED): + continue + if tid not in next_tasks: + continue + next_tasks[tid].writes.append((k, v)) + # clear all current tasks + apply_writes(checkpoint, channels, next_tasks.values(), None) + # save checkpoint + next_config = checkpointer.put( + checkpoint_config, + create_checkpoint(checkpoint, None, step), + { + **checkpoint_metadata, + "source": "update", + "step": step + 1, + "writes": {}, + "parents": saved.metadata.get("parents", {}) + if saved + else {}, + }, + {}, + ) + return patch_checkpoint_map( + next_config, saved.metadata if saved else None + ) + # no values, empty checkpoint + if values is None and as_node is None: + if len(updates) > 1: + raise InvalidUpdateError( + "Cannot create empty checkpoint with multiple updates" + ) + + next_checkpoint = create_checkpoint(checkpoint, None, step) + # copy checkpoint + next_config = checkpointer.put( + checkpoint_config, + next_checkpoint, + { + **checkpoint_metadata, + "source": "update", + "step": step + 1, + "writes": {}, + "parents": saved.metadata.get("parents", {}) + if saved + else {}, + }, + {}, + ) + return patch_checkpoint_map( + next_config, saved.metadata if saved else None + ) + # no values, copy checkpoint + if values is None and as_node == "__copy__": + if len(updates) > 1: + raise InvalidUpdateError( + "Cannot copy checkpoint with multiple updates" + ) + + next_checkpoint = create_checkpoint(checkpoint, None, step) + # copy checkpoint + next_config = checkpointer.put( + saved.parent_config or saved.config + if saved + else checkpoint_config, + next_checkpoint, + { + **checkpoint_metadata, + "source": "fork", + "step": step + 1, + "parents": saved.metadata.get("parents", {}) + if saved + else {}, + }, + {}, + ) + return patch_checkpoint_map( + next_config, saved.metadata if saved else None + ) + # apply pending writes, if not on specific checkpoint + if ( + CONFIG_KEY_CHECKPOINT_ID not in config[CONF] + and saved is not None + and saved.pending_writes + ): # tasks for this checkpoint next_tasks = prepare_next_tasks( checkpoint, - saved.pending_writes or [], + saved.pending_writes, self.nodes, channels, managed, @@ -1268,37 +1395,18 @@ class Pregel(PregelProtocol): [PregelTaskWrites((), INPUT, null_writes, [])], None, ) - # apply writes from tasks that already ran - for tid, k, v in saved.pending_writes or []: + # apply writes + for tid, k, v in saved.pending_writes: if k in (ERROR, INTERRUPT, SCHEDULED): continue if tid not in next_tasks: continue next_tasks[tid].writes.append((k, v)) - # clear all current tasks - apply_writes(checkpoint, channels, next_tasks.values(), None) - # save checkpoint - next_config = checkpointer.put( - checkpoint_config, - create_checkpoint(checkpoint, None, step), - { - **checkpoint_metadata, - "source": "update", - "step": step + 1, - "writes": {}, - "parents": saved.metadata.get("parents", {}) if saved else {}, - }, - {}, - ) - return patch_checkpoint_map( - next_config, saved.metadata if saved else None - ) - # no values, empty checkpoint - if values is None and as_node is None: - if len(updates) > 1: - raise InvalidUpdateError( - "Cannot create empty checkpoint with multiple updates" - ) + if tasks := [t for t in next_tasks.values() if t.writes]: + apply_writes(checkpoint, channels, tasks, None) + valid_updates: list[tuple[str, Optional[dict[str, Any]]]] = [] + if len(updates) == 1: + values, as_node = updates[0] next_checkpoint = create_checkpoint(checkpoint, None, step) # copy checkpoint @@ -1397,128 +1505,144 @@ class Pregel(PregelProtocol): isinstance(self.input_channels, str) and self.input_channels in self.nodes ): - as_node = self.input_channels - elif as_node is None: - last_seen_by_node = sorted( - (v, n) - for n, seen in checkpoint["versions_seen"].items() - if n in self.nodes - for v in seen.values() - ) - # if two nodes updated the state at the same time, it's ambiguous - if last_seen_by_node: - if len(last_seen_by_node) == 1: - as_node = last_seen_by_node[0][1] - elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]: - as_node = last_seen_by_node[-1][1] - if as_node is None: - raise InvalidUpdateError("Ambiguous update, specify as_node") - if as_node not in self.nodes: - raise InvalidUpdateError(f"Node {as_node} does not exist") - - valid_updates.append((as_node, values)) - else: - for values, as_node in updates: - if as_node is None: - raise InvalidUpdateError( - "as_node is required when applying multiple updates" + if ( + isinstance(self.input_channels, str) + and self.input_channels in self.nodes + ): + as_node = self.input_channels + elif as_node is None: + last_seen_by_node = sorted( + (v, n) + for n, seen in checkpoint["versions_seen"].items() + if n in self.nodes + for v in seen.values() ) - + # if two nodes updated the state at the same time, it's ambiguous + if last_seen_by_node: + if len(last_seen_by_node) == 1: + as_node = last_seen_by_node[0][1] + elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]: + as_node = last_seen_by_node[-1][1] + if as_node is None: + raise InvalidUpdateError("Ambiguous update, specify as_node") if as_node not in self.nodes: raise InvalidUpdateError(f"Node {as_node} does not exist") valid_updates.append((as_node, values)) + else: + for values, as_node in updates: + if as_node is None: + raise InvalidUpdateError( + "as_node is required when applying multiple updates" + ) - run_tasks: list[PregelTaskWrites] = [] - run_task_ids: list[str] = [] + if as_node not in self.nodes: + raise InvalidUpdateError(f"Node {as_node} does not exist") - for as_node, values in valid_updates: - # create task to run all writers of the chosen node - writers = self.nodes[as_node].flat_writers - if not writers: - raise InvalidUpdateError(f"Node {as_node} has no writers") - writes: deque[tuple[str, Any]] = deque() - task = PregelTaskWrites((), as_node, writes, [INTERRUPT]) - task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT)) - run_tasks.append(task) - run_task_ids.append(task_id) + valid_updates.append((as_node, values)) - run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] + run_tasks: list[PregelTaskWrites] = [] + run_task_ids: list[str] = [] - # execute task - run.invoke( - values, - patch_config( - config, - run_name=self.name + "UpdateState", - configurable={ - # deque.extend is thread-safe - CONFIG_KEY_SEND: partial( - local_write, - writes.extend, - self.nodes.keys(), - ), - CONFIG_KEY_READ: partial( - local_read, - step + 1, - checkpoint, - channels, - managed, - task, - config, - ), + for as_node, values in valid_updates: + # create task to run all writers of the chosen node + writers = self.nodes[as_node].flat_writers + if not writers: + raise InvalidUpdateError(f"Node {as_node} has no writers") + writes: deque[tuple[str, Any]] = deque() + task = PregelTaskWrites((), as_node, writes, [INTERRUPT]) + task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT)) + run_tasks.append(task) + run_task_ids.append(task_id) + + run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] + + # execute task + run.invoke( + values, + patch_config( + config, + run_name=self.name + "UpdateState", + configurable={ + # deque.extend is thread-safe + CONFIG_KEY_SEND: partial( + local_write, + writes.extend, + self.nodes.keys(), + ), + CONFIG_KEY_READ: partial( + local_read, + step + 1, + checkpoint, + channels, + managed, + task, + config, + ), + }, + ), + ) + + # save task writes + for task_id, task in zip(run_task_ids, run_tasks): + channel_writes = [w for w in task.writes if w[0] != PUSH] + + # channel writes are saved to current checkpoint + if saved and channel_writes: + checkpointer.put_writes( + checkpoint_config, channel_writes, task_id + ) + + # apply to checkpoint and save + mv_writes = apply_writes( + checkpoint, channels, run_tasks, checkpointer.get_next_version + ) + + assert not mv_writes, "Can't write to SharedValues from update_state" + + checkpoint = create_checkpoint(checkpoint, channels, step + 1) + next_config = checkpointer.put( + checkpoint_config, + checkpoint, + { + **checkpoint_metadata, + "source": "update", + "step": step + 1, + "writes": { + as_node: values for as_node, values in valid_updates }, + "parents": saved.metadata.get("parents", {}) if saved else {}, + }, + get_new_channel_versions( + checkpoint_previous_versions, checkpoint["channel_versions"] ), ) - # save task writes - for task_id, task in zip(run_task_ids, run_tasks): - channel_writes = [w for w in task.writes if w[0] != PUSH] + for task_id, task in zip(run_task_ids, run_tasks): + # save push writes + if push_writes := [w for w in task.writes if w[0] == PUSH]: + checkpointer.put_writes(next_config, push_writes, task_id) - # channel writes are saved to current checkpoint - if saved and channel_writes: - checkpointer.put_writes(checkpoint_config, channel_writes, task_id) + return patch_checkpoint_map( + next_config, saved.metadata if saved else None + ) - # apply to checkpoint and save - mv_writes = apply_writes( - checkpoint, channels, run_tasks, checkpointer.get_next_version - ) - - assert not mv_writes, "Can't write to SharedValues from update_state" - - checkpoint = create_checkpoint(checkpoint, channels, step + 1) - next_config = checkpointer.put( - checkpoint_config, - checkpoint, - { - **checkpoint_metadata, - "source": "update", - "step": step + 1, - "writes": {as_node: values for as_node, values in valid_updates}, - "parents": saved.metadata.get("parents", {}) if saved else {}, - }, - get_new_channel_versions( - checkpoint_previous_versions, checkpoint["channel_versions"] - ), - ) - - for task_id, task in zip(run_task_ids, run_tasks): - # save push writes - if push_writes := [w for w in task.writes if w[0] == PUSH]: - checkpointer.put_writes(next_config, push_writes, task_id) - - return patch_checkpoint_map(next_config, saved.metadata if saved else None) + current_config = config + for superstep in supersteps: + current_config = perform_superstep(current_config, superstep) + return current_config async def abulk_update_state( self, config: RunnableConfig, - updates: list[StateUpdate], + supersteps: Sequence[Sequence[StateUpdate]], ) -> RunnableConfig: """Apply updates to the graph state in bulk. Requires a checkpointer to be set. Args: config: The config to apply the updates to. - updates: A list of updates to apply to the graph state. Each update is a tuple of the form `(values, as_node)`. + supersteps: A list of supersteps, each including a list of updates to apply sequentially to a graph state. + Each update is a tuple of the form `(values, as_node)`. Raises: ValueError: If no checkpointer is set or no updates are provided. @@ -1534,7 +1658,10 @@ class Pregel(PregelProtocol): if not checkpointer: raise ValueError("No checkpointer set") - if len(updates) == 0: + if len(supersteps) == 0: + raise ValueError("No supersteps provided") + + if any(len(u) == 0 for u in supersteps): raise ValueError("No updates provided") # delegate to subgraph @@ -1547,50 +1674,173 @@ class Pregel(PregelProtocol): async for _, pregel in self.aget_subgraphs(namespace=recast, recurse=True): return await pregel.abulk_update_state( patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}), - updates, + supersteps, ) else: raise ValueError(f"Subgraph {recast} not found") - # get last checkpoint - config = ensure_config(self.config, config) - saved = await checkpointer.aget_tuple(config) - checkpoint = copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint() - checkpoint_previous_versions = ( - saved.checkpoint["channel_versions"].copy() if saved else {} - ) - step = saved.metadata.get("step", -1) if saved else -1 - # merge configurable fields with previous checkpoint config - checkpoint_config = patch_configurable( - config, - {CONFIG_KEY_CHECKPOINT_NS: config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")}, - ) - checkpoint_metadata = config["metadata"] - if saved: - checkpoint_config = patch_configurable(config, saved.config[CONF]) - checkpoint_metadata = {**saved.metadata, **checkpoint_metadata} - async with AsyncChannelsManager( - self.channels, - checkpoint, - LoopProtocol(config=config, step=step + 1, stop=step + 2), - ) as ( - channels, - managed, - ): - values, as_node = updates[0] - - # no values, just clear all tasks - if values is None and as_node == END: - if len(updates) > 1: - raise InvalidUpdateError( - "Cannot apply multiple updates when clearing state" + async def aperform_superstep( + input_config: RunnableConfig, updates: Sequence[StateUpdate] + ) -> RunnableConfig: + # get last checkpoint + config = ensure_config(self.config, input_config) + saved = await checkpointer.aget_tuple(config) + checkpoint = ( + copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint() + ) + checkpoint_previous_versions = ( + saved.checkpoint["channel_versions"].copy() if saved else {} + ) + step = saved.metadata.get("step", -1) if saved else -1 + # merge configurable fields with previous checkpoint config + checkpoint_config = patch_configurable( + config, + { + CONFIG_KEY_CHECKPOINT_NS: config[CONF].get( + CONFIG_KEY_CHECKPOINT_NS, "" ) + }, + ) + checkpoint_metadata = config["metadata"] + if saved: + checkpoint_config = patch_configurable(config, saved.config[CONF]) + checkpoint_metadata = {**saved.metadata, **checkpoint_metadata} + async with AsyncChannelsManager( + self.channels, + checkpoint, + LoopProtocol(config=config, step=step + 1, stop=step + 2), + ) as ( + channels, + managed, + ): + values, as_node = updates[0] - if saved is not None: + # no values, just clear all tasks + if values is None and as_node == END: + if len(updates) > 1: + raise InvalidUpdateError( + "Cannot apply multiple updates when clearing state" + ) + + if saved is not None: + # tasks for this checkpoint + next_tasks = prepare_next_tasks( + checkpoint, + saved.pending_writes or [], + self.nodes, + channels, + managed, + saved.config, + saved.metadata.get("step", -1) + 1, + for_execution=True, + store=self.store, + checkpointer=self.checkpointer + if isinstance(self.checkpointer, BaseCheckpointSaver) + else None, + manager=None, + ) + # apply null writes + if null_writes := [ + w[1:] + for w in saved.pending_writes or [] + if w[0] == NULL_TASK_ID + ]: + apply_writes( + saved.checkpoint, + channels, + [PregelTaskWrites((), INPUT, null_writes, [])], + None, + ) + # apply writes from tasks that already ran + for tid, k, v in saved.pending_writes or []: + if k in (ERROR, INTERRUPT, SCHEDULED): + continue + if tid not in next_tasks: + continue + next_tasks[tid].writes.append((k, v)) + # clear all current tasks + apply_writes(checkpoint, channels, next_tasks.values(), None) + # save checkpoint + next_config = await checkpointer.aput( + checkpoint_config, + create_checkpoint(checkpoint, None, step), + { + **checkpoint_metadata, + "source": "update", + "step": step + 1, + "writes": {}, + "parents": saved.metadata.get("parents", {}) + if saved + else {}, + }, + {}, + ) + return patch_checkpoint_map( + next_config, saved.metadata if saved else None + ) + # no values, empty checkpoint + if values is None and as_node is None: + if len(updates) > 1: + raise InvalidUpdateError( + "Cannot create empty checkpoint with multiple updates" + ) + + next_checkpoint = create_checkpoint(checkpoint, None, step) + # copy checkpoint + next_config = await checkpointer.aput( + checkpoint_config, + next_checkpoint, + { + **checkpoint_metadata, + "source": "update", + "step": step + 1, + "writes": {}, + "parents": saved.metadata.get("parents", {}) + if saved + else {}, + }, + {}, + ) + return patch_checkpoint_map( + next_config, saved.metadata if saved else None + ) + # no values, copy checkpoint + if values is None and as_node == "__copy__": + if len(updates) > 1: + raise InvalidUpdateError( + "Cannot copy checkpoint with multiple updates" + ) + + next_checkpoint = create_checkpoint(checkpoint, None, step) + # copy checkpoint + next_config = await checkpointer.aput( + saved.parent_config or saved.config + if saved + else checkpoint_config, + next_checkpoint, + { + **checkpoint_metadata, + "source": "fork", + "step": step + 1, + "parents": saved.metadata.get("parents", {}) + if saved + else {}, + }, + {}, + ) + return patch_checkpoint_map( + next_config, saved.metadata if saved else None + ) + # apply pending writes, if not on specific checkpoint + if ( + CONFIG_KEY_CHECKPOINT_ID not in config[CONF] + and saved is not None + and saved.pending_writes + ): # tasks for this checkpoint next_tasks = prepare_next_tasks( checkpoint, - saved.pending_writes or [], + saved.pending_writes, self.nodes, channels, managed, @@ -1617,61 +1867,18 @@ class Pregel(PregelProtocol): [PregelTaskWrites((), INPUT, null_writes, [])], None, ) - # apply writes from tasks that already ran - for tid, k, v in saved.pending_writes or []: + for tid, k, v in saved.pending_writes: if k in (ERROR, INTERRUPT, SCHEDULED): continue if tid not in next_tasks: continue next_tasks[tid].writes.append((k, v)) - # clear all current tasks - apply_writes(checkpoint, channels, next_tasks.values(), None) - # save checkpoint - next_config = await checkpointer.aput( - checkpoint_config, - create_checkpoint(checkpoint, None, step), - { - **checkpoint_metadata, - "source": "update", - "step": step + 1, - "writes": {}, - "parents": saved.metadata.get("parents", {}) if saved else {}, - }, - {}, - ) - return patch_checkpoint_map( - next_config, saved.metadata if saved else None - ) - # no values, empty checkpoint - if values is None and as_node is None: - if len(updates) > 1: - raise InvalidUpdateError( - "Cannot create empty checkpoint with multiple updates" - ) + if tasks := [t for t in next_tasks.values() if t.writes]: + apply_writes(checkpoint, channels, tasks, None) + valid_updates: list[tuple[str, Optional[dict[str, Any]]]] = [] - next_checkpoint = create_checkpoint(checkpoint, None, step) - # copy checkpoint - next_config = await checkpointer.aput( - checkpoint_config, - next_checkpoint, - { - **checkpoint_metadata, - "source": "update", - "step": step + 1, - "writes": {}, - "parents": saved.metadata.get("parents", {}) if saved else {}, - }, - {}, - ) - return patch_checkpoint_map( - next_config, saved.metadata if saved else None - ) - # no values, copy checkpoint - if values is None and as_node == "__copy__": - if len(updates) > 1: - raise InvalidUpdateError( - "Cannot copy checkpoint with multiple updates" - ) + if len(updates) == 1: + values, as_node = updates[0] next_checkpoint = create_checkpoint(checkpoint, None, step) # copy checkpoint @@ -1769,91 +1976,121 @@ class Pregel(PregelProtocol): raise InvalidUpdateError( "as_node is required when applying multiple updates" ) + # if two nodes updated the state at the same time, it's ambiguous + if last_seen_by_node: + if len(last_seen_by_node) == 1: + as_node = last_seen_by_node[0][1] + elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]: + as_node = last_seen_by_node[-1][1] + if as_node is None: + raise InvalidUpdateError("Ambiguous update, specify as_node") if as_node not in self.nodes: raise InvalidUpdateError(f"Node {as_node} does not exist") valid_updates.append((as_node, values)) + else: + for values, as_node in updates: + if as_node is None: + raise InvalidUpdateError( + "as_node is required when applying multiple updates" + ) - run_tasks: list[PregelTaskWrites] = [] - run_task_ids: list[str] = [] + if as_node not in self.nodes: + raise InvalidUpdateError(f"Node {as_node} does not exist") - for as_node, values in valid_updates: - # create task to run all writers of the chosen node - writers = self.nodes[as_node].flat_writers - if not writers: - raise InvalidUpdateError(f"Node {as_node} has no writers") - writes: deque[tuple[str, Any]] = deque() - task = PregelTaskWrites((), as_node, writes, [INTERRUPT]) - task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT)) - run_tasks.append(task) - run_task_ids.append(task_id) + valid_updates.append((as_node, values)) - run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] + run_tasks: list[PregelTaskWrites] = [] + run_task_ids: list[str] = [] - # execute task - await run.ainvoke( - values, - patch_config( - config, - run_name=self.name + "UpdateState", - configurable={ - # deque.extend is thread-safe - CONFIG_KEY_SEND: partial( - local_write, - writes.extend, - self.nodes.keys(), - ), - CONFIG_KEY_READ: partial( - local_read, - step + 1, - checkpoint, - channels, - managed, - task, - config, - ), + for as_node, values in valid_updates: + # create task to run all writers of the chosen node + writers = self.nodes[as_node].flat_writers + if not writers: + raise InvalidUpdateError(f"Node {as_node} has no writers") + writes: deque[tuple[str, Any]] = deque() + task = PregelTaskWrites((), as_node, writes, [INTERRUPT]) + task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT)) + run_tasks.append(task) + run_task_ids.append(task_id) + + run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] + + # execute task + await run.ainvoke( + values, + patch_config( + config, + run_name=self.name + "UpdateState", + configurable={ + # deque.extend is thread-safe + CONFIG_KEY_SEND: partial( + local_write, + writes.extend, + self.nodes.keys(), + ), + CONFIG_KEY_READ: partial( + local_read, + step + 1, + checkpoint, + channels, + managed, + task, + config, + ), + }, + ), + ) + + # save task writes + for task_id, task in zip(run_task_ids, run_tasks): + # channel writes are saved to current checkpoint + channel_writes = [w for w in task.writes if w[0] != PUSH] + if saved and channel_writes: + await checkpointer.aput_writes( + checkpoint_config, channel_writes, task_id + ) + + # apply to checkpoint and save + mv_writes = apply_writes( + checkpoint, channels, run_tasks, checkpointer.get_next_version + ) + assert not mv_writes, "Can't write to SharedValues from update_state" + checkpoint = create_checkpoint(checkpoint, channels, step + 1) + # save checkpoint, after applying writes + next_config = await checkpointer.aput( + checkpoint_config, + checkpoint, + { + **checkpoint_metadata, + "source": "update", + "step": step + 1, + "writes": { + as_node: values for as_node, values in valid_updates }, + "parents": saved.metadata.get("parents", {}) if saved else {}, + }, + get_new_channel_versions( + checkpoint_previous_versions, checkpoint["channel_versions"] ), ) - # save task writes - for task_id, task in zip(run_task_ids, run_tasks): - # channel writes are saved to current checkpoint - channel_writes = [w for w in task.writes if w[0] != PUSH] - if saved and channel_writes: - await checkpointer.aput_writes( - checkpoint_config, channel_writes, task_id - ) + for task_id, task in zip(run_task_ids, run_tasks): + # save push writes + if push_writes := [w for w in task.writes if w[0] == PUSH]: + await checkpointer.aput_writes( + next_config, push_writes, task_id + ) - # apply to checkpoint and save - mv_writes = apply_writes( - checkpoint, channels, run_tasks, checkpointer.get_next_version - ) - assert not mv_writes, "Can't write to SharedValues from update_state" - checkpoint = create_checkpoint(checkpoint, channels, step + 1) - # save checkpoint, after applying writes - next_config = await checkpointer.aput( - checkpoint_config, - checkpoint, - { - **checkpoint_metadata, - "source": "update", - "step": step + 1, - "writes": {as_node: values for as_node, values in valid_updates}, - "parents": saved.metadata.get("parents", {}) if saved else {}, - }, - get_new_channel_versions( - checkpoint_previous_versions, checkpoint["channel_versions"] - ), - ) + return patch_checkpoint_map( + next_config, saved.metadata if saved else None + ) - for task_id, task in zip(run_task_ids, run_tasks): - # save push writes - if push_writes := [w for w in task.writes if w[0] == PUSH]: - await checkpointer.aput_writes(next_config, push_writes, task_id) - - return patch_checkpoint_map(next_config, saved.metadata if saved else None) + current_config = config + for superstep in supersteps: + current_config = await aperform_superstep(current_config, superstep) + return current_config def update_state( self, @@ -1865,7 +2102,7 @@ class Pregel(PregelProtocol): node `as_node`. If `as_node` is not provided, it will be set to the last node that updated the state, if not ambiguous. """ - return self.bulk_update_state(config, [StateUpdate(values, as_node)]) + return self.bulk_update_state(config, [[StateUpdate(values, as_node)]]) async def aupdate_state( self, @@ -1877,7 +2114,7 @@ class Pregel(PregelProtocol): node `as_node`. If `as_node` is not provided, it will be set to the last node that updated the state, if not ambiguous. """ - return await self.abulk_update_state(config, [StateUpdate(values, as_node)]) + return await self.abulk_update_state(config, [[StateUpdate(values, as_node)]]) def _defaults( self, diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 26234c27c..201e230f7 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -69,6 +69,7 @@ from langgraph.types import ( Interrupt, PregelTask, Send, + StateUpdate, StreamWriter, interrupt, ) @@ -7643,14 +7644,23 @@ def test_bulk_state_updates( config = {"configurable": {"thread_id": "1"}} # First update with node_a - graph.bulk_update_state(config, [({"foo": "bar"}, "node_a")]) + graph.bulk_update_state( + config, + [ + [ + StateUpdate(values={"foo": "bar"}, as_node="node_a"), + ] + ], + ) # Then bulk update with both nodes graph.bulk_update_state( config, [ - ({"foo": "updated"}, "node_a"), - ({"baz": "new"}, "node_b"), + [ + StateUpdate(values={"foo": "updated"}, as_node="node_a"), + StateUpdate(values={"baz": "new"}, as_node="node_b"), + ] ], ) @@ -7658,7 +7668,34 @@ def test_bulk_state_updates( assert state.values == {"foo": "updated", "baz": "new"} # Check if there are only two checkpoints - checkpoints = list(checkpointer.list({"configurable": {"thread_id": "1"}})) + checkpoints = list(checkpointer.list(config)) + assert len(checkpoints) == 2 + assert checkpoints[0].metadata["writes"] == { + "node_a": {"foo": "updated"}, + "node_b": {"baz": "new"}, + } + assert checkpoints[1].metadata["writes"] == {"node_a": {"foo": "bar"}} + + # perform multiple steps at the same time + config = {"configurable": {"thread_id": "2"}} + + graph.bulk_update_state( + config, + [ + [ + StateUpdate(values={"foo": "bar"}, as_node="node_a"), + ], + [ + StateUpdate(values={"foo": "updated"}, as_node="node_a"), + StateUpdate(values={"baz": "new"}, as_node="node_b"), + ], + ], + ) + + state = graph.get_state(config) + assert state.values == {"foo": "updated", "baz": "new"} + + checkpoints = list(checkpointer.list(config)) assert len(checkpoints) == 2 assert checkpoints[0].metadata["writes"] == { "node_a": {"foo": "updated"}, @@ -7670,13 +7707,30 @@ def test_bulk_state_updates( with pytest.raises(InvalidUpdateError): graph.bulk_update_state( config, - [({"foo": "error"}, None), ({"bar": "error"}, None)], + [ + [ + StateUpdate(values={"foo": "error"}, as_node=None), + StateUpdate(values={"bar": "error"}, as_node=None), + ] + ], ) # Should raise if no updates are provided - with pytest.raises(ValueError, match="No updates provided"): + with pytest.raises(ValueError, match="No supersteps provided"): graph.bulk_update_state(config, []) + # Should raise if no updates are provided + with pytest.raises(ValueError, match="No updates provided"): + graph.bulk_update_state(config, [[], []]) + # Should raise if __end__ or __copy__ update is applied in bulk with pytest.raises(InvalidUpdateError): - graph.bulk_update_state(config, [(None, "__end__"), (None, "__copy__")]) + graph.bulk_update_state( + config, + [ + [ + StateUpdate(values=None, as_node="__end__"), + StateUpdate(values=None, as_node="__copy__"), + ], + ], + ) diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index e6f541ae7..8b83a5cf2 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -59,6 +59,7 @@ from langgraph.types import ( Interrupt, PregelTask, Send, + StateUpdate, StreamWriter, interrupt, ) @@ -7866,14 +7867,23 @@ async def test_bulk_state_updates(checkpointer_name: str) -> None: config = {"configurable": {"thread_id": "1"}} # First update with node_a - await graph.abulk_update_state(config, [({"foo": "bar"}, "node_a")]) + await graph.abulk_update_state( + config, + [ + [ + StateUpdate({"foo": "bar"}, "node_a"), + ] + ], + ) # Then bulk update with both nodes await graph.abulk_update_state( config, [ - ({"foo": "updated"}, "node_a"), - ({"baz": "new"}, "node_b"), + [ + StateUpdate({"foo": "updated"}, "node_a"), + StateUpdate({"baz": "new"}, "node_b"), + ] ], ) @@ -7891,19 +7901,63 @@ async def test_bulk_state_updates(checkpointer_name: str) -> None: } assert checkpoints[1].metadata["writes"] == {"node_a": {"foo": "bar"}} + # perform multiple steps at the same time + config = {"configurable": {"thread_id": "2"}} + + await graph.abulk_update_state( + config, + [ + [ + StateUpdate({"foo": "bar"}, "node_a"), + ], + [ + StateUpdate({"foo": "updated"}, "node_a"), + StateUpdate({"baz": "new"}, "node_b"), + ], + ], + ) + + state = await graph.aget_state(config) + assert state.values == {"foo": "updated", "baz": "new"} + + checkpoints = [ + c async for c in checkpointer.alist({"configurable": {"thread_id": "1"}}) + ] + assert len(checkpoints) == 2 + assert checkpoints[0].metadata["writes"] == { + "node_a": {"foo": "updated"}, + "node_b": {"baz": "new"}, + } + assert checkpoints[1].metadata["writes"] == {"node_a": {"foo": "bar"}} + # Should raise error if updating without as_node with pytest.raises(InvalidUpdateError): await graph.abulk_update_state( config, - [({"foo": "error"}, None), ({"bar": "error"}, None)], + [ + [ + StateUpdate(values={"foo": "error"}, as_node=None), + StateUpdate(values={"bar": "error"}, as_node=None), + ] + ], ) # Should raise if no updates are provided - with pytest.raises(ValueError, match="No updates provided"): + with pytest.raises(ValueError, match="No supersteps provided"): await graph.abulk_update_state(config, []) + # Should raise if no updates are provided + with pytest.raises(ValueError, match="No updates provided"): + await graph.abulk_update_state(config, [[], []]) + # Should raise if __end__ or __copy__ update is applied in bulk with pytest.raises(InvalidUpdateError): await graph.abulk_update_state( - config, [(None, "__end__"), (None, "__copy__")] + config, + [ + [ + StateUpdate(values=None, as_node="__end__"), + StateUpdate(values=None, as_node="__copy__"), + ], + ], ) From fbec2074460edb75adf339361dad1ebfc012ae65 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Fri, 14 Mar 2025 01:55:29 +0100 Subject: [PATCH 22/32] Apply formatting --- libs/langgraph/tests/test_pregel_async.py | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 8b83a5cf2..6e45ad504 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -7961,3 +7961,56 @@ async def test_bulk_state_updates(checkpointer_name: str) -> None: ], ], ) + + +@NEEDS_CONTEXTVARS +async def test_handles_multiple_interrupts_from_tasks() -> None: + @task + async def add_participant(name: str) -> str: + feedback = interrupt(f"Hey do you want to add {name}?") + + if feedback is False: + return f"The user changed their mind and doesn't want to add {name}!" + + if feedback is True: + return f"Added {name}!" + + raise ValueError("Invalid feedback") + + @entrypoint(checkpointer=MemorySaver()) + async def program(_state: Any) -> list[str]: + first = await add_participant("James") + second = await add_participant("Will") + return [first, second] + + config = {"configurable": {"thread_id": "1"}} + + result = await program.ainvoke("this is ignored", config=config) + assert result is None + + state = await program.aget_state(config=config) + assert len(state.tasks[0].interrupts) == 1 + task_interrupt = state.tasks[0].interrupts[0] + assert task_interrupt.resumable is True + assert len(task_interrupt.ns) == 2 + assert task_interrupt.ns[0].startswith("program:") + assert task_interrupt.ns[1].startswith("add_participant:") + assert task_interrupt.value == "Hey do you want to add James?" + + result = await program.ainvoke(Command(resume=True), config=config) + assert result is None + + state = await program.aget_state(config=config) + assert len(state.tasks[0].interrupts) == 1 + task_interrupt = state.tasks[0].interrupts[0] + assert task_interrupt.resumable is True + assert len(task_interrupt.ns) == 2 + assert task_interrupt.ns[0].startswith("program:") + assert task_interrupt.ns[1].startswith("add_participant:") + assert task_interrupt.value == "Hey do you want to add Will?" + + result = await program.ainvoke(Command(resume=True), config=config) + assert result is not None + assert len(result) == 2 + assert result[0] == "Added James!" + assert result[1] == "Added Will!" From c8a605cbc816dbbf40280344f448401725e87eae Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Fri, 14 Mar 2025 01:56:37 +0100 Subject: [PATCH 23/32] Update PregelProtocol --- libs/langgraph/langgraph/pregel/protocol.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/protocol.py b/libs/langgraph/langgraph/pregel/protocol.py index 9a5f56b76..5a27f417a 100644 --- a/libs/langgraph/langgraph/pregel/protocol.py +++ b/libs/langgraph/langgraph/pregel/protocol.py @@ -73,14 +73,14 @@ class PregelProtocol( def bulk_update_state( self, config: RunnableConfig, - updates: Sequence[StateUpdate], + updates: Sequence[Sequence[StateUpdate]], ) -> RunnableConfig: ... @abstractmethod async def abulk_update_state( self, config: RunnableConfig, - updates: Sequence[StateUpdate], + updates: Sequence[Sequence[StateUpdate]], ) -> RunnableConfig: ... @abstractmethod From 6edf29f04300128944de2ab864f5e882450aed60 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Wed, 19 Mar 2025 14:39:59 +0100 Subject: [PATCH 24/32] Fix rebase artifacts --- libs/langgraph/langgraph/pregel/__init__.py | 328 ++++---------------- 1 file changed, 67 insertions(+), 261 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 687f59161..b79bb00d5 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -1404,97 +1404,9 @@ class Pregel(PregelProtocol): next_tasks[tid].writes.append((k, v)) if tasks := [t for t in next_tasks.values() if t.writes]: apply_writes(checkpoint, channels, tasks, None) - valid_updates: list[tuple[str, Optional[dict[str, Any]]]] = [] - if len(updates) == 1: - values, as_node = updates[0] - - next_checkpoint = create_checkpoint(checkpoint, None, step) - # copy checkpoint - next_config = checkpointer.put( - checkpoint_config, - next_checkpoint, - { - **checkpoint_metadata, - "source": "update", - "step": step + 1, - "writes": {}, - "parents": saved.metadata.get("parents", {}) if saved else {}, - }, - {}, - ) - return patch_checkpoint_map( - next_config, saved.metadata if saved else None - ) - # no values, copy checkpoint - if values is None and as_node == "__copy__": - if len(updates) > 1: - raise InvalidUpdateError( - "Cannot copy checkpoint with multiple updates" - ) - - next_checkpoint = create_checkpoint(checkpoint, None, step) - # copy checkpoint - next_config = checkpointer.put( - saved.parent_config or saved.config if saved else checkpoint_config, - next_checkpoint, - { - **checkpoint_metadata, - "source": "fork", - "step": step + 1, - "parents": saved.metadata.get("parents", {}) if saved else {}, - }, - {}, - ) - return patch_checkpoint_map( - next_config, saved.metadata if saved else None - ) - # apply pending writes, if not on specific checkpoint - if ( - CONFIG_KEY_CHECKPOINT_ID not in config[CONF] - and saved is not None - and saved.pending_writes - ): - # tasks for this checkpoint - next_tasks = prepare_next_tasks( - checkpoint, - saved.pending_writes, - self.nodes, - channels, - managed, - saved.config, - saved.metadata.get("step", -1) + 1, - for_execution=True, - store=self.store, - checkpointer=( - self.checkpointer - if isinstance(self.checkpointer, BaseCheckpointSaver) - else None - ), - manager=None, - ) - # apply null writes - if null_writes := [ - w[1:] for w in saved.pending_writes or [] if w[0] == NULL_TASK_ID - ]: - apply_writes( - saved.checkpoint, - channels, - [PregelTaskWrites((), INPUT, null_writes, [])], - None, - ) - # apply writes - for tid, k, v in saved.pending_writes: - if k in (ERROR, INTERRUPT, SCHEDULED): - continue - if tid not in next_tasks: - continue - next_tasks[tid].writes.append((k, v)) - if tasks := [t for t in next_tasks.values() if t.writes]: - apply_writes(checkpoint, channels, tasks, None) valid_updates: list[tuple[str, Optional[dict[str, Any]]]] = [] if len(updates) == 1: values, as_node = updates[0] - # find last node that updated the state, if not provided if as_node is None and not any( v @@ -1554,9 +1466,7 @@ class Pregel(PregelProtocol): task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT)) run_tasks.append(task) run_task_ids.append(task_id) - run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] - # execute task run.invoke( values, @@ -1582,24 +1492,19 @@ class Pregel(PregelProtocol): }, ), ) - # save task writes for task_id, task in zip(run_task_ids, run_tasks): channel_writes = [w for w in task.writes if w[0] != PUSH] - # channel writes are saved to current checkpoint if saved and channel_writes: checkpointer.put_writes( checkpoint_config, channel_writes, task_id ) - # apply to checkpoint and save mv_writes = apply_writes( checkpoint, channels, run_tasks, checkpointer.get_next_version ) - assert not mv_writes, "Can't write to SharedValues from update_state" - checkpoint = create_checkpoint(checkpoint, channels, step + 1) next_config = checkpointer.put( checkpoint_config, @@ -1617,7 +1522,6 @@ class Pregel(PregelProtocol): checkpoint_previous_versions, checkpoint["channel_versions"] ), ) - for task_id, task in zip(run_task_ids, run_tasks): # save push writes if push_writes := [w for w in task.writes if w[0] == PUSH]: @@ -1714,14 +1618,12 @@ class Pregel(PregelProtocol): managed, ): values, as_node = updates[0] - # no values, just clear all tasks if values is None and as_node == END: if len(updates) > 1: raise InvalidUpdateError( "Cannot apply multiple updates when clearing state" ) - if saved is not None: # tasks for this checkpoint next_tasks = prepare_next_tasks( @@ -1875,74 +1777,9 @@ class Pregel(PregelProtocol): next_tasks[tid].writes.append((k, v)) if tasks := [t for t in next_tasks.values() if t.writes]: apply_writes(checkpoint, channels, tasks, None) - valid_updates: list[tuple[str, Optional[dict[str, Any]]]] = [] - - if len(updates) == 1: - values, as_node = updates[0] - - next_checkpoint = create_checkpoint(checkpoint, None, step) - # copy checkpoint - next_config = await checkpointer.aput( - saved.parent_config or saved.config if saved else checkpoint_config, - next_checkpoint, - { - **checkpoint_metadata, - "source": "fork", - "step": step + 1, - "parents": saved.metadata.get("parents", {}) if saved else {}, - }, - {}, - ) - return patch_checkpoint_map( - next_config, saved.metadata if saved else None - ) - # apply pending writes, if not on specific checkpoint - if ( - CONFIG_KEY_CHECKPOINT_ID not in config[CONF] - and saved is not None - and saved.pending_writes - ): - # tasks for this checkpoint - next_tasks = prepare_next_tasks( - checkpoint, - saved.pending_writes, - self.nodes, - channels, - managed, - saved.config, - saved.metadata.get("step", -1) + 1, - for_execution=True, - store=self.store, - checkpointer=( - self.checkpointer - if isinstance(self.checkpointer, BaseCheckpointSaver) - else None - ), - manager=None, - ) - # apply null writes - if null_writes := [ - w[1:] for w in saved.pending_writes or [] if w[0] == NULL_TASK_ID - ]: - apply_writes( - saved.checkpoint, - channels, - [PregelTaskWrites((), INPUT, null_writes, [])], - None, - ) - for tid, k, v in saved.pending_writes: - if k in (ERROR, INTERRUPT, SCHEDULED): - continue - if tid not in next_tasks: - continue - next_tasks[tid].writes.append((k, v)) - if tasks := [t for t in next_tasks.values() if t.writes]: - apply_writes(checkpoint, channels, tasks, None) valid_updates: list[tuple[str, Optional[dict[str, Any]]]] = [] - if len(updates) == 1: values, as_node = updates[0] - # find last node that updated the state, if not provided if as_node is None and not saved: if ( @@ -1965,10 +1802,8 @@ class Pregel(PregelProtocol): as_node = last_seen_by_node[-1][1] if as_node is None: raise InvalidUpdateError("Ambiguous update, specify as_node") - if as_node not in self.nodes: raise InvalidUpdateError(f"Node {as_node} does not exist") - valid_updates.append((as_node, values)) else: for values, as_node in updates: @@ -1976,12 +1811,6 @@ class Pregel(PregelProtocol): raise InvalidUpdateError( "as_node is required when applying multiple updates" ) - # if two nodes updated the state at the same time, it's ambiguous - if last_seen_by_node: - if len(last_seen_by_node) == 1: - as_node = last_seen_by_node[0][1] - elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]: - as_node = last_seen_by_node[-1][1] if as_node is None: raise InvalidUpdateError("Ambiguous update, specify as_node") @@ -1989,103 +1818,80 @@ class Pregel(PregelProtocol): raise InvalidUpdateError(f"Node {as_node} does not exist") valid_updates.append((as_node, values)) - else: - for values, as_node in updates: - if as_node is None: - raise InvalidUpdateError( - "as_node is required when applying multiple updates" - ) - - if as_node not in self.nodes: - raise InvalidUpdateError(f"Node {as_node} does not exist") - - valid_updates.append((as_node, values)) run_tasks: list[PregelTaskWrites] = [] run_task_ids: list[str] = [] - for as_node, values in valid_updates: - # create task to run all writers of the chosen node - writers = self.nodes[as_node].flat_writers - if not writers: - raise InvalidUpdateError(f"Node {as_node} has no writers") - writes: deque[tuple[str, Any]] = deque() - task = PregelTaskWrites((), as_node, writes, [INTERRUPT]) - task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT)) - run_tasks.append(task) - run_task_ids.append(task_id) - - run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] - - # execute task - await run.ainvoke( - values, - patch_config( - config, - run_name=self.name + "UpdateState", - configurable={ - # deque.extend is thread-safe - CONFIG_KEY_SEND: partial( - local_write, - writes.extend, - self.nodes.keys(), - ), - CONFIG_KEY_READ: partial( - local_read, - step + 1, - checkpoint, - channels, - managed, - task, - config, - ), - }, - ), - ) - - # save task writes - for task_id, task in zip(run_task_ids, run_tasks): - # channel writes are saved to current checkpoint - channel_writes = [w for w in task.writes if w[0] != PUSH] - if saved and channel_writes: - await checkpointer.aput_writes( - checkpoint_config, channel_writes, task_id - ) - - # apply to checkpoint and save - mv_writes = apply_writes( - checkpoint, channels, run_tasks, checkpointer.get_next_version - ) - assert not mv_writes, "Can't write to SharedValues from update_state" - checkpoint = create_checkpoint(checkpoint, channels, step + 1) - # save checkpoint, after applying writes - next_config = await checkpointer.aput( - checkpoint_config, - checkpoint, - { - **checkpoint_metadata, - "source": "update", - "step": step + 1, - "writes": { - as_node: values for as_node, values in valid_updates + for as_node, values in valid_updates: + # create task to run all writers of the chosen node + writers = self.nodes[as_node].flat_writers + if not writers: + raise InvalidUpdateError(f"Node {as_node} has no writers") + writes: deque[tuple[str, Any]] = deque() + task = PregelTaskWrites((), as_node, writes, [INTERRUPT]) + task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT)) + run_tasks.append(task) + run_task_ids.append(task_id) + run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] + # execute task + await run.ainvoke( + values, + patch_config( + config, + run_name=self.name + "UpdateState", + configurable={ + # deque.extend is thread-safe + CONFIG_KEY_SEND: partial( + local_write, + writes.extend, + self.nodes.keys(), + ), + CONFIG_KEY_READ: partial( + local_read, + step + 1, + checkpoint, + channels, + managed, + task, + config, + ), }, - "parents": saved.metadata.get("parents", {}) if saved else {}, - }, - get_new_channel_versions( - checkpoint_previous_versions, checkpoint["channel_versions"] ), ) - - for task_id, task in zip(run_task_ids, run_tasks): - # save push writes - if push_writes := [w for w in task.writes if w[0] == PUSH]: - await checkpointer.aput_writes( - next_config, push_writes, task_id - ) - - return patch_checkpoint_map( - next_config, saved.metadata if saved else None - ) + # save task writes + for task_id, task in zip(run_task_ids, run_tasks): + # channel writes are saved to current checkpoint + channel_writes = [w for w in task.writes if w[0] != PUSH] + if saved and channel_writes: + await checkpointer.aput_writes( + checkpoint_config, channel_writes, task_id + ) + # apply to checkpoint and save + mv_writes = apply_writes( + checkpoint, channels, run_tasks, checkpointer.get_next_version + ) + assert not mv_writes, "Can't write to SharedValues from update_state" + checkpoint = create_checkpoint(checkpoint, channels, step + 1) + # save checkpoint, after applying writes + next_config = await checkpointer.aput( + checkpoint_config, + checkpoint, + { + **checkpoint_metadata, + "source": "update", + "step": step + 1, + "writes": {as_node: values for as_node, values in valid_updates}, + "parents": saved.metadata.get("parents", {}) if saved else {}, + }, + get_new_channel_versions( + checkpoint_previous_versions, checkpoint["channel_versions"] + ), + ) + for task_id, task in zip(run_task_ids, run_tasks): + # save push writes + if push_writes := [w for w in task.writes if w[0] == PUSH]: + await checkpointer.aput_writes(next_config, push_writes, task_id) + return patch_checkpoint_map(next_config, saved.metadata if saved else None) current_config = config for superstep in supersteps: From 7fb062895719b710ea80ab0b8edcc8817ef2a886 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Wed, 19 Mar 2025 14:44:07 +0100 Subject: [PATCH 25/32] Remove duplicated test --- libs/langgraph/tests/test_pregel_async.py | 53 ----------------------- 1 file changed, 53 deletions(-) diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 6e45ad504..8b83a5cf2 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -7961,56 +7961,3 @@ async def test_bulk_state_updates(checkpointer_name: str) -> None: ], ], ) - - -@NEEDS_CONTEXTVARS -async def test_handles_multiple_interrupts_from_tasks() -> None: - @task - async def add_participant(name: str) -> str: - feedback = interrupt(f"Hey do you want to add {name}?") - - if feedback is False: - return f"The user changed their mind and doesn't want to add {name}!" - - if feedback is True: - return f"Added {name}!" - - raise ValueError("Invalid feedback") - - @entrypoint(checkpointer=MemorySaver()) - async def program(_state: Any) -> list[str]: - first = await add_participant("James") - second = await add_participant("Will") - return [first, second] - - config = {"configurable": {"thread_id": "1"}} - - result = await program.ainvoke("this is ignored", config=config) - assert result is None - - state = await program.aget_state(config=config) - assert len(state.tasks[0].interrupts) == 1 - task_interrupt = state.tasks[0].interrupts[0] - assert task_interrupt.resumable is True - assert len(task_interrupt.ns) == 2 - assert task_interrupt.ns[0].startswith("program:") - assert task_interrupt.ns[1].startswith("add_participant:") - assert task_interrupt.value == "Hey do you want to add James?" - - result = await program.ainvoke(Command(resume=True), config=config) - assert result is None - - state = await program.aget_state(config=config) - assert len(state.tasks[0].interrupts) == 1 - task_interrupt = state.tasks[0].interrupts[0] - assert task_interrupt.resumable is True - assert len(task_interrupt.ns) == 2 - assert task_interrupt.ns[0].startswith("program:") - assert task_interrupt.ns[1].startswith("add_participant:") - assert task_interrupt.value == "Hey do you want to add Will?" - - result = await program.ainvoke(Command(resume=True), config=config) - assert result is not None - assert len(result) == 2 - assert result[0] == "Added James!" - assert result[1] == "Added Will!" From 7ba48d75c9267a7b3e69253b2604bc71b85decb5 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Wed, 19 Mar 2025 15:05:22 +0100 Subject: [PATCH 26/32] Another merge issue --- 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 b79bb00d5..d2a66a0b9 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -1819,8 +1819,8 @@ class Pregel(PregelProtocol): valid_updates.append((as_node, values)) - run_tasks: list[PregelTaskWrites] = [] - run_task_ids: list[str] = [] + run_tasks: list[PregelTaskWrites] = [] + run_task_ids: list[str] = [] for as_node, values in valid_updates: # create task to run all writers of the chosen node From 1cece3228cbaeb1adef4afc247f5e4ed64d55563 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Wed, 19 Mar 2025 15:37:43 +0100 Subject: [PATCH 27/32] Fix indent bug --- libs/langgraph/langgraph/pregel/__init__.py | 301 +++++++++++++------- libs/langgraph/tests/test_pregel.py | 163 +++++++++++ libs/langgraph/tests/test_pregel_async.py | 166 +++++++++++ 3 files changed, 524 insertions(+), 106 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index d2a66a0b9..9fa0770a0 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -92,7 +92,7 @@ from langgraph.pregel.algo import ( prepare_next_tasks, ) from langgraph.pregel.debug import tasks_w_writes -from langgraph.pregel.io import read_channels +from langgraph.pregel.io import map_input, read_channels from langgraph.pregel.loop import AsyncPregelLoop, StreamProtocol, SyncPregelLoop from langgraph.pregel.manager import AsyncChannelsManager, ChannelsManager from langgraph.pregel.messages import StreamMessagesHandler @@ -1332,6 +1332,58 @@ class Pregel(PregelProtocol): return patch_checkpoint_map( next_config, saved.metadata if saved else None ) + + # act as an input + if as_node == INPUT: + if len(updates) > 1: + raise InvalidUpdateError( + "Cannot apply multiple updates when updating as input" + ) + + if input_writes := deque(map_input(self.input_channels, values)): + apply_writes( + checkpoint, + channels, + [PregelTaskWrites((), INPUT, input_writes, [])], + checkpointer.get_next_version, + ) + + # apply input write to channels + next_step = ( + step + 1 + if saved and saved.metadata.get("step") is not None + else -1 + ) + next_config = checkpointer.put( + checkpoint_config, + create_checkpoint(checkpoint, channels, next_step), + { + **checkpoint_metadata, + "source": "input", + "step": next_step, + "writes": dict(input_writes), + }, + get_new_channel_versions( + checkpoint_previous_versions, + checkpoint["channel_versions"], + ), + ) + + # store the writes + checkpointer.put_writes( + next_config, + input_writes, + str(uuid5(UUID(checkpoint["id"]), INPUT)), + ) + + return patch_checkpoint_map( + next_config, saved.metadata if saved else None + ) + else: + raise InvalidUpdateError( + f"Received no input writes for {self.input_channels}" + ) + # no values, copy checkpoint if values is None and as_node == "__copy__": if len(updates) > 1: @@ -1417,119 +1469,107 @@ class Pregel(PregelProtocol): isinstance(self.input_channels, str) and self.input_channels in self.nodes ): - if ( - isinstance(self.input_channels, str) - and self.input_channels in self.nodes - ): - as_node = self.input_channels - elif as_node is None: - last_seen_by_node = sorted( - (v, n) - for n, seen in checkpoint["versions_seen"].items() - if n in self.nodes - for v in seen.values() - ) - # if two nodes updated the state at the same time, it's ambiguous - if last_seen_by_node: - if len(last_seen_by_node) == 1: - as_node = last_seen_by_node[0][1] - elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]: - as_node = last_seen_by_node[-1][1] + as_node = self.input_channels + elif as_node is None: + last_seen_by_node = sorted( + (v, n) + for n, seen in checkpoint["versions_seen"].items() + if n in self.nodes + for v in seen.values() + ) + # if two nodes updated the state at the same time, it's ambiguous + if last_seen_by_node: + if len(last_seen_by_node) == 1: + as_node = last_seen_by_node[0][1] + elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]: + as_node = last_seen_by_node[-1][1] + if as_node is None: + raise InvalidUpdateError("Ambiguous update, specify as_node") + if as_node not in self.nodes: + raise InvalidUpdateError(f"Node {as_node} does not exist") + valid_updates.append((as_node, values)) + else: + for values, as_node in updates: if as_node is None: - raise InvalidUpdateError("Ambiguous update, specify as_node") + raise InvalidUpdateError( + "as_node is required when applying multiple updates" + ) if as_node not in self.nodes: raise InvalidUpdateError(f"Node {as_node} does not exist") valid_updates.append((as_node, values)) - else: - for values, as_node in updates: - if as_node is None: - raise InvalidUpdateError( - "as_node is required when applying multiple updates" - ) - if as_node not in self.nodes: - raise InvalidUpdateError(f"Node {as_node} does not exist") + run_tasks: list[PregelTaskWrites] = [] + run_task_ids: list[str] = [] - valid_updates.append((as_node, values)) - - run_tasks: list[PregelTaskWrites] = [] - run_task_ids: list[str] = [] - - for as_node, values in valid_updates: - # create task to run all writers of the chosen node - writers = self.nodes[as_node].flat_writers - if not writers: - raise InvalidUpdateError(f"Node {as_node} has no writers") - writes: deque[tuple[str, Any]] = deque() - task = PregelTaskWrites((), as_node, writes, [INTERRUPT]) - task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT)) - run_tasks.append(task) - run_task_ids.append(task_id) - run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] - # execute task - run.invoke( - values, - patch_config( - config, - run_name=self.name + "UpdateState", - configurable={ - # deque.extend is thread-safe - CONFIG_KEY_SEND: partial( - local_write, - writes.extend, - self.nodes.keys(), - ), - CONFIG_KEY_READ: partial( - local_read, - step + 1, - checkpoint, - channels, - managed, - task, - config, - ), - }, - ), - ) - # save task writes - for task_id, task in zip(run_task_ids, run_tasks): - channel_writes = [w for w in task.writes if w[0] != PUSH] - # channel writes are saved to current checkpoint - if saved and channel_writes: - checkpointer.put_writes( - checkpoint_config, channel_writes, task_id - ) - # apply to checkpoint and save - mv_writes = apply_writes( - checkpoint, channels, run_tasks, checkpointer.get_next_version - ) - assert not mv_writes, "Can't write to SharedValues from update_state" - checkpoint = create_checkpoint(checkpoint, channels, step + 1) - next_config = checkpointer.put( - checkpoint_config, - checkpoint, - { - **checkpoint_metadata, - "source": "update", - "step": step + 1, - "writes": { - as_node: values for as_node, values in valid_updates + for as_node, values in valid_updates: + # create task to run all writers of the chosen node + writers = self.nodes[as_node].flat_writers + if not writers: + raise InvalidUpdateError(f"Node {as_node} has no writers") + writes: deque[tuple[str, Any]] = deque() + task = PregelTaskWrites((), as_node, writes, [INTERRUPT]) + task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT)) + run_tasks.append(task) + run_task_ids.append(task_id) + run = RunnableSequence(*writers) if len(writers) > 1 else writers[0] + # execute task + run.invoke( + values, + patch_config( + config, + run_name=self.name + "UpdateState", + configurable={ + # deque.extend is thread-safe + CONFIG_KEY_SEND: partial( + local_write, + writes.extend, + self.nodes.keys(), + ), + CONFIG_KEY_READ: partial( + local_read, + step + 1, + checkpoint, + channels, + managed, + task, + config, + ), }, - "parents": saved.metadata.get("parents", {}) if saved else {}, - }, - get_new_channel_versions( - checkpoint_previous_versions, checkpoint["channel_versions"] ), ) - for task_id, task in zip(run_task_ids, run_tasks): - # save push writes - if push_writes := [w for w in task.writes if w[0] == PUSH]: - checkpointer.put_writes(next_config, push_writes, task_id) + # save task writes + for task_id, task in zip(run_task_ids, run_tasks): + # channel writes are saved to current checkpoint + channel_writes = [w for w in task.writes if w[0] != PUSH] + if saved and channel_writes: + checkpointer.put_writes(checkpoint_config, channel_writes, task_id) + # apply to checkpoint and save + mv_writes = apply_writes( + checkpoint, channels, run_tasks, checkpointer.get_next_version + ) + assert not mv_writes, "Can't write to SharedValues from update_state" + checkpoint = create_checkpoint(checkpoint, channels, step + 1) + next_config = checkpointer.put( + checkpoint_config, + checkpoint, + { + **checkpoint_metadata, + "source": "update", + "step": step + 1, + "writes": {as_node: values for as_node, values in valid_updates}, + "parents": saved.metadata.get("parents", {}) if saved else {}, + }, + get_new_channel_versions( + checkpoint_previous_versions, checkpoint["channel_versions"] + ), + ) + for task_id, task in zip(run_task_ids, run_tasks): + # save push writes + if push_writes := [w for w in task.writes if w[0] == PUSH]: + checkpointer.put_writes(next_config, push_writes, task_id) - return patch_checkpoint_map( - next_config, saved.metadata if saved else None - ) + return patch_checkpoint_map(next_config, saved.metadata if saved else None) current_config = config for superstep in supersteps: @@ -1706,6 +1746,58 @@ class Pregel(PregelProtocol): return patch_checkpoint_map( next_config, saved.metadata if saved else None ) + + # act as an input + if as_node == INPUT: + if len(updates) > 1: + raise InvalidUpdateError( + "Cannot apply multiple updates when updating as input" + ) + + if input_writes := deque(map_input(self.input_channels, values)): + apply_writes( + checkpoint, + channels, + [PregelTaskWrites((), INPUT, input_writes, [])], + checkpointer.get_next_version, + ) + + # apply input write to channels + next_step = ( + step + 1 + if saved and saved.metadata.get("step") is not None + else -1 + ) + next_config = await checkpointer.aput( + checkpoint_config, + create_checkpoint(checkpoint, channels, next_step), + { + **checkpoint_metadata, + "source": "input", + "step": next_step, + "writes": dict(input_writes), + }, + get_new_channel_versions( + checkpoint_previous_versions, + checkpoint["channel_versions"], + ), + ) + + # store the writes + await checkpointer.aput_writes( + next_config, + input_writes, + str(uuid5(UUID(checkpoint["id"]), INPUT)), + ) + + return patch_checkpoint_map( + next_config, saved.metadata if saved else None + ) + else: + raise InvalidUpdateError( + f"Received no input writes for {self.input_channels}" + ) + # no values, copy checkpoint if values is None and as_node == "__copy__": if len(updates) > 1: @@ -1811,9 +1903,6 @@ class Pregel(PregelProtocol): raise InvalidUpdateError( "as_node is required when applying multiple updates" ) - if as_node is None: - raise InvalidUpdateError("Ambiguous update, specify as_node") - if as_node not in self.nodes: raise InvalidUpdateError(f"Node {as_node} does not exist") diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 201e230f7..0eeb492e2 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -7734,3 +7734,166 @@ def test_bulk_state_updates( ], ], ) + + +@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_SYNC) +def test_update_as_input( + request: pytest.FixtureRequest, checkpointer_name: str +) -> None: + checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}") + + class State(TypedDict): + foo: str + + def agent(state: State) -> State: + return {"foo": "agent"} + + def tool(state: State) -> State: + return {"foo": "tool"} + + graph = ( + StateGraph(State) + .add_node("agent", agent) + .add_node("tool", tool) + .add_edge(START, "agent") + .add_edge("agent", "tool") + .compile(checkpointer=checkpointer) + ) + + assert graph.invoke({"foo": "input"}, {"configurable": {"thread_id": "1"}}) == { + "foo": "tool" + } + + assert graph.invoke({"foo": "input"}, {"configurable": {"thread_id": "1"}}) == { + "foo": "tool" + } + + def map_snapshot(i: StateSnapshot) -> dict: + return { + "values": i.values, + "next": i.next, + "step": i.metadata.get("step"), + } + + history = [ + map_snapshot(s) + for s in graph.get_state_history({"configurable": {"thread_id": "1"}}) + ] + + graph.bulk_update_state( + {"configurable": {"thread_id": "2"}}, + [ + # First turn + [StateUpdate({"foo": "input"}, "__input__")], + [StateUpdate({"foo": "input"}, "__start__")], + [StateUpdate({"foo": "agent"}, "agent")], + [StateUpdate({"foo": "tool"}, "tool")], + # Second turn + [StateUpdate({"foo": "input"}, "__input__")], + [StateUpdate({"foo": "input"}, "__start__")], + [StateUpdate({"foo": "agent"}, "agent")], + [StateUpdate({"foo": "tool"}, "tool")], + ], + ) + + state = graph.get_state({"configurable": {"thread_id": "2"}}) + assert state.values == {"foo": "tool"} + + new_history = [ + map_snapshot(s) + for s in graph.get_state_history({"configurable": {"thread_id": "2"}}) + ] + + assert new_history == history + + +@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_SYNC) +def test_batch_update_as_input( + request: pytest.FixtureRequest, checkpointer_name: str +) -> None: + checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}") + + class State(TypedDict): + foo: str + tasks: Annotated[list[int], operator.add] + + def agent(state: State) -> State: + return {"foo": "agent"} + + def map(state: State) -> Command["task"]: + return Command( + goto=[ + Send("task", {"index": 0}), + Send("task", {"index": 1}), + Send("task", {"index": 2}), + ], + update={"foo": "map"}, + ) + + def task(state: dict) -> State: + return {"tasks": [state["index"]]} + + graph = ( + StateGraph(State) + .add_node("agent", agent) + .add_node("map", map) + .add_node("task", task) + .add_edge(START, "agent") + .add_edge("agent", "map") + .compile(checkpointer=checkpointer) + ) + + assert graph.invoke({"foo": "input"}, {"configurable": {"thread_id": "1"}}) == { + "foo": "map", + "tasks": [0, 1, 2], + } + + def map_snapshot(i: StateSnapshot) -> dict: + return { + "values": i.values, + "next": i.next, + "step": i.metadata.get("step"), + "tasks": [t.name for t in i.tasks], + } + + history = [ + map_snapshot(s) + for s in graph.get_state_history({"configurable": {"thread_id": "1"}}) + ] + + graph.bulk_update_state( + {"configurable": {"thread_id": "2"}}, + [ + [StateUpdate({"foo": "input"}, "__input__")], + [StateUpdate({"foo": "input"}, "__start__")], + [StateUpdate({"foo": "agent", "tasks": []}, "agent")], + [ + StateUpdate( + Command( + goto=[ + Send("task", {"index": 0}), + Send("task", {"index": 1}), + Send("task", {"index": 2}), + ], + update={"foo": "map"}, + ), + "map", + ) + ], + [ + StateUpdate({"tasks": [0]}, "task"), + StateUpdate({"tasks": [1]}, "task"), + StateUpdate({"tasks": [2]}, "task"), + ], + ], + ) + + state = graph.get_state({"configurable": {"thread_id": "2"}}) + assert state.values == {"foo": "map", "tasks": [0, 1, 2]} + + new_history = [ + map_snapshot(s) + for s in graph.get_state_history({"configurable": {"thread_id": "2"}}) + ] + + assert new_history == history diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 8b83a5cf2..438d669db 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -7961,3 +7961,169 @@ async def test_bulk_state_updates(checkpointer_name: str) -> None: ], ], ) + + +@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC) +async def test_update_as_input(checkpointer_name: str) -> None: + async with awith_checkpointer(checkpointer_name) as checkpointer: + + class State(TypedDict): + foo: str + + def agent(state: State) -> State: + return {"foo": "agent"} + + def tool(state: State) -> State: + return {"foo": "tool"} + + graph = ( + StateGraph(State) + .add_node("agent", agent) + .add_node("tool", tool) + .add_edge(START, "agent") + .add_edge("agent", "tool") + .compile(checkpointer=checkpointer) + ) + + assert await graph.ainvoke( + {"foo": "input"}, {"configurable": {"thread_id": "1"}} + ) == {"foo": "tool"} + + assert await graph.ainvoke( + {"foo": "input"}, {"configurable": {"thread_id": "1"}} + ) == {"foo": "tool"} + + def map_snapshot(i: StateSnapshot) -> dict: + return { + "values": i.values, + "next": i.next, + "step": i.metadata.get("step"), + } + + history = [ + map_snapshot(s) + async for s in graph.aget_state_history( + {"configurable": {"thread_id": "1"}} + ) + ] + + await graph.abulk_update_state( + {"configurable": {"thread_id": "2"}}, + [ + # First turn + [StateUpdate({"foo": "input"}, "__input__")], + [StateUpdate({"foo": "input"}, "__start__")], + [StateUpdate({"foo": "agent"}, "agent")], + [StateUpdate({"foo": "tool"}, "tool")], + # Second turn + [StateUpdate({"foo": "input"}, "__input__")], + [StateUpdate({"foo": "input"}, "__start__")], + [StateUpdate({"foo": "agent"}, "agent")], + [StateUpdate({"foo": "tool"}, "tool")], + ], + ) + + state = await graph.aget_state({"configurable": {"thread_id": "2"}}) + assert state.values == {"foo": "tool"} + + new_history = [ + map_snapshot(s) + async for s in graph.aget_state_history( + {"configurable": {"thread_id": "2"}} + ) + ] + + assert new_history == history + + +@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC) +async def test_batch_update_as_input(checkpointer_name: str) -> None: + async with awith_checkpointer(checkpointer_name) as checkpointer: + + class State(TypedDict): + foo: str + tasks: Annotated[list[int], operator.add] + + def agent(state: State) -> State: + return {"foo": "agent"} + + def map(state: State) -> Command["task"]: + return Command( + goto=[ + Send("task", {"index": 0}), + Send("task", {"index": 1}), + Send("task", {"index": 2}), + ], + update={"foo": "map"}, + ) + + def task(state: dict) -> State: + return {"tasks": [state["index"]]} + + graph = ( + StateGraph(State) + .add_node("agent", agent) + .add_node("map", map) + .add_node("task", task) + .add_edge(START, "agent") + .add_edge("agent", "map") + .compile(checkpointer=checkpointer) + ) + + assert await graph.ainvoke( + {"foo": "input"}, {"configurable": {"thread_id": "1"}} + ) == {"foo": "map", "tasks": [0, 1, 2]} + + def map_snapshot(i: StateSnapshot) -> dict: + return { + "values": i.values, + "next": i.next, + "step": i.metadata.get("step"), + "tasks": [t.name for t in i.tasks], + } + + history = [ + map_snapshot(s) + async for s in graph.aget_state_history( + {"configurable": {"thread_id": "1"}} + ) + ] + + await graph.abulk_update_state( + {"configurable": {"thread_id": "2"}}, + [ + [StateUpdate({"foo": "input"}, "__input__")], + [StateUpdate({"foo": "input"}, "__start__")], + [StateUpdate({"foo": "agent", "tasks": []}, "agent")], + [ + StateUpdate( + Command( + goto=[ + Send("task", {"index": 0}), + Send("task", {"index": 1}), + Send("task", {"index": 2}), + ], + update={"foo": "map"}, + ), + "map", + ) + ], + [ + StateUpdate({"tasks": [0]}, "task"), + StateUpdate({"tasks": [1]}, "task"), + StateUpdate({"tasks": [2]}, "task"), + ], + ], + ) + + state = await graph.aget_state({"configurable": {"thread_id": "2"}}) + assert state.values == {"foo": "map", "tasks": [0, 1, 2]} + + new_history = [ + map_snapshot(s) + async for s in graph.aget_state_history( + {"configurable": {"thread_id": "2"}} + ) + ] + + assert new_history == history From 75a727877fc8f9fb7020034138ad6926484cc440 Mon Sep 17 00:00:00 2001 From: lc-arjun Date: Wed, 19 Mar 2025 07:51:54 -0700 Subject: [PATCH 28/32] feat: add docs for prompt engineering (#3846) --- docs/README.md | 2 + .../img/studio_graph_with_configuration.png | Bin 0 -> 39508 bytes .../how-tos/img/studio_node_configuration.png | Bin 0 -> 95259 bytes .../cloud/how-tos/iterate_graph_studio.md | 131 +++++++++++++++++- 4 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 docs/docs/cloud/how-tos/img/studio_graph_with_configuration.png create mode 100644 docs/docs/cloud/how-tos/img/studio_node_configuration.png diff --git a/docs/README.md b/docs/README.md index 980ee6ade..b3be09886 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,8 @@ To run the documentation server locally you can run: make serve-docs ``` +This will start the documentation server on [http://127.0.0.1:8000/langgraph/](http://127.0.0.1:8000/langgraph/). + ## Execute notebooks If you would like to automatically execute all of the notebooks, to mimic the "Run notebooks" GHA, you can run: diff --git a/docs/docs/cloud/how-tos/img/studio_graph_with_configuration.png b/docs/docs/cloud/how-tos/img/studio_graph_with_configuration.png new file mode 100644 index 0000000000000000000000000000000000000000..91166691e4ddfea5afefb13436b3036e9a8199ce GIT binary patch literal 39508 zcmeEu2Uk;D*Di!Ah@c_}ND~B>>MS2O+Aqhw)fDj9ah#*yZ2kE`HC{4Q3 z5_**udhgsFJmty7={alb#?OZkx*^sVNU$C66b!oTqTvIlfO86$6E1 zJ?kIyB1{tZ4>x`twh-(bP`5sY<5`XiX$2mlmPZh%Z4fQ-^jqeu3i(A}csTD8O%{*l ziIYl9JlQa5X*GPfDdkvUS}5IX)P2c!yS6K?Q&;;v^PE{E9S4h2^VY}qhs;!zbY;|= zm#n*|ReJOT?DOM)X@59~!er4z+}gbU;{IrgJ);pHEZ0EN^^oYKr2*QA{PiAGfV)ukshqMh@c&bDCreAXvyFp`1TvlxxYdxYww{Zg z>Ju?@2N<83g~M}8J`b29t_hxmhZyh{X6a(a;sJxgoy9yPZ(m#?2E508&3~Ka;u05o z$=iCW8Z2@SPL?cSzWaRlZ%dJ}u&_utSy+i_$v^tDIq*sHwvCI6qZmKGySqD|yC9!~ zlQsVXQBhI;`vUv|0=&Q#yw09*7c&oDxHH@DoBVYjc}r(=CtF7sTL(A`?!IQv9b8=` zZ{Nms^!LBta$0)W{--Cn^Pj^42FQ>5h5rHHeg41i4K$U&eJiG6>tPAim$!uh>H+$Y z0zU*xTwMP@e)&(2|7fZApOy~=1pe9bAHV$HEuT4CI>|Y}fG%C6{xLJZyZz^ne>Rlh z$Bq0Sn)t2ei*ErvOOZ+N|2=0?WTB$*&3Je+c*^p!+8+2TITePm;ZpX0i9v;Nyz);uWu}No zp`gh(+W#d6DfA)9KjmcE0Ca9$sE<8Ymey9n5+|6J zu-I@UQkJd{{b!uL#$#Rf$4BVN?`(S)aUR$Z-q z^vY(L}k7>>?peomCKo=OoLA(c`F zfXMIdih@C;A1>+Pl{znsZnZKLc8ZI;*RWdcQEabPPVzB;;RC2lwJ1Jgj+Pb$^Q37K zLaM52885GZcyV_ff;fbz_hRU+hmNRSzmd>#k_uQjqd3zi*l0zq-@?4tEF<9H{ColM zd@>4-xQtXHb#ZlVH{E@NZHYFC&^$uFBvc53jNm z#W3scy~hK+Al19(r)zSUcCt2XJth(RIAP^vqlvP!Vs+GVb;Nb2r`hbgA=T$#zulQz z;%?e*4W|`#hx2iL>Fp1pXWhCDM-dWd2iN(QP|&?jYrr=lwaKV3=~_wVY*c2*r&-It zL>kY^PnLBX!zwCtM5yNlL!|B*RzN>Fof5}T;1kZ9ChL1C?XQh<7uFx;FpwE&Y28zo zJ}vV;WtjXS3Ul2y#rud064*6>a0mH$g$$o?=K4d#&byP9Ea^z=@+=N+Zk5E76WY0U z&Lo$1{O?ejB-ZsmSTsRV9;h(#x}83it-c`s#TpHQt!Jy%hkNb~X>zh?&gJ_nBtW?H z*&X>|-RGS7+;$@58$M2aEGU4IIgI&iiezidxv{@mkbEv~@+8xHNyW6U8BgU7FE0fr zup&zQjNQpy#u+-rY{(G?p{3Tn@TDJRKUJc6Z!p~M@W%ycvVg>-`SNsENdx_M2lOBkz-^ z2UI%^08^;N55W%TJ+|(AX%z@cL@eL+-1r(mYNs_R6oH4dC0bD-sa0D=lw*eqOlv=` zBCO;9NlxwolAxnfj7Z z1?IJh!#L#A2&}Gya!W2+5Gz%c$7X8XL0=Gjvm3XKB6`Sa`7X9v*k=@KM56p&3EQ5*;td!`Z?c^V$ z3Jhysu8LJ)hYRfgXH%*o2j@wg-70m?$}=u~Q~{e@8)_w$9RvjaM9l@zN9bbXUN_^> zUDZNWc6FkA!G48+J@6ivpuU2HkQr4u%1(@ztBj0}{#x@|i!8TC6P4bMpvNa{C0zNd z&s8z&3-z)C>dhKT<^j|@m3DeOK0=$TTlgv8u(E{IT4^NGPTnxNs_Hwgw{^gRuKbTy zGRgV*s(b5`oKd1nd!r7~uGq!=OR}X}M1=8vzh#7>tBu#8IHP3>#H%NL*CGgzp``IS zh3>XyDk>_LO|8LNIyy9Av6>Wwz-(M-g!i=c?=}LbT2viKJkT`N!xuAe;vc>+T3*&! zO5?i~`CfxpOiavjJyFtW1*3VFhbMy9aDBj}vi$Ci4f7Z+vap%gqpmP?rSXDWy~9YI zXv>|M4L}am7l0$N1_7qx395uWAy4{lG{0$7024wFFv(FxKBZ}Mq%Moi8)y?>)tBdE z>lQy#6~dyUqmNLn0hW)|><_lN0Nsy(fmub?POR4ScFnncOUhTsrg|5JC-*aTaAJT4 z>3L%jaNN$;y^r18DtPp3BGU1Y73oSx*%G%< zjvMj@`Wa8UC5-OE(UJ@R1ehYhZ+N~b$E`t3_{Z%ZKZjWO&=)A8H3Qo?zUE8TcYn9;ua0Sut?wG9@LpdF|;I@(Qh(&vJy2u&}bbdL{MT0`__K zjS~1NqI!SA%W`wZfPzn+&o(Mo`gG~WaIJgcElI?{jk>fnxrrZUwhO&U;WMevJpo%V zcXqtaoEt$T2kxC|qLhvR{7J?ygPD zAJ1KN-#!$l9Fy0|3;h(iIJZ@V;uU5w_UJ;Rp^|aVxHSxWt-J5TG=Y{|ub3A!Q3VdQ zJ*$XHY$9L+s5V~XA%mF>kOC}J*N^}1-P)&*6`uBHNgiB%VVRxKPFb@v5bQ|tr(S_mj^H#&VYkZX(sa{RH=X#;$A@4gL z8{4?9>OuOmMM@nSPosKTJbN-u%X{14G)0)oBXK5A`G&LI#uv7Yynb61=7<$q6AzP; zl9FZw@(R<5-r2(WNu&@MTq7_N?ELK6;D?U6)i~b;Q(y?4p>FeCw#EO;AQ~E^D(_W~ ze^C>*xXV7c<0H&xT*KZgeeUIXNa^b+R}Z!yx~KBfzMc;)v?1X*#U|>xl`e6-BB-UI zHw6mKdHZZDMp0_^RUiQ~V`zVtdiAC0%E^}j(1Bh9|1x2E492Qy6#Am|e6uvEn(2nu z>RRpki?2a<7;Jt5*rG8qcoAV!jr5;czaB65tA7+7Na4$tV#LyL^13%HD&XR@;N9 z1fw=*+vd_j_G&At_8X4p3@48&3?EE@{B;sYw)c7}e^pPOs+FRJt2{+uVp*9w0qH)6 z+@_zp4J*D=`W_I_VldrUwvpMb0dxc3(#UvNu51ttJht+!g}iTYa0!%hyzVR0a5V4~ zO%Uhp{z2FDH2h>My3z4#cZ1Kd#poW1We%!Uz%BvpDd9fuQz>z@XkKE~O;0O-!THm6zdjP*_wqWI##FsHBfy1LP0Zdh1>;&%o+9pk6@$S${Hzg7f{$FHQ#W%RKz?Js+E zA62Q{CB_l^aLFauW`u2DfX8zaqLrfx>{x+&?b?OklO_fHUdjzF0>JOx{B)HuQz-9x zOaC|<`yiQ-Fb_qcBvjn#pr~$dId^MHj#3%6dX4sBL`0O$R55I5x1+oJ#WoDu)4h9F zoDUzl>gedG#IuBYOHP}fpWn^cRFXezUIUx;4|WP5e*Bb3xZlTr;VJi&@vK|@sB79uOpb6pJRB(aJ}y7TJ?fSv;>Ob5siZ_@4LgP*OX(;=i!{ts$t9M zuvjA~)N{qytI(qYeV(EvQ|MDvZrW-`p~g?k-(dEsE02@mY&0(6ou1KylPWx*Dc z{ya_tN(qV^5X!5TE`rsn4Hbq-dhLC0zs@-~R$cmhxUXPwCVFo@alH1|>HehiQe@*n z3;$A$;IkLB74`YXCcdk#hljns98nd+pv;!?^4MfZkRe;vlZ819Lwhiiqc`mwG%(fda{iD%*owHK#8-J*;#Ls1}BmL^j`H= z$f0&s$MYy|GQtpE?aG7nTb*4V%w*uWeA72R<^=2-V^u;jA?}V}+psDINYt4svR_{U z#CS3gIAEZhw`co-*q$x4Fi28oN#uamH8`B3zSvTw!HamJSTRnW6{|b`?S6c|9`+5x4cjj)tQOMT?YYqsylBh6<7XAM3sRA(B53&e+0Fz6@i=`4YG|x;)kv$S-8$Q~_y7oLRFdF2s^4ec@wN1{~ z>Wt!bmit*Z5u5t$kGuEyoa#luW8cFl2W-LRTU%t6H?0*EsxkBr$;(N;hh4iR>McKk z!14|kV27xwscraG<8BQ>ZTK4wUIEd%7%O};;w~%6bIup!9`<)&nzs_Z5? zA|2qynUiYgzKMwxXL!|;vemXhNSCquB(Ikci|6{d`?z;(YwIJ`-8v_pqU^##_obLz zp=#7?N+~X^(Bfu=1gvQ=R|hMy88mJnw7xcSCtCPnPL2Ur(m4}NfW0$|nLVJ|0F6NWOzIh>93z|qm6eFblV7TUUw(5-`eoN@Q{TBk%@RN0&7A%~|4^2PTvw0zjuIJqvwim3 z813dW>I!Vy1cv{6(<(uBmj_D>794L$d4C@txQ2OWaBFCwW8I!o$7_t&_{-M-Sb|tQ z&%~+@#qISWS6edIo+dCOy;VKm{+L%`k7o@RZ_U~V-U$6v=jZo??iWv6R*oa$&Ws+| z?yI;Y#APKSI(EDCAbr_#eF6dk7X6IwRr@2UiIwASwbV4!9k9yDHUISCGngau#vq@ab|C=~kx#WRurWZ`PeYK^P??5UE?r1gTl{ERi$R(?qEtj=M6bLgdd41$y15d5R8~e9r?JMsBPZLl>iAQ39e9 zs}5)Lw$;Ce42>b?wQ~ao{uo!D*~a zvLZp8OM7Q0v!PNyLQ^N-)#Q>6NEpD0WIWkys zW;~O-zkJ?)%k&(3&e!su}b)fO1Keow*0JilQC zz~F4EsAyaPA_#~i2ypvu^+Y6BZiS{PhIG`A*i-$2Av2rDi(aHLe7jL77kGuO6t=m$ zV!7$vgAfMrBuD+Af57lYaSS}_Qn!?S$P(Bqq#IkTC{Ph-xxBO#nV}NBELwSYt!Y$D zykco2xNQ{&}P{wpV|?q>-*d%LaU9TRm#3{o`fR&+~bE*^RjU_+KI!6eTCiol@C ziPGZk&d&bJPMx!o@?4YF$^}0Vb_YC*=w08zmHt<4K2ZQeRn7297rS zp6h!QUV!{blS;33gYes32s+!i&~1F=_xLXaH^otwBLWfRfZ%}X|2`Wc&59%N`1T&M z^h#`hsM{SfO)o9wW*1HeC>?cFf%^Z(28C|cub>@{NA!>r|ym10CN)P!ywHDOmPD>uXlQEQ1?axWf*v)r zv-8~dtL6L5dPI7d3;~*L&~jA)01x(44}L3`F_dEQ-8k!zvs98 zWCj4;rM@KA@Ffi6(Ewmjr!J5C?B4$BC^rC^beGndLpwSi-{s|v1R{C%=yy%5R4{zQ ztjvZ6lb8FolkE<^+x9>n!KtY?FUvofV`PE2ToGW%3Bj`V4?2QmND1>6tN{K`q;dX7 zN>hpM!nlwySlIwkLk{2C-j>r)$?@|$CyI@WvqTpdZ^fCOkL(W3Ezo|iY}n-$$?~7J zK68+M{01f08_TsuwXJ=diSgF?0(ea0lea>qcJ3<2kgSo|@X<}`# z5bupMFQ=7*7uj6=(QQcwd4}HoL)oku&UPdIX}A4QV}PRP;oq5ntO-7dmfyrBH?DEB z6}#2blK>!%HGv1?1tJCjfge#*Bc>`+`@tlhP_Hz|=91oeSGBYCxI}DIz?F9V)7#U{ zfwKtS8=VBB7407xL%vtQMvWiu*|cjI5>k=I0VFNPAmWIH3WCeNYGksFBW!!s;RlTMWthJF@gzGxgeMDcrGcr zyH~1YPvl4ns_Mqg0ZA37voXlKC zLa>>Q&+>f+d~zu9(QzAyfx*db7Xj%n>>Z89kGhPS{|Ox_(_h1sDcjlGU$U`!-3{>k zs7hd+HNfb}fIyAoBKstCV>J+nKhj^$dateM7pD^hs|NQut;RBxSWRk;cE^p66|+() z;FB-&E>=#cFg(|(fFQMP1+J)oid=>T^fM$AfsHlJA}0ySq!V4_^zU7X~k$W5W0$$V|x9I<`Ti6cO+y(u*Y zjK`mM18({08lVJi=coFAUj`V+yMSfuam&t;_YkAZprSHOQY`0pD0y;J_(Bsh2d zubBZ%>;F%NI%YbLrW{w0LG?Ko<$Kn9ez(AQWwKHihsr|u&%d5B^afNoO!5KV>t?T^ zC*^Nf^5RnZ?jFU5=3TXkH^1E!vR9%+ZsO%}{u}h6*W|h4;lzGJ(#C0t+=VBy0g8R) zYfrN!f#!~KNO=;GA(ibBti#yG=Q;or0xJszCH!nI$RI5B8u%zLKjk&44()xMx=;Jb zDmnJTFTDWh<7Q;DAeP{@&EeQoy+SVyzq5M51@nVA=lPraf1yV?x|$`GCg+aKcW^0FZ%)Wk5^q(Yd+BAHuG~Of?wjijOl#o{6DobNF?0-^A9>anyu|iRWJLG?Hi5`Mv-Ov zyZgprXJ;w`sceHKC7tDpSu6#;TO7dLLO4J>m@fIC{o6Ra^5N1GkxBn#s}Ed4(t7Hu zD%*xf9&$XWnANCA#CJAXBt=RU=9~eXl#hq(E)%M|fH{tW$iS}@?I1V@O&uDh zrs~yM+c&n88fPeorpFk?ZFTgjycqWBs*d62NK(W@z5wN!3x0fUdVzGTZg14D`}&TJ za-K=8DZIRt?stc*NK|-l{Ygbb1FI^}eW4|`((%c3#!W(Ce(wWW04oq_vgvz2H0V%KC@Erp|IL=<%dzw~SDraCg3KSv8laU8~ zGZ|MJbnuWTi2K;o*DonmRGj#EqT%aZ-j#B>0Q#0!aWgYUtL1bNcgkCwI$Q6lo}aWM zz;`nCm0efeT?$k;BaWw)aVpOQ#R#6_tt$fSjDrmG&D*D*Ha)i=?zb#_{HehHr>`oy ze6i2LBCFvrri$_^M#R$N%P8-+Xwxb+r|l@L!HvkU zB@n||mtzhKl{PY0*Te~_3O$599L{-0KE}4^xo(a9Df&h3XP#o#>V}P-4Yoe!CMgAl z>-*ik5*(loyf*cbO*3qT1o`d~rZS)JOZAbzVP0|B39Iq56SemC{GTTDXJzs$F3B~l zH;UR%pHgs^PqobdC@bF$+?~+Yx$%LB&t%FR`J}L+Fr8g7=N@@$ci{uR(e-y{Avn%Q zDFV-(IWWvZnp9p1)nHjZZ4DH`aQ}$r^H4&kSLv3Nblgstd3KewQO3Z)|2XwFxN37A z6Q9=zCN7d+7-;4oDN43_PT7r#D?Epo`lYKrkcZ72AK=b_ywe4sxXIm#06dU6D2fX; zKY-Z~#AdU8p-TfM=Z`y~pY&M(a}+zh5iXA(RWs}#qu<+Q$N1bz+A!4o5`hy>P&0Mh z9SXgYbPJ<+1R?mh z8st$3Tc8dHb|G>RaQ}xyCC)i_#H~|y_TuSslPMmQGTn^6HB~RQn!V(T9e~CcsdZyW zb^?r6+XIc8T1`6Jvwxy8S@vKjK=el1r3~y`HE-{m$i+)~9BhVJtr;#uE7t5}?e^(@S^NVD-~`{#K^iC~YO%l3M^ru znyohl(x0NDTJD!ARY*sEjP=_cp{AM4v>mVPt&Sc-c&c8(-uu-k? z{ZqRI@7LmN12Bk1P>gqD-Eq!7(OM8r5hT`Vm4V&#?r?k`ng zm>DpSfCQ}6Me-m%u6C}lk9+lfqds;#DX8Ea`#=y>O;vMK2mMq|!dU~xBC4D;h$6eZ}Cz}RVR zWwg(6nt;OQv8BSewT0%Y@tTqd0>l^HSND_(o_Qdb;bH~RMcnu%f<;5 z@JZfo&h#?qTORbDzD9#skq$s>1Bf*YnBq`_pb`K}%n|t$d;J<2OmkZ6GG%W}eteI1 zLcq!?4c$7?OxZW&rgJ*slpUzjsgv`)Nb1^k%+7AbC-dt;-p;K$-svf^AtvS zZ97^*`R%X!7s~nT1*Hv;6Hl`@Zv8`Xq=b+CQf!cRd}KjD#lrQQOiTl;iZC%VBV7~b zHTUbSus;xPZlBcFdaKM{C_a)WcD0G2&|gMd;8pO)*{pHIV;8Oqr=0q~eK*_EFyW@E z^CR<@a&&zB^Y)&*eNOwz>h7gO$Fb?QlH}yMu086*dNGP+PN$~{edB}duf;x1NSVtm z2ncSsP6&>tj9VXzjS-%IMKX>1X3i~pr;gQWwSWJfxMn_^$b4(X-BNIL=L3&5y?R{p zTJwmt%z{w01>Wg6pZ@D^`;K1xLQt%9(Y zNwsVa%|zx`8Ox3rd{~R)(ya5r@h*PD&ASQIrkv4yby?2JS8O(3bPx6>8rICP-YwMQ zpLZ)|gb#^GHPT*YaRf-=JrU&rnID zKIExZg;9=u#p9({HMRugotM$6t7gtd28L?Y$qytYRT#O`Q7y3x6F!7P)0dAv4bJIH$J zUg7RzU<{ukbCW_NbD5lv-}^`oOIO+%t0#FY2nhKU?{XR2OYw1jY+zwrrEPuhbl4|_ zZYqH{=^D#8&HZo?suG<;q}Pd9TKy8Oq^OiyeN_=CX)y1!+|2xIE-*SHZM6>jbxX?r z+`zGa%td=>`N2KmMHO{s=~#q~4T?x%h>wC+I>KZ%|Ik9E-*3(b9n|0Nvm2h3b&%ay z__W7+d@p>oRs@|^Huwd_r@lQhh+%lLw&G;St;dJMZyVKSYm#z(F1#12XW5BBf8_gNe~_RyBaZmd-bnv^mqfEn`w>iO^M4ws6cnpA@9_W ziyGD36vv8Qrtj@7&AP29s10`J&#PSQ9o;uiY7GK}XeIO{Y51$v9gfJ$MRMDTNXO|l zjA}L-7%Ftqllb2)1(zfy0W*R2Tk>35tXkyhETiRWgrtz4BQ4V+xMKPX0*I z_Z?zFf>%AOo_Sd&I!cn7K+GrSlOArp5;74@7CEjR6XT$%(sO(+_oJq^plX7YA=Xl^ z7J!NO_DZ|WmInN$=89>5&22`Pp=VwR86WBeIE=?_nK0OMh$Ja3fCg>IW-~gO-9%37 z>v^5_29k`atmWEP{2RZd>*(a4zTz>i@Zl*c-su30?BG=TS0Y%#DlLx7u5nvBIWD06 zV(f+Ndj2a%(el%t#|yNpADYnwkNb9gkrZ8CbNLjLwV>2BvaUK=xNoA z?Yi;i)Q=J=(_r*adcHF^ge9kjie50sZzSca1{gV7+%4yY7+24~$TqG~JvCp4Z|kRsOmXW-K~?xOrf1~hfUIuAl0x9-V>avz3^#(r=gP~yJRACt>Dpq zQ3@=30$OFPyrh+&vR$ODGoFe3{6)6>%B4;hNxC5w^QJ}`ejk~DpOTr#ueWtA_L(S_ zdAN#Dt-0a4%;fVtT*YYEtMh9V?{o@}!-%gxn=mxmfG86}Z^lr(Eftlr($y=&M5d+9 zzG67CYOD0KUfLpYWj1wjwy#T|wxbe~keCB?UZKe2fYaR4pBixaRmj)=HSCwc$8ytK zx>xK~$Yt^i06Xi7To7g~cmxsD44=eRlFk7X1N&q=8{pw&h@kYUhFIJEqI(#PFUtys zQkU!s(A+@e!QgMn-D_=0TV+Dxz@_h|mQu@F8MpP>R&;G)dKy34tQ#InLgFq+>gzS)X1Kms zo3&XtBcDgiy`rTRXs8ZvKzH_wc4nFhDZVeoKEt7}wE!kKNv{<$RQ zgq3aDs##n{Ft|TEGjDD_vKq}BN~bE~(;YLsUN4#7)3aw*Z+O_hVVdM!wl$7%e~IC_%^3KQq2tmF314IpP-2PT9(oFCiialI9!ZnPPssQ5JF4S9D8J9DB|~;Ju>NN5@4>LWWP<;uRnHw=uS^ zsy}@?YE^H=8Pd3duxYJdI^s{$-+06fdU@%uX8{<6fyiA}OKEnBFOo6oYJi1u-Md8x z6!mjJ#z`L-UvhJ{5BZ!0H(+D8M=RlKs?n zN}QlmKcoK)1x;%(vD^@Y0KL!?-3OgkV1-AepDXC-=whkMJiJOFR?lT->%m9sHlon0 zJtaiB#_PQYXwLJBWba@p(NYL}%fy2i2T>sffQxayF}Dr47&?50=a$<-E2A%?S)VL4 z=vCy$kIht3Kw-3=glz|uAQxs z;k9O3yift}(oC+3L(V%H+(z?)Kmxk{w2oLf+J(!|pC_kYWoUBCH9^}{YHj-zVZNUA z27i`JgtBoCivZ8tH8pm%8s*bk%#7RzYffrGC@J=8Jtg+&I#aFU=nP(gI;-aQQdYH0 zL|-N_cqP>j4Zm!E8DFN~DrmUf3TB|Oael?~;9IkkUS9xy!fW$-74Qjk1{?0y1w?Cr z^vYMOhGmx0sb9UueUiOeM8pi6RvLxohpW&^#Z$l0D@5jxIziS$=1>jH_8f)d=9lpD z&}Mo8kB5I`ti)eK%gwsEol1jk$2l^+=o+4zl4E;^oS)^8f6lZmvJP?vFP@|Y>UTob zf-|01lp~>~(_VWqAG&^~6IJX5XQWnizoPYW2q6Ac9Jf1zp=z{8e{7SI+$+7qTU1d_ z^bA&?+DW@#l~{^daow9=E8wVvhm;rcrrHG+|LGA-{FvefD)H|C+X(McL{xr+fw6VQ zETZ>Y%n{n9r#0CyS@Q;v-5}(+=z>F+ey^6gi*{T}pj>x_X_X@9$BJdb2%oQO zOix~=WzHNvxa$Ya4zvLwPpjk8o1bsKYK0xvBU}3L;a2q59ux8WRk&rgF`{vgFQ1W{ zL>f0er2p8GATG$ZZPXGd5==Qa_mg(4+r!;G+(_)aVZt$l7z~zf)e>Epk6eBpmDJ~| zUs01{tutP-HHeUF_)1|}IuU?G@>N>BAGa~AnI0N?8kPtaBkX|3rxj&j>4k;uEVfOr zQ0}S(@pZ7?zFMm4$~<>cpHhqR1TrCA9%MsYtEigzzY_o z&OCgWBl(IR^ggH1xR(R}*?gJA*s46RA*$JI)xWSN#ijypfi8h^Fi-Z`CqBa1l(wRQ zF<$htaJ@@uT$6cFc2opUTouF1i$Gop350KCKSbGqb6|$3A?0kt(>J-z!~MQ)+fsR1 zEu%c`em+4C^;Df$rk@VS(H{KOZ}4-6q?Y{CjBPPl^vs-g{ zxkjKuT?GTVGXGS&&ZFbfv8~uZuLRU5<$cF)!G3E=o_;mYT|dTtgngbOFB==W9Q`d; z6|QFh)i+x2O|5PArvVGUT$w+;yyIG2-!ansid|esXYauE1DVi4j^VbbzFvJh zuaKRj2V+SOLpnk#mtzXgv+N9gk(1>Z{7PcLZJZbW$rr_D)K@%l4CIC-RA| zxkFFAs$u=^GiOgyCm#w4O$meq^BKfW`zPzm2tD``yH;goFZ>@+EX!!OmSuJexLx<9BX0mr43 z>9^a85jolWyksTPQ6ZBhIq%$wo=E7(F`Sip6FnrhM%mTGz3vxexQ)IW)eE`5N8>3Oqq!Jhr~ zqQwhft!0|2gTFYA3*2H2tY6XVB?NzSMz|4{1N$_`&~Fx3-SyYisCZnt(39_1{vwxf zKAdF`7be2-KZeLm5%#(4?mq!=z>hY_)MItzRT;=jqLuYmr#?lE*KgBpd)?o|L8WF~ z{tL3f1|l6ZV&RoE6c8Y@uql!w-u;R@-2hbiPZAv;Lwq>}1j)@!XK=A7#01xKWOM=> zl@#*B2k-VS6BdF~-QAzm4P;utLFCWjey7Gojg))tx4CP*)sAPH0GUxwfJzdp)E2L+ zGBS;Cc_UU<=$EkdEcKGsH$S$D0!Qr3LEy{XXMC7Rl4J2tk@b!%!Y^^G+q@x;!FoF^ z4Pw2ngl3AXI}y{(UF%6h5cE@(&}%UQ-R1k8N)|PeR}4=E=B>ORUVGn*n?^wzfbo1l z$1KA#0`^z8iZnz<4W3UOIdON$tAFCCp{%k_(eZlXdz|b;8opyoao|*$ieVihKxjgh#1K0j}a+gpYVo^0YKIP)dG&GfIa&*jKQFI=A znZotc6)}%)<-n^|DL}qresLzQ27oOt6)V;No8RyQaxhG>IxQ9U%j2)h$NkxrsseS*CsZFbyFwSo|@n3Y&TBbuGM+m^p;t9WqZ$m z>?Bg`e71jWx0rH+0Jl43z5t8okHL6t9I7TQL~Wm+O50FAt2D}6kD4YC>72P?n3)Gn zBo^6QToM(gnW~aeBw!e=L$$qP^fHB?k%1EL-th#qNktk3Z2O{-pgtTC{)xIMd49&0 z(cjIdbn+a1UN(9R)a+%T2|jDlQCJ|1{hSdz9-9c~71Mp^?ZvkG#KNI@>7dGx7vl4@ z7q-Nd{C-<2x?640WGJ7=k9GC(+ zD@|m?Ru@BBoy!+GJKQnSgraK5bDSMKqfKsWJ%`C(!Ub2~$)3;-+zG&>Pj1`& zidVn%NLCdpoXHSIbDTJQva-9V2QjY~nTPhaoJqx`g)#r0ZE-n>WOJYm!8)0;in^yD>t zVe?+~a8LQ>-qrQS#OhOfm{~hX-2xQGifv(i>rxk6cj27kWe_uNEr}OKI_&Ng#p}oO z4;HrdN-6B}o($QWC#mmQ;Ri=Y_W0)xTh%{|D(-p3RA|jR{A0a7u`13-WX z;+>-K-9lUf8OLDXCaGx54o=R`>G_4{bo=M(&kONS?fB z;-PE)LP3TaP9~HQ=?R2fOZ7 z=iyrcURjlrq?sDvIV><_=A7u=mbzFVOGG!{gX<{@adX{p_1$U3f&LHK8NK4Zm<$M4Y=Mn76Dwykq<=|HuTzpC@i8VzAi3`y`cmksW2G_#3iHt-h! zT7?pQe@` zUHyXFQTNY5UBxlJ<`)m7=rRD3rO;J+hi`{@?$oa+b$AUp1In0GX+z#YPqj$xgsRDQ zI#j{axQ)eP0zeOs3=dqsE>s@rPcH2giFb3i5=_T7?b z;_k0qBMQZb8DKjUu;cc@(L&q?gq0XVklO^+l>7#JatE$}2p<83e{Tu`#)LY#gz3)a zZMEU<@oEaH(oc|HFIpM)=P7w-t!EI90H*dfurse^w>RLH??Yg5JoTzoy~3Fsczx|? zH1_t0y&$3?&ykFVYSgnChoe~T1J9CZiUDLIW!Fb(?T@Ht!+^pSy)+BrvuEglwC*Xk z6t7&OuVCPrCYXtaAI~DlEU*=ji z)%xoBC{!<2B;v&g<;F#s5OrR`Rd~UP@EuIiQ9?#tRUhHl!~m+Yl``GW^yLE zJ*#jIt~SuL^H~N6!s~zrINooFPz87;5P5@>UV4 zp&3f)hM^lJ1ZL=(fgz+D>4x_j@8|iy@3G$xdw<_IA35e%>sr^kR-UJvh=%}`aeQCh zSBt(Iw+yWqma(Y|sg%Au-QPDirU;gf1rJW6_y1fB5NdXN6D7{5z&1v!XXLoy=2iSK z<{D#dnSQXBqk}KRK%wy$P3lh${F=fVuy{-)?-u&PZyPav#W~|46E!a<8tr|>)zZ$&sP%D^=i*RRpw|R$I0yq zXqn~)Ez|SE88$$s4~c5!5-r8)=lj^cxV;cX5d&->EtmjbZ+zdCr)RJtp@^NHJGSPz zpnqcq*v~fm-=N`u<)ML*gOm(am)qCDvlkHmoYMj!wS-oZ8RAI|hD~nJc(39&OZ3Z1 z!xA*uk=-bMYi3ZvKb$7V0%qKvL;M;3(qSb+?wc+Yk}MEnM5!CZizPu%xinAe5f*dGrthFEF9<~ zjO*tDSkqoej9{m3lK94v&=}O|BSX`afYqv9*;JHBHG4+oqw0yG~ob9}G;3lpP7^ z`omWMv<{Yse-YW9VYF1!=+g2(e&E4E3|s^lLffGAeqE(YcSJ{br0PIw;wv>!n;Ts( z*@4 z5Dln8PT@%1Kv;*XKDn9AYNkKM)Bv=n4IMiC%+7Iu2Hsa~`e)s=fG~cHq6R7aNy)!Z zm^&?oSVObgph#UQshN9E_ftAs)9Ctcg+jdt9k5$6wTdj2$2XkTe~oiFb(2&%w*jvOGUEUb=z1MAt%da28>4AE{>O>W_g=-lvgZr( zBZ2OWDo)dRPVa2`+DVR>vr+~iAJKrg5sZ0-i?Og#$_Y|`LIAnB7{Ad#cWFnBp2D1W zhs!oStZnwr?lstqTp>&bQojKPv$%qL_bCf8#8|tV_oRKOZN8tn_kHTyg&cB&qL8)Z z%N81UJ#l?6kHyNVKxc|nG4L^HR`=3&NZ+d)ZQV{#X)uKyDBeSgy^a2yWEEMQgaVN; zco8XrN`HcV3nzUhYXpC*r2mn_rCr;JID1KtxNUcI_3%{Br6;d5-%DJ^eyRqyEet8JZiZ?%4ZETEDC=0vwnYEr?@2SI zbZjS^UOo&~&H*;|GQ=R0e?&B`;{ulY<@+W>gM-HLfFy>E6BHJbOi&SE$$%5jabm4V zdG&V&Fnn6exR`k%y~C|7iFnx=G(;m?imYXCRKSAVykZmLa*B&3|D#%Qz}Wm4Oc*5b z_antpzHUD3Q#6|pqer{7eUSP_3@;4X9meiu?oAYXvl>Z<==)B@lBd-DP$8&GF+p9% zeWIBBrO0RJi*Ci<@+&#>hrgJl9sQ8{+QQEmNvUKE@T5Fu6oKf92~!f~ra830g|M5X zUeOT%4PwyRl6Uq>%z~<)g135U&r~!;b<4J?wfoz2Mm^-MORXaR2rw83P)i5l!7`;m zWQ&c@h&7dAxe1{%q_W{wpx?Gu?}OYXcvLS#!S5Urg~=fi*MECh)Y+fJyc_ng01~T^ zTJ=K8)M}5loz$B!){kdJj^DmbaOmTNfUdZwnf@eYF{&3}yEG;CJJ1g_r{1whD|ow& zw~X=9Eb6)GW6sq1=d6F`=M2C0qg@%eKG%!Cni+iHUnzh44aM2qm_t+i*4*HO07EiN zT=+i=auqDdpB?zJ+K>Gb_vl>i1kHF3vk1b|w%GRoWj;El;WAI=8^sM=Svd^X! zT9udBi@VoBJayCl>V0~MJ%yH?hO_vtV-zYw8$qS}B=$cmzJh(9!JcyoB6saKD#hK= z65*tEC9tWE;^eu8)EFeW0t87EZvQyl0e7Xsl0%rbr~-8OT-8kBO&RE^}nrSxcw;o zACha?%@jCZ-L~I>NiGSTMFF6hH`pjm!<2ZQ)rjay2V|bL-7F9&k01@! zP50KvOl{i`5-70YD26`|kS4%fdr+wRfCo&OO3uu4f|Az{O-KomGB$vLulCONoR@Q_ z+AW}pfxro$_xmY(^;3Y~enFwoWgc?p%urG{NXb%a!KI(qE`#n!;BX#j_%*eqtUb*E z5riLYZ#d1ckkV6W1h>)mu4a+_HYhq_Z|r=})++_@i?!Y%oLp6Pg|QK zrU?*f5x)y$$cv4A-lakmx;vQIHwEKG(q6zxaFcFTbS@WpNNy3j*`~NaTfCv*rN5<( z&WPvlR^{aT$X9j5H$shj&S>HuG;M0wH4AQRY!h<2Qq#chT(j+Dy864IDda;IrmUA( zkpJe)>D!yX9sQaGnTJb5J?{AZ?;f)|G%s61u1G6=QlZX;S`kTJd=hjE0b|kIE|uUp zU;$nxz-Z$L!rmq)k|f3KzWCWT&ZoQUd-(N16cLdjx#G_XM?j zV6mQpj28KgULBNoEXveiaEF}OvXNp6=bP@JPzCt*?lq;x4~&V#BMC(B5{ z0rOWX8%2(?K2$d1n4ETqd|*isW6#xh-V_FA87P!@;F5Fmn9F#Rr`H)C5CY ztd?^P!haQ%TRx`3IKI`<`Xec{_(H8at~gxr-PuLbgt|Kku}_13e_&G~3@Omdtxs9B zAwmq_ekduUbOL6cy>1Ff%nTkzI+|SbA3;WDy9?`WM1FFI5P=jMFb&_@(jSmw-b}sN zwth+TQ1`XO$0iI(=1RxrUeqTkvl;`PhL+z^hag21079kfgE47%QZ_B4+^G+D%*kHB zTuCviQ!xL~*!N8?qLBtkK?K>tun+4%caIksG@L=hzN(U@zhK!z>IEbWmJ!K5pLzi? zNaY{z72GH-Aqo>0dbAkChydm*XA>w8u|8PBMV^<3S6>!4^@TO^&@EM?JZ7k_)tR{f z#SfzS4PhMH=n7!mz|!ns->7*?Juclg8847@Y}{rh#0`re(=KiMoh{ne&Q7(awl*WJ zRHjC>Ha)9S_UP!y#K=hX!%q3tQ)b=YBw!#Ty|GaaRm#NoMkq zo#MxRobiUsbw93Zmw83b&KKPBWR@h>U=in$d5l7_b7e_6Y%8#FR;u<79O}|yhSLZC zX#h58mmK^50>ZjN`Te(DyI{6~Dslh7fZ#*4i?pJ>eMuCL7R&4C*XkM?cfnA3Qs~7!e z$)wR^w*(qY$e~eP1+*)bw=jnYlRG;Ihe0ZDE{|3;>bn3~lj}^)B05P%s zKjXo(Q{MyWvA1IZbU`h$b$Qt`Cp$X|0MOolK!18sOu?3Q4ThiuyHYD$JoS6U%G=e& z@R%5yYLD`cq-L7;W5RVFyY$s_F}fxLnOzP)GnLeJbrn=Xndxnl(MI(&3!xhb4cUqQ z)cNo)=2S}d+66amXv!-aM3^jgy>V*4_SR;@Wi$;eEW< z2@qW~%F13X?=Y21bEXvQOdB^MVOeUvHiIi`tkQZpku9ymMj%d!cH^in_ zX1(sDEkwvCS*gFN_7W)W;6dzs$)_IldAj)c*9h{Bow^bKQV?=IK#0xI$jw~JlM!Hr z#ANdKr(3ZK8Vct$Usft4DOZZppA zW(&$fEtrPBhSR+<0>mOccCe#A1Dy`Q9K%|(6iHindM76CEOaM%?9CHhBH}l5LS^SBqJ)(#oZI2Yv4?C^r-tiox1J1QSz^lu+mi@l6Oq zV1S3+CHDQ|+e&?X{VmP<63@*H+Btx;$*l>H2G$Y;9rnfdJBJH&O~-4TZQ8Y|m!HyI zjm!Ce1+b-Dr64DV)#y%a@B;``V{yS_m$RoAFL^qfcgu(fEJ%QY%~UCZBlpR5)_AhU ziJg0q({XYtcqiJr@5~T!{Sl|dxmuB(Exys~NnFUvQ-%@8=0pbHGOCO?=Kn&#`nYr% zoN^n!Mc(r`w3xRd4Dp8MJv07ot?JOBs=^K3>7 z`v`UglhY$glzb%FjTJ#V<&U}A;f_0tSG&Rmzsc}f;q_3$jGw5oUZoy(k_lHG3tL!A zaG^&eSkNQ3RQ9To7nbGhG3lJw?5JHLF8gyU&FQP^eU*E3F+Jy3cDMNI(49E0@{!EP zbRQRP0k47OJwLm~^sVf`WRcf=jfcBMbDE7u(Pic3HD85N+;`Lnh@?>~Lc;(?TCVAX z)`a^)k_{6MAnZN(gRq<6zK(aV{wBxYa28^_XyvTNN~WaGld%n^NM!y@kxeo(PgJE}74eAqK_4=EOlM6EdPZ^g@a=^c* zwA15rq~X4YGjmD`<34Pg6kGGKqqPjsP>>3F+k-ox>gw%36dh7UjwXSk|5#mwXe@>{2!>a6tcsm z@t?K*uGtn*e#a;}2mbaE-B>%RTxlx&c-~kqx>0!az?Aesr&6-tv70T4Lz-*9G$#S}=2R$ZEM=9NI+y z79O=Ks^%BC9>5UE8l}bPwE%{I1g-Ol;tgNX_rq<0Ar$10k_S!pnSd8DR=bC#xe3AnRS6 z#j%Xhvm+wKR+T(sfQG`wa8;A{Z<$godPnMJ*eB;+sm?1zh78ZUWvXNCdSGD zg-`wN%I0$X{`Wnj1w7UbWp6rzZj1lFuYIL!w86B2uaLSNg*{7Mncz0x$zA0l! zuG;-083tSE`n`XI!}5>-J>s~+Jfyh+uy{|8_p23^T*sYb^YuF5O0WDEaILGyzXo$O z9A?|qPLr!xGz-6A0Em5UHM9Yqs0|ZfaGh5IuK6PAm(a^{hWn;VmZA%1WnuTh?VA=r z2<4UBD|U1eKaizQKf1aK*Dm)c2QVU-wBdn4a|(cFqv&%OY}kH&!hEhj;bDaYIPEWv z*=crm|MPtoL)Is1e}wf{{hGrbR{3yeX0kp0?pQ`q7P|=T`~#3&7=Ua(@^+J+s|7Ou}U*_IF8^UkE=z2=J4FqN4pe z)}gC$`u-KcoawKb-)t#Zrl*?xeP$hT{3QLHl4@6O1*xT`LW$vtI2@GlmU&SkHX9Y+y3kwO6OFUETg!Cy6OmM}Ek2NkvU%GfPeG@M%r>Gc}F*nCXc@Vt3 z{f^>w6T%VeR%&lH)6;f`#6O&*?We4%lZjT_i=y^}1Fd#YMwjH`=_yr*n!gN7sr=(SnLq?VNlh*LWsWiaaNkJ}WXr=JIp zPkh1TD{58(=-|;&9d=$`WpWA%!G~S_jlO5cSD-SzeSJ&I2>|0MS;AqLcI`d($CU#c zR^#rrl#pf&2IHrzFhKLIX(-Rw;d!h}h#sAj?~BW;)f0sZDDys6IYs%{mRW(*K}yNm zLW5|o=F`Uly=#zftw;P1?hDHx)E}GJ<1s$ndP5gFV`3;NUC;;E?3Px}mk#nV4F)w0 zdkg1@!DX&Tl0`Kv*5<{sW5TJDo-;e6@g9;nDDy{+(~7ojrKBx|7t$nN9J!8>KZmdTT=mZ>(J1Dh(Da|6_AMs1tLZVbf zr<*yta4&PEtJKtjrcCR8L4It;kgL^0YS+{4K<_mhN#*0Y(xdtU+o?sxFp?`YgC_Ib zecpV|i0H~!DYdOd*Nz->vsS*XM+&o!Gj=^5oMbm10X>Cfw>vucVt@2UBa&aI7YTP! z)vWNupYa8rCV8m*)C0MtI#%EbUxd;Lx2nIYkw-ZwWZ?&PG^YVdz^9x3x+emkqsMfg z>_2)Ua4+eo(6<#u-oe2zn(th9Cdw0Iy=r(qH?jpliC+SLUMu|SeKImK^?I*ruQ{|~ zgjurpn;zkuAg|6-_mcyA_qFm#IeGcdSa_G5($AA=it`5*-+kfmciNxoJuOS2lneB; z)yyZVfymDLBcIBC&BYoxSXbHo3gEqe$o&Z*e<2)07Ip;Hvj%4ZaaW5sL4PGZefQ@O z8FI*1vM-?^X3Nm%w>t^EuT;vyd2KO0b3sn~b^Qn0>;54mf@WcB{UV|nnghIHhR26D zs{KfW7z@lYU~4l7Lp}a34EpubvN=NI<5{ZokOA$W;+eJuzhKT%wYq_0*sCHBt-m7J zBNTcTdy+%9`|YOxvJfdB>k`40ubE}tJg)5;En5-SEb@6q9-Gfr6(8}y@O{$>$cM$B zdoQjVxw&DJ0&LJIVzN-HX$;{5Z)iG$jS z!!$}tx##U?2R|i;UAJwMt4mnsdum-*M3aK`!?$+^daATamODJ)xTX0`#eRy_j=_ELT!{UrSIo6%dNU^4?2c^vYzranpB^)Cy-ix zaPH8W=?E*z(pFaPidn6DO#A7}qWfd$asSBpgN2?|SLd0TYwTI6KTa{h_*x!&QSSql zb&(2UUYcMYuHP z*YC0mDwsuoNZ06TSb|$2G6y{I4%LHYdX$OstV%N~Kt10A-}u%NJ|aS`(q33l@b#*6 zP=l1GZY|tO}ye45}_2Ytst?J-**9M z_2y>Y)`H|l48U9;ArW+Uci)=TQ0V}MBFDc=9gJpdJ)!WL@bwjKG*@|%lF=yJmdJjk z_@52`zU9XKAza+Fsny(KgHX{WXvO9H!ZSMBaIN0bmy_mn%nT91At{|pX=CGxAv{Yu zZyXz=xOEpG475+mk|QrBbyW{Kz2p_RZRcrf>8yfoK|GFlJHGMGKcXx1GSyy9S${N_ z&=RKGVZVIuFoxo%U^b4Ou?Dk-529ih7SzaHu6X$rv9?|9y)?pa4Zmn<|KpZ)rgDdzqAptn&-RyoH3-u|~~x-zlP` z*B#0I_mCypI&V07B(??J4AGg_?cX^MWM7J03OO zvA25{tYJT6>Twc@%xAx4Z48xWNn&_(IXXmi2fmfQINn0i5o22zP3oc z-Vw}z6&6*|!}XVU(&N-3>(;#ilNAZ(+RW%@O6IERr5a>GIW|E$x$ARsBRE#zPjX)- znsj2Lkg;IG&%Lf*-qXhRb9kzL<1#wdQC{-7<%tzpuN@v|-`!;>yd)T*iAJNNosX*3 zDRsDwS2(3VIiq>ZfU)Pb|p1I{~cKB!0NB<&EOk7^x?5$Sr%w; z8?BnTlvI#zIx1*$CTy01tU7C1;LeL*tMB_H&s@nY8xz4(S*$MNESK0tudPn3z!7eG z>{q6=6n}__(4UU!?h;@xFaHSq^6fvEJo{g31FH_)DrJqI#Ww5pZNElDM!uWv{!tb3 z{p+36pB_^l7t6+LC0#uAGp&+LXM;@6m)3`sP)BivcuQO=Nk+x+_-D`a$;{xw=&#b& zA1hkr-@J+O)H@!Gl7!E5D)js)DY{b}|MdDU6}%2tRVpK5eOsM6t_K2Ld1sMSz_yg% z*@A-(;3Lk$OL}k!J_>k#sQC1C1U_Ytzc(ficle}&*ro>YWa-GIA!MjhU^h}jSw*u4 zW)w4oUx0mrONO)Gc{2GsNcWnVHOddYH>l{q(bRjb|nxSD9^l>N4EH)wAvNPovXKE+RGWJwfQ zf5MPs-9=`Os$SC#$ZWFSPq*C_6x-mVrgMazu^tzk6m{ODj#KatVs^Q1o8z_D))~T+ zaKaIK++nhjja9j~ztEy7J26{bQs})Z;ktrW(KMYFu5U1PEM;lin{z6MTEOY#S&65a+RW<)CCK zRI+GfOoEGd_2dy>L2Qb|d7Vvv%2`r-JJ;C7^ZeEP_Ky?3%G?^rb`LX!ew%Vk7b*+q zP^H^N<1X}`LJu63!VJFu>^Gt@it8zHt5?=y=B44HYIlyathWix_2-ilero`b}e`f5{w-PP_H7 zUSAd&sg7W_#fYK}PlCl-856fhKaWP~Dm=zggufP>;nT1w;sxMCO4tb>cp8sLWEHNi zt~viUaQd_sj%)dVxm8^++!*4f|IXwiY(BTddcJa79x+r`=e49{&Cs9mu36^jFlsjY z{F_y&||=_Dhknn0rY|!!c4z_g*QN5m9=G=zl(#iwOBBUVtG9 zk-P>S(+%&P9C+ZJpd-xb8}HMV&e#7SFd-fj@TsKlYUK&Hp!iQywNg`)idPs^G_HY8 z6h8pdaG=Puc~`Qq_O^9d5x$jjBqH=0#px73*4~~OpUHq7HABm+46BX#;c&F>Pqy4DAF=xp_T}w5)(Wz2CPvTzR&0^mH&HU_}XcO2R z$kA{8x>Rd3+W1bPac>oO_xzyoTw|6c68$m& zQ|6w*X0*xhR>ga%7^i9C>7drMDpgrLU8UCeF!*3)475M5G zAIE5WEYzKdxjva8(127nx(|^E#KdE+tmhXMg^hZrXQ}9IoHBCF_GYDY0{GL}*v5mu z2_#BpVV(M)mu7y3p(_e>=e)MvX)kD}%EXHkDJ)rsg)nT;5Dw<2G%p96MXv8WR1$qs zTDqH`C|TQ+;aovStFQuJXyDE{Ku|mp;Z5n|x4vTDbd9rBVWfuJc-^nN!g2jVH;5la z&U4j!s-e$>U)W_mbex}{F2GiEyg0({ z8APpo|Kjq}D3VtNKzG;s=y-p@PbW^ks9-`Jwp>@K@#ZS3J3%1JtMk#{b8~aqRH)`} z4{0o@B#GQN>q&O@v|kp2_Zqqg)udnPv#jg7T`(fGw3L++5ejGndry8*opS&L`-lt$ zP6+bDhfgKV=DXQdj>V5`dPIF za>}>8G|hZNt4ttwJtW0zcJd8-hD+)tTIa0B-9=5FBi}@FUi~Ee$1hhO`wLa4!a{j?DMVR3hVV^Oox9UJHfo*3 z^m<)h=L#M#{9g@Q567pwC8>!l7LJbzmT3MgWGos{9w>EOwd?*odPyK0sYPR!LKszp zQ~WNnyS!_wq!cnZm>+&u_6mAwAj?>yc&SgEgV}p0VqznLZvWi}-Z@mi=cSKt=1t7d z_50&|T$;l*HRzqB!wv6^WA)m18Mh_v^{Ce2eXc@9ixB!vQ${^!PNPD^b=^V8D2}(SUQ}Cnd@GTeM$<#+o)e0H-TIpqN+WG zR)}G)oZTMiI*lRXVHcD|ZO1Na^AC+6E-L0``<6GQqiiB?#0=L2iVX;sYNul*z6N>V zY}|FjGMy?ioLw%4jR$r~WtiQpit1e&J<~1ca}YlRoyJ&@?GH+7h{|2Iq{VopB1gk+ zOK=l76cZD%O2_cV#Kz-p(XCEKjQkDS*ea$*r={sHo((9JI%??AH7wSl7=gIv9oz7X zsqaF%=GY@IQ?0*{uVNjJUCiRa5J=bBH$fuVZ?0sddnK)-2gKdzu`Nre0tO*0Cr4JTcH$3quJMVR zv{>%Xj>oU3X9UZuPamEnzT+3lK|7AGXnUBUb>`JYSCV6^s#M+4$dV_+6xzG)a0H?zfzaiJ?1W!>-By%P;cRc4BosCj+U!`w*1RlN>S4Z z7&-j@p#NW&X0av44(F%)KYJOK>!Vv**ec)poF46JNZKRAP^FxZdITcgTD@E$I*L0U zDplq?!1L*)a_BTsVx`5gQ_4|3WlB9#czon}a;Ygi?@bY)mXs+)am0MWKCX+WK^CA) z-AnZToA&N*V{9r|gXlK7l7^`?gh1jRZ(~92>-iWm>ag>$SG)h(7;b(5n9} zM#dz2M%{NXH2U3FEF!1Teg!~v)$@)GK&mKS%HBSL_bXCM z2U<$+HRJ?|86InkB^e-SE2Su<~!))Wl7O-ER!}i-8Ktdyljw_re0U z#j;1arYJYp@^ERp2i@BnUbi=DwY8mcu2;Ay+CwG2Q*2`9sB^^j02B$GiFAEn{DL%t zfKmfJ(s<@91Z1Z+|4~Zs={GiZv(Dc}MTEkIyw3WG`ICZYES-?OQ``SXA4g+_9JN&AjKbQ@~72G> zcxldg+;?v}n6DGiuK;f0@-0o@Qz4s*%(^Se;9!;T^*s)Ydb?NC(>n{g zE+uSinJqisj*g_yh%X+C&f+(dM2t&SKF1bD*BqJQS50TPPlBZKLD{ZFC#?Io4}o~7 z^|MY+f&Tp<`j*5eK8h~q?v;rm?&_mENmjau$mC?+`}a>E4`tLF;K^i8E-sxQiOLH2 zR-!cx-L=okyldGwIXi?JcdZX`fa8%sA=%MoT6!q9GK@?>MkW4&S?Ta4hIdz7)XrDv z>wmmWW@Cex4$`__oSM1Wa;e|$UDlas3U3~C@#~M*4EnG#ty;}>0Abqn?dz|Iya=iT z$G7B(lD-p3nP(H88=c*ZqUl*#KY8k_6{rsMK&kPSrlx}X;o}YrMk`JV?~EbEt|}VO z2v>&9xBqH(m}(*t1}soMwq~7#YR%@)Ms83(j_B|2kE=Gbd6wQ-sT8{5t8r!HFr>kP zpJNWa5s6y%blS^&=G&p*N9rGUtyo;JUY|DOaL2|%Y&U{pUeop@bFk1?r~mPm@v=cR z9kC49ib(DBQSnq?~sha&mJK88vKKfI6JqDMi^u zIh0o$-Rn)7Q)9>}?Q4fJaP{|DA$A>uj+uit7Em9otkkS=f@PK$ye3djDuskN%wy;% z;mUHcFp{zhnc!d(`#lwivKWAYrnB$^H^lRcBROj3sF8Gxb>HUH zi_?`I$R9Xe%#s?If>F;t`yQ`=0rMODe^^DFE1*>wv1_p^at`tpypo=8EQh^^c$yQD z!<)IJCv%`medaK{Jsr*6RKhPRfon7b6t@d|6IjvFJ%z9Aw*ey7HR@SKa`?3!chHL7 zS5NURf(H9Vui8PpxP;xz8WgLzj_SOrbRYW(0w(UG-QBH>-4(!c{>2YQ{(D|}TwIly(kyFE2=Hz<5 z?9>!%S5#8m6A=A z!m3l0d848_HK}w{0G#57HMB1#Zf;Q`{;86)fx0^D9H4S{Nb=9m$?0458$v`s|Bz38 zw91G3&=+yXTlP3Vvtc~%d)L*ab#p|+I*oT_t!&$FET~j%s~MGCoO3zIOwx#qu6-4l z@H*iX9@zczjiow^x@gZ&iHt5oz%eo(T+LIZ1C5-C+1uIelrn6*e1)>}_wVB5ApQr& zN7Z>pz}=xHrs-hqdVjNML10mGC*jszcH!sh0|RV#z27adDRezu%&4|)uFCXfVp?{* zZwH!DOySQaQ~T&{|MM2zy)Eo)HO)di8Sd&1HHGO$7ab(_*sb?K?Rj%M4Y0BQ=a>g) zH8D$5=*9&Re~$nCFF;M40y=zCn9KhR;r_=VVYvWK<$r%jklnxH`A>_F)W1scKYQt4 zrTAAV{vZPR`}O{{Qs?kJ_Xezp3MdLgDIwv|-6bUq-Q7rc&H&QFAskSU?(Xg`DWy9k1_bGljse8) z;hf`<_xGOn-|zbV__!`E9_D%W-fOSD*53EJ*Lnu7C@+DDL5Okd)-6maNipSHw@}w^ z-MYh#b`SW(<$?q2)~(w{mZGAHQlg?{ica=smNurhZqbB0e`u9KF?{m5H8${-kONwf z#fLz$vbTp3Wt3wdJ}!>7Tk$LK<236JhFH*BJesFr?GMfPUGc6iQIB) zmS%LItsuMZ9G&5QlD4~Lir`R&BR_7=xPLrEA-J~~K#C11F3V$}roFpRK3l|( z_F`UkPrvJv4$r=zLybYH(2(u`y3b)_e`248YB1@tVf0ffGRe+^&>kLAtjABQaQD&8 z2Nq=pwSugRlU7vUoyB~~#g8K1$8lttO}8e|WwI?a;NK&;I6Rk5SYknHeD9xX1ek9y}@lE(}zJ^F` z)-L5viA0*27YgdM$?A_ziy%Ph>ZY1fW^!`3=zwdqTepKOZ=nKLw}D?m;1^h%h`?L- zfxjT&S1b+X*Im@Lv^&49?{Hr~D54@NB?bIdF?KRFwS!vN!|nwXo&jA=S*mKnH05OZ zjO}fi3{C8fOqpKWI$Sro_2M-jaA|7_GbDR$YhwrHdo4im=M#Lu_4UWh6l8xs0<#vN z(3Ddo6Sa3TCF5dZVPc^W#2_Ohd*Nha#-}VM{&#cWKLH907|el>nc3CVmC2Qz$==DF znU$B9mzjl)nT?GRc!Ck?W(PBT&1eUG{#Pfz`VljQ8ar7!z%1?U$gcY}G_rSw2~bd6 z5A@H^Uviqhw){Pk9rW*M0TX1tzQfGQ#KQbf-$2tB*Prq!TD~^5(Gs(?1=ItKA;`nV z{^HN)|Le~05pT5A{N0k3jg#|c(;K(`Z&Nj>sgtO^Eifib@b}XE-T3CszZ*U|UxLmINTG!Ia$$e7xS~HE(@NmL+0O;phNj8$K$nwyolqi+=7D1sVQ#ad$-YqZ=qnlxrIu0 z%a;L1Q%lS7`1n|Bkn?bRa&l5nPR@Ci!09e<^RMT+@7(s5>A7PP{AYt3ZU4R$ra*ZM zh37du{JTrw<1`yIEKUtBIVs5BotR49ax7s;jFuMpHSmA*6NPfSvtHSP($oK1rvJEg zEh&u*FoNlHkt)XTA#U`OhKoiv82yrd@%g_sej}^}5>BG;Gs{-~Ys&w(`)pxAHe79V zQu6;E8JKc!#H}S=EiU?KzkiqQE+AX>u=E+)|LynB(G+7)vDSJkwDNKPT{c=QK(+w& zc8T9ha-$#L3h)~qjhF%H?0?Y;Ae*!=ARBEJNbuzib^N}Jq6E}hVWUoW_eP(;FBLHW z*`(8>_ptw6tqkaZT1_jJY5!d|WimiEjAH+=e^=}O_m-SSA7yK6OUqFn**`egfJ6pF zA#H7Jc)WrU6A{F0t4e8VtTO!Nt6IK{rf5 zI25gaV4z+lSHAJ;O7Q2#kTfkleW9JRa~CHEN8RRT@{jjkW@eLm&3si|sm&f2x;i?u zG2doMG&ME%keH3nB_7$vawHk-8GYL8eyelroPXfFzHZ^_aRSSAw)Lcd+pY~6z&JTM z=TFtA=H_ryI}f!nuNWB_1EA8GCwV0$GZ9>*_W^&M8y+r8OiXn3@@h`MqSM%6OO>Yc?jlX~YzVX}Vuz(AC?Vn@0Lv;-ee6_Cj zt*t)?gKW*s%{ipzPmWXai;FRL{b3jb9A12GX7ukni!05CY|V1l8p_K@j*qq-<>Yiy zcLw|+W$e7XPv;9UkB?gf&O3gDE7S%C8Sh2`^INOb?CoU(OpxIYCI4?5@?D}x>yAkd z*5c2HqeF{}8MSa$mnHP{io-^=pr8bo=fe{ohKnED+K39&)uKJht(=_)^je%Ic4nt` z1FBZB44pPc@eE?m$+!?-6$uSuVq%gzOGJYn)tOuww0Kf33$Tb)OP--idm?AvTtgnyW?)B@=9tk+M)j|jXa}XCk5yMZ^nc3M6FN%?>%1Yz0+tmXH z2ToWDl*1$f?%>^*{W_Y_wL`Xk?D0q15>`?iN(dFzTv*o$sgZ_jW&Q#)0<0bYq{4Z;22Pe zuxA7AZlDz~*0yxoA)hW=xpGE$Cq3p=B+K~3MD^3~oOLX88xTLGwj^#yts5ye*HUKa z$?igJHoKmR3Q37lR+#KkV6URsD>fCA`Li>VywZ$$T&0{Xaq&+{m*L3*9)e=w&aSSu zJ>uS)ZlJxW&zF~%F)$<_na_A20TY8Ae!hid2xwF!Id-U=rl)f$DdQd`M*-9O_n0E6 z_y+NVw42s0E=*OG6nnC-lwqTLdwKj%u+5{x>S**bxF(w^_7zXX-Y@K)*rrK#iRD}v zd3v@>h@Ybkebrc4^(t#=S==pF;$HhvaBN#=uAs5EvbXpLd7-K|H64BI=S?rvs2M|d zclXHT-o*HL^p|B61jodZHqmieyKE4#(6F$^fB*>T&cym`_7H6;uxJ5X^R@Q&lHALg z8f%`jQge}Va&iG4=j$UKIw^7jTfr_9>ZdLKbulq!uHWa}YL^4rv-el_UOeM>;izg( zF@S4>4k-N!Wf2DO(ZX#wyhpJm(752)0q^vM$OhDQiPBL%r+7yA)J;M{A}{qj1k*Iu z8Tl512+Nn3h#`g;f)y^T*1LR5l;IFhs|(&2>Ck-heP&k;Lzv(hW;DWd`f4T%G1 z7Y6Ndh>HUQS<{A}%zD9a2P*={mamyDF#XdqF{w%|=GQdMue-sU6K?yI;WRlfjE3g) za}6WuMBQ9H7Iky1!3W*oWl7@g&CR2vi$BmzY(LO6`!i$0$Jx_g96gfZ*cLEp8St5Y z^~}H@Q8+PC?)rW+!wE(z%|mpuG_t&`dTGk-N#Ii*Dc0;F1Tz?VPZC0Kf7k6_Wlw@3&S!lz_#Yv9@$N zu`%wT?ktWs5Mi^P!xt6&tekfP7Xtl07eHoGb&mE3{TWEA3Na|3%J*&3(+w1GG!3T> z79%l0o@Tj7-RtmM!{_R)vdt3md}cKqJRP|MYzSn}q1}lE41T#(mdk&p z^fWQLIN8O-ezVz4|57HLrppKqv-C8&y?RKvp(cwItz)- zYPbbMn;gPv3`j!kjWG@J*wF=+p4mp8z2K-)H~Mg?cv0ffCUzQBGEw#dXBC!=E;+L> zxVtWpe}rT~(viv~B__Xzyr&`>EhGO(MJx^)nesz3;nbodql$b z1eF3aJ~m%qTcE(WkG|Z32@<5=;^}tCC-9uhM)N)q5qqIRGC}+f!s@iZe^i!1J16CS zbv>dFROd`evou~bNE(u&fuxF?Co^k;&Dj=jm?e`22s#Zg5ZD~55w1}_eHS7t$du1@Vct& zo)AsaVQY0nWV$fEq9&dh+&!zvK!Rt6)OETt*2LS{)7~;6a`!!pFFAc$d%;$5v`dm1 z@XIi`V~F9xcL_||XyM!!lrN~+27YFz_kemP?itay(P87j^9YxUDx&1CW-e{^-DaUA zjoe0pGH4M)J)2KbC5}95;^+?)uVh}06I)P8(;ledAJ^7*wMyvP#1+g_-?=kIVDh$9 z^vFBpJlRGFd%?sqk|Zo9e)59TU8F)bl2~EYzF~)$@%)^Vd%kZHUrP7u9uqqF%Cfaj zwx0O+0^LFhAWM4`pHZQV4&x(TaO?{#Rq~bZ(5@sOrab7&(8JyTFyL+>nUdj?k%N|C zfvbL$v=fxBE!Zp5{${ms)aVZUG6F3fjn?^Uz^eI48gde()lA^4>OFoWfLP3;a)1Qz zpeJn36&_k*(~exK>=e$y`sAY(E_or%J*9aY9-fHIwaA^V6vbxam%_4HDyY#1Dv9fv z2S(VjtmU#Dr#0;866Z7v>%&Xy&Y&>dyJj8pB^RV2<533ZntGYoqO(X6M(X4rHr3Gw z;cuvXWtKAMNqS_LhR@yqAT96`c>kRfM-Kc^UQWJ|j+C~*LwSSJf}VnjPzf)!HO_kE zju$Sg%VK+%wGvDxQ-adW(xM*TKt=U=Eq}M+=LCN6<`^d%lAb-aBbMW5A3hYB6_%kx zDK4EgCSZ!Ke=wENl;PupRPntyiAi*Cr|el^PrFKebjs8QfE%tfO-;oRC<^V%zGLaZn6miYH5hf%a@(C-^6zr}{*OMM#-| z+&O#_HW|jsswqk1sg<6SsN||nS*L)V{U^}XBn(J~WCQA9y|b2t@H+*GaIG1u^%VHM z_~k-(S|_wg33VAUK^ewG2-OfE^6gya#nd_Q=`1Cf=olMDJJ#l$q@}g$FOPf zuf^t~^c~V!6NiR}%d`V7-UfX%yF`%E0+LP@Gh!TG3PK}ki>eazkF-4+~?aTWIo zgVPFBLb#*5I+%^N*}qXN=ZIQ9=^;aOE-E-au(WrxDXrvkyN<`oqTp;xdxfO>?C3!2 zG-yT)X?<3?IyinDz%>DR+45bjnI4=3j}64MekMjTW>ue1$g0AguCPji;z`l_Pbt=b(CpqRZjOG}WFoF^$US?8NEo+VDX5%W+Q)XmH%OCNI!r!^K@U*^pKFzFx8# z+%n;QaaV}RM%<{iZ_on*Cii_q7a80TzRBSArw2$G4rO;p!_iS^hRaglhd1PH3Ww#i zqcVQ}`0|SWvV1w0oR`Ys3Y%DcxId~FhJ-X^a||;Jsuy6g&6F?S6HGRi z!{EvXDxwexiO_USR_e> z6XYybsVGnZ5Nm8?4*N^lSOXJ_naAo)ue0CMKte6;Sg$Eg;cOh?q{1WqH|L_im^NQx zY#Mw=CW(pXI%vXVcTj>qhLh#i4_EmWtv;ID7)Cq{rlbY5;~R0!n1KfTBmV(cX-C)8 zrGDZf>o4sgsXttaJw~3s2mL!8Nv1e?%_ugwl=+JITND@M`@szA?~ zvz)|DT)`2Tc>utr7JdlKNw}eqdde~#N2^*C$@JcZFJJaD=Ct4F=;(YaC|EaIK9TS9 z`9Arn;j@+1_$M`_tKXk0k7B*;?G)qqO_}re^nB-VSQcMW?uaK-!Wmi<8VO=<>mQI__`L`)?=ieca|RwzOG`VQoy zbF<5>m9ynelU7XKavS8K{g!dh)p=3J=dE1-;^8ZmGI@HC;=0tpa2$6?7c7jjiIw4WExAV4S){&n2RUd0UQ6* z&>XMLV9=$F{)V1FR4f4fxVp9`Q+KcK(ce}f+=5otFW(}m^60lc33p+AU;%i|sHzq| zA)!{R!PBE{sRSkhYSkKe(EZ2H_Pn{)GU9@Jp{Ax?j0_B@F-0cYN^%OO*EVGm09cSZ zO9cAw?rx!uJ|{D@fx$S?c&V0FWXM$90Q%HHKtP~P3+c4?%C4(Ef5VNNrYn~ zW&_-&r`zE_pj;yv88$<_6tPODu54akvoMuVMtf4PppX8DS3SWe5IXcg!YFw#`}yF& z;GmuO1t-UNW3$I;Lre_5DUJ;gu*u2GH$+EIkcI9bW_NMha^BVejPiX}Wt5qvrHsav zm_$T0y$0gwsA{&>zI35=>HPd$S3@IKMGyz4*SxE`y88U^f=eS4vG>TJ*?nT5zn`br zWOjZYyCgLw1-(6qnAhV(7pu$MRyMb`5MRFvf1xWrctS{c;N8Do z$lN-%!Kd>|)calN`-bQhys0l=uskiTf+5DzvXKiRAu=T;B}}?g)6>`oi$Z^{IE?_H zj_fJ3*Ea|#MHE!?N zvvW1Uk&)5z%(*t!SMVr6w4;&_9fZ^5a*dn@Qw`My1nKJZOs#Hn(^7Oy%mJ zYlHU{f)Pf6AOd{y{<$>j~pmtH8)E8w~ zV#bKLxOn@?`beh8oR!5BOl-^GsK`C4NFKAbOm7;e5 z_YVG|0!x-vaxEzAV$;3CP^|~QWPC4TNyb~hO^oc2MR73d%JQ6d%SkyvOp=}jt_Oyc{ zq((}0%$aJ?60Im% zhUy6N)hym+i$@jlQmuisL;BF+HOQd@4K~O~e7t=yTGau#y$2?%QeIbwRncp5#R zM&=k6x-@TQSAf^=j@TI^E}Y1=t@rN_@aJAYl-@|sE-VlPj#(__vCqS^VENbPW<9E8Jgt6)rl)e-@~g~onVyYMI1s+ zMedTmiY2%A^wim3P3v0#Jvx-HHrbLVxA<+rVj?I4mo=x06g=n*s|qsi=;*leFn%%$ z?7=u<3~=3zgjMG;3akO|k+)_-Vb_^1f{xX>0qzCj-jU#ZLTPOSgSE;cjKg`_CE29} zNRga^LI83n0&*VDEL|GbJ6uo^eA4KNRK`LJBjLyI9jU*%x>8gKnTvdzS0mtcF8gG) z;6zV57&CmLJ~;t@?Bk!)OLG8>2;*}~#0~t?8~4T^$7@M$DVU&;hSRowhuE>@s5usl3oj29BSgJ|S_Yu^i9Xz?O1kYB^w$;sn@ zv`QVZDbXiaRL6A$hN%-Z@Zlbe9N)Qfhn!TeG=PFhx9*9$QuJ!>U7>rWjE~5}<-O^G zY-$nO-+*s}EzUH})e6M``%{W-`HY)htw%Oet8*)JRr%U2?RDH2nm63eXk`bCz z`%LjHG4k^pS<|$iVF&Wm1M+!HC1gG6Sqt*4KExz3n|t*H_=(i1j45^P<*dq zg4oJ>lBdkFK}HjdLCt#>N%5@bQdV7`D|D?9QMt(O_Gf0_3_3VUK-5?v zd9#FS8cP$s^%wd6H0VZf)JoZ8Wx@PjrDc6K8D~4{DVsGJI-$tu&yIhcMuI3kJ{7pB zrLR$Mnq>w>T75e%BFa`JrDoHjpy`30H{Aly{a?t$9a!qoZ(?-6AhhzeZ)f zQ=?xv^G9<$1`jHlRPKhvH{Tq^(3&}Hr#rjmwXkbXteLT7920|RXmB;G(s9@sRy&W5d1|@x1 z9_Dp#2q1iz6%cj$55$i(!GsQ-cJPi+8sPK_2@ znSnn&=&!iwg)aI|oHTfIXR0K> zxA0$a7O-k*W!KKxmpXbw?Kj4xiv#p|ms?Qn-#Z7!?yPS=$c5cp?|+VMUkoZ>7H#hp zCjPy13>lzMr0kORe@NyBzyLLeZz|6J-g)~qT>k&3ePblp%#%qlONLjp5Lj z^gMW-EP%wrQbBg{{t|>P46w(QYF^I3e=OXM{DXp{e0vgVZOMGUe+@i*g>0F8cx5Ff z4eM7c5Y{V^x2TFdH+C{bK2(k+DD>YO-v>^V z1Y>zoEmx}sbvN8hM&{DGkr5?sA)!>xQ>SqA$)xAc{~ApAF6dQ!0fr5%;5Ulz4S1t& zI_&~bnfo2{{TnhCI1k{yCz=3P#^foYHHl+TS4L07LlE;DWbU8 zwob;J8ZqDI!u)mOELx{f&somqc+w)9V0ChCbg5wrF@+`2l<5Vj9~fe;OGy#nZ8jFP*85sg~2i^8Sq?XL@ z%7kX5r>9ra(jq7}l`k8Q3PehS9)?bgVsqEQkBF_25e1qj{2N3lmV!`fYy4^WNo?%xA0Yiv zFaJ5dwXcuwb}@=yj&to0SqdLf>k$|*xO7r#>VqK8@o^hNa;k1i8b@nuYpgFYZx@%z z5Nk$#)h&$nr0g&|ZZ0mayUjAOOtvY(B(09=4~bR_#0Z(_$(`(629tRl+hfNz`S|%? zf)~uFwtt-!WsJbiV2d4oV>e6FL?1tYTU*T_Hbnb)C0Z^712!-qr=>G2BC24TU_B@c z9gN>N1F-Az^X*UV#L(x@pOd55y?XUA#5_6125ODm5cW(_Q_DAR<8@jt8*`ZM>B%o6 zAtil$xUjR(YA|j1%Z;)`0A>eM672iW27pB)k8=E;)6UHj!8xH%P~ftZf1s#e&&G%q zUOb`BxsZr&G>oN=35m9R#s{ge6yzD;%uO%mD<#jH5pp|F)f^@d=b;vnKb*8=gy-eu z)NjFF#M4&DV|xUTZ+-v%XvOOFj(@7FT`4Iay#dQFUDp5rs8-;)@-LMDs>h}EHEWgL zn(N<;nb2l;c6Ia~sID?S)i7p6OE)Y|@EOclXFHZT?CiCg`f3~bN9>NPORp!i-j z+|lB7(SE%DQwr;a*LjuoQHc7{@$r2}dFZedi&$JpZkOxddz=%>^bbt18*kDLmAw(3 zM6arvYUn9n!4k;D)A%O$K!CWhn)+%Fa{-UDwxa1f;Ft_V?Ma?W97z;kIdoMI+kjmo zkazhJ@so%2W%_jvurmraYEPNFe-C-X_+=`wIaJgHY7GL*d&D zGSOML9{ocy-$qlVy3Hv6!AjGlCa-^}q&|P&bgGiO#>p}V8LF@6M1nXjspsSe(ukD( zkgas`tOOz^KI)`FYqY}7T9a@qAZOtyjbq<)FiuxQM?J#1@Ak!2?>T7%(s1L;FZ5Xk zaIRJVj{_SGtKWMGhCEs7QW71#uU*>^$<(|W0cx%4EsiK>ccU&&&M6bMI2kpn zwckfC18

+Dm$s|52@+ab2w(HQ=Z6%M5Pp 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

Q_Sylg}-2BJQ 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%0A_=Jg|LSL|96EVRuOZ+j9q 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 VD(&5TcLnh8%t@;gMSr;7{y&-j Date: Wed, 19 Mar 2025 16:59:53 +0100 Subject: [PATCH 29/32] Fix formatting --- libs/sdk-js/src/client.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/sdk-js/src/client.ts b/libs/sdk-js/src/client.ts index 7efc2e423..52051f1e8 100644 --- a/libs/sdk-js/src/client.ts +++ b/libs/sdk-js/src/client.ts @@ -643,7 +643,9 @@ export class ThreadsClient< * Create a new thread from a batch states. */ async bulkUpdateState( - supersteps: Array<{ updates: Array<{ values: unknown; command?: Command; asNode: string }> }>, + supersteps: Array<{ + updates: Array<{ values: unknown; command?: Command; asNode: string }>; + }>, options?: { graphId?: string; threadId?: string; From 00855999d207a6cd3ac9b4c96dcf7c866bd07ff9 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Wed, 19 Mar 2025 17:02:19 +0100 Subject: [PATCH 30/32] Bump to 0.0.59 --- libs/sdk-js/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/sdk-js/package.json b/libs/sdk-js/package.json index a29a438b0..6c3d5a06c 100644 --- a/libs/sdk-js/package.json +++ b/libs/sdk-js/package.json @@ -1,6 +1,6 @@ { "name": "@langchain/langgraph-sdk", - "version": "0.0.58", + "version": "0.0.59", "description": "Client library for interacting with the LangGraph API", "type": "module", "packageManager": "yarn@1.22.19", From d4c8b219c4c6bd02695e2e2b4855dde5c9da3529 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Wed, 19 Mar 2025 09:40:34 -0700 Subject: [PATCH 31/32] Update tests --- libs/langgraph/langgraph/pregel/algo.py | 10 +- libs/langgraph/tests/test_large_cases.py | 61 +++++++----- .../langgraph/tests/test_large_cases_async.py | 96 ++++++++++++++----- libs/langgraph/tests/test_pregel.py | 5 +- libs/langgraph/tests/test_pregel_async.py | 5 +- 5 files changed, 125 insertions(+), 52 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/algo.py b/libs/langgraph/langgraph/pregel/algo.py index db56828f9..d4948a22e 100644 --- a/libs/langgraph/langgraph/pregel/algo.py +++ b/libs/langgraph/langgraph/pregel/algo.py @@ -655,13 +655,14 @@ def prepare_single_task( if checkpoint_null_version is None: return # If any of the channels read by this process were updated - if triggers := _triggers( + if _triggers( channels, checkpoint["channel_versions"], checkpoint["versions_seen"].get(name), checkpoint_null_version, proc, ): + triggers = tuple(sorted(proc.triggers)) try: val = next( _proc_input(proc, managed, channels, for_execution=for_execution) @@ -730,7 +731,10 @@ def prepare_single_task( channels, managed, PregelTaskWrites( - task_path[:3], name, writes, proc.triggers + task_path[:3], + name, + writes, + triggers, ), config, ), @@ -757,7 +761,7 @@ def prepare_single_task( ), }, ), - proc.triggers, + triggers, proc.retry_policy, None, task_id, diff --git a/libs/langgraph/tests/test_large_cases.py b/libs/langgraph/tests/test_large_cases.py index d6637f000..6616d4cbb 100644 --- a/libs/langgraph/tests/test_large_cases.py +++ b/libs/langgraph/tests/test_large_cases.py @@ -2483,7 +2483,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None: { "langgraph_step": 1, "langgraph_node": "agent", - "langgraph_triggers": ("start:agent",), + "langgraph_triggers": ("branch:to:agent", "start:agent", "tools"), "langgraph_path": (PULL, "agent"), "langgraph_checkpoint_ns": AnyStr("agent:"), "checkpoint_ns": AnyStr("agent:"), @@ -2542,7 +2542,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None: { "langgraph_step": 3, "langgraph_node": "agent", - "langgraph_triggers": ("tools",), + "langgraph_triggers": ("branch:to:agent", "start:agent", "tools"), "langgraph_path": (PULL, "agent"), "langgraph_checkpoint_ns": AnyStr("agent:"), "checkpoint_ns": AnyStr("agent:"), @@ -2585,7 +2585,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None: { "langgraph_step": 5, "langgraph_node": "agent", - "langgraph_triggers": ("tools",), + "langgraph_triggers": ("branch:to:agent", "start:agent", "tools"), "langgraph_path": (PULL, "agent"), "langgraph_checkpoint_ns": AnyStr("agent:"), "checkpoint_ns": AnyStr("agent:"), @@ -5501,7 +5501,10 @@ def test_in_one_fan_out_out_one_graph_state() -> None: "id": AnyStr(), "name": "rewrite_query", "input": {"query": "what is weather in sf", "docs": []}, - "triggers": ("start:rewrite_query",), + "triggers": ( + "branch:to:rewrite_query", + "start:rewrite_query", + ), }, }, ), @@ -5532,7 +5535,10 @@ def test_in_one_fan_out_out_one_graph_state() -> None: "id": AnyStr(), "name": "retriever_one", "input": {"query": "query: what is weather in sf", "docs": []}, - "triggers": ("rewrite_query",), + "triggers": ( + "branch:to:retriever_one", + "rewrite_query", + ), }, }, ), @@ -5546,7 +5552,10 @@ def test_in_one_fan_out_out_one_graph_state() -> None: "id": AnyStr(), "name": "retriever_two", "input": {"query": "query: what is weather in sf", "docs": []}, - "triggers": ("rewrite_query",), + "triggers": ( + "branch:to:retriever_two", + "rewrite_query", + ), }, }, ), @@ -5608,7 +5617,7 @@ def test_in_one_fan_out_out_one_graph_state() -> None: "query": "query: what is weather in sf", "docs": ["doc1", "doc2", "doc3", "doc4"], }, - "triggers": (AnyStr("retriever_"),), + "triggers": ("branch:to:qa", "retriever_one", "retriever_two"), }, }, ), @@ -6634,7 +6643,7 @@ def test_branch_then( "id": AnyStr(), "name": "prepare", "input": {"my_key": "value", "market": "DE"}, - "triggers": ("start:prepare",), + "triggers": ("branch:to:prepare", "start:prepare"), }, }, { @@ -6773,7 +6782,10 @@ def test_branch_then( "id": AnyStr(), "name": "finish", "input": {"my_key": "value prepared slow", "market": "DE"}, - "triggers": ("branch:prepare:condition::then",), + "triggers": ( + "branch:prepare:condition::then", + "branch:to:finish", + ), }, }, { @@ -7783,7 +7795,7 @@ def test_nested_graph_state( "langgraph_node": "inner", "langgraph_path": [PULL, "inner"], "langgraph_step": 2, - "langgraph_triggers": ["outer_1"], + "langgraph_triggers": ["branch:to:inner", "outer_1"], "langgraph_checkpoint_ns": AnyStr("inner:"), }, created_at=AnyStr(), @@ -7978,7 +7990,7 @@ def test_nested_graph_state( "langgraph_node": "inner", "langgraph_path": [PULL, "inner"], "langgraph_step": 2, - "langgraph_triggers": ["outer_1"], + "langgraph_triggers": ["branch:to:inner", "outer_1"], "langgraph_checkpoint_ns": AnyStr("inner:"), }, created_at=AnyStr(), @@ -8021,7 +8033,7 @@ def test_nested_graph_state( "langgraph_node": "inner", "langgraph_path": [PULL, "inner"], "langgraph_step": 2, - "langgraph_triggers": ["outer_1"], + "langgraph_triggers": ["branch:to:inner", "outer_1"], "langgraph_checkpoint_ns": AnyStr("inner:"), }, created_at=AnyStr(), @@ -8070,7 +8082,7 @@ def test_nested_graph_state( "langgraph_node": "inner", "langgraph_path": [PULL, "inner"], "langgraph_step": 2, - "langgraph_triggers": ["outer_1"], + "langgraph_triggers": ["branch:to:inner", "outer_1"], "langgraph_checkpoint_ns": AnyStr("inner:"), }, created_at=AnyStr(), @@ -8504,7 +8516,7 @@ def test_doubly_nested_graph_state( "langgraph_node": "child_1", "langgraph_path": [PULL, AnyStr("child_1")], "langgraph_step": 1, - "langgraph_triggers": [AnyStr("start:child_1")], + "langgraph_triggers": ["branch:to:child_1", AnyStr("start:child_1")], }, created_at=AnyStr(), parent_config=( @@ -8588,7 +8600,10 @@ def test_doubly_nested_graph_state( AnyStr("child_1"), ], "langgraph_step": 1, - "langgraph_triggers": [AnyStr("start:child_1")], + "langgraph_triggers": [ + "branch:to:child_1", + AnyStr("start:child_1"), + ], }, created_at=AnyStr(), parent_config=( @@ -8635,7 +8650,7 @@ def test_doubly_nested_graph_state( "langgraph_node": "child", "langgraph_path": [PULL, AnyStr("child")], "langgraph_step": 2, - "langgraph_triggers": [AnyStr("parent_1")], + "langgraph_triggers": ["branch:to:child", AnyStr("parent_1")], "langgraph_checkpoint_ns": AnyStr("child:"), }, created_at=AnyStr(), @@ -8931,7 +8946,7 @@ def test_doubly_nested_graph_state( "langgraph_node": "child", "langgraph_path": [PULL, AnyStr("child")], "langgraph_step": 2, - "langgraph_triggers": [AnyStr("parent_1")], + "langgraph_triggers": ["branch:to:child", AnyStr("parent_1")], "langgraph_checkpoint_ns": AnyStr("child:"), }, created_at=AnyStr(), @@ -8970,7 +8985,7 @@ def test_doubly_nested_graph_state( "langgraph_node": "child", "langgraph_path": [PULL, AnyStr("child")], "langgraph_step": 2, - "langgraph_triggers": [AnyStr("parent_1")], + "langgraph_triggers": ["branch:to:child", AnyStr("parent_1")], "langgraph_checkpoint_ns": AnyStr("child:"), }, created_at=AnyStr(), @@ -9022,7 +9037,7 @@ def test_doubly_nested_graph_state( "langgraph_node": "child", "langgraph_path": [PULL, AnyStr("child")], "langgraph_step": 2, - "langgraph_triggers": [AnyStr("parent_1")], + "langgraph_triggers": ["branch:to:child", AnyStr("parent_1")], "langgraph_checkpoint_ns": AnyStr("child:"), }, created_at=AnyStr(), @@ -9076,7 +9091,7 @@ def test_doubly_nested_graph_state( AnyStr("child_1"), ], "langgraph_step": 1, - "langgraph_triggers": [AnyStr("start:child_1")], + "langgraph_triggers": ["branch:to:child_1", AnyStr("start:child_1")], }, created_at=AnyStr(), parent_config={ @@ -9131,7 +9146,7 @@ def test_doubly_nested_graph_state( AnyStr("child_1"), ], "langgraph_step": 1, - "langgraph_triggers": [AnyStr("start:child_1")], + "langgraph_triggers": ["branch:to:child_1", AnyStr("start:child_1")], }, created_at=AnyStr(), parent_config={ @@ -9193,7 +9208,7 @@ def test_doubly_nested_graph_state( AnyStr("child_1"), ], "langgraph_step": 1, - "langgraph_triggers": [AnyStr("start:child_1")], + "langgraph_triggers": ["branch:to:child_1", AnyStr("start:child_1")], }, created_at=AnyStr(), parent_config={ @@ -9255,7 +9270,7 @@ def test_doubly_nested_graph_state( AnyStr("child_1"), ], "langgraph_step": 1, - "langgraph_triggers": [AnyStr("start:child_1")], + "langgraph_triggers": ["branch:to:child_1", AnyStr("start:child_1")], }, created_at=AnyStr(), parent_config=None, diff --git a/libs/langgraph/tests/test_large_cases_async.py b/libs/langgraph/tests/test_large_cases_async.py index 157e0e080..9fe7f2ac3 100644 --- a/libs/langgraph/tests/test_large_cases_async.py +++ b/libs/langgraph/tests/test_large_cases_async.py @@ -2300,7 +2300,11 @@ async def test_prebuilt_tool_chat() -> None: { "langgraph_step": 1, "langgraph_node": "agent", - "langgraph_triggers": ("start:agent",), + "langgraph_triggers": ( + "branch:to:agent", + "start:agent", + "tools", + ), "langgraph_path": ("__pregel_pull", "agent"), "langgraph_checkpoint_ns": AnyStr("agent:"), "checkpoint_ns": AnyStr("agent:"), @@ -2359,7 +2363,11 @@ async def test_prebuilt_tool_chat() -> None: { "langgraph_step": 3, "langgraph_node": "agent", - "langgraph_triggers": ("tools",), + "langgraph_triggers": ( + "branch:to:agent", + "start:agent", + "tools", + ), "langgraph_path": ("__pregel_pull", "agent"), "langgraph_checkpoint_ns": AnyStr("agent:"), "checkpoint_ns": AnyStr("agent:"), @@ -2402,7 +2410,11 @@ async def test_prebuilt_tool_chat() -> None: { "langgraph_step": 5, "langgraph_node": "agent", - "langgraph_triggers": ("tools",), + "langgraph_triggers": ( + "branch:to:agent", + "start:agent", + "tools", + ), "langgraph_path": ("__pregel_pull", "agent"), "langgraph_checkpoint_ns": AnyStr("agent:"), "checkpoint_ns": AnyStr("agent:"), @@ -3883,7 +3895,10 @@ async def test_in_one_fan_out_out_one_graph_state() -> None: "id": AnyStr(), "name": "rewrite_query", "input": {"query": "what is weather in sf", "docs": []}, - "triggers": ("start:rewrite_query",), + "triggers": ( + "branch:to:rewrite_query", + "start:rewrite_query", + ), }, }, ), @@ -3914,7 +3929,10 @@ async def test_in_one_fan_out_out_one_graph_state() -> None: "id": AnyStr(), "name": "retriever_one", "input": {"query": "query: what is weather in sf", "docs": []}, - "triggers": ("rewrite_query",), + "triggers": ( + "branch:to:retriever_one", + "rewrite_query", + ), }, }, ), @@ -3928,7 +3946,10 @@ async def test_in_one_fan_out_out_one_graph_state() -> None: "id": AnyStr(), "name": "retriever_two", "input": {"query": "query: what is weather in sf", "docs": []}, - "triggers": ("rewrite_query",), + "triggers": ( + "branch:to:retriever_two", + "rewrite_query", + ), }, }, ), @@ -3990,7 +4011,7 @@ async def test_in_one_fan_out_out_one_graph_state() -> None: "query": "query: what is weather in sf", "docs": ["doc1", "doc2", "doc3", "doc4"], }, - "triggers": (AnyStr("retriever_"),), + "triggers": ("branch:to:qa", "retriever_one", "retriever_two"), }, }, ), @@ -4465,7 +4486,10 @@ async def test_branch_then(checkpointer_name: str) -> None: "id": AnyStr(), "name": "prepare", "input": {"my_key": "value", "market": "DE"}, - "triggers": ("start:prepare",), + "triggers": ( + "branch:to:prepare", + "start:prepare", + ), }, }, { @@ -4609,7 +4633,10 @@ async def test_branch_then(checkpointer_name: str) -> None: "id": AnyStr(), "name": "finish", "input": {"my_key": "value prepared slow", "market": "DE"}, - "triggers": ("branch:prepare:condition::then",), + "triggers": ( + "branch:prepare:condition::then", + "branch:to:finish", + ), }, }, { @@ -4778,7 +4805,10 @@ async def test_branch_then(checkpointer_name: str) -> None: "id": AnyStr(), "name": "prepare", "input": {"my_key": "value", "market": "DE"}, - "triggers": ("start:prepare",), + "triggers": ( + "branch:to:prepare", + "start:prepare", + ), }, }, { @@ -5333,7 +5363,7 @@ async def test_nested_graph_state(checkpointer_name: str) -> None: "langgraph_node": "inner", "langgraph_path": [PULL, "inner"], "langgraph_step": 2, - "langgraph_triggers": ["outer_1"], + "langgraph_triggers": ["branch:to:inner", "outer_1"], "langgraph_checkpoint_ns": AnyStr("inner:"), }, created_at=AnyStr(), @@ -5530,7 +5560,7 @@ async def test_nested_graph_state(checkpointer_name: str) -> None: "langgraph_node": "inner", "langgraph_path": [PULL, "inner"], "langgraph_step": 2, - "langgraph_triggers": ["outer_1"], + "langgraph_triggers": ["branch:to:inner", "outer_1"], "langgraph_checkpoint_ns": AnyStr("inner:"), }, created_at=AnyStr(), @@ -5573,7 +5603,7 @@ async def test_nested_graph_state(checkpointer_name: str) -> None: "langgraph_node": "inner", "langgraph_path": [PULL, "inner"], "langgraph_step": 2, - "langgraph_triggers": ["outer_1"], + "langgraph_triggers": ["branch:to:inner", "outer_1"], "langgraph_checkpoint_ns": AnyStr("inner:"), }, created_at=AnyStr(), @@ -5622,7 +5652,7 @@ async def test_nested_graph_state(checkpointer_name: str) -> None: "langgraph_node": "inner", "langgraph_path": [PULL, "inner"], "langgraph_step": 2, - "langgraph_triggers": ["outer_1"], + "langgraph_triggers": ["branch:to:inner", "outer_1"], "langgraph_checkpoint_ns": AnyStr("inner:"), }, created_at=AnyStr(), @@ -6060,7 +6090,7 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None: "langgraph_node": "child_1", "langgraph_path": [PULL, AnyStr("child_1")], "langgraph_step": 1, - "langgraph_triggers": [AnyStr("start:child_1")], + "langgraph_triggers": ["branch:to:child_1", "start:child_1"], }, created_at=AnyStr(), parent_config=( @@ -6146,7 +6176,10 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None: AnyStr("child_1"), ], "langgraph_step": 1, - "langgraph_triggers": [AnyStr("start:child_1")], + "langgraph_triggers": [ + "branch:to:child_1", + "start:child_1", + ], }, created_at=AnyStr(), parent_config=( @@ -6195,7 +6228,10 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None: "langgraph_node": "child", "langgraph_path": [PULL, AnyStr("child")], "langgraph_step": 2, - "langgraph_triggers": [AnyStr("parent_1")], + "langgraph_triggers": [ + "branch:to:child", + AnyStr("parent_1"), + ], "langgraph_checkpoint_ns": AnyStr("child:"), }, created_at=AnyStr(), @@ -6493,7 +6529,7 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None: "langgraph_node": "child", "langgraph_path": [PULL, AnyStr("child")], "langgraph_step": 2, - "langgraph_triggers": [AnyStr("parent_1")], + "langgraph_triggers": ["branch:to:child", AnyStr("parent_1")], "langgraph_checkpoint_ns": AnyStr("child:"), }, created_at=AnyStr(), @@ -6532,7 +6568,7 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None: "langgraph_node": "child", "langgraph_path": [PULL, AnyStr("child")], "langgraph_step": 2, - "langgraph_triggers": [AnyStr("parent_1")], + "langgraph_triggers": ["branch:to:child", AnyStr("parent_1")], "langgraph_checkpoint_ns": AnyStr("child:"), }, created_at=AnyStr(), @@ -6584,7 +6620,7 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None: "langgraph_node": "child", "langgraph_path": [PULL, AnyStr("child")], "langgraph_step": 2, - "langgraph_triggers": [AnyStr("parent_1")], + "langgraph_triggers": ["branch:to:child", AnyStr("parent_1")], "langgraph_checkpoint_ns": AnyStr("child:"), }, created_at=AnyStr(), @@ -6642,7 +6678,10 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None: AnyStr("child_1"), ], "langgraph_step": 1, - "langgraph_triggers": [AnyStr("start:child_1")], + "langgraph_triggers": [ + "branch:to:child_1", + AnyStr("start:child_1"), + ], }, created_at=AnyStr(), parent_config={ @@ -6697,7 +6736,10 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None: AnyStr("child_1"), ], "langgraph_step": 1, - "langgraph_triggers": [AnyStr("start:child_1")], + "langgraph_triggers": [ + "branch:to:child_1", + AnyStr("start:child_1"), + ], }, created_at=AnyStr(), parent_config={ @@ -6759,7 +6801,10 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None: AnyStr("child_1"), ], "langgraph_step": 1, - "langgraph_triggers": [AnyStr("start:child_1")], + "langgraph_triggers": [ + "branch:to:child_1", + AnyStr("start:child_1"), + ], }, created_at=AnyStr(), parent_config={ @@ -6821,7 +6866,10 @@ async def test_doubly_nested_graph_state(checkpointer_name: str) -> None: AnyStr("child_1"), ], "langgraph_step": 1, - "langgraph_triggers": [AnyStr("start:child_1")], + "langgraph_triggers": [ + "branch:to:child_1", + AnyStr("start:child_1"), + ], }, created_at=AnyStr(), parent_config=None, diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index d8d51a8a0..d6b9f49bc 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -6925,7 +6925,10 @@ def test_tags_stream_mode_messages() -> None: { "langgraph_step": 1, "langgraph_node": "call_model", - "langgraph_triggers": ("start:call_model",), + "langgraph_triggers": ( + "branch:to:call_model", + "start:call_model", + ), "langgraph_path": ("__pregel_pull", "call_model"), "langgraph_checkpoint_ns": AnyStr("call_model:"), "checkpoint_ns": AnyStr("call_model:"), diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 60cbd2547..5f130b631 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -7582,7 +7582,10 @@ async def test_tags_stream_mode_messages() -> None: { "langgraph_step": 1, "langgraph_node": "call_model", - "langgraph_triggers": ("start:call_model",), + "langgraph_triggers": ( + "branch:to:call_model", + "start:call_model", + ), "langgraph_path": ("__pregel_pull", "call_model"), "langgraph_checkpoint_ns": AnyStr("call_model:"), "checkpoint_ns": AnyStr("call_model:"), From c52cc03e4b4d3f938dcc7efcf0aae6065d616b2f Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Wed, 19 Mar 2025 19:02:51 +0100 Subject: [PATCH 32/32] release(langgraph): 0.3.17 --- 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 c625f47bc..17561c247 100644 --- a/libs/langgraph/pyproject.toml +++ b/libs/langgraph/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langgraph" -version = "0.3.16" +version = "0.3.17" description = "Building stateful, multi-actor applications with LLMs" authors = [] license = "MIT"