From 509dfd1f21ce6c6cc8a144a9c068959eab911522 Mon Sep 17 00:00:00 2001 From: Sydney Runkle Date: Mon, 28 Jul 2025 15:05:57 -0400 Subject: [PATCH] better hitl multi interrupt resume docs --- docs/docs/concepts/human_in_the_loop.md | 2 +- .../how-tos/assets/human_in_loop_parallel.png | Bin 0 -> 9381 bytes .../add-human-in-the-loop.md | 96 ++++++++++++++++-- 3 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 docs/docs/how-tos/assets/human_in_loop_parallel.png diff --git a/docs/docs/concepts/human_in_the_loop.md b/docs/docs/concepts/human_in_the_loop.md index 442a98b35..aa26f44a5 100644 --- a/docs/docs/concepts/human_in_the_loop.md +++ b/docs/docs/concepts/human_in_the_loop.md @@ -28,7 +28,7 @@ To review, edit, and approve tool calls in an agent or workflow, [use LangGraph' There are two ways to pause a graph: - [Dynamic interrupts](../how-tos/human_in_the_loop/add-human-in-the-loop.md#pause-using-interrupt): Use `interrupt` to pause a graph from inside a specific node, based on the current state of the graph. - - [Static interrupts](../how-tos/human_in_the_loop/add-human-in-the-loop.md#debug-with-interrupts): Use `interrupt_before` and `interrupt_after` to pause the graph at defined points, either before or after a node executes. + - [Static interrupts](../how-tos/human_in_the_loop/add-human-in-the-loop.md#debug-with-interrupts): Use `interrupt_before` and `interrupt_after` to pause the graph at pre-defined points, either before or after a node executes.
![image](./img/breakpoints.png){: style="max-height:400px"} diff --git a/docs/docs/how-tos/assets/human_in_loop_parallel.png b/docs/docs/how-tos/assets/human_in_loop_parallel.png new file mode 100644 index 0000000000000000000000000000000000000000..0fdb9da95147ccec18457e71ea3beb385cb26fbc GIT binary patch literal 9381 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 literal 0 HcmV?d00001 diff --git a/docs/docs/how-tos/human_in_the_loop/add-human-in-the-loop.md b/docs/docs/how-tos/human_in_the_loop/add-human-in-the-loop.md index e9d18ea4a..45c5b3ff7 100644 --- a/docs/docs/how-tos/human_in_the_loop/add-human-in-the-loop.md +++ b/docs/docs/how-tos/human_in_the_loop/add-human-in-the-loop.md @@ -128,7 +128,7 @@ print(graph.invoke(Command(resume="Edited text"), config=config)) # (7)! !!! tip "New in 0.4.0" - `__interrupt__` is a special key that will be returned when running the graph if the graph is interrupted. Support for `__interrupt__` in `invoke` and `ainvoke` has been added in version 0.4.0. If you're on an older version, you will only see `__interrupt__` in the result if you use `stream` or `astream`. You can also use `graph.get_state(thread_id)` to get the interrupt value. + `__interrupt__` is a special key that will be returned when running the graph if the graph is interrupted. Support for `__interrupt__` in `invoke` and `ainvoke` has been added in version 0.4.0. If you're on an older version, you will only see `__interrupt__` in the result if you use `stream` or `astream`. You can also use `graph.get_state(thread_id)` to get the interrupt value(s). !!! warning @@ -145,21 +145,99 @@ To resume execution, use the [`Command`][langgraph.types.Command] primitive, whi graph.invoke(Command(resume={"age": "25"}), thread_config) ``` -### Resume multiple interrupts with one invocation +### Multiple interrupts -If you have multiple interrupts in the task queue, you can use `Command.resume` with a dictionary mapping of interrupt ids to resume with a single `invoke` / `stream` call. +When nodes with interrupt conditions are run in parallel, it's possible to have multiple interrupts in the task queue. +For example, the following graph has two nodes run in parallel that require human input: -For example, once your graph has been interrupted (multiple times, theoretically) and is stalled: +
+![image](../assets/human_in_loop_parallel.png){: style="max-height:400px"} +
+ +Once your graph has been interrupted and is stalled, you can resume all the interrupts at once with `Command.resume`, passing a dictionary mapping of interrupt ids to resume values. ```python -resume_map = { - i.id: f"human input for prompt {i.value}" - for i in parent.get_state(thread_config).interrupts -} +# Run the graph, resulting in two interrupts +result = graph.invoke( + { + 'text_1': 'original text 1', + 'text_2': 'original text 2' + }, + config=thread_config +) -parent_graph.invoke(Command(resume=resume_map), config=thread_config) +print(result["__interrupt__"]) +""" +[ + Interrupt(value={'text_to_revise': 'original text 1'}, id='bba46aa8d060d6e1edbc6cd913a96494'), + Interrupt(value={'text_to_revise': 'original text 2'}, id='22632249d75d67e0e718c1d18596c78a') +] +""" + +# Create a mapping of interrupt ids to corresponding resume values +resume_map = { + i.id: f"edited text for {i.value['text_to_revise']}" + for i in result["__interrupt__"] +} +graph.invoke(Command(resume=resume_map), config=thread_config) ``` +!!! example "Extended example: resume multiple interrupts" + + ```python + from typing import TypedDict + import uuid + from langchain_core.runnables import RunnableConfig + from langgraph.checkpoint.memory import InMemorySaver + from langgraph.constants import START + from langgraph.graph import StateGraph + from langgraph.types import interrupt, Command + + + class State(TypedDict): + text_1: str + text_2: str + + + def human_node_1(state: State): + value = interrupt({"text_to_revise": state["text_1"]}) + return {"text_1": value} + + + def human_node_2(state: State): + value = interrupt({"text_to_revise": state["text_2"]}) + return {"text_2": value} + + + # Build the graph + graph_builder = StateGraph(State) + graph_builder.add_node("human_node_1", human_node_1) + graph_builder.add_node("human_node_2", human_node_2) + + # Add both nodes in parallel from START + graph_builder.add_edge(START, "human_node_1") + graph_builder.add_edge(START, "human_node_2") + + checkpointer = InMemorySaver() + graph = graph_builder.compile(checkpointer=checkpointer) + + # Pass a thread ID to the graph to run it + thread_id = str(uuid.uuid4()) + config: RunnableConfig = {"configurable": {"thread_id": thread_id}} + + # Run the graph until both interrupts are hit + result = graph.invoke( + {"text_1": "original text 1", "text_2": "original text 2"}, config=config + ) + + interrupts = result["__interrupt__"] + resume_map = {i.id: f"edited text for {i.value['text_to_revise']}" for i in interrupts} + + # Resume with mapping of interrupt IDs to values + print(graph.invoke(Command(resume=resume_map), config=config)) + # > {'text_1': 'edited text for original text 1', 'text_2': 'edited text for original text 2'} + ``` + ## Common patterns Below we show different design patterns that can be implemented using `interrupt` and `Command`.