diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 606492df2..c2d6d80d2 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -24,13 +24,7 @@ jobs: name: "test #${{ matrix.python-version }}" steps: - uses: actions/checkout@v4 - - name: Get changed files - id: changed-files - uses: Ana06/get-changed-files@v2.2.0 - with: - filter: "${{ inputs.working-directory }}/**" - name: Set up Python ${{ matrix.python-version }} + Poetry ${{ env.POETRY_VERSION }} - if: steps.changed-files.outputs.all uses: "./.github/actions/poetry_setup" with: python-version: ${{ matrix.python-version }} @@ -39,20 +33,17 @@ jobs: cache-key: core - name: Install dependencies - if: steps.changed-files.outputs.all shell: bash working-directory: ${{ inputs.working-directory }} run: poetry install --with dev - name: Run core tests - if: steps.changed-files.outputs.all shell: bash working-directory: ${{ inputs.working-directory }} run: | make test - name: Ensure the tests did not create any additional files - if: steps.changed-files.outputs.all shell: bash working-directory: ${{ inputs.working-directory }} run: | diff --git a/docs/docs/cloud/concepts/api.md b/docs/docs/cloud/concepts/api.md index ec0b1ddf6..e18f52c55 100644 --- a/docs/docs/cloud/concepts/api.md +++ b/docs/docs/cloud/concepts/api.md @@ -10,7 +10,7 @@ The LangGraph Cloud API consists of a few core data models: [Assistants](#assist An assistant is a configured instance of a [`CompiledGraph`][compiledgraph]. It abstracts the cognitive architecture of the graph and contains instance specific configuration and metadata. Multiple assistants can reference the same graph but can contain different configuration and metadata, which may differentiate the behavior of the assistants. An assistant (i.e. the graph) is invoked as part of a run. -The LangGraph Cloud API provides several endpoints for creating and managing assistants. See the API reference for more details. +The LangGraph Cloud API provides several endpoints for creating and managing assistants. See the [API reference](../reference/api/api_ref.html#tag/assistantscreate) for more details. #### Configuring Assistants @@ -24,13 +24,13 @@ The state of a thread at a particular point in time is called a checkpoint. For more on threads and checkpoints, see this section of the [LangGraph conceptual guide](../../concepts/low_level.md#checkpointer). -The LangGraph Cloud API provides several endpoints for creating and managing threads and thread state. See the API reference for more details. +The LangGraph Cloud API provides several endpoints for creating and managing threads and thread state. See the [API reference](../reference/api/api_ref.html#tag/threadscreate) for more details. ### Runs A run is an invocation of an assistant. Each run may have its own input, configuration, and metadata, which may affect execution and output of the underlying graph. A run can optionally be executed on a thread. -The LangGraph Cloud API provides several endpoints for creating and managing runs. See the API reference for more details. +The LangGraph Cloud API provides several endpoints for creating and managing runs. See the [API reference](../reference/api/api_ref.html#tag/runscreate) for more details. ### Cron Jobs @@ -41,7 +41,7 @@ It's often useful to run graphs on some schedule. LangGraph Cloud supports cron Note that this sends the same input to the thread every time. See the [how-to guide](../how-tos/cloud_examples/cron_jobs.ipynb) for creating cron jobs. -The LangGraph Cloud API provides several endpoints for creating and managing cron jobs. See the API reference for more details. +The LangGraph Cloud API provides several endpoints for creating and managing cron jobs. See the [API reference](../reference/api/api_ref.html#tag/runscreate/POST/threads/{thread_id}/runs/crons) for more details. ## Features @@ -59,7 +59,7 @@ Streaming is critical for making LLM applications feel responsive to end users. You can also specify multiple streaming modes at the same time. See the [how-to guide](../how-tos/stream_multiple.md) for configuring multiple streaming modes at the same time. -See the API reference for how to create streaming runs. +See the [API reference](../reference/api/api_ref.html#tag/runscreate/POST/threads/{thread_id}/runs/stream) for how to create streaming runs. ### Human-in-the-Loop diff --git a/docs/docs/cloud/deployment/test_locally.md b/docs/docs/cloud/deployment/test_locally.md index d25ecfc79..a5a06624a 100644 --- a/docs/docs/cloud/deployment/test_locally.md +++ b/docs/docs/cloud/deployment/test_locally.md @@ -38,6 +38,46 @@ Ready! We can now interact with the API server using the LangGraph SDK. First, we need to start our client, select our assistant (in this case a graph we called "agent", make sure to select the proper assistant you wish to test). +You can either initialize by passing authentication or by setting an environment variable. + +#### Initialize with authentication + +=== "Python" + + ```python + from langgraph_sdk import get_client + + # only pass the url argument to get_client() if you changed the default port when calling langgraph up + client = get_client(url=,api_key=) + assistant_id = "agent" + thread = await client.threads.create() + ``` + +=== "Javascript" + + ```js + import { Client } from "@langchain/langgraph-sdk"; + + // only set the apiUrl if you changed the default port when calling langgraph up + const client = new Client({ apiUrl: , apiKey: }); + const assistantId = "agent" + const thread = await client.threads.create(); + ``` + +=== "CURL" + + ```bash + curl --request POST \ + --url /threads \ + --header 'Content-Type: application/json' + --header 'x-api-key: ' + ``` + + +#### Initialize with environment variables + +If you have a `LANGCHAIN_API_KEY` set in your environment, you do not need to explicitly pass authentication to the client + === "Python" ```python @@ -60,6 +100,14 @@ We can now interact with the API server using the LangGraph SDK. First, we need const thread = await client.threads.create(); ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads \ + --header 'Content-Type: application/json' + ``` + Now we can invoke our graph to ensure it is working. Make sure to change the input to match the proper schema for your graph. === "Python" @@ -96,4 +144,39 @@ Now we can invoke our graph to ensure it is working. Make sure to change the inp } ``` + === "CURL" + + ```bash + curl --request POST \ + --url /threads//runs/stream \ + --header 'Content-Type: application/json' \ + --data "{ + \"assistant_id\": \"agent\", + \"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"what's the weather in sf\"}]}, + \"stream_mode\": [ + \"events\" + ] + }" | \ + 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" + } + } + ' + ``` + If your graph works correctly, you should see your graph output displayed in the console. Of course, there are many more ways you might need to test your graph, for a full list of commands you can send with the SDK, see the [Python](https://langchain-ai.github.io/langgraph/cloud/reference/sdk/python_sdk_ref/) and [JS/TS](https://langchain-ai.github.io/langgraph/cloud/reference/sdk/js_ts_sdk_ref/) references. \ No newline at end of file diff --git a/docs/docs/cloud/faq/studio.md b/docs/docs/cloud/faq/studio.md new file mode 100644 index 000000000..cef962f34 --- /dev/null +++ b/docs/docs/cloud/faq/studio.md @@ -0,0 +1,65 @@ +# Studio FAQs + +## Why is my project failing to start? + +There are a few reasons that your project might fail to start, here are some of the most common ones. + +### Docker issues + +LangGraph Studio requires Docker Desktop version 4.24 or higher. Please make sure you have a version of Docker installed that satisfies that requirement and also make sure you have the Docker Desktop app up and running before trying to use LangGraph Studio. In addition, make sure you have docker-compose updated to version 2.22.0 or higher. + +### Configuration or environment issues + +Another reason your project might fail to start is because your configuration file is defined incorrectly, or you are missing required environment variables. + +## How does interrupt work? + +When you select the `Interrupts` dropdown and select a node to interrupt the graph will pause execution before and after (unless the node goes straight to `END`) that node has run. This means that you will be able to both edit the state before the node is ran and the state after the node has ran. This is intended to allow developers more fine-grained control over the behavior of a node and make it easier to observe how the node is behaving. You will not be able to edit the state after the node has ran if the node is the final node in the graph. + +## How do I reload the app? + +If you would like to reload the app, don't use Command+R as you might normally do. Instead, close and reopen the app for a full refresh. + +## How does automatic rebuilding work? + +One of the key features of LangGraph Studio is that it automatically rebuilds your image when you change the source code. This allows for a super fast development and testing cycle which makes it easy to iterate on your graph. There are two different ways that LangGraph rebuilds your image: either by editing the image or completely rebuilding it. + +### Rebuilds from source code changes + +If you modified the source code only (no configuration or dependency changes!) then the image does not require a full rebuild, and LangGraph Studio will only update the relevant parts. The UI status in the bottom left will switch from `Online` to `Stopping` temporarily while the image gets edited. The logs will be shown as this process is happening, and after the image has been edited the status will change back to `Online` and you will be able to run your graph with the modified code! + + +### Rebuilds from configuration or dependency changes + +If you edit your graph configuration file (`langgraph.json`) or the dependencies (either `pyproject.toml` or `requirements.txt`) then the entire image will be rebuilt. This will cause the UI to switch away from the graph view and start showing the logs of the new image building process. This can take a minute or two, and once it is done your updated image will be ready to use! + +## Why is my graph taking so long to startup? + +The LangGraph Studio interacts with a local LangGraph API server. To stay aligned with ongoing updates, the LangGraph API requires regular rebuilding. As a result, you may occasionally experience slight delays when starting up your project. + +## Why are extra edges showing up in my graph? + +If you don't define your conditional edges carefully, you might notice extra edges appearing in your graph. This is because without proper definition, LangGraph Studio assumes the conditional edge could access all other nodes. In order for this to not be the case, you need to be explicit about how you define the nodes the conditional edge routes to. There are two ways you can do this: + +### Solution 1: Include a path map + +The first way to solve this is to add path maps to your conditional edges. A path map is just a dictionary that maps the possible outputs of your router function with the names of the nodes that each output corresponds to. The path map is passed as the third argument to the `add_conditional_edges` function like so: + +```python +graph.add_conditional_edges("node_a", routing_function, {True: "node_b", False: "node_c"}) +``` + +In this case, the routing function returns either True or False, which map to `node_b` and `node_c` respectively. + +### Solution 2: Update the typing of the router + +Instead of passing a path map, you can also be explicit about the typing of your routing function by specifying the nodes it can map to using the `Literal` python definition. Here is an example of how to define a routing function in that way: + +```python +def routing_function(state: GraphState) -> Literal["node_b","node_c"]: + if state['some_condition'] == True: + return "node_a" + else: + return "node_b" +``` + diff --git a/docs/docs/cloud/how-tos/check_thread_status.md b/docs/docs/cloud/how-tos/check_thread_status.md new file mode 100644 index 000000000..655b918c1 --- /dev/null +++ b/docs/docs/cloud/how-tos/check_thread_status.md @@ -0,0 +1,200 @@ +# Check the Status of your Threads + +## Setup + +To start, we can setup our client with whatever URL you are hosting your graph from: + +### 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=) + assistant_id = "agent" + thread = await client.threads.create() + ``` + +=== "Javascript" + + ```js + import { Client } from "@langchain/langgraph-sdk"; + + const client = new Client({ apiUrl: }); + const assistantId = agent; + const thread = await client.threads.create(); + ``` + +=== "CURL" + + ```bash + curl --request POST \ + --url /threads \ + --header 'Content-Type: application/json' + ``` + +## Find idle threads + +We can use the following commands to find threads that are idle, which means that all runs executed on the thread have finished running: + +=== "Python" + + ```python + print(await client.threads.search(status="idle",limit=1)) + ``` + +=== "Javascript" + + ```js + console.log(await client.threads.search({status: "idle",limit:1})); + ``` + +=== "CURL" + + ```bash + curl --request POST \ + --url /threads/search \ + --header 'Content-Type: application/json' \ + --data '{"status": "idle", "limit": 1}' + ``` + +Output: + + [{'thread_id': 'cacf79bb-4248-4d01-aabc-938dbd60ed2c', + 'created_at': '2024-08-14T17:36:38.921660+00:00', + 'updated_at': '2024-08-14T17:36:38.921660+00:00', + 'metadata': {'graph_id': 'agent'}, + 'status': 'idle', + 'config': {'configurable': {}}}] + + +## Find interrupted threads + +We can use the following commands to find threads that have been interrupted in the middle of a run, which could either mean an error occurred before the run finished or a human-in-the-loop breakpoint was reached and the run is waiting to continue: + +=== "Python" + + ```python + print(await client.threads.search(status="interrupted",limit=1)) + ``` + +=== "Javascript" + + ```js + console.log(await client.threads.search({status: "interrupted",limit:1})); + ``` + +=== "CURL" + + ```bash + curl --request POST \ + --url /threads/search \ + --header 'Content-Type: application/json' \ + --data '{"status": "interrupted", "limit": 1}' + ``` + +Output: + + [{'thread_id': '0d282b22-bbd5-4d95-9c61-04dcc2e302a5', + 'created_at': '2024-08-14T17:41:50.235455+00:00', + 'updated_at': '2024-08-14T17:41:50.235455+00:00', + 'metadata': {'graph_id': 'agent'}, + 'status': 'interrupted', + 'config': {'configurable': {}}}] + +## Find busy threads + +We can use the following commands to find threads that are busy, meaning they are currently handling the execution of a run: + +=== "Python" + + ```python + print(await client.threads.search(status="busy",limit=1)) + ``` + +=== "Javascript" + + ```js + console.log(await client.threads.search({status: "busy",limit: 1})); + ``` + +=== "CURL" + + ```bash + curl --request POST \ + --url /threads/search \ + --header 'Content-Type: application/json' \ + --data '{"status": "busy", "limit": 1}' + ``` + +Output: + + [{'thread_id': '0d282b22-bbd5-4d95-9c61-04dcc2e302a5', + 'created_at': '2024-08-14T17:41:50.235455+00:00', + 'updated_at': '2024-08-14T17:41:50.235455+00:00', + 'metadata': {'graph_id': 'agent'}, + 'status': 'busy', + 'config': {'configurable': {}}}] + +## Find specific threads + +You may also want to check the status of specific threads, which you can do in a few ways: + +### Find by ID + +You can use the `get` function to find the status of a specific thread, as long as you have the ID saved + +=== "Python" + + ```python + print((await client.threads.get())['status']) + ``` + +=== "Javascript" + + ```js + console.log((await client.threads.get()).status); + ``` + +=== "CURL" + + ```bash + curl --request GET \ + --url /threads/ \ + --header 'Content-Type: application/json' | jq -r '.status' + ``` + +Output: + + 'idle' + +### Find by metadata + +The search endpoint for threads also allows you to filter on metadata, which can be helpful if you use metadata to tag threads in order to keep them organized: + +=== "Python" + + ```python + print((await client.threads.search(metadata={"foo":"bar"},limit=1))[0]['status']) + ``` + +=== "Javascript" + + ```js + console.log((await client.threads.search({metadata: {"foo":"bar"},limit: 1}))[0].status); + ``` + +=== "CURL" + + ```bash + curl --request POST \ + --url /threads/search \ + --header 'Content-Type: application/json' \ + --data '{"metadata": {"foo":"bar"}, "limit": 1}' | jq -r '.[0].status' + ``` + +Output: + + 'idle' \ No newline at end of file diff --git a/docs/docs/cloud/how-tos/copy_threads.md b/docs/docs/cloud/how-tos/copy_threads.md new file mode 100644 index 000000000..9a27282b2 --- /dev/null +++ b/docs/docs/cloud/how-tos/copy_threads.md @@ -0,0 +1,132 @@ +# Copying Threads + +You may wish to copy (i.e. "fork") an existing thread in order to keep the existing thread's history and create independent runs that do not affect the original thread. This guide shows how you can do that. + +## Setup + +This code assumes you already have a thread to copy. You can read about what a thread is [here](https://langchain-ai.github.io/langgraph/cloud/concepts/api/#threads) and learn how to stream a run on a thread in [these how-to guides](https://langchain-ai.github.io/langgraph/cloud/how-tos/#streaming). + +### 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="") + assistant_id = "agent" + thread = await client.threads.create() + ``` + +=== "Javascript" + + ```js + import { Client } from "@langchain/langgraph-sdk"; + + const client = new Client({ apiUrl:"" }); + const assistantId = agent; + const thread = await client.threads.create(); + ``` + +=== "CURL" + + ```bash + curl --request POST \ + --url /threads \ + --header 'Content-Type: application/json' \ + --data '{ + "metadata": {} + }' + ``` + +## Copying a thread + +The code below assumes that a thread you'd like to copy already exists. + +Copying a thread will create a new thread with the same history as the existing thread, and then allow you to continue executing runs. + +### Create copy + +=== "Python" + + ```python + copied_thread = await client.threads.copy() + ``` + +=== "Javascript" + + ```js + let copiedThread = await client.threads.copy(); + ``` + +=== "CURL" + + ```bash + curl --request POST --url /threads//copy \ + --header 'Content-Type: application/json' + ``` + +### Verify copy + +We can verify that the history from the prior thread did indeed copy over correctly: + +=== "Python" + + ```python + def remove_thread_id(d): + if 'metadata' in d and 'thread_id' in d['metadata']: + del d['metadata']['thread_id'] + return d + + original_thread_history = list(map(remove_thread_id,await client.threads.get_history())) + copied_thread_history = list(map(remove_thread_id,await client.threads.get_history(copied_thread['thread_id']))) + + # Compare the two histories + assert original_thread_history == copied_thread_history + # if we made it here the assertion passed! + print("The histories are the same.") + ``` + +=== "Javascript" + + ```js + function removeThreadId(d) { + if (d.metadata && d.metadata.thread_id) { + delete d.metadata.thread_id; + } + return d; + } + + // Assuming `client.threads.getHistory(threadId)` is an async function that returns a list of dicts + async function compareThreadHistories(threadId, copiedThreadId) { + const originalThreadHistory = (await client.threads.getHistory(threadId)).map(removeThreadId); + const copiedThreadHistory = (await client.threads.getHistory(copiedThreadId)).map(removeThreadId); + + // Compare the two histories + console.assert(JSON.stringify(originalThreadHistory) === JSON.stringify(copiedThreadHistory)) + // if we made it here the assertion passed! + console.log("The histories are the same."); + } + + // Example usage + compareThreadHistories(, copiedThread.thread_id); + ``` + +=== "CURL" + + ```bash + if diff <( + curl --request GET --url /threads//history | jq -S 'map(del(.metadata.thread_id))' + ) <( + curl --request GET --url /threads//history | jq -S 'map(del(.metadata.thread_id))' + ) >/dev/null; then + echo "The histories are the same." + else + echo "The histories are different." + fi + ``` + +Output: + + The histories are the same. \ No newline at end of file diff --git a/docs/docs/cloud/how-tos/enqueue_concurrent.md b/docs/docs/cloud/how-tos/enqueue_concurrent.md index 1e4a2e087..17c4ea0f5 100644 --- a/docs/docs/cloud/how-tos/enqueue_concurrent.md +++ b/docs/docs/cloud/how-tos/enqueue_concurrent.md @@ -31,7 +31,7 @@ Then, let's import our required packages and instantiate our client, assistant, from langchain_core.messages import convert_to_messages from langgraph_sdk import get_client - client = get_client(url="whatever-your-deployment-url-is") + client = get_client(url=) assistant_id = "agent" thread = await client.threads.create() ``` @@ -42,7 +42,7 @@ Then, let's import our required packages and instantiate our client, assistant, import { Client } from "@langchain/langgraph-sdk"; - const client = new Client({ apiUrl:"whatever-your-deployment-url-is" }); + const client = new Client({ apiUrl: }); const assistantId = "agent"; const thread = await client.threads.create(); ``` diff --git a/docs/docs/cloud/how-tos/human_in_the_loop_breakpoint.md b/docs/docs/cloud/how-tos/human_in_the_loop_breakpoint.md index 908d9fa9e..56c1931e2 100644 --- a/docs/docs/cloud/how-tos/human_in_the_loop_breakpoint.md +++ b/docs/docs/cloud/how-tos/human_in_the_loop_breakpoint.md @@ -21,7 +21,7 @@ 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="whatever-your-deployment-url-is") + client = get_client(url=) assistant_id = "agent" thread = await client.threads.create() ``` @@ -31,11 +31,19 @@ In this how-to we use a simple ReAct style hosted graph (you can see the full co ```js import { Client } from "@langchain/langgraph-sdk"; - const client = new Client({ apiUrl:"whatever-your-deployment-url-is" }); + const client = new Client({ apiUrl: }); const assistantId = "agent" const thread = await client.threads.create(); ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads \ + --header 'Content-Type: application/json' + ``` + ## Adding a breakpoint We now want to add a breakpoint in our graph run, which we will do before a tool is called. @@ -82,6 +90,42 @@ And, now let's compile it with a breakpoint before the tool node: console.log("\n\n"); } ``` + +=== "CURL" + + ```bash + curl --request POST \ + --url /threads//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" + } + } + ' + ``` Output: diff --git a/docs/docs/cloud/how-tos/human_in_the_loop_edit_state.md b/docs/docs/cloud/how-tos/human_in_the_loop_edit_state.md index fef0e3c6e..668d826a8 100644 --- a/docs/docs/cloud/how-tos/human_in_the_loop_edit_state.md +++ b/docs/docs/cloud/how-tos/human_in_the_loop_edit_state.md @@ -17,7 +17,7 @@ First, we need to setup our client so that we can communicate with our hosted gr ```python from langgraph_sdk import get_client - client = get_client(url="whatever-your-deployment-url-is") + client = get_client(url=) assistant_id = "agent" thread = await client.threads.create() ``` @@ -27,11 +27,19 @@ First, we need to setup our client so that we can communicate with our hosted gr ```js import { Client } from "@langchain/langgraph-sdk"; - const client = new Client({ apiUrl:"whatever-your-deployment-url-is" }); + const client = new Client({ apiUrl: }); const assistantId = "agent"; const thread = await client.threads.create(); ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads \ + --header 'Content-Type: application/json' + ``` + ## Editing state ### Initial invocation @@ -75,6 +83,42 @@ Now let's invoke our graph, making sure to interrupt before the `action` node. } ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads//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}]}} @@ -129,10 +173,22 @@ Now, let's assume we actually meant to search for the weather in Sidi Frej (anot await client.threads.updateState(thread['thread_id'], {values:{"messages": lastMessage}}); ``` +=== "CURL" + + ```bash + curl --request GET --url /threads//state | \ + jq '.values.messages[-1] | (.tool_calls[0].args = {"query": "current weather in Sidi Frej"})' | \ + curl --request POST \ + --url /threads//state \ + --header 'Content-Type: application/json' \ + --data @- + ``` + Output: - {'configurable': {'thread_id': '88d58d3f-4151-47a9-a8e0-e42fdd3527b8', - 'thread_ts': '1ef3274b-a809-6913-8002-91536ce6554d'}} + {'configurable': {'thread_id': '9c8f1a43-9dd8-4017-9271-2c53e57cf66a', + 'checkpoint_ns': '', + 'checkpoint_id': '1ef58e7e-3641-649f-8002-8b4305a64858'}} @@ -171,6 +227,40 @@ Now we can resume our graph run but with the updated state: } ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads//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'}]}} diff --git a/docs/docs/cloud/how-tos/human_in_the_loop_review_tool_calls.md b/docs/docs/cloud/how-tos/human_in_the_loop_review_tool_calls.md new file mode 100644 index 000000000..e9a698181 --- /dev/null +++ b/docs/docs/cloud/how-tos/human_in_the_loop_review_tool_calls.md @@ -0,0 +1,575 @@ +# Review Tool Calls + +Human-in-the-loop (HIL) interactions are crucial for [agentic systems](https://langchain-ai.github.io/langgraph/concepts/agentic_concepts/#human-in-the-loop). A common pattern is to add some human in the loop step after certain tool calls. These tool calls often lead to either a function call or saving of some information. Examples include: + +- A tool call to execute SQL, which will then be run by the tool +- A tool call to generate a summary, which will then be saved to the State of the graph + +Note that using tool calls is common **whether actually calling tools or not**. + +There are typically a few different interactions you may want to do here: + +1. Approve the tool call and continue +2. Modify the tool call manually and then continue +3. Give natural language feedback, and then pass that back to the agent instead of continuing + +We can implement this in LangGraph using a [breakpoint](https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/breakpoints/): breakpoints allow us to interrupt graph execution before a specific step. At this breakpoint, we can manually update the graph state taking one of the three options above + +## 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/review-tool-calls.ipynb#simple-usage) 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=) + assistant_id = "agent" + thread = await client.threads.create() + ``` + +=== "Javascript" + + ```js + import { Client } from "@langchain/langgraph-sdk"; + + const client = new Client({ apiUrl: }); + const assistantId = "agent"; + const thread = await client.threads.create(); + ``` + +## Example with no review + +Let's look at an example when no review is required (because no tools are called) + +=== "Python" + + ```python + input = { 'messages':[{ "role":"user", "content":"hi!" }] } + + 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": "hi!"}] } + + 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); + } + } + ``` + +Output: + + {'messages': [{'content': 'hi!', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '39c51f14-2d5c-4690-883a-d940854b1845', 'example': False}]} + {'messages': [{'content': 'hi!', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '39c51f14-2d5c-4690-883a-d940854b1845', 'example': False}, {'content': [{'text': "Hello! Welcome. How can I assist you today? Is there anything specific you'd like to know or any information you're looking for?", 'type': 'text', 'index': 0}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'end_turn', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-d65e07fb-43ff-4d98-ab6b-6316191b9c8b', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 355, 'output_tokens': 31, 'total_tokens': 386}}]} + + +If we check the state, we can see that it is finished + +=== "Python" + + ```python + state = await client.threads.get_state(thread["thread_id"]) + + print(state['next']) + ``` + +=== "Javascript" + + ```js + const state = await client.threads.getState(thread["thread_id"]); + + console.log(state.next); + ``` + +Output: + + [] + +## Example of approving tool + +Let's now look at what it looks like to approve a tool call. Note that we don't need to pass an interrupt to our streaming calls because the graph (defined [here](../../how-tos/human_in_the_loop/review-tool-calls.ipynb#simple-usage)) was already compiled with an interrupt before the `human_review_node`. + +=== "Python" + + ```python + input = {"messages": [{"role": "user", "content": "what's the weather in sf?"}]} + + async for chunk in client.runs.stream( + thread["thread_id"], + "agent", + input=input, + stream_mode="values", + ): + if chunk.data and chunk.event != "metadata": + print(chunk.data) + ``` + +=== "Javascript" + + ```js + const input = {"messages": [{"role": "user", "content": "what's the weather in sf?"}]} + + const streamResponse = client.runs.stream( + thread["thread_id"], + assistantId, + { + input: input, + streamMode: "values", + } + ); + for await (const chunk of streamResponse) { + if (chunk.data && chunk.event !== "metadata") { + console.log(chunk.data); + } + } + ``` + +Output: + + {'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '54e19d6e-89fa-44fb-b92c-12e7dd4ddf08', 'example': False}]} + {'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '54e19d6e-89fa-44fb-b92c-12e7dd4ddf08', 'example': False}, {'content': [{'text': "Certainly! I can help you check the weather in San Francisco. To get this information, I'll use the weather search function. Let me do that for you right away.", 'type': 'text', 'index': 0}, {'id': 'toolu_015yrR3GMDXe6X8m2p9CsEDN', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-45a6b6c3-ac69-42a4-8957-d982203d6392', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_015yrR3GMDXe6X8m2p9CsEDN', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 90, 'total_tokens': 450}}]} + + +If we now check, we can see that it is waiting on human review: + +=== "Python" + + ```python + state = await client.threads.get_state(thread["thread_id"]) + + print(state['next']) + ``` + +=== "Javascript" + + ```js + const state = await client.threads.getState(thread["thread_id"]); + + console.log(state.next); + ``` + +Output: + + ['human_review_node'] + +To approve the tool call, we can just continue the thread with no edits. To do this, we just create a new run with no inputs. + +=== "Python" + + ```python + async for chunk in client.runs.stream( + thread["thread_id"], + "agent", + input=None, + stream_mode="values", + ): + if chunk.data and chunk.event != "metadata": + print(chunk.data) + ``` + +=== "Javascript" + + ```js + const streamResponse = client.runs.stream( + thread["thread_id"], + assistantId, + { + input: undefined, + streamMode: "values", + } + ); + for await (const chunk of streamResponse) { + if (chunk.data && chunk.event !== "metadata") { + console.log(chunk.data); + } + } + ``` + +Output: + + {'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '54e19d6e-89fa-44fb-b92c-12e7dd4ddf08', 'example': False}, {'content': [{'text': "Certainly! I can help you check the weather in San Francisco. To get this information, I'll use the weather search function. Let me do that for you right away.", 'type': 'text', 'index': 0}, {'id': 'toolu_015yrR3GMDXe6X8m2p9CsEDN', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-45a6b6c3-ac69-42a4-8957-d982203d6392', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_015yrR3GMDXe6X8m2p9CsEDN', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 90, 'total_tokens': 450}}, {'content': 'Sunny!', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'weather_search', 'id': '826cd0f2-9cc6-46f0-b7df-daa6a05d13d2', 'tool_call_id': 'toolu_015yrR3GMDXe6X8m2p9CsEDN', 'artifact': None, 'status': 'success'}]} + {'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '54e19d6e-89fa-44fb-b92c-12e7dd4ddf08', 'example': False}, {'content': [{'text': "Certainly! I can help you check the weather in San Francisco. To get this information, I'll use the weather search function. Let me do that for you right away.", 'type': 'text', 'index': 0}, {'id': 'toolu_015yrR3GMDXe6X8m2p9CsEDN', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-45a6b6c3-ac69-42a4-8957-d982203d6392', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_015yrR3GMDXe6X8m2p9CsEDN', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 90, 'total_tokens': 450}}, {'content': 'Sunny!', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'weather_search', 'id': '826cd0f2-9cc6-46f0-b7df-daa6a05d13d2', 'tool_call_id': 'toolu_015yrR3GMDXe6X8m2p9CsEDN', 'artifact': None, 'status': 'success'}, {'content': [{'text': "\n\nGreat news! The weather in San Francisco is sunny today. It's a beautiful day in the city by the bay. Is there anything else you'd like to know about the weather or any other information I can help you with?", 'type': 'text', 'index': 0}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'end_turn', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-5d5fd0f1-a939-447e-801a-9aaa812322d3', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 464, 'output_tokens': 50, 'total_tokens': 514}}]} + +## Edit Tool Call + +Let's now say we want to edit the tool call. E.g. change some of the parameters (or even the tool called!) but then execute that tool. + +=== "Python" + + ```python + input = {"messages": [{"role": "user", "content": "what's the weather in sf?"}]} + + async for chunk in client.runs.stream( + thread["thread_id"], + "agent", + input=input, + stream_mode="values", + ): + if chunk.data and chunk.event != "metadata": + print(chunk.data) + ``` + +=== "Javascript" + + ```js + const input = {"messages": [{"role": "user", "content": "what's the weather in sf?"}]} + + const streamResponse = client.runs.stream( + thread["thread_id"], + assistantId, + { + input: input, + streamMode: "values", + } + ); + for await (const chunk of streamResponse) { + if (chunk.data && chunk.event !== "metadata") { + console.log(chunk.data); + } + } + ``` + +Output: + + {'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'cec11391-84da-464b-bd2a-bd4f0d93b9ee', 'example': False}]} + {'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'cec11391-84da-464b-bd2a-bd4f0d93b9ee', 'example': False}, {'content': [{'text': 'To get the weather information for San Francisco, I can use the weather_search function. Let me do that for you.', 'type': 'text', 'index': 0}, {'id': 'toolu_01SunSpDurNfcnXppWLPrtjC', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-6326da9f-6061-4e12-8586-482e32ab4cab', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_01SunSpDurNfcnXppWLPrtjC', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 80, 'total_tokens': 440}}]} + + +To do this, we first need to update the state. We can do this by passing a message in with the **same** id of the message we want to overwrite. This will have the effect of **replacing** that old message. Note that this is only possible because of the **reducer** we are using that replaces messages with the same ID - read more about that [here](https://langchain-ai.github.io/langgraph/concepts/low_level/#working-with-messages-in-graph-state). + + +=== "Python" + + ```python + # To get the ID of the message we want to replace, we need to fetch the current state and find it there. + state = await client.threads.get_state(thread['thread_id']) + print("Current State:") + print(state['values']) + print("\nCurrent Tool Call ID:") + current_content = state['values']['messages'][-1]['content'] + current_id = state['values']['messages'][-1]['id'] + tool_call_id = state['values']['messages'][-1]['tool_calls'][0]['id'] + print(tool_call_id) + + # We now need to construct a replacement tool call. + # We will change the argument to be `San Francisco, USA` + # Note that we could change any number of arguments or tool names - it just has to be a valid one + new_message = { + "role": "assistant", + "content": current_content, + "tool_calls": [ + { + "id": tool_call_id, + "name": "weather_search", + "args": {"city": "San Francisco, USA"} + } + ], + # This is important - this needs to be the same as the message you replacing! + # Otherwise, it will show up as a separate message + "id": current_id + } + await client.threads.update_state( + # This is the config which represents this thread + thread['thread_id'], + # This is the updated value we want to push + {"messages": [new_message]}, + # We push this update acting as our human_review_node + as_node="human_review_node" + ) + + print("\nResuming Execution") + # Let's now continue executing from here + async for chunk in client.runs.stream( + thread["thread_id"], + "agent", + input=None, + stream_mode="values", + ): + if chunk.data and chunk.event != "metadata": + print(chunk.data) + ``` + +=== "Javascript" + + ```js + const state = await client.threads.getState(thread.thread_id); + console.log("Current State:"); + console.log(state.values); + + console.log("\nCurrent Tool Call ID:"); + const lastMessage = state.values.messages[state.values.messages.length - 1]; + const currentContent = lastMessage.content; + const currentId = lastMessage.id; + const toolCallId = lastMessage.tool_calls[0].id; + console.log(toolCallId); + + // Construct a replacement tool call + const newMessage = { + role: "assistant", + content: currentContent, + tool_calls: [ + { + id: toolCallId, + name: "weather_search", + args: { city: "San Francisco, USA" } + } + ], + // Ensure the ID is the same as the message you're replacing + id: currentId + }; + + await client.threads.updateState( + thread.thread_id, // Thread ID + { + values: { "messages": [newMessage] }, // Updated message + asNode: "human_review_node" + } // Acting as human_review_node + ); + + console.log("\nResuming Execution"); + // Continue executing from here + const streamResponseResumed = client.runs.stream( + thread["thread_id"], + assistantId, + { + input: undefined, + streamMode: "values", + interruptBefore: ["action"], + } + ); + for await (const chunk of streamResponseResumed) { + if (chunk.data && chunk.event !== "metadata") { + console.log(chunk.data); + } + } + ``` + +Output: + + Current State: + {'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '8713d1fa-9b26-4eab-b768-dafdaac70590', 'example': False}, {'content': [{'text': 'To get the weather information for San Francisco, I can use the weather_search function. Let me do that for you.', 'type': 'text', 'index': 0}, {'id': 'toolu_01VzagzsUGZsNMwW1wHkcw7h', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-ede13f26-daf5-4d8f-817a-7611075bbcf1', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_01VzagzsUGZsNMwW1wHkcw7h', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 80, 'total_tokens': 440}}]} + + Current Tool Call ID: + toolu_01VzagzsUGZsNMwW1wHkcw7h + + Resuming Execution + {'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '8713d1fa-9b26-4eab-b768-dafdaac70590', 'example': False}, {'content': [{'text': 'To get the weather information for San Francisco, I can use the weather_search function. Let me do that for you.', 'type': 'text', 'index': 0}, {'id': 'toolu_01VzagzsUGZsNMwW1wHkcw7h', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-ede13f26-daf5-4d8f-817a-7611075bbcf1', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}, 'id': 'toolu_01VzagzsUGZsNMwW1wHkcw7h', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': None}, {'content': 'Sunny!', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'weather_search', 'id': '7fc7d463-66bf-4555-9929-6af483de169b', 'tool_call_id': 'toolu_01VzagzsUGZsNMwW1wHkcw7h', 'artifact': None, 'status': 'success'}]} + {'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '8713d1fa-9b26-4eab-b768-dafdaac70590', 'example': False}, {'content': [{'text': 'To get the weather information for San Francisco, I can use the weather_search function. Let me do that for you.', 'type': 'text', 'index': 0}, {'id': 'toolu_01VzagzsUGZsNMwW1wHkcw7h', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-ede13f26-daf5-4d8f-817a-7611075bbcf1', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}, 'id': 'toolu_01VzagzsUGZsNMwW1wHkcw7h', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': None}, {'content': 'Sunny!', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'weather_search', 'id': '7fc7d463-66bf-4555-9929-6af483de169b', 'tool_call_id': 'toolu_01VzagzsUGZsNMwW1wHkcw7h', 'artifact': None, 'status': 'success'}, {'content': [{'text': "\n\nBased on the search result, the weather in San Francisco is sunny! It's a beautiful day in the city by the bay. Is there anything else you'd like to know about the weather or any other information I can help you with?", 'type': 'text', 'index': 0}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'end_turn', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-d90ce97a-39f9-4330-985e-67c5f351a0c5', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 455, 'output_tokens': 52, 'total_tokens': 507}}]} + +## Give feedback to a tool call + +Sometimes, you may not want to execute a tool call, but you also may not want to ask the user to manually modify the tool call. In that case it may be better to get natural language feedback from the user. You can then insert these feedback as a mock **RESULT** of the tool call. + +There are multiple ways to do this: + +You could add a new message to the state (representing the "result" of a tool call) +You could add TWO new messages to the state - one representing an "error" from the tool call, other HumanMessage representing the feedback +Both are similar in that they involve adding messages to the state. The main difference lies in the logic AFTER the `human_node` and how it handles different types of messages. + +For this example we will just add a single tool call representing the feedback. Let's see this in action! + +=== "Python" + + ```python + input = {"messages": [{"role": "user", "content": "what's the weather in sf?"}]} + + async for chunk in client.runs.stream( + thread["thread_id"], + "agent", + input=input, + stream_mode="values", + ): + if chunk.data and chunk.event != "metadata": + print(chunk.data) + ``` + +=== "Javascript" + + ```js + const input = {"messages": [{"role": "user", "content": "what's the weather in sf?"}]} + + const streamResponse = client.runs.stream( + thread["thread_id"], + assistantId, + { + input: input, + streamMode: "values", + } + ); + for await (const chunk of streamResponse) { + if (chunk.data && chunk.event !== "metadata") { + console.log(chunk.data); + } + } + ``` + +Output: + + {'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'c80f13d0-674d-4233-b6a0-3940509d3cf3', 'example': False}]} + {'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'c80f13d0-674d-4233-b6a0-3940509d3cf3', 'example': False}, {'content': [{'text': 'To get the weather information for San Francisco, I can use the weather_search function. Let me do that for you.', 'type': 'text', 'index': 0}, {'id': 'toolu_016XyTdFA8NuPWeLyZPSzoM3', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-4911ac27-3d7c-4edf-a3ca-c2908e3922eb', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_016XyTdFA8NuPWeLyZPSzoM3', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 80, 'total_tokens': 440}}]} + +To do this, we first need to update the state. We can do this by passing a message in with the same **tool call id** of the tool call we want to respond to. Note that this is a **different*** ID from above + +=== "Python" + + ```python + # To get the ID of the message we want to replace, we need to fetch the current state and find it there. + state = await client.threads.get_state(thread['thread_id']) + print("Current State:") + print(state['values']) + print("\nCurrent Tool Call ID:") + tool_call_id = state['values']['messages'][-1]['tool_calls'][0]['id'] + print(tool_call_id) + + # We now need to construct a replacement tool call. + # We will change the argument to be `San Francisco, USA` + # Note that we could change any number of arguments or tool names - it just has to be a valid one + new_message = { + "role": "tool", + # This is our natural language feedback + "content": "User requested changes: pass in the country as well", + "name": "weather_search", + "tool_call_id": tool_call_id + } + await client.threads.update_state( + # This is the config which represents this thread + thread['thread_id'], + # This is the updated value we want to push + {"messages": [new_message]}, + # We push this update acting as our human_review_node + as_node="human_review_node" + ) + + print("\nResuming execution") + # Let's now continue executing from here + async for chunk in client.runs.stream( + thread["thread_id"], + "agent", + input=None, + stream_mode="values", + ): + if chunk.data and chunk.event != "metadata": + print(chunk.data) + ``` + +=== "Javascript" + + ```js + const state = await client.threads.getState(thread.thread_id); + console.log("Current State:"); + console.log(state.values); + + console.log("\nCurrent Tool Call ID:"); + const lastMessage = state.values.messages[state.values.messages.length - 1]; + const toolCallId = lastMessage.tool_calls[0].id; + console.log(toolCallId); + + // Construct a replacement tool call + const newMessage = { + role: "tool", + content: "User requested changes: pass in the country as well", + name: "weather_search", + tool_call_id: toolCallId, + }; + + await client.threads.updateState( + thread.thread_id, // Thread ID + { + values: { "messages": [newMessage] }, // Updated message + asNode: "human_review_node" + } // Acting as human_review_node + ); + + console.log("\nResuming Execution"); + // Continue executing from here + const streamResponseEdited = client.runs.stream( + thread["thread_id"], + assistantId, + { + input: undefined, + streamMode: "values", + interruptBefore: ["action"], + } + ); + for await (const chunk of streamResponseEdited) { + if (chunk.data && chunk.event !== "metadata") { + console.log(chunk.data); + } + } + ``` + +Output: + + Current State: + {'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '3b2bbc38-d11b-49eb-80c0-c24a40dab5a8', 'example': False}, {'content': [{'text': 'To get the weather information for San Francisco, I can use the weather_search function. Let me do that for you.', 'type': 'text', 'index': 0}, {'id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-c5a50900-abf5-4885-9cdb-da2bf0d892ac', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 80, 'total_tokens': 440}}]} + + Current Tool Call ID: + toolu_01NNw18j57GEGPZvsa9f1wvX + + Resuming execution + {'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '3b2bbc38-d11b-49eb-80c0-c24a40dab5a8', 'example': False}, {'content': [{'text': 'To get the weather information for San Francisco, I can use the weather_search function. Let me do that for you.', 'type': 'text', 'index': 0}, {'id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-c5a50900-abf5-4885-9cdb-da2bf0d892ac', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 80, 'total_tokens': 440}}, {'content': 'User requested changes: pass in the country as well', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'weather_search', 'id': '787288be-213c-4fd3-8503-4a009bdb1b00', 'tool_call_id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'artifact': None, 'status': 'success'}, {'content': [{'text': '\n\nI apologize for the oversight. It seems the function requires additional information. Let me try again with a more specific request.', 'type': 'text', 'index': 0}, {'id': 'toolu_01YAbLBoKozJyRQnB8LUMpXC', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco, USA"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-5c355a56-cfe3-4046-b49f-f5b09fc397ef', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}, 'id': 'toolu_01YAbLBoKozJyRQnB8LUMpXC', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 461, 'output_tokens': 83, 'total_tokens': 544}}]} + +We can see that we now get to another breakpoint - because it went back to the model and got an entirely new prediction of what to call. Let's now approve this one and continue + +=== "Python" + + ```python + async for chunk in client.runs.stream( + thread["thread_id"], + "agent", + input=None, + stream_mode="values", + ): + if chunk.data and chunk.event != "metadata": + print(chunk.data) + ``` + +=== "Javascript" + + ```js + const streamResponseResumed = client.runs.stream( + thread["thread_id"], + assistantId, + { + input: undefined, + streamMode: "values", + } + ); + for await (const chunk of streamResponseResumed) { + if (chunk.data && chunk.event !== "metadata") { + console.log(chunk.data); + } + } + ``` + +Output: + + {'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '3b2bbc38-d11b-49eb-80c0-c24a40dab5a8', 'example': False}, {'content': [{'text': 'To get the weather information for San Francisco, I can use the weather_search function. Let me do that for you.', 'type': 'text', 'index': 0}, {'id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-c5a50900-abf5-4885-9cdb-da2bf0d892ac', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 80, 'total_tokens': 440}}, {'content': 'User requested changes: pass in the country as well', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'weather_search', 'id': '787288be-213c-4fd3-8503-4a009bdb1b00', 'tool_call_id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'artifact': None, 'status': 'success'}, {'content': [{'text': '\n\nI apologize for the oversight. It seems the function requires additional information. Let me try again with a more specific request.', 'type': 'text', 'index': 0}, {'id': 'toolu_01YAbLBoKozJyRQnB8LUMpXC', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco, USA"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-5c355a56-cfe3-4046-b49f-f5b09fc397ef', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}, 'id': 'toolu_01YAbLBoKozJyRQnB8LUMpXC', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 461, 'output_tokens': 83, 'total_tokens': 544}}, {'content': 'Sunny!', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'weather_search', 'id': '3b857482-bca2-4a73-a9ab-1f35a3e43e5f', 'tool_call_id': 'toolu_01YAbLBoKozJyRQnB8LUMpXC', 'artifact': None, 'status': 'success'}]} + {'messages': [{'content': "what's the weather in sf?", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '3b2bbc38-d11b-49eb-80c0-c24a40dab5a8', 'example': False}, {'content': [{'text': 'To get the weather information for San Francisco, I can use the weather_search function. Let me do that for you.', 'type': 'text', 'index': 0}, {'id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-c5a50900-abf5-4885-9cdb-da2bf0d892ac', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco'}, 'id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 360, 'output_tokens': 80, 'total_tokens': 440}}, {'content': 'User requested changes: pass in the country as well', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'weather_search', 'id': '787288be-213c-4fd3-8503-4a009bdb1b00', 'tool_call_id': 'toolu_01NNw18j57GEGPZvsa9f1wvX', 'artifact': None, 'status': 'success'}, {'content': [{'text': '\n\nI apologize for the oversight. It seems the function requires additional information. Let me try again with a more specific request.', 'type': 'text', 'index': 0}, {'id': 'toolu_01YAbLBoKozJyRQnB8LUMpXC', 'input': {}, 'name': 'weather_search', 'type': 'tool_use', 'index': 1, 'partial_json': '{"city": "San Francisco, USA"}'}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'tool_use', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-5c355a56-cfe3-4046-b49f-f5b09fc397ef', 'example': False, 'tool_calls': [{'name': 'weather_search', 'args': {'city': 'San Francisco, USA'}, 'id': 'toolu_01YAbLBoKozJyRQnB8LUMpXC', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 461, 'output_tokens': 83, 'total_tokens': 544}}, {'content': 'Sunny!', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'weather_search', 'id': '3b857482-bca2-4a73-a9ab-1f35a3e43e5f', 'tool_call_id': 'toolu_01YAbLBoKozJyRQnB8LUMpXC', 'artifact': None, 'status': 'success'}, {'content': [{'text': "\n\nGreat news! The weather in San Francisco is sunny today. Is there anything else you'd like to know about the weather or any other information I can help you with?", 'type': 'text', 'index': 0}], 'additional_kwargs': {}, 'response_metadata': {'stop_reason': 'end_turn', 'stop_sequence': None}, 'type': 'ai', 'name': None, 'id': 'run-6a857bb1-f65b-4b86-93d6-c025e003c777', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': {'input_tokens': 557, 'output_tokens': 38, 'total_tokens': 595}}]} diff --git a/docs/docs/cloud/how-tos/human_in_the_loop_time_travel.md b/docs/docs/cloud/how-tos/human_in_the_loop_time_travel.md index 0c6df0910..aac3ee566 100644 --- a/docs/docs/cloud/how-tos/human_in_the_loop_time_travel.md +++ b/docs/docs/cloud/how-tos/human_in_the_loop_time_travel.md @@ -14,7 +14,7 @@ First, we need to setup our client so that we can communicate with our hosted gr ```python from langgraph_sdk import get_client - client = get_client(url="whatever-your-deployment-url-is") + client = get_client(url=) assistant_id = "agent" thread = await client.threads.create() ``` @@ -24,11 +24,19 @@ First, we need to setup our client so that we can communicate with our hosted gr ```js import { Client } from "@langchain/langgraph-sdk"; - const client = new Client({ apiUrl:"whatever-your-deployment-url-is" }); + const client = new Client({ apiUrl: }); const assistantId = agent; const thread = await client.threads.create(); ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads \ + --header 'Content-Type: application/json' + ``` + ## Replay a state ### Initial invocation @@ -69,6 +77,41 @@ Before replaying a state - we need to create states to replay from! In order to } } ``` + +=== "CURL" + + ```bash + curl --request POST \ + --url /threads//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: @@ -100,6 +143,12 @@ Now let's get our list of states, and invoke from the third state (right before console.log(stateToReplay['next']); ``` +=== "CURL" + + ```bash + curl --request GET --url /threads//history | jq -r '.[2].next' + ``` + Output: ['action'] @@ -116,7 +165,7 @@ To rerun from a state, we need to pass in the `checkpoint_id` into the config of assistant_id, # graph_id input=None, stream_mode="updates", - config={"configurable": {"thread_ts": state_to_replay['checkpoint_id']}} + config={"configurable": {"checkpoint_id": state_to_replay['checkpoint_id']}} ): if chunk.data and chunk.event != "metadata": print(chunk.data) @@ -131,7 +180,7 @@ To rerun from a state, we need to pass in the `checkpoint_id` into the config of { input: null, streamMode: "updates", - config: {"configurable": {"thread_ts": stateToReplay['checkpoint_id']}}, + config: {"configurable": {"checkpoint_id": stateToReplay['checkpoint_id']}}, } ); for await (const chunk of streamResponse) { @@ -141,6 +190,43 @@ To rerun from a state, we need to pass in the `checkpoint_id` into the config of } ``` +=== "CURL" + + ```bash + curl --request GET --url /threads//history | jq -r '.[2].checkpoint_id' | { + read checkpoint_id + curl --request POST \ + --url /threads//runs/stream \ + --header 'Content-Type: application/json' \ + --data "{ + \"assistant_id\": \"agent\", + \"config\": {\"configurable\": {\"checkpoint_id\": \"$checkpoint_id\"}}, + \"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 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'}]}} @@ -181,6 +267,23 @@ Let's show how to do this to edit the state at a particular point in time. Let's const newState = await client.threads.updateState(thread['thread_id'],{values:{"messages":[lastMessage]},checkpointId:stateToReplay['checkpoint_id']}); ``` +=== "CURL" + + ```bash + curl -s --request GET --url /threads//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 /threads//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" @@ -191,7 +294,7 @@ Now we can rerun our graph with this new config, starting from the `new_state`, assistant["assistant_id"], # graph_id input=None, stream_mode="updates", - config={"configurable": {"thread_ts": new_state['configurable']['thread_ts']}} + config={"configurable": {"checkpoint_id": new_state['configurable']['checkpoint_id']}} ): if chunk.data and chunk.event != "metadata": print(chunk.data) @@ -206,7 +309,7 @@ Now we can rerun our graph with this new config, starting from the `new_state`, { input: null, streamMode: "updates", - config: {"configurable": {"thread_ts": newState['configurable']['thread_ts']}}, + config: {"configurable": {"checkpoint_id": newState['configurable']['checkpoint_id']}}, } ); for await (const chunk of streamResponse) { @@ -216,6 +319,39 @@ Now we can rerun our graph with this new config, starting from the `new_state`, } ``` +=== "CURL" + + ```bash + curl -s --request GET --url /threads//state | \ + jq -r '.config.configurable.checkpoint_id' | \ + sh -c ' + CHECKPOINT_ID="$1" + curl --request POST \ + --url /threads//runs/stream \ + --header "Content-Type: application/json" \ + --data "{\"assistant_id\": \"agent\", \"config\": {\"configurable\": {\"checkpoint_id\": \"$CHECKPOINT_ID\"}}, \"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: diff --git a/docs/docs/cloud/how-tos/human_in_the_loop_user_input.md b/docs/docs/cloud/how-tos/human_in_the_loop_user_input.md index 1f29897ef..9b9d1392e 100644 --- a/docs/docs/cloud/how-tos/human_in_the_loop_user_input.md +++ b/docs/docs/cloud/how-tos/human_in_the_loop_user_input.md @@ -24,7 +24,7 @@ First, we need to setup our client so that we can communicate with our hosted gr ```python from langgraph_sdk import get_client - client = get_client(url="whatever-your-deployment-url-is") + client = get_client(url=) assistant_id = "agent" thread = await client.threads.create() ``` @@ -34,11 +34,19 @@ First, we need to setup our client so that we can communicate with our hosted gr ```js import { Client } from "@langchain/langgraph-sdk"; - const client = new Client({ apiUrl:"whatever-your-deployment-url-is" }); + const client = new Client({ apiUrl: }); const assistantId = "agent"; const thread = await client.threads.create(); ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads \ + --header 'Content-Type: application/json' + ``` + ## Waiting for user input ### Initial invocation @@ -80,6 +88,42 @@ Now, let's invoke our graph by interrupting before `ask_human` node: } } ``` + +=== "CURL" + + ```bash + curl --request POST \ + --url /threads//runs/stream \ + --header 'Content-Type: application/json' \ + --data "{ + \"assistant_id\": \"agent\", + \"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"Use the search tool to ask the user where they are, then look up the weather there\"}]}, + \"interrupt_before\": [\"ask_human\"], + \"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: @@ -117,11 +161,31 @@ Because we are treating this as a tool call, we will need to update the state as await client.threads.updateState(thread['thread_id'], {values: {"messages": toolMessage}, asNode:"ask_human"}) ``` +=== "CURL" + + ```bash + curl --request GET \ + --url /threads//state \ + | jq -r '.values.messages[-1].tool_calls[0].id' \ + | sh -c ' + TOOL_CALL_ID="$1" + + # Construct the JSON payload + JSON_PAYLOAD=$(printf "{\"messages\": [{\"tool_call_id\": \"%s\", \"type\": \"tool\", \"content\": \"san francisco\"}], \"as_node\": \"ask_human\"}" "$TOOL_CALL_ID") + + # Send the updated state + curl --request POST \ + --url /threads//state \ + --header "Content-Type: application/json" \ + --data "${JSON_PAYLOAD}" + ' _ + ``` + Output: - {'configurable': {'thread_id': '10d0ee61-db47-48fc-a58c-109a1e68cd73', - 'thread_ts': '1ef32729-3cc3-6647-8002-14dcb621b46e'}} - + {'configurable': {'thread_id': 'a9f322ae-4ed1-41ec-942b-38cb3d342c3a', + 'checkpoint_ns': '', + 'checkpoint_id': '1ef58e97-a623-63dd-8002-39a9a9b20be3'}} ### Invoking after receiving human input @@ -133,7 +197,7 @@ We can now tell the agent to continue. We can just pass in None as the input to ```python async for chunk in client.runs.stream( thread["thread_id"], - assistant_id, # graph_id + assistant_id, input=None, stream_mode="updates", ): @@ -158,6 +222,40 @@ We can now tell the agent to continue. We can just pass in None as the input to } ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads//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: {'agent': {'messages': [{'content': [{'text': "Thank you for letting me know that you're in San Francisco. Now, I'll use the search function to look up the weather in San Francisco.", 'type': 'text'}, {'id': 'toolu_01K57ofmgG2wyJ8tYJjbq5k7', 'input': {'query': 'current weather in San Francisco'}, 'name': 'search', 'type': 'tool_use'}], 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-241baed7-db5e-44ce-ac3c-56431705c22b', 'example': False, 'tool_calls': [{'name': 'search', 'args': {'query': 'current weather in San Francisco'}, 'id': 'toolu_01K57ofmgG2wyJ8tYJjbq5k7'}], 'invalid_tool_calls': [], 'usage_metadata': None}]}} diff --git a/docs/docs/cloud/how-tos/index.md b/docs/docs/cloud/how-tos/index.md index ba3d16e01..255490d32 100644 --- a/docs/docs/cloud/how-tos/index.md +++ b/docs/docs/cloud/how-tos/index.md @@ -46,6 +46,7 @@ When creating complex graphs, leaving every decision up to the LLM can be danger - [How to wait for user input](./human_in_the_loop_user_input.md) - [How to edit graph state](./human_in_the_loop_edit_state.md) - [How to replay and branch from prior states](./human_in_the_loop_time_travel.md) +- [How to review tool calls](./human_in_the_loop_review_tool_calls.md) ## LangGraph Studio @@ -72,3 +73,5 @@ Other guides that may prove helpful! - [How to configure agents](cloud_examples/configuration_cloud.ipynb) - [How to convert LangGraph calls to LangGraph cloud calls](cloud_examples/langgraph_to_langgraph_cloud.ipynb) - [How to integrate webhooks](cloud_examples/webhooks.ipynb) +- [How to copy threads](./copy_threads.md) +- [How to check status of your threads](./check_thread_status.md) diff --git a/docs/docs/cloud/how-tos/interrupt_concurrent.md b/docs/docs/cloud/how-tos/interrupt_concurrent.md index c34ae64c6..d58df875b 100644 --- a/docs/docs/cloud/how-tos/interrupt_concurrent.md +++ b/docs/docs/cloud/how-tos/interrupt_concurrent.md @@ -29,7 +29,7 @@ Now, let's import our required packages and instantiate our client, assistant, a from langchain_core.messages import convert_to_messages from langgraph_sdk import get_client - client = get_client(url="whatever-your-deployment-url-is") + client = get_client(url=) assistant_id = "agent" thread = await client.threads.create() ``` @@ -39,7 +39,7 @@ Now, let's import our required packages and instantiate our client, assistant, a ```js import { Client } from "@langchain/langgraph-sdk"; - const client = new Client({ apiUrl:"whatever-your-deployment-url-is" }); + const client = new Client({ apiUrl: }); const assistantId = "agent"; const thread = await client.threads.create(); ``` diff --git a/docs/docs/cloud/how-tos/reject_concurrent.md b/docs/docs/cloud/how-tos/reject_concurrent.md index 5d92e8466..22d0ae30c 100644 --- a/docs/docs/cloud/how-tos/reject_concurrent.md +++ b/docs/docs/cloud/how-tos/reject_concurrent.md @@ -28,7 +28,7 @@ Now, let's import our required packages and instantiate our client, assistant, a from langchain_core.messages import convert_to_messages from langgraph_sdk import get_client - client = get_client(url="whatever-your-deployment-url-is") + client = get_client(url=) assistant_id = "agent" thread = await client.threads.create() ``` @@ -38,7 +38,7 @@ Now, let's import our required packages and instantiate our client, assistant, a ```js import { Client } from "@langchain/langgraph-sdk"; - const client = new Client({ apiUrl:"whatever-your-deployment-url-is" }); + const client = new Client({ apiUrl: }); const assistantId = "agent"; const thread = await client.threads.create(); ``` diff --git a/docs/docs/cloud/how-tos/rollback_concurrent.md b/docs/docs/cloud/how-tos/rollback_concurrent.md index f8c64add4..6fa34eb95 100644 --- a/docs/docs/cloud/how-tos/rollback_concurrent.md +++ b/docs/docs/cloud/how-tos/rollback_concurrent.md @@ -30,7 +30,7 @@ Now, let's import our required packages and instantiate our client, assistant, a from langchain_core.messages import convert_to_messages from langgraph_sdk import get_client - client = get_client(url="whatever-your-deployment-url-is") + client = get_client(url=) assistant_id = "agent" thread = await client.threads.create() ``` @@ -40,7 +40,7 @@ Now, let's import our required packages and instantiate our client, assistant, a ```js import { Client } from "@langchain/langgraph-sdk"; - const client = new Client({ apiUrl:"whatever-your-deployment-url-is" }); + const client = new Client({ apiUrl: }); const assistantId = "agent"; const thread = await client.threads.create(); ``` diff --git a/docs/docs/cloud/how-tos/stream_debug.md b/docs/docs/cloud/how-tos/stream_debug.md index 815cdef82..e580286ca 100644 --- a/docs/docs/cloud/how-tos/stream_debug.md +++ b/docs/docs/cloud/how-tos/stream_debug.md @@ -9,7 +9,7 @@ First let's set up our client and thread: ```python from langgraph_sdk import get_client - client = get_client(url="whatever-your-deployment-url-is") + client = get_client(url=) # create thread thread = await client.threads.create() print(thread) @@ -20,7 +20,7 @@ First let's set up our client and thread: ```js import { Client } from "@langchain/langgraph-sdk"; - const client = new Client({ apiUrl:"whatever-your-deployment-url-is" }); + const client = new Client({ apiUrl: }); // create thread const thread = await client.threads.create(); console.log(thread) diff --git a/docs/docs/cloud/how-tos/stream_events.md b/docs/docs/cloud/how-tos/stream_events.md index bbd61b050..3b0c58177 100644 --- a/docs/docs/cloud/how-tos/stream_events.md +++ b/docs/docs/cloud/how-tos/stream_events.md @@ -6,7 +6,7 @@ This guide covers how to stream events from your graph (`stream_mode="events"`). ```python from langgraph_sdk import get_client - client = get_client(url="whatever-your-deployment-url-is") + client = get_client(url=) # create thread thread = await client.threads.create() print(thread) @@ -17,12 +17,19 @@ This guide covers how to stream events from your graph (`stream_mode="events"`). ```js import { Client } from "@langchain/langgraph-sdk"; - const client = new Client({ apiUrl:"whatever-your-deployment-url-is" }); + const client = new Client({ apiUrl: }); // create thread const thread = await client.threads.create(); console.log(thread) ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads \ + --header 'Content-Type: application/json' + ``` Output: @@ -30,7 +37,9 @@ Output: {'thread_id': '3f4c64e0-f792-4a5e-aa07-a4404e06e0bd', 'created_at': '2024-06-24T22:16:29.301522+00:00', 'updated_at': '2024-06-24T22:16:29.301522+00:00', - 'metadata': {}} + 'metadata': {}, + 'status': 'idle', + 'config': {}} @@ -91,6 +100,41 @@ Streaming events produces responses containing an `event` key (in addition to ot } ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads//runs/stream \ + --header 'Content-Type: application/json' \ + --data "{ + \"assistant_id\": \"agent\", + \"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"What's the weather in sf\"}]}, + \"stream_mode\": [ + \"events\" + ] + }" | \ + 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" + } + } + ' + ``` + Output: Receiving new event of type: metadata... @@ -258,9 +302,11 @@ Token-by-token streaming can be implemented with the `events` streaming mode. Th ): if ( chunk.event == "events" and - chunk.data["event"] == "on_chat_model_stream" + chunk.data["event"] == "on_chat_model_stream" and + len(chunk.data["data"]["chunk"]["content"]) > 0 and + 'text' in chunk.data["data"]["chunk"]["content"][0] ): - llm_response += chunk.data["data"]["chunk"]["content"] + llm_response += chunk.data["data"]["chunk"]["content"][0]['text'] print(llm_response) ``` @@ -278,21 +324,88 @@ Token-by-token streaming can be implemented with the `events` streaming mode. Th } ); for await (const chunk of streamResponse) { - if (chunk.event === "events" && chunk.data.event === "on_chat_model_stream") { - llmResponse += chunk.data.data.chunk.content; + if (chunk.event === "events" && chunk.data.event === "on_chat_model_stream" && chunk.data.chunk.content.length > 0 && 'text' in chunk.data.chunk.content[0]) { + llmResponse += chunk.data.data.chunk.content[0].text; console.log(llmResponse); } } ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads//runs/stream \ + --header 'Content-Type: application/json' \ + --data "{ + \"assistant_id\": \"agent\", + \"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"What's the weather in sf\"}]}, + \"stream_mode\": [ + \"events\" + ] + }" | sed 's/\r$//' | awk ' + /^event:/ { event = $2 } + /^data:/ { + json_data = substr($0, index($0, $2)) + + if (event == "events") { + print json_data + } + }' | jq -r ' + select(.event == "on_chat_model_stream") | + .data.chunk.content[] | .text // empty + ' | awk ' + BEGIN { llm_response="" } + $0 != "" && $0 != "null" { + llm_response = llm_response $0 + print llm_response + }' + ``` + Output: - b - be - beg - begi - begin - begine - beginen - beginend + The + The search + The search results provide + The search results provide the current weather conditions + The search results provide the current weather conditions in San Francisco. + The search results provide the current weather conditions in San Francisco. According + The search results provide the current weather conditions in San Francisco. According to the data, + The search results provide the current weather conditions in San Francisco. According to the data, as + The search results provide the current weather conditions in San Francisco. According to the data, as of 3 + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60 + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60. + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F ( + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16° + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The win + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is bl + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west- + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13 + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 k + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility is 6 + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility is 6 miles + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility is 6 miles (10 km + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility is 6 miles (10 km). + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility is 6 miles (10 km). Overall + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility is 6 miles (10 km). Overall, it appears + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility is 6 miles (10 km). Overall, it appears to be a nice + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility is 6 miles (10 km). Overall, it appears to be a nice sunny day in San + The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility is 6 miles (10 km). Overall, it appears to be a nice sunny day in San Francisco. + diff --git a/docs/docs/cloud/how-tos/stream_messages.md b/docs/docs/cloud/how-tos/stream_messages.md index 49cbf29b6..c25f2ccdd 100644 --- a/docs/docs/cloud/how-tos/stream_messages.md +++ b/docs/docs/cloud/how-tos/stream_messages.md @@ -41,7 +41,7 @@ First let's set up our client and thread: ```python from langgraph_sdk import get_client - client = get_client(url="whatever-your-deployment-url-is") + client = get_client(url=) # create thread thread = await client.threads.create() print(thread) @@ -52,20 +52,30 @@ First let's set up our client and thread: ```js import { Client } from "@langchain/langgraph-sdk"; - const client = new Client({ apiUrl:"whatever-your-deployment-url-is" }); + const client = new Client({ apiUrl: }); // create thread const thread = await client.threads.create(); console.log(thread) ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads \ + --header 'Content-Type: application/json' + ``` + Output: {'thread_id': 'e1431c95-e241-4d1d-a252-27eceb1e5c86', 'created_at': '2024-06-21T15:48:59.808924+00:00', 'updated_at': '2024-06-21T15:48:59.808924+00:00', - 'metadata': {}} + 'metadata': {}, + 'status': 'idle', + 'config': {}} -Let's also define a helper function for better formatting of the tool calls in messages +Let's also define a helper function for better formatting of the tool calls in messages (for CURL we will define a helper script called `process_stream.sh`) === "Python" @@ -95,6 +105,69 @@ Let's also define a helper function for better formatting of the tool calls in m } ``` +=== "CURL" + + ```bash + # process_stream.sh + + format_tool_calls() { + echo "$1" | jq -r 'map("Tool Call ID: \(.id), Function: \(.name), Arguments: \(.args)") | join("\n")' + } + + process_data_item() { + local data_item="$1" + + if echo "$data_item" | jq -e '.role == "user"' > /dev/null; then + echo "Human: $(echo "$data_item" | jq -r '.content')" + else + local tool_calls=$(echo "$data_item" | jq -r '.tool_calls // []') + local invalid_tool_calls=$(echo "$data_item" | jq -r '.invalid_tool_calls // []') + local content=$(echo "$data_item" | jq -r '.content // ""') + local response_metadata=$(echo "$data_item" | jq -r '.response_metadata // {}') + + if [ -n "$content" ] && [ "$content" != "null" ]; then + echo "AI: $content" + fi + + if [ "$tool_calls" != "[]" ]; then + echo "Tool Calls:" + format_tool_calls "$tool_calls" + fi + + if [ "$invalid_tool_calls" != "[]" ]; then + echo "Invalid Tool Calls:" + format_tool_calls "$invalid_tool_calls" + fi + + if [ "$response_metadata" != "{}" ]; then + local finish_reason=$(echo "$response_metadata" | jq -r '.finish_reason // "N/A"') + echo "Response Metadata: Finish Reason - $finish_reason" + fi + fi + } + + while IFS=': ' read -r key value; do + case "$key" in + event) + event="$value" + ;; + data) + if [ "$event" = "metadata" ]; then + run_id=$(echo "$value" | jq -r '.run_id') + echo "Metadata: Run ID - $run_id" + echo "------------------------------------------------" + elif [ "$event" = "messages/partial" ]; then + echo "$value" | jq -c '.[]' | while read -r data_item; do + process_data_item "$data_item" + done + echo "------------------------------------------------" + fi + ;; + esac + done + ``` + + Now we can stream by messages, which will return complete messages (at the end of node execution) as well as tokens for any messages generated inside a node: === "Python" @@ -201,6 +274,23 @@ Now we can stream by messages, which will return complete messages (at the end o } ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads//runs/stream \ + --header 'Content-Type: application/json' \ + --data "{ + \"assistant_id\": \"agent\", + \"config\":{\"configurable\":{\"model_name\":\"openai\"}}, + \"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"What's the weather in sf\"}]}, + \"stream_mode\": [ + \"messages\" + ] + }" | sed 's/\r$//' | ./process_stream.sh + ``` + + Output: Metadata: Run ID - 1ef2fe5c-6a1d-6575-bc09-d7832711c17e diff --git a/docs/docs/cloud/how-tos/stream_multiple.md b/docs/docs/cloud/how-tos/stream_multiple.md index 0e7a0c143..2f03fb550 100644 --- a/docs/docs/cloud/how-tos/stream_multiple.md +++ b/docs/docs/cloud/how-tos/stream_multiple.md @@ -9,7 +9,7 @@ First let's set up our client and thread: ```python from langgraph_sdk import get_client - client = get_client(url="whatever-your-deployment-url-is") + client = get_client(url=) # create thread thread = await client.threads.create() print(thread) @@ -20,19 +20,28 @@ First let's set up our client and thread: ```js import { Client } from "@langchain/langgraph-sdk"; - const client = new Client({ apiUrl:"whatever-your-deployment-url-is" }); + const client = new Client({ apiUrl: }); // create thread const thread = await client.threads.create(); console.log(thread) ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads \ + --header 'Content-Type: application/json' + ``` Output: {'thread_id': 'bfc68029-1f7b-400f-beab-6f9032a52da4', 'created_at': '2024-06-24T21:30:07.980789+00:00', 'updated_at': '2024-06-24T21:30:07.980789+00:00', - 'metadata': {}} + 'metadata': {}, + 'status': 'idle', + 'config': {}} When configuring multiple streaming modes for a run, responses for each respective mode will be produced. In the following example, note that a `list` of modes (`messages`, `events`, `debug`) is passed to the `stream_mode` parameter and the response contains `events`, `debug`, `messages/complete`, `messages/metadata`, and `messages/partial` event types. @@ -90,6 +99,43 @@ When configuring multiple streaming modes for a run, responses for each respecti } ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads//runs/stream \ + --header 'Content-Type: application/json' \ + --data "{ + \"assistant_id\": \"agent\", + \"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"What's the weather in SF?\"}]}, + \"stream_mode\": [ + \"messages\", + \"events\", + \"debug\" + ] + }" | \ + 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" + } + } + ' + ``` + Output: Receiving new event of type: metadata... diff --git a/docs/docs/cloud/how-tos/stream_updates.md b/docs/docs/cloud/how-tos/stream_updates.md index bd7123786..38edc3381 100644 --- a/docs/docs/cloud/how-tos/stream_updates.md +++ b/docs/docs/cloud/how-tos/stream_updates.md @@ -16,7 +16,7 @@ First let's set up our client and thread: ```python from langgraph_sdk import get_client - client = get_client(url="whatever-your-deployment-url-is") + client = get_client(url=) # create thread thread = await client.threads.create() print(thread) @@ -27,19 +27,28 @@ First let's set up our client and thread: ```js import { Client } from "@langchain/langgraph-sdk"; - const client = new Client({ apiUrl:"whatever-your-deployment-url-is" }); + const client = new Client({ apiUrl: }); // create thread const thread = await client.threads.create(); console.log(thread) ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads \ + --header 'Content-Type: application/json' + ``` Output: {'thread_id': '979e3c89-a702-4882-87c2-7a59a250ce16', 'created_at': '2024-06-21T15:22:07.453100+00:00', 'updated_at': '2024-06-21T15:22:07.453100+00:00', - 'metadata': {}} + 'metadata': {}, + 'status': 'idle', + 'config': {}} Now we can stream by updates, which outputs updates made to the state by each node after it has executed: @@ -93,6 +102,41 @@ Now we can stream by updates, which outputs updates made to the state by each no } ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads//runs/stream \ + --header 'Content-Type: application/json' \ + --data "{ + \"assistant_id\": \"agent\", + \"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"What's the weather in la\"}]}, + \"stream_mode\": [ + \"updates\" + ] + }" | \ + 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" + } + } + ' + ``` + Output: Receiving new event of type: metadata... diff --git a/docs/docs/cloud/how-tos/stream_values.md b/docs/docs/cloud/how-tos/stream_values.md index e2a739473..bb2c237eb 100644 --- a/docs/docs/cloud/how-tos/stream_values.md +++ b/docs/docs/cloud/how-tos/stream_values.md @@ -16,7 +16,7 @@ First let's set up our client and thread: ```python from langgraph_sdk import get_client - client = get_client(url="whatever-your-deployment-url-is") + client = get_client(url=) # create thread thread = await client.threads.create() print(thread) @@ -27,18 +27,28 @@ First let's set up our client and thread: ```js import { Client } from "@langchain/langgraph-sdk"; - const client = new Client({ apiUrl: "whatever-your-deployment-url-is" }); + const client = new Client({ apiUrl: }); // create thread const thread = await client.threads.create(); console.log(thread) ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads \ + --header 'Content-Type: application/json' + ``` + Output: {'thread_id': 'bfc68029-1f7b-400f-beab-6f9032a52da4', 'created_at': '2024-06-24T21:30:07.980789+00:00', 'updated_at': '2024-06-24T21:30:07.980789+00:00', - 'metadata': {}} + 'metadata': {}, + 'status': 'idle', + 'config': {}} Now we can stream by values, which streams the full state of the graph after each node has finished executing: @@ -60,7 +70,6 @@ Now we can stream by values, which streams the full state of the graph after eac ``` === "Javascript" - ```js const input = {"messages": [{"role": "human", "content": "what's the weather in la"}]} @@ -80,6 +89,41 @@ Now we can stream by values, which streams the full state of the graph after eac } ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads//runs/stream \ + --header 'Content-Type: application/json' \ + --data "{ + \"assistant_id\": \"agent\", + \"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"what's the weather in la\"}]}, + \"stream_mode\": [ + \"values\" + ] + }" | \ + 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" + } + } + ' + ``` + Output: @@ -149,6 +193,34 @@ If we want to just get the final result, we can use this endpoint and just keep } ``` +=== "CURL" + + ```bash + curl --request POST \ + --url /threads//runs/stream \ + --header 'Content-Type: application/json' \ + --data "{ + \"assistant_id\": \"agent\", + \"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"what's the weather in la\"}]}, + \"stream_mode\": [ + \"values\" + ] + }" | \ + sed 's/\r$//' | \ + awk ' + /^data:/ { + sub(/^data: /, "", $0) + data_content = $0 + } + END { + if (data_content != "") { + print data_content + } + } + ' + ``` + + Output: {'messages': [{'content': 'what's the weather in la', diff --git a/docs/docs/cloud/img/graph_video_poster.png b/docs/docs/cloud/img/graph_video_poster.png new file mode 100644 index 000000000..e757082d6 Binary files /dev/null and b/docs/docs/cloud/img/graph_video_poster.png differ diff --git a/docs/docs/cloud/index.md b/docs/docs/cloud/index.md index 61ff1ca72..73421da49 100644 --- a/docs/docs/cloud/index.md +++ b/docs/docs/cloud/index.md @@ -23,6 +23,8 @@ The LangGraph Cloud API exposes functionality of your LangGraph application thro LangGraph Cloud is seamlessly integrated with [LangSmith](https://www.langchain.com/langsmith) and is accessible from within the LangSmith UI. +LangGraph Cloud applications can be tested and debugged using the [LangGraph Studio Desktop](https://github.com/langchain-ai/langgraph-studio). + ## Key Features The LangGraph Cloud API supports key LangGraph features in addition to new functionality for enabling complex, agentic workflows. diff --git a/docs/docs/cloud/quick_start.md b/docs/docs/cloud/quick_start.md index bfab8ca09..c59a6fb85 100644 --- a/docs/docs/cloud/quick_start.md +++ b/docs/docs/cloud/quick_start.md @@ -66,6 +66,16 @@ Now that we have set everything up on our local file system, we are ready to hos ## Test the graph build locally +### Using LangGraph Studio Desktop (recommended) + +![LangGraph Studio Desktop](./img/graph_video_poster.png) + +Testing your graph locally is easy with LangGraph Studio Desktop. LangGraph Studio offers a new way to develop LLM applications by providing a specialized agent IDE that enables visualization, interaction, and debugging of complex agentic applications + +With visual graphs and the ability to edit state, you can better understand agent workflows and iterate faster. LangGraph Studio integrates with [LangSmith](https://smith.langchain.com) so you can collaborate with teammates to debug failure modes. + +### Using the LangGraph CLI + Before deploying to the cloud, we probably want to test the building of our graph locally. This is useful to make sure we have configured our [CLI configuration file][langgraph.json] correctly and our graph runs. In order to do this we can first install the LangGraph CLI diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index dfe321c89..3bc3f3d24 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -216,6 +216,7 @@ nav: - Wait for User Input: "cloud/how-tos/human_in_the_loop_user_input.md" - Edit Graph State: "cloud/how-tos/human_in_the_loop_edit_state.md" - Replay and Branch from Prior States: "cloud/how-tos/human_in_the_loop_time_travel.md" + - Review Tool Calls: "cloud/how-tos/human_in_the_loop_review_tool_calls.md" - LangGraph Studio: - Test Cloud Deployment: "cloud/how-tos/test_deployment.md" - Test Local Deployment: "cloud/how-tos/test_local_deployment.md" @@ -230,6 +231,8 @@ nav: - Configure Agents: "cloud/how-tos/cloud_examples/configuration_cloud.ipynb" - Convert LangGraph calls to LangGraph Cloud calls: "cloud/how-tos/cloud_examples/langgraph_to_langgraph_cloud.ipynb" - Integrate Webhooks: 'cloud/how-tos/cloud_examples/webhooks.ipynb' + - Copy Threads: 'cloud/how-tos/copy_threads.md' + - Check Status of Threads: "cloud/how-tos/check_thread_status.md" - Conceptual Guides: - API Concepts: "cloud/concepts/api.md" - Cloud Concepts: "cloud/concepts/cloud.md" @@ -240,6 +243,8 @@ nav: - JS/TS: "cloud/reference/sdk/js_ts_sdk_ref.md" - CLI: "cloud/reference/cli.md" - Environment Variables: "cloud/reference/env_var.md" + - FAQ: + - Studio: "cloud/faq/studio.md" markdown_extensions: - abbr diff --git a/examples/chatbot-simulation-evaluation/agent-simulation-evaluation.ipynb b/examples/chatbot-simulation-evaluation/agent-simulation-evaluation.ipynb index 98c130a52..c41653c50 100644 --- a/examples/chatbot-simulation-evaluation/agent-simulation-evaluation.ipynb +++ b/examples/chatbot-simulation-evaluation/agent-simulation-evaluation.ipynb @@ -26,7 +26,10 @@ "id": "0d30b6f7-3bec-4d9f-af50-43dfdc81ae6c", "metadata": {}, "outputs": [], - "source": ["# %%capture --no-stderr\n# %pip install -U langgraph langchain langchain_openai"] + "source": [ + "# %%capture --no-stderr\n", + "# %pip install -U langgraph langchain langchain_openai" + ] }, { "cell_type": "code", @@ -34,7 +37,24 @@ "id": "30c2f3de-c730-4aec-85a6-af2c2f058803", "metadata": {}, "outputs": [], - "source": ["import getpass\nimport os\n\n\ndef _set_if_undefined(var: str):\n if not os.environ.get(var):\n os.environ[var] = getpass.getpass(f\"Please provide your {var}\")\n\n\n_set_if_undefined(\"OPENAI_API_KEY\")\n_set_if_undefined(\"LANGCHAIN_API_KEY\")\n\n# Optional, add tracing in LangSmith.\n# This will help you visualize and debug the control flow\nos.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\nos.environ[\"LANGCHAIN_PROJECT\"] = \"Agent Simulation Evaluation\""] + "source": [ + "import getpass\n", + "import os\n", + "\n", + "\n", + "def _set_if_undefined(var: str):\n", + " if not os.environ.get(var):\n", + " os.environ[var] = getpass.getpass(f\"Please provide your {var}\")\n", + "\n", + "\n", + "_set_if_undefined(\"OPENAI_API_KEY\")\n", + "_set_if_undefined(\"LANGCHAIN_API_KEY\")\n", + "\n", + "# Optional, add tracing in LangSmith.\n", + "# This will help you visualize and debug the control flow\n", + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_PROJECT\"] = \"Agent Simulation Evaluation\"" + ] }, { "cell_type": "markdown", @@ -55,7 +75,24 @@ "id": "828479af-cf9c-4888-a365-599643a96b55", "metadata": {}, "outputs": [], - "source": ["from typing import List\n\nimport openai\n\n\n# This is flexible, but you can define your agent here, or call your agent API here.\ndef my_chat_bot(messages: List[dict]) -> dict:\n system_message = {\n \"role\": \"system\",\n \"content\": \"You are a customer support agent for an airline.\",\n }\n messages = [system_message] + messages\n completion = openai.chat.completions.create(\n messages=messages, model=\"gpt-3.5-turbo\"\n )\n return completion.choices[0].message.model_dump()"] + "source": [ + "from typing import List\n", + "\n", + "import openai\n", + "\n", + "\n", + "# This is flexible, but you can define your agent here, or call your agent API here.\n", + "def my_chat_bot(messages: List[dict]) -> dict:\n", + " system_message = {\n", + " \"role\": \"system\",\n", + " \"content\": \"You are a customer support agent for an airline.\",\n", + " }\n", + " messages = [system_message] + messages\n", + " completion = openai.chat.completions.create(\n", + " messages=messages, model=\"gpt-3.5-turbo\"\n", + " )\n", + " return completion.choices[0].message.model_dump()" + ] }, { "cell_type": "code", @@ -77,7 +114,9 @@ "output_type": "execute_result" } ], - "source": ["my_chat_bot([{\"role\": \"user\", \"content\": \"hi!\"}])"] + "source": [ + "my_chat_bot([{\"role\": \"user\", \"content\": \"hi!\"}])" + ] }, { "cell_type": "markdown", @@ -96,7 +135,33 @@ "id": "32c147df-7f90-4b0d-9a6b-671677020353", "metadata": {}, "outputs": [], - "source": ["from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder\nfrom langchain_openai import ChatOpenAI\n\nsystem_prompt_template = \"\"\"You are a customer of an airline company. \\\nYou are interacting with a user who is a customer support person. \\\n\n{instructions}\n\nWhen you are finished with the conversation, respond with a single word 'FINISHED'\"\"\"\n\nprompt = ChatPromptTemplate.from_messages(\n [\n (\"system\", system_prompt_template),\n MessagesPlaceholder(variable_name=\"messages\"),\n ]\n)\ninstructions = \"\"\"Your name is Harrison. You are trying to get a refund for the trip you took to Alaska. \\\nYou want them to give you ALL the money back. \\\nThis trip happened 5 years ago.\"\"\"\n\nprompt = prompt.partial(name=\"Harrison\", instructions=instructions)\n\nmodel = ChatOpenAI()\n\nsimulated_user = prompt | model"] + "source": [ + "from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder\n", + "from langchain_openai import ChatOpenAI\n", + "\n", + "system_prompt_template = \"\"\"You are a customer of an airline company. \\\n", + "You are interacting with a user who is a customer support person. \\\n", + "\n", + "{instructions}\n", + "\n", + "When you are finished with the conversation, respond with a single word 'FINISHED'\"\"\"\n", + "\n", + "prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\"system\", system_prompt_template),\n", + " MessagesPlaceholder(variable_name=\"messages\"),\n", + " ]\n", + ")\n", + "instructions = \"\"\"Your name is Harrison. You are trying to get a refund for the trip you took to Alaska. \\\n", + "You want them to give you ALL the money back. \\\n", + "This trip happened 5 years ago.\"\"\"\n", + "\n", + "prompt = prompt.partial(name=\"Harrison\", instructions=instructions)\n", + "\n", + "model = ChatOpenAI()\n", + "\n", + "simulated_user = prompt | model" + ] }, { "cell_type": "code", @@ -115,7 +180,12 @@ "output_type": "execute_result" } ], - "source": ["from langchain_core.messages import HumanMessage\n\nmessages = [HumanMessage(content=\"Hi! How can I help you?\")]\nsimulated_user.invoke({\"messages\": messages})"] + "source": [ + "from langchain_core.messages import HumanMessage\n", + "\n", + "messages = [HumanMessage(content=\"Hi! How can I help you?\")]\n", + "simulated_user.invoke({\"messages\": messages})" + ] }, { "cell_type": "markdown", @@ -153,7 +223,20 @@ "id": "69e2a3a3-40f3-4223-9136-113738440be9", "metadata": {}, "outputs": [], - "source": ["from langchain_community.adapters.openai import convert_message_to_dict\nfrom langchain_core.messages import AIMessage\n\n\ndef chat_bot_node(messages):\n # Convert from LangChain format to the OpenAI format, which our chatbot function expects.\n messages = [convert_message_to_dict(m) for m in messages]\n # Call the chat bot\n chat_bot_response = my_chat_bot(messages)\n # Respond with an AI Message\n return AIMessage(content=chat_bot_response[\"content\"])"] + "source": [ + "from langchain_community.adapters.openai import convert_message_to_dict\n", + "from langchain_core.messages import AIMessage\n", + "\n", + "\n", + "def chat_bot_node(state):\n", + " messages = state[\"messages\"]\n", + " # Convert from LangChain format to the OpenAI format, which our chatbot function expects.\n", + " messages = [convert_message_to_dict(m) for m in messages]\n", + " # Call the chat bot\n", + " chat_bot_response = my_chat_bot(messages)\n", + " # Respond with an AI Message\n", + " return {\"messages\":[AIMessage(content=chat_bot_response[\"content\"])]}" + ] }, { "cell_type": "markdown", @@ -169,7 +252,26 @@ "id": "7cad7527-ffa5-4c30-8585-b54a7a18bd98", "metadata": {}, "outputs": [], - "source": ["def _swap_roles(messages):\n new_messages = []\n for m in messages:\n if isinstance(m, AIMessage):\n new_messages.append(HumanMessage(content=m.content))\n else:\n new_messages.append(AIMessage(content=m.content))\n return new_messages\n\n\ndef simulated_user_node(messages):\n # Swap roles of messages\n new_messages = _swap_roles(messages)\n # Call the simulated user\n response = simulated_user.invoke({\"messages\": new_messages})\n # This response is an AI message - we need to flip this to be a human message\n return HumanMessage(content=response.content)"] + "source": [ + "def _swap_roles(messages):\n", + " new_messages = []\n", + " for m in messages:\n", + " if isinstance(m, AIMessage):\n", + " new_messages.append(HumanMessage(content=m.content))\n", + " else:\n", + " new_messages.append(AIMessage(content=m.content))\n", + " return new_messages\n", + "\n", + "\n", + "def simulated_user_node(state):\n", + " messages = state[\"messages\"]\n", + " # Swap roles of messages\n", + " new_messages = _swap_roles(messages)\n", + " # Call the simulated user\n", + " response = simulated_user.invoke({\"messages\": new_messages})\n", + " # This response is an AI message - we need to flip this to be a human message\n", + " return {\"messages\":[HumanMessage(content=response.content)]}" + ] }, { "cell_type": "markdown", @@ -192,7 +294,16 @@ "id": "28004fbf-a2f3-46b7-bde7-46c7adaf97fb", "metadata": {}, "outputs": [], - "source": ["def should_continue(messages):\n if len(messages) > 6:\n return \"end\"\n elif messages[-1].content == \"FINISHED\":\n return \"end\"\n else:\n return \"continue\""] + "source": [ + "def should_continue(state):\n", + " messages = state[\"messages\"]\n", + " if len(messages) > 6:\n", + " return \"end\"\n", + " elif messages[-1].content == \"FINISHED\":\n", + " return \"end\"\n", + " else:\n", + " return \"continue\"" + ] }, { "cell_type": "markdown", @@ -210,7 +321,36 @@ "id": "0b597e4b-4cbb-4bbc-82e5-f7e31275964c", "metadata": {}, "outputs": [], - "source": ["from langgraph.graph import END, MessageGraph, START\n\ngraph_builder = MessageGraph()\ngraph_builder.add_node(\"user\", simulated_user_node)\ngraph_builder.add_node(\"chat_bot\", chat_bot_node)\n# Every response from your chat bot will automatically go to the\n# simulated user\ngraph_builder.add_edge(\"chat_bot\", \"user\")\ngraph_builder.add_conditional_edges(\n \"user\",\n should_continue,\n # If the finish criteria are met, we will stop the simulation,\n # otherwise, the virtual user's message will be sent to your chat bot\n {\n \"end\": END,\n \"continue\": \"chat_bot\",\n },\n)\n# The input will first go to your chat bot\ngraph_builder.add_edge(START, \"chat_bot\")\nsimulation = graph_builder.compile()"] + "source": [ + "from langgraph.graph import END, StateGraph, START\n", + "from langgraph.graph.message import add_messages\n", + "from typing import Annotated\n", + "from typing_extensions import TypedDict\n", + "\n", + "\n", + "class State(TypedDict):\n", + " messages: Annotated[list, add_messages]\n", + "\n", + "graph_builder = StateGraph(State)\n", + "graph_builder.add_node(\"user\", simulated_user_node)\n", + "graph_builder.add_node(\"chat_bot\", chat_bot_node)\n", + "# Every response from your chat bot will automatically go to the\n", + "# simulated user\n", + "graph_builder.add_edge(\"chat_bot\", \"user\")\n", + "graph_builder.add_conditional_edges(\n", + " \"user\",\n", + " should_continue,\n", + " # If the finish criteria are met, we will stop the simulation,\n", + " # otherwise, the virtual user's message will be sent to your chat bot\n", + " {\n", + " \"end\": END,\n", + " \"continue\": \"chat_bot\",\n", + " },\n", + ")\n", + "# The input will first go to your chat bot\n", + "graph_builder.add_edge(START, \"chat_bot\")\n", + "simulation = graph_builder.compile()" + ] }, { "cell_type": "markdown", @@ -251,7 +391,13 @@ ] } ], - "source": ["for chunk in simulation.stream([]):\n # Print out all events aside from the final end chunk\n if END not in chunk:\n print(chunk)\n print(\"----\")"] + "source": [ + "for chunk in simulation.stream({}):\n", + " # Print out all events aside from the final end chunk\n", + " if END not in chunk:\n", + " print(chunk)\n", + " print(\"----\")" + ] }, { "cell_type": "code", @@ -259,7 +405,7 @@ "id": "dde4f2b5-cfe8-4ff0-99ea-fe2c5fed70c0", "metadata": {}, "outputs": [], - "source": [""] + "source": [] } ], "metadata": { diff --git a/examples/chatbots/information-gather-prompting.ipynb b/examples/chatbots/information-gather-prompting.ipynb index 079478dc7..bc1248cda 100644 --- a/examples/chatbots/information-gather-prompting.ipynb +++ b/examples/chatbots/information-gather-prompting.ipynb @@ -177,10 +177,16 @@ "outputs": [], "source": [ "from langgraph.checkpoint.memory import MemorySaver\n", - "from langgraph.graph import START, MessageGraph\n", + "from langgraph.graph import StateGraph, START\n", + "from langgraph.graph.message import add_messages\n", + "from typing import Annotated\n", + "from typing_extensions import TypedDict\n", + "\n", + "class State(TypedDict):\n", + " messages: Annotated[list, add_messages]\n", "\n", "memory = MemorySaver()\n", - "workflow = MessageGraph()\n", + "workflow = StateGraph(State)\n", "workflow.add_node(\"info\", chain)\n", "workflow.add_node(\"prompt\", prompt_gen_chain)\n", "\n", diff --git a/examples/introduction.ipynb b/examples/introduction.ipynb index d0f269946..3d0287d05 100644 --- a/examples/introduction.ipynb +++ b/examples/introduction.ipynb @@ -174,7 +174,7 @@ "id": "b6c1dcd9-fb86-4649-81b4-ff6ce20a2e46", "metadata": {}, "source": [ - "**Notice** how the `chatbot` node function takes the current `State` as input and returns an updated `messages` list. This is the basic pattern for all LangGraph node functions.\n", + "**Notice** how the `chatbot` node function takes the current `State` as input and returns a dictionary containing an updated `messages` list under the key \"messages\". This is the basic pattern for all LangGraph node functions.\n", "\n", "The `add_messages` function in our `State` will append the llm's response messages to whatever messages are already in the state.\n", "\n", @@ -1256,19 +1256,10 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 7, "id": "5a81608a-373a-4339-b1c6-65b73a92b983", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/Users/wfh/code/lc/langchain/libs/core/langchain_core/_api/beta_decorator.py:87: LangChainBetaWarning: The method `ChatAnthropic.bind_tools` is in beta. It is actively being worked on, so the API may change.\n", - " warn_beta(\n" - ] - } - ], + "outputs": [], "source": [ "from typing import Annotated\n", "\n", @@ -1320,12 +1311,12 @@ "id": "813505b2-18c1-46e9-b891-20a34232808b", "metadata": {}, "source": [ - "Now, compile the graph, specifying to `interrupt_before` the `action` node." + "Now, compile the graph, specifying to `interrupt_before` the `tools` node." ] }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 8, "id": "b0883e32-1a39-4ce9-ae32-bbd66708fd84", "metadata": {}, "outputs": [], @@ -1334,14 +1325,14 @@ " checkpointer=memory,\n", " # This is new!\n", " interrupt_before=[\"tools\"],\n", - " # Note: can also interrupt __after__ actions, if desired.\n", + " # Note: can also interrupt __after__ tools, if desired.\n", " # interrupt_after=[\"tools\"]\n", ")" ] }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 9, "id": "9f318020-ab7e-415b-a5e2-eddec6d9f3a6", "metadata": {}, "outputs": [ @@ -1354,10 +1345,10 @@ "I'm learning LangGraph. Could you do some research on it for me?\n", "==================================\u001b[1m Ai Message \u001b[0m==================================\n", "\n", - "[{'text': \"Okay, let's do some research on LangGraph:\", 'type': 'text'}, {'id': 'toolu_01Be7aRgMEv9cg6ezaFjiCry', 'input': {'query': 'LangGraph'}, 'name': 'tavily_search_results_json', 'type': 'tool_use'}]\n", + "[{'text': \"Okay, let's look up some information on LangGraph:\", 'type': 'text'}, {'id': 'toolu_01XoHVKTRbipJokQorfifzvh', 'input': {'query': 'LangGraph'}, 'name': 'tavily_search_results_json', 'type': 'tool_use'}]\n", "Tool Calls:\n", - " tavily_search_results_json (toolu_01Be7aRgMEv9cg6ezaFjiCry)\n", - " Call ID: toolu_01Be7aRgMEv9cg6ezaFjiCry\n", + " tavily_search_results_json (toolu_01XoHVKTRbipJokQorfifzvh)\n", + " Call ID: toolu_01XoHVKTRbipJokQorfifzvh\n", " Args:\n", " query: LangGraph\n" ] @@ -1385,17 +1376,17 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 10, "id": "9bb7af46-9b4f-4bb1-b8b9-e9ddf7dbc82c", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "('action',)" + "('tools',)" ] }, - "execution_count": 4, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } @@ -1410,12 +1401,12 @@ "id": "89326046-2b11-4812-8b6d-8780306ec275", "metadata": {}, "source": [ - "**Notice** that unlike last time, the \"next\" node is set to **'action'**. We've interrupted here! Let's check the tool invocation." + "**Notice** that unlike last time, the \"next\" node is set to **'tools'**. We've interrupted here! Let's check the tool invocation." ] }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 11, "id": "3facda0a-e6ad-4b28-b627-753ad8c90c15", "metadata": {}, "outputs": [ @@ -1424,10 +1415,11 @@ "text/plain": [ "[{'name': 'tavily_search_results_json',\n", " 'args': {'query': 'LangGraph'},\n", - " 'id': 'toolu_01Be7aRgMEv9cg6ezaFjiCry'}]" + " 'id': 'toolu_01XoHVKTRbipJokQorfifzvh',\n", + " 'type': 'tool_call'}]" ] }, - "execution_count": 5, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } @@ -1449,7 +1441,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 12, "id": "effb95d9-b7d5-40c5-9253-253d193b23b2", "metadata": {}, "outputs": [ @@ -1460,18 +1452,19 @@ "=================================\u001b[1m Tool Message \u001b[0m=================================\n", "Name: tavily_search_results_json\n", "\n", - "[{\"url\": \"https://github.com/langchain-ai/langgraph\", \"content\": \"LangGraph is a Python package that extends LangChain Expression Language with the ability to coordinate multiple chains across multiple steps of computation in a cyclic manner. It is inspired by Pregel and Apache Beam and can be used for agent-like behaviors, such as chatbots, with LLMs.\"}, {\"url\": \"https://langchain-ai.github.io/langgraph//\", \"content\": \"LangGraph is a library for building stateful, multi-actor applications with LLMs, built on top of (and intended to be used with) LangChain . It extends the LangChain Expression Language with the ability to coordinate multiple chains (or actors) across multiple steps of computation in a cyclic manner. It is inspired by Pregel and Apache Beam .\"}]\n", + "[{\"url\": \"https://langchain-ai.github.io/langgraph/tutorials/\", \"content\": \"LangGraph is a framework for building language agents as graphs. Learn how to use LangGraph to create chatbots, code assistants, planning agents, reflection agents, and more with these notebooks.\"}, {\"url\": \"https://github.com/langchain-ai/langgraph\", \"content\": \"LangGraph is a library for creating stateful, multi-actor applications with LLMs, using cycles, controllability, and persistence. Learn how to use LangGraph with examples, integration with LangChain, and streaming support.\"}]\n", "==================================\u001b[1m Ai Message \u001b[0m==================================\n", "\n", - "Based on the search results, LangGraph seems to be a Python library that extends the LangChain library to enable more complex, multi-step interactions with large language models (LLMs). Some key points:\n", + "Based on the search results, LangGraph seems to be a framework for building language-based AI agents and applications using language models. It provides a modular, graph-based approach for creating chatbots, code assistants, planning agents, and other language-centric applications.\n", "\n", - "- LangGraph allows coordinating multiple \"chains\" (or actors) over multiple steps of computation, in a cyclic manner. This enables more advanced agent-like behaviors like chatbots.\n", - "- It is inspired by distributed graph processing frameworks like Pregel and Apache Beam.\n", - "- LangGraph is built on top of the LangChain library, which provides a framework for building applications with LLMs.\n", + "Some key things I learned about LangGraph:\n", "\n", - "So in summary, LangGraph appears to be a powerful tool for building more sophisticated applications and agents using large language models, by allowing you to coordinate multiple steps and actors in a flexible, graph-like manner. It extends the capabilities of the base LangChain library.\n", + "- It is designed to make it easier to build stateful, multi-actor applications using large language models (LLMs).\n", + "- It provides features like cycles, controllability, and persistence to help manage the complexity of these types of applications.\n", + "- LangGraph can be integrated with the LangChain library, which provides additional tools for building LLM-powered applications.\n", + "- The framework includes examples and tutorials to help get started with using LangGraph.\n", "\n", - "Let me know if you need any clarification or have additional questions!\n" + "Overall, LangGraph seems like a promising approach for building more advanced, graph-based language applications on top of large language models. Let me know if you need any other details on LangGraph and how it works!\n" ] } ], @@ -1563,7 +1556,7 @@ "source": [ "## Part 5: Manually Updating the State\n", "\n", - "In the previous section, we showed how to interrupt a graph so that a human could inspect its actions. This lets the human `read` the state, but if they want to change they agent's course, they'll need to have `write` access.\n", + "In the previous section, we showed how to interrupt a graph so that a human could inspect its actions. This lets the human `read` the state, but if they want to change their agent's course, they'll need to have `write` access.\n", "\n", "Thankfully, LangGraph lets you **manually update state**! Updating the state lets you control the agent's trajectory by modifying its actions (even modifying the past!). This capability is particularly useful when you want to correct the agent's mistakes, explore alternative paths, or guide the agent towards a specific goal.\n", "\n", @@ -3068,9 +3061,9 @@ ], "metadata": { "kernelspec": { - "display_name": "langgraph", + "display_name": "env", "language": "python", - "name": "langgraph" + "name": "python3" }, "language_info": { "codemirror_mode": { diff --git a/examples/llm-compiler/LLMCompiler.ipynb b/examples/llm-compiler/LLMCompiler.ipynb index 1b9387e15..bfe5736d8 100644 --- a/examples/llm-compiler/LLMCompiler.ipynb +++ b/examples/llm-compiler/LLMCompiler.ipynb @@ -32,7 +32,9 @@ "id": "16bd5497-35ad-44f2-94d9-19ff39a5ffed", "metadata": {}, "outputs": [], - "source": ["# %pip install -U --quiet langchain_openai langsmith langgraph langchain numexpr"] + "source": [ + "# %pip install -U --quiet langchain_openai langsmith langgraph langchain numexpr" + ] }, { "cell_type": "code", @@ -40,7 +42,22 @@ "id": "abbd6948-e9a3-47ca-89c7-7ac2fc5eca8b", "metadata": {}, "outputs": [], - "source": ["import getpass\nimport os\n\n\ndef _get_pass(var: str):\n if var not in os.environ:\n os.environ[var] = getpass.getpass(f\"{var}: \")\n\n\n# Optional: Debug + trace calls using LangSmith\nos.environ[\"LANGCHAIN_TRACING_V2\"] = \"True\"\nos.environ[\"LANGCHAIN_PROJECT\"] = \"LLMCompiler\"\n_get_pass(\"LANGCHAIN_API_KEY\")\n_get_pass(\"OPENAI_API_KEY\")"] + "source": [ + "import getpass\n", + "import os\n", + "\n", + "\n", + "def _get_pass(var: str):\n", + " if var not in os.environ:\n", + " os.environ[var] = getpass.getpass(f\"{var}: \")\n", + "\n", + "\n", + "# Optional: Debug + trace calls using LangSmith\n", + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"True\"\n", + "os.environ[\"LANGCHAIN_PROJECT\"] = \"LLMCompiler\"\n", + "_get_pass(\"LANGCHAIN_API_KEY\")\n", + "_get_pass(\"OPENAI_API_KEY\")" + ] }, { "cell_type": "markdown", @@ -60,7 +77,23 @@ "id": "e7476bb2-1a51-42f6-b7ae-82a0300bbf84", "metadata": {}, "outputs": [], - "source": ["from langchain_community.tools.tavily_search import TavilySearchResults\nfrom langchain_openai import ChatOpenAI\n\n# Imported from the https://github.com/langchain-ai/langgraph/tree/main/examples/plan-and-execute repo\nfrom math_tools import get_math_tool\n\n_get_pass(\"TAVILY_API_KEY\")\n\ncalculate = get_math_tool(ChatOpenAI(model=\"gpt-4-turbo-preview\"))\nsearch = TavilySearchResults(\n max_results=1,\n description='tavily_search_results_json(query=\"the search query\") - a search engine.',\n)\n\ntools = [search, calculate]"] + "source": [ + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "from langchain_openai import ChatOpenAI\n", + "\n", + "# Imported from the https://github.com/langchain-ai/langgraph/tree/main/examples/plan-and-execute repo\n", + "from math_tools import get_math_tool\n", + "\n", + "_get_pass(\"TAVILY_API_KEY\")\n", + "\n", + "calculate = get_math_tool(ChatOpenAI(model=\"gpt-4-turbo-preview\"))\n", + "search = TavilySearchResults(\n", + " max_results=1,\n", + " description='tavily_search_results_json(query=\"the search query\") - a search engine.',\n", + ")\n", + "\n", + "tools = [search, calculate]" + ] }, { "cell_type": "code", @@ -79,7 +112,14 @@ "output_type": "execute_result" } ], - "source": ["calculate.invoke(\n {\n \"problem\": \"What's the temp of sf + 5?\",\n \"context\": [\"Thet empreature of sf is 32 degrees\"],\n }\n)"] + "source": [ + "calculate.invoke(\n", + " {\n", + " \"problem\": \"What's the temp of sf + 5?\",\n", + " \"context\": [\"Thet empreature of sf is 32 degrees\"],\n", + " }\n", + ")" + ] }, { "cell_type": "markdown", @@ -148,7 +188,26 @@ ] } ], - "source": ["from typing import Sequence\n\nfrom langchain import hub\nfrom langchain_core.language_models import BaseChatModel\nfrom langchain_core.messages import (\n BaseMessage,\n FunctionMessage,\n HumanMessage,\n SystemMessage,\n)\nfrom langchain_core.prompts import ChatPromptTemplate\nfrom langchain_core.runnables import RunnableBranch\nfrom langchain_core.tools import BaseTool\nfrom langchain_openai import ChatOpenAI\nfrom output_parser import LLMCompilerPlanParser, Task\n\nprompt = hub.pull(\"wfh/llm-compiler\")\nprint(prompt.pretty_print())"] + "source": [ + "from typing import Sequence\n", + "\n", + "from langchain import hub\n", + "from langchain_core.language_models import BaseChatModel\n", + "from langchain_core.messages import (\n", + " BaseMessage,\n", + " FunctionMessage,\n", + " HumanMessage,\n", + " SystemMessage,\n", + ")\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.runnables import RunnableBranch\n", + "from langchain_core.tools import BaseTool\n", + "from langchain_openai import ChatOpenAI\n", + "from output_parser import LLMCompilerPlanParser, Task\n", + "\n", + "prompt = hub.pull(\"wfh/llm-compiler\")\n", + "print(prompt.pretty_print())" + ] }, { "cell_type": "code", @@ -156,7 +215,58 @@ "id": "45689d40-d8df-4316-a121-6ea9c87d2efe", "metadata": {}, "outputs": [], - "source": ["def create_planner(\n llm: BaseChatModel, tools: Sequence[BaseTool], base_prompt: ChatPromptTemplate\n):\n tool_descriptions = \"\\n\".join(\n f\"{i+1}. {tool.description}\\n\"\n for i, tool in enumerate(\n tools\n ) # +1 to offset the 0 starting index, we want it count normally from 1.\n )\n planner_prompt = base_prompt.partial(\n replan=\"\",\n num_tools=len(tools)\n + 1, # Add one because we're adding the join() tool at the end.\n tool_descriptions=tool_descriptions,\n )\n replanner_prompt = base_prompt.partial(\n replan=' - You are given \"Previous Plan\" which is the plan that the previous agent created along with the execution results '\n \"(given as Observation) of each plan and a general thought (given as Thought) about the executed results.\"\n 'You MUST use these information to create the next plan under \"Current Plan\".\\n'\n ' - When starting the Current Plan, you should start with \"Thought\" that outlines the strategy for the next plan.\\n'\n \" - In the Current Plan, you should NEVER repeat the actions that are already executed in the Previous Plan.\\n\"\n \" - You must continue the task index from the end of the previous one. Do not repeat task indices.\",\n num_tools=len(tools) + 1,\n tool_descriptions=tool_descriptions,\n )\n\n def should_replan(state: list):\n # Context is passed as a system message\n return isinstance(state[-1], SystemMessage)\n\n def wrap_messages(state: list):\n return {\"messages\": state}\n\n def wrap_and_get_last_index(state: list):\n next_task = 0\n for message in state[::-1]:\n if isinstance(message, FunctionMessage):\n next_task = message.additional_kwargs[\"idx\"] + 1\n break\n state[-1].content = state[-1].content + f\" - Begin counting at : {next_task}\"\n return {\"messages\": state}\n\n return (\n RunnableBranch(\n (should_replan, wrap_and_get_last_index | replanner_prompt),\n wrap_messages | planner_prompt,\n )\n | llm\n | LLMCompilerPlanParser(tools=tools)\n )"] + "source": [ + "def create_planner(\n", + " llm: BaseChatModel, tools: Sequence[BaseTool], base_prompt: ChatPromptTemplate\n", + "):\n", + " tool_descriptions = \"\\n\".join(\n", + " f\"{i+1}. {tool.description}\\n\"\n", + " for i, tool in enumerate(\n", + " tools\n", + " ) # +1 to offset the 0 starting index, we want it count normally from 1.\n", + " )\n", + " planner_prompt = base_prompt.partial(\n", + " replan=\"\",\n", + " num_tools=len(tools)\n", + " + 1, # Add one because we're adding the join() tool at the end.\n", + " tool_descriptions=tool_descriptions,\n", + " )\n", + " replanner_prompt = base_prompt.partial(\n", + " replan=' - You are given \"Previous Plan\" which is the plan that the previous agent created along with the execution results '\n", + " \"(given as Observation) of each plan and a general thought (given as Thought) about the executed results.\"\n", + " 'You MUST use these information to create the next plan under \"Current Plan\".\\n'\n", + " ' - When starting the Current Plan, you should start with \"Thought\" that outlines the strategy for the next plan.\\n'\n", + " \" - In the Current Plan, you should NEVER repeat the actions that are already executed in the Previous Plan.\\n\"\n", + " \" - You must continue the task index from the end of the previous one. Do not repeat task indices.\",\n", + " num_tools=len(tools) + 1,\n", + " tool_descriptions=tool_descriptions,\n", + " )\n", + "\n", + " def should_replan(state: list):\n", + " # Context is passed as a system message\n", + " return isinstance(state[-1], SystemMessage)\n", + "\n", + " def wrap_messages(state: list):\n", + " return {\"messages\": state}\n", + "\n", + " def wrap_and_get_last_index(state: list):\n", + " next_task = 0\n", + " for message in state[::-1]:\n", + " if isinstance(message, FunctionMessage):\n", + " next_task = message.additional_kwargs[\"idx\"] + 1\n", + " break\n", + " state[-1].content = state[-1].content + f\" - Begin counting at : {next_task}\"\n", + " return {\"messages\": state}\n", + "\n", + " return (\n", + " RunnableBranch(\n", + " (should_replan, wrap_and_get_last_index | replanner_prompt),\n", + " wrap_messages | planner_prompt,\n", + " )\n", + " | llm\n", + " | LLMCompilerPlanParser(tools=tools)\n", + " )" + ] }, { "cell_type": "code", @@ -164,7 +274,11 @@ "id": "bbdcb57b-5362-4b9e-88db-fb3fae443fb0", "metadata": {}, "outputs": [], - "source": ["llm = ChatOpenAI(model=\"gpt-4-turbo-preview\")\n# This is the primary \"agent\" in our application\nplanner = create_planner(llm, tools, prompt)"] + "source": [ + "llm = ChatOpenAI(model=\"gpt-4-turbo-preview\")\n", + "# This is the primary \"agent\" in our application\n", + "planner = create_planner(llm, tools, prompt)" + ] }, { "cell_type": "code", @@ -185,7 +299,13 @@ ] } ], - "source": ["example_question = \"What's the temperature in SF raised to the 3rd power?\"\n\nfor task in planner.stream([HumanMessage(content=example_question)]):\n print(task[\"tool\"], task[\"args\"])\n print(\"---\")"] + "source": [ + "example_question = \"What's the temperature in SF raised to the 3rd power?\"\n", + "\n", + "for task in planner.stream([HumanMessage(content=example_question)]):\n", + " print(task[\"tool\"], task[\"args\"])\n", + " print(\"---\")" + ] }, { "cell_type": "markdown", @@ -217,7 +337,168 @@ "jp-MarkdownHeadingCollapsed": true }, "outputs": [], - "source": ["import re\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, wait\nfrom typing import Any, Dict, Iterable, List, Union\n\nfrom langchain_core.runnables import (\n chain as as_runnable,\n)\nfrom typing_extensions import TypedDict\n\n\ndef _get_observations(messages: List[BaseMessage]) -> Dict[int, Any]:\n # Get all previous tool responses\n results = {}\n for message in messages[::-1]:\n if isinstance(message, FunctionMessage):\n results[int(message.additional_kwargs[\"idx\"])] = message.content\n return results\n\n\nclass SchedulerInput(TypedDict):\n messages: List[BaseMessage]\n tasks: Iterable[Task]\n\n\ndef _execute_task(task, observations, config):\n tool_to_use = task[\"tool\"]\n if isinstance(tool_to_use, str):\n return tool_to_use\n args = task[\"args\"]\n try:\n if isinstance(args, str):\n resolved_args = _resolve_arg(args, observations)\n elif isinstance(args, dict):\n resolved_args = {\n key: _resolve_arg(val, observations) for key, val in args.items()\n }\n else:\n # This will likely fail\n resolved_args = args\n except Exception as e:\n return (\n f\"ERROR(Failed to call {tool_to_use.name} with args {args}.)\"\n f\" Args could not be resolved. Error: {repr(e)}\"\n )\n try:\n return tool_to_use.invoke(resolved_args, config)\n except Exception as e:\n return (\n f\"ERROR(Failed to call {tool_to_use.name} with args {args}.\"\n + f\" Args resolved to {resolved_args}. Error: {repr(e)})\"\n )\n\n\ndef _resolve_arg(arg: Union[str, Any], observations: Dict[int, Any]):\n # $1 or ${1} -> 1\n ID_PATTERN = r\"\\$\\{?(\\d+)\\}?\"\n\n def replace_match(match):\n # If the string is ${123}, match.group(0) is ${123}, and match.group(1) is 123.\n\n # Return the match group, in this case the index, from the string. This is the index\n # number we get back.\n idx = int(match.group(1))\n return str(observations.get(idx, match.group(0)))\n\n # For dependencies on other tasks\n if isinstance(arg, str):\n return re.sub(ID_PATTERN, replace_match, arg)\n elif isinstance(arg, list):\n return [_resolve_arg(a, observations) for a in arg]\n else:\n return str(arg)\n\n\n@as_runnable\ndef schedule_task(task_inputs, config):\n task: Task = task_inputs[\"task\"]\n observations: Dict[int, Any] = task_inputs[\"observations\"]\n try:\n observation = _execute_task(task, observations, config)\n except Exception:\n import traceback\n\n observation = traceback.format_exception() # repr(e) +\n observations[task[\"idx\"]] = observation\n\n\ndef schedule_pending_task(\n task: Task, observations: Dict[int, Any], retry_after: float = 0.2\n):\n while True:\n deps = task[\"dependencies\"]\n if deps and (any([dep not in observations for dep in deps])):\n # Dependencies not yet satisfied\n time.sleep(retry_after)\n continue\n schedule_task.invoke({\"task\": task, \"observations\": observations})\n break\n\n\n@as_runnable\ndef schedule_tasks(scheduler_input: SchedulerInput) -> List[FunctionMessage]:\n \"\"\"Group the tasks into a DAG schedule.\"\"\"\n # For streaming, we are making a few simplifying assumption:\n # 1. The LLM does not create cyclic dependencies\n # 2. That the LLM will not generate tasks with future deps\n # If this ceases to be a good assumption, you can either\n # adjust to do a proper topological sort (not-stream)\n # or use a more complicated data structure\n tasks = scheduler_input[\"tasks\"]\n args_for_tasks = {}\n messages = scheduler_input[\"messages\"]\n # If we are re-planning, we may have calls that depend on previous\n # plans. Start with those.\n observations = _get_observations(messages)\n task_names = {}\n originals = set(observations)\n # ^^ We assume each task inserts a different key above to\n # avoid race conditions...\n futures = []\n retry_after = 0.25 # Retry every quarter second\n with ThreadPoolExecutor() as executor:\n for task in tasks:\n deps = task[\"dependencies\"]\n task_names[task[\"idx\"]] = (\n task[\"tool\"] if isinstance(task[\"tool\"], str) else task[\"tool\"].name\n )\n args_for_tasks[task[\"idx\"]] = task[\"args\"]\n if (\n # Depends on other tasks\n deps\n and (any([dep not in observations for dep in deps]))\n ):\n futures.append(\n executor.submit(\n schedule_pending_task, task, observations, retry_after\n )\n )\n else:\n # No deps or all deps satisfied\n # can schedule now\n schedule_task.invoke(dict(task=task, observations=observations))\n # futures.append(executor.submit(schedule_task.invoke dict(task=task, observations=observations)))\n\n # All tasks have been submitted or enqueued\n # Wait for them to complete\n wait(futures)\n # Convert observations to new tool messages to add to the state\n new_observations = {\n k: (task_names[k], args_for_tasks[k], observations[k])\n for k in sorted(observations.keys() - originals)\n }\n tool_messages = [\n FunctionMessage(\n name=name, content=str(obs), additional_kwargs={\"idx\": k, \"args\": task_args}\n )\n for k, (name, task_args, obs) in new_observations.items()\n ]\n return tool_messages"] + "source": [ + "import re\n", + "import time\n", + "from concurrent.futures import ThreadPoolExecutor, wait\n", + "from typing import Any, Dict, Iterable, List, Union\n", + "\n", + "from langchain_core.runnables import (\n", + " chain as as_runnable,\n", + ")\n", + "from typing_extensions import TypedDict\n", + "\n", + "\n", + "def _get_observations(messages: List[BaseMessage]) -> Dict[int, Any]:\n", + " # Get all previous tool responses\n", + " results = {}\n", + " for message in messages[::-1]:\n", + " if isinstance(message, FunctionMessage):\n", + " results[int(message.additional_kwargs[\"idx\"])] = message.content\n", + " return results\n", + "\n", + "\n", + "class SchedulerInput(TypedDict):\n", + " messages: List[BaseMessage]\n", + " tasks: Iterable[Task]\n", + "\n", + "\n", + "def _execute_task(task, observations, config):\n", + " tool_to_use = task[\"tool\"]\n", + " if isinstance(tool_to_use, str):\n", + " return tool_to_use\n", + " args = task[\"args\"]\n", + " try:\n", + " if isinstance(args, str):\n", + " resolved_args = _resolve_arg(args, observations)\n", + " elif isinstance(args, dict):\n", + " resolved_args = {\n", + " key: _resolve_arg(val, observations) for key, val in args.items()\n", + " }\n", + " else:\n", + " # This will likely fail\n", + " resolved_args = args\n", + " except Exception as e:\n", + " return (\n", + " f\"ERROR(Failed to call {tool_to_use.name} with args {args}.)\"\n", + " f\" Args could not be resolved. Error: {repr(e)}\"\n", + " )\n", + " try:\n", + " return tool_to_use.invoke(resolved_args, config)\n", + " except Exception as e:\n", + " return (\n", + " f\"ERROR(Failed to call {tool_to_use.name} with args {args}.\"\n", + " + f\" Args resolved to {resolved_args}. Error: {repr(e)})\"\n", + " )\n", + "\n", + "\n", + "def _resolve_arg(arg: Union[str, Any], observations: Dict[int, Any]):\n", + " # $1 or ${1} -> 1\n", + " ID_PATTERN = r\"\\$\\{?(\\d+)\\}?\"\n", + "\n", + " def replace_match(match):\n", + " # If the string is ${123}, match.group(0) is ${123}, and match.group(1) is 123.\n", + "\n", + " # Return the match group, in this case the index, from the string. This is the index\n", + " # number we get back.\n", + " idx = int(match.group(1))\n", + " return str(observations.get(idx, match.group(0)))\n", + "\n", + " # For dependencies on other tasks\n", + " if isinstance(arg, str):\n", + " return re.sub(ID_PATTERN, replace_match, arg)\n", + " elif isinstance(arg, list):\n", + " return [_resolve_arg(a, observations) for a in arg]\n", + " else:\n", + " return str(arg)\n", + "\n", + "\n", + "@as_runnable\n", + "def schedule_task(task_inputs, config):\n", + " task: Task = task_inputs[\"task\"]\n", + " observations: Dict[int, Any] = task_inputs[\"observations\"]\n", + " try:\n", + " observation = _execute_task(task, observations, config)\n", + " except Exception:\n", + " import traceback\n", + "\n", + " observation = traceback.format_exception() # repr(e) +\n", + " observations[task[\"idx\"]] = observation\n", + "\n", + "\n", + "def schedule_pending_task(\n", + " task: Task, observations: Dict[int, Any], retry_after: float = 0.2\n", + "):\n", + " while True:\n", + " deps = task[\"dependencies\"]\n", + " if deps and (any([dep not in observations for dep in deps])):\n", + " # Dependencies not yet satisfied\n", + " time.sleep(retry_after)\n", + " continue\n", + " schedule_task.invoke({\"task\": task, \"observations\": observations})\n", + " break\n", + "\n", + "\n", + "@as_runnable\n", + "def schedule_tasks(scheduler_input: SchedulerInput) -> List[FunctionMessage]:\n", + " \"\"\"Group the tasks into a DAG schedule.\"\"\"\n", + " # For streaming, we are making a few simplifying assumption:\n", + " # 1. The LLM does not create cyclic dependencies\n", + " # 2. That the LLM will not generate tasks with future deps\n", + " # If this ceases to be a good assumption, you can either\n", + " # adjust to do a proper topological sort (not-stream)\n", + " # or use a more complicated data structure\n", + " tasks = scheduler_input[\"tasks\"]\n", + " args_for_tasks = {}\n", + " messages = scheduler_input[\"messages\"]\n", + " # If we are re-planning, we may have calls that depend on previous\n", + " # plans. Start with those.\n", + " observations = _get_observations(messages)\n", + " task_names = {}\n", + " originals = set(observations)\n", + " # ^^ We assume each task inserts a different key above to\n", + " # avoid race conditions...\n", + " futures = []\n", + " retry_after = 0.25 # Retry every quarter second\n", + " with ThreadPoolExecutor() as executor:\n", + " for task in tasks:\n", + " deps = task[\"dependencies\"]\n", + " task_names[task[\"idx\"]] = (\n", + " task[\"tool\"] if isinstance(task[\"tool\"], str) else task[\"tool\"].name\n", + " )\n", + " args_for_tasks[task[\"idx\"]] = task[\"args\"]\n", + " if (\n", + " # Depends on other tasks\n", + " deps\n", + " and (any([dep not in observations for dep in deps]))\n", + " ):\n", + " futures.append(\n", + " executor.submit(\n", + " schedule_pending_task, task, observations, retry_after\n", + " )\n", + " )\n", + " else:\n", + " # No deps or all deps satisfied\n", + " # can schedule now\n", + " schedule_task.invoke(dict(task=task, observations=observations))\n", + " # futures.append(executor.submit(schedule_task.invoke dict(task=task, observations=observations)))\n", + "\n", + " # All tasks have been submitted or enqueued\n", + " # Wait for them to complete\n", + " wait(futures)\n", + " # Convert observations to new tool messages to add to the state\n", + " new_observations = {\n", + " k: (task_names[k], args_for_tasks[k], observations[k])\n", + " for k in sorted(observations.keys() - originals)\n", + " }\n", + " tool_messages = [\n", + " FunctionMessage(\n", + " name=name, content=str(obs), additional_kwargs={\"idx\": k, \"args\": task_args}\n", + " )\n", + " for k, (name, task_args, obs) in new_observations.items()\n", + " ]\n", + " return tool_messages" + ] }, { "cell_type": "code", @@ -225,7 +506,28 @@ "id": "052f6b16-103a-40e9-94dd-8fcc37e77ba4", "metadata": {}, "outputs": [], - "source": ["import itertools\n\n\n@as_runnable\ndef plan_and_schedule(messages: List[BaseMessage], config):\n tasks = planner.stream(messages, config)\n # Begin executing the planner immediately\n try:\n tasks = itertools.chain([next(tasks)], tasks)\n except StopIteration:\n # Handle the case where tasks is empty.\n tasks = iter([])\n scheduled_tasks = schedule_tasks.invoke(\n {\n \"messages\": messages,\n \"tasks\": tasks,\n },\n config,\n )\n return scheduled_tasks"] + "source": [ + "import itertools\n", + "\n", + "\n", + "@as_runnable\n", + "def plan_and_schedule(state):\n", + " messages = state[\"messages\"]\n", + " tasks = planner.stream(messages)\n", + " # Begin executing the planner immediately\n", + " try:\n", + " tasks = itertools.chain([next(tasks)], tasks)\n", + " except StopIteration:\n", + " # Handle the case where tasks is empty.\n", + " tasks = iter([])\n", + " scheduled_tasks = schedule_tasks.invoke(\n", + " {\n", + " \"messages\": messages,\n", + " \"tasks\": tasks,\n", + " }\n", + " )\n", + " return {\"messages\":[scheduled_tasks]}" + ] }, { "cell_type": "markdown", @@ -243,7 +545,9 @@ "id": "55142257-2674-4a47-988e-0d2810917329", "metadata": {}, "outputs": [], - "source": ["tool_messages = plan_and_schedule.invoke([HumanMessage(content=example_question)])"] + "source": [ + "tool_messages = plan_and_schedule.invoke([HumanMessage(content=example_question)])" + ] }, { "cell_type": "code", @@ -264,7 +568,9 @@ "output_type": "execute_result" } ], - "source": ["tool_messages"] + "source": [ + "tool_messages" + ] }, { "cell_type": "markdown", @@ -287,7 +593,40 @@ "id": "942dab42-ad42-4ba2-90d5-49edbe4fae68", "metadata": {}, "outputs": [], - "source": ["from langchain.chains.openai_functions import create_structured_output_runnable\nfrom langchain_core.messages import AIMessage\nfrom langchain_core.pydantic_v1 import BaseModel, Field\n\n\nclass FinalResponse(BaseModel):\n \"\"\"The final response/answer.\"\"\"\n\n response: str\n\n\nclass Replan(BaseModel):\n feedback: str = Field(\n description=\"Analysis of the previous attempts and recommendations on what needs to be fixed.\"\n )\n\n\nclass JoinOutputs(BaseModel):\n \"\"\"Decide whether to replan or whether you can return the final response.\"\"\"\n\n thought: str = Field(\n description=\"The chain of thought reasoning for the selected action\"\n )\n action: Union[FinalResponse, Replan]\n\n\njoiner_prompt = hub.pull(\"wfh/llm-compiler-joiner\").partial(\n examples=\"\"\n) # You can optionally add examples\nllm = ChatOpenAI(model=\"gpt-4-turbo-preview\")\n\nrunnable = create_structured_output_runnable(JoinOutputs, llm, joiner_prompt)"] + "source": [ + "from langchain.chains.openai_functions import create_structured_output_runnable\n", + "from langchain_core.messages import AIMessage\n", + "from langchain_core.pydantic_v1 import BaseModel, Field\n", + "\n", + "\n", + "class FinalResponse(BaseModel):\n", + " \"\"\"The final response/answer.\"\"\"\n", + "\n", + " response: str\n", + "\n", + "\n", + "class Replan(BaseModel):\n", + " feedback: str = Field(\n", + " description=\"Analysis of the previous attempts and recommendations on what needs to be fixed.\"\n", + " )\n", + "\n", + "\n", + "class JoinOutputs(BaseModel):\n", + " \"\"\"Decide whether to replan or whether you can return the final response.\"\"\"\n", + "\n", + " thought: str = Field(\n", + " description=\"The chain of thought reasoning for the selected action\"\n", + " )\n", + " action: Union[FinalResponse, Replan]\n", + "\n", + "\n", + "joiner_prompt = hub.pull(\"wfh/llm-compiler-joiner\").partial(\n", + " examples=\"\"\n", + ") # You can optionally add examples\n", + "llm = ChatOpenAI(model=\"gpt-4-turbo-preview\")\n", + "\n", + "runnable = create_structured_output_runnable(JoinOutputs, llm, joiner_prompt)" + ] }, { "cell_type": "markdown", @@ -304,7 +643,31 @@ "id": "951a33cf-2a05-4a33-899a-0ab1d97122fa", "metadata": {}, "outputs": [], - "source": ["def _parse_joiner_output(decision: JoinOutputs) -> List[BaseMessage]:\n response = [AIMessage(content=f\"Thought: {decision.thought}\")]\n if isinstance(decision.action, Replan):\n return response + [\n SystemMessage(\n content=f\"Context from last attempt: {decision.action.feedback}\"\n )\n ]\n else:\n return response + [AIMessage(content=decision.action.response)]\n\n\ndef select_recent_messages(messages: list) -> dict:\n selected = []\n for msg in messages[::-1]:\n selected.append(msg)\n if isinstance(msg, HumanMessage):\n break\n return {\"messages\": selected[::-1]}\n\n\njoiner = select_recent_messages | runnable | _parse_joiner_output"] + "source": [ + "def _parse_joiner_output(decision: JoinOutputs) -> List[BaseMessage]:\n", + " response = [AIMessage(content=f\"Thought: {decision.thought}\")]\n", + " if isinstance(decision.action, Replan):\n", + " return response + [\n", + " SystemMessage(\n", + " content=f\"Context from last attempt: {decision.action.feedback}\"\n", + " )\n", + " ]\n", + " else:\n", + " return {\"messages\":response + [AIMessage(content=decision.action.response)]}\n", + "\n", + "\n", + "def select_recent_messages(state) -> dict:\n", + " messages = state[\"messages\"]\n", + " selected = []\n", + " for msg in messages[::-1]:\n", + " selected.append(msg)\n", + " if isinstance(msg, HumanMessage):\n", + " break\n", + " return {\"messages\": selected[::-1]}\n", + "\n", + "\n", + "joiner = select_recent_messages | runnable | _parse_joiner_output" + ] }, { "cell_type": "code", @@ -312,7 +675,9 @@ "id": "1e49d4b1-8266-4520-a566-1448b1c31c8f", "metadata": {}, "outputs": [], - "source": ["input_messages = [HumanMessage(content=example_question)] + tool_messages"] + "source": [ + "input_messages = [HumanMessage(content=example_question)] + tool_messages" + ] }, { "cell_type": "code", @@ -332,7 +697,9 @@ "output_type": "execute_result" } ], - "source": ["joiner.invoke(input_messages)"] + "source": [ + "joiner.invoke(input_messages)" + ] }, { "cell_type": "markdown", @@ -354,7 +721,44 @@ "id": "768b5f11-e3d2-47be-8143-a7dcd8765243", "metadata": {}, "outputs": [], - "source": ["from typing import Dict\n\nfrom langgraph.graph import END, MessageGraph, START\n\ngraph_builder = MessageGraph()\n\n# 1. Define vertices\n# We defined plan_and_schedule above already\n# Assign each node to a state variable to update\ngraph_builder.add_node(\"plan_and_schedule\", plan_and_schedule)\ngraph_builder.add_node(\"join\", joiner)\n\n\n## Define edges\ngraph_builder.add_edge(\"plan_and_schedule\", \"join\")\n\n### This condition determines looping logic\n\n\ndef should_continue(state: List[BaseMessage]):\n if isinstance(state[-1], AIMessage):\n return END\n return \"plan_and_schedule\"\n\n\ngraph_builder.add_conditional_edges(\n start_key=\"join\",\n # Next, we pass in the function that will determine which node is called next.\n condition=should_continue,\n)\ngraph_builder.add_edge(START, \"plan_and_schedule\")\nchain = graph_builder.compile()"] + "source": [ + "from langgraph.graph import END, StateGraph, START\n", + "from langgraph.graph.message import add_messages\n", + "from typing import Annotated\n", + "\n", + "class State(TypedDict):\n", + " messages: Annotated[list, add_messages]\n", + "\n", + "graph_builder = StateGraph(State)\n", + "\n", + "# 1. Define vertices\n", + "# We defined plan_and_schedule above already\n", + "# Assign each node to a state variable to update\n", + "graph_builder.add_node(\"plan_and_schedule\", plan_and_schedule)\n", + "graph_builder.add_node(\"join\", joiner)\n", + "\n", + "\n", + "## Define edges\n", + "graph_builder.add_edge(\"plan_and_schedule\", \"join\")\n", + "\n", + "### This condition determines looping logic\n", + "\n", + "\n", + "def should_continue(state):\n", + " messages = state[\"messages\"]\n", + " if isinstance(messages[-1], AIMessage):\n", + " return END\n", + " return \"plan_and_schedule\"\n", + "\n", + "\n", + "graph_builder.add_conditional_edges(\n", + " start_key=\"join\",\n", + " # Next, we pass in the function that will determine which node is called next.\n", + " condition=should_continue,\n", + ")\n", + "graph_builder.add_edge(START, \"plan_and_schedule\")\n", + "chain = graph_builder.compile()" + ] }, { "cell_type": "markdown", @@ -389,7 +793,11 @@ ] } ], - "source": ["for step in chain.stream([HumanMessage(content=\"What's the GDP of New York?\")]):\n print(step)\n print(\"---\")"] + "source": [ + "for step in chain.stream({\"messages\":[HumanMessage(content=\"What's the GDP of New York?\")]}):\n", + " print(step)\n", + " print(\"---\")" + ] }, { "cell_type": "code", @@ -405,7 +813,10 @@ ] } ], - "source": ["# Final answer\nprint(step[END][-1].content)"] + "source": [ + "# Final answer\n", + "print(step[END][-1].content)" + ] }, { "cell_type": "markdown", @@ -440,7 +851,21 @@ ] } ], - "source": ["steps = chain.stream(\n [\n HumanMessage(\n content=\"What's the oldest parrot alive, and how much longer is that than the average?\"\n )\n ],\n {\n \"recursion_limit\": 100,\n },\n)\nfor step in steps:\n print(step)\n print(\"---\")"] + "source": [ + "steps = chain.stream(\n", + " [\n", + " HumanMessage(\n", + " content=\"What's the oldest parrot alive, and how much longer is that than the average?\"\n", + " )\n", + " ],\n", + " {\n", + " \"recursion_limit\": 100,\n", + " },\n", + ")\n", + "for step in steps:\n", + " print(step)\n", + " print(\"---\")" + ] }, { "cell_type": "code", @@ -456,7 +881,10 @@ ] } ], - "source": ["# Final answer\nprint(step[END][-1].content)"] + "source": [ + "# Final answer\n", + "print(step[END][-1].content)" + ] }, { "cell_type": "markdown", @@ -482,7 +910,16 @@ ] } ], - "source": ["for step in chain.stream(\n [\n HumanMessage(\n content=\"What's ((3*(4+5)/0.5)+3245) + 8? What's 32/4.23? What's the sum of those two values?\"\n )\n ]\n):\n print(step)"] + "source": [ + "for step in chain.stream(\n", + " [\n", + " HumanMessage(\n", + " content=\"What's ((3*(4+5)/0.5)+3245) + 8? What's 32/4.23? What's the sum of those two values?\"\n", + " )\n", + " ]\n", + "):\n", + " print(step)" + ] }, { "cell_type": "code", @@ -500,7 +937,10 @@ ] } ], - "source": ["# Final answer\nprint(step[END][-1].content)"] + "source": [ + "# Final answer\n", + "print(step[END][-1].content)" + ] }, { "cell_type": "markdown", @@ -522,7 +962,7 @@ "id": "431217e6-4c00-409f-a2bd-40ebff902489", "metadata": {}, "outputs": [], - "source": [""] + "source": [] } ], "metadata": { diff --git a/examples/reflection/reflection.ipynb b/examples/reflection/reflection.ipynb index 72f3d1bc3..810990e38 100644 --- a/examples/reflection/reflection.ipynb +++ b/examples/reflection/reflection.ipynb @@ -32,7 +32,10 @@ "id": "8b323f43-328b-4b4b-88b0-6c84dc0a1d60", "metadata": {}, "outputs": [], - "source": ["%pip install -U --quiet langgraph langchain-fireworks\n%pip install -U --quiet tavily-python"] + "source": [ + "%pip install -U --quiet langgraph langchain-fireworks\n", + "%pip install -U --quiet tavily-python" + ] }, { "cell_type": "code", @@ -40,7 +43,24 @@ "id": "3368f330-cad6-4d35-a291-68fbf4389d98", "metadata": {}, "outputs": [], - "source": ["import getpass\nimport os\n\n\ndef _set_if_undefined(var: str) -> None:\n if os.environ.get(var):\n return\n os.environ[var] = getpass.getpass(var)\n\n\n# Optional: Configure tracing to visualize and debug the agent\n_set_if_undefined(\"LANGCHAIN_API_KEY\")\nos.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\nos.environ[\"LANGCHAIN_PROJECT\"] = \"Reflection\"\n\n_set_if_undefined(\"FIREWORKS_API_KEY\")"] + "source": [ + "import getpass\n", + "import os\n", + "\n", + "\n", + "def _set_if_undefined(var: str) -> None:\n", + " if os.environ.get(var):\n", + " return\n", + " os.environ[var] = getpass.getpass(var)\n", + "\n", + "\n", + "# Optional: Configure tracing to visualize and debug the agent\n", + "_set_if_undefined(\"LANGCHAIN_API_KEY\")\n", + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_PROJECT\"] = \"Reflection\"\n", + "\n", + "_set_if_undefined(\"FIREWORKS_API_KEY\")" + ] }, { "cell_type": "markdown", @@ -58,7 +78,28 @@ "id": "cc10028f-9cef-4936-9419-cbdf06d24f1e", "metadata": {}, "outputs": [], - "source": ["from langchain_core.messages import AIMessage, BaseMessage, HumanMessage\nfrom langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder\nfrom langchain_fireworks import ChatFireworks\n\nprompt = ChatPromptTemplate.from_messages(\n [\n (\n \"system\",\n \"You are an essay assistant tasked with writing excellent 5-paragraph essays.\"\n \" Generate the best essay possible for the user's request.\"\n \" If the user provides critique, respond with a revised version of your previous attempts.\",\n ),\n MessagesPlaceholder(variable_name=\"messages\"),\n ]\n)\nllm = ChatFireworks(\n model=\"accounts/fireworks/models/mixtral-8x7b-instruct\",\n model_kwargs={\"max_tokens\": 32768},\n)\ngenerate = prompt | llm"] + "source": [ + "from langchain_core.messages import AIMessage, BaseMessage, HumanMessage\n", + "from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder\n", + "from langchain_fireworks import ChatFireworks\n", + "\n", + "prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\n", + " \"system\",\n", + " \"You are an essay assistant tasked with writing excellent 5-paragraph essays.\"\n", + " \" Generate the best essay possible for the user's request.\"\n", + " \" If the user provides critique, respond with a revised version of your previous attempts.\",\n", + " ),\n", + " MessagesPlaceholder(variable_name=\"messages\"),\n", + " ]\n", + ")\n", + "llm = ChatFireworks(\n", + " model=\"accounts/fireworks/models/mixtral-8x7b-instruct\",\n", + " model_kwargs={\"max_tokens\": 32768},\n", + ")\n", + "generate = prompt | llm" + ] }, { "cell_type": "code", @@ -86,7 +127,15 @@ ] } ], - "source": ["essay = \"\"\nrequest = HumanMessage(\n content=\"Write an essay on why the little prince is relevant in modern childhood\"\n)\nfor chunk in generate.stream({\"messages\": [request]}):\n print(chunk.content, end=\"\")\n essay += chunk.content"] + "source": [ + "essay = \"\"\n", + "request = HumanMessage(\n", + " content=\"Write an essay on why the little prince is relevant in modern childhood\"\n", + ")\n", + "for chunk in generate.stream({\"messages\": [request]}):\n", + " print(chunk.content, end=\"\")\n", + " essay += chunk.content" + ] }, { "cell_type": "markdown", @@ -102,7 +151,19 @@ "id": "a705be92-88c0-4f4f-b4c2-cdcd9af8cb2c", "metadata": {}, "outputs": [], - "source": ["reflection_prompt = ChatPromptTemplate.from_messages(\n [\n (\n \"system\",\n \"You are a teacher grading an essay submission. Generate critique and recommendations for the user's submission.\"\n \" Provide detailed recommendations, including requests for length, depth, style, etc.\",\n ),\n MessagesPlaceholder(variable_name=\"messages\"),\n ]\n)\nreflect = reflection_prompt | llm"] + "source": [ + "reflection_prompt = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\n", + " \"system\",\n", + " \"You are a teacher grading an essay submission. Generate critique and recommendations for the user's submission.\"\n", + " \" Provide detailed recommendations, including requests for length, depth, style, etc.\",\n", + " ),\n", + " MessagesPlaceholder(variable_name=\"messages\"),\n", + " ]\n", + ")\n", + "reflect = reflection_prompt | llm" + ] }, { "cell_type": "code", @@ -132,7 +193,12 @@ ] } ], - "source": ["reflection = \"\"\nfor chunk in reflect.stream({\"messages\": [request, HumanMessage(content=essay)]}):\n print(chunk.content, end=\"\")\n reflection += chunk.content"] + "source": [ + "reflection = \"\"\n", + "for chunk in reflect.stream({\"messages\": [request, HumanMessage(content=essay)]}):\n", + " print(chunk.content, end=\"\")\n", + " reflection += chunk.content" + ] }, { "cell_type": "markdown", @@ -170,7 +236,12 @@ ] } ], - "source": ["for chunk in generate.stream(\n {\"messages\": [request, AIMessage(content=essay), HumanMessage(content=reflection)]}\n):\n print(chunk.content, end=\"\")"] + "source": [ + "for chunk in generate.stream(\n", + " {\"messages\": [request, AIMessage(content=essay), HumanMessage(content=reflection)]}\n", + "):\n", + " print(chunk.content, end=\"\")" + ] }, { "cell_type": "markdown", @@ -188,7 +259,50 @@ "id": "9e9a9d7c-5d2e-4194-b745-4511ec20db76", "metadata": {}, "outputs": [], - "source": ["from typing import List, Sequence\n\nfrom langgraph.graph import END, MessageGraph, START\n\n\nasync def generation_node(state: Sequence[BaseMessage]):\n return await generate.ainvoke({\"messages\": state})\n\n\nasync def reflection_node(messages: Sequence[BaseMessage]) -> List[BaseMessage]:\n # Other messages we need to adjust\n cls_map = {\"ai\": HumanMessage, \"human\": AIMessage}\n # First message is the original user request. We hold it the same for all nodes\n translated = [messages[0]] + [\n cls_map[msg.type](content=msg.content) for msg in messages[1:]\n ]\n res = await reflect.ainvoke({\"messages\": translated})\n # We treat the output of this as human feedback for the generator\n return HumanMessage(content=res.content)\n\n\nbuilder = MessageGraph()\nbuilder.add_node(\"generate\", generation_node)\nbuilder.add_node(\"reflect\", reflection_node)\nbuilder.add_edge(START, \"generate\")\n\n\ndef should_continue(state: List[BaseMessage]):\n if len(state) > 6:\n # End after 3 iterations\n return END\n return \"reflect\"\n\n\nbuilder.add_conditional_edges(\"generate\", should_continue)\nbuilder.add_edge(\"reflect\", \"generate\")\ngraph = builder.compile()"] + "source": [ + "from typing import Annotated, List, Sequence\n", + "from langgraph.graph import END, StateGraph, START\n", + "from langgraph.graph.message import add_messages\n", + "from typing_extensions import TypedDict\n", + "\n", + "\n", + "class State(TypedDict):\n", + " messages: Annotated[list, add_messages]\n", + "\n", + " \n", + "async def generation_node(state: Sequence[BaseMessage]):\n", + " return await generate.ainvoke({\"messages\": state})\n", + "\n", + "\n", + "async def reflection_node(messages: Sequence[BaseMessage]) -> List[BaseMessage]:\n", + " # Other messages we need to adjust\n", + " cls_map = {\"ai\": HumanMessage, \"human\": AIMessage}\n", + " # First message is the original user request. We hold it the same for all nodes\n", + " translated = [messages[0]] + [\n", + " cls_map[msg.type](content=msg.content) for msg in messages[1:]\n", + " ]\n", + " res = await reflect.ainvoke({\"messages\": translated})\n", + " # We treat the output of this as human feedback for the generator\n", + " return HumanMessage(content=res.content)\n", + "\n", + "\n", + "builder = StateGraph(State)\n", + "builder.add_node(\"generate\", generation_node)\n", + "builder.add_node(\"reflect\", reflection_node)\n", + "builder.add_edge(START, \"generate\")\n", + "\n", + "\n", + "def should_continue(state: List[BaseMessage]):\n", + " if len(state) > 6:\n", + " # End after 3 iterations\n", + " return END\n", + " return \"reflect\"\n", + "\n", + "\n", + "builder.add_conditional_edges(\"generate\", should_continue)\n", + "builder.add_edge(\"reflect\", \"generate\")\n", + "graph = builder.compile()" + ] }, { "cell_type": "code", @@ -219,7 +333,17 @@ ] } ], - "source": ["async for event in graph.astream(\n [\n HumanMessage(\n content=\"Generate an essay on the topicality of The Little Prince and its message in modern life\"\n )\n ],\n):\n print(event)\n print(\"---\")"] + "source": [ + "async for event in graph.astream(\n", + " [\n", + " HumanMessage(\n", + " content=\"Generate an essay on the topicality of The Little Prince and its message in modern life\"\n", + " )\n", + " ],\n", + "):\n", + " print(event)\n", + " print(\"---\")" + ] }, { "cell_type": "code", @@ -371,7 +495,9 @@ ] } ], - "source": ["ChatPromptTemplate.from_messages(event[END]).pretty_print()"] + "source": [ + "ChatPromptTemplate.from_messages(event[END]).pretty_print()" + ] }, { "cell_type": "markdown", @@ -389,7 +515,7 @@ "id": "7c0e3efd-7f54-410e-bd31-36185a46b9a8", "metadata": {}, "outputs": [], - "source": [""] + "source": [] } ], "metadata": { diff --git a/examples/reflexion/reflexion.ipynb b/examples/reflexion/reflexion.ipynb index 9dcbcc7c7..670e6eb5b 100644 --- a/examples/reflexion/reflexion.ipynb +++ b/examples/reflexion/reflexion.ipynb @@ -40,7 +40,10 @@ "id": "1b64a6f6-1d32-48be-92b5-66c3b04b17f7", "metadata": {}, "outputs": [], - "source": ["%pip install -U --quiet langgraph langchain_anthropic\n%pip install -U --quiet tavily-python"] + "source": [ + "%pip install -U --quiet langgraph langchain_anthropic\n", + "%pip install -U --quiet tavily-python" + ] }, { "cell_type": "code", @@ -48,7 +51,25 @@ "id": "a917bb70-f84c-48e6-8d32-d14f9df2ca2f", "metadata": {}, "outputs": [], - "source": ["import getpass\nimport os\n\n\ndef _set_if_undefined(var: str) -> None:\n if os.environ.get(var):\n return\n os.environ[var] = getpass.getpass(var)\n\n\n# Optional: Configure tracing to visualize and debug the agent\n_set_if_undefined(\"LANGCHAIN_API_KEY\")\nos.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\nos.environ[\"LANGCHAIN_PROJECT\"] = \"Reflexion\"\n\n_set_if_undefined(\"ANTHROPIC_API_KEY\")\n_set_if_undefined(\"TAVILY_API_KEY\")"] + "source": [ + "import getpass\n", + "import os\n", + "\n", + "\n", + "def _set_if_undefined(var: str) -> None:\n", + " if os.environ.get(var):\n", + " return\n", + " os.environ[var] = getpass.getpass(var)\n", + "\n", + "\n", + "# Optional: Configure tracing to visualize and debug the agent\n", + "_set_if_undefined(\"LANGCHAIN_API_KEY\")\n", + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_PROJECT\"] = \"Reflexion\"\n", + "\n", + "_set_if_undefined(\"ANTHROPIC_API_KEY\")\n", + "_set_if_undefined(\"TAVILY_API_KEY\")" + ] }, { "cell_type": "code", @@ -56,7 +77,15 @@ "id": "567b6c4a", "metadata": {}, "outputs": [], - "source": ["from langchain_anthropic import ChatAnthropic\n\nllm = ChatAnthropic(model=\"claude-3-sonnet-20240229\")\n# You could also use OpenAI or another provider\n# from langchain_openai import ChatOpenAI\n\n# llm = ChatOpenAI(model=\"gpt-4-turbo-preview\")"] + "source": [ + "from langchain_anthropic import ChatAnthropic\n", + "\n", + "llm = ChatAnthropic(model=\"claude-3-sonnet-20240229\")\n", + "# You could also use OpenAI or another provider\n", + "# from langchain_openai import ChatOpenAI\n", + "\n", + "# llm = ChatOpenAI(model=\"gpt-4-turbo-preview\")" + ] }, { "cell_type": "markdown", @@ -81,7 +110,13 @@ "id": "5a2ac853-b8a6-40de-b7fe-3f9f3c5ca4d2", "metadata": {}, "outputs": [], - "source": ["from langchain_community.tools.tavily_search import TavilySearchResults\nfrom langchain_community.utilities.tavily_search import TavilySearchAPIWrapper\n\nsearch = TavilySearchAPIWrapper()\ntavily_tool = TavilySearchResults(api_wrapper=search, max_results=5)"] + "source": [ + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "from langchain_community.utilities.tavily_search import TavilySearchAPIWrapper\n", + "\n", + "search = TavilySearchAPIWrapper()\n", + "tavily_tool = TavilySearchResults(api_wrapper=search, max_results=5)" + ] }, { "cell_type": "markdown", @@ -97,7 +132,54 @@ "id": "5fffa8d5-068a-4f0b-adfc-b4daf30ef294", "metadata": {}, "outputs": [], - "source": ["from langchain_core.messages import HumanMessage, ToolMessage\nfrom langchain_core.output_parsers.openai_tools import PydanticToolsParser\nfrom langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder\nfrom langchain_core.pydantic_v1 import BaseModel, Field, ValidationError\n\n\nclass Reflection(BaseModel):\n missing: str = Field(description=\"Critique of what is missing.\")\n superfluous: str = Field(description=\"Critique of what is superfluous\")\n\n\nclass AnswerQuestion(BaseModel):\n \"\"\"Answer the question. Provide an answer, reflection, and then follow up with search queries to improve the answer.\"\"\"\n\n answer: str = Field(description=\"~250 word detailed answer to the question.\")\n reflection: Reflection = Field(description=\"Your reflection on the initial answer.\")\n search_queries: list[str] = Field(\n description=\"1-3 search queries for researching improvements to address the critique of your current answer.\"\n )\n\n\nclass ResponderWithRetries:\n def __init__(self, runnable, validator):\n self.runnable = runnable\n self.validator = validator\n\n def respond(self, state: list):\n response = []\n for attempt in range(3):\n response = self.runnable.invoke(\n {\"messages\": state}, {\"tags\": [f\"attempt:{attempt}\"]}\n )\n try:\n self.validator.invoke(response)\n return response\n except ValidationError as e:\n state = state + [\n response,\n ToolMessage(\n content=f\"{repr(e)}\\n\\nPay close attention to the function schema.\\n\\n\"\n + self.validator.schema_json()\n + \" Respond by fixing all validation errors.\",\n tool_call_id=response.tool_calls[0][\"id\"],\n ),\n ]\n return response"] + "source": [ + "from langchain_core.messages import HumanMessage, ToolMessage\n", + "from langchain_core.output_parsers.openai_tools import PydanticToolsParser\n", + "from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder\n", + "from langchain_core.pydantic_v1 import BaseModel, Field, ValidationError\n", + "\n", + "\n", + "class Reflection(BaseModel):\n", + " missing: str = Field(description=\"Critique of what is missing.\")\n", + " superfluous: str = Field(description=\"Critique of what is superfluous\")\n", + "\n", + "\n", + "class AnswerQuestion(BaseModel):\n", + " \"\"\"Answer the question. Provide an answer, reflection, and then follow up with search queries to improve the answer.\"\"\"\n", + "\n", + " answer: str = Field(description=\"~250 word detailed answer to the question.\")\n", + " reflection: Reflection = Field(description=\"Your reflection on the initial answer.\")\n", + " search_queries: list[str] = Field(\n", + " description=\"1-3 search queries for researching improvements to address the critique of your current answer.\"\n", + " )\n", + "\n", + "\n", + "class ResponderWithRetries:\n", + " def __init__(self, runnable, validator):\n", + " self.runnable = runnable\n", + " self.validator = validator\n", + "\n", + " def respond(self, state: list):\n", + " response = []\n", + " for attempt in range(3):\n", + " response = self.runnable.invoke(\n", + " {\"messages\": state}, {\"tags\": [f\"attempt:{attempt}\"]}\n", + " )\n", + " try:\n", + " self.validator.invoke(response)\n", + " return response\n", + " except ValidationError as e:\n", + " state = state + [\n", + " response,\n", + " ToolMessage(\n", + " content=f\"{repr(e)}\\n\\nPay close attention to the function schema.\\n\\n\"\n", + " + self.validator.schema_json()\n", + " + \" Respond by fixing all validation errors.\",\n", + " tool_call_id=response.tool_calls[0][\"id\"],\n", + " ),\n", + " ]\n", + " return response" + ] }, { "cell_type": "code", @@ -114,7 +196,40 @@ ] } ], - "source": ["import datetime\n\nactor_prompt_template = ChatPromptTemplate.from_messages(\n [\n (\n \"system\",\n \"\"\"You are expert researcher.\nCurrent time: {time}\n\n1. {first_instruction}\n2. Reflect and critique your answer. Be severe to maximize improvement.\n3. Recommend search queries to research information and improve your answer.\"\"\",\n ),\n MessagesPlaceholder(variable_name=\"messages\"),\n (\n \"user\",\n \"\\n\\nReflect on the user's original question and the\"\n \" actions taken thus far. Respond using the {function_name} function.\",\n ),\n ]\n).partial(\n time=lambda: datetime.datetime.now().isoformat(),\n)\ninitial_answer_chain = actor_prompt_template.partial(\n first_instruction=\"Provide a detailed ~250 word answer.\",\n function_name=AnswerQuestion.__name__,\n) | llm.bind_tools(tools=[AnswerQuestion])\nvalidator = PydanticToolsParser(tools=[AnswerQuestion])\n\nfirst_responder = ResponderWithRetries(\n runnable=initial_answer_chain, validator=validator\n)"] + "source": [ + "import datetime\n", + "\n", + "actor_prompt_template = ChatPromptTemplate.from_messages(\n", + " [\n", + " (\n", + " \"system\",\n", + " \"\"\"You are expert researcher.\n", + "Current time: {time}\n", + "\n", + "1. {first_instruction}\n", + "2. Reflect and critique your answer. Be severe to maximize improvement.\n", + "3. Recommend search queries to research information and improve your answer.\"\"\",\n", + " ),\n", + " MessagesPlaceholder(variable_name=\"messages\"),\n", + " (\n", + " \"user\",\n", + " \"\\n\\nReflect on the user's original question and the\"\n", + " \" actions taken thus far. Respond using the {function_name} function.\",\n", + " ),\n", + " ]\n", + ").partial(\n", + " time=lambda: datetime.datetime.now().isoformat(),\n", + ")\n", + "initial_answer_chain = actor_prompt_template.partial(\n", + " first_instruction=\"Provide a detailed ~250 word answer.\",\n", + " function_name=AnswerQuestion.__name__,\n", + ") | llm.bind_tools(tools=[AnswerQuestion])\n", + "validator = PydanticToolsParser(tools=[AnswerQuestion])\n", + "\n", + "first_responder = ResponderWithRetries(\n", + " runnable=initial_answer_chain, validator=validator\n", + ")" + ] }, { "cell_type": "code", @@ -122,7 +237,10 @@ "id": "5922e1fe-7533-4f41-8b1d-d812707c1968", "metadata": {}, "outputs": [], - "source": ["example_question = \"Why is reflection useful in AI?\"\ninitial = first_responder.respond([HumanMessage(content=example_question)])"] + "source": [ + "example_question = \"Why is reflection useful in AI?\"\n", + "initial = first_responder.respond([HumanMessage(content=example_question)])" + ] }, { "cell_type": "markdown", @@ -140,7 +258,38 @@ "id": "2605fd8d-c663-446f-ba25-751190195749", "metadata": {}, "outputs": [], - "source": ["revise_instructions = \"\"\"Revise your previous answer using the new information.\n - You should use the previous critique to add important information to your answer.\n - You MUST include numerical citations in your revised answer to ensure it can be verified.\n - Add a \"References\" section to the bottom of your answer (which does not count towards the word limit). In form of:\n - [1] https://example.com\n - [2] https://example.com\n - You should use the previous critique to remove superfluous information from your answer and make SURE it is not more than 250 words.\n\"\"\"\n\n\n# Extend the initial answer schema to include references.\n# Forcing citation in the model encourages grounded responses\nclass ReviseAnswer(AnswerQuestion):\n \"\"\"Revise your original answer to your question. Provide an answer, reflection,\n\n cite your reflection with references, and finally\n add search queries to improve the answer.\"\"\"\n\n references: list[str] = Field(\n description=\"Citations motivating your updated answer.\"\n )\n\n\nrevision_chain = actor_prompt_template.partial(\n first_instruction=revise_instructions,\n function_name=ReviseAnswer.__name__,\n) | llm.bind_tools(tools=[ReviseAnswer])\nrevision_validator = PydanticToolsParser(tools=[ReviseAnswer])\n\nrevisor = ResponderWithRetries(runnable=revision_chain, validator=revision_validator)"] + "source": [ + "revise_instructions = \"\"\"Revise your previous answer using the new information.\n", + " - You should use the previous critique to add important information to your answer.\n", + " - You MUST include numerical citations in your revised answer to ensure it can be verified.\n", + " - Add a \"References\" section to the bottom of your answer (which does not count towards the word limit). In form of:\n", + " - [1] https://example.com\n", + " - [2] https://example.com\n", + " - You should use the previous critique to remove superfluous information from your answer and make SURE it is not more than 250 words.\n", + "\"\"\"\n", + "\n", + "\n", + "# Extend the initial answer schema to include references.\n", + "# Forcing citation in the model encourages grounded responses\n", + "class ReviseAnswer(AnswerQuestion):\n", + " \"\"\"Revise your original answer to your question. Provide an answer, reflection,\n", + "\n", + " cite your reflection with references, and finally\n", + " add search queries to improve the answer.\"\"\"\n", + "\n", + " references: list[str] = Field(\n", + " description=\"Citations motivating your updated answer.\"\n", + " )\n", + "\n", + "\n", + "revision_chain = actor_prompt_template.partial(\n", + " first_instruction=revise_instructions,\n", + " function_name=ReviseAnswer.__name__,\n", + ") | llm.bind_tools(tools=[ReviseAnswer])\n", + "revision_validator = PydanticToolsParser(tools=[ReviseAnswer])\n", + "\n", + "revisor = ResponderWithRetries(runnable=revision_chain, validator=revision_validator)" + ] }, { "cell_type": "code", @@ -159,7 +308,25 @@ "output_type": "execute_result" } ], - "source": ["import json\n\nrevised = revisor.respond(\n [\n HumanMessage(content=example_question),\n initial,\n ToolMessage(\n tool_call_id=initial.tool_calls[0][\"id\"],\n content=json.dumps(\n tavily_tool.invoke(\n {\"query\": initial.tool_calls[0][\"args\"][\"search_queries\"][0]}\n )\n ),\n ),\n ]\n)\nrevised"] + "source": [ + "import json\n", + "\n", + "revised = revisor.respond(\n", + " [\n", + " HumanMessage(content=example_question),\n", + " initial,\n", + " ToolMessage(\n", + " tool_call_id=initial.tool_calls[0][\"id\"],\n", + " content=json.dumps(\n", + " tavily_tool.invoke(\n", + " {\"query\": initial.tool_calls[0][\"args\"][\"search_queries\"][0]}\n", + " )\n", + " ),\n", + " ),\n", + " ]\n", + ")\n", + "revised" + ] }, { "cell_type": "markdown", @@ -177,7 +344,24 @@ "id": "fccd6a17", "metadata": {}, "outputs": [], - "source": ["from langchain_core.tools import StructuredTool\n\nfrom langgraph.prebuilt import ToolNode\n\n\ndef run_queries(search_queries: list[str], **kwargs):\n \"\"\"Run the generated queries.\"\"\"\n return tavily_tool.batch([{\"query\": query} for query in search_queries])\n\n\ntool_node = ToolNode(\n [\n StructuredTool.from_function(run_queries, name=AnswerQuestion.__name__),\n StructuredTool.from_function(run_queries, name=ReviseAnswer.__name__),\n ]\n)"] + "source": [ + "from langchain_core.tools import StructuredTool\n", + "\n", + "from langgraph.prebuilt import ToolNode\n", + "\n", + "\n", + "def run_queries(search_queries: list[str], **kwargs):\n", + " \"\"\"Run the generated queries.\"\"\"\n", + " return tavily_tool.batch([{\"query\": query} for query in search_queries])\n", + "\n", + "\n", + "tool_node = ToolNode(\n", + " [\n", + " StructuredTool.from_function(run_queries, name=AnswerQuestion.__name__),\n", + " StructuredTool.from_function(run_queries, name=ReviseAnswer.__name__),\n", + " ]\n", + ")" + ] }, { "cell_type": "markdown", @@ -196,7 +380,55 @@ "id": "3c57318f-a30c-4dbd-9b88-f2633e8cb3b1", "metadata": {}, "outputs": [], - "source": ["from typing import Literal\n\nfrom langgraph.graph import END, MessageGraph, START\n\nMAX_ITERATIONS = 5\nbuilder = MessageGraph()\nbuilder.add_node(\"draft\", first_responder.respond)\n\n\nbuilder.add_node(\"execute_tools\", tool_node)\nbuilder.add_node(\"revise\", revisor.respond)\n# draft -> execute_tools\nbuilder.add_edge(\"draft\", \"execute_tools\")\n# execute_tools -> revise\nbuilder.add_edge(\"execute_tools\", \"revise\")\n\n# Define looping logic:\n\n\ndef _get_num_iterations(state: list):\n i = 0\n for m in state[::-1]:\n if m.type not in {\"tool\", \"ai\"}:\n break\n i += 1\n return i\n\n\ndef event_loop(state: list) -> Literal[\"execute_tools\", \"__end__\"]:\n # in our case, we'll just stop after N plans\n num_iterations = _get_num_iterations(state)\n if num_iterations > MAX_ITERATIONS:\n return END\n return \"execute_tools\"\n\n\n# revise -> execute_tools OR end\nbuilder.add_conditional_edges(\"revise\", event_loop)\nbuilder.add_edge(START, \"draft\")\ngraph = builder.compile()"] + "source": [ + "from typing import Literal\n", + "\n", + "from langgraph.graph import END, StateGraph, START\n", + "from langgraph.graph.message import add_messages\n", + "from typing import Annotated\n", + "from typing_extensions import TypedDict\n", + "\n", + "\n", + "class State(TypedDict):\n", + " messages: Annotated[list, add_messages]\n", + "\n", + "MAX_ITERATIONS = 5\n", + "builder = StateGraph(State)\n", + "builder.add_node(\"draft\", first_responder.respond)\n", + "\n", + "\n", + "builder.add_node(\"execute_tools\", tool_node)\n", + "builder.add_node(\"revise\", revisor.respond)\n", + "# draft -> execute_tools\n", + "builder.add_edge(\"draft\", \"execute_tools\")\n", + "# execute_tools -> revise\n", + "builder.add_edge(\"execute_tools\", \"revise\")\n", + "\n", + "# Define looping logic:\n", + "\n", + "\n", + "def _get_num_iterations(state: list):\n", + " i = 0\n", + " for m in state[::-1]:\n", + " if m.type not in {\"tool\", \"ai\"}:\n", + " break\n", + " i += 1\n", + " return i\n", + "\n", + "\n", + "def event_loop(state: list) -> Literal[\"execute_tools\", \"__end__\"]:\n", + " # in our case, we'll just stop after N plans\n", + " num_iterations = _get_num_iterations(state)\n", + " if num_iterations > MAX_ITERATIONS:\n", + " return END\n", + " return \"execute_tools\"\n", + "\n", + "\n", + "# revise -> execute_tools OR end\n", + "builder.add_conditional_edges(\"revise\", event_loop)\n", + "builder.add_edge(START, \"draft\")\n", + "graph = builder.compile()" + ] }, { "cell_type": "code", @@ -215,7 +447,15 @@ "output_type": "display_data" } ], - "source": ["from IPython.display import Image, display\n\ntry:\n display(Image(graph.get_graph().draw_mermaid_png()))\nexcept Exception:\n # This requires some extra dependencies and is optional\n pass"] + "source": [ + "from IPython.display import Image, display\n", + "\n", + "try:\n", + " display(Image(graph.get_graph().draw_mermaid_png()))\n", + "except Exception:\n", + " # This requires some extra dependencies and is optional\n", + " pass" + ] }, { "cell_type": "code", @@ -330,7 +570,15 @@ ] } ], - "source": ["events = graph.stream(\n [HumanMessage(content=\"How should we handle the climate crisis?\")],\n stream_mode=\"values\",\n)\nfor i, step in enumerate(events):\n print(f\"Step {i}\")\n step[-1].pretty_print()"] + "source": [ + "events = graph.stream(\n", + " [HumanMessage(content=\"How should we handle the climate crisis?\")],\n", + " stream_mode=\"values\",\n", + ")\n", + "for i, step in enumerate(events):\n", + " print(f\"Step {i}\")\n", + " step[-1].pretty_print()" + ] }, { "cell_type": "markdown", diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py index e07f0aa5f..02d5880a0 100644 --- a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py @@ -316,7 +316,17 @@ class PostgresSaver(BasePostgresSaver): writes (List[Tuple[str, Any]]): List of writes to store. task_id (str): Identifier for the task creating the writes. """ - with self._cursor() as cur: + with self._cursor(pipeline=True) as cur: + cur.execute( + self.DELETE_WRITES_SQL, + ( + config["configurable"]["thread_id"], + config["configurable"]["checkpoint_ns"], + config["configurable"]["checkpoint_id"], + task_id, + len(writes), + ), + ) cur.executemany( self.UPSERT_CHECKPOINT_WRITES_SQL, self._dump_writes( diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py index c325399e2..7ddb81237 100644 --- a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py @@ -272,7 +272,17 @@ class AsyncPostgresSaver(BasePostgresSaver): writes (Sequence[Tuple[str, Any]]): List of writes to store, each as (channel, value) pair. task_id (str): Identifier for the task creating the writes. """ - async with self._cursor() as cur: + async with self._cursor(pipeline=True) as cur: + await cur.execute( + self.DELETE_WRITES_SQL, + ( + config["configurable"]["thread_id"], + config["configurable"]["checkpoint_ns"], + config["configurable"]["checkpoint_id"], + task_id, + len(writes), + ), + ) await cur.executemany( self.UPSERT_CHECKPOINT_WRITES_SQL, await asyncio.to_thread( diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py index 038bb6677..99fb0bbba 100644 --- a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py @@ -105,6 +105,15 @@ UPSERT_CHECKPOINT_WRITES_SQL = """ ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) DO NOTHING """ +DELETE_WRITES_SQL = """ + DELETE FROM checkpoint_writes + WHERE thread_id = %s + AND checkpoint_ns = %s + AND checkpoint_id = %s + AND task_id = %s + AND idx >= %s +""" + class BasePostgresSaver(BaseCheckpointSaver): SELECT_SQL = SELECT_SQL @@ -112,6 +121,8 @@ class BasePostgresSaver(BaseCheckpointSaver): UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL + DELETE_WRITES_SQL = DELETE_WRITES_SQL + jsonplus_serde = JsonPlusSerializer() def _load_checkpoint(self, checkpoint: dict[str, Any]) -> Checkpoint: diff --git a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py index ac2f64f32..9bf4139ad 100644 --- a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py +++ b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py @@ -424,6 +424,16 @@ class SqliteSaver(BaseCheckpointSaver): task_id (str): Identifier for the task creating the writes. """ with self.lock, self.cursor() as cur: + cur.execute( + "DELETE FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? AND task_id = ? AND idx >= ?", + ( + str(config["configurable"]["thread_id"]), + str(config["configurable"]["checkpoint_ns"]), + str(config["configurable"]["checkpoint_id"]), + task_id, + len(writes), + ), + ) cur.executemany( "INSERT OR REPLACE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [ diff --git a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py index 95b77209a..56d14f613 100644 --- a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py +++ b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py @@ -432,19 +432,29 @@ class AsyncSqliteSaver(BaseCheckpointSaver): task_id (str): Identifier for the task creating the writes. """ await self.setup() - async with self.conn.executemany( - "INSERT OR REPLACE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - [ + async with self.conn.cursor() as cur: + await cur.execute( + "DELETE FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? AND task_id = ? AND idx >= ?", ( str(config["configurable"]["thread_id"]), str(config["configurable"]["checkpoint_ns"]), str(config["configurable"]["checkpoint_id"]), task_id, - idx, - channel, - *self.serde.dumps_typed(value), - ) - for idx, (channel, value) in enumerate(writes) - ], - ): - await self.conn.commit() + len(writes), + ), + ) + await cur.executemany( + "INSERT OR REPLACE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [ + ( + str(config["configurable"]["thread_id"]), + str(config["configurable"]["checkpoint_ns"]), + str(config["configurable"]["checkpoint_id"]), + task_id, + idx, + channel, + *self.serde.dumps_typed(value), + ) + for idx, (channel, value) in enumerate(writes) + ], + ) diff --git a/libs/checkpoint/langgraph/checkpoint/memory/__init__.py b/libs/checkpoint/langgraph/checkpoint/memory/__init__.py index 76901e2e0..6cc1a3b14 100644 --- a/libs/checkpoint/langgraph/checkpoint/memory/__init__.py +++ b/libs/checkpoint/langgraph/checkpoint/memory/__init__.py @@ -290,6 +290,7 @@ class MemorySaver( checkpoint_ns = config["configurable"]["checkpoint_ns"] checkpoint_id = config["configurable"]["checkpoint_id"] key = (thread_id, checkpoint_ns, checkpoint_id) + self.writes[key] = [w for w in self.writes[key] if w[0] != task_id] self.writes[key].extend( [(task_id, c, self.serde.dumps_typed(v)) for c, v in writes] ) diff --git a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py index 9f29ddc7e..206ec08e1 100644 --- a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py +++ b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py @@ -110,6 +110,8 @@ class JsonPlusSerializer(SerializerProtocol): return self._encode_constructor_args( obj.__class__, method="fromhex", args=[obj.hex()] ) + elif isinstance(obj, BaseException): + return self._encode_constructor_args(obj.__class__, args=obj.args) else: raise TypeError( f"Object of type {obj.__class__.__name__} is not JSON serializable" @@ -121,18 +123,28 @@ class JsonPlusSerializer(SerializerProtocol): and value.get("type", None) == "constructor" and value.get("id", None) is not None ): - # Get module and class name - [*module, name] = value["id"] - # Import module - mod = importlib.import_module(".".join(module)) - # Import class - cls = getattr(mod, name) - # Instantiate class - if value["method"] is not None: - method = getattr(cls, value["method"]) - return method(*value["args"], **value["kwargs"]) - else: - return cls(*value["args"], **value["kwargs"]) + try: + # Get module and class name + [*module, name] = value["id"] + # Import module + mod = importlib.import_module(".".join(module)) + # Import class + cls = getattr(mod, name) + # Instantiate class + if value["method"] is not None: + method = getattr(cls, value["method"]) + else: + method = cls + if value["args"] and value["kwargs"]: + return method(*value["args"], **value["kwargs"]) + elif value["args"]: + return method(*value["args"]) + elif value["kwargs"]: + return method(**value["kwargs"]) + else: + return method() + except (ImportError, AttributeError): + return None return LC_REVIVER(value) diff --git a/libs/checkpoint/pyproject.toml b/libs/checkpoint/pyproject.toml index 815a9b8da..b17a77238 100644 --- a/libs/checkpoint/pyproject.toml +++ b/libs/checkpoint/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langgraph-checkpoint" -version = "1.0.2" +version = "1.0.3" description = "Library with base interfaces for LangGraph checkpoint savers." authors = [] license = "MIT" diff --git a/libs/checkpoint/tests/test_jsonplus.py b/libs/checkpoint/tests/test_jsonplus.py index 2ca5a59c1..d5a53d248 100644 --- a/libs/checkpoint/tests/test_jsonplus.py +++ b/libs/checkpoint/tests/test_jsonplus.py @@ -149,3 +149,21 @@ def test_serde_jsonplus_bytearray() -> None: assert dumped == ("bytearray", some_bytearray) assert serde.loads_typed(dumped) == some_bytearray + + +def test_loads_cannot_find() -> None: + serde = JsonPlusSerializer() + + dumped = ( + "json", + b'{"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "MyPydanticccc"], "method": null, "args": [], "kwargs": {"foo": "foo", "bar": 1}}', + ) + + assert serde.loads_typed(dumped) is None, "Should return None if cannot find class" + + dumped = ( + "json", + b'{"lc": 2, "type": "constructor", "id": ["tests", "test_jsonpluss", "MyPydantic"], "method": null, "args": [], "kwargs": {"foo": "foo", "bar": 1}}', + ) + + assert serde.loads_typed(dumped) is None, "Should return None if cannot find module" diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 83d14d218..1e3ffa2a7 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -224,7 +224,7 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE def on_stdout(line: str): if "unpacking to docker.io" in line: set("Starting...") - elif "GET /ok" in line: + elif "Application startup complete" in line: debugger_origin = ( f"http://localhost:{debugger_port}" if debugger_port diff --git a/libs/langgraph/langgraph/constants.py b/libs/langgraph/langgraph/constants.py index bc74619ff..d0c1ebff7 100644 --- a/libs/langgraph/langgraph/constants.py +++ b/libs/langgraph/langgraph/constants.py @@ -1,4 +1,5 @@ -from typing import Any, Optional +from dataclasses import dataclass +from typing import Any, Literal, Optional from uuid import uuid4 INPUT = "__input__" @@ -6,15 +7,19 @@ CONFIG_KEY_SEND = "__pregel_send" CONFIG_KEY_READ = "__pregel_read" CONFIG_KEY_CHECKPOINTER = "__pregel_checkpointer" CONFIG_KEY_RESUMING = "__pregel_resuming" +CONFIG_KEY_TASK_ID = "__pregel_task_id" INTERRUPT = "__interrupt__" +ERROR = "__error__" TASKS = "__pregel_tasks" RESERVED = { INTERRUPT, + ERROR, TASKS, CONFIG_KEY_SEND, CONFIG_KEY_READ, CONFIG_KEY_CHECKPOINTER, CONFIG_KEY_RESUMING, + CONFIG_KEY_TASK_ID, INPUT, } TAG_HIDDEN = "langsmith:hidden" @@ -97,3 +102,9 @@ class Send: and self.node == value.node and self.arg == value.arg ) + + +@dataclass +class Interrupt: + when: Literal["before", "during", "after"] + value: Any = None diff --git a/libs/langgraph/langgraph/errors.py b/libs/langgraph/langgraph/errors.py index ea54cc55f..00dc36e58 100644 --- a/libs/langgraph/langgraph/errors.py +++ b/libs/langgraph/langgraph/errors.py @@ -1,4 +1,7 @@ +from typing import Any + from langgraph.checkpoint.base import EmptyChannelError +from langgraph.constants import Interrupt class GraphRecursionError(RecursionError): @@ -29,7 +32,15 @@ class InvalidUpdateError(Exception): class GraphInterrupt(Exception): """Raised when a subgraph is interrupted.""" - pass + def __init__(self, interrupts: list[Interrupt]) -> None: + super().__init__(interrupts) + + +class NodeInterrupt(GraphInterrupt): + """Raised by a node to interrupt execution.""" + + def __init__(self, value: Any) -> None: + super().__init__([Interrupt("during", value)]) class EmptyInputError(Exception): @@ -42,6 +53,7 @@ __all__ = [ "GraphRecursionError", "InvalidUpdateError", "GraphInterrupt", + "NodeInterrupt", "EmptyInputError", "EmptyChannelError", ] diff --git a/libs/langgraph/langgraph/managed/base.py b/libs/langgraph/langgraph/managed/base.py index 0e1476895..0455ed58b 100644 --- a/libs/langgraph/langgraph/managed/base.py +++ b/libs/langgraph/langgraph/managed/base.py @@ -3,7 +3,6 @@ from abc import ABC, abstractmethod from contextlib import AsyncExitStack, ExitStack, asynccontextmanager, contextmanager from inspect import isclass from typing import ( - TYPE_CHECKING, Any, AsyncGenerator, Generator, @@ -17,9 +16,6 @@ from typing import ( from langchain_core.runnables import RunnableConfig from typing_extensions import Self, TypeGuard -if TYPE_CHECKING: - from langgraph.pregel.types import PregelTaskDescription - V = TypeVar("V") @@ -60,7 +56,7 @@ class ManagedValue(ABC, Generic[V]): pass @abstractmethod - def __call__(self, step: int, task: "PregelTaskDescription") -> V: + def __call__(self, step: int) -> V: ... diff --git a/libs/langgraph/langgraph/managed/is_last_step.py b/libs/langgraph/langgraph/managed/is_last_step.py index 5860a9cbe..3c6daf5f4 100644 --- a/libs/langgraph/langgraph/managed/is_last_step.py +++ b/libs/langgraph/langgraph/managed/is_last_step.py @@ -1,11 +1,10 @@ from typing import Annotated from langgraph.managed.base import ManagedValue -from langgraph.pregel.types import PregelExecutableTask class IsLastStepManager(ManagedValue[bool]): - def __call__(self, step: int, task: PregelExecutableTask) -> bool: + def __call__(self, step: int) -> bool: return step == self.config["recursion_limit"] - 1 diff --git a/libs/langgraph/langgraph/prebuilt/tool_node.py b/libs/langgraph/langgraph/prebuilt/tool_node.py index 24622a4a7..ed7b69553 100644 --- a/libs/langgraph/langgraph/prebuilt/tool_node.py +++ b/libs/langgraph/langgraph/prebuilt/tool_node.py @@ -221,28 +221,34 @@ def tools_condition( ```pycon >>> from langchain_anthropic import ChatAnthropic >>> from langchain_core.tools import tool - >>> - >>> from langgraph.graph import MessageGraph + ... + >>> from langgraph.graph import StateGraph >>> from langgraph.prebuilt import ToolNode, tools_condition - >>> + >>> from langgraph.graph.message import add_messages + ... + >>> from typing import TypedDict, Annotated + ... >>> @tool >>> def divide(a: float, b: float) -> int: - >>> \"\"\"Return a / b.\"\"\" - >>> return a / b - >>> + ... \"\"\"Return a / b.\"\"\" + ... return a / b + ... >>> llm = ChatAnthropic(model="claude-3-haiku-20240307") >>> tools = [divide] + ... + >>> class State(TypedDict): + ... messages: Annotated[list, add_messages] >>> - >>> graph_builder = MessageGraph() + >>> graph_builder = StateGraph(State) >>> graph_builder.add_node("tools", ToolNode(tools)) - >>> graph_builder.add_node("chatbot", llm.bind_tools(tools)) + >>> graph_builder.add_node("chatbot", lambda state: {"messages":llm.bind_tools(tools).invoke(state['messages'])}) >>> graph_builder.add_edge("tools", "chatbot") >>> graph_builder.add_conditional_edges( ... "chatbot", tools_condition ... ) >>> graph_builder.set_entry_point("chatbot") >>> graph = graph_builder.compile() - >>> graph.invoke([("user", "What's 329993 divided by 13662?")]) + >>> graph.invoke({"messages": {"role": "user", "content": "What's 329993 divided by 13662?"}}) ``` """ if isinstance(state, list): diff --git a/libs/langgraph/langgraph/prebuilt/tool_validator.py b/libs/langgraph/langgraph/prebuilt/tool_validator.py index 5f54398f4..73b2cce2b 100644 --- a/libs/langgraph/langgraph/prebuilt/tool_validator.py +++ b/libs/langgraph/langgraph/prebuilt/tool_validator.py @@ -72,13 +72,14 @@ class ValidationNode(RunnableCallable): Examples: Example usage for re-prompting the model to generate a valid response: - >>> from typing import Literal + >>> from typing import Literal, Annotated, TypedDict ... >>> from langchain_anthropic import ChatAnthropic >>> from langchain_core.pydantic_v1 import BaseModel, validator ... - >>> from langgraph.graph import END, START, MessageGraph + >>> from langgraph.graph import END, START, StateGraph >>> from langgraph.prebuilt import ValidationNode + >>> from langgraph.graph.message import add_messages ... ... >>> class SelectNumber(BaseModel): @@ -91,7 +92,10 @@ class ValidationNode(RunnableCallable): ... return v ... ... - >>> builder = MessageGraph() + >>> class State(TypedDict): + ... messages: Annotated[list, add_messages] + ... + >>> builder = StateGraph(State) >>> llm = ChatAnthropic(model="claude-3-haiku-20240307").bind_tools([SelectNumber]) >>> builder.add_node("model", llm) >>> builder.add_node("validation", ValidationNode([SelectNumber])) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index bc4fd48a0..488334f63 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -70,10 +70,12 @@ from langgraph.constants import ( CONFIG_KEY_READ, CONFIG_KEY_RESUMING, CONFIG_KEY_SEND, + ERROR, INTERRUPT, SEND_CHECKPOINT_NAMESPACE_SEPARATOR, + Interrupt, ) -from langgraph.errors import GraphRecursionError, InvalidUpdateError +from langgraph.errors import GraphInterrupt, GraphRecursionError, InvalidUpdateError from langgraph.managed.base import ( AsyncManagedValuesManager, ManagedValuesManager, @@ -84,12 +86,14 @@ from langgraph.pregel.algo import ( apply_writes, local_read, prepare_next_tasks, + should_interrupt, ) from langgraph.pregel.debug import ( map_debug_task_results, print_step_checkpoint, print_step_tasks, print_step_writes, + tasks_w_writes, ) from langgraph.pregel.io import ( map_output_updates, @@ -274,7 +278,7 @@ def _prepare_state_snapshot( channels, managed, saved.config, - -1, + saved.metadata.get("step", -1) + 1, for_execution=False, ) return StateSnapshot( @@ -284,6 +288,7 @@ def _prepare_state_snapshot( metadata=saved.metadata, created_at=saved.checkpoint["ts"], parent_config=saved.parent_config, + tasks=tasks_w_writes(next_tasks, saved.pending_writes), ) @@ -306,7 +311,7 @@ async def _prepare_state_snapshot_async( channels, managed, saved.config, - -1, + saved.metadata.get("step", -1) + 1, for_execution=False, ) return StateSnapshot( @@ -316,6 +321,7 @@ async def _prepare_state_snapshot_async( metadata=saved.metadata, created_at=saved.checkpoint["ts"], parent_config=saved.parent_config, + tasks=tasks_w_writes(next_tasks, saved.pending_writes), ) @@ -552,7 +558,7 @@ class Pregel( if not checkpoint_ns_to_state_snapshots: return StateSnapshot( - values={}, next=(), config=config, metadata=None, created_at=None + values={}, next=(), config=config, metadata=None, created_at=None, tasks=() ) state_snapshot = _assemble_state_snapshot_hierarchy( @@ -606,7 +612,12 @@ class Pregel( if not checkpoint_ns_to_state_snapshots: return StateSnapshot( - values={}, next=(), config=config, metadata=None, created_at=None + values={}, + next=(), + config=config, + metadata=None, + created_at=None, + tasks=(), ) state_snapshot = _assemble_state_snapshot_hierarchy( @@ -693,7 +704,7 @@ class Pregel( saved = self.checkpointer.get_tuple(config) checkpoint = copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint() checkpoint_previous_versions = ( - saved.checkpoint["channel_versions"] if saved else {} + 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 @@ -719,7 +730,7 @@ class Pregel( create_checkpoint(checkpoint, None, step), { "source": "update", - "step": step, + "step": step + 1, "writes": {}, }, {}, @@ -749,7 +760,11 @@ class Pregel( if as_node not in self.nodes: raise InvalidUpdateError(f"Node {as_node} does not exist") # update channels - with ChannelsManager(self.channels, checkpoint, config) as channels: + with ChannelsManager( + self.channels, checkpoint, config + ) as channels, ManagedValuesManager( + self.managed_values_dict, ensure_config(config) + ) as managed: # create task to run all writers of the chosen node writers = self.nodes[as_node].get_writers() if not writers: @@ -783,19 +798,43 @@ class Pregel( apply_writes( checkpoint, channels, [task], self.checkpointer.get_next_version ) - - new_versions = get_new_channel_versions( - checkpoint_previous_versions, checkpoint["channel_versions"] - ) + checkpoint = create_checkpoint(checkpoint, channels, step + 1) + # check interrupt before + if tasks := should_interrupt( + checkpoint, + self.interrupt_before_nodes, + prepare_next_tasks( + checkpoint, + self.nodes, + channels, + managed, + config, + step + 2, + for_execution=False, + ), + ): + for t in tasks: + self.checkpointer.put_writes( + { + "configurable": { + **checkpoint_config["configurable"], + "checkpoint_id": checkpoint["id"], + } + }, + [(INTERRUPT, Interrupt("before"))], + t.id, + ) return self.checkpointer.put( checkpoint_config, - create_checkpoint(checkpoint, channels, step + 1), + checkpoint, { "source": "update", "step": step + 1, "writes": {as_node: values}, }, - new_versions, + get_new_channel_versions( + checkpoint_previous_versions, checkpoint["channel_versions"] + ), ) async def aupdate_state( @@ -811,7 +850,7 @@ class Pregel( saved = await self.checkpointer.aget_tuple(config) checkpoint = copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint() checkpoint_previous_versions = ( - saved.checkpoint["channel_versions"] if saved else {} + 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 @@ -837,7 +876,7 @@ class Pregel( create_checkpoint(checkpoint, None, step), { "source": "update", - "step": step, + "step": step + 1, "writes": {}, }, {}, @@ -865,7 +904,11 @@ class Pregel( if as_node not in self.nodes: raise InvalidUpdateError(f"Node {as_node} does not exist") # update channels, acting as the chosen node - async with AsyncChannelsManager(self.channels, checkpoint, config) as channels: + async with AsyncChannelsManager( + self.channels, checkpoint, config + ) as channels, AsyncManagedValuesManager( + self.managed_values_dict, ensure_config(config) + ) as managed: # create task to run all writers of the chosen node writers = self.nodes[as_node].get_writers() if not writers: @@ -899,19 +942,47 @@ class Pregel( apply_writes( checkpoint, channels, [task], self.checkpointer.get_next_version ) - - new_versions = get_new_channel_versions( - checkpoint_previous_versions, checkpoint["channel_versions"] - ) + checkpoint = create_checkpoint(checkpoint, channels, step + 1) + # check interrupt before + if tasks := should_interrupt( + checkpoint, + self.interrupt_before_nodes, + prepare_next_tasks( + checkpoint, + self.nodes, + channels, + managed, + config, + step + 2, + for_execution=False, + ), + ): + await asyncio.gather( + *( + self.checkpointer.aput_writes( + { + "configurable": { + **checkpoint_config["configurable"], + "checkpoint_id": checkpoint["id"], + } + }, + [(INTERRUPT, Interrupt("before"))], + t.id, + ) + for t in tasks + ) + ) return await self.checkpointer.aput( checkpoint_config, - create_checkpoint(checkpoint, channels, step + 1), + checkpoint, { "source": "update", "step": step + 1, "writes": {as_node: values}, }, - new_versions, + get_new_channel_versions( + checkpoint_previous_versions, checkpoint["channel_versions"] + ), ) def _defaults( @@ -948,7 +1019,7 @@ class Pregel( if ( config is not None and config.get("configurable", {}).get(CONFIG_KEY_CHECKPOINTER) - and (interrupt_before or interrupt_after or _has_nested_interrupts(self)) + and (interrupt_after or interrupt_before or _has_nested_interrupts(self)) ): checkpointer: Optional[BaseCheckpointSaver] = config["configurable"][ CONFIG_KEY_CHECKPOINTER @@ -1088,7 +1159,7 @@ class Pregel( manager=run_manager, ): # debug flag - if self.debug: + if debug: print_step_checkpoint( loop.checkpoint_metadata, loop.channels, @@ -1118,6 +1189,7 @@ class Pregel( for task in loop.tasks if not task.writes } + all_futures = futures.copy() end_time = ( self.step_timeout + time.monotonic() if self.step_timeout @@ -1138,10 +1210,17 @@ class Pregel( if not done: break # timed out for fut, task in zip(done, [futures.pop(fut) for fut in done]): - if fut.exception() is not None: - # we got an exception, break out of while loop - # exception will be handled in panic_or_proceed + if exc := _exception(fut): + # save error to checkpointer + if isinstance(exc, GraphInterrupt): + loop.put_writes( + task.id, [(INTERRUPT, i) for i in exc.args[0]] + ) + else: + loop.put_writes(task.id, [(ERROR, exc)]) + futures.clear() + else: # save task writes to checkpointer loop.put_writes(task.id, task.writes) @@ -1165,9 +1244,11 @@ class Pregel( else: # remove references to loop vars del fut, task + if _should_stop_others(done): + break # panic on failure or timeout - _panic_or_proceed(done, inflight, loop.step) + _panic_or_proceed(all_futures, loop.step) # don't keep futures around in memory longer than needed del done, inflight, futures # debug flag @@ -1331,7 +1412,7 @@ class Pregel( manager=run_manager, ): # debug flag - if self.debug: + if debug: print_step_checkpoint( loop.checkpoint_metadata, loop.channels, @@ -1364,6 +1445,7 @@ class Pregel( for task in loop.tasks if not task.writes } + all_futures = futures.copy() end_time = ( self.step_timeout + aioloop.time() if self.step_timeout @@ -1381,10 +1463,17 @@ class Pregel( ) if not done: break # timed out + for fut, task in zip(done, [futures.pop(fut) for fut in done]): - if fut.exception() is not None: - # we got an exception, break out of while loop - # exception will be handled in panic_or_proceed + if exc := _exception(fut): + # save error to checkpointer + if isinstance(exc, GraphInterrupt): + loop.put_writes( + task.id, [(INTERRUPT, i) for i in exc.args[0]] + ) + else: + loop.put_writes(task.id, [(ERROR, exc)]) + futures.clear() else: # save task writes to checkpointer @@ -1411,9 +1500,11 @@ class Pregel( else: # remove references to loop vars del fut, task + if _should_stop_others(done): + break # panic on failure or timeout - _panic_or_proceed(done, inflight, loop.step, asyncio.TimeoutError) + _panic_or_proceed(all_futures, loop.step, asyncio.TimeoutError) # don't keep futures around in memory longer than needed del done, inflight, futures # debug flag @@ -1554,15 +1645,45 @@ class Pregel( return chunks -def _panic_or_proceed( +def _should_stop_others( done: Union[set[concurrent.futures.Future[Any]], set[asyncio.Task[Any]]], - inflight: Union[set[concurrent.futures.Future[Any]], set[asyncio.Task[Any]]], +) -> bool: + for fut in done: + if fut.cancelled(): + return True + if exc := fut.exception(): + return not isinstance(exc, GraphInterrupt) + else: + return False + + +def _exception( + fut: Union[concurrent.futures.Future[Any], asyncio.Task[Any]], +) -> Optional[BaseException]: + if fut.cancelled(): + if isinstance(fut, asyncio.Task): + return asyncio.CancelledError() + else: + return concurrent.futures.CancelledError() + else: + return fut.exception() + + +def _panic_or_proceed( + futs: Union[set[concurrent.futures.Future[Any]], set[asyncio.Task[Any]]], step: int, timeout_exc_cls: Type[Exception] = TimeoutError, ) -> None: + done: set[Union[concurrent.futures.Future[Any], asyncio.Task[Any]]] = set() + inflight: set[Union[concurrent.futures.Future[Any], asyncio.Task[Any]]] = set() + for fut in futs: + if fut.done(): + done.add(fut) + else: + inflight.add(fut) while done: # if any task failed - if exc := done.pop().exception(): + if exc := _exception(done.pop()): # cancel all pending tasks while inflight: inflight.pop().cancel() diff --git a/libs/langgraph/langgraph/pregel/algo.py b/libs/langgraph/langgraph/pregel/algo.py index ec8c18650..453bd6a76 100644 --- a/libs/langgraph/langgraph/pregel/algo.py +++ b/libs/langgraph/langgraph/pregel/algo.py @@ -38,6 +38,7 @@ from langgraph.constants import ( CONFIG_KEY_READ, CONFIG_KEY_RESUMING, CONFIG_KEY_SEND, + CONFIG_KEY_TASK_ID, INTERRUPT, RESERVED, TAG_HIDDEN, @@ -49,7 +50,7 @@ from langgraph.managed.base import ManagedValueMapping, is_managed_value from langgraph.pregel.io import read_channel, read_channels from langgraph.pregel.log import logger from langgraph.pregel.read import PregelNode -from langgraph.pregel.types import All, PregelExecutableTask, PregelTaskDescription +from langgraph.pregel.types import All, PregelExecutableTask, PregelTask class WritesProtocol(Protocol): @@ -68,26 +69,28 @@ def should_interrupt( checkpoint: Checkpoint, interrupt_nodes: Union[All, Sequence[str]], tasks: list[PregelExecutableTask], -) -> bool: +) -> list[PregelExecutableTask]: version_type = type(next(iter(checkpoint["channel_versions"].values()), None)) null_version = version_type() seen = checkpoint["versions_seen"].get(INTERRUPT, {}) + # interrupt if any channel has been updated since last interrupt + any_updates_since_prev_interrupt = any( + version > seen.get(chan, null_version) + for chan, version in checkpoint["channel_versions"].items() + ) + # and any triggered node is in interrupt_nodes list return ( - # interrupt if any channel has been updated since last interrupt - any( - version > seen.get(chan, null_version) - for chan, version in checkpoint["channel_versions"].items() - ) - # and any triggered node is in interrupt_nodes list - and any( - task.name + [ + task for task in tasks if ( (not task.config or TAG_HIDDEN not in task.config.get("tags")) if interrupt_nodes == "*" else task.name in interrupt_nodes ) - ) + ] + if any_updates_since_prev_interrupt + else [] ) @@ -225,7 +228,7 @@ def prepare_next_tasks( is_resuming: bool = False, checkpointer: Literal[None] = None, manager: Literal[None] = None, -) -> list[PregelTaskDescription]: +) -> list[PregelTask]: ... @@ -258,9 +261,9 @@ def prepare_next_tasks( is_resuming: bool = False, checkpointer: Optional[BaseCheckpointSaver] = None, manager: Union[None, ParentRunManager, AsyncParentRunManager] = None, -) -> Union[list[PregelTaskDescription], list[PregelExecutableTask]]: +) -> Union[list[PregelTask], list[PregelExecutableTask]]: parent_ns = config.get("configurable", {}).get("checkpoint_ns", "") - tasks: Union[list[PregelTaskDescription], list[PregelExecutableTask]] = [] + tasks: Union[list[PregelTask], list[PregelExecutableTask]] = [] # Consume pending packets for packet in checkpoint["pending_sends"]: if not isinstance(packet, Send): @@ -269,6 +272,22 @@ def prepare_next_tasks( if packet.node not in processes: logger.warn(f"Ignoring unknown node name {packet.node} in pending sends") continue + # create task id + triggers = [TASKS] + metadata = { + "langgraph_step": step, + "langgraph_node": packet.node, + "langgraph_triggers": triggers, + "langgraph_task_idx": len(tasks), + } + checkpoint_ns = ( + f"{parent_ns}{CHECKPOINT_NAMESPACE_SEPARATOR}{packet.node}" + if parent_ns + else packet.node + ) + task_id = str( + uuid5(UUID(checkpoint["id"]), json.dumps((checkpoint_ns, metadata))) + ) if for_execution: proc = processes[packet.node] if node := proc.get_node(): @@ -307,6 +326,7 @@ def prepare_next_tasks( else None ), configurable={ + CONFIG_KEY_TASK_ID: task_id, # deque.extend is thread-safe CONFIG_KEY_SEND: partial( local_write, writes.extend, processes, channels @@ -322,8 +342,6 @@ def prepare_next_tasks( CONFIG_KEY_RESUMING: is_resuming, "checkpoint_id": checkpoint["id"], "checkpoint_ns": checkpoint_ns, - # in Send we can't checkpoint nested graphs - # as they could be running in parallel }, ), triggers, @@ -332,7 +350,7 @@ def prepare_next_tasks( ) ) else: - tasks.append(PregelTaskDescription(packet.node)) + tasks.append(PregelTask(task_id, packet.node)) # Check if any processes should be run in next step # If so, prepare the values to be passed to them version_type = type(next(iter(checkpoint["channel_versions"].values()), None)) @@ -360,26 +378,27 @@ def prepare_next_tasks( except StopIteration: continue + # create task id + metadata = { + "langgraph_step": step, + "langgraph_node": name, + "langgraph_triggers": triggers, + "langgraph_task_idx": len(tasks), + } + checkpoint_ns = ( + f"{parent_ns}{CHECKPOINT_NAMESPACE_SEPARATOR}{name}" + if parent_ns + else name + ) + task_id = str( + uuid5( + UUID(checkpoint["id"]), + json.dumps((checkpoint_ns, metadata)), + ) + ) + if for_execution: if node := proc.get_node(): - metadata = { - "langgraph_step": step, - "langgraph_node": name, - "langgraph_triggers": triggers, - "langgraph_task_idx": len(tasks), - } - checkpoint_ns = ( - f"{parent_ns}{CHECKPOINT_NAMESPACE_SEPARATOR}{name}" - if parent_ns - else name - ) - task_id = str( - uuid5( - UUID(checkpoint["id"]), - json.dumps((checkpoint_ns, metadata)), - ) - ) - writes = deque() tasks.append( PregelExecutableTask( @@ -400,6 +419,7 @@ def prepare_next_tasks( else None ), configurable={ + CONFIG_KEY_TASK_ID: task_id, # deque.extend is thread-safe CONFIG_KEY_SEND: partial( local_write, writes.extend, processes, channels @@ -411,7 +431,12 @@ def prepare_next_tasks( PregelTaskWrites(name, writes, triggers), config, ), - CONFIG_KEY_CHECKPOINTER: checkpointer, + CONFIG_KEY_CHECKPOINTER: ( + checkpointer + or config["configurable"].get( + CONFIG_KEY_CHECKPOINTER + ) + ), CONFIG_KEY_RESUMING: is_resuming, "checkpoint_id": checkpoint["id"], "checkpoint_ns": checkpoint_ns, @@ -423,7 +448,7 @@ def prepare_next_tasks( ) ) else: - tasks.append(PregelTaskDescription(name)) + tasks.append(PregelTask(task_id, name)) return tasks @@ -453,9 +478,7 @@ def _proc_input( managed_values = {} for key, chan in proc.channels.items(): if is_managed_value(chan): - managed_values[key] = managed[key]( - step, PregelTaskDescription(name) - ) + managed_values[key] = managed[key](step) val.update(managed_values) except EmptyChannelError: diff --git a/libs/langgraph/langgraph/pregel/debug.py b/libs/langgraph/langgraph/pregel/debug.py index 9e152cf87..5102cd8c5 100644 --- a/libs/langgraph/langgraph/pregel/debug.py +++ b/libs/langgraph/langgraph/pregel/debug.py @@ -9,10 +9,10 @@ from langchain_core.runnables.config import RunnableConfig from langchain_core.utils.input import get_bolded_text, get_colored_text from langgraph.channels.base import BaseChannel -from langgraph.checkpoint.base import CheckpointMetadata -from langgraph.constants import TAG_HIDDEN +from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata, PendingWrite +from langgraph.constants import ERROR, INTERRUPT, TAG_HIDDEN from langgraph.pregel.io import read_channels -from langgraph.pregel.types import PregelExecutableTask +from langgraph.pregel.types import PregelExecutableTask, PregelTask class TaskPayload(TypedDict): @@ -28,10 +28,19 @@ class TaskResultPayload(TypedDict): result: list[tuple[str, Any]] +class CheckpointTask(TypedDict): + id: str + name: str + error: Optional[str] + interrupts: list[dict] + + class CheckpointPayload(TypedDict): config: Optional[RunnableConfig] metadata: CheckpointMetadata values: dict[str, Any] + next: list[str] + tasks: list[CheckpointTask] class DebugOutputBase(TypedDict): @@ -118,16 +127,33 @@ def map_debug_checkpoint( channels: Mapping[str, BaseChannel], stream_channels: Union[str, Sequence[str]], metadata: CheckpointMetadata, + checkpoint: Checkpoint, + tasks: list[PregelExecutableTask], + pending_writes: list[PendingWrite], ) -> Iterator[DebugOutputCheckpoint]: - ts = datetime.now(timezone.utc).isoformat() yield { "type": "checkpoint", - "timestamp": ts, + "timestamp": checkpoint["ts"], "step": step, "payload": { "config": config, "values": read_channels(channels, stream_channels), "metadata": metadata, + "next": [t.name for t in tasks], + "tasks": [ + { + "id": t.id, + "name": t.name, + "error": t.error, + } + if t.error + else { + "id": t.id, + "name": t.name, + "interrupts": t.interrupts, + } + for t in tasks_w_writes(tasks, pending_writes) + ], }, } @@ -166,10 +192,38 @@ def print_step_writes( def print_step_checkpoint( - step: int, channels: Mapping[str, BaseChannel], whitelist: Sequence[str] + metadata: CheckpointMetadata, + channels: Mapping[str, BaseChannel], + whitelist: Sequence[str], ) -> None: + step = metadata["step"] print( f"{get_colored_text(f'[{step}:checkpoint]', color='blue')} " + get_bolded_text(f"State at the end of step {step}:\n") + pformat(read_channels(channels, whitelist), depth=3) ) + + +def tasks_w_writes( + tasks: list[PregelExecutableTask], + pending_writes: Optional[list[PendingWrite]], +) -> tuple[PregelTask, ...]: + pending_writes = pending_writes or [] + return tuple( + PregelTask( + task.id, + task.name, + next( + ( + exc + for tid, n, exc in pending_writes + if tid == task.id and n == ERROR + ), + None, + ), + tuple( + v for tid, n, v in pending_writes if tid == task.id and n == INTERRUPT + ), + ) + for task in tasks + ) diff --git a/libs/langgraph/langgraph/pregel/loop.py b/libs/langgraph/langgraph/pregel/loop.py index 0508cd942..f48f86fcd 100644 --- a/libs/langgraph/langgraph/pregel/loop.py +++ b/libs/langgraph/langgraph/pregel/loop.py @@ -39,7 +39,14 @@ from langgraph.checkpoint.base import ( create_checkpoint, empty_checkpoint, ) -from langgraph.constants import CONFIG_KEY_READ, CONFIG_KEY_RESUMING, INPUT, INTERRUPT +from langgraph.constants import ( + CONFIG_KEY_READ, + CONFIG_KEY_RESUMING, + ERROR, + INPUT, + INTERRUPT, + Interrupt, +) from langgraph.errors import EmptyInputError, GraphInterrupt from langgraph.managed.base import ( AsyncManagedValuesManager, @@ -200,10 +207,13 @@ class PregelLoop: } ) # after execution, check if we should interrupt - if should_interrupt(self.checkpoint, interrupt_after, self.tasks): + if tasks := should_interrupt(self.checkpoint, interrupt_after, self.tasks): self.status = "interrupt_after" + interrupts = [(t.id, Interrupt("after")) for t in tasks] + for tid, interrupt in interrupts: + self.put_writes(tid, [(INTERRUPT, interrupt)]) if self.is_nested: - raise GraphInterrupt(self) + raise GraphInterrupt([i[1] for i in interrupts]) else: return False else: @@ -228,6 +238,22 @@ class PregelLoop: is_resuming=self.input is INPUT_RESUMING, ) + # produce debug output + if self._checkpointer_put_after_previous is not None: + self.stream.extend( + ("debug", v) + for v in map_debug_checkpoint( + self.step - 1, # printing checkpoint for previous step + self.checkpoint_config, + self.channels, + self.graph.stream_channels_asis, + self.checkpoint_metadata, + self.checkpoint, + self.tasks, + self.checkpoint_pending_writes, + ) + ) + # if no more tasks, we're done if not self.tasks: self.status = "done" @@ -236,6 +262,8 @@ class PregelLoop: # if there are pending writes from a previous loop, apply them if self.checkpoint_pending_writes: for tid, k, v in self.checkpoint_pending_writes: + if k in (ERROR, INTERRUPT): + continue if task := next((t for t in self.tasks if t.id == tid), None): task.writes.append((k, v)) @@ -249,10 +277,13 @@ class PregelLoop: ) # before execution, check if we should interrupt - if should_interrupt(self.checkpoint, interrupt_before, self.tasks): + if tasks := should_interrupt(self.checkpoint, interrupt_before, self.tasks): self.status = "interrupt_before" + interrupts = [(t.id, Interrupt("before")) for t in tasks] + for tid, interrupt in interrupts: + self.put_writes(tid, [(INTERRUPT, interrupt)]) if self.is_nested: - raise GraphInterrupt() + raise GraphInterrupt([i[1] for i in interrupts]) else: return False @@ -361,17 +392,6 @@ class PregelLoop: "checkpoint_id": self.checkpoint["id"], }, } - # produce debug output - self.stream.extend( - ("debug", v) - for v in map_debug_checkpoint( - self.step, - self.checkpoint_config, - self.channels, - self.graph.stream_channels_asis, - self.checkpoint_metadata, - ) - ) # increment step self.step += 1 @@ -381,7 +401,7 @@ class PregelLoop: exc_value: Optional[BaseException], traceback: Optional[TracebackType], ) -> Optional[bool]: - if exc_type is GraphInterrupt and not self.is_nested: + if isinstance(exc_value, GraphInterrupt) and not self.is_nested: return True @@ -396,7 +416,6 @@ class SyncPregelLoop(PregelLoop, ContextManager): ) -> None: super().__init__(input, config=config, checkpointer=checkpointer, graph=graph) self.stack = ExitStack() - self.stack.push(self._suppress_interrupt) if checkpointer: self.checkpointer_get_next_version = checkpointer.get_next_version self.checkpointer_put_writes = checkpointer.put_writes @@ -444,6 +463,7 @@ class SyncPregelLoop(PregelLoop, ContextManager): self.managed = self.stack.enter_context( ManagedValuesManager(self.graph.managed_values_dict, self.config) ) + self.stack.push(self._suppress_interrupt) self.status = "pending" self.step = self.checkpoint_metadata["step"] + 1 self.stop = self.step + self.config["recursion_limit"] + 1 @@ -473,7 +493,6 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager): ) -> None: super().__init__(input, config=config, checkpointer=checkpointer, graph=graph) self.stack = AsyncExitStack() - self.stack.push(self._suppress_interrupt) if checkpointer: self.checkpointer_get_next_version = checkpointer.get_next_version self.checkpointer_put_writes = checkpointer.aput_writes @@ -523,6 +542,7 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager): self.managed = await self.stack.enter_async_context( AsyncManagedValuesManager(self.graph.managed_values_dict, self.config) ) + self.stack.push(self._suppress_interrupt) self.status = "pending" self.step = self.checkpoint_metadata["step"] + 1 self.stop = self.step + self.config["recursion_limit"] + 1 diff --git a/libs/langgraph/langgraph/pregel/retry.py b/libs/langgraph/langgraph/pregel/retry.py index b8da098c4..88940b584 100644 --- a/libs/langgraph/langgraph/pregel/retry.py +++ b/libs/langgraph/langgraph/pregel/retry.py @@ -49,7 +49,8 @@ def run_with_retry( ) # log the retry logger.info( - f"Retrying task {task.name} after {interval:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}" + f"Retrying task {task.name} after {interval:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}", + exc_info=exc, ) @@ -98,5 +99,6 @@ async def arun_with_retry( ) # log the retry logger.info( - f"Retrying task {task.name} after {interval:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}" + f"Retrying task {task.name} after {interval:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}", + exc_info=exc, ) diff --git a/libs/langgraph/langgraph/pregel/types.py b/libs/langgraph/langgraph/pregel/types.py index f3d25f0b1..a8d66cb20 100644 --- a/libs/langgraph/langgraph/pregel/types.py +++ b/libs/langgraph/langgraph/pregel/types.py @@ -4,6 +4,7 @@ from typing import Any, Callable, Literal, NamedTuple, Optional, Type, Union from langchain_core.runnables import Runnable, RunnableConfig from langgraph.checkpoint.base import CheckpointMetadata +from langgraph.constants import Interrupt def default_retry_on(exc: Exception) -> bool: @@ -56,8 +57,11 @@ class RetryPolicy(NamedTuple): """List of exception classes that should trigger a retry, or a callable that returns True for exceptions that should trigger a retry.""" -class PregelTaskDescription(NamedTuple): +class PregelTask(NamedTuple): + id: str name: str + error: Optional[Exception] = None + interrupts: tuple[Interrupt, ...] = () class PregelExecutableTask(NamedTuple): @@ -72,18 +76,22 @@ class PregelExecutableTask(NamedTuple): class StateSnapshot(NamedTuple): + """Snapshot of the state of the graph at the beginning of a step.""" + values: Union[dict[str, Any], Any] """Current values of channels""" - next: tuple[str] - """Nodes to execute in the next step, if any""" + next: tuple[str, ...] + """The name of the node to execute in each task for this step.""" config: RunnableConfig """Config used to fetch this snapshot""" metadata: Optional[CheckpointMetadata] """Metadata associated with this snapshot""" created_at: Optional[str] """Timestamp of snapshot creation""" - parent_config: Optional[RunnableConfig] = None + parent_config: Optional[RunnableConfig] """Config used to fetch the parent snapshot, if any""" + tasks: tuple[PregelTask, ...] + """Tasks to execute in this step. If already attempted, may contain an error.""" subgraph_state_snapshots: Optional[dict[str, "StateSnapshot"]] = None """State snapshots of subgraphs represented as a mapping from thread ID suffix to snapshot.""" diff --git a/libs/langgraph/poetry.lock b/libs/langgraph/poetry.lock index aedca6476..aa0054283 100644 --- a/libs/langgraph/poetry.lock +++ b/libs/langgraph/poetry.lock @@ -1,91 +1,103 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "aiohappyeyeballs" +version = "2.3.5" +description = "Happy Eyeballs for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohappyeyeballs-2.3.5-py3-none-any.whl", hash = "sha256:4d6dea59215537dbc746e93e779caea8178c866856a721c9c660d7a5a7b8be03"}, + {file = "aiohappyeyeballs-2.3.5.tar.gz", hash = "sha256:6fa48b9f1317254f122a07a131a86b71ca6946ca989ce6326fff54a99a920105"}, +] + [[package]] name = "aiohttp" -version = "3.9.5" +version = "3.10.2" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.8" files = [ - {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcde4c397f673fdec23e6b05ebf8d4751314fa7c24f93334bf1f1364c1c69ac7"}, - {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d6b3f1fabe465e819aed2c421a6743d8debbde79b6a8600739300630a01bf2c"}, - {file = "aiohttp-3.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae79c1bc12c34082d92bf9422764f799aee4746fd7a392db46b7fd357d4a17a"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d3ebb9e1316ec74277d19c5f482f98cc65a73ccd5430540d6d11682cd857430"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84dabd95154f43a2ea80deffec9cb44d2e301e38a0c9d331cc4aa0166fe28ae3"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a02fbeca6f63cb1f0475c799679057fc9268b77075ab7cf3f1c600e81dd46b"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c26959ca7b75ff768e2776d8055bf9582a6267e24556bb7f7bd29e677932be72"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:714d4e5231fed4ba2762ed489b4aec07b2b9953cf4ee31e9871caac895a839c0"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7a6a8354f1b62e15d48e04350f13e726fa08b62c3d7b8401c0a1314f02e3558"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c413016880e03e69d166efb5a1a95d40f83d5a3a648d16486592c49ffb76d0db"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ff84aeb864e0fac81f676be9f4685f0527b660f1efdc40dcede3c251ef1e867f"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ad7f2919d7dac062f24d6f5fe95d401597fbb015a25771f85e692d043c9d7832"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:702e2c7c187c1a498a4e2b03155d52658fdd6fda882d3d7fbb891a5cf108bb10"}, - {file = "aiohttp-3.9.5-cp310-cp310-win32.whl", hash = "sha256:67c3119f5ddc7261d47163ed86d760ddf0e625cd6246b4ed852e82159617b5fb"}, - {file = "aiohttp-3.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:471f0ef53ccedec9995287f02caf0c068732f026455f07db3f01a46e49d76bbb"}, - {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ae53e33ee7476dd3d1132f932eeb39bf6125083820049d06edcdca4381f342"}, - {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c088c4d70d21f8ca5c0b8b5403fe84a7bc8e024161febdd4ef04575ef35d474d"}, - {file = "aiohttp-3.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:639d0042b7670222f33b0028de6b4e2fad6451462ce7df2af8aee37dcac55424"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26383adb94da5e7fb388d441bf09c61e5e35f455a3217bfd790c6b6bc64b2ee"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66331d00fb28dc90aa606d9a54304af76b335ae204d1836f65797d6fe27f1ca2"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ff550491f5492ab5ed3533e76b8567f4b37bd2995e780a1f46bca2024223233"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f22eb3a6c1080d862befa0a89c380b4dafce29dc6cd56083f630073d102eb595"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a81b1143d42b66ffc40a441379387076243ef7b51019204fd3ec36b9f69e77d6"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f64fd07515dad67f24b6ea4a66ae2876c01031de91c93075b8093f07c0a2d93d"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:93e22add827447d2e26d67c9ac0161756007f152fdc5210277d00a85f6c92323"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:55b39c8684a46e56ef8c8d24faf02de4a2b2ac60d26cee93bc595651ff545de9"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4715a9b778f4293b9f8ae7a0a7cef9829f02ff8d6277a39d7f40565c737d3771"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:afc52b8d969eff14e069a710057d15ab9ac17cd4b6753042c407dcea0e40bf75"}, - {file = "aiohttp-3.9.5-cp311-cp311-win32.whl", hash = "sha256:b3df71da99c98534be076196791adca8819761f0bf6e08e07fd7da25127150d6"}, - {file = "aiohttp-3.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:88e311d98cc0bf45b62fc46c66753a83445f5ab20038bcc1b8a1cc05666f428a"}, - {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c7a4b7a6cf5b6eb11e109a9755fd4fda7d57395f8c575e166d363b9fc3ec4678"}, - {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0a158704edf0abcac8ac371fbb54044f3270bdbc93e254a82b6c82be1ef08f3c"}, - {file = "aiohttp-3.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d153f652a687a8e95ad367a86a61e8d53d528b0530ef382ec5aaf533140ed00f"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82a6a97d9771cb48ae16979c3a3a9a18b600a8505b1115cfe354dfb2054468b4"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60cdbd56f4cad9f69c35eaac0fbbdf1f77b0ff9456cebd4902f3dd1cf096464c"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8676e8fd73141ded15ea586de0b7cda1542960a7b9ad89b2b06428e97125d4fa"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da00da442a0e31f1c69d26d224e1efd3a1ca5bcbf210978a2ca7426dfcae9f58"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18f634d540dd099c262e9f887c8bbacc959847cfe5da7a0e2e1cf3f14dbf2daf"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:320e8618eda64e19d11bdb3bd04ccc0a816c17eaecb7e4945d01deee2a22f95f"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:2faa61a904b83142747fc6a6d7ad8fccff898c849123030f8e75d5d967fd4a81"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:8c64a6dc3fe5db7b1b4d2b5cb84c4f677768bdc340611eca673afb7cf416ef5a"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:393c7aba2b55559ef7ab791c94b44f7482a07bf7640d17b341b79081f5e5cd1a"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c671dc117c2c21a1ca10c116cfcd6e3e44da7fcde37bf83b2be485ab377b25da"}, - {file = "aiohttp-3.9.5-cp312-cp312-win32.whl", hash = "sha256:5a7ee16aab26e76add4afc45e8f8206c95d1d75540f1039b84a03c3b3800dd59"}, - {file = "aiohttp-3.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:5ca51eadbd67045396bc92a4345d1790b7301c14d1848feaac1d6a6c9289e888"}, - {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:694d828b5c41255e54bc2dddb51a9f5150b4eefa9886e38b52605a05d96566e8"}, - {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0605cc2c0088fcaae79f01c913a38611ad09ba68ff482402d3410bf59039bfb8"}, - {file = "aiohttp-3.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4558e5012ee03d2638c681e156461d37b7a113fe13970d438d95d10173d25f78"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbc053ac75ccc63dc3a3cc547b98c7258ec35a215a92bd9f983e0aac95d3d5b"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4109adee842b90671f1b689901b948f347325045c15f46b39797ae1bf17019de"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6ea1a5b409a85477fd8e5ee6ad8f0e40bf2844c270955e09360418cfd09abac"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3c2890ca8c59ee683fd09adf32321a40fe1cf164e3387799efb2acebf090c11"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3916c8692dbd9d55c523374a3b8213e628424d19116ac4308e434dbf6d95bbdd"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8d1964eb7617907c792ca00b341b5ec3e01ae8c280825deadbbd678447b127e1"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d5ab8e1f6bee051a4bf6195e38a5c13e5e161cb7bad83d8854524798bd9fcd6e"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:52c27110f3862a1afbcb2af4281fc9fdc40327fa286c4625dfee247c3ba90156"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7f64cbd44443e80094309875d4f9c71d0401e966d191c3d469cde4642bc2e031"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b4f72fbb66279624bfe83fd5eb6aea0022dad8eec62b71e7bf63ee1caadeafe"}, - {file = "aiohttp-3.9.5-cp38-cp38-win32.whl", hash = "sha256:6380c039ec52866c06d69b5c7aad5478b24ed11696f0e72f6b807cfb261453da"}, - {file = "aiohttp-3.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:da22dab31d7180f8c3ac7c7635f3bcd53808f374f6aa333fe0b0b9e14b01f91a"}, - {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1732102949ff6087589408d76cd6dea656b93c896b011ecafff418c9661dc4ed"}, - {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c6021d296318cb6f9414b48e6a439a7f5d1f665464da507e8ff640848ee2a58a"}, - {file = "aiohttp-3.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:239f975589a944eeb1bad26b8b140a59a3a320067fb3cd10b75c3092405a1372"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b7b30258348082826d274504fbc7c849959f1989d86c29bc355107accec6cfb"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd2adf5c87ff6d8b277814a28a535b59e20bfea40a101db6b3bdca7e9926bc24"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9a3d838441bebcf5cf442700e3963f58b5c33f015341f9ea86dcd7d503c07e2"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e3a1ae66e3d0c17cf65c08968a5ee3180c5a95920ec2731f53343fac9bad106"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c69e77370cce2d6df5d12b4e12bdcca60c47ba13d1cbbc8645dd005a20b738b"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf56238f4bbf49dab8c2dc2e6b1b68502b1e88d335bea59b3f5b9f4c001475"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d1469f228cd9ffddd396d9948b8c9cd8022b6d1bf1e40c6f25b0fb90b4f893ed"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:45731330e754f5811c314901cebdf19dd776a44b31927fa4b4dbecab9e457b0c"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:3fcb4046d2904378e3aeea1df51f697b0467f2aac55d232c87ba162709478c46"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8cf142aa6c1a751fcb364158fd710b8a9be874b81889c2bd13aa8893197455e2"}, - {file = "aiohttp-3.9.5-cp39-cp39-win32.whl", hash = "sha256:7b179eea70833c8dee51ec42f3b4097bd6370892fa93f510f76762105568cf09"}, - {file = "aiohttp-3.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:38d80498e2e169bc61418ff36170e0aad0cd268da8b38a17c4cf29d254a8b3f1"}, - {file = "aiohttp-3.9.5.tar.gz", hash = "sha256:edea7d15772ceeb29db4aff55e482d4bcfb6ae160ce144f2682de02f6d693551"}, + {file = "aiohttp-3.10.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:95213b3d79c7e387144e9cb7b9d2809092d6ff2c044cb59033aedc612f38fb6d"}, + {file = "aiohttp-3.10.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1aa005f060aff7124cfadaa2493f00a4e28ed41b232add5869e129a2e395935a"}, + {file = "aiohttp-3.10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eabe6bf4c199687592f5de4ccd383945f485779c7ffb62a9b9f1f8a3f9756df8"}, + {file = "aiohttp-3.10.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96e010736fc16d21125c7e2dc5c350cd43c528b85085c04bf73a77be328fe944"}, + {file = "aiohttp-3.10.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99f81f9c1529fd8e03be4a7bd7df32d14b4f856e90ef6e9cbad3415dbfa9166c"}, + {file = "aiohttp-3.10.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d611d1a01c25277bcdea06879afbc11472e33ce842322496b211319aa95441bb"}, + {file = "aiohttp-3.10.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e00191d38156e09e8c81ef3d75c0d70d4f209b8381e71622165f22ef7da6f101"}, + {file = "aiohttp-3.10.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74c091a5ded6cb81785de2d7a8ab703731f26de910dbe0f3934eabef4ae417cc"}, + {file = "aiohttp-3.10.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:18186a80ec5a701816adbf1d779926e1069392cf18504528d6e52e14b5920525"}, + {file = "aiohttp-3.10.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5a7ceb2a0d2280f23a02c64cd0afdc922079bb950400c3dd13a1ab2988428aac"}, + {file = "aiohttp-3.10.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8bd7be6ff6c162a60cb8fce65ee879a684fbb63d5466aba3fa5b9288eb04aefa"}, + {file = "aiohttp-3.10.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fae962b62944eaebff4f4fddcf1a69de919e7b967136a318533d82d93c3c6bd1"}, + {file = "aiohttp-3.10.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a0fde16d284efcacbe15fb0c1013f0967b6c3e379649239d783868230bf1db42"}, + {file = "aiohttp-3.10.2-cp310-cp310-win32.whl", hash = "sha256:f81cd85a0e76ec7b8e2b6636fe02952d35befda4196b8c88f3cec5b4fb512839"}, + {file = "aiohttp-3.10.2-cp310-cp310-win_amd64.whl", hash = "sha256:54ba10eb5a3481c28282eb6afb5f709aedf53cf9c3a31875ffbdc9fc719ffd67"}, + {file = "aiohttp-3.10.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:87fab7f948e407444c2f57088286e00e2ed0003ceaf3d8f8cc0f60544ba61d91"}, + {file = "aiohttp-3.10.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ec6ad66ed660d46503243cbec7b2b3d8ddfa020f984209b3b8ef7d98ce69c3f2"}, + {file = "aiohttp-3.10.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a4be88807283bd96ae7b8e401abde4ca0bab597ba73b5e9a2d98f36d451e9aac"}, + {file = "aiohttp-3.10.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01c98041f90927c2cbd72c22a164bb816fa3010a047d264969cf82e1d4bcf8d1"}, + {file = "aiohttp-3.10.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54e36c67e1a9273ecafab18d6693da0fb5ac48fd48417e4548ac24a918c20998"}, + {file = "aiohttp-3.10.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7de3ddb6f424af54535424082a1b5d1ae8caf8256ebd445be68c31c662354720"}, + {file = "aiohttp-3.10.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7dd9c7db94b4692b827ce51dcee597d61a0e4f4661162424faf65106775b40e7"}, + {file = "aiohttp-3.10.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e57e21e1167705f8482ca29cc5d02702208d8bf4aff58f766d94bcd6ead838cd"}, + {file = "aiohttp-3.10.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a1a50e59b720060c29e2951fd9f13c01e1ea9492e5a527b92cfe04dd64453c16"}, + {file = "aiohttp-3.10.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:686c87782481fda5ee6ba572d912a5c26d9f98cc5c243ebd03f95222af3f1b0f"}, + {file = "aiohttp-3.10.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:dafb4abb257c0ed56dc36f4e928a7341b34b1379bd87e5a15ce5d883c2c90574"}, + {file = "aiohttp-3.10.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:494a6f77560e02bd7d1ab579fdf8192390567fc96a603f21370f6e63690b7f3d"}, + {file = "aiohttp-3.10.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6fe8503b1b917508cc68bf44dae28823ac05e9f091021e0c41f806ebbb23f92f"}, + {file = "aiohttp-3.10.2-cp311-cp311-win32.whl", hash = "sha256:4ddb43d06ce786221c0dfd3c91b4892c318eaa36b903f7c4278e7e2fa0dd5102"}, + {file = "aiohttp-3.10.2-cp311-cp311-win_amd64.whl", hash = "sha256:ca2f5abcb0a9a47e56bac173c01e9f6c6e7f27534d91451c5f22e6a35a5a2093"}, + {file = "aiohttp-3.10.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:14eb6b17f6246959fb0b035d4f4ae52caa870c4edfb6170aad14c0de5bfbf478"}, + {file = "aiohttp-3.10.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:465e445ec348d4e4bd349edd8b22db75f025da9d7b6dc1369c48e7935b85581e"}, + {file = "aiohttp-3.10.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:341f8ece0276a828d95b70cd265d20e257f5132b46bf77d759d7f4e0443f2906"}, + {file = "aiohttp-3.10.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c01fbb87b5426381cd9418b3ddcf4fc107e296fa2d3446c18ce6c76642f340a3"}, + {file = "aiohttp-3.10.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c474af073e1a6763e1c5522bbb2d85ff8318197e4c6c919b8d7886e16213345"}, + {file = "aiohttp-3.10.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d9076810a5621236e29b2204e67a68e1fe317c8727ee4c9abbfbb1083b442c38"}, + {file = "aiohttp-3.10.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8f515d6859e673940e08de3922b9c4a2249653b0ac181169313bd6e4b1978ac"}, + {file = "aiohttp-3.10.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:655e583afc639bef06f3b2446972c1726007a21003cd0ef57116a123e44601bc"}, + {file = "aiohttp-3.10.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8da9449a575133828cc99985536552ea2dcd690e848f9d41b48d8853a149a959"}, + {file = "aiohttp-3.10.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:19073d57d0feb1865d12361e2a1f5a49cb764bf81a4024a3b608ab521568093a"}, + {file = "aiohttp-3.10.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8e98e1845805f184d91fda6f9ab93d7c7b0dddf1c07e0255924bfdb151a8d05"}, + {file = "aiohttp-3.10.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:377220a5efde6f9497c5b74649b8c261d3cce8a84cb661be2ed8099a2196400a"}, + {file = "aiohttp-3.10.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:92f7f4a4dc9cdb5980973a74d43cdbb16286dacf8d1896b6c3023b8ba8436f8e"}, + {file = "aiohttp-3.10.2-cp312-cp312-win32.whl", hash = "sha256:9bb2834a6f11d65374ce97d366d6311a9155ef92c4f0cee543b2155d06dc921f"}, + {file = "aiohttp-3.10.2-cp312-cp312-win_amd64.whl", hash = "sha256:518dc3cb37365255708283d1c1c54485bbacccd84f0a0fb87ed8917ba45eda5b"}, + {file = "aiohttp-3.10.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:7f98e70bbbf693086efe4b86d381efad8edac040b8ad02821453083d15ec315f"}, + {file = "aiohttp-3.10.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9f6f0b252a009e98fe84028a4ec48396a948e7a65b8be06ccfc6ef68cf1f614d"}, + {file = "aiohttp-3.10.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9360e3ffc7b23565600e729e8c639c3c50d5520e05fdf94aa2bd859eef12c407"}, + {file = "aiohttp-3.10.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3988044d1635c7821dd44f0edfbe47e9875427464e59d548aece447f8c22800a"}, + {file = "aiohttp-3.10.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30a9d59da1543a6f1478c3436fd49ec59be3868bca561a33778b4391005e499d"}, + {file = "aiohttp-3.10.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9f49bdb94809ac56e09a310a62f33e5f22973d6fd351aac72a39cd551e98194"}, + {file = "aiohttp-3.10.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ddfd2dca3f11c365d6857a07e7d12985afc59798458a2fdb2ffa4a0332a3fd43"}, + {file = "aiohttp-3.10.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:685c1508ec97b2cd3e120bfe309a4ff8e852e8a7460f1ef1de00c2c0ed01e33c"}, + {file = "aiohttp-3.10.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:49904f38667c44c041a0b44c474b3ae36948d16a0398a8f8cd84e2bb3c42a069"}, + {file = "aiohttp-3.10.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:352f3a4e5f11f3241a49b6a48bc5b935fabc35d1165fa0d87f3ca99c1fcca98b"}, + {file = "aiohttp-3.10.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:fc61f39b534c5d5903490478a0dd349df397d2284a939aa3cbaa2fb7a19b8397"}, + {file = "aiohttp-3.10.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:ad2274e707be37420d0b6c3d26a8115295fe9d8e6e530fa6a42487a8ca3ad052"}, + {file = "aiohttp-3.10.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c836bf3c7512100219fe1123743fd8dd9a2b50dd7cfb0c3bb10d041309acab4b"}, + {file = "aiohttp-3.10.2-cp38-cp38-win32.whl", hash = "sha256:53e8898adda402be03ff164b0878abe2d884e3ea03a4701e6ad55399d84b92dc"}, + {file = "aiohttp-3.10.2-cp38-cp38-win_amd64.whl", hash = "sha256:7cc8f65f5b22304693de05a245b6736b14cb5bc9c8a03da6e2ae9ef15f8b458f"}, + {file = "aiohttp-3.10.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9dfc906d656e14004c5bc672399c1cccc10db38df2b62a13fb2b6e165a81c316"}, + {file = "aiohttp-3.10.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:91b10208b222ddf655c3a3d5b727879d7163db12b634492df41a9182a76edaae"}, + {file = "aiohttp-3.10.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9fd16b5e1a7bdd14668cd6bde60a2a29b49147a535c74f50d8177d11b38433a7"}, + {file = "aiohttp-3.10.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2bfdda4971bd79201f59adbad24ec2728875237e1c83bba5221284dbbf57bda"}, + {file = "aiohttp-3.10.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69d73f869cf29e8a373127fc378014e2b17bcfbe8d89134bc6fb06a2f67f3cb3"}, + {file = "aiohttp-3.10.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df59f8486507c421c0620a2c3dce81fbf1d54018dc20ff4fecdb2c106d6e6abc"}, + {file = "aiohttp-3.10.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0df930015db36b460aa9badbf35eccbc383f00d52d4b6f3de2ccb57d064a6ade"}, + {file = "aiohttp-3.10.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:562b1153ab7f766ee6b8b357ec777a302770ad017cf18505d34f1c088fccc448"}, + {file = "aiohttp-3.10.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d984db6d855de58e0fde1ef908d48fe9a634cadb3cf715962722b4da1c40619d"}, + {file = "aiohttp-3.10.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:14dc3fcb0d877911d775d511eb617a486a8c48afca0a887276e63db04d3ee920"}, + {file = "aiohttp-3.10.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b52a27a5c97275e254704e1049f4b96a81e67d6205f52fa37a4777d55b0e98ef"}, + {file = "aiohttp-3.10.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:cd33d9de8cfd006a0d0fe85f49b4183c57e91d18ffb7e9004ce855e81928f704"}, + {file = "aiohttp-3.10.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1238fc979160bc03a92fff9ad021375ff1c8799c6aacb0d8ea1b357ea40932bb"}, + {file = "aiohttp-3.10.2-cp39-cp39-win32.whl", hash = "sha256:e2f43d238eae4f0b04f58d4c0df4615697d4ca3e9f9b1963d49555a94f0f5a04"}, + {file = "aiohttp-3.10.2-cp39-cp39-win_amd64.whl", hash = "sha256:947847f07a8f81d7b39b2d0202fd73e61962ebe17ac2d8566f260679e467da7b"}, + {file = "aiohttp-3.10.2.tar.gz", hash = "sha256:4d1f694b5d6e459352e5e925a42e05bac66655bfde44d81c59992463d2897014"}, ] [package.dependencies] +aiohappyeyeballs = ">=2.3.0" aiosignal = ">=1.1.2" async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} attrs = ">=17.3.0" @@ -94,7 +106,7 @@ multidict = ">=4.5,<7.0" yarl = ">=1.0,<2.0" [package.extras] -speedups = ["Brotli", "aiodns", "brotlicffi"] +speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] [[package]] name = "aiosignal" diff --git a/libs/langgraph/pyproject.toml b/libs/langgraph/pyproject.toml index 9701f5933..4dff54572 100644 --- a/libs/langgraph/pyproject.toml +++ b/libs/langgraph/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langgraph" -version = "0.2.3" +version = "0.2.6" description = "Building stateful, multi-actor applications with LLMs" authors = [] license = "MIT" diff --git a/libs/langgraph/tests/__snapshots__/test_pregel.ambr b/libs/langgraph/tests/__snapshots__/test_pregel.ambr index 004daa1e1..c92bc4688 100644 --- a/libs/langgraph/tests/__snapshots__/test_pregel.ambr +++ b/libs/langgraph/tests/__snapshots__/test_pregel.ambr @@ -682,6 +682,24 @@ ''' # --- +# name: test_dynamic_interrupt + ''' + %%{init: {'flowchart': {'curve': 'linear'}}}%% + graph TD; + __start__([__start__]):::first + tool_two_slow(tool_two_slow) + tool_two_fast(tool_two_fast) + __end__([__end__]):::last + __start__ -.-> tool_two_slow; + tool_two_slow --> __end__; + __start__ -.-> tool_two_fast; + tool_two_fast --> __end__; + classDef default fill:#f2f0ff,line-height:1.2 + classDef first fill-opacity:0 + classDef last fill:#bfb6fc + + ''' +# --- # name: test_in_one_fan_out_state_graph_waiting_edge ''' graph TD; @@ -1356,6 +1374,33 @@ # name: test_state_graph_w_config_inherited_state_keys.2 '{"title": "LangGraphOutput", "type": "object", "properties": {"input": {"title": "Input", "type": "string"}, "agent_outcome": {"title": "Agent Outcome", "anyOf": [{"$ref": "#/definitions/AgentAction"}, {"$ref": "#/definitions/AgentFinish"}]}, "intermediate_steps": {"title": "Intermediate Steps", "type": "array", "items": {"type": "array", "minItems": 2, "maxItems": 2, "items": [{"$ref": "#/definitions/AgentAction"}, {"type": "string"}]}}}, "definitions": {"AgentAction": {"title": "AgentAction", "description": "Represents a request to execute an action by an agent.\\n\\nThe action consists of the name of the tool to execute and the input to pass\\nto the tool. The log is used to pass along extra information about the action.", "type": "object", "properties": {"tool": {"title": "Tool", "type": "string"}, "tool_input": {"title": "Tool Input", "anyOf": [{"type": "string"}, {"type": "object"}]}, "log": {"title": "Log", "type": "string"}, "type": {"title": "Type", "default": "AgentAction", "enum": ["AgentAction"], "type": "string"}}, "required": ["tool", "tool_input", "log"]}, "AgentFinish": {"title": "AgentFinish", "description": "Final return value of an ActionAgent.\\n\\nAgents return an AgentFinish when they have reached a stopping condition.", "type": "object", "properties": {"return_values": {"title": "Return Values", "type": "object"}, "log": {"title": "Log", "type": "string"}, "type": {"title": "Type", "default": "AgentFinish", "enum": ["AgentFinish"], "type": "string"}}, "required": ["return_values", "log"]}}}' # --- +# name: test_xray_issue + ''' + %%{init: {'flowchart': {'curve': 'linear'}}}%% + graph TD; + __start__([__start__]):::first + p_one(p_one) + p_two___start__(__start__) + p_two_c_one(c_one) + p_two_c_two(c_two) + p_two___end__(__end__) + __end__([__end__]):::last + subgraph p_two + p_two___start__ --> p_two_c_one; + p_two_c_two --> p_two_c_one; + p_two_c_one -.  0  .-> p_two_c_two; + p_two_c_one -.  1  .-> p_two___end__; + end + __start__ --> p_one; + p_two___end__ --> p_one; + p_one -.  0  .-> p_two___start__; + p_one -.  1  .-> __end__; + classDef default fill:#f2f0ff,line-height:1.2 + classDef first fill-opacity:0 + classDef last fill:#bfb6fc + + ''' +# --- # name: test_xray_lance dict({ 'edges': list([ diff --git a/libs/langgraph/tests/any_str.py b/libs/langgraph/tests/any_str.py index 0cc419e8b..9aa030d3b 100644 --- a/libs/langgraph/tests/any_str.py +++ b/libs/langgraph/tests/any_str.py @@ -4,3 +4,21 @@ class AnyStr(str): def __eq__(self, other: object) -> bool: return isinstance(other, str) + + def __hash__(self) -> int: + return hash(str(self)) + + +class ExceptionLike: + def __init__(self, exc: Exception) -> None: + self.exc = exc + + def __eq__(self, value: object) -> bool: + return ( + isinstance(value, Exception) + and self.exc.__class__ == value.__class__ + and str(self.exc) == str(value) + ) + + def __hash__(self) -> int: + return hash((self.exc.__class__, str(self.exc))) diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 24bfca34d..8016f3911 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -5,6 +5,7 @@ import warnings from collections import Counter from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager +from random import randrange from typing import ( Annotated, Any, @@ -42,6 +43,7 @@ from langgraph.channels.last_value import LastValue from langgraph.channels.topic import Topic from langgraph.channels.untracked_value import UntrackedValue from langgraph.checkpoint.base import ( + BaseCheckpointSaver, Checkpoint, CheckpointMetadata, CheckpointTuple, @@ -50,8 +52,8 @@ from langgraph.checkpoint.memory import MemorySaver from langgraph.checkpoint.serde.base import SerializerProtocol from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer from langgraph.checkpoint.sqlite import SqliteSaver -from langgraph.constants import Send -from langgraph.errors import InvalidUpdateError +from langgraph.constants import ERROR, Interrupt, Send +from langgraph.errors import InvalidUpdateError, NodeInterrupt from langgraph.graph import END, Graph from langgraph.graph.graph import START from langgraph.graph.message import MessageGraph, add_messages @@ -67,7 +69,8 @@ from langgraph.pregel import ( StateSnapshot, ) from langgraph.pregel.retry import RetryPolicy -from tests.any_str import AnyStr +from langgraph.pregel.types import PregelTask +from tests.any_str import AnyStr, ExceptionLike from tests.memory_assert import ( MemorySaverAssertCheckpointMetadata, MemorySaverAssertImmutable, @@ -648,6 +651,7 @@ def test_invoke_two_processes_in_out_interrupt( assert history == [ StateSnapshot( values={"inbox": 4, "output": 5, "input": 3}, + tasks=(), next=(), config={ "configurable": { @@ -662,6 +666,7 @@ def test_invoke_two_processes_in_out_interrupt( ), StateSnapshot( values={"inbox": 4, "output": 4, "input": 3}, + tasks=(PregelTask(AnyStr(), "two"),), next=("two",), config={ "configurable": { @@ -676,6 +681,7 @@ def test_invoke_two_processes_in_out_interrupt( ), StateSnapshot( values={"inbox": 21, "output": 4, "input": 3}, + tasks=(PregelTask(AnyStr(), "one"),), next=("one",), config={ "configurable": { @@ -690,6 +696,7 @@ def test_invoke_two_processes_in_out_interrupt( ), StateSnapshot( values={"inbox": 21, "output": 4, "input": 20}, + tasks=(PregelTask(AnyStr(), "two"),), next=("two",), config={ "configurable": { @@ -704,6 +711,7 @@ def test_invoke_two_processes_in_out_interrupt( ), StateSnapshot( values={"inbox": 3, "output": 4, "input": 20}, + tasks=(PregelTask(AnyStr(), "one"),), next=("one",), config={ "configurable": { @@ -718,6 +726,7 @@ def test_invoke_two_processes_in_out_interrupt( ), StateSnapshot( values={"inbox": 3, "output": 4, "input": 2}, + tasks=(), next=(), config={ "configurable": { @@ -732,6 +741,7 @@ def test_invoke_two_processes_in_out_interrupt( ), StateSnapshot( values={"inbox": 3, "input": 2}, + tasks=(PregelTask(AnyStr(), "two"),), next=("two",), config={ "configurable": { @@ -746,6 +756,7 @@ def test_invoke_two_processes_in_out_interrupt( ), StateSnapshot( values={"input": 2}, + tasks=(PregelTask(AnyStr(), "one"),), next=("one",), config={ "configurable": { @@ -818,6 +829,7 @@ def test_fork_always_re_runs_nodes( assert history == [ StateSnapshot( values=6, + tasks=(), next=(), config={ "configurable": { @@ -832,6 +844,7 @@ def test_fork_always_re_runs_nodes( ), StateSnapshot( values=5, + tasks=(PregelTask(AnyStr(), "add_one"),), next=("add_one",), config={ "configurable": { @@ -846,6 +859,7 @@ def test_fork_always_re_runs_nodes( ), StateSnapshot( values=4, + tasks=(PregelTask(AnyStr(), "add_one"),), next=("add_one",), config={ "configurable": { @@ -860,6 +874,7 @@ def test_fork_always_re_runs_nodes( ), StateSnapshot( values=3, + tasks=(PregelTask(AnyStr(), "add_one"),), next=("add_one",), config={ "configurable": { @@ -874,6 +889,7 @@ def test_fork_always_re_runs_nodes( ), StateSnapshot( values=2, + tasks=(PregelTask(AnyStr(), "add_one"),), next=("add_one",), config={ "configurable": { @@ -888,6 +904,7 @@ def test_fork_always_re_runs_nodes( ), StateSnapshot( values=1, + tasks=(PregelTask(AnyStr(), "add_one"),), next=("add_one",), config={ "configurable": { @@ -902,6 +919,7 @@ def test_fork_always_re_runs_nodes( ), StateSnapshot( values=0, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -1242,16 +1260,19 @@ def test_invoke_checkpoint(mocker: MockerFixture) -> None: # total is now 2, so output is 2+3=5 assert app.invoke(3, {"configurable": {"thread_id": "1"}}) == 5 assert errored_once, "errored and retried" - checkpoint = memory.get({"configurable": {"thread_id": "1"}}) - assert checkpoint is not None - assert checkpoint["channel_values"].get("total") == 7 + checkpoint_tup = memory.get_tuple({"configurable": {"thread_id": "1"}}) + assert checkpoint_tup is not None + assert checkpoint_tup.checkpoint["channel_values"].get("total") == 7 # total is now 2+5=7, so output would be 7+4=11, but raises ValueError with pytest.raises(ValueError): app.invoke(4, {"configurable": {"thread_id": "1"}}) - # checkpoint is not updated - checkpoint = memory.get({"configurable": {"thread_id": "1"}}) - assert checkpoint is not None - assert checkpoint["channel_values"].get("total") == 7 + # checkpoint is not updated, error is recorded + checkpoint_tup = memory.get_tuple({"configurable": {"thread_id": "1"}}) + assert checkpoint_tup is not None + assert checkpoint_tup.checkpoint["channel_values"].get("total") == 7 + assert checkpoint_tup.pending_writes == [ + (AnyStr(), ERROR, ExceptionLike(ValueError("Input is too large"))) + ] # on a new thread, total starts out as 0, so output is 0+5=5 assert app.invoke(5, {"configurable": {"thread_id": "2"}}) == 5 checkpoint = memory.get({"configurable": {"thread_id": "1"}}) @@ -1269,7 +1290,9 @@ def test_invoke_checkpoint(mocker: MockerFixture) -> None: def test_pending_writes_resume( request: pytest.FixtureRequest, checkpointer_name: str ) -> None: - checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}") + checkpointer: BaseCheckpointSaver = request.getfixturevalue( + f"checkpointer_{checkpointer_name}" + ) class State(TypedDict): value: Annotated[int, operator.add] @@ -1313,16 +1336,28 @@ def test_pending_writes_resume( assert state is not None assert state.values == {"value": 1} assert state.next == ("one", "two") + assert state.tasks == ( + PregelTask(AnyStr(), "one"), + PregelTask(AnyStr(), "two", ExceptionLike(ConnectionError("I'm not good"))), + ) assert state.metadata == {"source": "loop", "step": 0, "writes": None} # should contain pending write of "one" checkpoint = checkpointer.get_tuple(thread1) assert checkpoint is not None - assert checkpoint.pending_writes == [ + # should contain error from "two" + expected_writes = [ (AnyStr(), "one", "one"), (AnyStr(), "value", 2), + (AnyStr(), ERROR, ExceptionLike(ConnectionError("I'm not good"))), ] - # both pending writes come from same task - assert checkpoint.pending_writes[0][0] == checkpoint.pending_writes[1][0] + assert len(checkpoint.pending_writes) == 3 + assert all(w in expected_writes for w in checkpoint.pending_writes) + # both non-error pending writes come from same task + non_error_writes = [w for w in checkpoint.pending_writes if w[1] != ERROR] + assert non_error_writes[0][0] == non_error_writes[1][0] + # error write is from the other task + error_write = next(w for w in checkpoint.pending_writes if w[1] == ERROR) + assert error_write[0] != non_error_writes[0][0] # resume execution with pytest.raises(ConnectionError, match="I'm not good"): @@ -1335,7 +1370,7 @@ def test_pending_writes_resume( # confirm no new checkpoints saved state_two = graph.get_state(thread1) - assert state_two == state + assert state_two.metadata == state.metadata # resume execution, without exception two.rtn = {"value": 3} @@ -1954,6 +1989,7 @@ def test_conditional_graph(snapshot: SnapshotAssertion) -> None: ), }, }, + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], config=app_w_interrupt.checkpointer.get_tuple(config).config, @@ -2003,6 +2039,7 @@ def test_conditional_graph(snapshot: SnapshotAssertion) -> None: "input": "what is weather in sf", }, }, + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -2102,6 +2139,7 @@ def test_conditional_graph(snapshot: SnapshotAssertion) -> None: ), }, }, + tasks=(), next=(), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -2162,6 +2200,7 @@ def test_conditional_graph(snapshot: SnapshotAssertion) -> None: ), }, }, + tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -2205,6 +2244,7 @@ def test_conditional_graph(snapshot: SnapshotAssertion) -> None: "input": "what is weather in sf", }, }, + tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -2304,6 +2344,7 @@ def test_conditional_graph(snapshot: SnapshotAssertion) -> None: ), }, }, + tasks=(), next=(), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -2364,6 +2405,7 @@ def test_conditional_graph(snapshot: SnapshotAssertion) -> None: ), }, }, + tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -2798,6 +2840,7 @@ def test_conditional_state_graph( ), "intermediate_steps": [], }, + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -2838,6 +2881,7 @@ def test_conditional_state_graph( ), "intermediate_steps": [], }, + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -2912,6 +2956,7 @@ def test_conditional_state_graph( ) ], }, + tasks=(), next=(), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -2959,6 +3004,7 @@ def test_conditional_state_graph( ), "intermediate_steps": [], }, + tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -2998,6 +3044,7 @@ def test_conditional_state_graph( ), "intermediate_steps": [], }, + tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -3070,6 +3117,7 @@ def test_conditional_state_graph( ) ], }, + tasks=(), next=(), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -3105,6 +3153,7 @@ def test_conditional_state_graph( values={ "intermediate_steps": [], }, + tasks=(PregelTask(AnyStr(), "agent", interrupts=(Interrupt("before"),)),), next=("agent",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -3129,6 +3178,7 @@ def test_conditional_state_graph( ), "intermediate_steps": [], }, + tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -3181,6 +3231,7 @@ def test_conditional_state_graph( ) ], }, + tasks=(PregelTask(AnyStr(), "agent", interrupts=(Interrupt("before"),)),), next=("agent",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -3244,6 +3295,7 @@ def test_conditional_state_graph( ), "intermediate_steps": [], }, + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -3296,6 +3348,7 @@ def test_conditional_state_graph( ) ], }, + tasks=(PregelTask(AnyStr(), "agent"),), next=("agent",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -4059,6 +4112,7 @@ def test_state_graph_packets(serde: SerializerProtocol) -> None: ), ] }, + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=(app_w_interrupt.checkpointer.get_tuple(config)).config, created_at=(app_w_interrupt.checkpointer.get_tuple(config)).checkpoint["ts"], @@ -4109,6 +4163,7 @@ def test_state_graph_packets(serde: SerializerProtocol) -> None: ), ] }, + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=(app_w_interrupt.checkpointer.get_tuple(config)).checkpoint["ts"], @@ -4207,6 +4262,7 @@ def test_state_graph_packets(serde: SerializerProtocol) -> None: ), ] }, + tasks=(PregelTask(AnyStr(), "tools"), PregelTask(AnyStr(), "tools")), next=("tools", "tools"), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=(app_w_interrupt.checkpointer.get_tuple(config)).checkpoint["ts"], @@ -4270,6 +4326,7 @@ def test_state_graph_packets(serde: SerializerProtocol) -> None: AIMessage(content="answer", id="ai2"), ] }, + tasks=(), next=(), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=(app_w_interrupt.checkpointer.get_tuple(config)).checkpoint["ts"], @@ -4547,6 +4604,7 @@ def test_message_graph( id="ai1", ), ], + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -4591,6 +4649,7 @@ def test_message_graph( ], ), ], + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=next_config, created_at=AnyStr(), @@ -4672,6 +4731,7 @@ def test_message_graph( id="ai2", ), ], + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -4723,6 +4783,7 @@ def test_message_graph( ), AIMessage(content="answer", id="ai2"), ], + tasks=(), next=(), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -4772,6 +4833,13 @@ def test_message_graph( id="ai1", ), ], + tasks=( + PregelTask( + AnyStr(), + "tools", + interrupts=(Interrupt("before"),), + ), + ), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -4816,6 +4884,7 @@ def test_message_graph( ], ), ], + tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -4897,6 +4966,7 @@ def test_message_graph( id="ai2", ), ], + tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -4948,6 +5018,7 @@ def test_message_graph( ), AIMessage(content="answer", id="ai2"), ], + tasks=(), next=(), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -4987,6 +5058,7 @@ def test_message_graph( AIMessage(content="answer", id="ai2"), _AnyIdAIMessage(content="an extra message"), ], + tasks=(PregelTask(AnyStr(), "agent"),), next=("agent",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -5257,6 +5329,7 @@ def test_root_graph( id="ai1", ), ], + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -5301,6 +5374,7 @@ def test_root_graph( ], ), ], + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=next_config, created_at=AnyStr(), @@ -5382,6 +5456,7 @@ def test_root_graph( id="ai2", ), ], + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -5433,6 +5508,7 @@ def test_root_graph( ), AIMessage(content="answer", id="ai2"), ], + tasks=(), next=(), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -5482,6 +5558,13 @@ def test_root_graph( id="ai1", ), ], + tasks=( + PregelTask( + AnyStr(), + "tools", + interrupts=(Interrupt("before"),), + ), + ), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -5526,6 +5609,7 @@ def test_root_graph( ], ), ], + tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -5607,6 +5691,7 @@ def test_root_graph( id="ai2", ), ], + tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -5658,6 +5743,7 @@ def test_root_graph( ), AIMessage(content="answer", id="ai2"), ], + tasks=(), next=(), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -5697,6 +5783,7 @@ def test_root_graph( AIMessage(content="answer", id="ai2"), _AnyIdAIMessage(content="an extra message"), ], + tasks=(PregelTask(AnyStr(), "agent"),), next=("agent",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -5768,6 +5855,7 @@ def test_root_graph( _AnyIdAIMessage(content="an extra message"), ] }, + tasks=(PregelTask(AnyStr(), "agent"),), next=("agent",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -6049,6 +6137,72 @@ def test_in_one_fan_out_out_one_graph_state() -> None: ] +def test_dynamic_interrupt(snapshot: SnapshotAssertion) -> None: + class State(TypedDict): + my_key: Annotated[str, operator.add] + market: str + + def tool_two_node(s: State) -> State: + if s["market"] == "DE": + raise NodeInterrupt("Just because...") + return {"my_key": " all good"} + + tool_two_graph = StateGraph(State) + tool_two_graph.add_node("tool_two", tool_two_node) + tool_two_graph.add_edge(START, "tool_two") + tool_two = tool_two_graph.compile() + + assert tool_two.invoke({"my_key": "value", "market": "DE"}) == { + "my_key": "value", + "market": "DE", + } + assert tool_two.invoke({"my_key": "value", "market": "US"}) == { + "my_key": "value all good", + "market": "US", + } + + with SqliteSaver.from_conn_string(":memory:") as saver: + tool_two = tool_two_graph.compile(checkpointer=saver) + + # missing thread_id + with pytest.raises(ValueError, match="thread_id"): + tool_two.invoke({"my_key": "value", "market": "DE"}) + + thread1 = {"configurable": {"thread_id": "1"}} + # stop when about to enter node + assert tool_two.invoke({"my_key": "value ⛰️", "market": "DE"}, thread1) == { + "my_key": "value ⛰️", + "market": "DE", + } + assert [c.metadata for c in tool_two.checkpointer.list(thread1)] == [ + { + "source": "loop", + "step": 0, + "writes": None, + }, + { + "source": "input", + "step": -1, + "writes": {"my_key": "value ⛰️", "market": "DE"}, + }, + ] + assert tool_two.get_state(thread1) == StateSnapshot( + values={"my_key": "value ⛰️", "market": "DE"}, + next=("tool_two",), + tasks=( + PregelTask( + AnyStr(), + "tool_two", + interrupts=(Interrupt("during", "Just because..."),), + ), + ), + config=tool_two.checkpointer.get_tuple(thread1).config, + created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"], + metadata={"source": "loop", "step": 0, "writes": None}, + parent_config=[*tool_two.checkpointer.list(thread1, limit=2)][-1].config, + ) + + def test_start_branch_then(snapshot: SnapshotAssertion) -> None: class State(TypedDict): my_key: Annotated[str, operator.add] @@ -6101,6 +6255,13 @@ def test_start_branch_then(snapshot: SnapshotAssertion) -> None: ] assert tool_two.get_state(thread1) == StateSnapshot( values={"my_key": "value ⛰️", "market": "DE"}, + tasks=( + PregelTask( + AnyStr(), + "tool_two_slow", + interrupts=(Interrupt("before"),), + ), + ), next=("tool_two_slow",), config=tool_two.checkpointer.get_tuple(thread1).config, created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"], @@ -6114,6 +6275,7 @@ def test_start_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread1) == StateSnapshot( values={"my_key": "value ⛰️ slow", "market": "DE"}, + tasks=(), next=(), config=tool_two.checkpointer.get_tuple(thread1).config, created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"], @@ -6133,6 +6295,13 @@ def test_start_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread2) == StateSnapshot( values={"my_key": "value", "market": "US"}, + tasks=( + PregelTask( + AnyStr(), + "tool_two_fast", + interrupts=(Interrupt("before"),), + ), + ), next=("tool_two_fast",), config=tool_two.checkpointer.get_tuple(thread2).config, created_at=tool_two.checkpointer.get_tuple(thread2).checkpoint["ts"], @@ -6146,6 +6315,7 @@ def test_start_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread2) == StateSnapshot( values={"my_key": "value fast", "market": "US"}, + tasks=(), next=(), config=tool_two.checkpointer.get_tuple(thread2).config, created_at=tool_two.checkpointer.get_tuple(thread2).checkpoint["ts"], @@ -6165,6 +6335,13 @@ def test_start_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread3) == StateSnapshot( values={"my_key": "value", "market": "US"}, + tasks=( + PregelTask( + AnyStr(), + "tool_two_fast", + interrupts=(Interrupt("before"),), + ), + ), next=("tool_two_fast",), config=tool_two.checkpointer.get_tuple(thread3).config, created_at=tool_two.checkpointer.get_tuple(thread3).checkpoint["ts"], @@ -6175,6 +6352,13 @@ def test_start_branch_then(snapshot: SnapshotAssertion) -> None: tool_two.update_state(thread3, {"my_key": "key"}) # appends to my_key assert tool_two.get_state(thread3) == StateSnapshot( values={"my_key": "valuekey", "market": "US"}, + tasks=( + PregelTask( + AnyStr(), + "tool_two_fast", + interrupts=(Interrupt("before"),), + ), + ), next=("tool_two_fast",), config=tool_two.checkpointer.get_tuple(thread3).config, created_at=tool_two.checkpointer.get_tuple(thread3).checkpoint["ts"], @@ -6192,6 +6376,7 @@ def test_start_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread3) == StateSnapshot( values={"my_key": "valuekey fast", "market": "US"}, + tasks=(), next=(), config=tool_two.checkpointer.get_tuple(thread3).config, created_at=tool_two.checkpointer.get_tuple(thread3).checkpoint["ts"], @@ -6265,6 +6450,8 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: "step": -1, "writes": {"my_key": "value", "market": "DE"}, }, + "next": ["__start__"], + "tasks": [{"id": AnyStr(), "name": "__start__", "interrupts": ()}], }, }, { @@ -6292,6 +6479,8 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: "step": 0, "writes": None, }, + "next": ["prepare"], + "tasks": [{"id": AnyStr(), "name": "prepare", "interrupts": ()}], }, }, { @@ -6340,6 +6529,10 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: "step": 1, "writes": {"prepare": {"my_key": " prepared"}}, }, + "next": ["tool_two_slow"], + "tasks": [ + {"id": AnyStr(), "name": "tool_two_slow", "interrupts": ()} + ], }, }, { @@ -6388,6 +6581,8 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: "step": 2, "writes": {"tool_two_slow": {"my_key": " slow"}}, }, + "next": ["finish"], + "tasks": [{"id": AnyStr(), "name": "finish", "interrupts": ()}], }, }, { @@ -6436,6 +6631,8 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: "step": 3, "writes": {"finish": {"my_key": " finished"}}, }, + "next": [], + "tasks": [], }, }, ] @@ -6456,6 +6653,13 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread1) == StateSnapshot( values={"my_key": "value prepared", "market": "DE"}, + tasks=( + PregelTask( + AnyStr(), + "tool_two_slow", + interrupts=(Interrupt("before"),), + ), + ), next=("tool_two_slow",), config=tool_two.checkpointer.get_tuple(thread1).config, created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"], @@ -6473,6 +6677,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread1) == StateSnapshot( values={"my_key": "value prepared slow finished", "market": "DE"}, + tasks=(), next=(), config=tool_two.checkpointer.get_tuple(thread1).config, created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"], @@ -6492,6 +6697,13 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread2) == StateSnapshot( values={"my_key": "value prepared", "market": "US"}, + tasks=( + PregelTask( + AnyStr(), + "tool_two_fast", + interrupts=(Interrupt("before"),), + ), + ), next=("tool_two_fast",), config=tool_two.checkpointer.get_tuple(thread2).config, created_at=tool_two.checkpointer.get_tuple(thread2).checkpoint["ts"], @@ -6509,6 +6721,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread2) == StateSnapshot( values={"my_key": "value prepared fast finished", "market": "US"}, + tasks=(), next=(), config=tool_two.checkpointer.get_tuple(thread2).config, created_at=tool_two.checkpointer.get_tuple(thread2).checkpoint["ts"], @@ -6537,6 +6750,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: "my_key": "value prepared slow", "market": "DE", }, + tasks=(PregelTask(AnyStr(), "finish", interrupts=(Interrupt("before"),)),), next=("finish",), config=tool_two.checkpointer.get_tuple(thread1).config, created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"], @@ -6555,6 +6769,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: "my_key": "value prepared slower", "market": "DE", }, + tasks=(PregelTask(AnyStr(), "finish", interrupts=(Interrupt("before"),)),), next=("finish",), config=tool_two.checkpointer.get_tuple(thread1).config, created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"], @@ -6583,6 +6798,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread1) == StateSnapshot( values={"my_key": "value prepared", "market": "DE"}, + tasks=(PregelTask(AnyStr(), "tool_two_slow"),), next=("tool_two_slow",), config=tool_two.checkpointer.get_tuple(thread1).config, created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"], @@ -6600,6 +6816,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread1) == StateSnapshot( values={"my_key": "value prepared slow finished", "market": "DE"}, + tasks=(), next=(), config=tool_two.checkpointer.get_tuple(thread1).config, created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"], @@ -6619,6 +6836,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread2) == StateSnapshot( values={"my_key": "value prepared", "market": "US"}, + tasks=(PregelTask(AnyStr(), "tool_two_fast"),), next=("tool_two_fast",), config=tool_two.checkpointer.get_tuple(thread2).config, created_at=tool_two.checkpointer.get_tuple(thread2).checkpoint["ts"], @@ -6636,6 +6854,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread2) == StateSnapshot( values={"my_key": "value prepared fast finished", "market": "US"}, + tasks=(), next=(), config=tool_two.checkpointer.get_tuple(thread2).config, created_at=tool_two.checkpointer.get_tuple(thread2).checkpoint["ts"], @@ -6653,6 +6872,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: # check current state assert tool_two.get_state(thread3) == StateSnapshot( values={"my_key": "key", "market": "DE"}, + tasks=(PregelTask(AnyStr(), "prepare"),), next=("prepare",), config=uconfig, created_at=AnyStr(), @@ -6661,6 +6881,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: "step": 0, "writes": {START: {"my_key": "key", "market": "DE"}}, }, + parent_config=None, ) # run from this point assert tool_two.invoke(None, thread3) == { @@ -6670,6 +6891,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: # get state after first node assert tool_two.get_state(thread3) == StateSnapshot( values={"my_key": "key prepared", "market": "DE"}, + tasks=(PregelTask(AnyStr(), "tool_two_slow"),), next=("tool_two_slow",), config=tool_two.checkpointer.get_tuple(thread3).config, created_at=tool_two.checkpointer.get_tuple(thread3).checkpoint["ts"], @@ -6687,6 +6909,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread3) == StateSnapshot( values={"my_key": "key prepared slow finished", "market": "DE"}, + tasks=(), next=(), config=tool_two.checkpointer.get_tuple(thread3).config, created_at=tool_two.checkpointer.get_tuple(thread3).checkpoint["ts"], @@ -6803,6 +7026,7 @@ def test_in_one_fan_out_state_graph_waiting_edge(snapshot: SnapshotAssertion) -> "query": "analyzed: query: what is weather in sf", "docs": ["doc1", "doc2", "doc3", "doc4", "doc5"], }, + tasks=(PregelTask(AnyStr(), "qa", interrupts=(Interrupt("before"),)),), next=("qa",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -7756,12 +7980,13 @@ def test_nested_graph_interrupts( # test invoke w/ nested interrupt config = {"configurable": {"thread_id": "1"}} - assert app.invoke({"my_key": "my value"}, config, debug=True) == { + assert app.invoke({"my_key": "my value"}, config) == { "my_key": "hi my value", } assert list(app.get_state_history(config)) == [ StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -7789,6 +8014,7 @@ def test_nested_graph_interrupts( "my_key": "hi my value here", "my_other_key": "hi my value", }, + tasks=(PregelTask(AnyStr(), "inner_2"),), next=("inner_2",), config={ "configurable": { @@ -7821,6 +8047,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -7841,6 +8068,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -7864,6 +8092,7 @@ def test_nested_graph_interrupts( assert list(app.get_state_history(config)) == [ StateSnapshot( values={"my_key": "hi my value here and there and back again"}, + tasks=(), next=(), config={ "configurable": { @@ -7890,6 +8119,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value here and there"}, + tasks=(PregelTask(AnyStr(), "outer_2"),), next=("outer_2",), config={ "configurable": { @@ -7914,6 +8144,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -7942,6 +8173,7 @@ def test_nested_graph_interrupts( "my_other_key": "hi my value here", }, next=(), + tasks=(), config={ "configurable": { "thread_id": "1", @@ -7973,6 +8205,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -7993,6 +8226,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -8054,6 +8288,7 @@ def test_nested_graph_interrupts( assert list(app.get_state_history(config)) == [ StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -8078,6 +8313,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -8098,6 +8334,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -8120,6 +8357,7 @@ def test_nested_graph_interrupts( assert list(app.get_state_history(config)) == [ StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -8147,6 +8385,7 @@ def test_nested_graph_interrupts( "my_key": "hi my value here", "my_other_key": "hi my value", }, + tasks=(PregelTask(AnyStr(), "inner_2"),), next=("inner_2",), config={ "configurable": { @@ -8179,6 +8418,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -8199,6 +8439,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -8227,6 +8468,7 @@ def test_nested_graph_interrupts( assert list(app.get_state_history(config)) == [ StateSnapshot( values={"my_key": "hi my value here and there and back again"}, + tasks=(), next=(), config={ "configurable": { @@ -8253,6 +8495,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value here and there"}, + tasks=(PregelTask(AnyStr(), "outer_2"),), next=("outer_2",), config={ "configurable": { @@ -8277,6 +8520,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -8304,6 +8548,7 @@ def test_nested_graph_interrupts( "my_key": "hi my value here and there", "my_other_key": "hi my value here", }, + tasks=(), next=(), config={ "configurable": { @@ -8336,6 +8581,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -8356,6 +8602,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -8388,6 +8635,7 @@ def test_nested_graph_interrupts( assert list(app.get_state_history(config)) == [ StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -8415,6 +8663,7 @@ def test_nested_graph_interrupts( "my_key": "hi my value here", "my_other_key": "hi my value", }, + tasks=(PregelTask(AnyStr(), "inner_2"),), next=("inner_2",), config={ "configurable": { @@ -8447,6 +8696,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -8467,6 +8717,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -8493,6 +8744,7 @@ def test_nested_graph_interrupts( assert list(app.get_state_history(config)) == [ StateSnapshot( values={"my_key": "hi my value here and there"}, + tasks=(PregelTask(AnyStr(), "outer_2"),), next=("outer_2",), config={ "configurable": { @@ -8517,6 +8769,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -8544,6 +8797,7 @@ def test_nested_graph_interrupts( "my_key": "hi my value here and there", "my_other_key": "hi my value here", }, + tasks=(), next=(), config={ "configurable": { @@ -8576,6 +8830,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -8596,6 +8851,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -8621,6 +8877,7 @@ def test_nested_graph_interrupts( assert list(app.get_state_history(config)) == [ StateSnapshot( values={"my_key": "hi my value here and there and back again"}, + tasks=(), next=(), config={ "configurable": { @@ -8647,6 +8904,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value here and there"}, + tasks=(PregelTask(AnyStr(), "outer_2"),), next=("outer_2",), config={ "configurable": { @@ -8671,6 +8929,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -8698,6 +8957,7 @@ def test_nested_graph_interrupts( "my_key": "hi my value here and there", "my_other_key": "hi my value here", }, + tasks=(), next=(), config={ "configurable": { @@ -8730,6 +8990,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -8750,6 +9011,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -8778,6 +9040,7 @@ def test_nested_graph_interrupts( assert state_history == [ StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -8805,6 +9068,7 @@ def test_nested_graph_interrupts( "my_key": "hi my value here", "my_other_key": "hi my value", }, + tasks=(PregelTask(AnyStr(), "inner_2"),), next=("inner_2",), config={ "configurable": { @@ -8837,6 +9101,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -8857,6 +9122,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -8882,7 +9148,11 @@ def test_nested_graph_interrupts( ] assert child_state_history == [ StateSnapshot( - values={"my_key": "hi my value here", "my_other_key": "hi my value"}, + values={ + "my_key": "hi my value here", + "my_other_key": "hi my value", + }, + tasks=(PregelTask(AnyStr(), "inner_2"),), next=("inner_2",), config={ "configurable": { @@ -8931,6 +9201,7 @@ def test_nested_graph_interrupts( assert list(app.get_state_history(config)) == [ StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -8958,6 +9229,7 @@ def test_nested_graph_interrupts( "my_key": "hi my value here", "my_other_key": "hi my value", }, + tasks=(PregelTask(AnyStr(), "inner_2"),), next=("inner_2",), config={ "configurable": { @@ -8989,6 +9261,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -9016,6 +9289,7 @@ def test_nested_graph_interrupts( "my_key": "hi my value here", "my_other_key": "hi my value", }, + tasks=(PregelTask(AnyStr(), "inner_2"),), next=("inner_2",), config={ "configurable": { @@ -9047,6 +9321,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -9067,6 +9342,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -9092,6 +9368,7 @@ def test_nested_graph_interrupts( assert list(app.get_state_history(config)) == [ StateSnapshot( values={"my_key": "hi my value here and there and back again"}, + tasks=(), next=(), config={ "configurable": { @@ -9118,6 +9395,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value here and there"}, + tasks=(PregelTask(AnyStr(), "outer_2"),), next=("outer_2",), config={ "configurable": { @@ -9142,6 +9420,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -9169,6 +9448,7 @@ def test_nested_graph_interrupts( "my_key": "hi my value here", "my_other_key": "hi my value", }, + tasks=(PregelTask(AnyStr(), "inner_2"),), next=("inner_2",), config={ "configurable": { @@ -9201,6 +9481,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -9228,6 +9509,7 @@ def test_nested_graph_interrupts( "my_key": "hi my value here and there", "my_other_key": "hi my value here", }, + tasks=(), next=(), config={ "configurable": { @@ -9260,6 +9542,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -9280,6 +9563,7 @@ def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -9585,6 +9869,7 @@ def test_nested_graph_state( # test state w/ nested subgraph state (right after interrupt) assert app.get_state(config) == StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -9609,6 +9894,7 @@ def test_nested_graph_state( subgraph_state_snapshots={ "inner": StateSnapshot( values={"my_key": "hi my value here", "my_other_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner_2"),), next=("inner_2",), config={ "configurable": { @@ -9642,6 +9928,7 @@ def test_nested_graph_state( assert list(app.get_state_history(config)) == [ StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -9669,6 +9956,7 @@ def test_nested_graph_state( "my_key": "hi my value here", "my_other_key": "hi my value", }, + tasks=(PregelTask(AnyStr(), "inner_2"),), next=("inner_2",), config={ "configurable": { @@ -9701,6 +9989,7 @@ def test_nested_graph_state( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -9722,6 +10011,7 @@ def test_nested_graph_state( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -9744,6 +10034,7 @@ def test_nested_graph_state( # test state w/ nested subgraph state (after resuming from interrupt) assert app.get_state(config) == StateSnapshot( values={"my_key": "hi my value here and there and back again"}, + tasks=(), next=(), config={ "configurable": { @@ -9777,6 +10068,7 @@ def test_nested_graph_state( "my_key": "hi my value here and there", "my_other_key": "hi my value here", }, + tasks=(), next=(), config={ "configurable": { @@ -9816,6 +10108,7 @@ def test_nested_graph_state( }, ) == StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -9843,6 +10136,7 @@ def test_nested_graph_state( assert list(app.get_state_history(config)) == [ StateSnapshot( values={"my_key": "hi my value here and there and back again"}, + tasks=(), next=(), config={ "configurable": { @@ -9870,6 +10164,7 @@ def test_nested_graph_state( ), StateSnapshot( values={"my_key": "hi my value here and there"}, + tasks=(PregelTask(AnyStr(), "outer_2"),), next=("outer_2",), config={ "configurable": { @@ -9895,6 +10190,7 @@ def test_nested_graph_state( ), StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -9922,6 +10218,7 @@ def test_nested_graph_state( "my_key": "hi my value here and there", "my_other_key": "hi my value here", }, + tasks=(), next=(), config={ "configurable": { @@ -9954,6 +10251,7 @@ def test_nested_graph_state( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -9975,6 +10273,7 @@ def test_nested_graph_state( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -10058,6 +10357,7 @@ def test_doubly_nested_graph_state( app.invoke({"my_key": "my value"}, config, debug=True) assert app.get_state(config) == StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "child"),), next=("child",), config={ "configurable": { @@ -10082,6 +10382,7 @@ def test_doubly_nested_graph_state( subgraph_state_snapshots={ "child": StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "child_1"),), next=("child_1",), config={ "configurable": { @@ -10102,6 +10403,7 @@ def test_doubly_nested_graph_state( subgraph_state_snapshots={ "child_1": StateSnapshot( values={"my_key": "hi my value here"}, + tasks=(PregelTask(AnyStr(), "grandchild_2"),), next=("grandchild_2",), config={ "configurable": { @@ -10132,6 +10434,7 @@ def test_doubly_nested_graph_state( app.invoke(None, config, debug=True) assert app.get_state(config) == StateSnapshot( values={"my_key": "hi my value here and there and back again"}, + tasks=(), next=(), config={ "configurable": { @@ -10162,6 +10465,7 @@ def test_doubly_nested_graph_state( ) assert grandchild_snapshot == StateSnapshot( values={"my_key": "hi my value here and there"}, + tasks=(), next=(), config={ "configurable": { @@ -10191,6 +10495,7 @@ def test_doubly_nested_graph_state( ) assert child_snapshot == StateSnapshot( values={"my_key": "hi my value here and there"}, + tasks=(), next=(), config={ "configurable": { @@ -10224,6 +10529,7 @@ def test_doubly_nested_graph_state( }, ) == StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "child"),), next=("child",), config={ "configurable": { @@ -10315,6 +10621,10 @@ def test_send_to_nested_graphs( expected_snapshot = StateSnapshot( values={"subjects": ["cats", "dogs"], "jokes": []}, + tasks=( + PregelTask(AnyStr(), "generate_joke"), + PregelTask(AnyStr(), "generate_joke"), + ), next=("generate_joke", "generate_joke"), config={ "configurable": { @@ -10348,6 +10658,7 @@ def test_send_to_nested_graphs( "subjects": ["cats", "dogs"], "jokes": ["Joke about cats - hohoho", "Joke about dogs - hohoho"], }, + tasks=(), next=(), config={ "configurable": { @@ -10393,6 +10704,7 @@ def test_send_to_nested_graphs( "subjects": ["cats", "dogs"], "jokes": ["Joke about cats - hohoho", "Joke about dogs - hohoho"], }, + tasks=(), next=(), config={ "configurable": { @@ -10423,6 +10735,10 @@ def test_send_to_nested_graphs( ), StateSnapshot( values={"subjects": ["cats", "dogs"], "jokes": []}, + tasks=( + PregelTask(AnyStr(), "generate_joke"), + PregelTask(AnyStr(), "generate_joke"), + ), next=("generate_joke", "generate_joke"), config={ "configurable": { @@ -10444,6 +10760,7 @@ def test_send_to_nested_graphs( ), StateSnapshot( values={"jokes": []}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -10902,3 +11219,41 @@ def test_channel_values(request: pytest.FixtureRequest, checkpointer_name: str) ) app.invoke({"input": 1, "ephemeral": "meow"}, config) assert checkpointer.get(config)["channel_values"] == {"input": 1, "output": 1} + + +def test_xray_issue(snapshot: SnapshotAssertion) -> None: + class State(TypedDict): + messages: Annotated[list, add_messages] + + def node(name): + def _node(state: State): + return {"messages": [("human", f"entered {name} node")]} + + return _node + + parent = StateGraph(State) + child = StateGraph(State) + + child.add_node("c_one", node("c_one")) + child.add_node("c_two", node("c_two")) + + child.add_edge("__start__", "c_one") + child.add_edge("c_two", "c_one") + + child.add_conditional_edges( + "c_one", lambda x: str(randrange(0, 2)), {"0": "c_two", "1": "__end__"} + ) + + parent.add_node("p_one", node("p_one")) + parent.add_node("p_two", child.compile()) + + parent.add_edge("__start__", "p_one") + parent.add_edge("p_two", "p_one") + + parent.add_conditional_edges( + "p_one", lambda x: str(randrange(0, 2)), {"0": "p_two", "1": "__end__"} + ) + + app = parent.compile() + + assert app.get_graph(xray=True).draw_mermaid() == snapshot diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 7aae5308b..eb6a1abe8 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -39,6 +39,7 @@ from langgraph.channels.last_value import LastValue from langgraph.channels.topic import Topic from langgraph.channels.untracked_value import UntrackedValue from langgraph.checkpoint.base import ( + BaseCheckpointSaver, ChannelVersions, Checkpoint, CheckpointMetadata, @@ -46,8 +47,8 @@ from langgraph.checkpoint.base import ( ) from langgraph.checkpoint.memory import MemorySaver from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver -from langgraph.constants import Send -from langgraph.errors import InvalidUpdateError +from langgraph.constants import ERROR, Interrupt, Send +from langgraph.errors import InvalidUpdateError, NodeInterrupt from langgraph.graph import END, Graph, StateGraph from langgraph.graph.graph import START from langgraph.graph.message import MessageGraph, add_messages @@ -63,7 +64,8 @@ from langgraph.pregel import ( StateSnapshot, ) from langgraph.pregel.retry import RetryPolicy -from tests.any_str import AnyStr +from langgraph.pregel.types import PregelTask +from tests.any_str import AnyStr, ExceptionLike from tests.memory_assert import ( MemorySaverAssertCheckpointMetadata, MemorySaverAssertImmutable, @@ -207,6 +209,61 @@ async def test_node_cancellation_on_other_node_exception() -> None: assert inner_task_cancelled +@pytest.mark.parametrize( + "checkpointer_name", + ["memory", "sqlite_aio", "postgres_aio", "postgres_aio_pipe"], +) +async def test_node_not_cancelled_on_other_node_interrupted( + checkpointer_name: str, request: pytest.FixtureRequest +) -> None: + checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}") + + class State(TypedDict): + hello: str + + awhiles = 0 + inner_task_cancelled = False + + async def awhile(input: State) -> None: + nonlocal awhiles + + awhiles += 1 + try: + await asyncio.sleep(1) + return {"hello": "again"} + except asyncio.CancelledError: + nonlocal inner_task_cancelled + inner_task_cancelled = True + raise + + async def iambad(input: State) -> None: + if input["hello"] != "bye": + raise NodeInterrupt("I am bad") + + builder = StateGraph(State) + builder.add_node("agent", awhile) + builder.add_node("bad", iambad) + builder.set_conditional_entry_point(lambda _: ["agent", "bad"], then=END) + + graph = builder.compile(checkpointer=checkpointer) + thread = {"configurable": {"thread_id": "1"}} + + assert await graph.ainvoke({"hello": "world"}, thread) == {"hello": "world"} + + assert not inner_task_cancelled + assert awhiles == 1 + + assert await graph.ainvoke(None, thread, debug=True) is None + + assert not inner_task_cancelled + assert awhiles == 1 + + assert await graph.ainvoke({"hello": "bye"}, thread) == {"hello": "again"} + + assert not inner_task_cancelled + assert awhiles == 2 + + async def test_step_timeout_on_stream_hang() -> None: inner_task_cancelled = False @@ -737,6 +794,7 @@ async def test_invoke_two_processes_in_out_interrupt( assert history == [ StateSnapshot( values={"inbox": 4, "output": 5, "input": 3}, + tasks=(), next=(), config={ "configurable": { @@ -751,6 +809,7 @@ async def test_invoke_two_processes_in_out_interrupt( ), StateSnapshot( values={"inbox": 4, "output": 4, "input": 3}, + tasks=(PregelTask(AnyStr(), "two"),), next=("two",), config={ "configurable": { @@ -765,6 +824,7 @@ async def test_invoke_two_processes_in_out_interrupt( ), StateSnapshot( values={"inbox": 21, "output": 4, "input": 3}, + tasks=(PregelTask(AnyStr(), "one"),), next=("one",), config={ "configurable": { @@ -779,6 +839,7 @@ async def test_invoke_two_processes_in_out_interrupt( ), StateSnapshot( values={"inbox": 21, "output": 4, "input": 20}, + tasks=(PregelTask(AnyStr(), "two"),), next=("two",), config={ "configurable": { @@ -793,6 +854,7 @@ async def test_invoke_two_processes_in_out_interrupt( ), StateSnapshot( values={"inbox": 3, "output": 4, "input": 20}, + tasks=(PregelTask(AnyStr(), "one"),), next=("one",), config={ "configurable": { @@ -807,6 +869,7 @@ async def test_invoke_two_processes_in_out_interrupt( ), StateSnapshot( values={"inbox": 3, "output": 4, "input": 2}, + tasks=(), next=(), config={ "configurable": { @@ -821,6 +884,7 @@ async def test_invoke_two_processes_in_out_interrupt( ), StateSnapshot( values={"inbox": 3, "input": 2}, + tasks=(PregelTask(AnyStr(), "two"),), next=("two",), config={ "configurable": { @@ -835,6 +899,7 @@ async def test_invoke_two_processes_in_out_interrupt( ), StateSnapshot( values={"input": 2}, + tasks=(PregelTask(AnyStr(), "one"),), next=("one",), config={ "configurable": { @@ -918,6 +983,7 @@ async def test_fork_always_re_runs_nodes( StateSnapshot( values=6, next=(), + tasks=(), config={ "configurable": { "thread_id": "1", @@ -931,6 +997,7 @@ async def test_fork_always_re_runs_nodes( ), StateSnapshot( values=5, + tasks=(PregelTask(AnyStr(), "add_one"),), next=("add_one",), config={ "configurable": { @@ -945,6 +1012,7 @@ async def test_fork_always_re_runs_nodes( ), StateSnapshot( values=4, + tasks=(PregelTask(AnyStr(), "add_one"),), next=("add_one",), config={ "configurable": { @@ -959,6 +1027,7 @@ async def test_fork_always_re_runs_nodes( ), StateSnapshot( values=3, + tasks=(PregelTask(AnyStr(), "add_one"),), next=("add_one",), config={ "configurable": { @@ -973,6 +1042,7 @@ async def test_fork_always_re_runs_nodes( ), StateSnapshot( values=2, + tasks=(PregelTask(AnyStr(), "add_one"),), next=("add_one",), config={ "configurable": { @@ -987,6 +1057,7 @@ async def test_fork_always_re_runs_nodes( ), StateSnapshot( values=1, + tasks=(PregelTask(AnyStr(), "add_one"),), next=("add_one",), config={ "configurable": { @@ -1001,6 +1072,7 @@ async def test_fork_always_re_runs_nodes( ), StateSnapshot( values=0, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -1379,7 +1451,9 @@ async def test_invoke_checkpoint(mocker: MockerFixture) -> None: async def test_pending_writes_resume( request: pytest.FixtureRequest, checkpointer_name: str ) -> None: - checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}") + checkpointer: BaseCheckpointSaver = request.getfixturevalue( + f"checkpointer_{checkpointer_name}" + ) class State(TypedDict): value: Annotated[int, operator.add] @@ -1423,16 +1497,28 @@ async def test_pending_writes_resume( assert state is not None assert state.values == {"value": 1} assert state.next == ("one", "two") + assert state.tasks == ( + PregelTask(AnyStr(), "one"), + PregelTask(AnyStr(), "two", ExceptionLike(ValueError("I'm not good"))), + ) assert state.metadata == {"source": "loop", "step": 0, "writes": None} # should contain pending write of "one" checkpoint = await checkpointer.aget_tuple(thread1) assert checkpoint is not None - assert checkpoint.pending_writes == [ + # should contain error from "two" + expected_writes = [ (AnyStr(), "one", "one"), (AnyStr(), "value", 2), + (AnyStr(), ERROR, ExceptionLike(ValueError("I'm not good"))), ] - # both pending writes come from same task - assert checkpoint.pending_writes[0][0] == checkpoint.pending_writes[1][0] + assert len(checkpoint.pending_writes) == 3 + assert all(w in expected_writes for w in checkpoint.pending_writes) + # both non-error pending writes come from same task + non_error_writes = [w for w in checkpoint.pending_writes if w[1] != ERROR] + assert non_error_writes[0][0] == non_error_writes[1][0] + # error write is from the other task + error_write = next(w for w in checkpoint.pending_writes if w[1] == ERROR) + assert error_write[0] != non_error_writes[0][0] # resume execution with pytest.raises(ValueError, match="I'm not good"): @@ -1445,7 +1531,7 @@ async def test_pending_writes_resume( # confirm no new checkpoints saved state_two = await graph.aget_state(thread1) - assert state_two == state + assert state_two.metadata == state.metadata # resume execution, without exception two.rtn = {"value": 3} @@ -2120,6 +2206,7 @@ async def test_conditional_graph() -> None: ), }, }, + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -2167,6 +2254,7 @@ async def test_conditional_graph() -> None: "input": "what is weather in sf", }, }, + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -2270,6 +2358,7 @@ async def test_conditional_graph() -> None: ), }, }, + tasks=(), next=(), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -2337,6 +2426,13 @@ async def test_conditional_graph() -> None: ), }, }, + tasks=( + PregelTask( + AnyStr(), + "tools", + interrupts=(Interrupt("before"),), + ), + ), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -2384,6 +2480,13 @@ async def test_conditional_graph() -> None: "input": "what is weather in sf", }, }, + tasks=( + PregelTask( + AnyStr(), + "tools", + interrupts=(Interrupt("before"),), + ), + ), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -2487,6 +2590,7 @@ async def test_conditional_graph() -> None: ), }, }, + tasks=(), next=(), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -2554,6 +2658,13 @@ async def test_conditional_graph() -> None: ), }, }, + tasks=( + PregelTask( + AnyStr(), + "tools", + interrupts=(Interrupt("before"),), + ), + ), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -2941,6 +3052,7 @@ async def test_conditional_graph_state(mocker: MockerFixture) -> None: ), "intermediate_steps": [], }, + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -2985,6 +3097,7 @@ async def test_conditional_graph_state(mocker: MockerFixture) -> None: ), "intermediate_steps": [], }, + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -3063,6 +3176,7 @@ async def test_conditional_graph_state(mocker: MockerFixture) -> None: ) ], }, + tasks=(), next=(), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -3116,6 +3230,13 @@ async def test_conditional_graph_state(mocker: MockerFixture) -> None: ), "intermediate_steps": [], }, + tasks=( + PregelTask( + AnyStr(), + "tools", + interrupts=(Interrupt("before"),), + ), + ), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -3159,6 +3280,7 @@ async def test_conditional_graph_state(mocker: MockerFixture) -> None: ), "intermediate_steps": [], }, + tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -3235,6 +3357,7 @@ async def test_conditional_graph_state(mocker: MockerFixture) -> None: ) ], }, + tasks=(), next=(), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -3798,6 +3921,7 @@ async def test_state_graph_packets() -> None: ), ] }, + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -3850,6 +3974,7 @@ async def test_state_graph_packets() -> None: ), ] }, + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -3951,6 +4076,7 @@ async def test_state_graph_packets() -> None: ), ] }, + tasks=(PregelTask(AnyStr(), "tools"), PregelTask(AnyStr(), "tools")), next=("tools", "tools"), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -4015,6 +4141,7 @@ async def test_state_graph_packets() -> None: AIMessage(content="answer", id="ai2"), ] }, + tasks=(), next=(), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -4235,6 +4362,7 @@ async def test_message_graph() -> None: id="ai1", ), ], + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -4278,6 +4406,7 @@ async def test_message_graph() -> None: id="ai1", ), ], + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -4349,6 +4478,7 @@ async def test_message_graph() -> None: id="ai2", ), ], + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -4401,6 +4531,7 @@ async def test_message_graph() -> None: ), AIMessage(content="answer", id="ai2"), ], + tasks=(), next=(), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -4699,6 +4830,13 @@ async def test_start_branch_then() -> None: ] assert await tool_two.aget_state(thread1) == StateSnapshot( values={"my_key": "value", "market": "DE"}, + tasks=( + PregelTask( + AnyStr(), + "tool_two_slow", + interrupts=(Interrupt("before"),), + ), + ), next=("tool_two_slow",), config=(await tool_two.checkpointer.aget_tuple(thread1)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread1)).checkpoint[ @@ -4716,6 +4854,7 @@ async def test_start_branch_then() -> None: } assert await tool_two.aget_state(thread1) == StateSnapshot( values={"my_key": "value slow", "market": "DE"}, + tasks=(), next=(), config=(await tool_two.checkpointer.aget_tuple(thread1)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread1)).checkpoint[ @@ -4739,6 +4878,13 @@ async def test_start_branch_then() -> None: } assert await tool_two.aget_state(thread2) == StateSnapshot( values={"my_key": "value", "market": "US"}, + tasks=( + PregelTask( + AnyStr(), + "tool_two_fast", + interrupts=(Interrupt("before"),), + ), + ), next=("tool_two_fast",), config=(await tool_two.checkpointer.aget_tuple(thread2)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread2)).checkpoint[ @@ -4756,6 +4902,7 @@ async def test_start_branch_then() -> None: } assert await tool_two.aget_state(thread2) == StateSnapshot( values={"my_key": "value fast", "market": "US"}, + tasks=(), next=(), config=(await tool_two.checkpointer.aget_tuple(thread2)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread2)).checkpoint[ @@ -4779,6 +4926,13 @@ async def test_start_branch_then() -> None: } assert await tool_two.aget_state(thread3) == StateSnapshot( values={"my_key": "value", "market": "US"}, + tasks=( + PregelTask( + AnyStr(), + "tool_two_fast", + interrupts=(Interrupt("before"),), + ), + ), next=("tool_two_fast",), config=(await tool_two.checkpointer.aget_tuple(thread3)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread3)).checkpoint[ @@ -4793,6 +4947,13 @@ async def test_start_branch_then() -> None: await tool_two.aupdate_state(thread3, {"my_key": "key"}) # appends to my_key assert await tool_two.aget_state(thread3) == StateSnapshot( values={"my_key": "valuekey", "market": "US"}, + tasks=( + PregelTask( + AnyStr(), + "tool_two_fast", + interrupts=(Interrupt("before"),), + ), + ), next=("tool_two_fast",), config=(await tool_two.checkpointer.aget_tuple(thread3)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread3)).checkpoint[ @@ -4814,6 +4975,7 @@ async def test_start_branch_then() -> None: } assert await tool_two.aget_state(thread3) == StateSnapshot( values={"my_key": "valuekey fast", "market": "US"}, + tasks=(), next=(), config=(await tool_two.checkpointer.aget_tuple(thread3)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread3)).checkpoint[ @@ -4890,6 +5052,8 @@ async def test_branch_then() -> None: "step": -1, "writes": {"my_key": "value", "market": "DE"}, }, + "next": ["__start__"], + "tasks": [{"id": AnyStr(), "name": "__start__", "interrupts": ()}], }, }, { @@ -4917,6 +5081,8 @@ async def test_branch_then() -> None: "step": 0, "writes": None, }, + "next": ["prepare"], + "tasks": [{"id": AnyStr(), "name": "prepare", "interrupts": ()}], }, }, { @@ -4965,6 +5131,10 @@ async def test_branch_then() -> None: "step": 1, "writes": {"prepare": {"my_key": " prepared"}}, }, + "next": ["tool_two_slow"], + "tasks": [ + {"id": AnyStr(), "name": "tool_two_slow", "interrupts": ()} + ], }, }, { @@ -5013,6 +5183,8 @@ async def test_branch_then() -> None: "step": 2, "writes": {"tool_two_slow": {"my_key": " slow"}}, }, + "next": ["finish"], + "tasks": [{"id": AnyStr(), "name": "finish", "interrupts": ()}], }, }, { @@ -5061,6 +5233,8 @@ async def test_branch_then() -> None: "step": 3, "writes": {"finish": {"my_key": " finished"}}, }, + "next": [], + "tasks": [], }, }, ] @@ -5081,6 +5255,13 @@ async def test_branch_then() -> None: } assert await tool_two.aget_state(thread1) == StateSnapshot( values={"my_key": "value prepared", "market": "DE"}, + tasks=( + PregelTask( + AnyStr(), + "tool_two_slow", + interrupts=(Interrupt("before"),), + ), + ), next=("tool_two_slow",), config=(await tool_two.checkpointer.aget_tuple(thread1)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread1)).checkpoint[ @@ -5102,6 +5283,7 @@ async def test_branch_then() -> None: } assert await tool_two.aget_state(thread1) == StateSnapshot( values={"my_key": "value prepared slow finished", "market": "DE"}, + tasks=(), next=(), config=(await tool_two.checkpointer.aget_tuple(thread1)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread1)).checkpoint[ @@ -5125,6 +5307,13 @@ async def test_branch_then() -> None: } assert await tool_two.aget_state(thread2) == StateSnapshot( values={"my_key": "value prepared", "market": "US"}, + tasks=( + PregelTask( + AnyStr(), + "tool_two_fast", + interrupts=(Interrupt("before"),), + ), + ), next=("tool_two_fast",), config=(await tool_two.checkpointer.aget_tuple(thread2)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread2)).checkpoint[ @@ -5146,6 +5335,7 @@ async def test_branch_then() -> None: } assert await tool_two.aget_state(thread2) == StateSnapshot( values={"my_key": "value prepared fast finished", "market": "US"}, + tasks=(), next=(), config=(await tool_two.checkpointer.aget_tuple(thread2)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread2)).checkpoint[ @@ -5178,6 +5368,7 @@ async def test_branch_then() -> None: } assert await tool_two.aget_state(thread1) == StateSnapshot( values={"my_key": "value prepared", "market": "DE"}, + tasks=(PregelTask(AnyStr(), "tool_two_slow"),), next=("tool_two_slow",), config=(await tool_two.checkpointer.aget_tuple(thread1)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread1)).checkpoint[ @@ -5199,6 +5390,7 @@ async def test_branch_then() -> None: } assert await tool_two.aget_state(thread1) == StateSnapshot( values={"my_key": "value prepared slow finished", "market": "DE"}, + tasks=(), next=(), config=(await tool_two.checkpointer.aget_tuple(thread1)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread1)).checkpoint[ @@ -5222,6 +5414,7 @@ async def test_branch_then() -> None: } assert await tool_two.aget_state(thread2) == StateSnapshot( values={"my_key": "value prepared", "market": "US"}, + tasks=(PregelTask(AnyStr(), "tool_two_fast"),), next=("tool_two_fast",), config=(await tool_two.checkpointer.aget_tuple(thread2)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread2)).checkpoint[ @@ -5243,6 +5436,7 @@ async def test_branch_then() -> None: } assert await tool_two.aget_state(thread2) == StateSnapshot( values={"my_key": "value prepared fast finished", "market": "US"}, + tasks=(), next=(), config=(await tool_two.checkpointer.aget_tuple(thread2)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread2)).checkpoint[ @@ -5266,6 +5460,7 @@ async def test_branch_then() -> None: # check current state assert await tool_two.aget_state(thread3) == StateSnapshot( values={"my_key": "key", "market": "DE"}, + tasks=(PregelTask(AnyStr(), "prepare"),), next=("prepare",), config=uconfig, created_at=AnyStr(), @@ -5274,6 +5469,7 @@ async def test_branch_then() -> None: "step": 0, "writes": {START: {"my_key": "key", "market": "DE"}}, }, + parent_config=None, ) # run from this point assert await tool_two.ainvoke(None, thread3) == { @@ -5283,6 +5479,7 @@ async def test_branch_then() -> None: # get state after first node assert await tool_two.aget_state(thread3) == StateSnapshot( values={"my_key": "key prepared", "market": "DE"}, + tasks=(PregelTask(AnyStr(), "tool_two_slow"),), next=("tool_two_slow",), config=(await tool_two.checkpointer.aget_tuple(thread3)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread3)).checkpoint[ @@ -5302,6 +5499,7 @@ async def test_branch_then() -> None: } assert await tool_two.aget_state(thread3) == StateSnapshot( values={"my_key": "key prepared slow finished", "market": "DE"}, + tasks=(), next=(), config=(await tool_two.checkpointer.aget_tuple(thread3)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread3)).checkpoint[ @@ -5643,6 +5841,7 @@ async def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class( "answer": "doc1,doc2,doc3,doc4", "docs": ["doc1", "doc2", "doc3", "doc4"], }, + tasks=(), next=(), config={ "configurable": { @@ -6257,6 +6456,7 @@ async def test_nested_graph_interrupts( assert [s async for s in app.aget_state_history(config)] == [ StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -6316,6 +6516,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -6336,6 +6537,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -6359,6 +6561,7 @@ async def test_nested_graph_interrupts( assert [s async for s in app.aget_state_history(config)] == [ StateSnapshot( values={"my_key": "hi my value here and there and back again"}, + tasks=(), next=(), config={ "configurable": { @@ -6385,6 +6588,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value here and there"}, + tasks=(PregelTask(AnyStr(), "outer_2"),), next=("outer_2",), config={ "configurable": { @@ -6409,6 +6613,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -6468,6 +6673,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -6488,6 +6694,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -6554,6 +6761,7 @@ async def test_nested_graph_interrupts( assert [s async for s in app.aget_state_history(config)] == [ StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -6578,6 +6786,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -6598,6 +6807,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -6620,6 +6830,7 @@ async def test_nested_graph_interrupts( assert [s async for s in app.aget_state_history(config)] == [ StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -6679,6 +6890,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -6699,6 +6911,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -6727,6 +6940,7 @@ async def test_nested_graph_interrupts( assert [s async for s in app.aget_state_history(config)] == [ StateSnapshot( values={"my_key": "hi my value here and there and back again"}, + tasks=(), next=(), config={ "configurable": { @@ -6753,6 +6967,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value here and there"}, + tasks=(PregelTask(AnyStr(), "outer_2"),), next=("outer_2",), config={ "configurable": { @@ -6777,6 +6992,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -6836,6 +7052,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -6856,6 +7073,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -6891,6 +7109,7 @@ async def test_nested_graph_interrupts( assert [s async for s in app.aget_state_history(config)] == [ StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -6950,6 +7169,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -6970,6 +7190,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -6996,6 +7217,7 @@ async def test_nested_graph_interrupts( assert [s async for s in app.aget_state_history(config)] == [ StateSnapshot( values={"my_key": "hi my value here and there"}, + tasks=(PregelTask(AnyStr(), "outer_2"),), next=("outer_2",), config={ "configurable": { @@ -7020,6 +7242,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -7079,6 +7302,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -7099,6 +7323,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -7124,6 +7349,7 @@ async def test_nested_graph_interrupts( assert [s async for s in app.aget_state_history(config)] == [ StateSnapshot( values={"my_key": "hi my value here and there and back again"}, + tasks=(), next=(), config={ "configurable": { @@ -7150,6 +7376,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value here and there"}, + tasks=(PregelTask(AnyStr(), "outer_2"),), next=("outer_2",), config={ "configurable": { @@ -7174,6 +7401,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -7233,6 +7461,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -7253,6 +7482,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -7280,6 +7510,7 @@ async def test_nested_graph_interrupts( assert state_history == [ StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -7339,6 +7570,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -7359,6 +7591,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -7385,8 +7618,9 @@ async def test_nested_graph_interrupts( ] assert child_state_history == [ StateSnapshot( - values={"my_key": "hi my value here", "my_other_key": "hi my value"}, - next=("inner_2",), + values={"my_key": "hi my value here"}, + tasks=(), + next=(), config={ "configurable": { "thread_id": "6", @@ -7434,6 +7668,7 @@ async def test_nested_graph_interrupts( assert [s async for s in app.aget_state_history(config)] == [ StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -7492,6 +7727,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -7550,6 +7786,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -7570,6 +7807,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { @@ -7595,6 +7833,7 @@ async def test_nested_graph_interrupts( assert [s async for s in app.aget_state_history(config)] == [ StateSnapshot( values={"my_key": "hi my value here and there and back again"}, + tasks=(), next=(), config={ "configurable": { @@ -7621,6 +7860,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value here and there"}, + tasks=(PregelTask(AnyStr(), "outer_2"),), next=("outer_2",), config={ "configurable": { @@ -7645,6 +7885,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -7704,6 +7945,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "hi my value"}, + tasks=(PregelTask(AnyStr(), "inner"),), next=("inner",), config={ "configurable": { @@ -7763,6 +8005,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={"my_key": "my value"}, + tasks=(PregelTask(AnyStr(), "outer_1"),), next=("outer_1",), config={ "configurable": { @@ -7783,6 +8026,7 @@ async def test_nested_graph_interrupts( ), StateSnapshot( values={}, + tasks=(PregelTask(AnyStr(), "__start__"),), next=("__start__",), config={ "configurable": { diff --git a/libs/sdk-js/package.json b/libs/sdk-js/package.json index 300578131..db509e9c2 100644 --- a/libs/sdk-js/package.json +++ b/libs/sdk-js/package.json @@ -1,6 +1,6 @@ { "name": "@langchain/langgraph-sdk", - "version": "0.0.4", + "version": "0.0.5", "description": "Client library for interacting with the LangGraph API", "type": "module", "packageManager": "yarn@1.22.19", diff --git a/libs/sdk-js/src/schema.ts b/libs/sdk-js/src/schema.ts index 897ed1ed8..9c2df19a9 100644 --- a/libs/sdk-js/src/schema.ts +++ b/libs/sdk-js/src/schema.ts @@ -101,6 +101,9 @@ export interface ThreadState { metadata: Metadata; created_at: Optional; parent_checkpoint_id: Optional; + + config: Config; + parent_config?: Config; } export interface Run { diff --git a/poetry.lock b/poetry.lock index 7e00b52aa..ffec8ab9d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -152,6 +152,27 @@ webencodings = "*" [package.extras] css = ["tinycss2 (>=1.1.0,<1.3)"] +[[package]] +name = "cachecontrol" +version = "0.14.0" +description = "httplib2 caching for requests" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachecontrol-0.14.0-py3-none-any.whl", hash = "sha256:f5bf3f0620c38db2e5122c0726bdebb0d16869de966ea6a2befe92470b740ea0"}, + {file = "cachecontrol-0.14.0.tar.gz", hash = "sha256:7db1195b41c81f8274a7bbd97c956f44e8348265a1bc7641c37dfebc39f0c938"}, +] + +[package.dependencies] +filelock = {version = ">=3.8.0", optional = true, markers = "extra == \"filecache\""} +msgpack = ">=0.5.2,<2.0.0" +requests = ">=2.16.0" + +[package.extras] +dev = ["CacheControl[filecache,redis]", "black", "build", "cherrypy", "furo", "mypy", "pytest", "pytest-cov", "sphinx", "sphinx-copybutton", "tox", "types-redis", "types-requests"] +filecache = ["filelock (>=3.8.0)"] +redis = ["redis (>=2.10.5)"] + [[package]] name = "cairocffi" version = "1.7.1" @@ -533,6 +554,22 @@ files = [ [package.extras] devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"] +[[package]] +name = "filelock" +version = "3.15.4" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"}, + {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] +typing = ["typing-extensions (>=4.8)"] + [[package]] name = "ghp-import" version = "2.1.0" @@ -684,6 +721,25 @@ files = [ {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] +[[package]] +name = "importlib-metadata" +version = "8.2.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_metadata-8.2.0-py3-none-any.whl", hash = "sha256:11901fa0c2f97919b288679932bb64febaeacf289d18ac84dd68cb2e74213369"}, + {file = "importlib_metadata-8.2.0.tar.gz", hash = "sha256:72e8d4399996132204f9a16dcc751af254a48f8d1b20b9ff0f98d4a8f901e73d"}, +] + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] + [[package]] name = "ipykernel" version = "6.29.4" @@ -719,13 +775,13 @@ test = ["flaky", "ipyparallel", "pre-commit", "pytest (>=7.0)", "pytest-asyncio [[package]] name = "ipython" -version = "8.25.0" +version = "8.18.1" description = "IPython: Productive Interactive Computing" optional = false -python-versions = ">=3.10" +python-versions = ">=3.9" files = [ - {file = "ipython-8.25.0-py3-none-any.whl", hash = "sha256:53eee7ad44df903a06655871cbab66d156a051fd86f3ec6750470ac9604ac1ab"}, - {file = "ipython-8.25.0.tar.gz", hash = "sha256:c6ed726a140b6e725b911528f80439c534fac915246af3efc39440a6b0f9d716"}, + {file = "ipython-8.18.1-py3-none-any.whl", hash = "sha256:e8267419d72d81955ec1177f8a29aaa90ac80ad647499201119e2f05e99aa397"}, + {file = "ipython-8.18.1.tar.gz", hash = "sha256:ca6f079bb33457c66e233e4580ebfc4128855b4cf6370dddd73842a9563e8a27"}, ] [package.dependencies] @@ -734,26 +790,25 @@ decorator = "*" exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} jedi = ">=0.16" matplotlib-inline = "*" -pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} prompt-toolkit = ">=3.0.41,<3.1.0" pygments = ">=2.4.0" stack-data = "*" -traitlets = ">=5.13.0" -typing-extensions = {version = ">=4.6", markers = "python_version < \"3.12\""} +traitlets = ">=5" +typing-extensions = {version = "*", markers = "python_version < \"3.10\""} [package.extras] -all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] +all = ["black", "curio", "docrepr", "exceptiongroup", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.22)", "pandas", "pickleshare", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio (<0.22)", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"] black = ["black"] -doc = ["docrepr", "exceptiongroup", "intersphinx-registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "tomli", "typing-extensions"] +doc = ["docrepr", "exceptiongroup", "ipykernel", "matplotlib", "pickleshare", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio (<0.22)", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "typing-extensions"] kernel = ["ipykernel"] -matplotlib = ["matplotlib"] nbconvert = ["nbconvert"] nbformat = ["nbformat"] notebook = ["ipywidgets", "notebook"] parallel = ["ipyparallel"] qtconsole = ["qtconsole"] -test = ["pickleshare", "pytest", "pytest-asyncio (<0.22)", "testpath"] -test-extra = ["curio", "ipython[test]", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.23)", "pandas", "trio"] +test = ["pickleshare", "pytest (<7.1)", "pytest-asyncio (<0.22)", "testpath"] +test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.22)", "pandas", "pickleshare", "pytest (<7.1)", "pytest-asyncio (<0.22)", "testpath", "trio"] [[package]] name = "jedi" @@ -873,6 +928,7 @@ files = [ ] [package.dependencies] +importlib-metadata = {version = ">=4.8.3", markers = "python_version < \"3.10\""} jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" python-dateutil = ">=2.8.2" pyzmq = ">=23.0" @@ -968,7 +1024,7 @@ typing-extensions = ">=4.7" [[package]] name = "langgraph" -version = "0.2.2" +version = "0.2.3" description = "Building stateful, multi-actor applications with LLMs" optional = false python-versions = ">=3.9.0,<4.0" @@ -1001,7 +1057,7 @@ url = "libs/checkpoint" [[package]] name = "langgraph-checkpoint-postgres" -version = "1.0.1" +version = "1.0.3" description = "Library with a Postgres implementation of LangGraph checkpoint saver." optional = false python-versions = "^3.9.0,<4.0" @@ -1079,6 +1135,9 @@ files = [ {file = "Markdown-3.6.tar.gz", hash = "sha256:ed4f41f6daecbeeb96e576ce414c41d2d876daa9a16cb35fa8ed8c2ddfad0224"}, ] +[package.dependencies] +importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} + [package.extras] docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] testing = ["coverage", "pyyaml"] @@ -1288,6 +1347,7 @@ files = [ click = ">=7.0" colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} ghp-import = ">=1.0" +importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} jinja2 = ">=2.11.1" markdown = ">=3.3.6" markupsafe = ">=2.0.1" @@ -1331,6 +1391,7 @@ files = [ ] [package.dependencies] +importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} mergedeep = ">=1.3.4" platformdirs = ">=2.2.0" pyyaml = ">=5.1" @@ -1449,16 +1510,17 @@ test = ["autoflake", "black", "isort", "pytest"] [[package]] name = "mkdocs-rss-plugin" -version = "1.13.1" +version = "1.15.0" description = "MkDocs plugin which generates a static RSS feed using git log and page.meta." optional = false -python-versions = "<4,>=3.10" +python-versions = "<4,>=3.8" files = [ - {file = "mkdocs_rss_plugin-1.13.1-py2.py3-none-any.whl", hash = "sha256:665af194d0a48a1a88f3753c3cee78fee8e00b269dd61af1fe79f3555457b7a4"}, - {file = "mkdocs_rss_plugin-1.13.1.tar.gz", hash = "sha256:1c12b1a7449dcff686b8fd991648a88bb8735e5013242b10ff4f375b90a93803"}, + {file = "mkdocs_rss_plugin-1.15.0-py2.py3-none-any.whl", hash = "sha256:7308ac13f0976c0479db5a62cb7ef9b10fdd74b6521e459bb66a13e2cfe69d4b"}, + {file = "mkdocs_rss_plugin-1.15.0.tar.gz", hash = "sha256:92995ed6c77b2ae1f5f2913e62282c27e50c35d618c4291b5b939e50badd7504"}, ] [package.dependencies] +cachecontrol = {version = ">=0.14,<1", extras = ["filecache"]} GitPython = ">=3.1,<3.2" mkdocs = ">=1.5,<2" requests = ">=2.31,<3" @@ -1482,6 +1544,7 @@ files = [ [package.dependencies] click = ">=7.0" +importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} Jinja2 = ">=2.11.1" Markdown = ">=3.3" MarkupSafe = ">=1.1" @@ -1489,6 +1552,7 @@ mkdocs = ">=1.4" mkdocs-autorefs = ">=0.3.1" platformdirs = ">=2.2.0" pymdown-extensions = ">=6.3" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.10\""} [package.extras] crystal = ["mkdocstrings-crystal (>=0.3.4)"] @@ -1510,6 +1574,71 @@ files = [ griffe = ">=0.47" mkdocstrings = ">=0.25" +[[package]] +name = "msgpack" +version = "1.0.8" +description = "MessagePack serializer" +optional = false +python-versions = ">=3.8" +files = [ + {file = "msgpack-1.0.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:505fe3d03856ac7d215dbe005414bc28505d26f0c128906037e66d98c4e95868"}, + {file = "msgpack-1.0.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b7842518a63a9f17107eb176320960ec095a8ee3b4420b5f688e24bf50c53c"}, + {file = "msgpack-1.0.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:376081f471a2ef24828b83a641a02c575d6103a3ad7fd7dade5486cad10ea659"}, + {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e390971d082dba073c05dbd56322427d3280b7cc8b53484c9377adfbae67dc2"}, + {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e073efcba9ea99db5acef3959efa45b52bc67b61b00823d2a1a6944bf45982"}, + {file = "msgpack-1.0.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82d92c773fbc6942a7a8b520d22c11cfc8fd83bba86116bfcf962c2f5c2ecdaa"}, + {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9ee32dcb8e531adae1f1ca568822e9b3a738369b3b686d1477cbc643c4a9c128"}, + {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e3aa7e51d738e0ec0afbed661261513b38b3014754c9459508399baf14ae0c9d"}, + {file = "msgpack-1.0.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69284049d07fce531c17404fcba2bb1df472bc2dcdac642ae71a2d079d950653"}, + {file = "msgpack-1.0.8-cp310-cp310-win32.whl", hash = "sha256:13577ec9e247f8741c84d06b9ece5f654920d8365a4b636ce0e44f15e07ec693"}, + {file = "msgpack-1.0.8-cp310-cp310-win_amd64.whl", hash = "sha256:e532dbd6ddfe13946de050d7474e3f5fb6ec774fbb1a188aaf469b08cf04189a"}, + {file = "msgpack-1.0.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9517004e21664f2b5a5fd6333b0731b9cf0817403a941b393d89a2f1dc2bd836"}, + {file = "msgpack-1.0.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d16a786905034e7e34098634b184a7d81f91d4c3d246edc6bd7aefb2fd8ea6ad"}, + {file = "msgpack-1.0.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2872993e209f7ed04d963e4b4fbae72d034844ec66bc4ca403329db2074377b"}, + {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c330eace3dd100bdb54b5653b966de7f51c26ec4a7d4e87132d9b4f738220ba"}, + {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b5c044f3eff2a6534768ccfd50425939e7a8b5cf9a7261c385de1e20dcfc85"}, + {file = "msgpack-1.0.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1876b0b653a808fcd50123b953af170c535027bf1d053b59790eebb0aeb38950"}, + {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dfe1f0f0ed5785c187144c46a292b8c34c1295c01da12e10ccddfc16def4448a"}, + {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3528807cbbb7f315bb81959d5961855e7ba52aa60a3097151cb21956fbc7502b"}, + {file = "msgpack-1.0.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e2f879ab92ce502a1e65fce390eab619774dda6a6ff719718069ac94084098ce"}, + {file = "msgpack-1.0.8-cp311-cp311-win32.whl", hash = "sha256:26ee97a8261e6e35885c2ecd2fd4a6d38252246f94a2aec23665a4e66d066305"}, + {file = "msgpack-1.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:eadb9f826c138e6cf3c49d6f8de88225a3c0ab181a9b4ba792e006e5292d150e"}, + {file = "msgpack-1.0.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:114be227f5213ef8b215c22dde19532f5da9652e56e8ce969bf0a26d7c419fee"}, + {file = "msgpack-1.0.8-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d661dc4785affa9d0edfdd1e59ec056a58b3dbb9f196fa43587f3ddac654ac7b"}, + {file = "msgpack-1.0.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d56fd9f1f1cdc8227d7b7918f55091349741904d9520c65f0139a9755952c9e8"}, + {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0726c282d188e204281ebd8de31724b7d749adebc086873a59efb8cf7ae27df3"}, + {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8db8e423192303ed77cff4dce3a4b88dbfaf43979d280181558af5e2c3c71afc"}, + {file = "msgpack-1.0.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99881222f4a8c2f641f25703963a5cefb076adffd959e0558dc9f803a52d6a58"}, + {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b5505774ea2a73a86ea176e8a9a4a7c8bf5d521050f0f6f8426afe798689243f"}, + {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ef254a06bcea461e65ff0373d8a0dd1ed3aa004af48839f002a0c994a6f72d04"}, + {file = "msgpack-1.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e1dd7839443592d00e96db831eddb4111a2a81a46b028f0facd60a09ebbdd543"}, + {file = "msgpack-1.0.8-cp312-cp312-win32.whl", hash = "sha256:64d0fcd436c5683fdd7c907eeae5e2cbb5eb872fafbc03a43609d7941840995c"}, + {file = "msgpack-1.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:74398a4cf19de42e1498368c36eed45d9528f5fd0155241e82c4082b7e16cffd"}, + {file = "msgpack-1.0.8-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0ceea77719d45c839fd73abcb190b8390412a890df2f83fb8cf49b2a4b5c2f40"}, + {file = "msgpack-1.0.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1ab0bbcd4d1f7b6991ee7c753655b481c50084294218de69365f8f1970d4c151"}, + {file = "msgpack-1.0.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1cce488457370ffd1f953846f82323cb6b2ad2190987cd4d70b2713e17268d24"}, + {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3923a1778f7e5ef31865893fdca12a8d7dc03a44b33e2a5f3295416314c09f5d"}, + {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a22e47578b30a3e199ab067a4d43d790249b3c0587d9a771921f86250c8435db"}, + {file = "msgpack-1.0.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd739c9251d01e0279ce729e37b39d49a08c0420d3fee7f2a4968c0576678f77"}, + {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d3420522057ebab1728b21ad473aa950026d07cb09da41103f8e597dfbfaeb13"}, + {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5845fdf5e5d5b78a49b826fcdc0eb2e2aa7191980e3d2cfd2a30303a74f212e2"}, + {file = "msgpack-1.0.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a0e76621f6e1f908ae52860bdcb58e1ca85231a9b0545e64509c931dd34275a"}, + {file = "msgpack-1.0.8-cp38-cp38-win32.whl", hash = "sha256:374a8e88ddab84b9ada695d255679fb99c53513c0a51778796fcf0944d6c789c"}, + {file = "msgpack-1.0.8-cp38-cp38-win_amd64.whl", hash = "sha256:f3709997b228685fe53e8c433e2df9f0cdb5f4542bd5114ed17ac3c0129b0480"}, + {file = "msgpack-1.0.8-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f51bab98d52739c50c56658cc303f190785f9a2cd97b823357e7aeae54c8f68a"}, + {file = "msgpack-1.0.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:73ee792784d48aa338bba28063e19a27e8d989344f34aad14ea6e1b9bd83f596"}, + {file = "msgpack-1.0.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f9904e24646570539a8950400602d66d2b2c492b9010ea7e965025cb71d0c86d"}, + {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e75753aeda0ddc4c28dce4c32ba2f6ec30b1b02f6c0b14e547841ba5b24f753f"}, + {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5dbf059fb4b7c240c873c1245ee112505be27497e90f7c6591261c7d3c3a8228"}, + {file = "msgpack-1.0.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4916727e31c28be8beaf11cf117d6f6f188dcc36daae4e851fee88646f5b6b18"}, + {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7938111ed1358f536daf311be244f34df7bf3cdedb3ed883787aca97778b28d8"}, + {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:493c5c5e44b06d6c9268ce21b302c9ca055c1fd3484c25ba41d34476c76ee746"}, + {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273"}, + {file = "msgpack-1.0.8-cp39-cp39-win32.whl", hash = "sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d"}, + {file = "msgpack-1.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011"}, + {file = "msgpack-1.0.8.tar.gz", hash = "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3"}, +] + [[package]] name = "nbclient" version = "0.10.0" @@ -1547,6 +1676,7 @@ files = [ beautifulsoup4 = "*" bleach = "!=5.0.0" defusedxml = "*" +importlib-metadata = {version = ">=3.6", markers = "python_version < \"3.10\""} jinja2 = ">=3.0" jupyter-core = ">=4.7" jupyterlab-pygments = "*" @@ -2826,7 +2956,22 @@ files = [ {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, ] +[[package]] +name = "zipp" +version = "3.20.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "zipp-3.20.0-py3-none-any.whl", hash = "sha256:58da6168be89f0be59beb194da1250516fdaa062ccebd30127ac65d30045e10d"}, + {file = "zipp-3.20.0.tar.gz", hash = "sha256:0145e43d89664cfe1a2e533adc75adafed82fe2da404b4bbb6b026c0157bdb31"}, +] + +[package.extras] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + [metadata] lock-version = "2.0" -python-versions = "^3.10" -content-hash = "8bbefefef5e786ed86783a98ff0809cfaaad26d59b2d83623c269587a3bf5f7a" +python-versions = "^3.9" +content-hash = "d98361cff93b5d06ac80fd076e73bcf3b0883fb3ad8e66bc3ca0cfc6b955c94c" diff --git a/pyproject.toml b/pyproject.toml index e6477dce2..e5794d566 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ license = "MIT" readme = "README.md" [tool.poetry.dependencies] -python = "^3.10" +python = "^3.9" [tool.poetry.group.docs.dependencies] langgraph = { path = "libs/langgraph/", develop = true }