docs: update HITL platform docs (#4664)

This commit is contained in:
Vadym Barda
2025-05-12 17:56:27 -04:00
committed by GitHub
parent 5b9c9057a2
commit ad315bb5e0
10 changed files with 813 additions and 1056 deletions
+2
View File
@@ -67,6 +67,8 @@ REDIRECT_MAP = {
"cloud/concepts/api.md": "concepts/langgraph_server.md",
"cloud/concepts/cloud.md": "concepts/langgraph_cloud.md",
"cloud/faq/studio.md": "concepts/langgraph_studio.md#studio-faqs",
"cloud/how-tos/human_in_the_loop_edit_state.md": "cloud/how-tos/add-human-in-the-loop.md",
"cloud/how-tos/human_in_the_loop_user_input.md": "cloud/how-tos/add-human-in-the-loop.md",
# cloud streaming redirects
"cloud/how-tos/stream_values.md": "cloud/how-tos/streaming.md#stream-graph-state",
"cloud/how-tos/stream_updates.md": "cloud/how-tos/streaming.md#stream-graph-state",
@@ -0,0 +1,342 @@
# Human-in-the-loop
LangGraph supports robust **human-in-the-loop (HIL)** workflows, enabling human intervention at any point in an automated process. This is especially useful in large language model (LLM)-driven applications where model output may require validation, correction, or additional context.
Please see [the overview of LangGraph human-in-the-loop](../../concepts/human_in_the_loop.md) features for more information.
## `interrupt`
The [`interrupt` function][langgraph.types.interrupt] in LangGraph enables human-in-the-loop workflows by pausing the graph at a specific node, presenting information to a human, and resuming the graph with their input. It's useful for tasks like approvals, edits, or gathering additional context.
The graph is resumed using a [`Command`](../reference/types.md#langgraph.types.Command) object that provides the human's response.
**Graph node with `interrupt`:**
```python
# highlight-next-line
from langgraph.types import interrupt, Command
def human_node(state: State):
# highlight-next-line
value = interrupt( # (1)!
{
"text_to_revise": state["some_text"] # (2)!
}
)
return {
"some_text": value # (3)!
}
```
1. `interrupt(...)` pauses execution at `human_node`, surfacing the given payload to a human.
2. Any JSON serializable value can be passed to the `interrupt` function. Here, a dict containing the text to revise.
3. Once resumed, the return value of `interrupt(...)` is the human-provided input, which is used to update the state.
**LangGraph API invoke & resume:**
=== "Python"
```python
from langgraph_sdk import get_client
# highlight-next-line
from langgraph_sdk.schema import Command
client = get_client(url=<DEPLOYMENT_URL>)
# Using the graph deployed with the name "agent"
assistant_id = "agent"
# create a thread
thread = await client.threads.create()
thread_id = thread["thread_id"]
# Run the graph until the interrupt is hit.
result = await client.runs.wait(
thread_id,
assistant_id,
input={"some_text": "original text"} # (1)!
)
print(result['__interrupt__']) # (2)!
# > [
# > {
# > 'value': {'text_to_revise': 'original text'},
# > 'resumable': True,
# > 'ns': ['human_node:fc722478-2f21-0578-c572-d9fc4dd07c3b'],
# > 'when': 'during'
# > }
# > ]
# Resume the graph
print(await client.runs.wait(
thread_id,
assistant_id,
# highlight-next-line
command=Command(resume="Edited text") # (3)!
))
# > {'some_text': 'Edited text'}
```
1. The graph is invoked with some initial state.
2. When the graph hits the interrupt, it returns an interrupt object with the payload and metadata.
3. The graph is resumed with a `Command(resume=...)`, injecting the human's input and continuing execution.
=== "JavaScript"
```js
import { Client } from "@langchain/langgraph-sdk";
const client = new Client({ apiUrl: <DEPLOYMENT_URL> });
// Using the graph deployed with the name "agent"
const assistantID = "agent";
// create a thread
const thread = await client.threads.create();
const threadID = thread["thread_id"];
// Run the graph until the interrupt is hit.
const result = await client.runs.wait(
threadID,
assistantID,
{ input: { "some_text": "original text" } } // (1)!
);
console.log(result['__interrupt__']); // (2)!
// > [
// > {
// > 'value': {'text_to_revise': 'original text'},
// > 'resumable': True,
// > 'ns': ['human_node:fc722478-2f21-0578-c572-d9fc4dd07c3b'],
// > 'when': 'during'
// > }
// > ]
// Resume the graph
console.log(await client.runs.wait(
threadID,
assistantID,
// highlight-next-line
{ command: { resume: "Edited text" }} // (3)!
));
// > {'some_text': 'Edited text'}
```
1. The graph is invoked with some initial state.
2. When the graph hits the interrupt, it returns an interrupt object with the payload and metadata.
3. The graph is resumed with a `{ resume: ... }` command object, injecting the human's input and continuing execution.
=== "cURL"
Create a thread:
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads \
--header 'Content-Type: application/json' \
--data '{}'
```
Run the graph until the interrupt is hit.:
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/wait \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"input\": {\"some_text\": \"original text\"}
}"
```
Resume the graph:
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/wait \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"command\": {
\"resume\": \"Edited text\"
}
}"
```
??? example "Extended example: using `interrupt`"
This is an example graph you can run in the LangGraph API server.
See [LangGraph Platform quickstart](../quick_start.md) for more details.
```python
from typing import TypedDict
import uuid
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.constants import START
from langgraph.graph import StateGraph
# highlight-next-line
from langgraph.types import interrupt, Command
class State(TypedDict):
some_text: str
def human_node(state: State):
# highlight-next-line
value = interrupt( # (1)!
{
"text_to_revise": state["some_text"] # (2)!
}
)
return {
"some_text": value # (3)!
}
# Build the graph
graph_builder = StateGraph(State)
graph_builder.add_node("human_node", human_node)
graph_builder.add_edge(START, "human_node")
graph = graph_builder.compile()
```
1. `interrupt(...)` pauses execution at `human_node`, surfacing the given payload to a human.
2. Any JSON serializable value can be passed to the `interrupt` function. Here, a dict containing the text to revise.
3. Once resumed, the return value of `interrupt(...)` is the human-provided input, which is used to update the state.
Once you have a running LangGraph API server, you can interact with it using
[LangGraph SDK](https://langchain-ai.github.io/langgraph/cloud/reference/sdk/python_sdk_ref/)
=== "Python"
```python
from langgraph_sdk import get_client
# highlight-next-line
from langgraph_sdk.schema import Command
client = get_client(url=<DEPLOYMENT_URL>)
# Using the graph deployed with the name "agent"
assistant_id = "agent"
# create a thread
thread = await client.threads.create()
thread_id = thread["thread_id"]
# Run the graph until the interrupt is hit.
result = await client.runs.wait(
thread_id,
assistant_id,
input={"some_text": "original text"} # (1)!
)
print(result['__interrupt__']) # (2)!
# > [
# > {
# > 'value': {'text_to_revise': 'original text'},
# > 'resumable': True,
# > 'ns': ['human_node:fc722478-2f21-0578-c572-d9fc4dd07c3b'],
# > 'when': 'during'
# > }
# > ]
# Resume the graph
print(await client.runs.wait(
thread_id,
assistant_id,
# highlight-next-line
command=Command(resume="Edited text") # (3)!
))
# > {'some_text': 'Edited text'}
```
1. The graph is invoked with some initial state.
2. When the graph hits the interrupt, it returns an interrupt object with the payload and metadata.
3. The graph is resumed with a `Command(resume=...)`, injecting the human's input and continuing execution.
=== "JavaScript"
```js
import { Client } from "@langchain/langgraph-sdk";
const client = new Client({ apiUrl: <DEPLOYMENT_URL> });
// Using the graph deployed with the name "agent"
const assistantID = "agent";
// create a thread
const thread = await client.threads.create();
const threadID = thread["thread_id"];
// Run the graph until the interrupt is hit.
const result = await client.runs.wait(
threadID,
assistantID,
{ input: { "some_text": "original text" } } // (1)!
);
console.log(result['__interrupt__']); // (2)!
// > [
// > {
// > 'value': {'text_to_revise': 'original text'},
// > 'resumable': True,
// > 'ns': ['human_node:fc722478-2f21-0578-c572-d9fc4dd07c3b'],
// > 'when': 'during'
// > }
// > ]
// Resume the graph
console.log(await client.runs.wait(
threadID,
assistantID,
// highlight-next-line
{ command: { resume: "Edited text" }} // (3)!
));
// > {'some_text': 'Edited text'}
```
1. The graph is invoked with some initial state.
2. When the graph hits the interrupt, it returns an interrupt object with the payload and metadata.
3. The graph is resumed with a `{ resume: ... }` command object, injecting the human's input and continuing execution.
=== "cURL"
Create a thread:
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads \
--header 'Content-Type: application/json' \
--data '{}'
```
Run the graph until the interrupt is hit:
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/wait \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"input\": {\"some_text\": \"original text\"}
}"
```
Resume the graph:
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/wait \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"command\": {
\"resume\": \"Edited text\"
}
}"
```
## Learn more
- [**LangGraph human-in-the-loop overview**](../../concepts/human_in_the_loop.md): learn more about LangGraph human-in-the-loop features.
- [**Design patterns**](../../how-tos/human_in_the_loop/add-human-in-the-loop.md#design-patterns): learn how to implement patterns like approving/rejecting actions, requesting user input, and more.
- [**How to review tool calls**](./human_in_the_loop_review_tool_calls.md): detailed examples of how to review and approve/edit tool calls or provide feedback to the tool-calling LLM.
@@ -1,24 +1,86 @@
# How to add static breakpoints
# Breakpoints
!!! tip "Prerequisites"
[Breakpoints](../../concepts/breakpoints.md) pause graph execution at defined points and let you step through each stage. They use LangGraph's [**persistence layer**](../../concepts/persistence.md), which saves the graph state after each step.
This guide assumes familiarity with the following concepts:
With breakpoints, you can inspect the graph's state and node inputs at any point. Execution pauses **indefinitely** until you resume, as the checkpointer preserves the state.
* [Breakpoints](../../concepts/breakpoints.md)
* [LangGraph Glossary](../../concepts/low_level.md)
## Set breakpoints
Human-in-the-loop (HIL) interactions are crucial for [agentic systems](../../concepts/agentic_concepts.md#human-in-the-loop). [Breakpoints](../../concepts/breakpoints.md) are a common HIL interaction pattern, allowing the graph to stop at specific steps and seek human approval before proceeding (e.g., for sensitive actions).
=== "Compile time"
Breakpoints are built on top of LangGraph [checkpoints](../../concepts/persistence.md#checkpoints), which save the graph's state after each node execution. Checkpoints are saved in [threads](../../concepts/persistence.md#threads) that preserve graph state and can be accessed after a graph has finished execution. This allows for graph execution to pause at specific points, await human approval, and then resume execution from the last checkpoint.
```python
# highlight-next-line
graph = graph_builder.compile( # (1)!
# highlight-next-line
interrupt_before=["node_a"], # (2)!
# highlight-next-line
interrupt_after=["node_b", "node_c"], # (3)!
)
```
## Setup
1. The breakpoints are set during `compile` time.
2. `interrupt_before` specifies the nodes where execution should pause before the node is executed.
3. `interrupt_after` specifies the nodes where execution should pause after the node is executed.
### Code for your graph
=== "Run time"
In this how-to we use a simple ReAct style hosted graph (you can see the full code for defining it [here](../../how-tos/human_in_the_loop/breakpoints.ipynb)). The important thing is that there are two nodes (one named `agent` that calls the LLM, and one named `action` that calls the tool), and a routing function from `agent` that determines whether to call `action` next or just end the graph run (the `action` node always calls the `agent` node after execution).
=== "Python"
### SDK Initialization
```python
# highlight-next-line
await client.runs.wait( # (1)!
thread_id,
assistant_id,
inputs=inputs,
# highlight-next-line
interrupt_before=["node_a"], # (2)!
# highlight-next-line
interrupt_after=["node_b", "node_c"] # (3)!
)
```
1. `client.runs.wait` is called with the `interrupt_before` and `interrupt_after` parameters. This is a run-time configuration and can be changed for every invocation.
2. `interrupt_before` specifies the nodes where execution should pause before the node is executed.
3. `interrupt_after` specifies the nodes where execution should pause after the node is executed.
=== "JavaScript"
```js
// highlight-next-line
await client.runs.wait( // (1)!
threadID,
assistantID,
{
input: input,
// highlight-next-line
interruptBefore: ["node_a"], // (2)!
// highlight-next-line
interruptAfter: ["node_b", "node_c"] // (3)!
}
)
```
1. `client.runs.wait` is called with the `interruptBefore` and `interruptAfter` parameters. This is a run-time configuration and can be changed for every invocation.
2. `interruptBefore` specifies the nodes where execution should pause before the node is executed.
3. `interruptAfter` specifies the nodes where execution should pause after the node is executed.
=== "cURL"
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/wait \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"interrupt_before\": [\"node_a\"],
\"interrupt_after\": [\"node_b\", \"node_c\"],
\"input\": <INPUT>
}"
```
!!! tip
This example shows how to add **static** breakpoints. See [this guide](../../how-tos/human_in_the_loop/breakpoints.ipynb) for more options for how to add breakpoints.
=== "Python"
@@ -26,130 +88,97 @@ In this how-to we use a simple ReAct style hosted graph (you can see the full co
```python
from langgraph_sdk import get_client
client = get_client(url=<DEPLOYMENT_URL>)
# Using the graph deployed with the name "agent"
assistant_id = "agent"
# create a thread
thread = await client.threads.create()
thread_id = thread["thread_id"]
# Run the graph until the breakpoint
result = await client.runs.wait(
thread_id,
assistant_id,
input=inputs # (1)!
)
# Resume the graph
await client.runs.wait(
thread_id,
assistant_id,
input=None # (2)!
)
```
=== "Javascript"
1. The graph is run until the first breakpoint is hit.
2. The graph is resumed by passing in `None` for the input. This will run the graph until the next breakpoint is hit.
=== "JavaScript"
```js
import { Client } from "@langchain/langgraph-sdk";
const client = new Client({ apiUrl: <DEPLOYMENT_URL> });
// Using the graph deployed with the name "agent"
const assistantId = "agent";
const assistantID = "agent";
// create a thread
const thread = await client.threads.create();
```
const threadID = thread["thread_id"];
=== "CURL"
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads \
--header 'Content-Type: application/json' \
--data '{}'
```
## Adding a breakpoint
We now want to add a breakpoint in our graph run, which we will do before a tool is called.
We can do this by adding `interrupt_before=["action"]`, which tells us to interrupt before calling the action node.
We can do this either when compiling the graph or when kicking off a run.
Here we will do it when kicking of a run, if you would like to to do it at compile time you need to edit the python file where your graph is defined and add the `interrupt_before` parameter when you call `.compile`.
First let's access our hosted LangGraph instance through the SDK:
And, now let's compile it with a breakpoint before the tool node:
=== "Python"
```python
input = {"messages": [{"role": "user", "content": "what's the weather in sf"}]}
async for chunk in client.runs.stream(
thread["thread_id"],
assistant_id,
input=input,
stream_mode="updates",
interrupt_before=["action"],
):
print(f"Receiving new event of type: {chunk.event}...")
print(chunk.data)
print("\n\n")
```
=== "Javascript"
```js
const input = { messages: [{ role: "human", content: "what's the weather in sf" }] };
const streamResponse = client.runs.stream(
thread["thread_id"],
assistantId,
{
input: input,
streamMode: "updates",
interruptBefore: ["action"]
}
// Run the graph until the breakpoint
const result = await client.runs.wait(
threadID,
assistantID,
{ input: input } // (1)!
);
for await (const chunk of streamResponse) {
console.log(`Receiving new event of type: ${chunk.event}...`);
console.log(chunk.data);
console.log("\n\n");
}
// Resume the graph
await client.runs.wait(
threadID,
assistantID,
{ input: null } // (2)!
);
```
=== "CURL"
1. The graph is run until the first breakpoint is hit.
2. The graph is resumed by passing in `null` for the input. This will run the graph until the next breakpoint is hit.
=== "cURL"
Create a thread:
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"what's the weather in sf\"}]},
\"interrupt_before\": [\"action\"],
\"stream_mode\": [
\"messages\"
]
}" | \
sed 's/\r$//' | \
awk '
/^event:/ {
if (data_content != "") {
print data_content "\n"
}
sub(/^event: /, "Receiving event of type: ", $0)
printf "%s...\n", $0
data_content = ""
}
/^data:/ {
sub(/^data: /, "", $0)
data_content = $0
}
END {
if (data_content != "") {
print data_content "\n"
}
}
'
--url <DEPLOYMENT_URL>/threads \
--header 'Content-Type: application/json' \
--data '{}'
```
Output:
Run the graph until the breakpoint:
Receiving new event of type: metadata...
{'run_id': '3b77ef83-687a-4840-8858-0371f91a92c3'}
Receiving new event of type: data...
{'agent': {'messages': [{'content': [{'id': 'toolu_01HwZqM1ptX6E15A5LAmyZTB', 'input': {'query': 'weather in san francisco'}, 'name': 'tavily_search_results_json', 'type': 'tool_use'}], 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-e5d17791-4d37-4ad2-815f-a0c4cba62585', 'example': False, 'tool_calls': [{'name': 'tavily_search_results_json', 'args': {'query': 'weather in san francisco'}, 'id': 'toolu_01HwZqM1ptX6E15A5LAmyZTB'}], 'invalid_tool_calls': []}]}}
Receiving new event of type: end...
None
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/wait \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"input\": <INPUT>
}"
```
Resume the graph:
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/wait \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\"
}"
```
## Learn more
- [**LangGraph breakpoints guide**](../../how-tos/human_in_the_loop/breakpoints.ipynb): learn more about adding breakpoints in LangGraph.
@@ -1,277 +0,0 @@
# How to Edit State of a Deployed Graph
When creating LangGraph agents, it is often nice to add a human-in-the-loop component. This can be helpful when giving them access to tools. Often in these situations you may want to edit the graph state before continuing (for example, to edit what tool is being called, or how it is being called).
This can be in several ways, but the primary supported way is to add an "interrupt" before a node is executed. This interrupts execution at that node. You can then use update_state to update the state, and then resume from that spot to continue.
## Setup
We are not going to show the full code for the graph we are hosting, but you can see it [here](../../how-tos/human_in_the_loop/time-travel.ipynb) if you want to. Once this graph is hosted, we are ready to invoke it and wait for user input.
### SDK initialization
First, we need to setup our client so that we can communicate with our hosted graph:
=== "Python"
```python
from langgraph_sdk import get_client
client = get_client(url=<DEPLOYMENT_URL>)
# Using the graph deployed with the name "agent"
assistant_id = "agent"
thread = await client.threads.create()
```
=== "Javascript"
```js
import { Client } from "@langchain/langgraph-sdk";
const client = new Client({ apiUrl: <DEPLOYMENT_URL> });
// Using the graph deployed with the name "agent"
const assistantId = "agent";
const thread = await client.threads.create();
```
=== "CURL"
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads \
--header 'Content-Type: application/json' \
--data '{}'
```
## Editing state
### Initial invocation
Now let's invoke our graph, making sure to interrupt before the `action` node.
=== "Python"
```python
input = { 'messages':[{ "role":"user", "content":"search for weather in SF" }] }
async for chunk in client.runs.stream(
thread["thread_id"],
assistant_id,
input=input,
stream_mode="updates",
interrupt_before=["action"],
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
```
=== "Javascript"
```js
const input = { messages: [{ role: "human", content: "search for weather in SF" }] };
const streamResponse = client.runs.stream(
thread["thread_id"],
assistantId,
{
input: input,
streamMode: "updates",
interruptBefore: ["action"],
}
);
for await (const chunk of streamResponse) {
if (chunk.data && chunk.event !== "metadata") {
console.log(chunk.data);
}
}
```
=== "CURL"
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"search for weather in SF\"}]},
\"interrupt_before\": [\"action\"],
\"stream_mode\": [
\"updates\"
]
}" | \
sed 's/\r$//' | \
awk '
/^event:/ {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
sub(/^event: /, "", $0)
event_type = $0
data_content = ""
}
/^data:/ {
sub(/^data: /, "", $0)
data_content = $0
}
END {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
}
'
```
Output:
{'agent': {'messages': [{'content': [{'text': "Certainly! I'll search for the current weather in San Francisco for you using the search function. Here's how I'll do that:", 'type': 'text'}, {'id': 'toolu_01KEJMBFozSiZoS4mAcPZeqQ', 'input': {'query': 'current weather in San Francisco'}, 'name': 'search', 'type': 'tool_use'}], 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-6dbb0167-f8f6-4e2a-ab68-229b2d1fbb64', 'example': False, 'tool_calls': [{'name': 'search', 'args': {'query': 'current weather in San Francisco'}, 'id': 'toolu_01KEJMBFozSiZoS4mAcPZeqQ'}], 'invalid_tool_calls': [], 'usage_metadata': None}]}}
### Edit the state
Now, let's assume we actually meant to search for the weather in Sidi Frej (another city with the initials SF). We can edit the state to properly reflect that:
=== "Python"
```python
# First, lets get the current state
current_state = await client.threads.get_state(thread['thread_id'])
# Let's now get the last message in the state
# This is the one with the tool calls that we want to update
last_message = current_state['values']['messages'][-1]
# Let's now update the args for that tool call
last_message['tool_calls'][0]['args'] = {'query': 'current weather in Sidi Frej'}
# Let's now call `update_state` to pass in this message in the `messages` key
# This will get treated as any other update to the state
# It will get passed to the reducer function for the `messages` key
# That reducer function will use the ID of the message to update it
# It's important that it has the right ID! Otherwise it would get appended
# as a new message
await client.threads.update_state(thread['thread_id'], {"messages": last_message})
```
=== "Javascript"
```js
// First, let's get the current state
const currentState = await client.threads.getState(thread["thread_id"]);
// Let's now get the last message in the state
// This is the one with the tool calls that we want to update
let lastMessage = currentState.values.messages.slice(-1)[0];
// Let's now update the args for that tool call
lastMessage.tool_calls[0].args = { query: "current weather in Sidi Frej" };
// Let's now call `update_state` to pass in this message in the `messages` key
// This will get treated as any other update to the state
// It will get passed to the reducer function for the `messages` key
// That reducer function will use the ID of the message to update it
// It's important that it has the right ID! Otherwise it would get appended
// as a new message
await client.threads.updateState(thread["thread_id"], { values: { messages: lastMessage } });
```
=== "CURL"
```bash
curl --request GET --url <DEPLOYMENT_URL>/threads/<THREAD_ID>/state | \
jq '.values.messages[-1] | (.tool_calls[0].args = {"query": "current weather in Sidi Frej"})' | \
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/state \
--header 'Content-Type: application/json' \
--data @-
```
Output:
{'configurable': {'thread_id': '9c8f1a43-9dd8-4017-9271-2c53e57cf66a',
'checkpoint_ns': '',
'checkpoint_id': '1ef58e7e-3641-649f-8002-8b4305a64858'}}
### Resume invocation
Now we can resume our graph run but with the updated state:
=== "Python"
```python
async for chunk in client.runs.stream(
thread["thread_id"],
assistant_id,
input=None,
stream_mode="updates",
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
```
=== "Javascript"
```js
const streamResponse = client.runs.stream(
thread["thread_id"],
assistantId,
{
input: null,
streamMode: "updates",
}
);
for await (const chunk of streamResponse) {
if (chunk.data && chunk.event !== "metadata") {
console.log(chunk.data);
}
}
```
=== "CURL"
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"stream_mode\": [
\"updates\"
]
}"| \
sed 's/\r$//' | \
awk '
/^event:/ {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
sub(/^event: /, "", $0)
event_type = $0
data_content = ""
}
/^data:/ {
sub(/^data: /, "", $0)
data_content = $0
}
END {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
}
'
```
Output:
{'action': {'messages': [{'content': '["I looked up: current weather in Sidi Frej. Result: It\'s sunny in San Francisco, but you better look out if you\'re a Gemini 😈."]', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'search', 'id': '1161b8d1-bee4-4188-9be8-698aecb69f10', 'tool_call_id': 'toolu_01KEJMBFozSiZoS4mAcPZeqQ'}]}}
{'agent': {'messages': [{'content': [{'text': 'I apologize for the confusion in my search query. It seems the search function interpreted "SF" as "Sidi Frej" instead of "San Francisco" as we intended. Let me search again with the full city name to get the correct information:', 'type': 'text'}, {'id': 'toolu_0111rrwgfAcmurHZn55qjqTR', 'input': {'query': 'current weather in San Francisco'}, 'name': 'search', 'type': 'tool_use'}], 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-b8c25779-cfb4-46fc-a421-48553551242f', 'example': False, 'tool_calls': [{'name': 'search', 'args': {'query': 'current weather in San Francisco'}, 'id': 'toolu_0111rrwgfAcmurHZn55qjqTR'}], 'invalid_tool_calls': [], 'usage_metadata': None}]}}
{'action': {'messages': [{'content': '["I looked up: current weather in San Francisco. Result: It\'s sunny in San Francisco, but you better look out if you\'re a Gemini 😈."]', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'search', 'id': '6bc632ae-5ee6-4d01-9532-79c524a2d443', 'tool_call_id': 'toolu_0111rrwgfAcmurHZn55qjqTR'}]}}
{'agent': {'messages': [{'content': "Now, based on the search results, I can provide you with information about the current weather in San Francisco:\n\nThe weather in San Francisco is currently sunny. \n\nIt's worth noting that the search result included an unusual comment about Gemini, which doesn't seem directly related to the weather. This might be due to the search engine including some astrological information or a joke in its results. However, for the purpose of weather information, we can focus on the fact that it's sunny in San Francisco right now.\n\nIs there anything else you'd like to know about the weather in San Francisco or any other location?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-227a042b-dd97-476e-af32-76a3703af5d8', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': None}]}}
As you can see it now looks up the current weather in Sidi Frej (although our dummy search node still returns results for SF because we don't actually do a search in this example, we just return the same "It's sunny in San Francisco ..." result every time).
@@ -86,7 +86,7 @@ First, we need to setup our client so that we can communicate with our hosted gr
const thread = await client.threads.create();
```
=== "CURL"
=== "cURL"
```bash
curl --request POST \
@@ -135,7 +135,7 @@ First, let's run the agent with an input that requires tool calls with approval:
}
```
=== "CURL"
=== "cURL"
```bash
curl --request POST \
@@ -194,7 +194,7 @@ To approve the tool call, we need to let `human_review_node` know what value to
}
```
=== "CURL"
=== "cURL"
```bash
curl --request POST \
@@ -257,7 +257,7 @@ Let's now say we want to edit the tool call. E.g. change some of the parameters
}
```
=== "CURL"
=== "cURL"
```bash
curl --request POST \
@@ -323,7 +323,7 @@ To do this, we will use `Command` with a different resume value of `{"action": "
}
```
=== "CURL"
=== "cURL"
```bash
curl --request POST \
@@ -395,7 +395,7 @@ For this example we will just add a single tool call representing the feedback (
}
```
=== "CURL"
=== "cURL"
```bash
curl --request POST \
@@ -462,7 +462,7 @@ To do this, we will use `Command` with a different resume value of `{"action": "
}
```
=== "CURL"
=== "cURL"
```bash
curl --request POST \
@@ -525,7 +525,7 @@ We can see that we now get to another interrupt - because it went back to the mo
}
```
=== "CURL"
=== "cURL"
```bash
curl --request POST \
@@ -1,386 +1,240 @@
# How to Replay and Branch from Prior States
# Time travel
With LangGraph Platform you have the ability to return to any of your prior states and either re-run the graph to reproduce issues noticed during testing, or branch out in a different way from what was originally done in the prior states. In this guide we will show a quick example of how to rerun past states and how to branch off from previous states as well.
LangGraph provides [**time travel**](../../concepts/time-travel.md) functionality to **resume execution from a prior checkpoint** — either replaying the same state or modifying it to explore alternatives. In all cases, resuming past execution produces a **new fork** in the history.
## Setup
## Use time travel
The examples below are executed against a specific deployment on LangGraph Platform. You will use
the SDK in a similar way, but you will expect to see different results based on the graph you have deployed.
To use time-travel in LangGraph:
### SDK initialization
1. **Run the graph** with initial inputs using [LangGraph SDK](https://langchain-ai.github.io/langgraph/cloud/reference/sdk/python_sdk_ref/)'s [`client.runs.wait`][langgraph_sdk.client.RunsClient.wait] or [`client.runs.stream`][langgraph_sdk.client.RunsClient.stream] APIs.
2. **Identify a checkpoint in an existing thread**: Use [`client.threads.get_history`][langgraph_sdk.client.ThreadsClient.get_history] method to retrieve the execution history for a specific `thread_id` and locate the desired `checkpoint_id`.
Alternatively, set a [breakpoint](./human_in_the_loop_breakpoint.md) before the node(s) where you want execution to pause. You can then find the most recent checkpoint recorded up to that breakpoint.
3. **(Optional) modify the graph state**: Use the [`client.threads.update_state`][langgraph_sdk.client.ThreadsClient.update_state] method to modify the graphs state at the checkpoint and resume execution from alternative state.
4. **Resume execution from the checkpoint**: Use the [`client.runs.wait`][langgraph_sdk.client.RunsClient.wait] or [`client.runs.stream`][langgraph_sdk.client.RunsClient.stream] APIs with an input of `None` and the appropriate `thread_id` and `checkpoint_id`.
First, we need to setup our client so that we can communicate with our hosted graph:
## Example
??? example "Example graph"
```python
from typing_extensions import TypedDict, NotRequired
from langgraph.graph import StateGraph, START, END
from langchain.chat_models import init_chat_model
from langgraph.checkpoint.memory import InMemorySaver
class State(TypedDict):
topic: NotRequired[str]
joke: NotRequired[str]
llm = init_chat_model(
"anthropic:claude-3-7-sonnet-latest",
temperature=0,
)
def generate_topic(state: State):
"""LLM call to generate a topic for the joke"""
msg = llm.invoke("Give me a funny topic for a joke")
return {"topic": msg.content}
def write_joke(state: State):
"""LLM call to write a joke based on the topic"""
msg = llm.invoke(f"Write a short joke about {state['topic']}")
return {"joke": msg.content}
# Build workflow
builder = StateGraph(State)
# Add nodes
builder.add_node("generate_topic", generate_topic)
builder.add_node("write_joke", write_joke)
# Add edges to connect nodes
builder.add_edge(START, "generate_topic")
builder.add_edge("generate_topic", "write_joke")
# Compile
graph = builder.compile()
```
### 1. Run the graph
=== "Python"
```python
from langgraph_sdk import get_client
client = get_client(url=<DEPLOYMENT_URL>)
# Using the graph deployed with the name "agent"
assistant_id = "agent"
# create a thread
thread = await client.threads.create()
thread_id = thread["thread_id"]
# Run the graph
result = await client.runs.wait(
thread_id,
assistant_id,
input={}
)
```
=== "Javascript"
=== "JavaScript"
```js
import { Client } from "@langchain/langgraph-sdk";
const client = new Client({ apiUrl: <DEPLOYMENT_URL> });
// Using the graph deployed with the name "agent"
const assistantId = "agent";
const assistantID = "agent";
// create a thread
const thread = await client.threads.create();
```
const threadID = thread["thread_id"];
=== "CURL"
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads \
--header 'Content-Type: application/json' \
--data '{}'
```
## Replay a state
### Initial invocation
Before replaying a state - we need to create states to replay from! In order to do this, let's invoke our graph with a simple message:
=== "Python"
```python
input = {"messages": [{"role": "user", "content": "Please search the weather in SF"}]}
async for chunk in client.runs.stream(
thread["thread_id"],
assistant_id,
input=input,
stream_mode="updates",
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
```
=== "Javascript"
```js
const input = { "messages": [{ "role": "user", "content": "Please search the weather in SF" }] }
const streamResponse = client.runs.stream(
thread["thread_id"],
assistantId,
{
input: input,
streamMode: "updates",
}
// Run the graph
const result = await client.runs.wait(
threadID,
assistantID,
{ input: {}}
);
for await (const chunk of streamResponse) {
if (chunk.data && chunk.event !== "metadata") {
console.log(chunk.data);
}
}
```
=== "CURL"
=== "cURL"
Create a thread:
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"Please search the weather in SF\"}]},
\"stream_mode\": [
\"updates\"
]
}" | \
sed 's/\r$//' | \
awk '
/^event:/ {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
sub(/^event: /, "", $0)
event_type = $0
data_content = ""
}
/^data:/ {
sub(/^data: /, "", $0)
data_content = $0
}
END {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
}
'
```
Output:
{'agent': {'messages': [{'content': [{'text': "Certainly! I'll use the search function to look up the current weather in San Francisco for you. Let me do that now.", 'type': 'text'}, {'id': 'toolu_011vroKUtWU7SBdrngpgpFMn', 'input': {'query': 'current weather in San Francisco'}, 'name': 'search', 'type': 'tool_use'}], 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-ee639877-d97d-40f8-96dc-d0d1ae22d203', 'example': False, 'tool_calls': [{'name': 'search', 'args': {'query': 'current weather in San Francisco'}, 'id': 'toolu_011vroKUtWU7SBdrngpgpFMn'}], 'invalid_tool_calls': [], 'usage_metadata': None}]}}
{'action': {'messages': [{'content': '["I looked up: current weather in San Francisco. Result: It\'s sunny in San Francisco, but you better look out if you\'re a Gemini 😈."]', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'search', 'id': '7bad0e72-5ebe-4b08-9b8a-b99b0fe22fb7', 'tool_call_id': 'toolu_011vroKUtWU7SBdrngpgpFMn'}]}}
{'agent': {'messages': [{'content': "Based on the search results, I can provide you with information about the current weather in San Francisco:\n\nThe weather in San Francisco is currently sunny. This is great news for outdoor activities and enjoying the city's beautiful sights.\n\nIt's worth noting that the search result included an unusual comment about Geminis, which isn't typically part of a weather report. This might be due to the search engine including some astrological information or a joke in its results. However, for the purpose of answering your question about the weather, we can focus on the fact that it's sunny in San Francisco.\n\nIf you need any more specific information about the weather in San Francisco, such as temperature, wind speed, or forecast for the coming days, please let me know, and I'd be happy to search for that information for you.", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-dbac539a-33c8-4f0c-9e20-91f318371e7c', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': None}]}}
Now let's get our list of states, and invoke from the third state (right before the tool get called):
=== "Python"
```python
states = await client.threads.get_history(thread['thread_id'])
# We can confirm that this state is correct by checking the 'next' attribute and seeing that it is the tool call node
state_to_replay = states[2]
print(state_to_replay['next'])
--url <DEPLOYMENT_URL>/threads \
--header 'Content-Type: application/json' \
--data '{}'
```
=== "Javascript"
```js
const states = await client.threads.getHistory(thread['thread_id']);
// We can confirm that this state is correct by checking the 'next' attribute and seeing that it is the tool call node
const stateToReplay = states[2];
console.log(stateToReplay['next']);
```
=== "CURL"
Run the graph:
```bash
curl --request GET --url <DEPLOYMENT_URL>/threads/<THREAD_ID>/history | jq -r '.[2].next'
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/wait \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"input\": {}
}"
```
Output:
['action']
To rerun from a state, we need first issue an empty update to the thread state. Then we need to pass in the resulting `checkpoint_id` as follows:
### 2. Identify a checkpoint
=== "Python"
```python
state_to_replay = states[2]
updated_config = await client.threads.update_state(
thread["thread_id"],
{"messages": []},
checkpoint_id=state_to_replay["checkpoint_id"]
# The states are returned in reverse chronological order.
states = await client.threads.get_history(thread_id)
selected_state = states[1]
print(selected_state)
```
=== "JavaScript"
```js
// The states are returned in reverse chronological order.
const states = await client.threads.getHistory(threadID);
const selectedState = states[1];
console.log(selectedState);
```
=== "cURL"
```bash
curl --request GET \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/history \
--header 'Content-Type: application/json'
```
### 3. Update the state (optional)
`update_state` will create a new checkpoint. The new checkpoint will be associated with the same thread, but a new checkpoint ID.
=== "Python"
```python
new_config = await client.threads.update_state(
thread_id,
{"topic": "chickens"},
# highlight-next-line
checkpoint_id=selected_state["checkpoint_id"]
)
async for chunk in client.runs.stream(
thread["thread_id"],
assistant_id, # graph_id
input=None,
stream_mode="updates",
checkpoint_id=updated_config["checkpoint_id"]
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
print(new_config)
```
=== "Javascript"
=== "JavaScript"
```js
const stateToReplay = states[2];
const config = await client.threads.updateState(thread["thread_id"], { values: {"messages": [] }, checkpointId: stateToReplay["checkpoint_id"] });
const streamResponse = client.runs.stream(
thread["thread_id"],
assistantId,
const newConfig = await client.threads.updateState(
threadID,
{
input: null,
streamMode: "updates",
checkpointId: config["checkpoint_id"]
values: { "topic": "chickens" },
checkpointId: selectedState["checkpoint_id"]
}
);
for await (const chunk of streamResponse) {
if (chunk.data && chunk.event !== "metadata") {
console.log(chunk.data);
}
}
console.log(newConfig);
```
=== "CURL"
=== "cURL"
```bash
curl --request GET --url <DEPLOYMENT_URL>/threads/<THREAD_ID>/history | jq -c '
.[2] as $state_to_replay |
{
values: { messages: .[2].values.messages[-1] },
checkpoint_id: $state_to_replay.checkpoint_id
}' | \
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/state \
--header 'Content-Type: application/json' \
--data @- | jq .checkpoint_id | \
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"checkpoint_id\": \"$1\",
\"stream_mode\": [
\"updates\"
]
}" | \
sed 's/\r$//' | \
awk '
/^event:/ {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
sub(/^event: /, "", $0)
event_type = $0
data_content = ""
}
/^data:/ {
sub(/^data: /, "", $0)
data_content = $0
}
END {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
}
'
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/state \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"checkpoint_id\": <CHECKPOINT_ID>,
\"values\": {\"topic\": \"chickens\"}
}"
```
Output:
{'action': {'messages': [{'content': '["I looked up: current weather in San Francisco. Result: It\'s sunny in San Francisco, but you better look out if you\'re a Gemini 😈."]', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'search', 'id': 'eba650e5-400e-4938-8508-f878dcbcc532', 'tool_call_id': 'toolu_011vroKUtWU7SBdrngpgpFMn'}]}}
{'agent': {'messages': [{'content': "Based on the search results, I can provide you with information about the current weather in San Francisco:\n\nThe weather in San Francisco is currently sunny. This is great news if you're planning any outdoor activities or simply want to enjoy a pleasant day in the city.\n\nIt's worth noting that the search result included an unusual comment about Geminis, which doesn't seem directly related to the weather. This appears to be a playful or humorous addition to the weather report, possibly from the source where this information was obtained.\n\nIs there anything else you'd like to know about the weather in San Francisco or any other information you need?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-bc6dca3f-a1e2-4f59-a69b-fe0515a348bb', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': None}]}}
As we can see, the graph restarted from the tool node with the same input as our original graph run.
## Branch off from previous state
Using LangGraph's checkpointing, you can do more than just replay past states. You can branch off previous locations to let the agent explore alternate trajectories or to let a user "version control" changes in a workflow.
Let's show how to do this to edit the state at a particular point in time. Let's update the state to change the input to the tool
### 4. Resume execution from the checkpoint
=== "Python"
```python
# Let's now get the last message in the state
# This is the one with the tool calls that we want to update
last_message = state_to_replay['values']['messages'][-1]
# Let's now update the args for that tool call
last_message['tool_calls'][0]['args'] = {'query': 'current weather in SF'}
config = await client.threads.update_state(thread['thread_id'],{"messages":[last_message]},checkpoint_id=state_to_replay['checkpoint_id'])
```
=== "Javascript"
```js
// Let's now get the last message in the state
// This is the one with the tool calls that we want to update
let lastMessage = stateToReplay['values']['messages'][-1];
// Let's now update the args for that tool call
lastMessage['tool_calls'][0]['args'] = { 'query': 'current weather in SF' };
const config = await client.threads.updateState(thread['thread_id'], { values: { "messages": [lastMessage] }, checkpointId: stateToReplay['checkpoint_id'] });
```
=== "CURL"
```bash
curl -s --request GET --url <DEPLOYMENT_URL>/threads/<THREAD_ID>/history | \
jq -c '
.[2] as $state_to_replay |
.[2].values.messages[-1].tool_calls[0].args.query = "current weather in SF" |
{
values: { messages: .[2].values.messages[-1] },
checkpoint_id: $state_to_replay.checkpoint_id
}' | \
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/state \
--header 'Content-Type: application/json' \
--data @-
```
Now we can rerun our graph with this new config, starting from the `new_state`, which is a branch of our `state_to_replay`:
=== "Python"
```python
async for chunk in client.runs.stream(
thread["thread_id"],
await client.runs.wait(
thread_id,
assistant_id,
# highlight-next-line
input=None,
stream_mode="updates",
checkpoint_id=config['checkpoint_id']
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
# highlight-next-line
checkpoint_id=new_config["checkpoint_id"]
)
```
=== "Javascript"
=== "JavaScript"
```js
const streamResponse = client.runs.stream(
thread["thread_id"],
assistantId,
await client.runs.wait(
threadID,
assistantID,
{
// highlight-next-line
input: null,
streamMode: "updates",
checkpointId: config['checkpoint_id'],
// highlight-next-line
checkpointId: newConfig["checkpoint_id"]
}
);
for await (const chunk of streamResponse) {
if (chunk.data && chunk.event !== "metadata") {
console.log(chunk.data);
}
}
```
=== "CURL"
=== "cURL"
```bash
curl -s --request GET --url <DEPLOYMENT_URL>/threads/<THREAD_ID>/state | \
jq -c '.checkpoint_id' | \
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"checkpoint_id\": \"$1\",
\"stream_mode\": [
\"updates\"
]
}" | \
sed 's/\r$//' | \
awk '
/^event:/ {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
sub(/^event: /, "", $0)
event_type = $0
data_content = ""
}
/^data:/ {
sub(/^data: /, "", $0)
data_content = $0
}
END {
if (data_content != "" && event_type != "metadata") {
print data_content "\n"
}
}
'
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/wait \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"checkpoint_id\": <CHECKPOINT_ID>
}"
```
Output:
## Learn more
{'action': {'messages': [{'content': '["I looked up: current weather in SF. Result: It\'s sunny in San Francisco, but you better look out if you\'re a Gemini 😈."]', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'search', 'id': '2baf9941-4fda-4081-9f87-d76795d289f1', 'tool_call_id': 'toolu_011vroKUtWU7SBdrngpgpFMn'}]}}
{'agent': {'messages': [{'content': "Based on the search results, I can provide you with information about the current weather in San Francisco (SF):\n\nThe weather in San Francisco is currently sunny. This means it's a clear day with plenty of sunshine. \n\nIt's worth noting that the specific temperature wasn't provided in the search result, but sunny weather in San Francisco typically means comfortable temperatures. San Francisco is known for its mild climate, so even on sunny days, it's often not too hot.\n\nThe search result also included a playful reference to astrological signs, mentioning Gemini. However, this is likely just a joke or part of the search engine's presentation and not related to the actual weather conditions.\n\nIs there any specific information about the weather in San Francisco you'd like to know more about? I'd be happy to perform another search if you need details on temperature, wind conditions, or the forecast for the coming days.", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-a83de52d-ed18-4402-9384-75c462485743', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': None}]}}
As we can see, the search query changed from San Francisco to SF, just as we had hoped!
- [**LangGraph time travel guide**](../../how-tos/human_in_the_loop/time-travel.ipynb): learn more about using time travel in LangGraph.
@@ -1,191 +0,0 @@
# How to wait for user input using `interrupt`
!!! tip "Prerequisites"
This guide assumes familiarity with the following concepts:
* [Human-in-the-loop](../../concepts/human_in_the_loop.md)
* [LangGraph Glossary](../../concepts/low_level.md)
**Human-in-the-loop (HIL)** interactions are crucial for [agentic systems](../../concepts/agentic_concepts.md#human-in-the-loop). Waiting for human input is a common HIL interaction pattern, allowing the agent to ask the user clarifying questions and await input before proceeding.
We can implement this in LangGraph using the [`interrupt()`][langgraph.types.interrupt] function. `interrupt` allows us to stop graph execution to collect input from a user and continue execution with collected input.
## Setup
We are not going to show the full code for the graph we are hosting, but you can see it [here](../../how-tos/human_in_the_loop/wait-user-input.ipynb#agent) if you want to. Once this graph is hosted, we are ready to invoke it and wait for user input.
### SDK initialization
First, we need to setup our client so that we can communicate with our hosted graph:
=== "Python"
```python
from langgraph_sdk import get_client
client = get_client(url=<DEPLOYMENT_URL>)
# Using the graph deployed with the name "agent"
assistant_id = "agent"
thread = await client.threads.create()
```
=== "Javascript"
```js
import { Client } from "@langchain/langgraph-sdk";
const client = new Client({ apiUrl: <DEPLOYMENT_URL> });
// Using the graph deployed with the name "agent"
const assistantId = "agent";
const thread = await client.threads.create();
```
=== "CURL"
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads \
--header 'Content-Type: application/json' \
--data '{}'
```
## Waiting for user input
### Initial invocation
Now, let's invoke our graph.
=== "Python"
```python
input = {
"messages": [
{
"role": "user",
"content": "Ask the user where they are, then look up the weather there",
}
]
}
async for chunk in client.runs.stream(
thread["thread_id"],
assistant_id,
input=input,
stream_mode="updates",
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
```
=== "Javascript"
```js
const input = {
messages: [
{
role: "human",
content: "Ask the user where they are, then look up the weather there" }
]
};
const streamResponse = client.runs.stream(
thread["thread_id"],
assistantId,
{
input: input,
streamMode: "updates",
}
);
for await (const chunk of streamResponse) {
if (chunk.data && chunk.event !== "metadata") {
console.log(chunk.data);
}
}
```
=== "CURL"
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"Ask the user where they are, then look up the weather there\"}]},
\"stream_mode\": [
\"updates\"
]
}"
```
Output:
{'agent': {'messages': [{'content': [{'text': "I'll help you ask the user about their location and then search for weather information.", 'type': 'text'}, {'id': 'toolu_012JeNEvyePZFWK39d52Wdwi', 'input': {'question': 'Where are you located?'}, 'name': 'AskHuman', 'type': 'tool_use'}], 'additional_kwargs': {}, 'response_metadata': {'id': 'msg_01UBEdS6UvuFMetdokNsykVG', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'tool_use', 'stop_sequence': None, 'usage': {'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 438, 'output_tokens': 76}, 'model_name': 'claude-3-5-sonnet-20241022'}, 'type': 'ai', 'name': None, 'id': 'run-1b1210d8-39e0-4607-9f0e-0ea932d28d5c-0', 'example': False, 'tool_calls': [{'name': 'AskHuman', 'args': {'question': 'Where are you located?'}, 'id': 'toolu_012JeNEvyePZFWK39d52Wdwi', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 438, 'output_tokens': 76, 'total_tokens': 514, 'input_token_details': {'cache_read': 0, 'cache_creation': 0}}}]}}
{'__interrupt__': [{'value': 'Where are you located?', 'resumable': True, 'ns': ['ask_human:2d41f894-f297-211e-9bfe-1d162ecba54a'], 'when': 'during'}]}
You can see that our graph got interrupted inside the `ask_human` node, which is now waiting for a `location` to be provided.
### Providing human input
We can provide human input (`location`) by invoking the graph with a `Command(resume="<location>")`:
=== "Python"
```python
# highlight-next-line
from langgraph_sdk.schema import Command
async for chunk in client.runs.stream(
thread["thread_id"],
assistant_id,
# highlight-next-line
command=Command(resume="san francisco"),
stream_mode="updates",
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
```
=== "Javascript"
```js
const streamResponse = client.runs.stream(
thread["thread_id"],
assistantId,
{
// highlight-next-line
command: { resume: "san francisco" },
streamMode: "updates"
}
);
for await (const chunk of streamResponse) {
if (chunk.data && chunk.event !== "metadata") {
console.log(chunk.data);
}
}
```
=== "CURL"
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"command\": {
\"resume\": \"san francisco\"
},
\"stream_mode\": [
\"updates\"
]
}"
```
Output:
{'ask_human': {'messages': [{'tool_call_id': 'toolu_012JeNEvyePZFWK39d52Wdwi', 'type': 'tool', 'content': 'san francisco'}]}}
{'agent': {'messages': [{'content': [{'text': 'Let me search for the weather in San Francisco.', 'type': 'text'}, {'id': 'toolu_019f9Y7ST6rNeDQkDjFCHk6C', 'input': {'query': 'current weather in san francisco'}, 'name': 'search', 'type': 'tool_use'}], 'additional_kwargs': {}, 'response_metadata': {'id': 'msg_0152YFm7DtnzfZQuiMUzaSsw', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'tool_use', 'stop_sequence': None, 'usage': {'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 527, 'output_tokens': 67}, 'model_name': 'claude-3-5-sonnet-20241022'}, 'type': 'ai', 'name': None, 'id': 'run-f509b5b2-eb30-4200-a8da-fa79ed68812a-0', 'example': False, 'tool_calls': [{'name': 'search', 'args': {'query': 'current weather in san francisco'}, 'id': 'toolu_019f9Y7ST6rNeDQkDjFCHk6C', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 527, 'output_tokens': 67, 'total_tokens': 594, 'input_token_details': {'cache_read': 0, 'cache_creation': 0}}}]}}
{'action': {'messages': [{'content': "I looked up: current weather in san francisco. Result: It's sunny in San Francisco, but you better look out if you're a Gemini 😈.", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'search', 'id': 'cbd0f623-cc12-48a2-8c18-3cbb943e46e0', 'tool_call_id': 'toolu_019f9Y7ST6rNeDQkDjFCHk6C', 'artifact': None, 'status': 'success'}]}}
{'agent': {'messages': [{'content': "Based on the search results, it's currently sunny in San Francisco. Would you like any specific details about the weather forecast?", 'additional_kwargs': {}, 'response_metadata': {'id': 'msg_01FhzXj72CehBYkJGX69vsBc', 'model': 'claude-3-5-sonnet-20241022', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 639, 'output_tokens': 29}, 'model_name': 'claude-3-5-sonnet-20241022'}, 'type': 'ai', 'name': None, 'id': 'run-f48e818e-dd88-415e-9a0b-4a958498b553-0', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 639, 'output_tokens': 29, 'total_tokens': 668, 'input_token_details': {'cache_read': 0, 'cache_creation': 0}}}]}}
@@ -22,15 +22,15 @@ The graph is resumed using a [`Command`](../reference/types.md#langgraph.types.C
from langgraph.types import interrupt, Command
def human_node(state: State):
# highlight-next-line
value = interrupt( # (1)!
{
"text_to_revise": state["some_text"] # (2)!
}
)
return {
"some_text": value # (3)!
}
# highlight-next-line
value = interrupt( # (1)!
{
"text_to_revise": state["some_text"] # (2)!
}
)
return {
"some_text": value # (3)!
}
graph = graph_builder.compile(checkpointer=checkpointer) # (4)!
@@ -62,67 +62,67 @@ print(graph.invoke(Command(resume="Edited text"), config=config)) # (7)!
??? example "Extended example: using `interrupt`"
```python
from typing import TypedDict
import uuid
```python
from typing import TypedDict
import uuid
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.constants import START
from langgraph.graph import StateGraph
# highlight-next-line
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.constants import START
from langgraph.graph import StateGraph
# highlight-next-line
from langgraph.types import interrupt, Command
class State(TypedDict):
some_text: str
class State(TypedDict):
some_text: str
def human_node(state: State):
# highlight-next-line
value = interrupt( # (1)!
def human_node(state: State):
# highlight-next-line
value = interrupt( # (1)!
{
"text_to_revise": state["some_text"] # (2)!
"text_to_revise": state["some_text"] # (2)!
}
)
return {
)
return {
"some_text": value # (3)!
}
}
# Build the graph
graph_builder = StateGraph(State)
graph_builder.add_node("human_node", human_node)
graph_builder.add_edge(START, "human_node")
# Build the graph
graph_builder = StateGraph(State)
graph_builder.add_node("human_node", human_node)
graph_builder.add_edge(START, "human_node")
checkpointer = InMemorySaver() # (4)!
checkpointer = InMemorySaver() # (4)!
graph = graph_builder.compile(checkpointer=checkpointer)
graph = graph_builder.compile(checkpointer=checkpointer)
# Pass a thread ID to the graph to run it.
config = {"configurable": {"thread_id": uuid.uuid4()}}
# Pass a thread ID to the graph to run it.
config = {"configurable": {"thread_id": uuid.uuid4()}}
# Run the graph until the interrupt is hit.
result = graph.invoke({"some_text": "original text"}, config=config) # (5)!
# Run the graph until the interrupt is hit.
result = graph.invoke({"some_text": "original text"}, config=config) # (5)!
print(result['__interrupt__']) # (6)!
# > [
# > Interrupt(
# > value={'text_to_revise': 'original text'},
# > resumable=True,
# > ns=['human_node:6ce9e64f-edef-fe5d-f7dc-511fa9526960']
# > )
# > ]
print(result['__interrupt__']) # (6)!
# > [
# > Interrupt(
# > value={'text_to_revise': 'original text'},
# > resumable=True,
# > ns=['human_node:6ce9e64f-edef-fe5d-f7dc-511fa9526960']
# > )
# > ]
# highlight-next-line
print(graph.invoke(Command(resume="Edited text"), config=config)) # (7)!
# > {'some_text': 'Edited text'}
```
# highlight-next-line
print(graph.invoke(Command(resume="Edited text"), config=config)) # (7)!
# > {'some_text': 'Edited text'}
```
1. `interrupt(...)` pauses execution at `human_node`, surfacing the given payload to a human.
2. Any JSON serializable value can be passed to the `interrupt` function. Here, a dict containing the text to revise.
3. Once resumed, the return value of `interrupt(...)` is the human-provided input, which is used to update the state.
4. A checkpointer is required to persist graph state. In production, this should be durable (e.g., backed by a database).
5. The graph is invoked with some initial state.
6. When the graph hits the interrupt, it returns an `Interrupt` object with the payload and metadata.
7. The graph is resumed with a `Command(resume=...)`, injecting the human's input and continuing execution.
1. `interrupt(...)` pauses execution at `human_node`, surfacing the given payload to a human.
2. Any JSON serializable value can be passed to the `interrupt` function. Here, a dict containing the text to revise.
3. Once resumed, the return value of `interrupt(...)` is the human-provided input, which is used to update the state.
4. A checkpointer is required to persist graph state. In production, this should be durable (e.g., backed by a database).
5. The graph is invoked with some initial state.
6. When the graph hits the interrupt, it returns an `Interrupt` object with the payload and metadata.
7. The graph is resumed with a `Command(resume=...)`, injecting the human's input and continuing execution.
@@ -657,120 +657,120 @@ def node_in_parent_graph(state: State):
??? example "Extended example: parent and subgraph execution flow"
Say we have a parent graph with 3 nodes:
Say we have a parent graph with 3 nodes:
**Parent Graph**: `node_1` → `node_2` (subgraph call) → `node_3`
**Parent Graph**: `node_1` → `node_2` (subgraph call) → `node_3`
And the subgraph has 3 nodes, where the second node contains an `interrupt`:
And the subgraph has 3 nodes, where the second node contains an `interrupt`:
**Subgraph**: `sub_node_1` → `sub_node_2` (`interrupt`) → `sub_node_3`
**Subgraph**: `sub_node_1` → `sub_node_2` (`interrupt`) → `sub_node_3`
When resuming the graph, the execution will proceed as follows:
When resuming the graph, the execution will proceed as follows:
1. **Skip `node_1`** in the parent graph (already executed, graph state was saved in snapshot).
2. **Re-execute `node_2`** in the parent graph from the start.
3. **Skip `sub_node_1`** in the subgraph (already executed, graph state was saved in snapshot).
4. **Re-execute `sub_node_2`** in the subgraph from the beginning.
5. Continue with `sub_node_3` and subsequent nodes.
1. **Skip `node_1`** in the parent graph (already executed, graph state was saved in snapshot).
2. **Re-execute `node_2`** in the parent graph from the start.
3. **Skip `sub_node_1`** in the subgraph (already executed, graph state was saved in snapshot).
4. **Re-execute `sub_node_2`** in the subgraph from the beginning.
5. Continue with `sub_node_3` and subsequent nodes.
Here is abbreviated example code that you can use to understand how subgraphs work with interrupts.
It counts the number of times each node is entered and prints the count.
Here is abbreviated example code that you can use to understand how subgraphs work with interrupts.
It counts the number of times each node is entered and prints the count.
```python
import uuid
from typing import TypedDict
```python
import uuid
from typing import TypedDict
from langgraph.graph import StateGraph
from langgraph.constants import START
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph
from langgraph.constants import START
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import MemorySaver
class State(TypedDict):
"""The graph state."""
state_counter: int
class State(TypedDict):
"""The graph state."""
state_counter: int
counter_node_in_subgraph = 0
counter_node_in_subgraph = 0
def node_in_subgraph(state: State):
"""A node in the sub-graph."""
global counter_node_in_subgraph
counter_node_in_subgraph += 1 # This code will **NOT** run again!
print(f"Entered `node_in_subgraph` a total of {counter_node_in_subgraph} times")
def node_in_subgraph(state: State):
"""A node in the sub-graph."""
global counter_node_in_subgraph
counter_node_in_subgraph += 1 # This code will **NOT** run again!
print(f"Entered `node_in_subgraph` a total of {counter_node_in_subgraph} times")
counter_human_node = 0
counter_human_node = 0
def human_node(state: State):
global counter_human_node
counter_human_node += 1 # This code will run again!
print(f"Entered human_node in sub-graph a total of {counter_human_node} times")
answer = interrupt("what is your name?")
print(f"Got an answer of {answer}")
def human_node(state: State):
global counter_human_node
counter_human_node += 1 # This code will run again!
print(f"Entered human_node in sub-graph a total of {counter_human_node} times")
answer = interrupt("what is your name?")
print(f"Got an answer of {answer}")
checkpointer = MemorySaver()
checkpointer = MemorySaver()
subgraph_builder = StateGraph(State)
subgraph_builder.add_node("some_node", node_in_subgraph)
subgraph_builder.add_node("human_node", human_node)
subgraph_builder.add_edge(START, "some_node")
subgraph_builder.add_edge("some_node", "human_node")
subgraph = subgraph_builder.compile(checkpointer=checkpointer)
subgraph_builder = StateGraph(State)
subgraph_builder.add_node("some_node", node_in_subgraph)
subgraph_builder.add_node("human_node", human_node)
subgraph_builder.add_edge(START, "some_node")
subgraph_builder.add_edge("some_node", "human_node")
subgraph = subgraph_builder.compile(checkpointer=checkpointer)
counter_parent_node = 0
counter_parent_node = 0
def parent_node(state: State):
"""This parent node will invoke the subgraph."""
global counter_parent_node
def parent_node(state: State):
"""This parent node will invoke the subgraph."""
global counter_parent_node
counter_parent_node += 1 # This code will run again on resuming!
print(f"Entered `parent_node` a total of {counter_parent_node} times")
# Please note that we're intentionally incrementing the state counter
# in the graph state as well to demonstrate that the subgraph update
# of the same key will not conflict with the parent graph (until
subgraph_state = subgraph.invoke(state)
return subgraph_state
counter_parent_node += 1 # This code will run again on resuming!
print(f"Entered `parent_node` a total of {counter_parent_node} times")
# Please note that we're intentionally incrementing the state counter
# in the graph state as well to demonstrate that the subgraph update
# of the same key will not conflict with the parent graph (until
subgraph_state = subgraph.invoke(state)
return subgraph_state
builder = StateGraph(State)
builder.add_node("parent_node", parent_node)
builder.add_edge(START, "parent_node")
builder = StateGraph(State)
builder.add_node("parent_node", parent_node)
builder.add_edge(START, "parent_node")
# A checkpointer must be enabled for interrupts to work!
checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)
# A checkpointer must be enabled for interrupts to work!
checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)
config = {
"configurable": {
"thread_id": uuid.uuid4(),
}
}
config = {
"configurable": {
"thread_id": uuid.uuid4(),
}
}
for chunk in graph.stream({"state_counter": 1}, config):
print(chunk)
for chunk in graph.stream({"state_counter": 1}, config):
print(chunk)
print('--- Resuming ---')
print('--- Resuming ---')
for chunk in graph.stream(Command(resume="35"), config):
print(chunk)
```
for chunk in graph.stream(Command(resume="35"), config):
print(chunk)
```
This will print out
This will print out
```pycon
Entered `parent_node` a total of 1 times
Entered `node_in_subgraph` a total of 1 times
Entered human_node in sub-graph a total of 1 times
{'__interrupt__': (Interrupt(value='what is your name?', resumable=True, ns=['parent_node:4c3a0248-21f0-1287-eacf-3002bc304db4', 'human_node:2fe86d52-6f70-2a3f-6b2f-b1eededd6348'], when='during'),)}
--- Resuming ---
Entered `parent_node` a total of 2 times
Entered human_node in sub-graph a total of 2 times
Got an answer of 35
{'parent_node': {'state_counter': 1}}
```
```pycon
Entered `parent_node` a total of 1 times
Entered `node_in_subgraph` a total of 1 times
Entered human_node in sub-graph a total of 1 times
{'__interrupt__': (Interrupt(value='what is your name?', resumable=True, ns=['parent_node:4c3a0248-21f0-1287-eacf-3002bc304db4', 'human_node:2fe86d52-6f70-2a3f-6b2f-b1eededd6348'], when='during'),)}
--- Resuming ---
Entered `parent_node` a total of 2 times
Entered human_node in sub-graph a total of 2 times
Got an answer of 35
{'parent_node': {'state_counter': 1}}
```
@@ -10,10 +10,12 @@
"\n",
"To use time-travel in LangGraph:\n",
"\n",
"1. **Identify a checkpoint in an existing thread**: Use the [`get_state_history()`][langgraph.graph.graph.CompiledGraph.get_state_history] method to retrieve the execution history for a specific `thread_id` and locate the desired `checkpoint_id`. \n",
"\n",
"1. **Run the graph** with initial inputs using `invoke` or `stream` APIs.\n",
"2. **Identify a checkpoint in an existing thread**: Use the [`get_state_history()`][langgraph.graph.graph.CompiledGraph.get_state_history] method to retrieve the execution history for a specific `thread_id` and locate the desired `checkpoint_id`. \n",
" Alternatively, set a [breakpoint](../../../concepts/breakpoints/) before the node(s) where you want execution to pause. You can then find the most recent checkpoint recorded up to that breakpoint.\n",
"2. **(Optional) modify the graph state**: Use the [`update_state`][langgraph.graph.graph.CompiledGraph.update_state] method to modify the graphs state at the checkpoint and resume execution from alternative state.\n",
"3. **Resume execution from the checkpoint**: Use the `invoke` or `stream` APIs with an input of `None` and a configuration containing the appropriate `thread_id` and `checkpoint_id`.\n",
"3. **(Optional) modify the graph state**: Use the [`update_state`][langgraph.graph.graph.CompiledGraph.update_state] method to modify the graphs state at the checkpoint and resume execution from alternative state.\n",
"4. **Resume execution from the checkpoint**: Use the `invoke` or `stream` APIs with an input of `None` and a configuration containing the appropriate `thread_id` and `checkpoint_id`.\n",
"\n",
"## Example\n",
"\n",
@@ -148,7 +150,7 @@
"id": "7ef76675-02e4-42ef-b707-0a9ce36ba0ac",
"metadata": {},
"source": [
"### Run the graph"
"### 1. Run the graph"
]
},
{
@@ -189,7 +191,7 @@
"id": "2fc0fc7f-5a7b-4b48-b1e2-d413ad4a51aa",
"metadata": {},
"source": [
"### 1. Identify a checkpoint"
"### 2. Identify a checkpoint"
]
},
{
@@ -254,7 +256,7 @@
"id": "59d5ecb8-1291-4131-83b6-c666862a09fc",
"metadata": {},
"source": [
"### 2. (Optional) update the state\n",
"### 3. Update the state (optional)\n",
"\n",
"`update_state` will create a new checkpoint. The new checkpoint will be associated with the same thread, but a new checkpoint ID."
]
@@ -283,7 +285,7 @@
"id": "db25cd11-7907-4c6f-9c97-b6b3458f246c",
"metadata": {},
"source": [
"### 3. Resume execution from the checkpoint"
"### 4. Resume execution from the checkpoint"
]
},
{
+4 -8
View File
@@ -207,14 +207,10 @@ nav:
- Streaming:
- Overview: cloud/concepts/streaming.md
- cloud/how-tos/streaming.md
- Human-in-the-loop:
- cloud/how-tos/human_in_the_loop_breakpoint.md
- cloud/how-tos/human_in_the_loop_user_input.md
- cloud/how-tos/human_in_the_loop_edit_state.md
- cloud/how-tos/human_in_the_loop_time_travel.md
- cloud/how-tos/human_in_the_loop_review_tool_calls.md
- MCP:
- Overview: concepts/server-mcp.md
- Human-in-the-loop: cloud/how-tos/add-human-in-the-loop.md
- Breakpoints: cloud/how-tos/human_in_the_loop_breakpoint.md
- Time travel: cloud/how-tos/human_in_the_loop_time_travel.md
- MCP: concepts/server-mcp.md
- Double-texting:
- Overview: concepts/double_texting.md
- cloud/how-tos/interrupt_concurrent.md