Compare commits

..
Author SHA1 Message Date
William Fu-Hinthorn 6920be192e Update inmem 2024-11-19 15:02:16 -08:00
86 changed files with 4774 additions and 7717 deletions
+2 -1
View File
@@ -22,7 +22,8 @@ def test(
# check docker available # check docker available
capabilities = langgraph_cli.docker.check_capabilities(runner) capabilities = langgraph_cli.docker.check_capabilities(runner)
# open config # open config
config_json = langgraph_cli.config.validate_config_file(config) with open(config) as f:
config_json = langgraph_cli.config.validate_config(json.load(f))
set("Running...") set("Running...")
args = [ args = [
-2
View File
@@ -88,7 +88,6 @@ jobs:
--check-links-ignore "https://(api|web|docs)\.smith\.langchain\.com/.*" \ --check-links-ignore "https://(api|web|docs)\.smith\.langchain\.com/.*" \
--check-links-ignore "https://x.com/.*" \ --check-links-ignore "https://x.com/.*" \
--check-links-ignore "https://github\.com/.*" \ --check-links-ignore "https://github\.com/.*" \
--check-links-ignore "http://localhost:8123/.*" \
--check-links-ignore "/.*\.(ipynb|html)$" \ --check-links-ignore "/.*\.(ipynb|html)$" \
--check-links-ignore "https://python\.langchain\.com/.*" \ --check-links-ignore "https://python\.langchain\.com/.*" \
--check-links-ignore "https://openai\.com/.*" \ --check-links-ignore "https://openai\.com/.*" \
@@ -105,7 +104,6 @@ jobs:
echo "Running link check on HTML files matching changed notebook files..." echo "Running link check on HTML files matching changed notebook files..."
poetry run pytest -v \ poetry run pytest -v \
--check-links-ignore "https://(api|web|docs)\.smith\.langchain\.com/.*" \ --check-links-ignore "https://(api|web|docs)\.smith\.langchain\.com/.*" \
--check-links-ignore "http://localhost:8123/.*" \
--check-links-ignore "https://x.com/.*" \ --check-links-ignore "https://x.com/.*" \
--check-links-ignore "https://github\.com/.*" \ --check-links-ignore "https://github\.com/.*" \
--check-links-ignore "/.*\.(ipynb|html)$" \ --check-links-ignore "/.*\.(ipynb|html)$" \
+1 -1
View File
@@ -238,7 +238,7 @@ final_state["messages"][-1].content
* [How-to Guides](https://langchain-ai.github.io/langgraph/how-tos/): Accomplish specific things within LangGraph, from streaming, to adding memory & persistence, to common design patterns (branching, subgraphs, etc.), these are the place to go if you want to copy and run a specific code snippet. * [How-to Guides](https://langchain-ai.github.io/langgraph/how-tos/): Accomplish specific things within LangGraph, from streaming, to adding memory & persistence, to common design patterns (branching, subgraphs, etc.), these are the place to go if you want to copy and run a specific code snippet.
* [Conceptual Guides](https://langchain-ai.github.io/langgraph/concepts/high_level/): In-depth explanations of the key concepts and principles behind LangGraph, such as nodes, edges, state and more. * [Conceptual Guides](https://langchain-ai.github.io/langgraph/concepts/high_level/): In-depth explanations of the key concepts and principles behind LangGraph, such as nodes, edges, state and more.
* [API Reference](https://langchain-ai.github.io/langgraph/reference/graphs/): Review important classes and methods, simple examples of how to use the graph and checkpointing APIs, higher-level prebuilt components and more. * [API Reference](https://langchain-ai.github.io/langgraph/reference/graphs/): Review important classes and methods, simple examples of how to use the graph and checkpointing APIs, higher-level prebuilt components and more.
* [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/#langgraph-platform): LangGraph Platform is a commercial solution for deploying agentic applications in production, built on the open-source LangGraph framework. * [Cloud (beta)](https://langchain-ai.github.io/langgraph/cloud/): With one click, deploy LangGraph applications to LangGraph Cloud.
## Contributing ## Contributing
+3 -3
View File
@@ -8,9 +8,9 @@ If you want to learn how to build an agent like this from scratch, take a look a
This tutorial will use: This tutorial will use:
- Anthropic for the LLM - sign up and get an API key [here](https://console.anthropic.com/). - Anthropic for the LLM - sign up and get an API key [here](https://console.anthropic.com/)
- Tavily for the search engine - sign up and get an API key [here](https://app.tavily.com/). - Tavily for the search engine - sign up and get an API key [here](https://app.tavily.com/)
- LangSmith for hosting - sign up and get an API key [here](https://smith.langchain.com/). - LangSmith for hosting - sign up and get an API key [here](https://smith.langchain.com/)
## Create and configure your app ## Create and configure your app
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
<!doctype html>
<html>
<head>
<title>Open Assistants API Specification</title>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1" />
</head>
<body>
<script id="api-reference" data-url="./open_agent_api.json"></script>
<script>
var configuration = {}
document.getElementById('api-reference').dataset.configuration =
JSON.stringify(configuration)
</script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>
+6 -15
View File
@@ -1557,11 +1557,8 @@
"200": { "200": {
"description": "Success", "description": "Success",
"content": { "content": {
"text/event-stream": { "application/json": {
"schema": { "schema": {}
"type": "string",
"description": "The server will send a stream of events in SSE format.\n\n**Example event**:\n\nid: 1\n\nevent: message\n\ndata: {}"
}
} }
} }
}, },
@@ -1908,11 +1905,8 @@
"200": { "200": {
"description": "Success", "description": "Success",
"content": { "content": {
"text/event-stream": { "application/json": {
"schema": { "schema": {}
"type": "string",
"description": "The server will send a stream of events in SSE format.\n\n**Example event**:\n\nid: 1\n\nevent: message\n\ndata: {}"
}
} }
} }
}, },
@@ -2149,11 +2143,8 @@
"200": { "200": {
"description": "Success", "description": "Success",
"content": { "content": {
"text/event-stream": { "application/json": {
"schema": { "schema": {}
"type": "string",
"description": "The server will send a stream of events in SSE format.\n\n**Example event**:\n\nid: 1\n\nevent: message\n\ndata: {}"
}
} }
} }
}, },
+1 -1
View File
@@ -30,7 +30,7 @@ The conceptual guide does not cover step-by-step instructions or specific implem
- [Streaming](streaming.md): Streaming is crucial for enhancing the responsiveness of applications built on LLMs. By displaying output progressively, even before a complete response is ready, streaming significantly improves user experience (UX), particularly when dealing with the latency of LLMs. - [Streaming](streaming.md): Streaming is crucial for enhancing the responsiveness of applications built on LLMs. By displaying output progressively, even before a complete response is ready, streaming significantly improves user experience (UX), particularly when dealing with the latency of LLMs.
- [FAQ](faq.md): Frequently asked questions about LangGraph. - [FAQ](faq.md): Frequently asked questions about LangGraph.
## LangGraph Platform ## LangGraph Platform
LangGraph Platform is a commercial solution for deploying agentic applications in production, built on the open-source LangGraph framework. LangGraph Platform is a commercial solution for deploying agentic applications in production, built on the open-source LangGraph framework.
-8
View File
@@ -14,13 +14,6 @@ A **deployment** is an instance of a LangGraph API. A single deployment can have
See the [how-to guide](../cloud/deployment/cloud.md#create-new-deployment) for creating a new deployment. See the [how-to guide](../cloud/deployment/cloud.md#create-new-deployment) for creating a new deployment.
## Resource Allocation
| **Deployment Type** | **CPU** | **Memory** | **Scaling** |
|---------------------|---------|------------|---------------------|
| Development | 1 CPU | 1 GB | Up to 1 container |
| Production | 1 CPU | 2 GB | Up to 10 containers |
## Revision ## Revision
A revision is an iteration of a [deployment](#deployment). When a new deployment is created, an initial revision is automatically created. To deploy new code changes or update environment variable configurations for a deployment, a new revision must be created. When a revision is created, a new container image is built automatically. A revision is an iteration of a [deployment](#deployment). When a new deployment is created, an initial revision is automatically created. To deploy new code changes or update environment variable configurations for a deployment, a new revision must be created. When a revision is created, a new container image is built automatically.
@@ -40,7 +33,6 @@ A high-level diagram of a Cloud SaaS deployment.
![diagram](img/langgraph_cloud_architecture.png) ![diagram](img/langgraph_cloud_architecture.png)
## Related ## Related
- [Deployment Options](./deployment_options.md) - [Deployment Options](./deployment_options.md)
+1 -5
View File
@@ -7,7 +7,7 @@
## Versions ## Versions
There are two versions of the self-hosted deployment: [Self-Hosted Enterprise](./deployment_options.md#self-hosted-enterprise) and [Self-Hosted Lite](./deployment_options.md#self-hosted-lite). There are two versions of the self hosted deployment: [Self-Hosted Enterprise](./deployment_options.md#self-hosted-enterprise) and [Self-Hosted Lite](./deployment_options.md#self-hosted-lite).
### Self-Hosted Lite ### Self-Hosted Lite
@@ -34,10 +34,6 @@ To use the Self-Hosted Enterprise version, you must acquire a license key that y
For step-by-step instructions, see [How to set up a self-hosted deployment of LangGraph](../how-tos/deploy-self-hosted.md). For step-by-step instructions, see [How to set up a self-hosted deployment of LangGraph](../how-tos/deploy-self-hosted.md).
## Helm Chart
If you would like to deploy LangGraph Cloud on Kubernetes, you can use this [Helm chart](https://github.com/langchain-ai/helm/blob/main/charts/langgraph-cloud/README.md).
## Related ## Related
- [How to set up a self-hosted deployment of LangGraph](../how-tos/deploy-self-hosted.md). - [How to set up a self-hosted deployment of LangGraph](../how-tos/deploy-self-hosted.md).
-4
View File
@@ -17,10 +17,6 @@ You will need to do the following:
2. Build a docker image with the [LangGraph Server](../concepts/langgraph_server.md) using the [LangGraph CLI](../concepts/langgraph_cli.md). 2. Build a docker image with the [LangGraph Server](../concepts/langgraph_server.md) using the [LangGraph CLI](../concepts/langgraph_cli.md).
3. Deploy a web server that will run the docker image and pass in the necessary environment variables. 3. Deploy a web server that will run the docker image and pass in the necessary environment variables.
## Helm Chart
If you would like to deploy LangGraph Cloud on Kubernetes, you can use this [Helm chart](https://github.com/langchain-ai/helm/blob/main/charts/langgraph-cloud/README.md).
## Environment Variables ## Environment Variables
You will eventually need to pass in the following environment variables to the LangGraph Deploy server: You will eventually need to pass in the following environment variables to the LangGraph Deploy server:
+3 -4
View File
@@ -151,7 +151,6 @@ LangGraph applications can be deployed using LangGraph Cloud, which provides a r
- [How to deploy to LangGraph cloud](../cloud/deployment/cloud.md) - [How to deploy to LangGraph cloud](../cloud/deployment/cloud.md)
- [How to deploy to a self-hosted environment](./deploy-self-hosted.md) - [How to deploy to a self-hosted environment](./deploy-self-hosted.md)
- [How to interact with the deployment using RemoteGraph](./use-remote-graph.md) - [How to interact with the deployment using RemoteGraph](./use-remote-graph.md)
### Assistants ### Assistants
[Assistants](../concepts/assistants.md) is a configured instance of a template. [Assistants](../concepts/assistants.md) is a configured instance of a template.
@@ -166,7 +165,7 @@ LangGraph applications can be deployed using LangGraph Cloud, which provides a r
### Runs ### Runs
LangGraph Platform supports multiple types of runs besides streaming runs. LangGraph Cloud supports multiple types of runs besides streaming runs.
- [How to run an agent in the background](../cloud/how-tos/background_run.md) - [How to run an agent in the background](../cloud/how-tos/background_run.md)
- [How to run multiple agents in the same thread](../cloud/how-tos/same-thread.md) - [How to run multiple agents in the same thread](../cloud/how-tos/same-thread.md)
@@ -186,7 +185,7 @@ Streaming the results of your LLM application is vital for ensuring a good user
### Human-in-the-loop ### Human-in-the-loop
When designing complex graphs, relying entirely on the LLM for decision-making can be risky, particularly when it involves tools that interact with files, APIs, or databases. These interactions may lead to unintended data access or modifications, depending on the use case. To mitigate these risks, LangGraph allows you to integrate human-in-the-loop behavior, ensuring your LLM applications operate as intended without undesirable outcomes. When creating complex graphs, leaving every decision up to the LLM can be dangerous, especially when the decisions involve invoking certain tools or accessing specific documents. To remedy this, LangGraph allows you to insert human-in-the-loop behavior to ensure your graph does not have undesired outcomes. Read more about the different ways you can add human-in-the-loop capabilities to your LangGraph Cloud projects in these how-to guides:
- [How to add a breakpoint](../cloud/how-tos/human_in_the_loop_breakpoint.md) - [How to add a breakpoint](../cloud/how-tos/human_in_the_loop_breakpoint.md)
- [How to wait for user input](../cloud/how-tos/human_in_the_loop_user_input.md) - [How to wait for user input](../cloud/how-tos/human_in_the_loop_user_input.md)
@@ -196,7 +195,7 @@ When designing complex graphs, relying entirely on the LLM for decision-making c
### Double-texting ### Double-texting
Graph execution can take a while, and sometimes users may change their mind about the input they wanted to send before their original input has finished running. For example, a user might notice a typo in their original request and will edit the prompt and resend it. Deciding what to do in these cases is important for ensuring a smooth user experience and preventing your graphs from behaving in unexpected ways. Graph execution can take a while, and sometimes users may change their mind about the input they wanted to send before their original input has finished running. For example, a user might notice a typo in their original request and will edit the prompt and resend it. Deciding what to do in these cases is important for ensuring a smooth user experience and preventing your graphs from behaving in unexpected ways. The following how-to guides provide information on the various options LangGraph Cloud gives you for dealing with double-texting:
- [How to use the interrupt option](../cloud/how-tos/interrupt_concurrent.md) - [How to use the interrupt option](../cloud/how-tos/interrupt_concurrent.md)
- [How to use the rollback option](../cloud/how-tos/rollback_concurrent.md) - [How to use the rollback option](../cloud/how-tos/rollback_concurrent.md)
+12 -10
View File
@@ -6,23 +6,25 @@ title: Tutorials
# Tutorials # Tutorials
New to LangGraph or LLM app development? Read this material to get up and running building your first applications. Welcome to the LangGraph Tutorials! These notebooks introduce LangGraph through building various language agents and applications.
## Get Started 🚀 {#quick-start} ## Quick Start
- [LangGraph Quickstart](introduction.ipynb): Build a chatbot that can use tools and keep track of conversation history. Add human-in-the-loop capabilities and explore how time-travel works. Learn the basics of LangGraph through a comprehensive quick start in which you will build an agent from scratch.
- [LangGraph Server Quickstart](langgraph-platform/local-server.md): Launch a LangGraph server locally and interact with it using the REST API and LangGraph Studio Web UI.
- [LangGraph Cloud QuickStart](../cloud/quick_start.md): Deploy a LangGraph app using LangGraph Cloud.
## Use cases 🛠️ - [Quick Start](introduction.ipynb): In this tutorial, you will build a support chatbot using LangGraph.
- [LangGraph Cloud Quick Start](../cloud/quick_start.md): In this tutorial, you will build and deploy an agent to LangGraph Cloud.
Explore practical implementations tailored for specific scenarios: ## Use cases
Learn from example implementations of graphs designed for specific scenarios and that implement common design patterns.
### Chatbots ### Chatbots
- [Customer Support](customer-support/customer-support.ipynb): Build a multi-functional support bot for flights, hotels, and car rentals. - [Customer Support](customer-support/customer-support.ipynb): Build a customer support chatbot to manage flights, hotel reservations, car rentals, and other tasks
- [Prompt Generation from User Requirements](chatbots/information-gather-prompting.ipynb): Build an information gathering chatbot. - [Prompt Generation from User Requirements](chatbots/information-gather-prompting.ipynb): Build an information gathering chatbot
- [Code Assistant](code_assistant/langgraph_code_assistant.ipynb): Build a code analysis and generation assistant. - [Code Assistant](code_assistant/langgraph_code_assistant.ipynb): Build a code analysis and generation assistant
### RAG ### RAG
+40 -47
View File
@@ -5,21 +5,21 @@
"id": "4a1aae78-88a6-4133-b905-7e46c8e3772f", "id": "4a1aae78-88a6-4133-b905-7e46c8e3772f",
"metadata": {}, "metadata": {},
"source": [ "source": [
"# 🚀 LangGraph Quick Start\n", "# LangGraph Quick Start\n",
"\n", "\n",
"In this tutorial, we will build a support chatbot in LangGraph that can:\n", "In this comprehensive quick start, we will build a support chatbot in LangGraph that can:\n",
"\n", "\n",
"✅ **Answer common questions** by searching the web \n", "- Answer common questions by searching the web\n",
"✅ **Maintain conversation state** across calls \n", "- Maintain conversation state across calls\n",
"✅ **Route complex queries** to a human for review \n", "- Route complex queries to a human for review\n",
"✅ **Use custom state** to control its behavior \n", "- Use custom state to control its behavior\n",
"✅ **Rewind and explore** alternative conversation paths \n", "- Rewind and explore alternative conversation paths\n",
"\n", "\n",
"We'll start with a **basic chatbot** and progressively add more sophisticated capabilities, introducing key LangGraph concepts along the way. Let’s dive in! 🌟\n", "We'll start with a basic chatbot and progressively add more sophisticated capabilities, introducing key LangGraph concepts along the way.\n",
"\n", "\n",
"## Setup\n", "## Setup\n",
"\n", "\n",
"First, install the required packages and configure your environment:" "First, install the required packages:"
] ]
}, },
{ {
@@ -33,6 +33,14 @@
"%pip install -U langgraph langsmith langchain_anthropic" "%pip install -U langgraph langsmith langchain_anthropic"
] ]
}, },
{
"cell_type": "markdown",
"id": "a6d1e870-1bc0-4d44-86c0-96681ccf6113",
"metadata": {},
"source": [
"Next, set your API keys:"
]
},
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 2, "execution_count": 2,
@@ -112,24 +120,27 @@
] ]
}, },
{ {
"attachments": {},
"cell_type": "markdown", "cell_type": "markdown",
"id": "c08c41da-0855-49d3-9a3d-b7eb94413367", "id": "31c755cd-8994-4867-bdff-96a55d7beae7",
"metadata": {}, "metadata": {},
"source": [ "source": [
"Our graph can now handle two key tasks:\n", "<div class=\"admonition tip\">\n",
"\n", " <p class=\"admonition-title\">Note</p>\n",
"1. Each `node` can receive the current `State` as input and output an update to the state.\n", " <p>\n",
"2. Updates to `messages` will be appended to the existing list rather than overwriting it, thanks to the prebuilt [`add_messages`](https://langchain-ai.github.io/langgraph/reference/graphs/?h=add+messages#add_messages) function used with the `Annotated` syntax.\n", " The first thing you do when you define a graph is define the <code>State</code> of the graph. The <code>State</code> consists of the schema of the graph as well as <a href=\"https://langchain-ai.github.io/langgraph/concepts/low_level/#reducers\">reducer functions</a> which specify how to apply updates to the state. In our example <code>State</code> is a <code>TypedDict</code> with a single key: <code>messages</code>. The <code>messages</code> key is annotated with the <a href=\"https://langchain-ai.github.io/langgraph/reference/graphs/#langgraph.graph.message.add_messages\"><code>add_messages</code></a> reducer function, which tells LangGraph to append new messages to the existing list, rather than overwriting it. State keys without an annotation will be overwritten by each update, storing the most recent value. Check out <a href=\"https://langchain-ai.github.io/langgraph/reference/graphs/#langgraph.graph.message.add_messages\">this conceptual guide</a> to learn more about state, reducers and other low-level concepts.\n",
"\n", " </p>\n",
"------\n", "</div>"
"\n", ]
"!!! tip \"Concept\"\n", },
"\n", {
" When defining a graph, the first step is to define its `State`. The `State` includes the graph's schema and [reducer functions](https://langchain-ai.github.io/langgraph/concepts/low_level/#reducers) that handle state updates. In our example, `State` is a `TypedDict` with one key: `messages`. The [`add_messages`](https://langchain-ai.github.io/langgraph/reference/graphs/#langgraph.graph.message.add_messages) reducer function is used to append new messages to the list instead of overwriting it. Keys without a reducer annotation will overwrite previous values. Learn more about state, reducers, and related concepts in [this guide](https://langchain-ai.github.io/langgraph/reference/graphs/#langgraph.graph.message.add_messages).\n", "cell_type": "markdown",
"\n", "id": "4137feed-746e-4c72-a34a-f7a699ad5dcf",
"---------\n", "metadata": {},
"source": [
"So now our graph knows two things:\n",
"\n", "\n",
"1. Every `node` we define will receive the current `State` as input and return a value that updates that state.\n",
"2. `messages` will be _appended_ to the current list, rather than directly overwritten. This is communicated via the prebuilt [`add_messages`](https://langchain-ai.github.io/langgraph/reference/graphs/?h=add+messages#add_messages) function in the `Annotated` syntax.\n",
"\n", "\n",
"Next, add a \"`chatbot`\" node. Nodes represent units of work. They are typically regular python functions." "Next, add a \"`chatbot`\" node. Nodes represent units of work. They are typically regular python functions."
] ]
@@ -354,7 +365,7 @@
"id": "f22c5d4a-3134-413c-81fe-dd9752fbeb66", "id": "f22c5d4a-3134-413c-81fe-dd9752fbeb66",
"metadata": {}, "metadata": {},
"source": [ "source": [
"## Part 2: 🛠️ Enhancing the Chatbot with Tools\n", "## Part 2: Enhancing the Chatbot with Tools\n",
"\n", "\n",
"To handle queries our chatbot can't answer \"from memory\", we'll integrate a web search tool. Our bot can use this tool to find relevant information and provide better responses.\n", "To handle queries our chatbot can't answer \"from memory\", we'll integrate a web search tool. Our bot can use this tool to find relevant information and provide better responses.\n",
"\n", "\n",
@@ -2035,7 +2046,7 @@
"\n", "\n",
"So far, we've relied on a simple state (it's just a list of messages!). You can go far with this simple state, but if you want to define complex behavior without relying on the message list, you can add additional fields to the state. In this section, we will extend our chat bot with a new node to illustrate this.\n", "So far, we've relied on a simple state (it's just a list of messages!). You can go far with this simple state, but if you want to define complex behavior without relying on the message list, you can add additional fields to the state. In this section, we will extend our chat bot with a new node to illustrate this.\n",
"\n", "\n",
"In the examples above, we involved a human deterministically: the graph __always__ interrupted whenever a tool was invoked. Suppose we wanted our chat bot to have the choice of relying on a human.\n", "In the examples above, we involved a human deterministically: the graph __always__ interrupted whenever an tool was invoked. Suppose we wanted our chat bot to have the choice of relying on a human.\n",
"\n", "\n",
"One way to do this is to create a passthrough \"human\" node, before which the graph will always stop. We will only execute this node if the LLM invokes a \"human\" tool. For our convenience, we will include an \"ask_human\" flag in our graph state that we will flip if the LLM calls this tool.\n", "One way to do this is to create a passthrough \"human\" node, before which the graph will always stop. We will only execute this node if the LLM invokes a \"human\" tool. For our convenience, we will include an \"ask_human\" flag in our graph state that we will flip if the LLM calls this tool.\n",
"\n", "\n",
@@ -3125,29 +3136,11 @@
"id": "e584d57f-5aad-4507-815f-0b2e4b64b791", "id": "e584d57f-5aad-4507-815f-0b2e4b64b791",
"metadata": {}, "metadata": {},
"source": [ "source": [
"## Next Steps\n", "## Conclusion\n",
"\n", "\n",
"Take your journey further by exploring deployment and advanced features:\n", "Congrats! You've completed the intro tutorial and built a chat bot in LangGraph that supports tool calling, persistent memory, human-in-the-loop interactivity, and even time-travel!\n",
"\n", "\n",
"### Server Quickstart\n", "The [LangGraph documentation](https://langchain-ai.github.io/langgraph/) is a great resource for diving deeper into the library's capabilities."
"\n",
"- **[LangGraph Server Quickstart](../langgraph-platform/local-server)**: Launch a LangGraph server locally and interact with it using the REST API and LangGraph Studio Web UI.\n",
"\n",
"### LangGraph Cloud\n",
"\n",
"- **[LangGraph Cloud QuickStart](../../cloud/quick_start)**: Deploy your LangGraph app using LangGraph Cloud.\n",
"\n",
"### LangGraph Framework\n",
"\n",
"- **[LangGraph Concepts](../../concepts)**: Learn the foundational concepts of LangGraph. \n",
"- **[LangGraph How-to Guides](../../how-tos)**: Guides for common tasks with LangGraph.\n",
"\n",
"### LangGraph Platform\n",
"\n",
"Expand your knowledge with these resources:\n",
"\n",
"- **[LangGraph Platform Concepts](../../concepts#langgraph-platform)**: Understand the foundational concepts of the LangGraph Platform. \n",
"- **[LangGraph Platform How-to Guides](../../how-tos#langgraph-platform)**: Guides for common tasks with LangGraph Platform. "
] ]
} }
], ],
@@ -3167,7 +3160,7 @@
"name": "python", "name": "python",
"nbconvert_exporter": "python", "nbconvert_exporter": "python",
"pygments_lexer": "ipython3", "pygments_lexer": "ipython3",
"version": "3.11.4" "version": "3.11.9"
} }
}, },
"nbformat": 4, "nbformat": 4,
@@ -1,253 +0,0 @@
# Quick Start: Launch Local LangGraph Server
This is a quick start guide to help you get a LangGraph app up and running locally.
!!! info "Requirements"
- Python >= 3.11
- [LangGraph CLI](https://langchain-ai.github.io/langgraph/cloud/reference/cli/): Requires langchain-cli[inmem] >= 0.1.58
## Install the LangGraph CLI
```bash
pip install "langgraph-cli[inmem]==0.1.58" python-dotenv
```
## 🌱 Create a LangGraph App
Create a new app from the `react-agent` template. This template is a simple agent that can be flexibly extended to many tools.
=== "Python Server"
```shell
langgraph new path/to/your/app --template react-agent-python
```
=== "Node Server"
```shell
langgraph new path/to/your/app --template react-agent-js
```
!!! tip "Additional Templates"
If you use `langgraph new` without specifying a template, you will be presented with an interactive menu that will allow you to choose from a list of available templates.
## Install Dependencies
In the root of your new LangGraph app, install the dependencies in `edit` mode so your local changes are used by the server:
```shell
pip install -e .
```
## Create a `.env` file
You will find a `.env.example` in the root of your new LangGraph app. Create
a `.env` file in the root of your new LangGraph app and copy the contents of the `.env.example` file into it, filling in the necessary API keys:
```bash
LANGSMITH_API_KEY=lsv2...
TAVILY_API_KEY=tvly-...
ANTHROPIC_API_KEY=sk-
OPENAI_API_KEY=sk-...
```
<details><summary>Get API Keys</summary>
<ul>
<li> <b>LANGSMITH_API_KEY</b>: Go to the <a href="https://smith.langchain.com/settings">LangSmith Settings page</a>. Then clck <b>Create API Key</b>.
</li>
<li>
<b>ANTHROPIC_API_KEY</b>: Get an API key from <a href="https://console.anthropic.com/">Anthropic</a>.
</li>
<li>
<b>OPENAI_API_KEY</b>: Get an API key from <a href="https://openai.com/">OpenAI</a>.
</li>
<li>
<b>TAVILY_API_KEY</b>: Get an API key on the <a href="https://app.tavily.com/">Tavily website</a>.
</li>
</ul>
</details>
## 🚀 Launch LangGraph Server
```shell
langgraph dev
```
This will start up the LangGraph API server locally. If this runs successfully, you should see something like:
> Ready!
>
> - API: [http://localhost:8123](http://localhost:8123/)
>
> - Docs: http://localhost:8123/docs
>
> - LangGraph Studio Web UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:8123
!!! note "In-Memory Mode"
The `langgraph dev` command starts LangGraph Server in an in-memory mode. This mode is suitable for development and testing purposes. For production use, you should deploy LangGraph Server with access to a persistent storage backend.
If you want to test your application with a persistent storage backend, you can use the `langgraph up` command instead of `langgraph dev`. You will
need to have `docker` installed on your machine to use this command.
## LangGraph Studio Web UI
Test your graph in the LangGraph Studio Web UI by visiting the URL provided in the output of the `langgraph up` command.
> - LangGraph Studio Web UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:8123
!!! warning "Safari Compatibility"
Currently, LangGraph Studio Web does not support Safari when running a server locally.
## Test the API
=== "Python SDK (Async)"
**Install the LangGraph Python SDK**
```shell
pip install langgraph-sdk
```
**Send a message to the assistant (threadless run)**
```python
from langgraph_sdk import get_client
client = get_client(url="http://localhost:8123")
async for chunk in client.runs.stream(
None, # Threadless run
"agent", # Name of assistant. Defined in langgraph.json.
input={
"messages": [{
"role": "human",
"content": "What is LangGraph?",
}],
},
stream_mode="updates",
):
print(f"Receiving new event of type: {chunk.event}...")
print(chunk.data)
print("\n\n")
```
=== "Python SDK (Sync)"
**Install the LangGraph Python SDK**
```shell
pip install langgraph-sdk
```
**Send a message to the assistant (threadless run)**
```python
from langgraph_sdk import get_sync_client
client = get_sync_client(url="http://localhost:8123")
for chunk in client.runs.stream(
None, # Threadless run
"agent", # Name of assistant. Defined in langgraph.json.
input={
"messages": [{
"role": "human",
"content": "What is LangGraph?",
}],
},
stream_mode="updates",
):
print(f"Receiving new event of type: {chunk.event}...")
print(chunk.data)
print("\n\n")
```
=== "Javascript SDK"
**Install the LangGraph JS SDK**
```shell
npm install @langchain/langgraph-sdk
```
**Send a message to the assistant (threadless run)**
```js
const { Client } = await import("@langchain/langgraph-sdk");
// only set the apiUrl if you changed the default port when calling langgraph up
const client = new Client({ apiUrl: "http://localhost:8123"});
const streamResponse = client.runs.stream(
null, // Threadless run
"agent", // Assistant ID
{
input: {
"messages": [
{ "role": "user", "content": "What is LangGraph?"}
]
},
streamMode: "messages",
}
);
for await (const chunk of streamResponse) {
console.log(`Receiving new event of type: ${chunk.event}...`);
console.log(JSON.stringify(chunk.data));
console.log("\n\n");
}
```
=== "Rest API"
```bash
curl -s --request POST \
--url "http://localhost:8123/runs/stream" \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": \"agent\",
\"input\": {
\"messages\": [
{
\"role\": \"human\",
\"content\": \"What is LangGraph?\"
}
]
},
\"stream_mode\": \"updates\"
}"
```
!!! tip "Auth"
If you're connecting to a remote server, you will need to provide a LangSmith
API Key for authorization. Please see the API Reference for the clients
for more information.
## Next Steps
Now that you have a LangGraph app running locally, take your journey further by exploring deployment and advanced features:
### 🌐 Deploy to LangGraph Cloud
- **[LangGraph Cloud QuickStart](../../cloud/quick_start.md)**: Deploy your LangGraph app using LangGraph Cloud.
### 📚 Learn More about LangGraph Platform
Expand your knowledge with these resources:
- **[LangGraph Platform Concepts](../../concepts/index.md#langgraph-platform)**: Understand the foundational concepts of the LangGraph Platform.
- **[LangGraph Platform How-to Guides](../../how-tos/index.md#langgraph-platform)**: Discover step-by-step guides to build and deploy applications.
### 🛠️ Developer References
Access detailed documentation for development and API usage:
- **[LangGraph Server API Reference](../../cloud/reference/api/api_ref.html)**: Explore the LangGraph Server API documentation.
- **[Python SDK Reference](../../cloud/reference/sdk/python_sdk_ref.md)**: Explore the Python SDK API Reference.
- **[JS/TS SDK Reference](../../cloud/reference/sdk/js_ts_sdk_ref.md)**: Explore the Python SDK API Reference.
@@ -158,6 +158,7 @@
"from typing_extensions import TypedDict\n", "from typing_extensions import TypedDict\n",
"\n", "\n",
"from langchain_anthropic import ChatAnthropic\n", "from langchain_anthropic import ChatAnthropic\n",
"from langgraph.graph import MessagesState\n",
"\n", "\n",
"members = [\"researcher\", \"coder\"]\n", "members = [\"researcher\", \"coder\"]\n",
"# Our team supervisor is an LLM node. It just picks the next agent to process\n", "# Our team supervisor is an LLM node. It just picks the next agent to process\n",
@@ -252,7 +253,7 @@
" }\n", " }\n",
"\n", "\n",
"\n", "\n",
"builder = StateGraph(AgentState)\n", "builder = StateGraph(MessagesState)\n",
"builder.add_edge(START, \"supervisor\")\n", "builder.add_edge(START, \"supervisor\")\n",
"builder.add_node(\"supervisor\", supervisor_node)\n", "builder.add_node(\"supervisor\", supervisor_node)\n",
"builder.add_node(\"researcher\", research_node)\n", "builder.add_node(\"researcher\", research_node)\n",
@@ -934,7 +934,7 @@
"name": "python", "name": "python",
"nbconvert_exporter": "python", "nbconvert_exporter": "python",
"pygments_lexer": "ipython3", "pygments_lexer": "ipython3",
"version": "3.11.4" "version": "3.11.9"
} }
}, },
"nbformat": 4, "nbformat": 4,
+1 -2
View File
@@ -94,7 +94,6 @@ nav:
- Quick Start: - Quick Start:
- Quick Start: tutorials#quick-start - Quick Start: tutorials#quick-start
- tutorials/introduction.ipynb - tutorials/introduction.ipynb
- tutorials/langgraph-platform/local-server.md
- cloud/quick_start.md - cloud/quick_start.md
- Chatbots: - Chatbots:
- Chatbots: tutorials#chatbots - Chatbots: tutorials#chatbots
@@ -439,4 +438,4 @@ validation:
# and those anchors are not available in the actual doc # and those anchors are not available in the actual doc
anchors: info anchors: info
# this is needed to handle headers with anchors for nav # this is needed to handle headers with anchors for nav
not_found: info not_found: info
@@ -23,7 +23,6 @@ from langgraph.store.base import (
Op, Op,
PutOp, PutOp,
Result, Result,
SearchItem,
SearchOp, SearchOp,
) )
@@ -284,7 +283,7 @@ class DuckDBStore(BaseStore, BaseDuckDBStore[duckdb.DuckDBPyConnection]):
for cur, idx in cursors: for cur, idx in cursors:
rows = cur.fetchall() rows = cur.fetchall()
items = [_row_to_search_item(_convert_ns(row[0]), row) for row in rows] items = [_row_to_item(_convert_ns(row[0]), row) for row in rows]
results[idx] = items results[idx] = items
def _batch_list_namespaces_ops( def _batch_list_namespaces_ops(
@@ -377,22 +376,6 @@ def _row_to_item(
) )
def _row_to_search_item(
namespace: tuple[str, ...],
row: tuple,
) -> SearchItem:
"""Convert a row from the database into an SearchItem."""
# TODO: Add support for search
_, key, val, created_at, updated_at = row
return SearchItem(
value=val if isinstance(val, dict) else json.loads(val),
key=key,
namespace=namespace,
created_at=created_at,
updated_at=updated_at,
)
def _group_ops(ops: Iterable[Op]) -> tuple[dict[type, list[tuple[int, Op]]], int]: def _group_ops(ops: Iterable[Op]) -> tuple[dict[type, list[tuple[int, Op]]], int]:
grouped_ops: dict[type, list[tuple[int, Op]]] = defaultdict(list) grouped_ops: dict[type, list[tuple[int, Op]]] = defaultdict(list)
tot = 0 tot = 0
+1 -5
View File
@@ -5,11 +5,7 @@
###################### ######################
start-postgres: start-postgres:
POSTGRES_VERSION=${POSTGRES_VERSION:-16} docker compose -f tests/compose-postgres.yml up -V --force-recreate --wait || ( \ POSTGRES_VERSION=${POSTGRES_VERSION:-16} docker compose -f tests/compose-postgres.yml up -V --force-recreate --wait
echo "Failed to start PostgreSQL, printing logs..."; \
docker compose -f tests/compose-postgres.yml logs; \
exit 1 \
)
stop-postgres: stop-postgres:
docker compose -f tests/compose-postgres.yml down docker compose -f tests/compose-postgres.yml down
@@ -1,10 +1,10 @@
import threading import threading
from collections.abc import Iterator, Sequence
from contextlib import contextmanager from contextlib import contextmanager
from typing import Any, Optional from typing import Any, Iterator, Optional, Sequence, Union
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from psycopg import Capabilities, Connection, Cursor, Pipeline from psycopg import Capabilities, Connection, Cursor, Pipeline
from psycopg.errors import UndefinedTable
from psycopg.rows import DictRow, dict_row from psycopg.rows import DictRow, dict_row
from psycopg.types.json import Jsonb from psycopg.types.json import Jsonb
from psycopg_pool import ConnectionPool from psycopg_pool import ConnectionPool
@@ -17,11 +17,21 @@ from langgraph.checkpoint.base import (
CheckpointTuple, CheckpointTuple,
get_checkpoint_id, get_checkpoint_id,
) )
from langgraph.checkpoint.postgres import _internal
from langgraph.checkpoint.postgres.base import BasePostgresSaver from langgraph.checkpoint.postgres.base import BasePostgresSaver
from langgraph.checkpoint.serde.base import SerializerProtocol from langgraph.checkpoint.serde.base import SerializerProtocol
Conn = _internal.Conn # For backward compatibility Conn = Union[Connection[DictRow], ConnectionPool[Connection[DictRow]]]
@contextmanager
def _get_connection(conn: Conn) -> Iterator[Connection[DictRow]]:
if isinstance(conn, Connection):
yield conn
elif isinstance(conn, ConnectionPool):
with conn.connection() as conn:
yield conn
else:
raise TypeError(f"Invalid connection type: {type(conn)}")
class PostgresSaver(BasePostgresSaver): class PostgresSaver(BasePostgresSaver):
@@ -29,7 +39,7 @@ class PostgresSaver(BasePostgresSaver):
def __init__( def __init__(
self, self,
conn: _internal.Conn, conn: Conn,
pipe: Optional[Pipeline] = None, pipe: Optional[Pipeline] = None,
serde: Optional[SerializerProtocol] = None, serde: Optional[SerializerProtocol] = None,
) -> None: ) -> None:
@@ -63,9 +73,9 @@ class PostgresSaver(BasePostgresSaver):
) as conn: ) as conn:
if pipeline: if pipeline:
with conn.pipeline() as pipe: with conn.pipeline() as pipe:
yield cls(conn, pipe) yield PostgresSaver(conn, pipe)
else: else:
yield cls(conn) yield PostgresSaver(conn)
def setup(self) -> None: def setup(self) -> None:
"""Set up the checkpoint database asynchronously. """Set up the checkpoint database asynchronously.
@@ -75,15 +85,16 @@ class PostgresSaver(BasePostgresSaver):
the first time checkpointer is used. the first time checkpointer is used.
""" """
with self._cursor() as cur: with self._cursor() as cur:
cur.execute(self.MIGRATIONS[0]) try:
results = cur.execute( row = cur.execute(
"SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1" "SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
) ).fetchone()
row = results.fetchone() if row is None:
if row is None: version = -1
else:
version = row["v"]
except UndefinedTable:
version = -1 version = -1
else:
version = row["v"]
for v, migration in zip( for v, migration in zip(
range(version + 1, len(self.MIGRATIONS)), range(version + 1, len(self.MIGRATIONS)),
self.MIGRATIONS[version + 1 :], self.MIGRATIONS[version + 1 :],
@@ -362,7 +373,7 @@ class PostgresSaver(BasePostgresSaver):
Will be applied regardless of whether the PostgresSaver instance was initialized with a pipeline. Will be applied regardless of whether the PostgresSaver instance was initialized with a pipeline.
If pipeline mode is not supported, will fall back to using transaction context manager. If pipeline mode is not supported, will fall back to using transaction context manager.
""" """
with _internal.get_connection(self.conn) as conn: with _get_connection(self.conn) as conn:
if self.pipe: if self.pipe:
# a connection in pipeline mode can be used concurrently # a connection in pipeline mode can be used concurrently
# in multiple threads/coroutines, but only one cursor can be # in multiple threads/coroutines, but only one cursor can be
@@ -377,23 +388,19 @@ class PostgresSaver(BasePostgresSaver):
# a connection not in pipeline mode can only be used by one # a connection not in pipeline mode can only be used by one
# thread/coroutine at a time, so we acquire a lock # thread/coroutine at a time, so we acquire a lock
if self.supports_pipeline: if self.supports_pipeline:
with ( with self.lock, conn.pipeline(), conn.cursor(
self.lock, binary=True, row_factory=dict_row
conn.pipeline(), ) as cur:
conn.cursor(binary=True, row_factory=dict_row) as cur,
):
yield cur yield cur
else: else:
# Use connection's transaction context manager when pipeline mode not supported # Use connection's transaction context manager when pipeline mode not supported
with ( with self.lock, conn.transaction(), conn.cursor(
self.lock, binary=True, row_factory=dict_row
conn.transaction(), ) as cur:
conn.cursor(binary=True, row_factory=dict_row) as cur,
):
yield cur yield cur
else: else:
with self.lock, conn.cursor(binary=True, row_factory=dict_row) as cur: with self.lock, conn.cursor(binary=True, row_factory=dict_row) as cur:
yield cur yield cur
__all__ = ["PostgresSaver", "BasePostgresSaver", "Conn"] __all__ = ["PostgresSaver", "Conn"]
@@ -1,24 +0,0 @@
"""Shared async utility functions for the Postgres checkpoint & storage classes."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Union
from psycopg import AsyncConnection
from psycopg.rows import DictRow
from psycopg_pool import AsyncConnectionPool
Conn = Union[AsyncConnection[DictRow], AsyncConnectionPool[AsyncConnection[DictRow]]]
@asynccontextmanager
async def get_connection(
conn: Conn,
) -> AsyncIterator[AsyncConnection[DictRow]]:
if isinstance(conn, AsyncConnection):
yield conn
elif isinstance(conn, AsyncConnectionPool):
async with conn.connection() as conn:
yield conn
else:
raise TypeError(f"Invalid connection type: {type(conn)}")
@@ -1,22 +0,0 @@
"""Shared utility functions for the Postgres checkpoint & storage classes."""
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Union
from psycopg import Connection
from psycopg.rows import DictRow
from psycopg_pool import ConnectionPool
Conn = Union[Connection[DictRow], ConnectionPool[Connection[DictRow]]]
@contextmanager
def get_connection(conn: Conn) -> Iterator[Connection[DictRow]]:
if isinstance(conn, Connection):
yield conn
elif isinstance(conn, ConnectionPool):
with conn.connection() as conn:
yield conn
else:
raise TypeError(f"Invalid connection type: {type(conn)}")
@@ -1,10 +1,10 @@
import asyncio import asyncio
from collections.abc import AsyncIterator, Iterator, Sequence
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any, Optional from typing import Any, AsyncIterator, Iterator, Optional, Sequence, Union
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
from psycopg.errors import UndefinedTable
from psycopg.rows import DictRow, dict_row from psycopg.rows import DictRow, dict_row
from psycopg.types.json import Jsonb from psycopg.types.json import Jsonb
from psycopg_pool import AsyncConnectionPool from psycopg_pool import AsyncConnectionPool
@@ -17,11 +17,23 @@ from langgraph.checkpoint.base import (
CheckpointTuple, CheckpointTuple,
get_checkpoint_id, get_checkpoint_id,
) )
from langgraph.checkpoint.postgres import _ainternal
from langgraph.checkpoint.postgres.base import BasePostgresSaver from langgraph.checkpoint.postgres.base import BasePostgresSaver
from langgraph.checkpoint.serde.base import SerializerProtocol from langgraph.checkpoint.serde.base import SerializerProtocol
Conn = _ainternal.Conn # For backward compatibility Conn = Union[AsyncConnection[DictRow], AsyncConnectionPool[AsyncConnection[DictRow]]]
@asynccontextmanager
async def _get_connection(
conn: Conn,
) -> AsyncIterator[AsyncConnection[DictRow]]:
if isinstance(conn, AsyncConnection):
yield conn
elif isinstance(conn, AsyncConnectionPool):
async with conn.connection() as conn:
yield conn
else:
raise TypeError(f"Invalid connection type: {type(conn)}")
class AsyncPostgresSaver(BasePostgresSaver): class AsyncPostgresSaver(BasePostgresSaver):
@@ -29,7 +41,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
def __init__( def __init__(
self, self,
conn: _ainternal.Conn, conn: Conn,
pipe: Optional[AsyncPipeline] = None, pipe: Optional[AsyncPipeline] = None,
serde: Optional[SerializerProtocol] = None, serde: Optional[SerializerProtocol] = None,
) -> None: ) -> None:
@@ -68,9 +80,9 @@ class AsyncPostgresSaver(BasePostgresSaver):
) as conn: ) as conn:
if pipeline: if pipeline:
async with conn.pipeline() as pipe: async with conn.pipeline() as pipe:
yield cls(conn=conn, pipe=pipe, serde=serde) yield AsyncPostgresSaver(conn=conn, pipe=pipe, serde=serde)
else: else:
yield cls(conn=conn, serde=serde) yield AsyncPostgresSaver(conn=conn, serde=serde)
async def setup(self) -> None: async def setup(self) -> None:
"""Set up the checkpoint database asynchronously. """Set up the checkpoint database asynchronously.
@@ -80,15 +92,17 @@ class AsyncPostgresSaver(BasePostgresSaver):
the first time checkpointer is used. the first time checkpointer is used.
""" """
async with self._cursor() as cur: async with self._cursor() as cur:
await cur.execute(self.MIGRATIONS[0]) try:
results = await cur.execute( results = await cur.execute(
"SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1" "SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
) )
row = await results.fetchone() row = await results.fetchone()
if row is None: if row is None:
version = -1
else:
version = row["v"]
except UndefinedTable:
version = -1 version = -1
else:
version = row["v"]
for v, migration in zip( for v, migration in zip(
range(version + 1, len(self.MIGRATIONS)), range(version + 1, len(self.MIGRATIONS)),
self.MIGRATIONS[version + 1 :], self.MIGRATIONS[version + 1 :],
@@ -143,17 +157,15 @@ class AsyncPostgresSaver(BasePostgresSaver):
value["pending_sends"], value["pending_sends"],
), ),
self._load_metadata(value["metadata"]), self._load_metadata(value["metadata"]),
( {
{ "configurable": {
"configurable": { "thread_id": value["thread_id"],
"thread_id": value["thread_id"], "checkpoint_ns": value["checkpoint_ns"],
"checkpoint_ns": value["checkpoint_ns"], "checkpoint_id": value["parent_checkpoint_id"],
"checkpoint_id": value["parent_checkpoint_id"],
}
} }
if value["parent_checkpoint_id"] }
else None if value["parent_checkpoint_id"]
), else None,
await asyncio.to_thread(self._load_writes, value["pending_writes"]), await asyncio.to_thread(self._load_writes, value["pending_writes"]),
) )
@@ -204,17 +216,15 @@ class AsyncPostgresSaver(BasePostgresSaver):
value["pending_sends"], value["pending_sends"],
), ),
self._load_metadata(value["metadata"]), self._load_metadata(value["metadata"]),
( {
{ "configurable": {
"configurable": { "thread_id": thread_id,
"thread_id": thread_id, "checkpoint_ns": checkpoint_ns,
"checkpoint_ns": checkpoint_ns, "checkpoint_id": value["parent_checkpoint_id"],
"checkpoint_id": value["parent_checkpoint_id"],
}
} }
if value["parent_checkpoint_id"] }
else None if value["parent_checkpoint_id"]
), else None,
await asyncio.to_thread(self._load_writes, value["pending_writes"]), await asyncio.to_thread(self._load_writes, value["pending_writes"]),
) )
@@ -321,7 +331,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
Will be applied regardless of whether the AsyncPostgresSaver instance was initialized with a pipeline. Will be applied regardless of whether the AsyncPostgresSaver instance was initialized with a pipeline.
If pipeline mode is not supported, will fall back to using transaction context manager. If pipeline mode is not supported, will fall back to using transaction context manager.
""" """
async with _ainternal.get_connection(self.conn) as conn: async with _get_connection(self.conn) as conn:
if self.pipe: if self.pipe:
# a connection in pipeline mode can be used concurrently # a connection in pipeline mode can be used concurrently
# in multiple threads/coroutines, but only one cursor can be # in multiple threads/coroutines, but only one cursor can be
@@ -336,25 +346,20 @@ class AsyncPostgresSaver(BasePostgresSaver):
# a connection not in pipeline mode can only be used by one # a connection not in pipeline mode can only be used by one
# thread/coroutine at a time, so we acquire a lock # thread/coroutine at a time, so we acquire a lock
if self.supports_pipeline: if self.supports_pipeline:
async with ( async with self.lock, conn.pipeline(), conn.cursor(
self.lock, binary=True, row_factory=dict_row
conn.pipeline(), ) as cur:
conn.cursor(binary=True, row_factory=dict_row) as cur,
):
yield cur yield cur
else: else:
# Use connection's transaction context manager when pipeline mode not supported # Use connection's transaction context manager when pipeline mode not supported
async with ( async with self.lock, conn.transaction(), conn.cursor(
self.lock, binary=True, row_factory=dict_row
conn.transaction(), ) as cur:
conn.cursor(binary=True, row_factory=dict_row) as cur,
):
yield cur yield cur
else: else:
async with ( async with self.lock, conn.cursor(
self.lock, binary=True, row_factory=dict_row
conn.cursor(binary=True, row_factory=dict_row) as cur, ) as cur:
):
yield cur yield cur
def list( def list(
@@ -383,7 +388,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
while True: while True:
try: try:
yield asyncio.run_coroutine_threadsafe( yield asyncio.run_coroutine_threadsafe(
anext(aiter_), # noqa: F821 anext(aiter_),
self.loop, self.loop,
).result() ).result()
except StopAsyncIteration: except StopAsyncIteration:
@@ -462,6 +467,3 @@ class AsyncPostgresSaver(BasePostgresSaver):
return asyncio.run_coroutine_threadsafe( return asyncio.run_coroutine_threadsafe(
self.aput_writes(config, writes, task_id), self.loop self.aput_writes(config, writes, task_id), self.loop
).result() ).result()
__all__ = ["AsyncPostgresSaver", "Conn"]
@@ -1,6 +1,5 @@
import random import random
from collections.abc import Sequence from typing import Any, List, Optional, Sequence, Tuple, cast
from typing import Any, Optional, cast
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from psycopg.types.json import Jsonb from psycopg.types.json import Jsonb
@@ -250,7 +249,7 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
config: Optional[RunnableConfig], config: Optional[RunnableConfig],
filter: MetadataInput, filter: MetadataInput,
before: Optional[RunnableConfig] = None, before: Optional[RunnableConfig] = None,
) -> tuple[str, list[Any]]: ) -> Tuple[str, List[Any]]:
"""Return WHERE clause predicates for alist() given config, filter, before. """Return WHERE clause predicates for alist() given config, filter, before.
This method returns a tuple of a string and a tuple of values. The string This method returns a tuple of a string and a tuple of values. The string
@@ -1,241 +1,110 @@
import asyncio import asyncio
import logging import logging
from collections.abc import AsyncIterator, Iterable, Sequence
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any, Callable, Optional, Union, cast from typing import (
Any,
AsyncIterator,
Callable,
Iterable,
Optional,
Sequence,
Union,
cast,
)
import orjson import orjson
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities from psycopg import AsyncConnection, AsyncCursor
from psycopg.errors import UndefinedTable from psycopg.errors import UndefinedTable
from psycopg.rows import DictRow, dict_row from psycopg.rows import dict_row
from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.postgres import _ainternal from langgraph.store.base import GetOp, ListNamespacesOp, Op, PutOp, Result, SearchOp
from langgraph.store.base import (
GetOp,
ListNamespacesOp,
Op,
PutOp,
Result,
SearchOp,
)
from langgraph.store.base.batch import AsyncBatchedBaseStore from langgraph.store.base.batch import AsyncBatchedBaseStore
from langgraph.store.postgres.base import ( from langgraph.store.postgres.base import (
_PLACEHOLDER,
BasePostgresStore, BasePostgresStore,
PoolConfig,
PostgresIndexConfig,
Row, Row,
_decode_ns_bytes, _decode_ns_bytes,
_ensure_index_config,
_group_ops, _group_ops,
_row_to_item, _row_to_item,
_row_to_search_item,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Conn]): class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[AsyncConnection]):
__slots__ = ( __slots__ = ("_deserializer",)
"_deserializer",
"pipe",
"lock",
"supports_pipeline",
"index_config",
"embeddings",
)
def __init__( def __init__(
self, self,
conn: _ainternal.Conn, conn: AsyncConnection[Any],
*, *,
pipe: Optional[AsyncPipeline] = None,
deserializer: Optional[ deserializer: Optional[
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]] Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
] = None, ] = None,
index: Optional[PostgresIndexConfig] = None,
) -> None: ) -> None:
if isinstance(conn, AsyncConnectionPool) and pipe is not None:
raise ValueError(
"Pipeline should be used only with a single AsyncConnection, not AsyncConnectionPool."
)
super().__init__() super().__init__()
self._deserializer = deserializer self._deserializer = deserializer
self.conn = conn self.conn = conn
self.pipe = pipe
self.lock = asyncio.Lock()
self.loop = asyncio.get_running_loop() self.loop = asyncio.get_running_loop()
self.supports_pipeline = Capabilities().has_pipeline()
self.index_config = index
if self.index_config:
self.embeddings, self.index_config = _ensure_index_config(self.index_config)
else:
self.embeddings = None
async def abatch(self, ops: Iterable[Op]) -> list[Result]: async def abatch(self, ops: Iterable[Op]) -> list[Result]:
grouped_ops, num_ops = _group_ops(ops) grouped_ops, num_ops = _group_ops(ops)
results: list[Result] = [None] * num_ops results: list[Result] = [None] * num_ops
async with _ainternal.get_connection(self.conn) as conn: async with self.conn.pipeline():
if self.pipe: tasks = []
async with self.pipe:
await self._execute_batch(grouped_ops, results, conn) if GetOp in grouped_ops:
else: tasks.append(
await self._execute_batch(grouped_ops, results, conn) self._batch_get_ops(
cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results
)
)
if PutOp in grouped_ops:
tasks.append(
self._batch_put_ops(
cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp])
)
)
if SearchOp in grouped_ops:
tasks.append(
self._batch_search_ops(
cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
results,
)
)
if ListNamespacesOp in grouped_ops:
tasks.append(
self._batch_list_namespaces_ops(
cast(
Sequence[tuple[int, ListNamespacesOp]],
grouped_ops[ListNamespacesOp],
),
results,
)
)
await asyncio.gather(*tasks)
return results return results
def batch(self, ops: Iterable[Op]) -> list[Result]: def batch(self, ops: Iterable[Op]) -> list[Result]:
return asyncio.run_coroutine_threadsafe(self.abatch(ops), self.loop).result() return asyncio.run_coroutine_threadsafe(self.abatch(ops), self.loop).result()
@classmethod
@asynccontextmanager
async def from_conn_string(
cls,
conn_string: str,
*,
pipeline: bool = False,
pool_config: Optional[PoolConfig] = None,
index: Optional[PostgresIndexConfig] = None,
) -> AsyncIterator["AsyncPostgresStore"]:
"""Create a new AsyncPostgresStore instance from a connection string.
Args:
conn_string (str): The Postgres connection info string.
pipeline (bool): Whether to use AsyncPipeline (only for single connections)
pool_config (Optional[PoolConfig]): Configuration for the connection pool.
If provided, will create a connection pool and use it instead of a single connection.
This overrides the `pipeline` argument.
index (Optional[PostgresIndexConfig]): The embedding config.
Returns:
AsyncPostgresStore: A new AsyncPostgresStore instance.
"""
if pool_config is not None:
pc = pool_config.copy()
async with cast(
AsyncConnectionPool[AsyncConnection[DictRow]],
AsyncConnectionPool(
conn_string,
min_size=pc.pop("min_size", 1),
max_size=pc.pop("max_size", None),
kwargs={
"autocommit": True,
"prepare_threshold": 0,
"row_factory": dict_row,
**(pc.pop("kwargs", None) or {}),
},
**cast(dict, pc),
),
) as pool:
yield cls(conn=pool, index=index)
else:
async with await AsyncConnection.connect(
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
) as conn:
if pipeline:
async with conn.pipeline() as pipe:
yield cls(conn=conn, pipe=pipe, index=index)
else:
yield cls(conn=conn, index=index)
async def setup(self) -> None:
"""Set up the store database asynchronously.
This method creates the necessary tables in the Postgres database if they don't
already exist and runs database migrations. It MUST be called directly by the user
the first time the store is used.
"""
async def _get_version(cur: AsyncCursor[DictRow], table: str) -> int:
try:
await cur.execute(f"SELECT v FROM {table} ORDER BY v DESC LIMIT 1")
row = await cur.fetchone()
if row is None:
version = -1
else:
version = row["v"]
except UndefinedTable:
version = -1
await cur.execute(
f"""
CREATE TABLE IF NOT EXISTS {table} (
v INTEGER PRIMARY KEY
)
"""
)
return version
async with self._cursor() as cur:
version = await _get_version(cur, table="store_migrations")
for v, sql in enumerate(self.MIGRATIONS[version + 1 :], start=version + 1):
await cur.execute(sql)
await cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,))
if self.index_config:
version = await _get_version(cur, table="vector_migrations")
for v, migration in enumerate(
self.VECTOR_MIGRATIONS[version + 1 :], start=version + 1
):
sql = migration.sql
if migration.params:
params = {
k: v(self) if v is not None and callable(v) else v
for k, v in migration.params.items()
}
sql = sql % params
await cur.execute(sql)
await cur.execute(
"INSERT INTO vector_migrations (v) VALUES (%s)", (v,)
)
async def _execute_batch(
self,
grouped_ops: dict,
results: list[Result],
conn: AsyncConnection[DictRow],
) -> None:
async with self._cursor(pipeline=True) as cur:
if GetOp in grouped_ops:
await self._batch_get_ops(
cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]),
results,
cur,
)
if SearchOp in grouped_ops:
await self._batch_search_ops(
cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
results,
cur,
)
if ListNamespacesOp in grouped_ops:
await self._batch_list_namespaces_ops(
cast(
Sequence[tuple[int, ListNamespacesOp]],
grouped_ops[ListNamespacesOp],
),
results,
cur,
)
if PutOp in grouped_ops:
await self._batch_put_ops(
cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp]),
cur,
)
async def _batch_get_ops( async def _batch_get_ops(
self, self,
get_ops: Sequence[tuple[int, GetOp]], get_ops: Sequence[tuple[int, GetOp]],
results: list[Result], results: list[Result],
cur: AsyncCursor[DictRow],
) -> None: ) -> None:
cursors = []
for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops): for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops):
cur = self.conn.cursor(binary=True)
await cur.execute(query, params) await cur.execute(query, params)
cursors.append((cur, namespace, items))
for cur, namespace, items in cursors:
rows = cast(list[Row], await cur.fetchall()) rows = cast(list[Row], await cur.fetchall())
key_to_row = {row["key"]: row for row in rows} key_to_row = {row["key"]: row for row in rows}
for idx, key in items: for idx, key in items:
@@ -250,59 +119,29 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
async def _batch_put_ops( async def _batch_put_ops(
self, self,
put_ops: Sequence[tuple[int, PutOp]], put_ops: Sequence[tuple[int, PutOp]],
cur: AsyncCursor[DictRow],
) -> None: ) -> None:
queries, embedding_request = self._prepare_batch_PUT_queries(put_ops) queries = self._get_batch_PUT_queries(put_ops)
if embedding_request:
if self.embeddings is None:
# Should not get here since the embedding config is required
# to return an embedding_request above
raise ValueError(
"Embedding configuration is required for vector operations "
f"(for semantic search). "
f"Please provide an EmbeddingConfig when initializing the {self.__class__.__name__}."
)
query, txt_params = embedding_request
vectors = await self.embeddings.aembed_documents(
[param[-1] for param in txt_params]
)
queries.append(
(
query,
[
p
for (ns, k, pathname, _), vector in zip(txt_params, vectors)
for p in (ns, k, pathname, vector)
],
)
)
for query, params in queries: for query, params in queries:
cur = self.conn.cursor(binary=True)
await cur.execute(query, params) await cur.execute(query, params)
async def _batch_search_ops( async def _batch_search_ops(
self, self,
search_ops: Sequence[tuple[int, SearchOp]], search_ops: Sequence[tuple[int, SearchOp]],
results: list[Result], results: list[Result],
cur: AsyncCursor[DictRow],
) -> None: ) -> None:
queries, embedding_requests = self._prepare_batch_search_queries(search_ops) queries = self._get_batch_search_queries(search_ops)
cursors: list[tuple[AsyncCursor[Any], int]] = []
if embedding_requests and self.embeddings: for (query, params), (idx, _) in zip(queries, search_ops):
vectors = await self.embeddings.aembed_documents( cur = self.conn.cursor(binary=True)
[query for _, query in embedding_requests]
)
for (idx, _), vector in zip(embedding_requests, vectors):
_paramslist = queries[idx][1]
for i in range(len(_paramslist)):
if _paramslist[i] is _PLACEHOLDER:
_paramslist[i] = vector
for (idx, _), (query, params) in zip(search_ops, queries):
await cur.execute(query, params) await cur.execute(query, params)
cursors.append((cur, idx))
for cur, idx in cursors:
rows = cast(list[Row], await cur.fetchall()) rows = cast(list[Row], await cur.fetchall())
items = [ items = [
_row_to_search_item( _row_to_item(
_decode_ns_bytes(row["prefix"]), row, loader=self._deserializer _decode_ns_bytes(row["prefix"]), row, loader=self._deserializer
) )
for row in rows for row in rows
@@ -313,57 +152,67 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
self, self,
list_ops: Sequence[tuple[int, ListNamespacesOp]], list_ops: Sequence[tuple[int, ListNamespacesOp]],
results: list[Result], results: list[Result],
cur: AsyncCursor[DictRow],
) -> None: ) -> None:
queries = self._get_batch_list_namespaces_queries(list_ops) queries = self._get_batch_list_namespaces_queries(list_ops)
cursors: list[tuple[AsyncCursor[Any], int]] = []
for (query, params), (idx, _) in zip(queries, list_ops): for (query, params), (idx, _) in zip(queries, list_ops):
cur = self.conn.cursor(binary=True)
await cur.execute(query, params) await cur.execute(query, params)
cursors.append((cur, idx))
for cur, idx in cursors:
rows = cast(list[dict], await cur.fetchall()) rows = cast(list[dict], await cur.fetchall())
namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows] namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows]
results[idx] = namespaces results[idx] = namespaces
@classmethod
@asynccontextmanager @asynccontextmanager
async def _cursor( async def from_conn_string(
self, *, pipeline: bool = False cls,
) -> AsyncIterator[AsyncCursor[DictRow]]: conn_string: str,
"""Create a database cursor as a context manager. ) -> AsyncIterator["AsyncPostgresStore"]:
"""Create a new AsyncPostgresStore instance from a connection string.
Args: Args:
pipeline: whether to use pipeline for the DB operations inside the context manager. conn_string (str): The Postgres connection info string.
Will be applied regardless of whether the PostgresStore instance was initialized with a pipeline.
If pipeline mode is not supported, will fall back to using transaction context manager. Returns:
AsyncPostgresStore: A new AsyncPostgresStore instance.
""" """
async with _ainternal.get_connection(self.conn) as conn: async with await AsyncConnection.connect(
if self.pipe: conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
# a connection in pipeline mode can be used concurrently ) as conn:
# in multiple threads/coroutines, but only one cursor can be yield cls(conn=conn)
# used at a time
try: async def setup(self) -> None:
async with conn.cursor(binary=True, row_factory=dict_row) as cur: """Set up the store database asynchronously.
yield cur
finally: This method creates the necessary tables in the Postgres database if they don't
if pipeline: already exist and runs database migrations. It MUST be called directly by the user
await self.pipe.sync() the first time the store is used.
elif pipeline: """
# a connection not in pipeline mode can only be used by one async with self.conn.cursor() as cur:
# thread/coroutine at a time, so we acquire a lock try:
if self.supports_pipeline: await cur.execute(
async with ( "SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1"
self.lock, )
conn.pipeline(), row = cast(dict, await cur.fetchone())
conn.cursor(binary=True, row_factory=dict_row) as cur, if row is None:
): version = -1
yield cur
else: else:
async with ( version = row["v"]
self.lock, except UndefinedTable:
conn.transaction(), version = -1
conn.cursor(binary=True, row_factory=dict_row) as cur, # Create store_migrations table if it doesn't exist
): await cur.execute(
yield cur """
else: CREATE TABLE IF NOT EXISTS store_migrations (
async with ( v INTEGER PRIMARY KEY
self.lock, )
conn.cursor(binary=True) as cur, """
): )
yield cur for v, migration in enumerate(
self.MIGRATIONS[version + 1 :], start=version + 1
):
await cur.execute(migration)
await cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,))
File diff suppressed because it is too large Load Diff
+426 -529
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "langgraph-checkpoint-postgres" name = "langgraph-checkpoint-postgres"
version = "2.0.7" version = "2.0.3"
description = "Library with a Postgres implementation of LangGraph checkpoint saver." description = "Library with a Postgres implementation of LangGraph checkpoint saver."
authors = [] authors = []
license = "MIT" license = "MIT"
@@ -10,10 +10,10 @@ packages = [{ include = "langgraph" }]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.9.0,<4.0" python = "^3.9.0,<4.0"
langgraph-checkpoint = "^2.0.7" langgraph-checkpoint = "^2.0.2"
orjson = ">=3.10.1" orjson = ">=3.10.1"
psycopg = "^3.2.0" psycopg = "^3.0.0"
psycopg-pool = "^3.2.0" psycopg-pool = "^3.0.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
ruff = "^0.6.2" ruff = "^0.6.2"
@@ -1,13 +1,12 @@
services: services:
postgres-test: postgres-test:
image: pgvector/pgvector:pg${POSTGRES_VERSION:-16} image: postgres:${POSTGRES_VERSION:-16}
ports: ports:
- "5441:5432" - "5441:5432"
environment: environment:
POSTGRES_DB: postgres POSTGRES_DB: postgres
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
command: ["postgres", "-c", "shared_preload_libraries=vector"]
healthcheck: healthcheck:
test: pg_isready -U postgres test: pg_isready -U postgres
start_period: 10s start_period: 10s
+1 -16
View File
@@ -1,13 +1,10 @@
from collections.abc import AsyncIterator from typing import AsyncIterator
import pytest import pytest
from psycopg import AsyncConnection from psycopg import AsyncConnection
from psycopg.errors import UndefinedTable from psycopg.errors import UndefinedTable
from psycopg.rows import DictRow, dict_row from psycopg.rows import DictRow, dict_row
from tests.embed_test_utils import CharacterEmbeddings
DEFAULT_POSTGRES_URI = "postgres://postgres:postgres@localhost:5441/"
DEFAULT_URI = "postgres://postgres:postgres@localhost:5441/postgres?sslmode=disable" DEFAULT_URI = "postgres://postgres:postgres@localhost:5441/postgres?sslmode=disable"
@@ -27,18 +24,6 @@ async def clear_test_db(conn: AsyncConnection[DictRow]) -> None:
await conn.execute("DELETE FROM checkpoint_blobs") await conn.execute("DELETE FROM checkpoint_blobs")
await conn.execute("DELETE FROM checkpoint_writes") await conn.execute("DELETE FROM checkpoint_writes")
await conn.execute("DELETE FROM checkpoint_migrations") await conn.execute("DELETE FROM checkpoint_migrations")
except UndefinedTable:
pass
try:
await conn.execute("DELETE FROM store_migrations") await conn.execute("DELETE FROM store_migrations")
await conn.execute("DELETE FROM store")
except UndefinedTable: except UndefinedTable:
pass pass
@pytest.fixture
def fake_embeddings() -> CharacterEmbeddings:
return CharacterEmbeddings(dims=500)
VECTOR_TYPES = ["vector", "halfvec"]
@@ -1,55 +0,0 @@
"""Embedding utilities for testing."""
import math
import random
from collections import Counter, defaultdict
from typing import Any
from langchain_core.embeddings import Embeddings
class CharacterEmbeddings(Embeddings):
"""Simple character-frequency based embeddings using random projections."""
def __init__(self, dims: int = 50, seed: int = 42):
"""Initialize with embedding dimensions and random seed."""
self._rng = random.Random(seed)
self.dims = dims
# Create projection vector for each character lazily
self._char_projections: defaultdict[str, list[float]] = defaultdict(
lambda: [
self._rng.gauss(0, 1 / math.sqrt(self.dims)) for _ in range(self.dims)
]
)
def _embed_one(self, text: str) -> list[float]:
"""Embed a single text."""
counts = Counter(text)
total = sum(counts.values())
if total == 0:
return [0.0] * self.dims
embedding = [0.0] * self.dims
for char, count in counts.items():
weight = count / total
char_proj = self._char_projections[char]
for i, proj in enumerate(char_proj):
embedding[i] += weight * proj
norm = math.sqrt(sum(x * x for x in embedding))
if norm > 0:
embedding = [x / norm for x in embedding]
return embedding
def embed_documents(self, texts: list[str]) -> list[list[float]]:
"""Embed a list of documents."""
return [self._embed_one(text) for text in texts]
def embed_query(self, text: str) -> list[float]:
"""Embed a query string."""
return self._embed_one(text)
def __eq__(self, other: Any) -> bool:
return isinstance(other, CharacterEmbeddings) and self.dims == other.dims
+85 -200
View File
@@ -1,14 +1,8 @@
# type: ignore
from contextlib import asynccontextmanager
from typing import Any from typing import Any
from uuid import uuid4
import pytest import pytest
from conftest import DEFAULT_URI # type: ignore
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from psycopg import AsyncConnection
from psycopg.rows import dict_row
from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.base import ( from langgraph.checkpoint.base import (
Checkpoint, Checkpoint,
@@ -17,212 +11,103 @@ from langgraph.checkpoint.base import (
empty_checkpoint, empty_checkpoint,
) )
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from tests.conftest import DEFAULT_POSTGRES_URI
@asynccontextmanager class TestAsyncPostgresSaver:
async def _pool_saver(): @pytest.fixture(autouse=True)
"""Fixture for pool mode testing.""" async def setup(self) -> None:
database = f"test_{uuid4().hex[:16]}" # objects for test setup
# create unique db self.config_1: RunnableConfig = {
async with await AsyncConnection.connect( "configurable": {
DEFAULT_POSTGRES_URI, autocommit=True "thread_id": "thread-1",
) as conn: # for backwards compatibility testing
await conn.execute(f"CREATE DATABASE {database}") "thread_ts": "1",
try: "checkpoint_ns": "",
# yield checkpointer }
async with AsyncConnectionPool(
DEFAULT_POSTGRES_URI + database,
max_size=10,
kwargs={"autocommit": True, "row_factory": dict_row},
) as pool:
checkpointer = AsyncPostgresSaver(pool)
await checkpointer.setup()
yield checkpointer
finally:
# drop unique db
async with await AsyncConnection.connect(
DEFAULT_POSTGRES_URI, autocommit=True
) as conn:
await conn.execute(f"DROP DATABASE {database}")
@asynccontextmanager
async def _pipe_saver():
"""Fixture for pipeline mode testing."""
database = f"test_{uuid4().hex[:16]}"
# create unique db
async with await AsyncConnection.connect(
DEFAULT_POSTGRES_URI, autocommit=True
) as conn:
await conn.execute(f"CREATE DATABASE {database}")
try:
async with await AsyncConnection.connect(
DEFAULT_POSTGRES_URI + database,
autocommit=True,
prepare_threshold=0,
row_factory=dict_row,
) as conn:
async with conn.pipeline() as pipe:
checkpointer = AsyncPostgresSaver(conn, pipe=pipe)
await checkpointer.setup()
async with conn.pipeline() as pipe:
checkpointer = AsyncPostgresSaver(conn, pipe=pipe)
yield checkpointer
finally:
# drop unique db
async with await AsyncConnection.connect(
DEFAULT_POSTGRES_URI, autocommit=True
) as conn:
await conn.execute(f"DROP DATABASE {database}")
@asynccontextmanager
async def _base_saver():
"""Fixture for regular connection mode testing."""
database = f"test_{uuid4().hex[:16]}"
# create unique db
async with await AsyncConnection.connect(
DEFAULT_POSTGRES_URI, autocommit=True
) as conn:
await conn.execute(f"CREATE DATABASE {database}")
try:
async with await AsyncConnection.connect(
DEFAULT_POSTGRES_URI + database,
autocommit=True,
prepare_threshold=0,
row_factory=dict_row,
) as conn:
checkpointer = AsyncPostgresSaver(conn)
await checkpointer.setup()
yield checkpointer
finally:
# drop unique db
async with await AsyncConnection.connect(
DEFAULT_POSTGRES_URI, autocommit=True
) as conn:
await conn.execute(f"DROP DATABASE {database}")
@asynccontextmanager
async def _saver(name: str):
if name == "base":
async with _base_saver() as saver:
yield saver
elif name == "pool":
async with _pool_saver() as saver:
yield saver
elif name == "pipe":
async with _pipe_saver() as saver:
yield saver
@pytest.fixture
def test_data():
"""Fixture providing test data for checkpoint tests."""
config_1: RunnableConfig = {
"configurable": {
"thread_id": "thread-1",
# for backwards compatibility testing
"thread_ts": "1",
"checkpoint_ns": "",
} }
} self.config_2: RunnableConfig = {
config_2: RunnableConfig = { "configurable": {
"configurable": { "thread_id": "thread-2",
"thread_id": "thread-2", "checkpoint_id": "2",
"checkpoint_id": "2", "checkpoint_ns": "",
"checkpoint_ns": "", }
} }
} self.config_3: RunnableConfig = {
config_3: RunnableConfig = { "configurable": {
"configurable": { "thread_id": "thread-2",
"thread_id": "thread-2", "checkpoint_id": "2-inner",
"checkpoint_id": "2-inner", "checkpoint_ns": "inner",
"checkpoint_ns": "inner", }
} }
}
chkpnt_1: Checkpoint = empty_checkpoint() self.chkpnt_1: Checkpoint = empty_checkpoint()
chkpnt_2: Checkpoint = create_checkpoint(chkpnt_1, {}, 1) self.chkpnt_2: Checkpoint = create_checkpoint(self.chkpnt_1, {}, 1)
chkpnt_3: Checkpoint = empty_checkpoint() self.chkpnt_3: Checkpoint = empty_checkpoint()
metadata_1: CheckpointMetadata = { self.metadata_1: CheckpointMetadata = {
"source": "input", "source": "input",
"step": 2, "step": 2,
"writes": {}, "writes": {},
"score": 1, "score": 1,
} }
metadata_2: CheckpointMetadata = { self.metadata_2: CheckpointMetadata = {
"source": "loop", "source": "loop",
"step": 1,
"writes": {"foo": "bar"},
"score": None,
}
metadata_3: CheckpointMetadata = {}
return {
"configs": [config_1, config_2, config_3],
"checkpoints": [chkpnt_1, chkpnt_2, chkpnt_3],
"metadata": [metadata_1, metadata_2, metadata_3],
}
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
async def test_asearch(request, saver_name: str, test_data) -> None:
async with _saver(saver_name) as saver:
configs = test_data["configs"]
checkpoints = test_data["checkpoints"]
metadata = test_data["metadata"]
await saver.aput(configs[0], checkpoints[0], metadata[0], {})
await saver.aput(configs[1], checkpoints[1], metadata[1], {})
await saver.aput(configs[2], checkpoints[2], metadata[2], {})
# call method / assertions
query_1 = {"source": "input"} # search by 1 key
query_2 = {
"step": 1, "step": 1,
"writes": {"foo": "bar"}, "writes": {"foo": "bar"},
} # search by multiple keys "score": None,
query_3: dict[str, Any] = {} # search by no keys, return all checkpoints }
query_4 = {"source": "update", "step": 1} # no match self.metadata_3: CheckpointMetadata = {}
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
await saver.setup()
search_results_1 = [c async for c in saver.alist(None, filter=query_1)] async def test_asearch(self) -> None:
assert len(search_results_1) == 1 async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
assert search_results_1[0].metadata == metadata[0] await saver.aput(self.config_1, self.chkpnt_1, self.metadata_1, {})
await saver.aput(self.config_2, self.chkpnt_2, self.metadata_2, {})
await saver.aput(self.config_3, self.chkpnt_3, self.metadata_3, {})
search_results_2 = [c async for c in saver.alist(None, filter=query_2)] # call method / assertions
assert len(search_results_2) == 1 query_1 = {"source": "input"} # search by 1 key
assert search_results_2[0].metadata == metadata[1] query_2 = {
"step": 1,
"writes": {"foo": "bar"},
} # search by multiple keys
query_3: dict[str, Any] = {} # search by no keys, return all checkpoints
query_4 = {"source": "update", "step": 1} # no match
search_results_3 = [c async for c in saver.alist(None, filter=query_3)] search_results_1 = [c async for c in saver.alist(None, filter=query_1)]
assert len(search_results_3) == 3 assert len(search_results_1) == 1
assert search_results_1[0].metadata == self.metadata_1
search_results_4 = [c async for c in saver.alist(None, filter=query_4)] search_results_2 = [c async for c in saver.alist(None, filter=query_2)]
assert len(search_results_4) == 0 assert len(search_results_2) == 1
assert search_results_2[0].metadata == self.metadata_2
# search by config (defaults to checkpoints across all namespaces) search_results_3 = [c async for c in saver.alist(None, filter=query_3)]
search_results_5 = [ assert len(search_results_3) == 3
c async for c in saver.alist({"configurable": {"thread_id": "thread-2"}})
]
assert len(search_results_5) == 2
assert {
search_results_5[0].config["configurable"]["checkpoint_ns"],
search_results_5[1].config["configurable"]["checkpoint_ns"],
} == {"", "inner"}
search_results_4 = [c async for c in saver.alist(None, filter=query_4)]
assert len(search_results_4) == 0
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"]) # search by config (defaults to checkpoints across all namespaces)
async def test_null_chars(request, saver_name: str, test_data) -> None: search_results_5 = [
async with _saver(saver_name) as saver: c
config = await saver.aput( async for c in saver.alist({"configurable": {"thread_id": "thread-2"}})
test_data["configs"][0], ]
test_data["checkpoints"][0], assert len(search_results_5) == 2
{"my_key": "\x00abc"}, assert {
{}, search_results_5[0].config["configurable"]["checkpoint_ns"],
) search_results_5[1].config["configurable"]["checkpoint_ns"],
assert (await saver.aget_tuple(config)).metadata["my_key"] == "abc" # type: ignore } == {"", "inner"}
assert [c async for c in saver.alist(None, filter={"my_key": "abc"})][
0 # TODO: test before and limit params
].metadata["my_key"] == "abc"
async def test_null_chars(self) -> None:
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
config = await saver.aput(
self.config_1, self.chkpnt_1, {"my_key": "\x00abc"}, {}
)
assert (await saver.aget_tuple(config)).metadata["my_key"] == "abc" # type: ignore
assert [c async for c in saver.alist(None, filter={"my_key": "abc"})][
0
].metadata["my_key"] == "abc"
+377 -355
View File
@@ -1,84 +1,114 @@
# type: ignore # type: ignore
import itertools
import sys
import uuid import uuid
from collections.abc import AsyncIterator from datetime import datetime
from contextlib import asynccontextmanager from typing import Any
from typing import Any, Optional from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from langchain_core.embeddings import Embeddings from conftest import DEFAULT_URI # type: ignore
from psycopg import AsyncConnection
from langgraph.store.base import GetOp, Item, ListNamespacesOp, PutOp, SearchOp from langgraph.store.base import GetOp, Item, ListNamespacesOp, PutOp, SearchOp
from langgraph.store.postgres import AsyncPostgresStore from langgraph.store.postgres import AsyncPostgresStore
from tests.conftest import (
DEFAULT_URI,
VECTOR_TYPES,
CharacterEmbeddings,
)
@pytest.fixture(scope="function", params=["default", "pipe", "pool"]) class MockAsyncCursor:
async def store(request) -> AsyncIterator[AsyncPostgresStore]: def __init__(self, fetch_result: Any) -> None:
if sys.version_info < (3, 10): self.fetch_result = fetch_result
pytest.skip("Async Postgres tests require Python 3.10+") self.execute = AsyncMock()
self.fetchall = AsyncMock(return_value=self.fetch_result)
database = f"test_{uuid.uuid4().hex[:16]}"
uri_parts = DEFAULT_URI.split("/")
uri_base = "/".join(uri_parts[:-1])
query_params = ""
if "?" in uri_parts[-1]:
db_name, query_params = uri_parts[-1].split("?", 1)
query_params = "?" + query_params
conn_string = f"{uri_base}/{database}{query_params}" class MockAsyncConnection:
admin_conn_string = DEFAULT_URI def __init__(self) -> None:
self.cursor = MagicMock()
self.pipeline = MagicMock(
return_value=AsyncMock(__aenter__=AsyncMock(), __aexit__=AsyncMock())
)
async with await AsyncConnection.connect(
admin_conn_string, autocommit=True
) as conn:
await conn.execute(f"CREATE DATABASE {database}")
try:
async with AsyncPostgresStore.from_conn_string(conn_string) as store:
await store.setup()
if request.param == "pipe": @pytest.fixture
async with AsyncPostgresStore.from_conn_string( def mock_connection() -> MockAsyncConnection:
conn_string, pipeline=True return MockAsyncConnection()
) as store:
yield store
elif request.param == "pool": @pytest.fixture
async with AsyncPostgresStore.from_conn_string( async def store(mock_connection: MockAsyncConnection) -> AsyncPostgresStore:
conn_string, pool_config={"min_size": 1, "max_size": 10} return AsyncPostgresStore(mock_connection)
) as store:
yield store
else: # default
async with AsyncPostgresStore.from_conn_string(conn_string) as store:
yield store
finally:
async with await AsyncConnection.connect(
admin_conn_string, autocommit=True
) as conn:
await conn.execute(f"DROP DATABASE {database}")
async def test_abatch_order(store: AsyncPostgresStore) -> None: async def test_abatch_order(store: AsyncPostgresStore) -> None:
# Setup test data mock_connection = store.conn
await store.aput(("test", "foo"), "key1", {"data": "value1"}) mock_get_cursor = MockAsyncCursor(
await store.aput(("test", "bar"), "key2", {"data": "value2"}) [
{
"key": "key1",
"value": '{"data": "value1"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.foo",
},
{
"key": "key2",
"value": '{"data": "value2"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.bar",
},
]
)
mock_search_cursor = MockAsyncCursor(
[
{
"key": "key1",
"value": '{"data": "value1"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.foo",
},
]
)
mock_list_namespaces_cursor = MockAsyncCursor(
[
{"truncated_prefix": b"\x01test"},
]
)
failures = []
def cursor_side_effect(binary: bool = False) -> Any:
cursor = MagicMock()
async def execute_side_effect(query: str, *params: Any) -> None:
# My super sophisticated database.
if "SELECT prefix, key," in query:
cursor.fetchall = mock_search_cursor.fetchall
elif "SELECT DISTINCT ON (truncated_prefix)" in query:
cursor.fetchall = mock_list_namespaces_cursor.fetchall
elif "WHERE prefix = %s AND key" in query:
cursor.fetchall = mock_get_cursor.fetchall
elif "INSERT INTO " in query:
pass
else:
e = ValueError(f"Unmatched query: {query}")
failures.append(e)
raise e
cursor.execute = AsyncMock(side_effect=execute_side_effect)
return cursor
mock_connection.cursor.side_effect = cursor_side_effect # type: ignore
ops = [ ops = [
GetOp(namespace=("test", "foo"), key="key1"), GetOp(namespace=("test",), key="key1"),
PutOp(namespace=("test", "bar"), key="key2", value={"data": "value2"}), PutOp(namespace=("test",), key="key2", value={"data": "value2"}),
SearchOp( SearchOp(
namespace_prefix=("test",), filter={"data": "value1"}, limit=10, offset=0 namespace_prefix=("test",), filter={"data": "value1"}, limit=10, offset=0
), ),
ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0), ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0),
GetOp(namespace=("test",), key="key3"), GetOp(namespace=("test",), key="key3"),
] ]
results = await store.abatch(ops) results = await store.abatch(ops)
assert not failures
assert len(results) == 5 assert len(results) == 5
assert isinstance(results[0], Item) assert isinstance(results[0], Item)
assert isinstance(results[0].value, dict) assert isinstance(results[0].value, dict)
@@ -88,29 +118,27 @@ async def test_abatch_order(store: AsyncPostgresStore) -> None:
assert isinstance(results[2], list) assert isinstance(results[2], list)
assert len(results[2]) == 1 assert len(results[2]) == 1
assert isinstance(results[3], list) assert isinstance(results[3], list)
assert ("test", "foo") in results[3] and ("test", "bar") in results[3] assert results[3] == [("test",)]
assert results[4] is None assert results[4] is None
ops_reordered = [ ops_reordered = [
SearchOp(namespace_prefix=("test",), filter=None, limit=5, offset=0), SearchOp(namespace_prefix=("test",), filter=None, limit=5, offset=0),
GetOp(namespace=("test", "bar"), key="key2"), GetOp(namespace=("test",), key="key2"),
ListNamespacesOp(match_conditions=None, max_depth=None, limit=5, offset=0), ListNamespacesOp(match_conditions=None, max_depth=None, limit=5, offset=0),
PutOp(namespace=("test",), key="key3", value={"data": "value3"}), PutOp(namespace=("test",), key="key3", value={"data": "value3"}),
GetOp(namespace=("test", "foo"), key="key1"), GetOp(namespace=("test",), key="key1"),
] ]
results_reordered = await store.abatch(ops_reordered) results_reordered = await store.abatch(ops_reordered)
assert not failures
assert len(results_reordered) == 5 assert len(results_reordered) == 5
assert isinstance(results_reordered[0], list) assert isinstance(results_reordered[0], list)
assert len(results_reordered[0]) == 2 assert len(results_reordered[0]) == 1
assert isinstance(results_reordered[1], Item) assert isinstance(results_reordered[1], Item)
assert results_reordered[1].value == {"data": "value2"} assert results_reordered[1].value == {"data": "value2"}
assert results_reordered[1].key == "key2" assert results_reordered[1].key == "key2"
assert isinstance(results_reordered[2], list) assert isinstance(results_reordered[2], list)
assert ("test", "foo") in results_reordered[2] and ( assert results_reordered[2] == [("test",)]
"test",
"bar",
) in results_reordered[2]
assert results_reordered[3] is None assert results_reordered[3] is None
assert isinstance(results_reordered[4], Item) assert isinstance(results_reordered[4], Item)
assert results_reordered[4].value == {"data": "value1"} assert results_reordered[4].value == {"data": "value1"}
@@ -118,9 +146,26 @@ async def test_abatch_order(store: AsyncPostgresStore) -> None:
async def test_batch_get_ops(store: AsyncPostgresStore) -> None: async def test_batch_get_ops(store: AsyncPostgresStore) -> None:
# Setup test data mock_connection = store.conn
await store.aput(("test",), "key1", {"data": "value1"}) mock_cursor = MockAsyncCursor(
await store.aput(("test",), "key2", {"data": "value2"}) [
{
"key": "key1",
"value": '{"data": "value1"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.foo",
},
{
"key": "key2",
"value": '{"data": "value2"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.bar",
},
]
)
mock_connection.cursor.return_value = mock_cursor
ops = [ ops = [
GetOp(namespace=("test",), key="key1"), GetOp(namespace=("test",), key="key1"),
@@ -139,6 +184,10 @@ async def test_batch_get_ops(store: AsyncPostgresStore) -> None:
async def test_batch_put_ops(store: AsyncPostgresStore) -> None: async def test_batch_put_ops(store: AsyncPostgresStore) -> None:
mock_connection = store.conn
mock_cursor = MockAsyncCursor([])
mock_connection.cursor.return_value = mock_cursor
ops = [ ops = [
PutOp(namespace=("test",), key="key1", value={"data": "value1"}), PutOp(namespace=("test",), key="key1", value={"data": "value1"}),
PutOp(namespace=("test",), key="key2", value={"data": "value2"}), PutOp(namespace=("test",), key="key2", value={"data": "value2"}),
@@ -149,16 +198,30 @@ async def test_batch_put_ops(store: AsyncPostgresStore) -> None:
assert len(results) == 3 assert len(results) == 3
assert all(result is None for result in results) assert all(result is None for result in results)
assert mock_cursor.execute.call_count == 2
# Verify the puts worked
items = await store.asearch(["test"], limit=10)
assert len(items) == 2 # key3 had None value so wasn't stored
async def test_batch_search_ops(store: AsyncPostgresStore) -> None: async def test_batch_search_ops(store: AsyncPostgresStore) -> None:
# Setup test data mock_connection = store.conn
await store.aput(("test", "foo"), "key1", {"data": "value1"}) mock_cursor = MockAsyncCursor(
await store.aput(("test", "bar"), "key2", {"data": "value2"}) [
{
"key": "key1",
"value": '{"data": "value1"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.foo",
},
{
"key": "key2",
"value": '{"data": "value2"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.bar",
},
]
)
mock_connection.cursor.return_value = mock_cursor
ops = [ ops = [
SearchOp( SearchOp(
@@ -170,338 +233,297 @@ async def test_batch_search_ops(store: AsyncPostgresStore) -> None:
results = await store.abatch(ops) results = await store.abatch(ops)
assert len(results) == 2 assert len(results) == 2
assert len(results[0]) == 1 # Filtered results assert len(results[0]) == 2
assert len(results[1]) == 2 # All results assert len(results[1]) == 2
async def test_batch_list_namespaces_ops(store: AsyncPostgresStore) -> None: async def test_batch_list_namespaces_ops(store: AsyncPostgresStore) -> None:
# Setup test data mock_connection = store.conn
await store.aput(("test", "namespace1"), "key1", {"data": "value1"}) mock_cursor = MockAsyncCursor(
await store.aput(("test", "namespace2"), "key2", {"data": "value2"}) [
{"truncated_prefix": b"\x01test.namespace1"},
{"truncated_prefix": b"\x01test.namespace2"},
]
)
mock_connection.cursor.return_value = mock_cursor
ops = [ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0)] ops = [ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0)]
results = await store.abatch(ops) results = await store.abatch(ops)
assert len(results) == 1 assert len(results) == 1
assert len(results[0]) == 2 assert results[0] == [("test", "namespace1"), ("test", "namespace2")]
assert ("test", "namespace1") in results[0]
assert ("test", "namespace2") in results[0]
@asynccontextmanager # The following use the actual DB connection
async def _create_vector_store(
vector_type: str,
distance_type: str,
fake_embeddings: CharacterEmbeddings,
text_fields: Optional[list[str]] = None,
) -> AsyncIterator[AsyncPostgresStore]:
"""Create a store with vector search enabled."""
if sys.version_info < (3, 10):
pytest.skip("Async Postgres tests require Python 3.10+")
database = f"test_{uuid.uuid4().hex[:16]}"
uri_parts = DEFAULT_URI.split("/")
uri_base = "/".join(uri_parts[:-1])
query_params = ""
if "?" in uri_parts[-1]:
db_name, query_params = uri_parts[-1].split("?", 1)
query_params = "?" + query_params
conn_string = f"{uri_base}/{database}{query_params}" class TestAsyncPostgresStore:
admin_conn_string = DEFAULT_URI @pytest.fixture(autouse=True)
async def setup(self) -> None:
index_config = { async with AsyncPostgresStore.from_conn_string(DEFAULT_URI) as store:
"dims": fake_embeddings.dims,
"embed": fake_embeddings,
"ann_index_config": {
"vector_type": vector_type,
},
"distance_type": distance_type,
"text_fields": text_fields,
}
async with await AsyncConnection.connect(
admin_conn_string, autocommit=True
) as conn:
await conn.execute(f"CREATE DATABASE {database}")
try:
async with AsyncPostgresStore.from_conn_string(
conn_string,
index=index_config,
) as store:
await store.setup() await store.setup()
yield store
finally:
async with await AsyncConnection.connect(
admin_conn_string, autocommit=True
) as conn:
await conn.execute(f"DROP DATABASE {database}")
async def test_basic_store_ops(self) -> None:
async with AsyncPostgresStore.from_conn_string(DEFAULT_URI) as store:
namespace = ("test", "documents")
item_id = "doc1"
item_value = {"title": "Test Document", "content": "Hello, World!"}
@pytest.fixture( await store.aput(namespace, item_id, item_value)
scope="function", item = await store.aget(namespace, item_id)
params=[
(vector_type, distance_type)
for vector_type in VECTOR_TYPES
for distance_type in (
["hamming"] if vector_type == "bit" else ["l2", "inner_product", "cosine"]
)
],
ids=lambda p: f"{p[0]}_{p[1]}",
)
async def vector_store(
request,
fake_embeddings: CharacterEmbeddings,
) -> AsyncIterator[AsyncPostgresStore]:
"""Create a store with vector search enabled."""
vector_type, distance_type = request.param
async with _create_vector_store(
vector_type, distance_type, fake_embeddings
) as store:
yield store
assert item
assert item.namespace == namespace
assert item.key == item_id
assert item.value == item_value
async def test_vector_store_initialization( updated_value = {
vector_store: AsyncPostgresStore, fake_embeddings: CharacterEmbeddings "title": "Updated Test Document",
) -> None: "content": "Hello, LangGraph!",
"""Test store initialization with embedding config.""" }
assert vector_store.index_config is not None await store.aput(namespace, item_id, updated_value)
assert vector_store.index_config["dims"] == fake_embeddings.dims updated_item = await store.aget(namespace, item_id)
if isinstance(vector_store.index_config["embed"], Embeddings):
assert vector_store.index_config["embed"] == fake_embeddings
assert updated_item.value == updated_value
assert updated_item.updated_at > item.updated_at
different_namespace = ("test", "other_documents")
item_in_different_namespace = await store.aget(different_namespace, item_id)
assert item_in_different_namespace is None
async def test_vector_insert_with_auto_embedding( new_item_id = "doc2"
vector_store: AsyncPostgresStore, new_item_value = {"title": "Another Document", "content": "Greetings!"}
) -> None: await store.aput(namespace, new_item_id, new_item_value)
"""Test inserting items that get auto-embedded."""
docs = [
("doc1", {"text": "short text"}),
("doc2", {"text": "longer text document"}),
("doc3", {"text": "longest text document here"}),
("doc4", {"description": "text in description field"}),
("doc5", {"content": "text in content field"}),
("doc6", {"body": "text in body field"}),
]
for key, value in docs: search_results = await store.asearch(["test"], limit=10)
await vector_store.aput(("test",), key, value) items = search_results
assert len(items) == 2
assert any(item.key == item_id for item in items)
assert any(item.key == new_item_id for item in items)
results = await vector_store.asearch(("test",), query="long text") namespaces = await store.alist_namespaces(prefix=["test"])
assert len(results) > 0 assert ("test", "documents") in namespaces
doc_order = [r.key for r in results] await store.adelete(namespace, item_id)
assert "doc2" in doc_order await store.adelete(namespace, new_item_id)
assert "doc3" in doc_order deleted_item = await store.aget(namespace, item_id)
assert deleted_item is None
deleted_item = await store.aget(namespace, new_item_id)
assert deleted_item is None
async def test_vector_update_with_embedding(vector_store: AsyncPostgresStore) -> None: empty_search_results = await store.asearch(["test"], limit=10)
"""Test that updating items properly updates their embeddings.""" assert len(empty_search_results) == 0
await vector_store.aput(("test",), "doc1", {"text": "zany zebra Xerxes"})
await vector_store.aput(("test",), "doc2", {"text": "something about dogs"})
await vector_store.aput(("test",), "doc3", {"text": "text about birds"})
results_initial = await vector_store.asearch(("test",), query="Zany Xerxes") async def test_list_namespaces(self) -> None:
assert len(results_initial) > 0 async with AsyncPostgresStore.from_conn_string(DEFAULT_URI) as store:
assert results_initial[0].key == "doc1" test_pref = str(uuid.uuid4())
initial_score = results_initial[0].score test_namespaces = [
(test_pref, "test", "documents", "public", test_pref),
(test_pref, "test", "documents", "private", test_pref),
(test_pref, "test", "images", "public", test_pref),
(test_pref, "test", "images", "private", test_pref),
(test_pref, "prod", "documents", "public", test_pref),
(
test_pref,
"prod",
"documents",
"some",
"nesting",
"public",
test_pref,
),
(test_pref, "prod", "documents", "private", test_pref),
]
await vector_store.aput(("test",), "doc1", {"text": "new text about dogs"}) for namespace in test_namespaces:
await store.aput(namespace, "dummy", {"content": "dummy"})
results_after = await vector_store.asearch(("test",), query="Zany Xerxes") prefix_result = await store.alist_namespaces(prefix=[test_pref, "test"])
after_score = next((r.score for r in results_after if r.key == "doc1"), 0.0) assert len(prefix_result) == 4
assert after_score < initial_score assert all([ns[1] == "test" for ns in prefix_result])
results_new = await vector_store.asearch(("test",), query="new text about dogs") specific_prefix_result = await store.alist_namespaces(
for r in results_new: prefix=[test_pref, "test", "documents"]
if r.key == "doc1": )
assert r.score > after_score assert len(specific_prefix_result) == 2
assert all(
[ns[1:3] == ("test", "documents") for ns in specific_prefix_result]
)
# Don't index this one suffix_result = await store.alist_namespaces(suffix=["public", test_pref])
await vector_store.aput( assert len(suffix_result) == 4
("test",), "doc4", {"text": "new text about dogs"}, index=False assert all(ns[-2] == "public" for ns in suffix_result)
)
results_new = await vector_store.asearch(
("test",), query="new text about dogs", limit=3
)
assert not any(r.key == "doc4" for r in results_new)
prefix_suffix_result = await store.alist_namespaces(
prefix=[test_pref, "test"], suffix=["public", test_pref]
)
assert len(prefix_suffix_result) == 2
assert all(
ns[1] == "test" and ns[-2] == "public" for ns in prefix_suffix_result
)
async def test_vector_search_with_filters(vector_store: AsyncPostgresStore) -> None: wildcard_prefix_result = await store.alist_namespaces(
"""Test combining vector search with filters.""" prefix=[test_pref, "*", "documents"]
docs = [ )
("doc1", {"text": "red apple", "color": "red", "score": 4.5}), assert len(wildcard_prefix_result) == 5
("doc2", {"text": "red car", "color": "red", "score": 3.0}), assert all(ns[2] == "documents" for ns in wildcard_prefix_result)
("doc3", {"text": "green apple", "color": "green", "score": 4.0}),
("doc4", {"text": "blue car", "color": "blue", "score": 3.5}),
]
for key, value in docs: wildcard_suffix_result = await store.alist_namespaces(
await vector_store.aput(("test",), key, value) suffix=["*", "public", test_pref]
)
assert len(wildcard_suffix_result) == 4
assert all(ns[-2] == "public" for ns in wildcard_suffix_result)
wildcard_single = await store.alist_namespaces(
suffix=["some", "*", "public", test_pref]
)
assert len(wildcard_single) == 1
assert wildcard_single[0] == (
test_pref,
"prod",
"documents",
"some",
"nesting",
"public",
test_pref,
)
results = await vector_store.asearch( max_depth_result = await store.alist_namespaces(max_depth=3)
("test",), query="apple", filter={"color": "red"} assert all([len(ns) <= 3 for ns in max_depth_result])
) max_depth_result = await store.alist_namespaces(
assert len(results) == 2 max_depth=4, prefix=[test_pref, "*", "documents"]
assert results[0].key == "doc1" )
assert (
len(set(tuple(res) for res in max_depth_result))
== len(max_depth_result)
== 5
)
results = await vector_store.asearch( limit_result = await store.alist_namespaces(prefix=[test_pref], limit=3)
("test",), query="car", filter={"color": "red"} assert len(limit_result) == 3
)
assert len(results) == 2
assert results[0].key == "doc2"
results = await vector_store.asearch( offset_result = await store.alist_namespaces(prefix=[test_pref], offset=3)
("test",), query="bbbbluuu", filter={"score": {"$gt": 3.2}} assert len(offset_result) == len(test_namespaces) - 3
)
assert len(results) == 3
assert results[0].key == "doc4"
results = await vector_store.asearch( empty_prefix_result = await store.alist_namespaces(prefix=[test_pref])
("test",), query="apple", filter={"score": {"$gte": 4.0}, "color": "green"} assert len(empty_prefix_result) == len(test_namespaces)
) assert set(tuple(ns) for ns in empty_prefix_result) == set(
assert len(results) == 1 tuple(ns) for ns in test_namespaces
assert results[0].key == "doc3" )
for namespace in test_namespaces:
await store.adelete(namespace, "dummy")
async def test_vector_search_pagination(vector_store: AsyncPostgresStore) -> None: async def test_search(self):
"""Test pagination with vector search.""" async with AsyncPostgresStore.from_conn_string(DEFAULT_URI) as store:
for i in range(5): test_namespaces = [
await vector_store.aput( ("test_search", "documents", "user1"),
("test",), f"doc{i}", {"text": f"test document number {i}"} ("test_search", "documents", "user2"),
) ("test_search", "reports", "department1"),
("test_search", "reports", "department2"),
]
test_items = [
{"title": "Doc 1", "author": "John Doe", "tags": ["important"]},
{"title": "Doc 2", "author": "Jane Smith", "tags": ["draft"]},
{"title": "Report A", "author": "John Doe", "tags": ["final"]},
{"title": "Report B", "author": "Alice Johnson", "tags": ["draft"]},
]
empty = await store.asearch(
(
"scoped",
"assistant_id",
"shared",
"6c5356f6-63ab-4158-868d-cd9fd14c736e",
),
limit=10,
offset=0,
)
assert len(empty) == 0
results_page1 = await vector_store.asearch(("test",), query="test", limit=2) for namespace, item in zip(test_namespaces, test_items):
results_page2 = await vector_store.asearch( await store.aput(namespace, f"item_{namespace[-1]}", item)
("test",), query="test", limit=2, offset=2
)
assert len(results_page1) == 2 docs_result = await store.asearch(["test_search", "documents"])
assert len(results_page2) == 2 assert len(docs_result) == 2
assert results_page1[0].key != results_page2[0].key assert all([item.namespace[1] == "documents" for item in docs_result]), [
item.namespace for item in docs_result
]
all_results = await vector_store.asearch(("test",), query="test", limit=10) reports_result = await store.asearch(["test_search", "reports"])
assert len(all_results) == 5 assert len(reports_result) == 2
assert all(item.namespace[1] == "reports" for item in reports_result)
limited_result = await store.asearch(["test_search"], limit=2)
assert len(limited_result) == 2
offset_result = await store.asearch(["test_search"])
assert len(offset_result) == 4
async def test_vector_search_edge_cases(vector_store: AsyncPostgresStore) -> None: offset_result = await store.asearch(["test_search"], offset=2)
"""Test edge cases in vector search.""" assert len(offset_result) == 2
await vector_store.aput(("test",), "doc1", {"text": "test document"}) assert all(item not in limited_result for item in offset_result)
perfect_match = await vector_store.asearch(("test",), query="text test document") john_doe_result = await store.asearch(
perfect_score = perfect_match[0].score ["test_search"], filter={"author": "John Doe"}
)
assert len(john_doe_result) == 2
assert all(item.value["author"] == "John Doe" for item in john_doe_result)
results = await vector_store.asearch(("test",), query="") draft_result = await store.asearch(
assert len(results) == 1 ["test_search"], filter={"tags": ["draft"]}
assert results[0].score is None )
assert len(draft_result) == 2
assert all("draft" in item.value["tags"] for item in draft_result)
results = await vector_store.asearch(("test",), query=None) page1 = await store.asearch(["test_search"], limit=2, offset=0)
assert len(results) == 1 page2 = await store.asearch(["test_search"], limit=2, offset=2)
assert results[0].score is None all_items = page1 + page2
assert len(all_items) == 4
assert len(set(item.key for item in all_items)) == 4
empty = await store.asearch(
(
"scoped",
"assistant_id",
"shared",
"again",
"maybe",
"some-long",
"6be5cb0e-2eb4-42e6-bb6b-fba3c269db25",
),
limit=10,
offset=0,
)
assert len(empty) == 0
long_query = "foo " * 100 # Test with a namespace beginning with a number (like a UUID)
results = await vector_store.asearch(("test",), query=long_query) uuid_namespace = (str(uuid.uuid4()), "documents")
assert len(results) == 1 uuid_item_id = "uuid_doc"
assert results[0].score < perfect_score uuid_item_value = {
"title": "UUID Document",
"content": "This document has a UUID namespace.",
}
special_query = "test!@#$%^&*()" # Insert the item with the UUID namespace
results = await vector_store.asearch(("test",), query=special_query) await store.aput(uuid_namespace, uuid_item_id, uuid_item_value)
assert len(results) == 1
assert results[0].score < perfect_score
# Retrieve the item to verify it was stored correctly
retrieved_item = await store.aget(uuid_namespace, uuid_item_id)
assert retrieved_item is not None
assert retrieved_item.namespace == uuid_namespace
assert retrieved_item.key == uuid_item_id
assert retrieved_item.value == uuid_item_value
@pytest.mark.parametrize( # Search for the item using the UUID namespace
"vector_type,distance_type", search_result = await store.asearch([uuid_namespace[0]])
[ assert len(search_result) == 1
*itertools.product(["vector", "halfvec"], ["cosine", "inner_product", "l2"]), assert search_result[0].key == uuid_item_id
], assert search_result[0].value == uuid_item_value
)
async def test_embed_with_path(
request: Any,
fake_embeddings: CharacterEmbeddings,
vector_type: str,
distance_type: str,
) -> None:
"""Test vector search with specific text fields in Postgres store."""
async with _create_vector_store(
vector_type,
distance_type,
fake_embeddings,
text_fields=["key0", "key1", "key3"],
) as store:
# This will have 2 vectors representing it
doc1 = {
# Omit key0 - check it doesn't raise an error
"key1": "xxx",
"key2": "yyy",
"key3": "zzz",
}
# This will have 3 vectors representing it
doc2 = {
"key0": "uuu",
"key1": "vvv",
"key2": "www",
"key3": "xxx",
}
await store.aput(("test",), "doc1", doc1)
await store.aput(("test",), "doc2", doc2)
# doc2.key3 and doc1.key1 both would have the highest score # Clean up: delete the item with the UUID namespace
results = await store.asearch(("test",), query="xxx") await store.adelete(uuid_namespace, uuid_item_id)
assert len(results) == 2
assert results[0].key != results[1].key
ascore = results[0].score
bscore = results[1].score
assert ascore == pytest.approx(bscore, abs=1e-3)
results = await store.asearch(("test",), query="uuu") # Verify the item was deleted
assert len(results) == 2 deleted_item = await store.aget(uuid_namespace, uuid_item_id)
assert results[0].key != results[1].key assert deleted_item is None
assert results[0].key == "doc2"
assert results[0].score > results[1].score
assert ascore == pytest.approx(results[0].score, abs=1e-3)
# Un-indexed - will have low results for both. Not zero (because we're projecting) for namespace in test_namespaces:
# but less than the above. await store.adelete(namespace, f"item_{namespace[-1]}")
results = await store.asearch(("test",), query="www")
assert len(results) == 2
assert results[0].score < ascore
assert results[1].score < ascore
@pytest.mark.parametrize(
"vector_type,distance_type",
[
*itertools.product(["vector", "halfvec"], ["cosine", "inner_product", "l2"]),
],
)
async def test_search_sorting(
request: Any,
fake_embeddings: CharacterEmbeddings,
vector_type: str,
distance_type: str,
) -> None:
"""Test operation-level field configuration for vector search."""
async with _create_vector_store(
vector_type,
distance_type,
fake_embeddings,
text_fields=["key1"], # Default fields that won't match our test data
) as store:
amatch = {
"key1": "mmm",
}
await store.aput(("test", "M"), "M", amatch)
N = 100
for i in range(N):
await store.aput(("test", "A"), f"A{i}", {"key1": "no"})
for i in range(N):
await store.aput(("test", "Z"), f"Z{i}", {"key1": "no"})
results = await store.asearch(("test",), query="mmm", limit=10)
assert len(results) == 10
assert len(set(r.key for r in results)) == 10
assert results[0].key == "M"
assert results[0].score > results[1].score
File diff suppressed because it is too large Load Diff
+84 -187
View File
@@ -1,14 +1,8 @@
# type: ignore
from contextlib import contextmanager
from typing import Any from typing import Any
from uuid import uuid4
import pytest import pytest
from conftest import DEFAULT_URI # type: ignore
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from psycopg import Connection
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from langgraph.checkpoint.base import ( from langgraph.checkpoint.base import (
Checkpoint, Checkpoint,
@@ -17,199 +11,102 @@ from langgraph.checkpoint.base import (
empty_checkpoint, empty_checkpoint,
) )
from langgraph.checkpoint.postgres import PostgresSaver from langgraph.checkpoint.postgres import PostgresSaver
from tests.conftest import DEFAULT_POSTGRES_URI
@contextmanager class TestPostgresSaver:
def _pool_saver(): @pytest.fixture(autouse=True)
"""Fixture for pool mode testing.""" def setup(self) -> None:
database = f"test_{uuid4().hex[:16]}" # objects for test setup
# create unique db self.config_1: RunnableConfig = {
with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn: "configurable": {
conn.execute(f"CREATE DATABASE {database}") "thread_id": "thread-1",
try: # for backwards compatibility testing
# yield checkpointer "thread_ts": "1",
with ConnectionPool( "checkpoint_ns": "",
DEFAULT_POSTGRES_URI + database, }
max_size=10,
kwargs={"autocommit": True, "row_factory": dict_row},
) as pool:
checkpointer = PostgresSaver(pool)
checkpointer.setup()
yield checkpointer
finally:
# drop unique db
with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn:
conn.execute(f"DROP DATABASE {database}")
@contextmanager
def _pipe_saver():
"""Fixture for pipeline mode testing."""
database = f"test_{uuid4().hex[:16]}"
# create unique db
with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn:
conn.execute(f"CREATE DATABASE {database}")
try:
with Connection.connect(
DEFAULT_POSTGRES_URI + database,
autocommit=True,
prepare_threshold=0,
row_factory=dict_row,
) as conn:
with conn.pipeline() as pipe:
checkpointer = PostgresSaver(conn, pipe=pipe)
checkpointer.setup()
with conn.pipeline() as pipe:
checkpointer = PostgresSaver(conn, pipe=pipe)
yield checkpointer
finally:
# drop unique db
with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn:
conn.execute(f"DROP DATABASE {database}")
@contextmanager
def _base_saver():
"""Fixture for regular connection mode testing."""
database = f"test_{uuid4().hex[:16]}"
# create unique db
with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn:
conn.execute(f"CREATE DATABASE {database}")
try:
with Connection.connect(
DEFAULT_POSTGRES_URI + database,
autocommit=True,
prepare_threshold=0,
row_factory=dict_row,
) as conn:
checkpointer = PostgresSaver(conn)
checkpointer.setup()
yield checkpointer
finally:
# drop unique db
with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn:
conn.execute(f"DROP DATABASE {database}")
@contextmanager
def _saver(name: str):
if name == "base":
with _base_saver() as saver:
yield saver
elif name == "pool":
with _pool_saver() as saver:
yield saver
elif name == "pipe":
with _pipe_saver() as saver:
yield saver
@pytest.fixture
def test_data():
"""Fixture providing test data for checkpoint tests."""
config_1: RunnableConfig = {
"configurable": {
"thread_id": "thread-1",
# for backwards compatibility testing
"thread_ts": "1",
"checkpoint_ns": "",
} }
} self.config_2: RunnableConfig = {
config_2: RunnableConfig = { "configurable": {
"configurable": { "thread_id": "thread-2",
"thread_id": "thread-2", "checkpoint_id": "2",
"checkpoint_id": "2", "checkpoint_ns": "",
"checkpoint_ns": "", }
} }
} self.config_3: RunnableConfig = {
config_3: RunnableConfig = { "configurable": {
"configurable": { "thread_id": "thread-2",
"thread_id": "thread-2", "checkpoint_id": "2-inner",
"checkpoint_id": "2-inner", "checkpoint_ns": "inner",
"checkpoint_ns": "inner", }
} }
}
chkpnt_1: Checkpoint = empty_checkpoint() self.chkpnt_1: Checkpoint = empty_checkpoint()
chkpnt_2: Checkpoint = create_checkpoint(chkpnt_1, {}, 1) self.chkpnt_2: Checkpoint = create_checkpoint(self.chkpnt_1, {}, 1)
chkpnt_3: Checkpoint = empty_checkpoint() self.chkpnt_3: Checkpoint = empty_checkpoint()
metadata_1: CheckpointMetadata = { self.metadata_1: CheckpointMetadata = {
"source": "input", "source": "input",
"step": 2, "step": 2,
"writes": {}, "writes": {},
"score": 1, "score": 1,
} }
metadata_2: CheckpointMetadata = { self.metadata_2: CheckpointMetadata = {
"source": "loop", "source": "loop",
"step": 1,
"writes": {"foo": "bar"},
"score": None,
}
metadata_3: CheckpointMetadata = {}
return {
"configs": [config_1, config_2, config_3],
"checkpoints": [chkpnt_1, chkpnt_2, chkpnt_3],
"metadata": [metadata_1, metadata_2, metadata_3],
}
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
def test_search(saver_name: str, test_data) -> None:
with _saver(saver_name) as saver:
configs = test_data["configs"]
checkpoints = test_data["checkpoints"]
metadata = test_data["metadata"]
saver.put(configs[0], checkpoints[0], metadata[0], {})
saver.put(configs[1], checkpoints[1], metadata[1], {})
saver.put(configs[2], checkpoints[2], metadata[2], {})
# call method / assertions
query_1 = {"source": "input"} # search by 1 key
query_2 = {
"step": 1, "step": 1,
"writes": {"foo": "bar"}, "writes": {"foo": "bar"},
} # search by multiple keys "score": None,
query_3: dict[str, Any] = {} # search by no keys, return all checkpoints }
query_4 = {"source": "update", "step": 1} # no match self.metadata_3: CheckpointMetadata = {}
with PostgresSaver.from_conn_string(DEFAULT_URI) as saver:
saver.setup()
search_results_1 = list(saver.list(None, filter=query_1)) def test_search(self) -> None:
assert len(search_results_1) == 1 with PostgresSaver.from_conn_string(DEFAULT_URI) as saver:
assert search_results_1[0].metadata == metadata[0] # save checkpoints
saver.put(self.config_1, self.chkpnt_1, self.metadata_1, {})
saver.put(self.config_2, self.chkpnt_2, self.metadata_2, {})
saver.put(self.config_3, self.chkpnt_3, self.metadata_3, {})
search_results_2 = list(saver.list(None, filter=query_2)) # call method / assertions
assert len(search_results_2) == 1 query_1 = {"source": "input"} # search by 1 key
assert search_results_2[0].metadata == metadata[1] query_2 = {
"step": 1,
"writes": {"foo": "bar"},
} # search by multiple keys
query_3: dict[str, Any] = {} # search by no keys, return all checkpoints
query_4 = {"source": "update", "step": 1} # no match
search_results_3 = list(saver.list(None, filter=query_3)) search_results_1 = list(saver.list(None, filter=query_1))
assert len(search_results_3) == 3 assert len(search_results_1) == 1
assert search_results_1[0].metadata == self.metadata_1
search_results_4 = list(saver.list(None, filter=query_4)) search_results_2 = list(saver.list(None, filter=query_2))
assert len(search_results_4) == 0 assert len(search_results_2) == 1
assert search_results_2[0].metadata == self.metadata_2
# search by config (defaults to checkpoints across all namespaces) search_results_3 = list(saver.list(None, filter=query_3))
search_results_5 = list(saver.list({"configurable": {"thread_id": "thread-2"}})) assert len(search_results_3) == 3
assert len(search_results_5) == 2
assert {
search_results_5[0].config["configurable"]["checkpoint_ns"],
search_results_5[1].config["configurable"]["checkpoint_ns"],
} == {"", "inner"}
search_results_4 = list(saver.list(None, filter=query_4))
assert len(search_results_4) == 0
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"]) # search by config (defaults to checkpoints across all namespaces)
def test_null_chars(saver_name: str, test_data) -> None: search_results_5 = list(
with _saver(saver_name) as saver: saver.list({"configurable": {"thread_id": "thread-2"}})
config = saver.put( )
test_data["configs"][0], assert len(search_results_5) == 2
test_data["checkpoints"][0], assert {
{"my_key": "\x00abc"}, search_results_5[0].config["configurable"]["checkpoint_ns"],
{}, search_results_5[1].config["configurable"]["checkpoint_ns"],
) } == {"", "inner"}
assert saver.get_tuple(config).metadata["my_key"] == "abc" # type: ignore
assert ( # TODO: test before and limit params
list(saver.list(None, filter={"my_key": "abc"}))[0].metadata["my_key"]
== "abc" def test_null_chars(self) -> None:
) with PostgresSaver.from_conn_string(DEFAULT_URI) as saver:
config = saver.put(self.config_1, self.chkpnt_1, {"my_key": "\x00abc"}, {})
assert saver.get_tuple(config).metadata["my_key"] == "abc" # type: ignore
assert (
list(saver.list(None, filter={"my_key": "abc"}))[0].metadata["my_key"] # type: ignore
== "abc"
)
+2 -4
View File
@@ -4,13 +4,11 @@
# TESTING AND COVERAGE # TESTING AND COVERAGE
###################### ######################
TEST ?= .
test: test:
poetry run pytest $(TEST) poetry run pytest tests
test_watch: test_watch:
poetry run ptw $(TEST) poetry run ptw .
###################### ######################
# LINTING AND FORMATTING # LINTING AND FORMATTING
+105 -548
View File
@@ -1,27 +1,12 @@
"""Base classes and types for persistent key-value stores. """Base classes and types for persistent key-value stores.
Stores provide long-term memory that persists across threads and conversations. Stores enable persistence and memory that can be shared across threads,
Supports hierarchical namespaces, key-value storage, and optional vector search. scoped to user IDs, assistant IDs, or other arbitrary namespaces.
Core types:
- BaseStore: Store interface with sync/async operations
- Item: Stored key-value pairs with metadata
- Op: Get/Put/Search/List operations
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import datetime from datetime import datetime
from typing import Any, Iterable, Literal, NamedTuple, Optional, TypedDict, Union, cast from typing import Any, Iterable, Literal, NamedTuple, Optional, Union, cast
from langchain_core.embeddings import Embeddings
from langgraph.store.base.embed import (
AEmbeddingsFunc,
EmbeddingsFunc,
ensure_embeddings,
get_text_at_path,
tokenize_path,
)
class Item: class Item:
@@ -88,415 +73,112 @@ class Item:
} }
class SearchItem(Item):
"""Represents a result item with additional response metadata."""
__slots__ = ("score",)
def __init__(
self,
namespace: tuple[str, ...],
key: str,
value: dict[str, Any],
created_at: datetime,
updated_at: datetime,
score: Optional[float] = None,
) -> None:
"""Initialize a result item.
Args:
namespace: Hierarchical path to the item.
key: Unique identifier within the namespace.
value: The stored value.
created_at: When the item was first created.
updated_at: When the item was last updated.
score: Relevance/similarity score if from a ranked operation.
"""
super().__init__(
value=value,
key=key,
namespace=namespace,
created_at=created_at,
updated_at=updated_at,
)
self.score = score
def dict(self) -> dict:
result = super().dict()
result["score"] = self.score
return result
class GetOp(NamedTuple): class GetOp(NamedTuple):
"""Operation to retrieve a specific item by its namespace and key. """Operation to retrieve an item by namespace and key."""
This operation allows precise retrieval of stored items using their full path
(namespace) and unique identifier (key) combination.
??? example "Examples"
Basic item retrieval:
```python
GetOp(namespace=("users", "profiles"), key="user123")
GetOp(namespace=("cache", "embeddings"), key="doc456")
```
"""
namespace: tuple[str, ...] namespace: tuple[str, ...]
"""Hierarchical path that uniquely identifies the item's location. """Hierarchical path for the item."""
??? example "Examples"
```python
("users",) # Root level users namespace
("users", "profiles") # Profiles within users namespace
```
"""
key: str key: str
"""Unique identifier for the item within its specific namespace. """Unique identifier within the namespace."""
??? example "Examples"
```python
"user123" # For a user profile
"doc456" # For a document
```
"""
class SearchOp(NamedTuple): class SearchOp(NamedTuple):
"""Operation to search for items within a specified namespace hierarchy. """Operation to search for items within a namespace prefix."""
This operation supports both structured filtering and natural language search
within a given namespace prefix. It provides pagination through limit and offset
parameters.
Note:
Natural language search support depends on your store implementation.
??? example "Examples"
Search with filters and pagination:
```python
SearchOp(
namespace_prefix=("documents",),
filter={"type": "report", "status": "active"},
limit=5,
offset=10
)
```
Natural language search:
```python
SearchOp(
namespace_prefix=("users", "content"),
query="technical documentation about APIs",
limit=20
)
```
"""
namespace_prefix: tuple[str, ...] namespace_prefix: tuple[str, ...]
"""Hierarchical path prefix defining the search scope. """Hierarchical path prefix to search within."""
??? example "Examples"
```python
() # Search entire store
("documents",) # Search all documents
("users", "content") # Search within user content
```
"""
filter: Optional[dict[str, Any]] = None filter: Optional[dict[str, Any]] = None
"""Key-value pairs for filtering results based on exact matches or comparison operators. """Key-value pairs to filter results."""
The filter supports both exact matches and operator-based comparisons.
Supported Operators:
- $eq: Equal to (same as direct value comparison)
- $ne: Not equal to
- $gt: Greater than
- $gte: Greater than or equal to
- $lt: Less than
- $lte: Less than or equal to
??? example "Examples"
Simple exact match:
```python
{"status": "active"}
```
Comparison operators:
```python
{"score": {"$gt": 4.99}} # Score greater than 4.99
```
Multiple conditions:
```python
{
"score": {"$gte": 3.0},
"color": "red"
}
```
Note:
Comparison operator support depends on your store implementation.
"""
limit: int = 10 limit: int = 10
"""Maximum number of items to return in the search results.""" """Maximum number of items to return."""
offset: int = 0 offset: int = 0
"""Number of matching items to skip for pagination.""" """Number of items to skip before returning results."""
query: Optional[str] = None
"""Natural language search query for semantic search capabilities.
??? example "Examples" class PutOp(NamedTuple):
- "technical documentation about REST APIs" """Operation to store, update, or delete an item."""
- "machine learning papers from 2023"
namespace: tuple[str, ...]
"""Hierarchical path for the item.
Represented as a tuple of strings, allowing for nested categorization.
For example: ("documents", "user123")
"""
key: str
"""Unique identifier for the document.
Should be distinct within its namespace.
"""
value: Optional[dict[str, Any]]
"""Data to be stored, or None to delete the item.
Schema:
- Should be a dictionary where:
- Keys are strings representing field names
- Values can be of any serializable type
- If None, it indicates that the item should be deleted
""" """
# Type representing a namespace path that can include wildcards NameSpacePath = tuple[Union[str, Literal["*"]], ...]
NamespacePath = tuple[Union[str, Literal["*"]], ...]
"""A tuple representing a namespace path that can include wildcards.
Examples:
("users",) # Exact users namespace
("documents", "*") # Any sub-namespace under documents
("cache", "*", "v1") # Any cache category with v1 version
"""
# Type for specifying how to match namespaces
NamespaceMatchType = Literal["prefix", "suffix"] NamespaceMatchType = Literal["prefix", "suffix"]
"""Specifies how to match namespace paths.
Values:
"prefix": Match from the start of the namespace
"suffix": Match from the end of the namespace
"""
class MatchCondition(NamedTuple): class MatchCondition(NamedTuple):
"""Represents a pattern for matching namespaces in the store. """Represents a single match condition."""
This class combines a match type (prefix or suffix) with a namespace path
pattern that can include wildcards to flexibly match different namespace
hierarchies.
??? example "Examples"
Prefix matching:
```python
MatchCondition(match_type="prefix", path=("users", "profiles"))
```
Suffix matching with wildcard:
```python
MatchCondition(match_type="suffix", path=("cache", "*"))
```
Simple suffix matching:
```python
MatchCondition(match_type="suffix", path=("v1",))
```
"""
match_type: NamespaceMatchType match_type: NamespaceMatchType
"""Type of namespace matching to perform.""" path: NameSpacePath
path: NamespacePath
"""Namespace path pattern that can include wildcards."""
class ListNamespacesOp(NamedTuple): class ListNamespacesOp(NamedTuple):
"""Operation to list and filter namespaces in the store. """Operation to list namespaces with optional match conditions."""
This operation allows exploring the organization of data, finding specific
collections, and navigating the namespace hierarchy.
??? example "Examples"
List all namespaces under the "documents" path:
```python
ListNamespacesOp(
match_conditions=(MatchCondition(match_type="prefix", path=("documents",)),),
max_depth=2
)
```
List all namespaces that end with "v1":
```python
ListNamespacesOp(
match_conditions=(MatchCondition(match_type="suffix", path=("v1",)),),
limit=50
)
```
"""
match_conditions: Optional[tuple[MatchCondition, ...]] = None match_conditions: Optional[tuple[MatchCondition, ...]] = None
"""Optional conditions for filtering namespaces. """A tuple of match conditions to apply to namespaces."""
??? example "Examples"
All user namespaces:
```python
(MatchCondition(match_type="prefix", path=("users",)),)
```
All namespaces that start with "docs" and end with "draft":
```python
(
MatchCondition(match_type="prefix", path=("docs",)),
MatchCondition(match_type="suffix", path=("draft",))
)
```
"""
max_depth: Optional[int] = None max_depth: Optional[int] = None
"""Maximum depth of namespace hierarchy to return. """Return namespaces up to this depth in the hierarchy."""
Note:
Namespaces deeper than this level will be truncated.
"""
limit: int = 100 limit: int = 100
"""Maximum number of namespaces to return.""" """Maximum number of namespaces to return."""
offset: int = 0 offset: int = 0
"""Number of namespaces to skip for pagination.""" """Number of namespaces to skip before returning results."""
class PutOp(NamedTuple):
"""Operation to store, update, or delete an item in the store.
This class represents a single operation to modify the store's contents,
whether adding new items, updating existing ones, or removing them.
"""
namespace: tuple[str, ...]
"""Hierarchical path that identifies the location of the item.
The namespace acts as a folder-like structure to organize items.
Each element in the tuple represents one level in the hierarchy.
??? example "Examples"
Root level documents
```python
("documents",)
```
User-specific documents
```python
("documents", "user123")
```
Nested cache structure
```python
("cache", "embeddings", "v1")
```
"""
key: str
"""Unique identifier for the item within its namespace.
The key must be unique within the specific namespace to avoid conflicts.
Together with the namespace, it forms a complete path to the item.
Example:
If namespace is ("documents", "user123") and key is "report1",
the full path would effectively be "documents/user123/report1"
"""
value: Optional[dict[str, Any]]
"""The data to store, or None to mark the item for deletion.
The value must be a dictionary with string keys and JSON-serializable values.
Setting this to None signals that the item should be deleted.
Example:
{
"field1": "string value",
"field2": 123,
"nested": {"can": "contain", "any": "serializable data"}
}
"""
index: Optional[Union[Literal[False], list[str]]] = None # type: ignore[assignment]
"""Controls how the item's fields are indexed for search operations.
Indexing configuration determines how the item can be found through search:
- None (default): Uses the store's default indexing configuration (if provided)
- False: Disables indexing for this item
- list[str]: Specifies which json path fields to index for search
The item remains accessible through direct get() operations regardless of indexing.
When indexed, fields can be searched using natural language queries through
vector similarity search (if supported by the store implementation).
Path Syntax:
- Simple field access: "field"
- Nested fields: "parent.child.grandchild"
- Array indexing:
- Specific index: "array[0]"
- Last element: "array[-1]"
- All elements (each individually): "array[*]"
??? example "Examples"
- None - Use store defaults
- False - Don't index this item
- list[str] - List of fields to index
```python
[
"metadata.title", # Nested field access
"chapters[*].content", # Index content from all chapters as separate vectors
"authors[0].name", # First author's name
"revisions[-1].changes", # Most recent revision's changes
"sections[*].paragraphs[*].text", # All text from all paragraphs in all sections
"metadata.tags[*]", # All tags in metadata
]
```
"""
Op = Union[GetOp, SearchOp, PutOp, ListNamespacesOp] Op = Union[GetOp, SearchOp, PutOp, ListNamespacesOp]
Result = Union[Item, list[Item], list[SearchItem], list[tuple[str, ...]], None] Result = Union[Item, list[Item], list[tuple[str, ...]], None]
class InvalidNamespaceError(ValueError): class InvalidNamespaceError(ValueError):
"""Provided namespace is invalid.""" """Provided namespace is invalid."""
class IndexConfig(TypedDict, total=False): def _validate_namespace(namespace: tuple[str, ...]) -> None:
"""Configuration for indexing documents for semantic search in the store.""" if not namespace:
raise InvalidNamespaceError("Namespace cannot be empty.")
dims: int for label in namespace:
"""Number of dimensions in the embedding vectors. if not isinstance(label, str):
raise InvalidNamespaceError(
Common embedding models have the following dimensions: f"Invalid namespace label '{label}' found in {namespace}. Namespace labels"
- OpenAI text-embedding-3-large: 256, 1024, or 3072 f" must be strings, but got {type(label).__name__}."
- OpenAI text-embedding-3-small: 512 or 1536 )
- OpenAI text-embedding-ada-002: 1536 if "." in label:
- Cohere embed-english-v3.0: 1024 raise InvalidNamespaceError(
- Cohere embed-english-light-v3.0: 384 f"Invalid namespace label '{label}' found in {namespace}. Namespace labels cannot contain periods ('.')."
- Cohere embed-multilingual-v3.0: 1024 )
- Cohere embed-multilingual-light-v3.0: 384 elif not label:
""" raise InvalidNamespaceError(
f"Namespace labels cannot be empty strings. Got {label} in {namespace}"
embed: Union[Embeddings, EmbeddingsFunc, AEmbeddingsFunc] )
"""Optional function to generate embeddings from text.""" if namespace[0] == "langgraph":
raise InvalidNamespaceError(
fields: Optional[list[str]] f'Root label for namespace cannot be "langgraph". Got: {namespace}'
"""Fields to extract text from for embedding generation. )
Defaults to the root ["$"], which embeds the json object as a whole.
"""
class BaseStore(ABC): class BaseStore(ABC):
@@ -549,16 +231,14 @@ class BaseStore(ABC):
namespace_prefix: tuple[str, ...], namespace_prefix: tuple[str, ...],
/, /,
*, *,
query: Optional[str] = None,
filter: Optional[dict[str, Any]] = None, filter: Optional[dict[str, Any]] = None,
limit: int = 10, limit: int = 10,
offset: int = 0, offset: int = 0,
) -> list[SearchItem]: ) -> list[Item]:
"""Search for items within a namespace prefix. """Search for items within a namespace prefix.
Args: Args:
namespace_prefix: Hierarchical path prefix to search within. namespace_prefix: Hierarchical path prefix to search within.
query: Optional query for natural language search.
filter: Key-value pairs to filter results. filter: Key-value pairs to filter results.
limit: Maximum number of items to return. limit: Maximum number of items to return.
offset: Number of items to skip before returning results. offset: Number of items to skip before returning results.
@@ -566,54 +246,18 @@ class BaseStore(ABC):
Returns: Returns:
List of items matching the search criteria. List of items matching the search criteria.
""" """
return self.batch([SearchOp(namespace_prefix, filter, limit, offset, query)])[0] return self.batch([SearchOp(namespace_prefix, filter, limit, offset)])[0]
def put( def put(self, namespace: tuple[str, ...], key: str, value: dict[str, Any]) -> None:
self, """Store or update an item.
namespace: tuple[str, ...],
key: str,
value: dict[str, Any],
index: Optional[Union[Literal[False], list[str]]] = None,
) -> None:
"""Store or update an item in the store.
Args: Args:
namespace: Hierarchical path for the item, represented as a tuple of strings. namespace: Hierarchical path for the item.
Example: ("documents", "user123") key: Unique identifier within the namespace.
key: Unique identifier within the namespace. Together with namespace forms value: Dictionary containing the item's data.
the complete path to the item.
value: Dictionary containing the item's data. Must contain string keys
and JSON-serializable values.
index: Controls how the item's fields are indexed for search:
- None (default): Use store's default indexing configuration
- False: Disable indexing for this item
- list[str]: List of field paths to index, supporting:
- Nested fields: "metadata.title"
- Array access: "chapters[*].content" (each indexed separately)
- Specific indices: "authors[0].name"
Note:
Indexing capabilities depend on your store implementation.
Some implementations may support only a subset of indexing features.
??? example "Examples"
Simple storage without special indexing (respects store defaults)
```python
store.put(("docs",), "report", {"title": "Annual Report"})
```
Index specific fields for search
```python
store.put(("docs",), "report", {"title": "Annual Report"}, index=["title"])
```
Do not index for semantic search
```python
store.put(("docs",), "report", {"title": "Annual Report"}, index=False)
```
""" """
_validate_namespace(namespace) _validate_namespace(namespace)
self.batch([PutOp(namespace, key, value, index=index)]) self.batch([PutOp(namespace, key, value)])
def delete(self, namespace: tuple[str, ...], key: str) -> None: def delete(self, namespace: tuple[str, ...], key: str) -> None:
"""Delete an item. """Delete an item.
@@ -627,8 +271,8 @@ class BaseStore(ABC):
def list_namespaces( def list_namespaces(
self, self,
*, *,
prefix: Optional[NamespacePath] = None, prefix: Optional[NameSpacePath] = None,
suffix: Optional[NamespacePath] = None, suffix: Optional[NameSpacePath] = None,
max_depth: Optional[int] = None, max_depth: Optional[int] = None,
limit: int = 100, limit: int = 100,
offset: int = 0, offset: int = 0,
@@ -642,7 +286,7 @@ class BaseStore(ABC):
prefix (Optional[Tuple[str, ...]]): Filter namespaces that start with this path. prefix (Optional[Tuple[str, ...]]): Filter namespaces that start with this path.
suffix (Optional[Tuple[str, ...]]): Filter namespaces that end with this path. suffix (Optional[Tuple[str, ...]]): Filter namespaces that end with this path.
max_depth (Optional[int]): Return namespaces up to this depth in the hierarchy. max_depth (Optional[int]): Return namespaces up to this depth in the hierarchy.
Namespaces deeper than this level will be truncated. Namespaces deeper than this level will be truncated to this depth.
limit (int): Maximum number of namespaces to return (default 100). limit (int): Maximum number of namespaces to return (default 100).
offset (int): Number of namespaces to skip for pagination (default 0). offset (int): Number of namespaces to skip for pagination (default 0).
@@ -650,18 +294,16 @@ class BaseStore(ABC):
List[Tuple[str, ...]]: A list of namespace tuples that match the criteria. List[Tuple[str, ...]]: A list of namespace tuples that match the criteria.
Each tuple represents a full namespace path up to `max_depth`. Each tuple represents a full namespace path up to `max_depth`.
??? example "Examples": Examples:
Setting max_depth=3. Given the namespaces: Setting max_depth=3. Given the namespaces:
```python # ("a", "b", "c")
# Example if you have the following namespaces: # ("a", "b", "d", "e")
# ("a", "b", "c") # ("a", "b", "d", "i")
# ("a", "b", "d", "e") # ("a", "b", "f")
# ("a", "b", "d", "i") # ("a", "c", "f")
# ("a", "b", "f") store.list_namespaces(prefix=("a", "b"), max_depth=3)
# ("a", "c", "f") # [("a", "b", "c"), ("a", "b", "d"), ("a", "b", "f")]
store.list_namespaces(prefix=("a", "b"), max_depth=3)
# [("a", "b", "c"), ("a", "b", "d"), ("a", "b", "f")]
```
""" """
match_conditions = [] match_conditions = []
if prefix: if prefix:
@@ -694,16 +336,14 @@ class BaseStore(ABC):
namespace_prefix: tuple[str, ...], namespace_prefix: tuple[str, ...],
/, /,
*, *,
query: Optional[str] = None,
filter: Optional[dict[str, Any]] = None, filter: Optional[dict[str, Any]] = None,
limit: int = 10, limit: int = 10,
offset: int = 0, offset: int = 0,
) -> list[SearchItem]: ) -> list[Item]:
"""Asynchronously search for items within a namespace prefix. """Asynchronously search for items within a namespace prefix.
Args: Args:
namespace_prefix: Hierarchical path prefix to search within. namespace_prefix: Hierarchical path prefix to search within.
query: Optional query for natural language search.
filter: Key-value pairs to filter results. filter: Key-value pairs to filter results.
limit: Maximum number of items to return. limit: Maximum number of items to return.
offset: Number of items to skip before returning results. offset: Number of items to skip before returning results.
@@ -711,61 +351,22 @@ class BaseStore(ABC):
Returns: Returns:
List of items matching the search criteria. List of items matching the search criteria.
""" """
return ( return (await self.abatch([SearchOp(namespace_prefix, filter, limit, offset)]))[
await self.abatch( 0
[SearchOp(namespace_prefix, filter, limit, offset, query)] ]
)
)[0]
async def aput( async def aput(
self, self, namespace: tuple[str, ...], key: str, value: dict[str, Any]
namespace: tuple[str, ...],
key: str,
value: dict[str, Any],
index: Optional[Union[Literal[False], list[str]]] = None,
) -> None: ) -> None:
"""Asynchronously store or update an item in the store. """Asynchronously store or update an item.
Args: Args:
namespace: Hierarchical path for the item, represented as a tuple of strings. namespace: Hierarchical path for the item.
Example: ("documents", "user123") key: Unique identifier within the namespace.
key: Unique identifier within the namespace. Together with namespace forms value: Dictionary containing the item's data.
the complete path to the item.
value: Dictionary containing the item's data. Must contain string keys
and JSON-serializable values.
index: Controls how the item's fields are indexed for search:
- None (default): Use store's default indexing configuration
- False: Disable indexing for this item
- list[str]: List of field paths to index, supporting:
- Nested fields: "metadata.title"
- Array access: "chapters[*].content" (each indexed separately)
- Specific indices: "authors[0].name"
Note:
Indexing capabilities depend on your store implementation.
Some implementations may support only a subset of indexing features.
??? example "Examples"
Simple storage without special indexing:
```python
await store.aput(("docs",), "report", {"title": "Annual Report"})
```
Index specific fields for search:
```python
await store.aput(
("docs",),
"report",
{
"title": "Q4 Report",
"chapters": [{"content": "..."}, {"content": "..."}]
},
index=["title", "chapters[*].content"]
)
```
""" """
_validate_namespace(namespace) _validate_namespace(namespace)
await self.abatch([PutOp(namespace, key, value, index=index)]) await self.abatch([PutOp(namespace, key, value)])
async def adelete(self, namespace: tuple[str, ...], key: str) -> None: async def adelete(self, namespace: tuple[str, ...], key: str) -> None:
"""Asynchronously delete an item. """Asynchronously delete an item.
@@ -779,8 +380,8 @@ class BaseStore(ABC):
async def alist_namespaces( async def alist_namespaces(
self, self,
*, *,
prefix: Optional[NamespacePath] = None, prefix: Optional[NameSpacePath] = None,
suffix: Optional[NamespacePath] = None, suffix: Optional[NameSpacePath] = None,
max_depth: Optional[int] = None, max_depth: Optional[int] = None,
limit: int = 100, limit: int = 100,
offset: int = 0, offset: int = 0,
@@ -802,19 +403,16 @@ class BaseStore(ABC):
List[Tuple[str, ...]]: A list of namespace tuples that match the criteria. List[Tuple[str, ...]]: A list of namespace tuples that match the criteria.
Each tuple represents a full namespace path up to `max_depth`. Each tuple represents a full namespace path up to `max_depth`.
??? example "Examples" Examples:
Setting max_depth=3 with existing namespaces:
```python
# Given the following namespaces:
# ("a", "b", "c")
# ("a", "b", "d", "e")
# ("a", "b", "d", "i")
# ("a", "b", "f")
# ("a", "c", "f")
await store.alist_namespaces(prefix=("a", "b"), max_depth=3) Setting max_depth=3. Given the namespaces:
# Returns: [("a", "b", "c"), ("a", "b", "d"), ("a", "b", "f")] # ("a", "b", "c")
``` # ("a", "b", "d", "e")
# ("a", "b", "d", "i")
# ("a", "b", "f")
# ("a", "c", "f")
await store.alist_namespaces(prefix=("a", "b"), max_depth=3)
# [("a", "b", "c"), ("a", "b", "d"), ("a", "b", "f")]
""" """
match_conditions = [] match_conditions = []
if prefix: if prefix:
@@ -829,44 +427,3 @@ class BaseStore(ABC):
offset=offset, offset=offset,
) )
return (await self.abatch([op]))[0] return (await self.abatch([op]))[0]
def _validate_namespace(namespace: tuple[str, ...]) -> None:
if not namespace:
raise InvalidNamespaceError("Namespace cannot be empty.")
for label in namespace:
if not isinstance(label, str):
raise InvalidNamespaceError(
f"Invalid namespace label '{label}' found in {namespace}. Namespace labels"
f" must be strings, but got {type(label).__name__}."
)
if "." in label:
raise InvalidNamespaceError(
f"Invalid namespace label '{label}' found in {namespace}. Namespace labels cannot contain periods ('.')."
)
elif not label:
raise InvalidNamespaceError(
f"Namespace labels cannot be empty strings. Got {label} in {namespace}"
)
if namespace[0] == "langgraph":
raise InvalidNamespaceError(
f'Root label for namespace cannot be "langgraph". Got: {namespace}'
)
__all__ = [
"BaseStore",
"Item",
"Op",
"PutOp",
"GetOp",
"SearchOp",
"ListNamespacesOp",
"MatchCondition",
"NamespacePath",
"NamespaceMatchType",
"Embeddings",
"ensure_embeddings",
"tokenize_path",
"get_text_at_path",
]
+5 -84
View File
@@ -1,17 +1,13 @@
import asyncio import asyncio
import weakref import weakref
from typing import Any, Literal, Optional, Union from typing import Any, Optional
from langgraph.store.base import ( from langgraph.store.base import (
BaseStore, BaseStore,
GetOp, GetOp,
Item, Item,
ListNamespacesOp,
MatchCondition,
NamespacePath,
Op, Op,
PutOp, PutOp,
SearchItem,
SearchOp, SearchOp,
_validate_namespace, _validate_namespace,
) )
@@ -44,13 +40,12 @@ class AsyncBatchedBaseStore(BaseStore):
namespace_prefix: tuple[str, ...], namespace_prefix: tuple[str, ...],
/, /,
*, *,
query: Optional[str] = None,
filter: Optional[dict[str, Any]] = None, filter: Optional[dict[str, Any]] = None,
limit: int = 10, limit: int = 10,
offset: int = 0, offset: int = 0,
) -> list[SearchItem]: ) -> list[Item]:
fut = self._loop.create_future() fut = self._loop.create_future()
self._aqueue[fut] = SearchOp(namespace_prefix, filter, limit, offset, query) self._aqueue[fut] = SearchOp(namespace_prefix, filter, limit, offset)
return await fut return await fut
async def aput( async def aput(
@@ -58,11 +53,10 @@ class AsyncBatchedBaseStore(BaseStore):
namespace: tuple[str, ...], namespace: tuple[str, ...],
key: str, key: str,
value: dict[str, Any], value: dict[str, Any],
index: Optional[Union[Literal[False], list[str]]] = None,
) -> None: ) -> None:
_validate_namespace(namespace) _validate_namespace(namespace)
fut = self._loop.create_future() fut = self._loop.create_future()
self._aqueue[fut] = PutOp(namespace, key, value, index) self._aqueue[fut] = PutOp(namespace, key, value)
return await fut return await fut
async def adelete( async def adelete(
@@ -74,74 +68,6 @@ class AsyncBatchedBaseStore(BaseStore):
self._aqueue[fut] = PutOp(namespace, key, None) self._aqueue[fut] = PutOp(namespace, key, None)
return await fut return await fut
async def alist_namespaces(
self,
*,
prefix: Optional[NamespacePath] = None,
suffix: Optional[NamespacePath] = None,
max_depth: Optional[int] = None,
limit: int = 100,
offset: int = 0,
) -> list[tuple[str, ...]]:
fut = self._loop.create_future()
match_conditions = []
if prefix:
match_conditions.append(MatchCondition(match_type="prefix", path=prefix))
if suffix:
match_conditions.append(MatchCondition(match_type="suffix", path=suffix))
op = ListNamespacesOp(
match_conditions=tuple(match_conditions),
max_depth=max_depth,
limit=limit,
offset=offset,
)
self._aqueue[fut] = op
return await fut
def _dedupe_ops(values: list[Op]) -> tuple[Optional[list[int]], list[Op]]:
"""Dedupe operations while preserving order for results.
Args:
values: List of operations to dedupe
Returns:
Tuple of (listen indices, deduped operations)
where listen indices map deduped operation results back to original positions
"""
if len(values) <= 1:
return None, list(values)
dedupped: list[Op] = []
listen: list[int] = []
puts: dict[tuple[tuple[str, ...], str], int] = {}
for op in values:
if isinstance(op, (GetOp, SearchOp, ListNamespacesOp)):
try:
listen.append(dedupped.index(op))
except ValueError:
listen.append(len(dedupped))
dedupped.append(op)
elif isinstance(op, PutOp):
putkey = (op.namespace, op.key)
if putkey in puts:
# Overwrite previous put
ix = puts[putkey]
dedupped[ix] = op
listen.append(ix)
else:
puts[putkey] = len(dedupped)
listen.append(len(dedupped))
dedupped.append(op)
else: # Any new ops will be treated regularly
listen.append(len(dedupped))
dedupped.append(op)
return listen, dedupped
async def _run( async def _run(
aqueue: dict[asyncio.Future, Op], store: weakref.ReferenceType[BaseStore] aqueue: dict[asyncio.Future, Op], store: weakref.ReferenceType[BaseStore]
@@ -155,12 +81,7 @@ async def _run(
taken = aqueue.copy() taken = aqueue.copy()
# action each operation # action each operation
try: try:
values = list(taken.values()) results = await s.abatch(taken.values())
listen, dedupped = _dedupe_ops(values)
results = await s.abatch(dedupped)
if listen is not None:
results = [results[ix] for ix in listen]
# set the results of each operation # set the results of each operation
for fut, result in zip(taken, results): for fut, result in zip(taken, results):
fut.set_result(result) fut.set_result(result)
@@ -1,380 +0,0 @@
"""Utilities for working with embedding functions and LangChain's Embeddings interface.
This module provides tools to wrap arbitrary embedding functions (both sync and async)
into LangChain's Embeddings interface. This enables using custom embedding functions
with LangChain-compatible tools while maintaining support for both synchronous and
asynchronous operations.
"""
import asyncio
import json
from typing import Any, Awaitable, Callable, Optional, Sequence, Union
from langchain_core.embeddings import Embeddings
EmbeddingsFunc = Callable[[Sequence[str]], list[list[float]]]
"""Type for synchronous embedding functions.
The function should take a sequence of strings and return a list of embeddings,
where each embedding is a list of floats. The dimensionality of the embeddings
should be consistent for all inputs.
"""
AEmbeddingsFunc = Callable[[Sequence[str]], Awaitable[list[list[float]]]]
"""Type for asynchronous embedding functions.
Similar to EmbeddingsFunc, but returns an awaitable that resolves to the embeddings.
"""
def ensure_embeddings(
embed: Union[Embeddings, EmbeddingsFunc, AEmbeddingsFunc, None],
) -> Embeddings:
"""Ensure that an embedding function conforms to LangChain's Embeddings interface.
This function wraps arbitrary embedding functions to make them compatible with
LangChain's Embeddings interface. It handles both synchronous and asynchronous
functions.
Args:
embed: Either an existing Embeddings instance, or a function that converts
text to embeddings. If the function is async, it will be used for both
sync and async operations.
Returns:
An Embeddings instance that wraps the provided function(s).
??? example "Examples"
Wrap a synchronous embedding function:
```python
def my_embed_fn(texts):
return [[0.1, 0.2] for _ in texts]
embeddings = ensure_embeddings(my_embed_fn)
result = embeddings.embed_query("hello") # Returns [0.1, 0.2]
```
Wrap an asynchronous embedding function:
```python
async def my_async_fn(texts):
return [[0.1, 0.2] for _ in texts]
embeddings = ensure_embeddings(my_async_fn)
result = await embeddings.aembed_query("hello") # Returns [0.1, 0.2]
```
"""
if embed is None:
raise ValueError("embed must be provided")
if isinstance(embed, Embeddings):
return embed
return EmbeddingsLambda(embed)
class EmbeddingsLambda(Embeddings):
"""Wrapper to convert embedding functions into LangChain's Embeddings interface.
This class allows arbitrary embedding functions to be used with LangChain-compatible
tools. It supports both synchronous and asynchronous operations, and can handle:
1. A synchronous function for sync operations (async operations will use sync function)
2. An async function for both sync/async operations (sync operations will raise an error)
The embedding functions should convert text into fixed-dimensional vectors that
capture the semantic meaning of the text.
Args:
func: Function that converts text to embeddings. Can be sync or async.
If async, it will be used for async operations, but sync operations
will raise an error. If sync, it will be used for both sync and async operations.
??? example "Examples"
With a sync function:
```python
def my_embed_fn(texts):
# Return 2D embeddings for each text
return [[0.1, 0.2] for _ in texts]
embeddings = EmbeddingsLambda(my_embed_fn)
result = embeddings.embed_query("hello") # Returns [0.1, 0.2]
await embeddings.aembed_query("hello") # Also returns [0.1, 0.2]
```
With an async function:
```python
async def my_async_fn(texts):
return [[0.1, 0.2] for _ in texts]
embeddings = EmbeddingsLambda(my_async_fn)
await embeddings.aembed_query("hello") # Returns [0.1, 0.2]
# Note: embed_query() would raise an error
```
"""
def __init__(
self,
func: Union[EmbeddingsFunc, AEmbeddingsFunc],
) -> None:
if func is None:
raise ValueError("func must be provided")
if _is_async_callable(func):
self.afunc = func
else:
self.func = func
def embed_documents(self, texts: list[str]) -> list[list[float]]:
"""Embed a list of texts into vectors.
Args:
texts: list of texts to convert to embeddings.
Returns:
list of embeddings, one per input text. Each embedding is a list of floats.
Raises:
ValueError: If the instance was initialized with only an async function.
"""
func = getattr(self, "func", None)
if func is None:
raise ValueError(
"EmbeddingsLambda was initialized with an async function but no sync function. "
"Use aembed_documents for async operation or provide a sync function."
)
return func(texts)
def embed_query(self, text: str) -> list[float]:
"""Embed a single piece of text.
Args:
text: Text to convert to an embedding.
Returns:
Embedding vector as a list of floats.
Note:
This is equivalent to calling embed_documents with a single text
and taking the first result.
"""
return self.embed_documents([text])[0]
async def aembed_documents(self, texts: list[str]) -> list[list[float]]:
"""Asynchronously embed a list of texts into vectors.
Args:
texts: list of texts to convert to embeddings.
Returns:
list of embeddings, one per input text. Each embedding is a list of floats.
Note:
If no async function was provided, this falls back to the sync implementation.
"""
afunc = getattr(self, "afunc", None)
if afunc is None:
return await super().aembed_documents(texts)
return await afunc(texts)
async def aembed_query(self, text: str) -> list[float]:
"""Asynchronously embed a single piece of text.
Args:
text: Text to convert to an embedding.
Returns:
Embedding vector as a list of floats.
Note:
This is equivalent to calling aembed_documents with a single text
and taking the first result.
"""
afunc = getattr(self, "afunc", None)
if afunc is None:
return await super().aembed_query(text)
return (await afunc([text]))[0]
def get_text_at_path(obj: Any, path: Union[str, list[str]]) -> list[str]:
"""Extract text from an object using a path expression or pre-tokenized path.
Args:
obj: The object to extract text from
path: Either a path string or pre-tokenized path list.
!!! info "Path types handled"
- Simple paths: "field1.field2"
- Array indexing: "[0]", "[*]", "[-1]"
- Wildcards: "*"
- Multi-field selection: "{field1,field2}"
- Nested paths in multi-field: "{field1,nested.field2}"
"""
if not path or path == "$":
return [json.dumps(obj, sort_keys=True)]
tokens = tokenize_path(path) if isinstance(path, str) else path
def _extract_from_obj(obj: Any, tokens: list[str], pos: int) -> list[str]:
if pos >= len(tokens):
if isinstance(obj, (str, int, float, bool)):
return [str(obj)]
elif obj is None:
return []
elif isinstance(obj, (list, dict)):
return [json.dumps(obj, sort_keys=True)]
return []
token = tokens[pos]
results = []
if token.startswith("[") and token.endswith("]"):
if not isinstance(obj, list):
return []
index = token[1:-1]
if index == "*":
for item in obj:
results.extend(_extract_from_obj(item, tokens, pos + 1))
else:
try:
idx = int(index)
if idx < 0:
idx = len(obj) + idx
if 0 <= idx < len(obj):
results.extend(_extract_from_obj(obj[idx], tokens, pos + 1))
except (ValueError, IndexError):
return []
elif token.startswith("{") and token.endswith("}"):
if not isinstance(obj, dict):
return []
fields = [f.strip() for f in token[1:-1].split(",")]
for field in fields:
nested_tokens = tokenize_path(field)
if nested_tokens:
current_obj: Optional[dict] = obj
for nested_token in nested_tokens:
if (
isinstance(current_obj, dict)
and nested_token in current_obj
):
current_obj = current_obj[nested_token]
else:
current_obj = None
break
if current_obj is not None:
if isinstance(current_obj, (str, int, float, bool)):
results.append(str(current_obj))
elif isinstance(current_obj, (list, dict)):
results.append(json.dumps(current_obj, sort_keys=True))
# Handle wildcard
elif token == "*":
if isinstance(obj, dict):
for value in obj.values():
results.extend(_extract_from_obj(value, tokens, pos + 1))
elif isinstance(obj, list):
for item in obj:
results.extend(_extract_from_obj(item, tokens, pos + 1))
# Handle regular field
else:
if isinstance(obj, dict) and token in obj:
results.extend(_extract_from_obj(obj[token], tokens, pos + 1))
return results
return _extract_from_obj(obj, tokens, 0)
# Private utility functions
def tokenize_path(path: str) -> list[str]:
"""Tokenize a path into components.
!!! info "Types handled"
- Simple paths: "field1.field2"
- Array indexing: "[0]", "[*]", "[-1]"
- Wildcards: "*"
- Multi-field selection: "{field1,field2}"
"""
if not path:
return []
tokens = []
current: list[str] = []
i = 0
while i < len(path):
char = path[i]
if char == "[": # Handle array index
if current:
tokens.append("".join(current))
current = []
bracket_count = 1
index_chars = ["["]
i += 1
while i < len(path) and bracket_count > 0:
if path[i] == "[":
bracket_count += 1
elif path[i] == "]":
bracket_count -= 1
index_chars.append(path[i])
i += 1
tokens.append("".join(index_chars))
continue
elif char == "{": # Handle multi-field selection
if current:
tokens.append("".join(current))
current = []
brace_count = 1
field_chars = ["{"]
i += 1
while i < len(path) and brace_count > 0:
if path[i] == "{":
brace_count += 1
elif path[i] == "}":
brace_count -= 1
field_chars.append(path[i])
i += 1
tokens.append("".join(field_chars))
continue
elif char == ".": # Handle regular field
if current:
tokens.append("".join(current))
current = []
else:
current.append(char)
i += 1
if current:
tokens.append("".join(current))
return tokens
def _is_async_callable(
func: Any,
) -> bool:
"""Check if a function is async.
This includes both async def functions and classes with async __call__ methods.
Args:
func: Function or callable object to check.
Returns:
True if the function is async, False otherwise.
"""
return (
asyncio.iscoroutinefunction(func)
or hasattr(func, "__call__") # noqa: B004
and asyncio.iscoroutinefunction(func.__call__)
)
__all__ = [
"ensure_embeddings",
"EmbeddingsFunc",
"AEmbeddingsFunc",
]
@@ -1,379 +1,79 @@
"""In-memory key-value store.
A lightweight store implementation using Python dictionaries. Supports basic
key-value operations and vector search when configured with embeddings.
Examples:
Basic key-value storage:
store = InMemoryStore()
store.put(("users", "123"), "prefs", {"theme": "dark"})
item = store.get(("users", "123"), "prefs")
Vector search with embeddings:
from langchain_openai import OpenAIEmbeddings
store = InMemoryStore(index={
"dims": 1536,
"embed": OpenAIEmbeddings(model="text-embedding-3-small"),
})
# Store documents
store.put(("docs",), "doc1", {"text": "Python tutorial"})
store.put(("docs",), "doc2", {"text": "TypeScript guide"})
# Search by similarity
results = store.search(("docs",), query="python programming")
Note:
For production use cases requiring persistence, use a database-backed store instead.
"""
import asyncio
import concurrent.futures as cf
import functools
import logging
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timezone from datetime import datetime, timezone
from importlib import util from typing import Iterable
from typing import Any, Iterable, Optional
from langchain_core.embeddings import Embeddings
from langgraph.store.base import ( from langgraph.store.base import (
BaseStore, BaseStore,
GetOp, GetOp,
IndexConfig,
Item, Item,
ListNamespacesOp, ListNamespacesOp,
MatchCondition, MatchCondition,
Op, Op,
PutOp, PutOp,
Result, Result,
SearchItem,
SearchOp, SearchOp,
ensure_embeddings,
get_text_at_path,
tokenize_path,
) )
logger = logging.getLogger(__name__)
class InMemoryStore(BaseStore): class InMemoryStore(BaseStore):
"""In-memory dictionary-backed store with optional vector search. """A KV store backed by an in-memory python dictionary.
Examples: Useful for testing/experimentation and lightweight PoC's.
Basic key-value storage: For actual persistence, use a Store backed by a proper database.
store = InMemoryStore()
store.put(("users", "123"), "prefs", {"theme": "dark"})
item = store.get(("users", "123"), "prefs")
Vector search with embeddings:
from langchain_openai import OpenAIEmbeddings
store = InMemoryStore(index={
"dims": 1536,
"embed": OpenAIEmbeddings(model="text-embedding-3-small"),
})
# Store documents
store.put(("docs",), "doc1", {"text": "Python tutorial"})
store.put(("docs",), "doc2", {"text": "TypeScript guide"})
# Search by similarity
results = store.search(("docs",), query="python programming")
Warning:
This store keeps all data in memory. Data is lost when the process exits.
For persistence, use a database-backed store like PostgresStore.
Tip:
For vector search, install numpy for better performance:
```bash
pip install numpy
```
""" """
__slots__ = ( __slots__ = ("_data",)
"_data",
"_vectors",
"index_config",
"embeddings",
)
def __init__(self, *, index: Optional[IndexConfig] = None) -> None: def __init__(self) -> None:
# Both _data and _vectors are wrapped in the In-memory API
# Do not change their names
self._data: dict[tuple[str, ...], dict[str, Item]] = defaultdict(dict) self._data: dict[tuple[str, ...], dict[str, Item]] = defaultdict(dict)
# [ns][key][path]
self._vectors: dict[tuple[str, ...], dict[str, dict[str, list[float]]]] = (
defaultdict(lambda: defaultdict(dict))
)
self.index_config = index
if self.index_config:
self.index_config = self.index_config.copy()
self.embeddings: Optional[Embeddings] = ensure_embeddings(
self.index_config.get("embed"),
)
self.index_config["__tokenized_fields"] = [
(p, tokenize_path(p)) if p != "$" else (p, p)
for p in (self.index_config.get("fields") or ["$"])
]
else:
self.index_config = None
self.embeddings = None
def batch(self, ops: Iterable[Op]) -> list[Result]: def batch(self, ops: Iterable[Op]) -> list[Result]:
# The batch/abatch methods are treated as internal.
# Users should access via put/search/get/list_namespaces/etc.
results, put_ops, search_ops = self._prepare_ops(ops)
if search_ops:
queryinmem_store = self._embed_search_queries(search_ops)
self._batch_search(search_ops, queryinmem_store, results)
to_embed = self._extract_texts(put_ops)
if to_embed and self.index_config and self.embeddings:
embeddings = self.embeddings.embed_documents(list(to_embed))
self._insertinmem_store(to_embed, embeddings)
self._apply_put_ops(put_ops)
return results
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
# The batch/abatch methods are treated as internal.
# Users should access via put/search/get/list_namespaces/etc.
results, put_ops, search_ops = self._prepare_ops(ops)
if search_ops:
queryinmem_store = await self._aembed_search_queries(search_ops)
self._batch_search(search_ops, queryinmem_store, results)
to_embed = self._extract_texts(put_ops)
if to_embed and self.index_config and self.embeddings:
embeddings = await self.embeddings.aembed_documents(list(to_embed))
self._insertinmem_store(to_embed, embeddings)
self._apply_put_ops(put_ops)
return results
# Helpers
def _filter_items(self, op: SearchOp) -> list[tuple[Item, list[list[float]]]]:
"""Filter items by namespace and filter function, return items with their embeddings."""
namespace_prefix = op.namespace_prefix
def filter_func(item: Item) -> bool:
if not op.filter:
return True
return all(
_compare_values(item.value.get(key), filter_value)
for key, filter_value in op.filter.items()
)
filtered = []
for namespace in self._data:
if not (
namespace[: len(namespace_prefix)] == namespace_prefix
if len(namespace) >= len(namespace_prefix)
else False
):
continue
for key, item in self._data[namespace].items():
if filter_func(item):
if op.query and (embeddings := self._vectors[namespace].get(key)):
filtered.append((item, list(embeddings.values())))
else:
filtered.append((item, []))
return filtered
def _embed_search_queries(
self,
search_ops: dict[int, tuple[SearchOp, list[tuple[Item, list[list[float]]]]]],
) -> dict[str, list[float]]:
queryinmem_store = {}
if self.index_config and self.embeddings and search_ops:
queries = {op.query for (op, _) in search_ops.values() if op.query}
if queries:
with cf.ThreadPoolExecutor() as executor:
futures = {
q: executor.submit(self.embeddings.embed_query, q)
for q in list(queries)
}
for query, future in futures.items():
queryinmem_store[query] = future.result()
return queryinmem_store
async def _aembed_search_queries(
self,
search_ops: dict[int, tuple[SearchOp, list[tuple[Item, list[list[float]]]]]],
) -> dict[str, list[float]]:
queryinmem_store = {}
if self.index_config and self.embeddings and search_ops:
queries = {op.query for (op, _) in search_ops.values() if op.query}
if queries:
coros = [self.embeddings.aembed_query(q) for q in list(queries)]
results = await asyncio.gather(*coros)
queryinmem_store = dict(zip(queries, results))
return queryinmem_store
def _batch_search(
self,
ops: dict[int, tuple[SearchOp, list[tuple[Item, list[list[float]]]]]],
queryinmem_store: dict[str, list[float]],
results: list[Result],
) -> None:
"""Perform batch similarity search for multiple queries."""
for i, (op, candidates) in ops.items():
if not candidates:
results[i] = []
continue
if op.query and queryinmem_store:
query_embedding = queryinmem_store[op.query]
flat_items, flat_vectors = [], []
scoreless = []
for item, vectors in candidates:
for vector in vectors:
flat_items.append(item)
flat_vectors.append(vector)
if not vectors:
scoreless.append(item)
scores = _cosine_similarity(query_embedding, flat_vectors)
sorted_results = sorted(
zip(scores, flat_items), key=lambda x: x[0], reverse=True
)
# max pooling
seen: set[tuple[tuple[str, ...], str]] = set()
kept: list[tuple[Optional[float], Item]] = []
for score, item in sorted_results:
key = (item.namespace, item.key)
if key in seen:
continue
ix = len(seen)
seen.add(key)
if ix >= op.offset + op.limit:
break
if ix < op.offset:
continue
kept.append((score, item))
if scoreless and len(kept) < op.limit:
# Corner case: if we request more items than what we have embedded,
# fill the rest with non-scored items
kept.extend(
(None, item) for item in scoreless[: op.limit - len(kept)]
)
results[i] = [
SearchItem(
namespace=item.namespace,
key=item.key,
value=item.value,
created_at=item.created_at,
updated_at=item.updated_at,
score=float(score) if score is not None else None,
)
for score, item in kept
]
else:
results[i] = [
SearchItem(
namespace=item.namespace,
key=item.key,
value=item.value,
created_at=item.created_at,
updated_at=item.updated_at,
)
for (item, _) in candidates[op.offset : op.offset + op.limit]
]
def _prepare_ops(
self, ops: Iterable[Op]
) -> tuple[
list[Result],
dict[tuple[tuple[str, ...], str], PutOp],
dict[int, tuple[SearchOp, list[tuple[Item, list[list[float]]]]]],
]:
results: list[Result] = [] results: list[Result] = []
put_ops: dict[tuple[tuple[str, ...], str], PutOp] = {} for op in ops:
search_ops: dict[
int, tuple[SearchOp, list[tuple[Item, list[list[float]]]]]
] = {}
for i, op in enumerate(ops):
if isinstance(op, GetOp): if isinstance(op, GetOp):
item = self._data[op.namespace].get(op.key) item = self._data[op.namespace].get(op.key)
results.append(item) results.append(item)
elif isinstance(op, SearchOp): elif isinstance(op, SearchOp):
search_ops[i] = (op, self._filter_items(op)) candidates = [
item
for namespace, items in self._data.items()
if (
namespace[: len(op.namespace_prefix)] == op.namespace_prefix
if len(namespace) >= len(op.namespace_prefix)
else False
)
for item in items.values()
]
if op.filter:
candidates = [
item
for item in candidates
if item.value.items() >= op.filter.items()
]
results.append(candidates[op.offset : op.offset + op.limit])
elif isinstance(op, PutOp):
if op.value is None:
self._data[op.namespace].pop(op.key, None)
elif op.key in self._data[op.namespace]:
self._data[op.namespace][op.key].value = op.value
self._data[op.namespace][op.key].updated_at = datetime.now(
timezone.utc
)
else:
self._data[op.namespace][op.key] = Item(
value=op.value,
key=op.key,
namespace=op.namespace,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
results.append(None) results.append(None)
elif isinstance(op, ListNamespacesOp): elif isinstance(op, ListNamespacesOp):
results.append(self._handle_list_namespaces(op)) results.append(self._handle_list_namespaces(op))
elif isinstance(op, PutOp): return results
put_ops[(op.namespace, op.key)] = op
results.append(None)
else:
raise ValueError(f"Unknown operation type: {type(op)}")
return results, put_ops, search_ops async def abatch(self, ops: Iterable[Op]) -> list[Result]:
return self.batch(ops)
def _apply_put_ops(self, put_ops: dict[tuple[tuple[str, ...], str], PutOp]) -> None:
for (namespace, key), op in put_ops.items():
if op.value is None:
self._data[namespace].pop(key, None)
self._vectors[namespace].pop(key, None)
else:
self._data[namespace][key] = Item(
value=op.value,
key=key,
namespace=namespace,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
def _extract_texts(
self, put_ops: dict[tuple[tuple[str, ...], str], PutOp]
) -> dict[str, list[tuple[tuple[str, ...], str, str]]]:
if put_ops and self.index_config and self.embeddings:
to_embed = defaultdict(list)
for op in put_ops.values():
if op.value is not None and op.index is not False:
if op.index is None:
paths = self.index_config["__tokenized_fields"]
else:
paths = [(ix, tokenize_path(ix)) for ix in op.index]
for path, field in paths:
texts = get_text_at_path(op.value, field)
if texts:
if len(texts) > 1:
for i, text in enumerate(texts):
to_embed[text].append(
(op.namespace, op.key, f"{path}.{i}")
)
else:
to_embed[texts[0]].append((op.namespace, op.key, path))
return to_embed
return {}
def _insertinmem_store(
self,
to_embed: dict[str, list[tuple[tuple[str, ...], str, str]]],
embeddings: list[list[float]],
) -> None:
indices = [index for indices in to_embed.values() for index in indices]
if len(indices) != len(embeddings):
raise ValueError(
f"Number of embeddings ({len(embeddings)}) does not"
f" match number of indices ({len(indices)})"
)
for embedding, (ns, key, path) in zip(embeddings, indices):
self._vectors[ns][key][path] = embedding
def _handle_list_namespaces(self, op: ListNamespacesOp) -> list[tuple[str, ...]]: def _handle_list_namespaces(self, op: ListNamespacesOp) -> list[tuple[str, ...]]:
all_namespaces = list( all_namespaces = list(
@@ -394,52 +94,7 @@ class InMemoryStore(BaseStore):
return namespaces[op.offset : op.offset + op.limit] return namespaces[op.offset : op.offset + op.limit]
@functools.lru_cache(maxsize=1)
def _check_numpy() -> bool:
if bool(util.find_spec("numpy")):
return True
logger.warning(
"NumPy not found in the current Python environment. "
"The InMemoryStore will use a pure Python implementation for vector operations, "
"which may significantly impact performance, especially for large datasets or frequent searches. "
"For optimal speed and efficiency, consider installing NumPy: "
"pip install numpy"
)
return False
def _cosine_similarity(X: list[float], Y: list[list[float]]) -> list[float]:
"""
Compute cosine similarity between a vector X and a matrix Y.
Lazy import numpy for efficiency.
"""
if _check_numpy():
import numpy as np # type: ignore
X_arr = np.array(X) if not isinstance(X, np.ndarray) else X
Y_arr = np.array(Y) if not isinstance(Y, np.ndarray) else Y
X_norm = np.linalg.norm(X_arr)
Y_norm = np.linalg.norm(Y_arr, axis=1)
# Avoid division by zero
mask = Y_norm != 0
similarities = np.zeros_like(Y_norm)
similarities[mask] = np.dot(Y_arr[mask], X_arr) / (Y_norm[mask] * X_norm)
return similarities.tolist()
similarities = []
for y in Y:
dot_product = sum(a * b for a, b in zip(X, y))
norm1 = sum(a * a for a in X) ** 0.5
norm2 = sum(a * a for a in y) ** 0.5
similarity = dot_product / (norm1 * norm2) if norm1 > 0 and norm2 > 0 else 0.0
similarities.append(similarity)
return similarities
def _does_match(match_condition: MatchCondition, key: tuple[str, ...]) -> bool: def _does_match(match_condition: MatchCondition, key: tuple[str, ...]) -> bool:
"""Whether a namespace key matches a match condition."""
match_type = match_condition.match_type match_type = match_condition.match_type
path = match_condition.path path = match_condition.path
@@ -462,44 +117,3 @@ def _does_match(match_condition: MatchCondition, key: tuple[str, ...]) -> bool:
return True return True
else: else:
raise ValueError(f"Unsupported match type: {match_type}") raise ValueError(f"Unsupported match type: {match_type}")
def _compare_values(item_value: Any, filter_value: Any) -> bool:
"""Compare values in a JSONB-like way, handling nested objects."""
if isinstance(filter_value, dict):
if any(k.startswith("$") for k in filter_value):
return all(
_apply_operator(item_value, op_key, op_value)
for op_key, op_value in filter_value.items()
)
if not isinstance(item_value, dict):
return False
return all(
_compare_values(item_value.get(k), v) for k, v in filter_value.items()
)
elif isinstance(filter_value, (list, tuple)):
return (
isinstance(item_value, (list, tuple))
and len(item_value) == len(filter_value)
and all(_compare_values(iv, fv) for iv, fv in zip(item_value, filter_value))
)
else:
return item_value == filter_value
def _apply_operator(value: Any, operator: str, op_value: Any) -> bool:
"""Apply a comparison operator, matching PostgreSQL's JSONB behavior."""
if operator == "$eq":
return value == op_value
elif operator == "$gt":
return float(value) > float(op_value)
elif operator == "$gte":
return float(value) >= float(op_value)
elif operator == "$lt":
return float(value) < float(op_value)
elif operator == "$lte":
return float(value) <= float(op_value)
elif operator == "$ne":
return value != op_value
else:
raise ValueError(f"Unsupported operator: {operator}")
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "langgraph-checkpoint" name = "langgraph-checkpoint"
version = "2.0.7" version = "2.0.5"
description = "Library with base interfaces for LangGraph checkpoint savers." description = "Library with base interfaces for LangGraph checkpoint savers."
authors = [] authors = []
license = "MIT" license = "MIT"
-55
View File
@@ -1,55 +0,0 @@
"""Embedding utilities for testing."""
import math
import random
from collections import Counter, defaultdict
from typing import Any
from langchain_core.embeddings import Embeddings
class CharacterEmbeddings(Embeddings):
"""Simple character-frequency based embeddings using random projections."""
def __init__(self, dims: int = 50, seed: int = 42):
"""Initialize with embedding dimensions and random seed."""
self._rng = random.Random(seed)
self.dims = dims
# Create projection vector for each character lazily
self._char_projections: defaultdict[str, list[float]] = defaultdict(
lambda: [
self._rng.gauss(0, 1 / math.sqrt(self.dims)) for _ in range(self.dims)
]
)
def _embed_one(self, text: str) -> list[float]:
"""Embed a single text."""
counts = Counter(text)
total = sum(counts.values())
if total == 0:
return [0.0] * self.dims
embedding = [0.0] * self.dims
for char, count in counts.items():
weight = count / total
char_proj = self._char_projections[char]
for i, proj in enumerate(char_proj):
embedding[i] += weight * proj
norm = math.sqrt(sum(x * x for x in embedding))
if norm > 0:
embedding = [x / norm for x in embedding]
return embedding
def embed_documents(self, texts: list[str]) -> list[list[float]]:
"""Embed a list of documents."""
return [self._embed_one(text) for text in texts]
def embed_query(self, text: str) -> list[float]:
"""Embed a query string."""
return self._embed_one(text)
def __eq__(self, other: Any) -> bool:
return isinstance(other, CharacterEmbeddings) and self.dims == other.dims
+15 -608
View File
@@ -1,104 +1,13 @@
# mypy: disable-error-code="operator"
import asyncio import asyncio
import json
from datetime import datetime from datetime import datetime
from typing import Any, Iterable from typing import Iterable
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from langgraph.store.base import ( from langgraph.store.base import GetOp, InvalidNamespaceError, Item, Op, PutOp, Result
GetOp,
InvalidNamespaceError,
Item,
Op,
PutOp,
Result,
get_text_at_path,
)
from langgraph.store.base.batch import AsyncBatchedBaseStore from langgraph.store.base.batch import AsyncBatchedBaseStore
from langgraph.store.memory import InMemoryStore from langgraph.store.memory import InMemoryStore
from tests.embed_test_utils import CharacterEmbeddings
class MockAsyncBatchedStore(AsyncBatchedBaseStore):
def __init__(self, **kwargs: Any) -> None:
super().__init__()
self._store = InMemoryStore(**kwargs)
def batch(self, ops: Iterable[Op]) -> list[Result]:
return self._store.batch(ops)
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
return self._store.batch(ops)
def test_get_text_at_path() -> None:
nested_data = {
"name": "test",
"info": {
"age": 25,
"tags": ["a", "b", "c"],
"metadata": {"created": "2024-01-01", "updated": "2024-01-02"},
},
"items": [
{"id": 1, "value": "first", "tags": ["x", "y"]},
{"id": 2, "value": "second", "tags": ["y", "z"]},
{"id": 3, "value": "third", "tags": ["z", "w"]},
],
"empty": None,
"zeros": [0, 0.0, "0"],
"empty_list": [],
"empty_dict": {},
}
assert get_text_at_path(nested_data, "$") == [
json.dumps(nested_data, sort_keys=True)
]
assert get_text_at_path(nested_data, "name") == ["test"]
assert get_text_at_path(nested_data, "info.age") == ["25"]
assert get_text_at_path(nested_data, "info.metadata.created") == ["2024-01-01"]
assert get_text_at_path(nested_data, "items[0].value") == ["first"]
assert get_text_at_path(nested_data, "items[-1].value") == ["third"]
assert get_text_at_path(nested_data, "items[1].tags[0]") == ["y"]
values = get_text_at_path(nested_data, "items[*].value")
assert set(values) == {"first", "second", "third"}
metadata_dates = get_text_at_path(nested_data, "info.metadata.*")
assert set(metadata_dates) == {"2024-01-01", "2024-01-02"}
name_and_age = get_text_at_path(nested_data, "{name,info.age}")
assert set(name_and_age) == {"test", "25"}
item_fields = get_text_at_path(nested_data, "items[*].{id,value}")
assert set(item_fields) == {"1", "2", "3", "first", "second", "third"}
all_tags = get_text_at_path(nested_data, "items[*].tags[*]")
assert set(all_tags) == {"x", "y", "z", "w"}
assert get_text_at_path(None, "any.path") == []
assert get_text_at_path({}, "any.path") == []
assert get_text_at_path(nested_data, "") == [
json.dumps(nested_data, sort_keys=True)
]
assert get_text_at_path(nested_data, "nonexistent") == []
assert get_text_at_path(nested_data, "items[99].value") == []
assert get_text_at_path(nested_data, "items[*].nonexistent") == []
assert get_text_at_path(nested_data, "empty") == []
assert get_text_at_path(nested_data, "empty_list") == ["[]"]
assert get_text_at_path(nested_data, "empty_dict") == ["{}"]
zeros = get_text_at_path(nested_data, "zeros[*]")
assert set(zeros) == {"0", "0.0"}
assert get_text_at_path(nested_data, "items[].value") == []
assert get_text_at_path(nested_data, "items[abc].value") == []
assert get_text_at_path(nested_data, "{unclosed") == []
assert get_text_at_path(nested_data, "nested[{invalid}]") == []
async def test_async_batch_store(mocker: MockerFixture) -> None: async def test_async_batch_store(mocker: MockerFixture) -> None:
@@ -383,14 +292,12 @@ async def test_cannot_put_empty_namespace() -> None:
await store.aput(("foo", "langgraph", "foo"), "bar", doc) await store.aput(("foo", "langgraph", "foo"), "bar", doc)
assert (await store.aget(("foo", "langgraph", "foo"), "bar")).value == doc # type: ignore[union-attr] assert (await store.aget(("foo", "langgraph", "foo"), "bar")).value == doc # type: ignore[union-attr]
assert (await store.asearch(("foo", "langgraph", "foo"), query="bar"))[ assert (await store.asearch(("foo", "langgraph", "foo")))[0].value == doc
0
].value == doc
await store.adelete(("foo", "langgraph", "foo"), "bar") await store.adelete(("foo", "langgraph", "foo"), "bar")
assert (await store.aget(("foo", "langgraph", "foo"), "bar")) is None assert (await store.aget(("foo", "langgraph", "foo"), "bar")) is None
store.put(("foo", "langgraph", "foo"), "bar", doc) store.put(("foo", "langgraph", "foo"), "bar", doc)
assert store.get(("foo", "langgraph", "foo"), "bar").value == doc # type: ignore[union-attr] assert store.get(("foo", "langgraph", "foo"), "bar").value == doc # type: ignore[union-attr]
assert store.search(("foo", "langgraph", "foo"), query="bar")[0].value == doc assert store.search(("foo", "langgraph", "foo"))[0].value == doc
store.delete(("foo", "langgraph", "foo"), "bar") store.delete(("foo", "langgraph", "foo"), "bar")
assert store.get(("foo", "langgraph", "foo"), "bar") is None assert store.get(("foo", "langgraph", "foo"), "bar") is None
@@ -406,6 +313,17 @@ async def test_cannot_put_empty_namespace() -> None:
store.delete(("langgraph", "foo"), "bar") store.delete(("langgraph", "foo"), "bar")
assert store.get(("langgraph", "foo"), "bar") is None assert store.get(("langgraph", "foo"), "bar") is None
class MockAsyncBatchedStore(AsyncBatchedBaseStore):
def __init__(self) -> None:
super().__init__()
self._store = InMemoryStore()
def batch(self, ops: Iterable[Op]) -> list[Result]:
return self._store.batch(ops)
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
return self._store.batch(ops)
async_store = MockAsyncBatchedStore() async_store = MockAsyncBatchedStore()
doc = {"foo": "bar"} doc = {"foo": "bar"}
@@ -426,9 +344,6 @@ async def test_cannot_put_empty_namespace() -> None:
assert val is not None assert val is not None
assert val.value == doc assert val.value == doc
assert (await async_store.asearch(("foo", "langgraph", "foo")))[0].value == doc assert (await async_store.asearch(("foo", "langgraph", "foo")))[0].value == doc
assert (await async_store.asearch(("foo", "langgraph", "foo"), query="bar"))[
0
].value == doc
await async_store.adelete(("foo", "langgraph", "foo"), "bar") await async_store.adelete(("foo", "langgraph", "foo"), "bar")
assert (await async_store.aget(("foo", "langgraph", "foo"), "bar")) is None assert (await async_store.aget(("foo", "langgraph", "foo"), "bar")) is None
@@ -439,511 +354,3 @@ async def test_cannot_put_empty_namespace() -> None:
assert (await async_store.asearch(("valid", "namespace")))[0].value == doc assert (await async_store.asearch(("valid", "namespace")))[0].value == doc
await async_store.adelete(("valid", "namespace"), "key") await async_store.adelete(("valid", "namespace"), "key")
assert (await async_store.aget(("valid", "namespace"), "key")) is None assert (await async_store.aget(("valid", "namespace"), "key")) is None
async def test_async_batch_store_deduplication(mocker: MockerFixture) -> None:
abatch = mocker.spy(InMemoryStore, "batch")
store = MockAsyncBatchedStore()
same_doc = {"value": "same"}
diff_doc = {"value": "different"}
await asyncio.gather(
store.aput(namespace=("test",), key="same", value=same_doc),
store.aput(namespace=("test",), key="different", value=diff_doc),
)
abatch.reset_mock()
results = await asyncio.gather(
store.aget(namespace=("test",), key="same"),
store.aget(namespace=("test",), key="same"),
store.aget(namespace=("test",), key="different"),
)
assert len(results) == 3
assert results[0] == results[1]
assert results[0] != results[2]
assert results[0].value == same_doc # type: ignore
assert results[2].value == diff_doc # type: ignore
assert len(abatch.call_args_list) == 1
ops = list(abatch.call_args_list[0].args[1])
assert len(ops) == 2
assert GetOp(("test",), "same") in ops
assert GetOp(("test",), "different") in ops
abatch.reset_mock()
doc1 = {"value": 1}
doc2 = {"value": 2}
results = await asyncio.gather(
store.aput(namespace=("test",), key="key", value=doc1),
store.aput(namespace=("test",), key="key", value=doc2),
)
assert len(abatch.call_args_list) == 1
ops = list(abatch.call_args_list[0].args[1])
assert len(ops) == 1
assert ops[0] == PutOp(("test",), "key", doc2)
assert len(results) == 2
assert all(result is None for result in results)
result = await store.aget(namespace=("test",), key="key")
assert result is not None
assert result.value == doc2
abatch.reset_mock()
results = await asyncio.gather(
store.asearch(("test",), filter={"value": 2}),
store.asearch(("test",), filter={"value": 2}),
)
assert len(abatch.call_args_list) == 1
ops = list(abatch.call_args_list[0].args[1])
assert len(ops) == 1
assert len(results) == 2
assert results[0] == results[1]
assert len(results[0]) == 1
assert results[0][0].value == doc2
abatch.reset_mock()
@pytest.fixture
def fake_embeddings() -> CharacterEmbeddings:
return CharacterEmbeddings(dims=500)
def test_vector_store_initialization(fake_embeddings: CharacterEmbeddings) -> None:
"""Test store initialization with embedding config."""
store = InMemoryStore(
index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
)
assert store.index_config is not None
assert store.index_config["dims"] == fake_embeddings.dims
assert store.index_config["embed"] == fake_embeddings
def test_vector_insert_with_auto_embedding(
fake_embeddings: CharacterEmbeddings,
) -> None:
"""Test inserting items that get auto-embedded."""
store = InMemoryStore(
index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
)
docs = [
("doc1", {"text": "short text"}),
("doc2", {"text": "longer text document"}),
("doc3", {"text": "longest text document here"}),
("doc4", {"description": "text in description field"}),
("doc5", {"content": "text in content field"}),
("doc6", {"body": "text in body field"}),
]
for key, value in docs:
store.put(("test",), key, value)
results = store.search(("test",), query="long text")
assert len(results) > 0
doc_order = [r.key for r in results]
assert "doc2" in doc_order
assert "doc3" in doc_order
async def test_async_vector_insert_with_auto_embedding(
fake_embeddings: CharacterEmbeddings,
) -> None:
"""Test inserting items that get auto-embedded using async methods."""
store = InMemoryStore(
index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
)
docs = [
("doc1", {"text": "short text"}),
("doc2", {"text": "longer text document"}),
("doc3", {"text": "longest text document here"}),
("doc4", {"description": "text in description field"}),
("doc5", {"content": "text in content field"}),
("doc6", {"body": "text in body field"}),
]
for key, value in docs:
await store.aput(("test",), key, value)
results = await store.asearch(("test",), query="long text")
assert len(results) > 0
doc_order = [r.key for r in results]
assert "doc2" in doc_order
assert "doc3" in doc_order
def test_vector_update_with_embedding(fake_embeddings: CharacterEmbeddings) -> None:
"""Test that updating items properly updates their embeddings."""
store = InMemoryStore(
index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
)
store.put(("test",), "doc1", {"text": "zany zebra Xerxes"})
store.put(("test",), "doc2", {"text": "something about dogs"})
store.put(("test",), "doc3", {"text": "text about birds"})
results_initial = store.search(("test",), query="Zany Xerxes")
assert len(results_initial) > 0
assert results_initial[0].key == "doc1"
initial_score = results_initial[0].score
assert initial_score is not None
store.put(("test",), "doc1", {"text": "new text about dogs"})
results_after = store.search(("test",), query="Zany Xerxes")
after_score = next((r.score for r in results_after if r.key == "doc1"), 0.0)
assert after_score is not None
assert after_score < initial_score
results_new = store.search(("test",), query="new text about dogs")
for r in results_new:
if r.key == "doc1":
assert r.score > after_score
# Don't index this one
store.put(("test",), "doc4", {"text": "new text about dogs"}, index=False)
results_new = store.search(("test",), query="new text about dogs", limit=3)
assert not any(r.key == "doc4" for r in results_new)
async def test_async_vector_update_with_embedding(
fake_embeddings: CharacterEmbeddings,
) -> None:
"""Test that updating items properly updates their embeddings using async methods."""
store = InMemoryStore(
index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
)
await store.aput(("test",), "doc1", {"text": "zany zebra Xerxes"})
await store.aput(("test",), "doc2", {"text": "something about dogs"})
await store.aput(("test",), "doc3", {"text": "text about birds"})
results_initial = await store.asearch(("test",), query="Zany Xerxes")
assert len(results_initial) > 0
assert results_initial[0].key == "doc1"
initial_score = results_initial[0].score
await store.aput(("test",), "doc1", {"text": "new text about dogs"})
results_after = await store.asearch(("test",), query="Zany Xerxes")
after_score = next((r.score for r in results_after if r.key == "doc1"), 0.0)
assert after_score is not None
assert after_score < initial_score
results_new = await store.asearch(("test",), query="new text about dogs")
for r in results_new:
if r.key == "doc1":
assert r.score is not None
assert r.score > after_score
# Don't index this one
await store.aput(("test",), "doc4", {"text": "new text about dogs"}, index=False)
results_new = await store.asearch(("test",), query="new text about dogs", limit=3)
assert not any(r.key == "doc4" for r in results_new)
def test_vector_search_with_filters(fake_embeddings: CharacterEmbeddings) -> None:
"""Test combining vector search with filters."""
inmem_store = InMemoryStore(
index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
)
# Insert test documents
docs = [
("doc1", {"text": "red apple", "color": "red", "score": 4.5}),
("doc2", {"text": "red car", "color": "red", "score": 3.0}),
("doc3", {"text": "green apple", "color": "green", "score": 4.0}),
("doc4", {"text": "blue car", "color": "blue", "score": 3.5}),
]
for key, value in docs:
inmem_store.put(("test",), key, value)
results = inmem_store.search(("test",), query="apple", filter={"color": "red"})
assert len(results) == 2
assert results[0].key == "doc1"
results = inmem_store.search(("test",), query="car", filter={"color": "red"})
assert len(results) == 2
assert results[0].key == "doc2"
results = inmem_store.search(
("test",), query="bbbbluuu", filter={"score": {"$gt": 3.2}}
)
assert len(results) == 3
assert results[0].key == "doc4"
# Multiple filters
results = inmem_store.search(
("test",), query="apple", filter={"score": {"$gte": 4.0}, "color": "green"}
)
assert len(results) == 1
assert results[0].key == "doc3"
async def test_async_vector_search_with_filters(
fake_embeddings: CharacterEmbeddings,
) -> None:
"""Test combining vector search with filters using async methods."""
store = InMemoryStore(
index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
)
# Insert test documents
docs = [
("doc1", {"text": "red apple", "color": "red", "score": 4.5}),
("doc2", {"text": "red car", "color": "red", "score": 3.0}),
("doc3", {"text": "green apple", "color": "green", "score": 4.0}),
("doc4", {"text": "blue car", "color": "blue", "score": 3.5}),
]
for key, value in docs:
await store.aput(("test",), key, value)
results = await store.asearch(("test",), query="apple", filter={"color": "red"})
assert len(results) == 2
assert results[0].key == "doc1"
results = await store.asearch(("test",), query="car", filter={"color": "red"})
assert len(results) == 2
assert results[0].key == "doc2"
results = await store.asearch(
("test",), query="bbbbluuu", filter={"score": {"$gt": 3.2}}
)
assert len(results) == 3
assert results[0].key == "doc4"
# Multiple filters
results = await store.asearch(
("test",), query="apple", filter={"score": {"$gte": 4.0}, "color": "green"}
)
assert len(results) == 1
assert results[0].key == "doc3"
async def test_async_batched_vector_search_concurrent(
fake_embeddings: CharacterEmbeddings,
) -> None:
"""Test concurrent vector search operations using async batched store."""
store = MockAsyncBatchedStore(
index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
)
colors = ["red", "blue", "green", "yellow", "purple"]
items = ["apple", "car", "house", "book", "phone"]
scores = [3.0, 3.5, 4.0, 4.5, 5.0]
docs = []
for i in range(50):
color = colors[i % len(colors)]
item = items[i % len(items)]
score = scores[i % len(scores)]
docs.append(
(
f"doc{i}",
{"text": f"{color} {item}", "color": color, "score": score, "index": i},
)
)
coros = [
*[store.aput(("test",), key, value) for key, value in docs],
*[store.adelete(("test",), key) for key, value in docs],
*[store.aput(("test",), key, value) for key, value in docs],
]
await asyncio.gather(*coros)
# Prepare multiple search queries with different filters
search_queries: list[tuple[str, dict[str, Any]]] = [
("apple", {"color": "red"}),
("car", {"color": "blue"}),
("house", {"color": "green"}),
("phone", {"score": {"$gt": 4.99}}),
("book", {"score": {"$lte": 3.5}}),
("apple", {"score": {"$gte": 3.0}, "color": "red"}),
("car", {"score": {"$lt": 5.1}, "color": "blue"}),
("house", {"index": {"$gt": 25}}),
("phone", {"index": {"$lte": 10}}),
]
all_results = await asyncio.gather(
*[
store.asearch(("test",), query=query, filter=filter_)
for query, filter_ in search_queries
]
)
for results, (query, filter_) in zip(all_results, search_queries):
assert len(results) > 0, f"No results for query '{query}' with filter {filter_}"
for result in results:
if "color" in filter_:
assert result.value["color"] == filter_["color"]
if "score" in filter_:
score = result.value["score"]
for op, value in filter_["score"].items():
if op == "$gt":
assert score > value
elif op == "$gte":
assert score >= value
elif op == "$lt":
assert score < value
elif op == "$lte":
assert score <= value
if "index" in filter_:
index = result.value["index"]
for op, value in filter_["index"].items():
if op == "$gt":
assert index > value
elif op == "$gte":
assert index >= value
elif op == "$lt":
assert index < value
elif op == "$lte":
assert index <= value
def test_vector_search_pagination(fake_embeddings: CharacterEmbeddings) -> None:
"""Test pagination with vector search."""
store = InMemoryStore(
index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
)
for i in range(5):
store.put(("test",), f"doc{i}", {"text": f"test document number {i}"})
results_page1 = store.search(("test",), query="test", limit=2)
results_page2 = store.search(("test",), query="test", limit=2, offset=2)
assert len(results_page1) == 2
assert len(results_page2) == 2
assert results_page1[0].key != results_page2[0].key
all_results = store.search(("test",), query="test", limit=10)
assert len(all_results) == 5
async def test_async_vector_search_pagination(
fake_embeddings: CharacterEmbeddings,
) -> None:
"""Test pagination with vector search using async methods."""
store = InMemoryStore(
index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
)
for i in range(5):
await store.aput(("test",), f"doc{i}", {"text": f"test document number {i}"})
results_page1 = await store.asearch(("test",), query="test", limit=2)
results_page2 = await store.asearch(("test",), query="test", limit=2, offset=2)
assert len(results_page1) == 2
assert len(results_page2) == 2
assert results_page1[0].key != results_page2[0].key
all_results = await store.asearch(("test",), query="test", limit=10)
assert len(all_results) == 5
async def test_embed_with_path(fake_embeddings: CharacterEmbeddings) -> None:
# Test store-level field configuration
store = InMemoryStore(
index={
"dims": fake_embeddings.dims,
"embed": fake_embeddings,
# Key 2 isn't included. Don't index it.
"fields": ["key0", "key1", "key3"],
}
)
# This will have 2 vectors representing it
doc1 = {
# Omit key0 - check it doesn't raise an error
"key1": "xxx",
"key2": "yyy",
"key3": "zzz",
}
# This will have 3 vectors representing it
doc2 = {
"key0": "uuu",
"key1": "vvv",
"key2": "www",
"key3": "xxx",
}
await store.aput(("test",), "doc1", doc1)
await store.aput(("test",), "doc2", doc2)
# doc2.key3 and doc1.key1 both would have the highest score
results = await store.asearch(("test",), query="xxx")
assert len(results) == 2
assert results[0].key != results[1].key
ascore = results[0].score
bscore = results[1].score
assert ascore == bscore
assert ascore is not None and bscore is not None
results = await store.asearch(("test",), query="uuu")
assert len(results) == 2
assert results[0].key != results[1].key
assert results[0].key == "doc2"
assert results[0].score is not None and results[0].score > results[1].score
assert ascore == pytest.approx(results[0].score, abs=1e-5)
# Un-indexed - will have low results for both. Not zero (because we're projecting)
# but less than the above.
results = await store.asearch(("test",), query="www")
assert len(results) == 2
assert results[0].score < ascore
assert results[1].score < ascore
# Test operation-level field configuration
store_no_defaults = InMemoryStore(
index={
"dims": fake_embeddings.dims,
"embed": fake_embeddings,
"fields": ["key17"],
}
)
doc3 = {
"key0": "aaa",
"key1": "bbb",
"key2": "ccc",
"key3": "ddd",
}
doc4 = {
"key0": "eee",
"key1": "bbb", # Same as doc3.key1
"key2": "fff",
"key3": "ggg",
}
await store_no_defaults.aput(("test",), "doc3", doc3, index=["key0", "key1"])
await store_no_defaults.aput(("test",), "doc4", doc4, index=["key1", "key3"])
results = await store_no_defaults.asearch(("test",), query="aaa")
assert len(results) == 2
assert results[0].key == "doc3"
assert results[0].score is not None and results[0].score > results[1].score
results = await store_no_defaults.asearch(("test",), query="ggg")
assert len(results) == 2
assert results[0].key == "doc4"
assert results[0].score is not None and results[0].score > results[1].score
results = await store_no_defaults.asearch(("test",), query="bbb")
assert len(results) == 2
assert results[0].key != results[1].key
assert results[0].score == results[1].score
results = await store_no_defaults.asearch(("test",), query="ccc")
assert len(results) == 2
assert all(r.score < ascore for r in results)
doc5 = {
"key0": "hhh",
"key1": "iii",
}
await store_no_defaults.aput(("test",), "doc5", doc5, index=False)
results = await store_no_defaults.asearch(("test",), query="hhh")
assert len(results) == 3
doc5_result = next(r for r in results if r.key == "doc5")
assert doc5_result.score is None
+3 -3
View File
@@ -1299,9 +1299,9 @@ create-jest@^29.7.0:
prompts "^2.0.1" prompts "^2.0.1"
cross-spawn@^7.0.2, cross-spawn@^7.0.3: cross-spawn@^7.0.2, cross-spawn@^7.0.3:
version "7.0.6" version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
dependencies: dependencies:
path-key "^3.1.0" path-key "^3.1.0"
shebang-command "^2.0.0" shebang-command "^2.0.0"
+31 -42
View File
@@ -1,4 +1,4 @@
import os import json
import pathlib import pathlib
import shutil import shutil
import sys import sys
@@ -45,7 +45,7 @@ OPT_CONFIG = click.option(
- "graphs": mapping from graph ID to path where the compiled graph is defined, i.e. ./your_package/your_file.py:variable, where - "graphs": mapping from graph ID to path where the compiled graph is defined, i.e. ./your_package/your_file.py:variable, where
"variable" is an instance of langgraph.graph.graph.CompiledGraph "variable" is an instance of langgraph.graph.graph.CompiledGraph
- "env": (optional) path to .env file or a mapping from environment variable to its value - "env": (optional) path to .env file or a mapping from environment variable to its value
- "python_version": (optional) 3.11, 3.12, or 3.13. Defaults to 3.11 - "python_version": (optional) 3.11 or 3.12. Defaults to 3.11
- "pip_config_file": (optional) path to pip config file - "pip_config_file": (optional) path to pip config file
- "dockerfile_lines": (optional) array of additional lines to add to Dockerfile following the import from parent image - "dockerfile_lines": (optional) array of additional lines to add to Dockerfile following the import from parent image
@@ -190,6 +190,7 @@ def up(
click.secho( click.secho(
"""For local dev, requires env var LANGSMITH_API_KEY with access to LangGraph Cloud closed beta. """For local dev, requires env var LANGSMITH_API_KEY with access to LangGraph Cloud closed beta.
For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KEY.""", For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KEY.""",
fg="red",
) )
with Runner() as runner, Progress(message="Pulling...") as set: with Runner() as runner, Progress(message="Pulling...") as set:
capabilities = langgraph_cli.docker.check_capabilities(runner) capabilities = langgraph_cli.docker.check_capabilities(runner)
@@ -353,7 +354,8 @@ def build(
with Runner() as runner, Progress(message="Pulling...") as set: with Runner() as runner, Progress(message="Pulling...") as set:
if shutil.which("docker") is None: if shutil.which("docker") is None:
raise click.UsageError("Docker not installed") from None raise click.UsageError("Docker not installed") from None
config_json = langgraph_cli.config.validate_config_file(config) with open(config) as f:
config_json = langgraph_cli.config.validate_config(json.load(f))
_build( _build(
runner, set, config, config_json, base_image, pull, tag, docker_build_args runner, set, config, config_json, base_image, pull, tag, docker_build_args
) )
@@ -433,7 +435,8 @@ tests
def dockerfile(save_path: str, config: pathlib.Path, add_docker_compose: bool) -> None: def dockerfile(save_path: str, config: pathlib.Path, add_docker_compose: bool) -> None:
save_path = pathlib.Path(save_path).absolute() save_path = pathlib.Path(save_path).absolute()
secho(f"🔍 Validating configuration at path: {config}", fg="yellow") secho(f"🔍 Validating configuration at path: {config}", fg="yellow")
config_json = langgraph_cli.config.validate_config_file(config) with open(config, encoding="utf-8") as f:
config_json = langgraph_cli.config.validate_config(json.load(f))
secho("✅ Configuration validated!", fg="green") secho("✅ Configuration validated!", fg="green")
secho(f"📝 Generating Dockerfile at {save_path}", fg="yellow") secho(f"📝 Generating Dockerfile at {save_path}", fg="yellow")
@@ -511,6 +514,19 @@ def dockerfile(save_path: str, config: pathlib.Path, add_docker_compose: bool) -
) )
@click.argument("path", required=False)
@click.option(
"--template",
type=str,
help=TEMPLATE_HELP_STRING,
)
@cli.command("new", help="🌱 Create a new LangGraph project from a template.")
@log_command
def new(path: Optional[str], template: Optional[str]) -> None:
"""Create a new LangGraph project from a template."""
return create_new(path, template)
@click.option( @click.option(
"--host", "--host",
default="127.0.0.1", default="127.0.0.1",
@@ -550,12 +566,6 @@ def dockerfile(save_path: str, config: pathlib.Path, add_docker_compose: bool) -
type=int, type=int,
help="Enable remote debugging by listening on specified port. Requires debugpy to be installed", help="Enable remote debugging by listening on specified port. Requires debugpy to be installed",
) )
@click.option(
"--wait-for-client",
is_flag=True,
help="Wait for a debugger client to connect to the debug port before starting the server",
default=False,
)
@cli.command( @cli.command(
"dev", "dev",
help="🏃‍♀️‍➡️ Run LangGraph API server in development mode with hot reloading and debugging support", help="🏃‍♀️‍➡️ Run LangGraph API server in development mode with hot reloading and debugging support",
@@ -565,11 +575,10 @@ def dev(
host: str, host: str,
port: int, port: int,
no_reload: bool, no_reload: bool,
config: pathlib.Path, config: str,
n_jobs_per_worker: Optional[int], n_jobs_per_worker: Optional[int],
no_browser: bool, no_browser: bool,
debug_port: Optional[int], debug_port: Optional[int],
wait_for_client: bool,
): ):
"""CLI entrypoint for running the LangGraph API server.""" """CLI entrypoint for running the LangGraph API server."""
try: try:
@@ -592,17 +601,12 @@ def dev(
"Please ensure langgraph-cli is installed with the 'inmem' extra: pip install -U \"langgraph-cli[inmem]\"" "Please ensure langgraph-cli is installed with the 'inmem' extra: pip install -U \"langgraph-cli[inmem]\""
) from None ) from None
config_json = langgraph_cli.config.validate_config_file(config) import json
cwd = os.getcwd()
sys.path.append(cwd)
dependencies = config_json.get("dependencies", [])
for dep in dependencies:
dep_path = pathlib.Path(cwd) / dep
if dep_path.is_dir() and dep_path.exists():
sys.path.append(str(dep_path))
graphs = config_json.get("graphs", {}) with open(config, encoding="utf-8") as f:
config_data = json.load(f)
graphs = config_data.get("graphs", {})
run_server( run_server(
host, host,
port, port,
@@ -611,25 +615,9 @@ def dev(
n_jobs_per_worker=n_jobs_per_worker, n_jobs_per_worker=n_jobs_per_worker,
open_browser=not no_browser, open_browser=not no_browser,
debug_port=debug_port, debug_port=debug_port,
env=config_json.get("env"),
store=config_json.get("store"),
wait_for_client=wait_for_client,
) )
@click.argument("path", required=False)
@click.option(
"--template",
type=str,
help=TEMPLATE_HELP_STRING,
)
@cli.command("new", help="🌱 Create a new LangGraph project from a template.")
@log_command
def new(path: Optional[str], template: Optional[str]) -> None:
"""Create a new LangGraph project from a template."""
return create_new(path, template)
def prepare_args_and_stdin( def prepare_args_and_stdin(
*, *,
capabilities: DockerCapabilities, capabilities: DockerCapabilities,
@@ -686,7 +674,8 @@ def prepare(
debugger_base_url: Optional[str] = None, debugger_base_url: Optional[str] = None,
postgres_uri: Optional[str] = None, postgres_uri: Optional[str] = None,
): ):
config_json = langgraph_cli.config.validate_config_file(config_path) with open(config_path) as f:
config = langgraph_cli.config.validate_config(json.load(f))
# pull latest images # pull latest images
if pull: if pull:
runner.run( runner.run(
@@ -694,9 +683,9 @@ def prepare(
"docker", "docker",
"pull", "pull",
( (
f"langchain/langgraphjs-api:{config_json['node_version']}" f"langchain/langgraphjs-api:{config['node_version']}"
if config_json.get("node_version") if config.get("node_version")
else f"langchain/langgraph-api:{config_json['python_version']}" else f"langchain/langgraph-api:{config['python_version']}"
), ),
verbose=verbose, verbose=verbose,
) )
@@ -705,7 +694,7 @@ def prepare(
args, stdin = prepare_args_and_stdin( args, stdin = prepare_args_and_stdin(
capabilities=capabilities, capabilities=capabilities,
config_path=config_path, config_path=config_path,
config=config_json, config=config,
docker_compose=docker_compose, docker_compose=docker_compose,
port=port, port=port,
watch=watch, watch=watch,
+15 -149
View File
@@ -6,48 +6,8 @@ from typing import NamedTuple, Optional, TypedDict, Union
import click import click
MIN_NODE_VERSION = "20"
MIN_PYTHON_VERSION = "3.11"
class Config(TypedDict):
class IndexConfig(TypedDict, total=False):
"""Configuration for indexing documents for semantic search in the store."""
dims: int
"""Number of dimensions in the embedding vectors.
Common embedding models have the following dimensions:
- OpenAI text-embedding-3-large: 256, 1024, or 3072
- OpenAI text-embedding-3-small: 512 or 1536
- OpenAI text-embedding-ada-002: 1536
- Cohere embed-english-v3.0: 1024
- Cohere embed-english-light-v3.0: 384
- Cohere embed-multilingual-v3.0: 1024
- Cohere embed-multilingual-light-v3.0: 384
"""
embed: str
"""Optional model (string) to generate embeddings from text or path to model or function.
Examples:
- "openai:text-embedding-3-large"
- "cohere:embed-multilingual-v3.0"
- "src/app.py:embeddings
"""
fields: Optional[list[str]]
"""Fields to extract text from for embedding generation.
Defaults to the root ["$"], which embeds the json object as a whole.
"""
class StoreConfig(TypedDict, total=False):
embed: Optional[IndexConfig]
"""Configuration for vector embeddings in store."""
class Config(TypedDict, total=False):
python_version: str python_version: str
node_version: Optional[str] node_version: Optional[str]
pip_config_file: Optional[str] pip_config_file: Optional[str]
@@ -55,29 +15,6 @@ class Config(TypedDict, total=False):
dependencies: list[str] dependencies: list[str]
graphs: dict[str, str] graphs: dict[str, str]
env: Union[dict[str, str], str] env: Union[dict[str, str], str]
store: Optional[StoreConfig]
def _parse_version(version_str: str) -> tuple[int, int]:
"""Parse a version string into a tuple of (major, minor)."""
try:
major, minor = map(int, version_str.split("."))
return (major, minor)
except ValueError:
raise click.UsageError(f"Invalid version format: {version_str}") from None
def _parse_node_version(version_str: str) -> int:
"""Parse a Node.js version string into a major version number."""
try:
if "." in version_str:
raise ValueError("Node.js version must be major version only")
return int(version_str)
except ValueError:
raise click.UsageError(
f"Invalid Node.js version format: {version_str}. "
"Use major version only (e.g., '20')."
) from None
def validate_config(config: Config) -> Config: def validate_config(config: Config) -> Config:
@@ -87,7 +24,6 @@ def validate_config(config: Config) -> Config:
"dockerfile_lines": config.get("dockerfile_lines", []), "dockerfile_lines": config.get("dockerfile_lines", []),
"graphs": config.get("graphs", {}), "graphs": config.get("graphs", {}),
"env": config.get("env", {}), "env": config.get("env", {}),
"store": config.get("store"),
} }
if config.get("node_version") if config.get("node_version")
else { else {
@@ -97,39 +33,25 @@ def validate_config(config: Config) -> Config:
"dependencies": config.get("dependencies", []), "dependencies": config.get("dependencies", []),
"graphs": config.get("graphs", {}), "graphs": config.get("graphs", {}),
"env": config.get("env", {}), "env": config.get("env", {}),
"store": config.get("store"),
} }
) )
if config.get("node_version"): if config.get("node_version"):
node_version = config["node_version"] if config["node_version"] not in ("20",):
try: raise click.UsageError(
major = _parse_node_version(node_version) f"Unsupported Node.js version: {config['node_version']}. "
min_major = _parse_node_version(MIN_NODE_VERSION) "Currently only `node_version: \"20\"` is supported."
if major < min_major: )
raise click.UsageError(
f"Node.js version {node_version} is not supported. "
f"Minimum required version is {MIN_NODE_VERSION}."
)
except ValueError as e:
raise click.UsageError(str(e)) from None
if config.get("python_version"): if config.get("python_version"):
pyversion = config["python_version"] if config["python_version"] not in (
if not pyversion.count(".") == 1 or not all( "3.11",
part.isdigit() for part in pyversion.split(".") "3.12",
): ):
raise click.UsageError( raise click.UsageError(
f"Invalid Python version format: {pyversion}. " f"Unsupported Python version: {config['python_version']}. "
"Use 'major.minor' format (e.g., '3.11'). " "Supported versions are 3.11 and 3.12."
"Patch version cannot be specified."
) )
if _parse_version(pyversion) < _parse_version(MIN_PYTHON_VERSION):
raise click.UsageError(
f"Python version {pyversion} is not supported. "
f"Minimum required version is {MIN_PYTHON_VERSION}."
)
if not config["dependencies"]: if not config["dependencies"]:
raise click.UsageError( raise click.UsageError(
"No dependencies found in config. " "No dependencies found in config. "
@@ -144,48 +66,6 @@ def validate_config(config: Config) -> Config:
return config return config
def validate_config_file(config_path: pathlib.Path) -> Config:
with open(config_path) as f:
config = json.load(f)
validated = validate_config(config)
# Enforce the package.json doesn't enforce an
# incompatible Node.js version
if validated.get("node_version"):
package_json_path = config_path.parent / "package.json"
if package_json_path.is_file():
try:
with open(package_json_path) as f:
package_json = json.load(f)
if "engines" in package_json:
engines = package_json["engines"]
if any(engine != "node" for engine in engines.keys()):
raise click.UsageError(
"Only 'node' engine is supported in package.json engines."
f" Got engines: {list(engines.keys())}"
)
if engines:
node_version = engines["node"]
try:
major = _parse_node_version(node_version)
min_major = _parse_node_version(MIN_NODE_VERSION)
if major < min_major:
raise click.UsageError(
f"Node.js version in package.json engines must be >= {MIN_NODE_VERSION} "
f"(major version only), got '{node_version}'. Minor/patch versions "
"(like '20.x.y') are not supported to prevent deployment issues "
"when new Node.js versions are released."
)
except ValueError as e:
raise click.UsageError(str(e)) from None
except json.JSONDecodeError:
raise click.UsageError(
"Invalid package.json found in langgraph "
f"config directory {package_json_path}: file is not valid JSON"
) from None
return validated
class LocalDeps(NamedTuple): class LocalDeps(NamedTuple):
pip_reqs: list[tuple[pathlib.Path, str]] pip_reqs: list[tuple[pathlib.Path, str]]
real_pkgs: dict[pathlib.Path, str] real_pkgs: dict[pathlib.Path, str]
@@ -392,14 +272,7 @@ RUN set -ex && \\
], ],
) )
) )
store_config = config.get("store")
env_additional_config = (
""
if not store_config
else f"""
ENV LANGGRAPH_STORE='{json.dumps(store_config)}'
"""
)
return f"""FROM {base_image}:{config['python_version']} return f"""FROM {base_image}:{config['python_version']}
{os.linesep.join(config["dockerfile_lines"])} {os.linesep.join(config["dockerfile_lines"])}
@@ -407,7 +280,7 @@ ENV LANGGRAPH_STORE='{json.dumps(store_config)}'
{installs} {installs}
RUN {pip_install} -e /deps/* RUN {pip_install} -e /deps/*
{env_additional_config}
ENV LANGSERVE_GRAPHS='{json.dumps(config["graphs"])}' ENV LANGSERVE_GRAPHS='{json.dumps(config["graphs"])}'
{f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else ""}""" {f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else ""}"""
@@ -437,14 +310,7 @@ def node_config_to_docker(config_path: pathlib.Path, config: Config, base_image:
install_cmd = "npm ci" install_cmd = "npm ci"
else: else:
install_cmd = "npm i" install_cmd = "npm i"
store_config = config.get("store")
env_additional_config = (
""
if not store_config
else f"""
ENV LANGGRAPH_STORE='{json.dumps(store_config)}'
"""
)
return f"""FROM {base_image}:{config['node_version']} return f"""FROM {base_image}:{config['node_version']}
{os.linesep.join(config["dockerfile_lines"])} {os.linesep.join(config["dockerfile_lines"])}
@@ -452,7 +318,7 @@ ENV LANGGRAPH_STORE='{json.dumps(store_config)}'
ADD . {faux_path} ADD . {faux_path}
RUN cd {faux_path} && {install_cmd} RUN cd {faux_path} && {install_cmd}
{env_additional_config}
ENV LANGSERVE_GRAPHS='{json.dumps(config["graphs"])}' ENV LANGSERVE_GRAPHS='{json.dumps(config["graphs"])}'
WORKDIR {faux_path} WORKDIR {faux_path}
+484 -528
View File
File diff suppressed because it is too large Load Diff
+5 -6
View File
@@ -1,12 +1,12 @@
[tool.poetry] [tool.poetry]
name = "langgraph-cli" name = "langgraph-cli"
version = "0.1.61" version = "0.1.55"
description = "CLI for interacting with LangGraph API" description = "CLI for interacting with LangGraph API"
authors = [] authors = []
license = "MIT" license = "MIT"
readme = "README.md" readme = "README.md"
repository = "https://www.github.com/langchain-ai/langgraph" repository = "https://www.github.com/langchain-ai/langgraph"
packages = [{ include = "langgraph_cli" }] packages = [{include = "langgraph_cli"}]
[tool.poetry.scripts] [tool.poetry.scripts]
langgraph = "langgraph_cli.cli:cli" langgraph = "langgraph_cli.cli:cli"
@@ -14,8 +14,7 @@ langgraph = "langgraph_cli.cli:cli"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.9.0,<4.0" python = "^3.9.0,<4.0"
click = "^8.1.7" click = "^8.1.7"
langgraph-api = { version = ">=0.0.6,<0.1.0", optional = true, python = ">=3.11,<4.0" } langgraph-api-inmem = { version = ">=0.0.5,<0.1.0", optional = true, python = ">=3.11" }
python-dotenv = { version = ">=0.8.0", optional = true }
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
ruff = "^0.6.2" ruff = "^0.6.2"
@@ -27,7 +26,7 @@ pytest-watch = "^4.2.0"
mypy = "^1.10.0" mypy = "^1.10.0"
[tool.poetry.extras] [tool.poetry.extras]
inmem = ["langgraph-api", "python-dotenv"] inmem = ["langgraph-api-inmem"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
# --strict-markers will raise errors on unknown marks. # --strict-markers will raise errors on unknown marks.
@@ -57,4 +56,4 @@ lint.select = [
# isort # isort
"I", "I",
] ]
lint.ignore = ["E501", "B008"] lint.ignore = [ "E501", "B008" ]
+1 -93
View File
@@ -1,17 +1,10 @@
import json
import os import os
import pathlib import pathlib
import tempfile
import click import click
import pytest import pytest
from langgraph_cli.config import ( from langgraph_cli.config import config_to_compose, config_to_docker, validate_config
config_to_compose,
config_to_docker,
validate_config,
validate_config_file,
)
from langgraph_cli.util import clean_empty_lines from langgraph_cli.util import clean_empty_lines
PATH_TO_CONFIG = pathlib.Path(__file__).parent / "test_config.json" PATH_TO_CONFIG = pathlib.Path(__file__).parent / "test_config.json"
@@ -30,7 +23,6 @@ def test_validate_config():
"pip_config_file": None, "pip_config_file": None,
"dockerfile_lines": [], "dockerfile_lines": [],
"env": {}, "env": {},
"store": None,
**expected_config, **expected_config,
} }
actual_config = validate_config(expected_config) actual_config = validate_config(expected_config)
@@ -47,13 +39,9 @@ def test_validate_config():
"agent": "./agent.py:graph", "agent": "./agent.py:graph",
}, },
"env": env, "env": env,
"store": None,
} }
actual_config = validate_config(expected_config) actual_config = validate_config(expected_config)
assert actual_config == expected_config assert actual_config == expected_config
expected_config["python_version"] = "3.13"
actual_config = validate_config(expected_config)
assert actual_config == expected_config
# check wrong python version raises # check wrong python version raises
with pytest.raises(click.UsageError): with pytest.raises(click.UsageError):
@@ -73,86 +61,6 @@ def test_validate_config():
with pytest.raises(click.UsageError): with pytest.raises(click.UsageError):
validate_config({"python_version": "3.9", "dependencies": ["."]}) validate_config({"python_version": "3.9", "dependencies": ["."]})
with pytest.raises(click.UsageError) as exc_info:
validate_config({"python_version": "3.11.0"})
assert "Invalid Python version format" in str(exc_info.value)
with pytest.raises(click.UsageError) as exc_info:
validate_config({"python_version": "3"})
assert "Invalid Python version format" in str(exc_info.value)
with pytest.raises(click.UsageError) as exc_info:
validate_config({"python_version": "abc.def"})
assert "Invalid Python version format" in str(exc_info.value)
with pytest.raises(click.UsageError) as exc_info:
validate_config({"python_version": "3.10"})
assert "Minimum required version" in str(exc_info.value)
def test_validate_config_file():
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
config_path = tmpdir_path / "langgraph.json"
node_config = {"node_version": "20", "graphs": {"agent": "./agent.js:graph"}}
with open(config_path, "w") as f:
json.dump(node_config, f)
validate_config_file(config_path)
package_json = {"name": "test", "engines": {"node": "20"}}
with open(tmpdir_path / "package.json", "w") as f:
json.dump(package_json, f)
validate_config_file(config_path)
package_json["engines"]["node"] = "20.18"
with open(tmpdir_path / "package.json", "w") as f:
json.dump(package_json, f)
with pytest.raises(click.UsageError, match="Use major version only"):
validate_config_file(config_path)
package_json["engines"] = {"node": "18"}
with open(tmpdir_path / "package.json", "w") as f:
json.dump(package_json, f)
with pytest.raises(click.UsageError, match="must be >= 20"):
validate_config_file(config_path)
package_json["engines"] = {"node": "20", "deno": "1.0"}
with open(tmpdir_path / "package.json", "w") as f:
json.dump(package_json, f)
with pytest.raises(click.UsageError, match="Only 'node' engine is supported"):
validate_config_file(config_path)
with open(tmpdir_path / "package.json", "w") as f:
f.write("{invalid json")
with pytest.raises(click.UsageError, match="Invalid package.json"):
validate_config_file(config_path)
python_config = {
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
}
with open(config_path, "w") as f:
json.dump(python_config, f)
validate_config_file(config_path)
for package_content in [
{"name": "test"},
{"engines": {"node": "18"}},
{"engines": {"node": "20", "deno": "1.0"}},
"{invalid json",
]:
with open(tmpdir_path / "package.json", "w") as f:
if isinstance(package_content, dict):
json.dump(package_content, f)
else:
f.write(package_content)
validate_config_file(config_path)
# config_to_docker # config_to_docker
def test_config_to_docker_simple(): def test_config_to_docker_simple():
+1 -6
View File
@@ -48,13 +48,8 @@ test:
make stop-postgres; \ make stop-postgres; \
exit $$EXIT_CODE exit $$EXIT_CODE
WORKERS ?= auto
XDIST_ARGS := $(if $(WORKERS),-n $(WORKERS) --dist worksteal,)
MAXFAIL ?=
MAXFAIL_ARGS := $(if $(MAXFAIL),--maxfail $(MAXFAIL),)
test_watch: test_watch:
make start-postgres && poetry run ptw . -- --ff -vv -x $(XDIST_ARGS) $(MAXFAIL_ARGS) --snapshot-update --tb short $(TEST); \ make start-postgres && poetry run ptw . -- --ff -vv -x -n auto --dist worksteal --snapshot-update --tb short $(TEST); \
EXIT_CODE=$$?; \ EXIT_CODE=$$?; \
make stop-postgres; \ make stop-postgres; \
exit $$EXIT_CODE exit $$EXIT_CODE
+1 -1
View File
@@ -238,7 +238,7 @@ final_state["messages"][-1].content
* [How-to Guides](https://langchain-ai.github.io/langgraph/how-tos/): Accomplish specific things within LangGraph, from streaming, to adding memory & persistence, to common design patterns (branching, subgraphs, etc.), these are the place to go if you want to copy and run a specific code snippet. * [How-to Guides](https://langchain-ai.github.io/langgraph/how-tos/): Accomplish specific things within LangGraph, from streaming, to adding memory & persistence, to common design patterns (branching, subgraphs, etc.), these are the place to go if you want to copy and run a specific code snippet.
* [Conceptual Guides](https://langchain-ai.github.io/langgraph/concepts/high_level/): In-depth explanations of the key concepts and principles behind LangGraph, such as nodes, edges, state and more. * [Conceptual Guides](https://langchain-ai.github.io/langgraph/concepts/high_level/): In-depth explanations of the key concepts and principles behind LangGraph, such as nodes, edges, state and more.
* [API Reference](https://langchain-ai.github.io/langgraph/reference/graphs/): Review important classes and methods, simple examples of how to use the graph and checkpointing APIs, higher-level prebuilt components and more. * [API Reference](https://langchain-ai.github.io/langgraph/reference/graphs/): Review important classes and methods, simple examples of how to use the graph and checkpointing APIs, higher-level prebuilt components and more.
* [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/#langgraph-platform): LangGraph Platform is a commercial solution for deploying agentic applications in production, built on the open-source LangGraph framework. * [Cloud (beta)](https://langchain-ai.github.io/langgraph/cloud/): With one click, deploy LangGraph applications to LangGraph Cloud.
## Contributing ## Contributing
+3 -14
View File
@@ -2,7 +2,7 @@ from enum import Enum
from typing import Any, Sequence from typing import Any, Sequence
from langgraph.checkpoint.base import EmptyChannelError # noqa: F401 from langgraph.checkpoint.base import EmptyChannelError # noqa: F401
from langgraph.types import Command, Interrupt from langgraph.types import Interrupt
# EmptyChannelError re-exported for backwards compatibility # EmptyChannelError re-exported for backwards compatibility
@@ -58,11 +58,7 @@ class InvalidUpdateError(Exception):
pass pass
class GraphBubbleUp(Exception): class GraphInterrupt(Exception):
pass
class GraphInterrupt(GraphBubbleUp):
"""Raised when a subgraph is interrupted, suppressed by the root graph. """Raised when a subgraph is interrupted, suppressed by the root graph.
Never raised directly, or surfaced to the user.""" Never raised directly, or surfaced to the user."""
@@ -77,20 +73,13 @@ class NodeInterrupt(GraphInterrupt):
super().__init__([Interrupt(value=value)]) super().__init__([Interrupt(value=value)])
class GraphDelegate(GraphBubbleUp): class GraphDelegate(Exception):
"""Raised when a graph is delegated (for distributed mode).""" """Raised when a graph is delegated (for distributed mode)."""
def __init__(self, *args: dict[str, Any]) -> None: def __init__(self, *args: dict[str, Any]) -> None:
super().__init__(*args) super().__init__(*args)
class ParentCommand(GraphBubbleUp):
args: tuple[Command]
def __init__(self, command: Command) -> None:
super().__init__(command)
class EmptyInputError(Exception): class EmptyInputError(Exception):
"""Raised when graph receives an empty input.""" """Raised when graph receives an empty input."""
+4 -5
View File
@@ -374,11 +374,6 @@ class Graph:
if source not in self.nodes and source != START: if source not in self.nodes and source != START:
raise ValueError(f"Found edge starting at unknown node '{source}'") raise ValueError(f"Found edge starting at unknown node '{source}'")
if START not in all_sources:
raise ValueError(
"Graph must have an entrypoint: add at least one edge from START to another node"
)
# assemble targets # assemble targets
all_targets = {end for _, end in self._all_edges} all_targets = {end for _, end in self._all_edges}
for start, branches in self.branches.items(): for start, branches in self.branches.items():
@@ -400,6 +395,10 @@ class Graph:
for name, spec in self.nodes.items(): for name, spec in self.nodes.items():
if spec.ends: if spec.ends:
all_targets.update(spec.ends) all_targets.update(spec.ends)
# validate targets
for node in self.nodes:
if node not in all_targets:
raise ValueError(f"Node `{node}` is not reachable")
for target in all_targets: for target in all_targets:
if target not in self.nodes and target != END: if target not in self.nodes and target != END:
raise ValueError(f"Found edge ending at unknown node `{target}`") raise ValueError(f"Found edge ending at unknown node `{target}`")
+2 -18
View File
@@ -37,12 +37,7 @@ from langgraph.channels.ephemeral_value import EphemeralValue
from langgraph.channels.last_value import LastValue from langgraph.channels.last_value import LastValue
from langgraph.channels.named_barrier_value import NamedBarrierValue from langgraph.channels.named_barrier_value import NamedBarrierValue
from langgraph.constants import EMPTY_SEQ, NS_END, NS_SEP, SELF, TAG_HIDDEN from langgraph.constants import EMPTY_SEQ, NS_END, NS_SEP, SELF, TAG_HIDDEN
from langgraph.errors import ( from langgraph.errors import ErrorCode, InvalidUpdateError, create_error_message
ErrorCode,
InvalidUpdateError,
ParentCommand,
create_error_message,
)
from langgraph.graph.graph import END, START, Branch, CompiledGraph, Graph, Send from langgraph.graph.graph import END, START, Branch, CompiledGraph, Graph, Send
from langgraph.managed.base import ( from langgraph.managed.base import (
ChannelKeyPlaceholder, ChannelKeyPlaceholder,
@@ -628,27 +623,20 @@ class CompiledStateGraph(CompiledGraph):
def _get_root(input: Any) -> Any: def _get_root(input: Any) -> Any:
if isinstance(input, Command): if isinstance(input, Command):
if input.graph == Command.PARENT:
return SKIP_WRITE
return input.update return input.update
else: else:
return input return input
# to avoid name collision below
node_key = key
def _get_state_key(input: Union[None, dict, Any], *, key: str) -> Any: def _get_state_key(input: Union[None, dict, Any], *, key: str) -> Any:
if input is None: if input is None:
return SKIP_WRITE return SKIP_WRITE
elif isinstance(input, dict): elif isinstance(input, dict):
if all(k not in output_keys for k in input): if all(k not in output_keys for k in input):
raise InvalidUpdateError( raise InvalidUpdateError(
f"Expected node {node_key} to update at least one of {output_keys}, got {input}" f"Expected node {key} to update at least one of {output_keys}, got {input}"
) )
return input.get(key, SKIP_WRITE) return input.get(key, SKIP_WRITE)
elif isinstance(input, Command): elif isinstance(input, Command):
if input.graph == Command.PARENT:
return SKIP_WRITE
return _get_state_key(input.update, key=key) return _get_state_key(input.update, key=key)
elif get_type_hints(type(input)): elif get_type_hints(type(input)):
value = getattr(input, key, SKIP_WRITE) value = getattr(input, key, SKIP_WRITE)
@@ -831,8 +819,6 @@ def _control_branch(value: Any) -> Sequence[Union[str, Send]]:
return [value] return [value]
if not isinstance(value, GraphCommand): if not isinstance(value, GraphCommand):
return EMPTY_SEQ return EMPTY_SEQ
if value.graph == Command.PARENT:
raise ParentCommand(value)
rtn: list[Union[str, Send]] = [] rtn: list[Union[str, Send]] = []
if isinstance(value.goto, str): if isinstance(value.goto, str):
rtn.append(value.goto) rtn.append(value.goto)
@@ -850,8 +836,6 @@ async def _acontrol_branch(value: Any) -> Sequence[Union[str, Send]]:
return [value] return [value]
if not isinstance(value, GraphCommand): if not isinstance(value, GraphCommand):
return EMPTY_SEQ return EMPTY_SEQ
if value.graph == Command.PARENT:
raise ParentCommand(value)
rtn: list[Union[str, Send]] = [] rtn: list[Union[str, Send]] = []
if isinstance(value.goto, str): if isinstance(value.goto, str):
rtn.append(value.goto) rtn.append(value.goto)
@@ -212,7 +212,6 @@ def create_react_agent(
Args: Args:
model: The `LangChain` chat model that supports tool calling. model: The `LangChain` chat model that supports tool calling.
tools: A list of tools, a ToolExecutor, or a ToolNode instance. tools: A list of tools, a ToolExecutor, or a ToolNode instance.
If an empty list is provided, the agent will consist of a single LLM node without tool calling.
state_schema: An optional state schema that defines graph state. state_schema: An optional state schema that defines graph state.
Must have `messages` and `is_last_step` keys. Must have `messages` and `is_last_step` keys.
Defaults to `AgentState` that defines those two keys. Defaults to `AgentState` that defines those two keys.
@@ -541,11 +540,20 @@ def create_react_agent(
# get the tool functions wrapped in a tool class from the ToolNode # get the tool functions wrapped in a tool class from the ToolNode
tool_classes = list(tool_node.tools_by_name.values()) tool_classes = list(tool_node.tools_by_name.values())
tool_calling_enabled = len(tool_classes) > 0 if _should_bind_tools(model, tool_classes):
if _should_bind_tools(model, tool_classes) and tool_calling_enabled:
model = cast(BaseChatModel, model).bind_tools(tool_classes) model = cast(BaseChatModel, model).bind_tools(tool_classes)
# Define the function that determines whether to continue or not
def should_continue(state: AgentState) -> Literal["tools", "__end__"]:
messages = state["messages"]
last_message = messages[-1]
# If there is no function call, then we finish
if not isinstance(last_message, AIMessage) or not last_message.tool_calls:
return "__end__"
# Otherwise if there is, we continue
else:
return "tools"
# we're passing store here for validation # we're passing store here for validation
preprocessor = _get_model_preprocessing_runnable( preprocessor = _get_model_preprocessing_runnable(
state_modifier, messages_modifier, store state_modifier, messages_modifier, store
@@ -627,30 +635,6 @@ def create_react_agent(
# We return a list, because this will get added to the existing list # We return a list, because this will get added to the existing list
return {"messages": [response]} return {"messages": [response]}
if not tool_calling_enabled:
# Define a new graph
workflow = StateGraph(state_schema or AgentState)
workflow.add_node("agent", RunnableCallable(call_model, acall_model))
workflow.set_entry_point("agent")
return workflow.compile(
checkpointer=checkpointer,
store=store,
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
debug=debug,
)
# Define the function that determines whether to continue or not
def should_continue(state: AgentState) -> Literal["tools", "__end__"]:
messages = state["messages"]
last_message = messages[-1]
# If there is no function call, then we finish
if not isinstance(last_message, AIMessage) or not last_message.tool_calls:
return "__end__"
# Otherwise if there is, we continue
else:
return "tools"
# Define a new graph # Define a new graph
workflow = StateGraph(state_schema or AgentState) workflow = StateGraph(state_schema or AgentState)
@@ -37,7 +37,7 @@ from langchain_core.tools import tool as create_tool
from langchain_core.tools.base import get_all_basemodel_annotations from langchain_core.tools.base import get_all_basemodel_annotations
from typing_extensions import Annotated, get_args, get_origin from typing_extensions import Annotated, get_args, get_origin
from langgraph.errors import GraphBubbleUp from langgraph.errors import GraphInterrupt
from langgraph.store.base import BaseStore from langgraph.store.base import BaseStore
from langgraph.utils.runnable import RunnableCallable from langgraph.utils.runnable import RunnableCallable
@@ -275,7 +275,7 @@ class ToolNode(RunnableCallable):
# (2) a NodeInterrupt is raised inside a graph node for a graph called as a tool # (2) a NodeInterrupt is raised inside a graph node for a graph called as a tool
# (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph called as a tool # (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph called as a tool
# (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture) # (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture)
except GraphBubbleUp as e: except GraphInterrupt as e:
raise e raise e
except Exception as e: except Exception as e:
if isinstance(self.handle_tool_errors, tuple): if isinstance(self.handle_tool_errors, tuple):
@@ -316,7 +316,7 @@ class ToolNode(RunnableCallable):
# (2) a NodeInterrupt is raised inside a graph node for a graph called as a tool # (2) a NodeInterrupt is raised inside a graph node for a graph called as a tool
# (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph called as a tool # (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph called as a tool
# (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture) # (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture)
except GraphBubbleUp as e: except GraphInterrupt as e:
raise e raise e
except Exception as e: except Exception as e:
if isinstance(self.handle_tool_errors, tuple): if isinstance(self.handle_tool_errors, tuple):
+4 -13
View File
@@ -107,7 +107,6 @@ from langgraph.types import (
Checkpointer, Checkpointer,
LoopProtocol, LoopProtocol,
StateSnapshot, StateSnapshot,
StreamChunk,
StreamMode, StreamMode,
) )
from langgraph.utils.config import ( from langgraph.utils.config import (
@@ -1753,10 +1752,6 @@ class Pregel(PregelProtocol):
stream = AsyncQueue() stream = AsyncQueue()
aioloop = asyncio.get_running_loop() aioloop = asyncio.get_running_loop()
stream_put = cast(
Callable[[StreamChunk], None],
partial(aioloop.call_soon_threadsafe, stream.put_nowait),
)
def output() -> Iterator: def output() -> Iterator:
while True: while True:
@@ -1811,14 +1806,12 @@ class Pregel(PregelProtocol):
# set up messages stream mode # set up messages stream mode
if "messages" in stream_modes: if "messages" in stream_modes:
run_manager.inheritable_handlers.append( run_manager.inheritable_handlers.append(
StreamMessagesHandler(stream_put) StreamMessagesHandler(stream.put_nowait)
) )
# set up custom stream mode # set up custom stream mode
if "custom" in stream_modes: if "custom" in stream_modes:
config[CONF][CONFIG_KEY_STREAM_WRITER] = ( config[CONF][CONFIG_KEY_STREAM_WRITER] = lambda c: stream.put_nowait(
lambda c: aioloop.call_soon_threadsafe( ((), "custom", c)
stream.put_nowait, ((), "custom", c)
)
) )
async with AsyncPregelLoop( async with AsyncPregelLoop(
input, input,
@@ -1845,9 +1838,7 @@ class Pregel(PregelProtocol):
) )
# enable subgraph streaming # enable subgraph streaming
if subgraphs: if subgraphs:
loop.config[CONF][CONFIG_KEY_STREAM] = StreamProtocol( loop.config[CONF][CONFIG_KEY_STREAM] = loop.stream
stream_put, stream_modes
)
# enable concurrent streaming # enable concurrent streaming
if subgraphs or "messages" in stream_modes or "custom" in stream_modes: if subgraphs or "messages" in stream_modes or "custom" in stream_modes:
+2 -12
View File
@@ -1,4 +1,3 @@
import sys
from collections import defaultdict, deque from collections import defaultdict, deque
from functools import partial from functools import partial
from hashlib import sha1 from hashlib import sha1
@@ -67,7 +66,6 @@ from langgraph.types import All, LoopProtocol, PregelExecutableTask, PregelTask
from langgraph.utils.config import merge_configs, patch_config from langgraph.utils.config import merge_configs, patch_config
GetNextVersion = Callable[[Optional[V], BaseChannel], V] GetNextVersion = Callable[[Optional[V], BaseChannel], V]
SUPPORTS_EXC_NOTES = sys.version_info >= (3, 11)
class WritesProtocol(Protocol): class WritesProtocol(Protocol):
@@ -595,7 +593,7 @@ def prepare_single_task(
for tid, c, v in pending_writes for tid, c, v in pending_writes
if tid in (NULL_TASK_ID, task_id) and c == RESUME if tid in (NULL_TASK_ID, task_id) and c == RESUME
), ),
configurable.get(CONFIG_KEY_RESUME_VALUE, MISSING), MISSING,
), ),
}, },
), ),
@@ -604,7 +602,6 @@ def prepare_single_task(
None, None,
task_id, task_id,
task_path, task_path,
writers=proc.flat_writers,
) )
else: else:
@@ -636,12 +633,6 @@ def prepare_single_task(
) )
except StopIteration: except StopIteration:
return return
except Exception as exc:
if SUPPORTS_EXC_NOTES:
exc.add_note(
f"Before task with name '{name}' and path '{task_path[:3]}'"
)
raise
# create task id # create task id
checkpoint_ns = f"{parent_ns}{NS_SEP}{name}" if parent_ns else name checkpoint_ns = f"{parent_ns}{NS_SEP}{name}" if parent_ns else name
@@ -720,7 +711,7 @@ def prepare_single_task(
if tid in (NULL_TASK_ID, task_id) if tid in (NULL_TASK_ID, task_id)
and c == RESUME and c == RESUME
), ),
configurable.get(CONFIG_KEY_RESUME_VALUE, MISSING), MISSING,
), ),
}, },
), ),
@@ -729,7 +720,6 @@ def prepare_single_task(
None, None,
task_id, task_id,
task_path, task_path,
writers=proc.flat_writers,
) )
else: else:
return PregelTask(task_id, name, task_path) return PregelTask(task_id, name, task_path)
+3 -3
View File
@@ -20,7 +20,7 @@ from langchain_core.runnables import RunnableConfig
from langchain_core.runnables.config import get_executor_for_config from langchain_core.runnables.config import get_executor_for_config
from typing_extensions import ParamSpec from typing_extensions import ParamSpec
from langgraph.errors import GraphBubbleUp from langgraph.errors import GraphInterrupt
P = ParamSpec("P") P = ParamSpec("P")
T = TypeVar("T") T = TypeVar("T")
@@ -68,7 +68,7 @@ class BackgroundExecutor(ContextManager):
def done(self, task: concurrent.futures.Future) -> None: def done(self, task: concurrent.futures.Future) -> None:
try: try:
task.result() task.result()
except GraphBubbleUp: except GraphInterrupt:
# This exception is an interruption signal, not an error # This exception is an interruption signal, not an error
# so we don't want to re-raise it on exit # so we don't want to re-raise it on exit
self.tasks.pop(task) self.tasks.pop(task)
@@ -155,7 +155,7 @@ class AsyncBackgroundExecutor(AsyncContextManager):
if exc := task.exception(): if exc := task.exception():
# This exception is an interruption signal, not an error # This exception is an interruption signal, not an error
# so we don't want to re-raise it on exit # so we don't want to re-raise it on exit
if isinstance(exc, GraphBubbleUp): if isinstance(exc, GraphInterrupt):
self.tasks.pop(task) self.tasks.pop(task)
else: else:
self.tasks.pop(task) self.tasks.pop(task)
-3
View File
@@ -15,7 +15,6 @@ from langgraph.constants import (
TAG_HIDDEN, TAG_HIDDEN,
TASKS, TASKS,
) )
from langgraph.errors import InvalidUpdateError
from langgraph.pregel.log import logger from langgraph.pregel.log import logger
from langgraph.types import Command, PregelExecutableTask, Send from langgraph.types import Command, PregelExecutableTask, Send
@@ -69,8 +68,6 @@ def map_command(
cmd: Command, cmd: Command,
) -> Iterator[tuple[str, str, Any]]: ) -> Iterator[tuple[str, str, Any]]:
"""Map input chunk to a sequence of pending writes in the form (channel, value).""" """Map input chunk to a sequence of pending writes in the form (channel, value)."""
if cmd.graph == Command.PARENT:
raise InvalidUpdateError("There is not parent graph")
if cmd.send: if cmd.send:
if isinstance(cmd.send, (tuple, list)): if isinstance(cmd.send, (tuple, list)):
sends = cmd.send sends = cmd.send
+1 -1
View File
@@ -110,13 +110,13 @@ from langgraph.types import (
Command, Command,
LoopProtocol, LoopProtocol,
PregelExecutableTask, PregelExecutableTask,
StreamChunk,
StreamProtocol, StreamProtocol,
) )
from langgraph.utils.config import patch_configurable from langgraph.utils.config import patch_configurable
V = TypeVar("V") V = TypeVar("V")
P = ParamSpec("P") P = ParamSpec("P")
StreamChunk = tuple[tuple[str, ...], str, Any]
INPUT_DONE = object() INPUT_DONE = object()
INPUT_RESUMING = object() INPUT_RESUMING = object()
+1 -1
View File
@@ -18,7 +18,7 @@ from langchain_core.outputs import ChatGenerationChunk, LLMResult
from langchain_core.tracers._streaming import T, _StreamingCallbackHandler from langchain_core.tracers._streaming import T, _StreamingCallbackHandler
from langgraph.constants import NS_SEP, TAG_HIDDEN, TAG_NOSTREAM from langgraph.constants import NS_SEP, TAG_HIDDEN, TAG_NOSTREAM
from langgraph.types import StreamChunk from langgraph.pregel.loop import StreamChunk
Meta = tuple[tuple[str, ...], dict[str, Any]] Meta = tuple[tuple[str, ...], dict[str, Any]]
+1 -15
View File
@@ -1,4 +1,3 @@
from dataclasses import asdict
from typing import ( from typing import (
Any, Any,
AsyncIterator, AsyncIterator,
@@ -28,7 +27,6 @@ from langgraph_sdk.client import (
get_sync_client, get_sync_client,
) )
from langgraph_sdk.schema import Checkpoint, ThreadState from langgraph_sdk.schema import Checkpoint, ThreadState
from langgraph_sdk.schema import Command as CommandSDK
from langgraph_sdk.schema import StreamMode as StreamModeSDK from langgraph_sdk.schema import StreamMode as StreamModeSDK
from typing_extensions import Self from typing_extensions import Self
@@ -43,7 +41,7 @@ from langgraph.constants import (
from langgraph.errors import GraphInterrupt from langgraph.errors import GraphInterrupt
from langgraph.pregel.protocol import PregelProtocol from langgraph.pregel.protocol import PregelProtocol
from langgraph.pregel.types import All, PregelTask, StateSnapshot, StreamMode from langgraph.pregel.types import All, PregelTask, StateSnapshot, StreamMode
from langgraph.types import Command, Interrupt, StreamProtocol from langgraph.types import Interrupt, StreamProtocol
from langgraph.utils.config import merge_configs from langgraph.utils.config import merge_configs
@@ -599,17 +597,11 @@ class RemoteGraph(PregelProtocol):
stream_modes, requested, req_single, stream = self._get_stream_modes( stream_modes, requested, req_single, stream = self._get_stream_modes(
stream_mode, config stream_mode, config
) )
if isinstance(input, Command):
command: Optional[CommandSDK] = cast(CommandSDK, asdict(input))
input = None
else:
command = None
for chunk in sync_client.runs.stream( for chunk in sync_client.runs.stream(
thread_id=sanitized_config["configurable"].get("thread_id"), thread_id=sanitized_config["configurable"].get("thread_id"),
assistant_id=self.name, assistant_id=self.name,
input=input, input=input,
command=command,
config=sanitized_config, config=sanitized_config,
stream_mode=stream_modes, stream_mode=stream_modes,
interrupt_before=interrupt_before, interrupt_before=interrupt_before,
@@ -688,17 +680,11 @@ class RemoteGraph(PregelProtocol):
stream_modes, requested, req_single, stream = self._get_stream_modes( stream_modes, requested, req_single, stream = self._get_stream_modes(
stream_mode, config stream_mode, config
) )
if isinstance(input, Command):
command: Optional[CommandSDK] = cast(CommandSDK, asdict(input))
input = None
else:
command = None
async for chunk in client.runs.stream( async for chunk in client.runs.stream(
thread_id=sanitized_config["configurable"].get("thread_id"), thread_id=sanitized_config["configurable"].get("thread_id"),
assistant_id=self.name, assistant_id=self.name,
input=input, input=input,
command=command,
config=sanitized_config, config=sanitized_config,
stream_mode=stream_modes, stream_mode=stream_modes,
interrupt_before=interrupt_before, interrupt_before=interrupt_before,
+4 -40
View File
@@ -1,9 +1,7 @@
import asyncio import asyncio
import logging import logging
import random import random
import sys
import time import time
from dataclasses import replace
from functools import partial from functools import partial
from typing import Any, Callable, Optional, Sequence from typing import Any, Callable, Optional, Sequence
@@ -12,14 +10,12 @@ from langgraph.constants import (
CONFIG_KEY_CHECKPOINT_NS, CONFIG_KEY_CHECKPOINT_NS,
CONFIG_KEY_RESUMING, CONFIG_KEY_RESUMING,
CONFIG_KEY_SEND, CONFIG_KEY_SEND,
NS_SEP,
) )
from langgraph.errors import _SEEN_CHECKPOINT_NS, GraphBubbleUp, ParentCommand from langgraph.errors import _SEEN_CHECKPOINT_NS, GraphInterrupt
from langgraph.types import Command, PregelExecutableTask, RetryPolicy from langgraph.types import PregelExecutableTask, RetryPolicy
from langgraph.utils.config import patch_configurable from langgraph.utils.config import patch_configurable
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SUPPORTS_EXC_NOTES = sys.version_info >= (3, 11)
def run_with_retry( def run_with_retry(
@@ -44,26 +40,10 @@ def run_with_retry(
task.proc.invoke(task.input, config) task.proc.invoke(task.input, config)
# if successful, end # if successful, end
break break
except ParentCommand as exc: except GraphInterrupt:
ns: str = config[CONF][CONFIG_KEY_CHECKPOINT_NS]
cmd = exc.args[0]
if cmd.graph == ns:
# this command is for the current graph, handle it
for w in task.writers:
w.invoke(cmd, config)
break
elif cmd.graph == Command.PARENT:
# this command is for the parent graph, assign it to the parent
parent_ns = NS_SEP.join(ns.split(NS_SEP)[:-1])
exc.args = (replace(cmd, graph=parent_ns),)
# bubble up
raise
except GraphBubbleUp:
# if interrupted, end # if interrupted, end
raise raise
except Exception as exc: except Exception as exc:
if SUPPORTS_EXC_NOTES:
exc.add_note(f"During task with name '{task.name}' and id '{task.id}'")
if retry_policy is None: if retry_policy is None:
raise raise
# increment attempts # increment attempts
@@ -138,26 +118,10 @@ async def arun_with_retry(
await task.proc.ainvoke(task.input, config) await task.proc.ainvoke(task.input, config)
# if successful, end # if successful, end
break break
except ParentCommand as exc: except GraphInterrupt:
ns: str = config[CONF][CONFIG_KEY_CHECKPOINT_NS]
cmd = exc.args[0]
if cmd.graph == ns:
# this command is for the current graph, handle it
for w in task.writers:
w.invoke(cmd, config)
break
elif cmd.graph == Command.PARENT:
# this command is for the parent graph, assign it to the parent
parent_ns = NS_SEP.join(ns.split(NS_SEP)[:-1])
exc.args = (replace(cmd, graph=parent_ns),)
# bubble up
raise
except GraphBubbleUp:
# if interrupted, end # if interrupted, end
raise raise
except Exception as exc: except Exception as exc:
if SUPPORTS_EXC_NOTES:
exc.add_note(f"During task with name '{task.name}' and id '{task.id}'")
if retry_policy is None: if retry_policy is None:
raise raise
# increment attempts # increment attempts
+3 -3
View File
@@ -23,7 +23,7 @@ from langgraph.constants import (
PUSH, PUSH,
TAG_HIDDEN, TAG_HIDDEN,
) )
from langgraph.errors import GraphBubbleUp, GraphInterrupt from langgraph.errors import GraphDelegate, GraphInterrupt
from langgraph.pregel.executor import Submit from langgraph.pregel.executor import Submit
from langgraph.pregel.retry import arun_with_retry, run_with_retry from langgraph.pregel.retry import arun_with_retry, run_with_retry
from langgraph.types import PregelExecutableTask, RetryPolicy from langgraph.types import PregelExecutableTask, RetryPolicy
@@ -298,7 +298,7 @@ class PregelRunner:
# save interrupt to checkpointer # save interrupt to checkpointer
if interrupts := [(INTERRUPT, i) for i in exception.args[0]]: if interrupts := [(INTERRUPT, i) for i in exception.args[0]]:
self.put_writes(task.id, interrupts) self.put_writes(task.id, interrupts)
elif isinstance(exception, GraphBubbleUp): elif isinstance(exception, GraphDelegate):
raise exception raise exception
else: else:
# save error to checkpointer # save error to checkpointer
@@ -324,7 +324,7 @@ def _should_stop_others(
if fut.cancelled(): if fut.cancelled():
return True return True
if exc := fut.exception(): if exc := fut.exception():
return not isinstance(exc, GraphBubbleUp) return not isinstance(exc, GraphInterrupt)
else: else:
return False return False
-5
View File
@@ -5,7 +5,6 @@ from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Any, Any,
Callable, Callable,
ClassVar,
Generic, Generic,
Hashable, Hashable,
Literal, Literal,
@@ -141,7 +140,6 @@ class PregelExecutableTask(NamedTuple):
id: str id: str
path: tuple[Union[str, int, tuple], ...] path: tuple[Union[str, int, tuple], ...]
scheduled: bool = False scheduled: bool = False
writers: Sequence[Runnable] = ()
class StateSnapshot(NamedTuple): class StateSnapshot(NamedTuple):
@@ -241,7 +239,6 @@ N = TypeVar("N", bound=Hashable)
class Command(Generic[N]): class Command(Generic[N]):
"""One or more commands to update the graph's state and send messages to nodes.""" """One or more commands to update the graph's state and send messages to nodes."""
graph: Optional[str] = None
update: Optional[dict[str, Any]] = None update: Optional[dict[str, Any]] = None
send: Union[Send, Sequence[Send]] = () send: Union[Send, Sequence[Send]] = ()
resume: Optional[Union[Any, dict[str, Any]]] = None resume: Optional[Union[Any, dict[str, Any]]] = None
@@ -255,8 +252,6 @@ class Command(Generic[N]):
) )
return f"Command({contents})" return f"Command({contents})"
PARENT: ClassVar[Literal["__parent__"]] = "__parent__"
StreamChunk = tuple[tuple[str, ...], str, Any] StreamChunk = tuple[tuple[str, ...], str, Any]
+2 -2
View File
@@ -1,7 +1,7 @@
import asyncio import asyncio
import sys import sys
from collections import ChainMap from collections import ChainMap
from typing import Any, Optional, Sequence, cast from typing import Any, Optional, Sequence
from langchain_core.callbacks import ( from langchain_core.callbacks import (
AsyncCallbackManager, AsyncCallbackManager,
@@ -281,7 +281,7 @@ def ensure_config(*configs: Optional[RunnableConfig]) -> RunnableConfig:
for k, v in config.items(): for k, v in config.items():
if v is not None and k in CONFIG_KEYS: if v is not None and k in CONFIG_KEYS:
if k == CONF: if k == CONF:
empty[k] = cast(dict, v).copy() empty[k] = v.copy() # type: ignore[literal-required]
else: else:
empty[k] = v # type: ignore[literal-required] empty[k] = v # type: ignore[literal-required]
for k, v in config.items(): for k, v in config.items():
+13 -13
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. # This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
[[package]] [[package]]
name = "aiosqlite" name = "aiosqlite"
@@ -1253,13 +1253,13 @@ test = ["jupyter-server (>=2.0.0)", "pytest (>=7.0)", "pytest-jupyter[server] (>
[[package]] [[package]]
name = "jupyterlab" name = "jupyterlab"
version = "4.2.5" version = "4.3.0"
description = "JupyterLab computational environment" description = "JupyterLab computational environment"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "jupyterlab-4.2.5-py3-none-any.whl", hash = "sha256:73b6e0775d41a9fee7ee756c80f58a6bed4040869ccc21411dc559818874d321"}, {file = "jupyterlab-4.3.0-py3-none-any.whl", hash = "sha256:f67e1095ad61ae04349024f0b40345062ab108a0c6998d9810fec6a3c1a70cd5"},
{file = "jupyterlab-4.2.5.tar.gz", hash = "sha256:ae7f3a1b8cb88b4f55009ce79fa7c06f99d70cd63601ee4aa91815d054f46f75"}, {file = "jupyterlab-4.3.0.tar.gz", hash = "sha256:7c6835cbf8df0af0ec8a39332e85ff11693fb9a468205343b4fc0bfbc74817e5"},
] ]
[package.dependencies] [package.dependencies]
@@ -1280,9 +1280,9 @@ tornado = ">=6.2.0"
traitlets = "*" traitlets = "*"
[package.extras] [package.extras]
dev = ["build", "bump2version", "coverage", "hatch", "pre-commit", "pytest-cov", "ruff (==0.3.5)"] dev = ["build", "bump2version", "coverage", "hatch", "pre-commit", "pytest-cov", "ruff (==0.6.9)"]
docs = ["jsx-lexer", "myst-parser", "pydata-sphinx-theme (>=0.13.0)", "pytest", "pytest-check-links", "pytest-jupyter", "sphinx (>=1.8,<7.3.0)", "sphinx-copybutton"] docs = ["jsx-lexer", "myst-parser", "pydata-sphinx-theme (>=0.13.0)", "pytest", "pytest-check-links", "pytest-jupyter", "sphinx (>=1.8,<8.1.0)", "sphinx-copybutton"]
docs-screenshots = ["altair (==5.3.0)", "ipython (==8.16.1)", "ipywidgets (==8.1.2)", "jupyterlab-geojson (==3.4.0)", "jupyterlab-language-pack-zh-cn (==4.1.post2)", "matplotlib (==3.8.3)", "nbconvert (>=7.0.0)", "pandas (==2.2.1)", "scipy (==1.12.0)", "vega-datasets (==0.9.0)"] docs-screenshots = ["altair (==5.4.1)", "ipython (==8.16.1)", "ipywidgets (==8.1.5)", "jupyterlab-geojson (==3.4.0)", "jupyterlab-language-pack-zh-cn (==4.2.post3)", "matplotlib (==3.9.2)", "nbconvert (>=7.0.0)", "pandas (==2.2.3)", "scipy (==1.14.1)", "vega-datasets (==0.9.0)"]
test = ["coverage", "pytest (>=7.0)", "pytest-check-links (>=0.7)", "pytest-console-scripts", "pytest-cov", "pytest-jupyter (>=0.5.3)", "pytest-timeout", "pytest-tornasync", "requests", "requests-cache", "virtualenv"] test = ["coverage", "pytest (>=7.0)", "pytest-check-links (>=0.7)", "pytest-console-scripts", "pytest-cov", "pytest-jupyter (>=0.5.3)", "pytest-timeout", "pytest-tornasync", "requests", "requests-cache", "virtualenv"]
upgrade-extension = ["copier (>=9,<10)", "jinja2-time (<0.3)", "pydantic (<3.0)", "pyyaml-include (<3.0)", "tomli-w (<2.0)"] upgrade-extension = ["copier (>=9,<10)", "jinja2-time (<0.3)", "pydantic (<3.0)", "pyyaml-include (<3.0)", "tomli-w (<2.0)"]
@@ -1792,26 +1792,26 @@ files = [
[[package]] [[package]]
name = "notebook" name = "notebook"
version = "7.2.2" version = "7.0.7"
description = "Jupyter Notebook - A web-based notebook environment for interactive computing" description = "Jupyter Notebook - A web-based notebook environment for interactive computing"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "notebook-7.2.2-py3-none-any.whl", hash = "sha256:c89264081f671bc02eec0ed470a627ed791b9156cad9285226b31611d3e9fe1c"}, {file = "notebook-7.0.7-py3-none-any.whl", hash = "sha256:289b606d7e173f75a18beb1406ef411b43f97f7a9c55ba03efa3622905a62346"},
{file = "notebook-7.2.2.tar.gz", hash = "sha256:2ef07d4220421623ad3fe88118d687bc0450055570cdd160814a59cf3a1c516e"}, {file = "notebook-7.0.7.tar.gz", hash = "sha256:3bcff00c17b3ac142ef5f436d50637d936b274cfa0b41f6ac0175363de9b4e09"},
] ]
[package.dependencies] [package.dependencies]
jupyter-server = ">=2.4.0,<3" jupyter-server = ">=2.4.0,<3"
jupyterlab = ">=4.2.0,<4.3" jupyterlab = ">=4.0.2,<5"
jupyterlab-server = ">=2.27.1,<3" jupyterlab-server = ">=2.22.1,<3"
notebook-shim = ">=0.2,<0.3" notebook-shim = ">=0.2,<0.3"
tornado = ">=6.2.0" tornado = ">=6.2.0"
[package.extras] [package.extras]
dev = ["hatch", "pre-commit"] dev = ["hatch", "pre-commit"]
docs = ["myst-parser", "nbsphinx", "pydata-sphinx-theme", "sphinx (>=1.3.6)", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] docs = ["myst-parser", "nbsphinx", "pydata-sphinx-theme", "sphinx (>=1.3.6)", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"]
test = ["importlib-resources (>=5.0)", "ipykernel", "jupyter-server[test] (>=2.4.0,<3)", "jupyterlab-server[test] (>=2.27.1,<3)", "nbval", "pytest (>=7.0)", "pytest-console-scripts", "pytest-timeout", "pytest-tornasync", "requests"] test = ["importlib-resources (>=5.0)", "ipykernel", "jupyter-server[test] (>=2.4.0,<3)", "jupyterlab-server[test] (>=2.22.1,<3)", "nbval", "pytest (>=7.0)", "pytest-console-scripts", "pytest-timeout", "pytest-tornasync", "requests"]
[[package]] [[package]]
name = "notebook-shim" name = "notebook-shim"
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "langgraph" name = "langgraph"
version = "0.2.53" version = "0.2.52"
description = "Building stateful, multi-actor applications with LLMs" description = "Building stateful, multi-actor applications with LLMs"
authors = [] authors = []
license = "MIT" license = "MIT"
+2 -107
View File
@@ -272,54 +272,6 @@ async def _store_postgres_aio():
await conn.execute(f"DROP DATABASE {database}") await conn.execute(f"DROP DATABASE {database}")
@asynccontextmanager
async def _store_postgres_aio_pipe():
if sys.version_info < (3, 10):
pytest.skip("Async Postgres tests require Python 3.10+")
database = f"test_{uuid4().hex[:16]}"
async with await AsyncConnection.connect(
DEFAULT_POSTGRES_URI, autocommit=True
) as conn:
await conn.execute(f"CREATE DATABASE {database}")
try:
async with AsyncPostgresStore.from_conn_string(
DEFAULT_POSTGRES_URI + database
) as store:
await store.setup() # Run in its own transaction
async with AsyncPostgresStore.from_conn_string(
DEFAULT_POSTGRES_URI + database, pipeline=True
) as store:
yield store
finally:
async with await AsyncConnection.connect(
DEFAULT_POSTGRES_URI, autocommit=True
) as conn:
await conn.execute(f"DROP DATABASE {database}")
@asynccontextmanager
async def _store_postgres_aio_pool():
if sys.version_info < (3, 10):
pytest.skip("Async Postgres tests require Python 3.10+")
database = f"test_{uuid4().hex[:16]}"
async with await AsyncConnection.connect(
DEFAULT_POSTGRES_URI, autocommit=True
) as conn:
await conn.execute(f"CREATE DATABASE {database}")
try:
async with AsyncPostgresStore.from_conn_string(
DEFAULT_POSTGRES_URI + database,
pool_config={"max_size": 10},
) as store:
await store.setup()
yield store
finally:
async with await AsyncConnection.connect(
DEFAULT_POSTGRES_URI, autocommit=True
) as conn:
await conn.execute(f"DROP DATABASE {database}")
@asynccontextmanager @asynccontextmanager
async def _store_duckdb_aio(): async def _store_duckdb_aio():
async with AsyncDuckDBStore.from_conn_string(":memory:") as store: async with AsyncDuckDBStore.from_conn_string(":memory:") as store:
@@ -344,45 +296,6 @@ def store_postgres():
conn.execute(f"DROP DATABASE {database}") conn.execute(f"DROP DATABASE {database}")
@pytest.fixture(scope="function")
def store_postgres_pipe():
database = f"test_{uuid4().hex[:16]}"
# create unique db
with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn:
conn.execute(f"CREATE DATABASE {database}")
try:
# yield store
with PostgresStore.from_conn_string(DEFAULT_POSTGRES_URI + database) as store:
store.setup() # Run in its own transaction
with PostgresStore.from_conn_string(
DEFAULT_POSTGRES_URI + database, pipeline=True
) as store:
yield store
finally:
# drop unique db
with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn:
conn.execute(f"DROP DATABASE {database}")
@pytest.fixture(scope="function")
def store_postgres_pool():
database = f"test_{uuid4().hex[:16]}"
# create unique db
with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn:
conn.execute(f"CREATE DATABASE {database}")
try:
# yield store
with PostgresStore.from_conn_string(
DEFAULT_POSTGRES_URI + database, pool_config={"max_size": 10}
) as store:
store.setup()
yield store
finally:
# drop unique db
with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn:
conn.execute(f"DROP DATABASE {database}")
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def store_duckdb(): def store_duckdb():
with DuckDBStore.from_conn_string(":memory:") as store: with DuckDBStore.from_conn_string(":memory:") as store:
@@ -404,12 +317,6 @@ async def awith_store(store_name: Optional[str]) -> AsyncIterator[BaseStore]:
elif store_name == "postgres_aio": elif store_name == "postgres_aio":
async with _store_postgres_aio() as store: async with _store_postgres_aio() as store:
yield store yield store
elif store_name == "postgres_aio_pipe":
async with _store_postgres_aio_pipe() as store:
yield store
elif store_name == "postgres_aio_pool":
async with _store_postgres_aio_pool() as store:
yield store
elif store_name == "duckdb_aio": elif store_name == "duckdb_aio":
async with _store_duckdb_aio() as store: async with _store_duckdb_aio() as store:
yield store yield store
@@ -435,17 +342,5 @@ ALL_CHECKPOINTERS_ASYNC_PLUS_NONE = [
*ALL_CHECKPOINTERS_ASYNC, *ALL_CHECKPOINTERS_ASYNC,
None, None,
] ]
ALL_STORES_SYNC = [ ALL_STORES_SYNC = ["in_memory", "postgres", "duckdb"]
"in_memory", ALL_STORES_ASYNC = ["in_memory", "postgres_aio", "duckdb_aio"]
"postgres",
"postgres_pipe",
"postgres_pool",
"duckdb",
]
ALL_STORES_ASYNC = [
"in_memory",
"postgres_aio",
"postgres_aio_pipe",
"postgres_aio_pool",
"duckdb_aio",
]
+2 -53
View File
@@ -1,10 +1,7 @@
import re import re
from typing import Any, AsyncIterator, Iterator, List, Optional, cast from typing import Any, Iterator, List, Optional, cast
from langchain_core.callbacks import ( from langchain_core.callbacks import CallbackManagerForLLMRun
AsyncCallbackManagerForLLMRun,
CallbackManagerForLLMRun,
)
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
@@ -87,51 +84,3 @@ class FakeChatModel(GenericFakeChatModel):
if run_manager: if run_manager:
run_manager.on_llm_new_token("", chunk=chunk) run_manager.on_llm_new_token("", chunk=chunk)
yield chunk yield chunk
async def _astream(
self,
messages: List[BaseMessage],
stop: Optional[List[str]] = None,
run_manager: Optional[AsyncCallbackManagerForLLMRun] = None,
**kwargs: Any,
) -> AsyncIterator[ChatGenerationChunk]:
"""Stream the output of the model."""
chat_result = self._generate(
messages, stop=stop, run_manager=run_manager, **kwargs
)
if not isinstance(chat_result, ChatResult):
raise ValueError(
f"Expected generate to return a ChatResult, "
f"but got {type(chat_result)} instead."
)
message = chat_result.generations[0].message
if not isinstance(message, AIMessage):
raise ValueError(
f"Expected invoke to return an AIMessage, "
f"but got {type(message)} instead."
)
content = message.content
if content:
# Use a regular expression to split on whitespace with a capture group
# so that we can preserve the whitespace in the output.
assert isinstance(content, str)
content_chunks = cast(list[str], re.split(r"(\s)", content))
for token in content_chunks:
chunk = ChatGenerationChunk(
message=AIMessageChunk(content=token, id=message.id)
)
if run_manager:
run_manager.on_llm_new_token(token, chunk=chunk)
yield chunk
else:
args = message.__dict__
args.pop("type")
chunk = ChatGenerationChunk(message=AIMessageChunk(**args))
if run_manager:
await run_manager.on_llm_new_token("", chunk=chunk)
yield chunk
-3
View File
@@ -102,9 +102,6 @@ class FakeToolCallingModel(BaseChatModel):
tools: Sequence[Union[Dict[str, Any], Type[BaseModel], Callable, BaseTool]], tools: Sequence[Union[Dict[str, Any], Type[BaseModel], Callable, BaseTool]],
**kwargs: Any, **kwargs: Any,
) -> Runnable[LanguageModelInput, BaseMessage]: ) -> Runnable[LanguageModelInput, BaseMessage]:
if len(tools) == 0:
raise ValueError("Must provide at least one tool")
tool_dicts = [] tool_dicts = []
for tool in tools: for tool in tools:
if not isinstance(tool, BaseTool): if not isinstance(tool, BaseTool):
+57 -359
View File
@@ -1,6 +1,5 @@
import enum import enum
import json import json
import logging
import operator import operator
import re import re
import time import time
@@ -68,9 +67,16 @@ from langgraph.errors import InvalidUpdateError, MultipleSubgraphsError, NodeInt
from langgraph.graph import END, Graph, GraphCommand, StateGraph from langgraph.graph import END, Graph, GraphCommand, StateGraph
from langgraph.graph.message import MessageGraph, MessagesState, add_messages from langgraph.graph.message import MessageGraph, MessagesState, add_messages
from langgraph.managed.shared_value import SharedValue from langgraph.managed.shared_value import SharedValue
from langgraph.prebuilt.chat_agent_executor import create_tool_calling_executor from langgraph.prebuilt.chat_agent_executor import (
create_tool_calling_executor,
)
from langgraph.prebuilt.tool_node import ToolNode from langgraph.prebuilt.tool_node import ToolNode
from langgraph.pregel import Channel, GraphRecursionError, Pregel, StateSnapshot from langgraph.pregel import (
Channel,
GraphRecursionError,
Pregel,
StateSnapshot,
)
from langgraph.pregel.retry import RetryPolicy from langgraph.pregel.retry import RetryPolicy
from langgraph.store.base import BaseStore from langgraph.store.base import BaseStore
from langgraph.store.memory import InMemoryStore from langgraph.store.memory import InMemoryStore
@@ -98,8 +104,6 @@ from tests.messages import (
_AnyIdToolMessage, _AnyIdToolMessage,
) )
logger = logging.getLogger(__name__)
# define these objects to avoid importing langchain_core.agents # define these objects to avoid importing langchain_core.agents
# and therefore avoid relying on core Pydantic version # and therefore avoid relying on core Pydantic version
@@ -160,7 +164,7 @@ def test_graph_validation() -> None:
workflow = Graph() workflow = Graph()
workflow.add_node("agent", logic) workflow.add_node("agent", logic)
workflow.set_finish_point("agent") workflow.set_finish_point("agent")
with pytest.raises(ValueError, match="must have an entrypoint"): with pytest.raises(ValueError, match="not reachable"):
workflow.compile() workflow.compile()
workflow = Graph() workflow = Graph()
@@ -207,6 +211,18 @@ def test_graph_validation() -> None:
with pytest.raises(ValueError, match="unknown"): # extra is not defined with pytest.raises(ValueError, match="unknown"): # extra is not defined
workflow.compile() workflow.compile()
workflow = Graph()
workflow.add_node("agent", logic)
workflow.add_node("tools", logic)
workflow.add_node("extra", logic)
workflow.set_entry_point("agent")
workflow.add_conditional_edges("agent", logic, {"continue": "tools", "exit": END})
workflow.add_edge("tools", "agent")
with pytest.raises(
ValueError, match="Node `extra` is not reachable"
): # extra is not reachable
workflow.compile()
workflow = Graph() workflow = Graph()
workflow.add_node("agent", logic) workflow.add_node("agent", logic)
workflow.add_node("tools", logic) workflow.add_node("tools", logic)
@@ -264,25 +280,6 @@ def test_graph_validation() -> None:
graph.invoke({"hello": "there"}) graph.invoke({"hello": "there"})
def test_graph_validation_with_command() -> None:
class State(TypedDict):
foo: str
bar: str
def node_a(state: State):
return GraphCommand(goto="b", update={"foo": "bar"})
def node_b(state: State):
return GraphCommand(goto=END, update={"bar": "baz"})
builder = StateGraph(State)
builder.add_node("a", node_a)
builder.add_node("b", node_b)
builder.add_edge(START, "a")
graph = builder.compile()
assert graph.invoke({"foo": ""}) == {"foo": "bar", "bar": "baz"}
def test_checkpoint_errors() -> None: def test_checkpoint_errors() -> None:
class FaultyGetCheckpointer(MemorySaver): class FaultyGetCheckpointer(MemorySaver):
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]: def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
@@ -6631,7 +6628,11 @@ def test_message_graph(
from langchain_core.language_models.fake_chat_models import ( from langchain_core.language_models.fake_chat_models import (
FakeMessagesListChatModel, FakeMessagesListChatModel,
) )
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage from langchain_core.messages import (
AIMessage,
BaseMessage,
HumanMessage,
)
from langchain_core.outputs import ChatGeneration, ChatResult from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.tools import tool from langchain_core.tools import tool
@@ -8735,176 +8736,6 @@ def test_copy_checkpoint(
) )
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_dynamic_interrupt_subgraph(
request: pytest.FixtureRequest, checkpointer_name: str
) -> None:
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
class SubgraphState(TypedDict):
my_key: str
market: str
tool_two_node_count = 0
def tool_two_node(s: SubgraphState) -> SubgraphState:
nonlocal tool_two_node_count
tool_two_node_count += 1
if s["market"] == "DE":
answer = interrupt("Just because...")
else:
answer = " all good"
return {"my_key": answer}
subgraph = StateGraph(SubgraphState)
subgraph.add_node("do", tool_two_node, retry=RetryPolicy())
subgraph.add_edge(START, "do")
class State(TypedDict):
my_key: Annotated[str, operator.add]
market: str
tool_two_graph = StateGraph(State)
tool_two_graph.add_node("tool_two", subgraph.compile())
tool_two_graph.add_edge(START, "tool_two")
tool_two = tool_two_graph.compile()
tracer = FakeTracer()
assert tool_two.invoke(
{"my_key": "value", "market": "DE"}, {"callbacks": [tracer]}
) == {
"my_key": "value",
"market": "DE",
}
assert tool_two_node_count == 1, "interrupts aren't retried"
assert len(tracer.runs) == 1
run = tracer.runs[0]
assert run.end_time is not None
assert run.error is None
assert run.outputs == {"market": "DE", "my_key": "value"}
assert tool_two.invoke({"my_key": "value", "market": "US"}) == {
"my_key": "value all good",
"market": "US",
}
tool_two = tool_two_graph.compile(checkpointer=checkpointer)
# missing thread_id
with pytest.raises(ValueError, match="thread_id"):
tool_two.invoke({"my_key": "value", "market": "DE"})
# flow: interrupt -> resume with answer
thread2 = {"configurable": {"thread_id": "2"}}
# stop when about to enter node
assert [
c for c in tool_two.stream({"my_key": "value ⛰️", "market": "DE"}, thread2)
] == [
{
"__interrupt__": (
Interrupt(
value="Just because...",
resumable=True,
ns=[AnyStr("tool_two:"), AnyStr("do:")],
),
)
},
]
# resume with answer
assert [c for c in tool_two.stream(Command(resume=" my answer"), thread2)] == [
{"tool_two": {"my_key": " my answer", "market": "DE"}},
]
# flow: interrupt -> clear tasks
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(
{"configurable": {"thread_id": "1", "checkpoint_ns": ""}}
)
] == [
{
"parents": {},
"source": "loop",
"step": 0,
"writes": None,
"thread_id": "1",
},
{
"parents": {},
"source": "input",
"step": -1,
"writes": {"__start__": {"my_key": "value ⛰️", "market": "DE"}},
"thread_id": "1",
},
]
assert tool_two.get_state(thread1) == StateSnapshot(
values={"my_key": "value ⛰️", "market": "DE"},
next=("tool_two",),
tasks=(
PregelTask(
AnyStr(),
"tool_two",
(PULL, "tool_two"),
interrupts=(
Interrupt(
value="Just because...",
resumable=True,
ns=[AnyStr("tool_two:"), AnyStr("do:")],
),
),
state={
"configurable": {
"thread_id": "1",
"checkpoint_ns": AnyStr("tool_two:"),
}
},
),
),
config=tool_two.checkpointer.get_tuple(thread1).config,
created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"],
metadata={
"parents": {},
"source": "loop",
"step": 0,
"writes": None,
"thread_id": "1",
},
parent_config=[
*tool_two.checkpointer.list(
{"configurable": {"thread_id": "1", "checkpoint_ns": ""}}, limit=2
)
][-1].config,
)
# clear the interrupt and next tasks
tool_two.update_state(thread1, None, as_node=END)
# interrupt and next tasks are cleared
assert tool_two.get_state(thread1) == StateSnapshot(
values={"my_key": "value ⛰️", "market": "DE"},
next=(),
tasks=(),
config=tool_two.checkpointer.get_tuple(thread1).config,
created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"],
metadata={
"parents": {},
"source": "update",
"step": 1,
"writes": {},
"thread_id": "1",
},
parent_config=[
*tool_two.checkpointer.list(
{"configurable": {"thread_id": "1", "checkpoint_ns": ""}}, limit=2
)
][-1].config,
)
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC) @pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_start_branch_then( def test_start_branch_then(
snapshot: SnapshotAssertion, request: pytest.FixtureRequest, checkpointer_name: str snapshot: SnapshotAssertion, request: pytest.FixtureRequest, checkpointer_name: str
@@ -14106,75 +13937,50 @@ def test_store_injected(
doc_id = str(uuid.uuid4()) doc_id = str(uuid.uuid4())
doc = {"some-key": "this-is-a-val"} doc = {"some-key": "this-is-a-val"}
uid = uuid.uuid4().hex
namespace = (f"foo-{uid}", "bar")
thread_1 = str(uuid.uuid4())
thread_2 = str(uuid.uuid4())
class Node: def node(input: State, config: RunnableConfig, store: BaseStore):
def __init__(self, i: Optional[int] = None): assert isinstance(store, BaseStore)
self.i = i store.put(
("foo", "bar"),
def __call__(self, inputs: State, config: RunnableConfig, store: BaseStore): doc_id,
assert isinstance(store, BaseStore) {
store.put( **doc,
namespace "from_thread": config["configurable"]["thread_id"],
if self.i is not None "some_val": input["count"],
and config["configurable"]["thread_id"] in (thread_1, thread_2) },
else (f"foo_{self.i}", "bar"), )
doc_id, return {"count": 1}
{
**doc,
"from_thread": config["configurable"]["thread_id"],
"some_val": inputs["count"],
},
)
return {"count": 1}
builder = StateGraph(State) builder = StateGraph(State)
builder.add_node("node", Node()) builder.add_node("node", node)
builder.add_edge("__start__", "node") builder.add_edge("__start__", "node")
N = 500
M = 1
if "duckdb" in store_name:
logger.warning(
"DuckDB store implementation has a known issue that does not"
" support concurrent writes, so we're reducing the test scope"
)
N = M = 1
for i in range(N):
builder.add_node(f"node_{i}", Node(i))
builder.add_edge("__start__", f"node_{i}")
graph = builder.compile(store=the_store, checkpointer=checkpointer) graph = builder.compile(store=the_store, checkpointer=checkpointer)
results = graph.batch( thread_1 = str(uuid.uuid4())
[{"count": 0}] * M,
([{"configurable": {"thread_id": str(uuid.uuid4())}}] * (M - 1))
+ [{"configurable": {"thread_id": thread_1}}],
)
result = results[-1]
assert result == {"count": N + 1}
returned_doc = the_store.get(namespace, doc_id).value
assert returned_doc == {**doc, "from_thread": thread_1, "some_val": 0}
assert len(the_store.search(namespace)) == 1
# Check results after another turn of the same thread
result = graph.invoke({"count": 0}, {"configurable": {"thread_id": thread_1}}) result = graph.invoke({"count": 0}, {"configurable": {"thread_id": thread_1}})
assert result == {"count": (N + 1) * 2} assert result == {"count": 1}
returned_doc = the_store.get(namespace, doc_id).value returned_doc = the_store.get(("foo", "bar"), doc_id).value
assert returned_doc == {**doc, "from_thread": thread_1, "some_val": N + 1} assert returned_doc == {**doc, "from_thread": thread_1, "some_val": 0}
assert len(the_store.search(namespace)) == 1 assert len(the_store.search(("foo", "bar"))) == 1
# Check update on existing thread
result = graph.invoke({"count": 0}, {"configurable": {"thread_id": thread_1}})
assert result == {"count": 2}
returned_doc = the_store.get(("foo", "bar"), doc_id).value
assert returned_doc == {**doc, "from_thread": thread_1, "some_val": 1}
assert len(the_store.search(("foo", "bar"))) == 1
thread_2 = str(uuid.uuid4())
result = graph.invoke({"count": 0}, {"configurable": {"thread_id": thread_2}}) result = graph.invoke({"count": 0}, {"configurable": {"thread_id": thread_2}})
assert result == {"count": N + 1} assert result == {"count": 1}
returned_doc = the_store.get(namespace, doc_id).value returned_doc = the_store.get(("foo", "bar"), doc_id).value
assert returned_doc == { assert returned_doc == {
**doc, **doc,
"from_thread": thread_2, "from_thread": thread_2,
"some_val": 0, "some_val": 0,
} # Overwrites the whole doc } # Overwrites the whole doc
assert len(the_store.search(namespace)) == 1 # still overwriting the same one assert len(the_store.search(("foo", "bar"))) == 1 # still overwriting the same one
def test_enum_node_names(): def test_enum_node_names():
@@ -14572,111 +14378,3 @@ def test_runnable_passthrough_node_graph() -> None:
graph = graph_builder.compile() graph = graph_builder.compile()
assert graph.get_graph(xray=True).to_json() == graph.get_graph(xray=False).to_json() assert graph.get_graph(xray=True).to_json() == graph.get_graph(xray=False).to_json()
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_parent_command(request: pytest.FixtureRequest, checkpointer_name: str) -> None:
from langchain_core.messages import BaseMessage
from langchain_core.tools import tool
@tool(return_direct=True)
def get_user_name() -> GraphCommand:
"""Retrieve user name"""
return GraphCommand(update={"user_name": "Meow"}, graph=GraphCommand.PARENT)
subgraph_builder = StateGraph(MessagesState)
subgraph_builder.add_node("tool", get_user_name)
subgraph_builder.add_edge(START, "tool")
subgraph = subgraph_builder.compile()
class CustomParentState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
# this key is not available to the child graph
user_name: str
builder = StateGraph(CustomParentState)
builder.add_node("alice", subgraph)
builder.add_edge(START, "alice")
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "1"}}
assert graph.invoke({"messages": [("user", "get user name")]}, config) == {
"messages": [
_AnyIdHumanMessage(
content="get user name", additional_kwargs={}, response_metadata={}
),
],
"user_name": "Meow",
}
assert graph.get_state(config) == StateSnapshot(
values={
"messages": [
_AnyIdHumanMessage(
content="get user name", additional_kwargs={}, response_metadata={}
),
],
"user_name": "Meow",
},
next=(),
config={
"configurable": {
"thread_id": "1",
"checkpoint_ns": "",
"checkpoint_id": AnyStr(),
}
},
metadata={
"source": "loop",
"writes": {
"alice": {
"user_name": "Meow",
}
},
"thread_id": "1",
"step": 1,
"parents": {},
},
created_at=AnyStr(),
parent_config={
"configurable": {
"thread_id": "1",
"checkpoint_ns": "",
"checkpoint_id": AnyStr(),
}
},
tasks=(),
)
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_interrupt_subgraph(request: pytest.FixtureRequest, checkpointer_name: str):
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
class State(TypedDict):
baz: str
def foo(state):
return {"baz": "foo"}
def bar(state):
value = interrupt("Please provide baz value:")
return {"baz": value}
child_builder = StateGraph(State)
child_builder.add_node(bar)
child_builder.add_edge(START, "bar")
builder = StateGraph(State)
builder.add_node(foo)
builder.add_node("bar", child_builder.compile())
builder.add_edge(START, "foo")
builder.add_edge("foo", "bar")
graph = builder.compile(checkpointer=checkpointer)
thread1 = {"configurable": {"thread_id": "1"}}
# First run, interrupted at bar
assert graph.invoke({"baz": ""}, thread1)
# Resume with answer
assert graph.invoke(Command(resume="bar"), thread1)
+32 -363
View File
@@ -1,5 +1,4 @@
import asyncio import asyncio
import logging
import operator import operator
import random import random
import re import re
@@ -101,8 +100,6 @@ from tests.messages import (
_AnyIdToolMessage, _AnyIdToolMessage,
) )
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.anyio pytestmark = pytest.mark.anyio
@@ -429,189 +426,6 @@ async def test_dynamic_interrupt(checkpointer_name: str) -> None:
) )
@pytest.mark.skipif(
sys.version_info < (3, 11),
reason="Python 3.11+ is required for async contextvars support",
)
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_dynamic_interrupt_subgraph(checkpointer_name: str) -> None:
class SubgraphState(TypedDict):
my_key: str
market: str
tool_two_node_count = 0
def tool_two_node(s: SubgraphState) -> SubgraphState:
nonlocal tool_two_node_count
tool_two_node_count += 1
if s["market"] == "DE":
answer = interrupt("Just because...")
else:
answer = " all good"
return {"my_key": answer}
subgraph = StateGraph(SubgraphState)
subgraph.add_node("do", tool_two_node, retry=RetryPolicy())
subgraph.add_edge(START, "do")
class State(TypedDict):
my_key: Annotated[str, operator.add]
market: str
tool_two_graph = StateGraph(State)
tool_two_graph.add_node("tool_two", subgraph.compile())
tool_two_graph.add_edge(START, "tool_two")
tool_two = tool_two_graph.compile()
tracer = FakeTracer()
assert await tool_two.ainvoke(
{"my_key": "value", "market": "DE"}, {"callbacks": [tracer]}
) == {
"my_key": "value",
"market": "DE",
}
assert tool_two_node_count == 1, "interrupts aren't retried"
assert len(tracer.runs) == 1
run = tracer.runs[0]
assert run.end_time is not None
assert run.error is None
assert run.outputs == {"market": "DE", "my_key": "value"}
assert await tool_two.ainvoke({"my_key": "value", "market": "US"}) == {
"my_key": "value all good",
"market": "US",
}
async with awith_checkpointer(checkpointer_name) as checkpointer:
tool_two = tool_two_graph.compile(checkpointer=checkpointer)
# missing thread_id
with pytest.raises(ValueError, match="thread_id"):
await tool_two.ainvoke({"my_key": "value", "market": "DE"})
# flow: interrupt -> resume with answer
thread2 = {"configurable": {"thread_id": "2"}}
# stop when about to enter node
assert [
c
async for c in tool_two.astream(
{"my_key": "value ⛰️", "market": "DE"}, thread2
)
] == [
{
"__interrupt__": (
Interrupt(
value="Just because...",
resumable=True,
ns=[AnyStr("tool_two:"), AnyStr("do:")],
),
)
},
]
# resume with answer
assert [
c async for c in tool_two.astream(Command(resume=" my answer"), thread2)
] == [
{"tool_two": {"my_key": " my answer", "market": "DE"}},
]
# flow: interrupt -> clear
thread1 = {"configurable": {"thread_id": "1"}}
thread1root = {"configurable": {"thread_id": "1", "checkpoint_ns": ""}}
# stop when about to enter node
assert [
c
async for c in tool_two.astream(
{"my_key": "value ⛰️", "market": "DE"}, thread1
)
] == [
{
"__interrupt__": (
Interrupt(
value="Just because...",
resumable=True,
ns=[AnyStr("tool_two:"), AnyStr("do:")],
),
)
},
]
assert [c.metadata async for c in tool_two.checkpointer.alist(thread1root)] == [
{
"parents": {},
"source": "loop",
"step": 0,
"writes": None,
"thread_id": "1",
},
{
"parents": {},
"source": "input",
"step": -1,
"writes": {"__start__": {"my_key": "value ⛰️", "market": "DE"}},
"thread_id": "1",
},
]
tup = await tool_two.checkpointer.aget_tuple(thread1)
assert await tool_two.aget_state(thread1) == StateSnapshot(
values={"my_key": "value ⛰️", "market": "DE"},
next=("tool_two",),
tasks=(
PregelTask(
AnyStr(),
"tool_two",
(PULL, "tool_two"),
interrupts=(
Interrupt(
value="Just because...",
resumable=True,
ns=[AnyStr("tool_two:"), AnyStr("do:")],
),
),
state={
"configurable": {
"thread_id": "1",
"checkpoint_ns": AnyStr("tool_two:"),
}
},
),
),
config=tup.config,
created_at=tup.checkpoint["ts"],
metadata={
"parents": {},
"source": "loop",
"step": 0,
"writes": None,
"thread_id": "1",
},
parent_config=[
c async for c in tool_two.checkpointer.alist(thread1root, limit=2)
][-1].config,
)
# clear the interrupt and next tasks
await tool_two.aupdate_state(thread1, None, as_node=END)
# interrupt is cleared, as well as the next tasks
tup = await tool_two.checkpointer.aget_tuple(thread1)
assert await tool_two.aget_state(thread1) == StateSnapshot(
values={"my_key": "value ⛰️", "market": "DE"},
next=(),
tasks=(),
config=tup.config,
created_at=tup.checkpoint["ts"],
metadata={
"parents": {},
"source": "update",
"step": 1,
"writes": {},
"thread_id": "1",
},
parent_config=[
c async for c in tool_two.checkpointer.alist(thread1root, limit=2)
][-1].config,
)
@pytest.mark.skipif(not FF_SEND_V2, reason="send v2 is not enabled") @pytest.mark.skipif(not FF_SEND_V2, reason="send v2 is not enabled")
@pytest.mark.skipif( @pytest.mark.skipif(
sys.version_info < (3, 11), sys.version_info < (3, 11),
@@ -12458,89 +12272,60 @@ async def test_store_injected_async(checkpointer_name: str, store_name: str) ->
doc_id = str(uuid.uuid4()) doc_id = str(uuid.uuid4())
doc = {"some-key": "this-is-a-val"} doc = {"some-key": "this-is-a-val"}
uid = uuid.uuid4().hex
namespace = (f"foo-{uid}", "bar")
thread_1 = str(uuid.uuid4())
thread_2 = str(uuid.uuid4())
class Node: async def node(input: State, config: RunnableConfig, store: BaseStore):
def __init__(self, i: Optional[int] = None): assert isinstance(store, BaseStore)
self.i = i await store.aput(
("foo", "bar"),
async def __call__( doc_id,
self, inputs: State, config: RunnableConfig, store: BaseStore {
): **doc,
assert isinstance(store, BaseStore) "from_thread": config["configurable"]["thread_id"],
await store.aput( "some_val": input["count"],
namespace },
if self.i is not None )
and config["configurable"]["thread_id"] in (thread_1, thread_2) return {"count": 1}
else (f"foo_{self.i}", "bar"),
doc_id,
{
**doc,
"from_thread": config["configurable"]["thread_id"],
"some_val": inputs["count"],
},
)
return {"count": 1}
builder = StateGraph(State) builder = StateGraph(State)
builder.add_node("node", Node()) builder.add_node("node", node)
builder.add_edge("__start__", "node") builder.add_edge("__start__", "node")
N = 500
M = 1
if "duckdb" in store_name:
logger.warning(
"DuckDB store implementation has a known issue that does not"
" support concurrent writes, so we're reducing the test scope"
)
N = M = 1
for i in range(N):
builder.add_node(f"node_{i}", Node(i))
builder.add_edge("__start__", f"node_{i}")
async with awith_checkpointer(checkpointer_name) as checkpointer, awith_store( async with awith_checkpointer(checkpointer_name) as checkpointer, awith_store(
store_name store_name
) as the_store: ) as the_store:
graph = builder.compile(store=the_store, checkpointer=checkpointer) graph = builder.compile(store=the_store, checkpointer=checkpointer)
# Test batch operations with multiple threads thread_1 = str(uuid.uuid4())
results = await graph.abatch(
[{"count": 0}] * M,
([{"configurable": {"thread_id": str(uuid.uuid4())}}] * (M - 1))
+ [{"configurable": {"thread_id": thread_1}}],
)
result = results[-1]
assert result == {"count": N + 1}
returned_doc = (await the_store.aget(namespace, doc_id)).value
assert returned_doc == {**doc, "from_thread": thread_1, "some_val": 0}
assert len((await the_store.asearch(namespace))) == 1
# Check results after another turn of the same thread
result = await graph.ainvoke( result = await graph.ainvoke(
{"count": 0}, {"configurable": {"thread_id": thread_1}} {"count": 0}, {"configurable": {"thread_id": thread_1}}
) )
assert result == {"count": (N + 1) * 2} assert result == {"count": 1}
returned_doc = (await the_store.aget(namespace, doc_id)).value returned_doc = (await the_store.aget(("foo", "bar"), doc_id)).value
assert returned_doc == {**doc, "from_thread": thread_1, "some_val": N + 1} assert returned_doc == {**doc, "from_thread": thread_1, "some_val": 0}
assert len((await the_store.asearch(namespace))) == 1 assert len((await the_store.asearch(("foo", "bar")))) == 1
# Check update on existing thread
result = await graph.ainvoke(
{"count": 0}, {"configurable": {"thread_id": thread_1}}
)
assert result == {"count": 2}
returned_doc = (await the_store.aget(("foo", "bar"), doc_id)).value
assert returned_doc == {**doc, "from_thread": thread_1, "some_val": 1}
assert len((await the_store.asearch(("foo", "bar")))) == 1
thread_2 = str(uuid.uuid4())
# Test with a different thread
result = await graph.ainvoke( result = await graph.ainvoke(
{"count": 0}, {"configurable": {"thread_id": thread_2}} {"count": 0}, {"configurable": {"thread_id": thread_2}}
) )
assert result == {"count": N + 1} assert result == {"count": 1}
returned_doc = (await the_store.aget(namespace, doc_id)).value returned_doc = (await the_store.aget(("foo", "bar"), doc_id)).value
assert returned_doc == { assert returned_doc == {
**doc, **doc,
"from_thread": thread_2, "from_thread": thread_2,
"some_val": 0, "some_val": 0,
} # Overwrites the whole doc } # Overwrites the whole doc
assert ( assert (
len((await the_store.asearch(namespace))) == 1 len((await the_store.asearch(("foo", "bar")))) == 1
) # still overwriting the same one ) # still overwriting the same one
@@ -12780,119 +12565,3 @@ async def test_debug_nested_subgraphs():
assert stream_task["interrupts"] == history_task.interrupts assert stream_task["interrupts"] == history_task.interrupts
assert stream_task.get("error") == history_task.error assert stream_task.get("error") == history_task.error
assert stream_task.get("state") == history_task.state assert stream_task.get("state") == history_task.state
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_parent_command(checkpointer_name: str) -> None:
from langchain_core.messages import BaseMessage
from langchain_core.tools import tool
@tool(return_direct=True)
def get_user_name() -> GraphCommand:
"""Retrieve user name"""
return GraphCommand(update={"user_name": "Meow"}, graph=GraphCommand.PARENT)
subgraph_builder = StateGraph(MessagesState)
subgraph_builder.add_node("tool", get_user_name)
subgraph_builder.add_edge(START, "tool")
subgraph = subgraph_builder.compile()
class CustomParentState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
# this key is not available to the child graph
user_name: str
builder = StateGraph(CustomParentState)
builder.add_node("alice", subgraph)
builder.add_edge(START, "alice")
async with awith_checkpointer(checkpointer_name) as checkpointer:
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "1"}}
assert await graph.ainvoke(
{"messages": [("user", "get user name")]}, config
) == {
"messages": [
_AnyIdHumanMessage(
content="get user name", additional_kwargs={}, response_metadata={}
),
],
"user_name": "Meow",
}
assert await graph.aget_state(config) == StateSnapshot(
values={
"messages": [
_AnyIdHumanMessage(
content="get user name",
additional_kwargs={},
response_metadata={},
),
],
"user_name": "Meow",
},
next=(),
config={
"configurable": {
"thread_id": "1",
"checkpoint_ns": "",
"checkpoint_id": AnyStr(),
}
},
metadata={
"source": "loop",
"writes": {
"alice": {
"user_name": "Meow",
}
},
"thread_id": "1",
"step": 1,
"parents": {},
},
created_at=AnyStr(),
parent_config={
"configurable": {
"thread_id": "1",
"checkpoint_ns": "",
"checkpoint_id": AnyStr(),
}
},
tasks=(),
)
@pytest.mark.skipif(
sys.version_info < (3, 11),
reason="Python 3.11+ is required for async contextvars support",
)
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_interrupt_subgraph(checkpointer_name: str):
class State(TypedDict):
baz: str
def foo(state):
return {"baz": "foo"}
def bar(state):
value = interrupt("Please provide baz value:")
return {"baz": value}
child_builder = StateGraph(State)
child_builder.add_node(bar)
child_builder.add_edge(START, "bar")
builder = StateGraph(State)
builder.add_node(foo)
builder.add_node("bar", child_builder.compile())
builder.add_edge(START, "foo")
builder.add_edge("foo", "bar")
async with awith_checkpointer(checkpointer_name) as checkpointer:
graph = builder.compile(checkpointer=checkpointer)
thread1 = {"configurable": {"thread_id": "1"}}
# First run, interrupted at bar
assert await graph.ainvoke({"baz": ""}, thread1)
# Resume with answer
assert await graph.ainvoke(Command(resume="bar"), thread1)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@langchain/langgraph-sdk", "name": "@langchain/langgraph-sdk",
"version": "0.0.30", "version": "0.0.26",
"description": "Client library for interacting with the LangGraph API", "description": "Client library for interacting with the LangGraph API",
"type": "module", "type": "module",
"packageManager": "yarn@1.22.19", "packageManager": "yarn@1.22.19",
+5 -22
View File
@@ -7,7 +7,6 @@ import {
GraphSchema, GraphSchema,
Metadata, Metadata,
Run, Run,
RunStatus,
Thread, Thread,
ThreadState, ThreadState,
Cron, Cron,
@@ -34,7 +33,6 @@ import {
OnConflictBehavior, OnConflictBehavior,
} from "./types.js"; } from "./types.js";
import { mergeSignals } from "./utils/signals.js"; import { mergeSignals } from "./utils/signals.js";
import { getEnvironmentVariable } from "./utils/env.js";
/** /**
* Get the API key from the environment. * Get the API key from the environment.
@@ -55,7 +53,7 @@ export function getApiKey(apiKey?: string): string | undefined {
const prefixes = ["LANGGRAPH", "LANGSMITH", "LANGCHAIN"]; const prefixes = ["LANGGRAPH", "LANGSMITH", "LANGCHAIN"];
for (const prefix of prefixes) { for (const prefix of prefixes) {
const envKey = getEnvironmentVariable(`${prefix}_API_KEY`); const envKey = process.env[`${prefix}_API_KEY`];
if (envKey) { if (envKey) {
// Remove surrounding quotes // Remove surrounding quotes
return envKey.trim().replace(/^["']|["']$/g, ""); return envKey.trim().replace(/^["']|["']$/g, "");
@@ -945,18 +943,12 @@ export class RunsClient extends BaseClient {
* Defaults to 0. * Defaults to 0.
*/ */
offset?: number; offset?: number;
/**
* Status of the run to filter by.
*/
status?: RunStatus;
}, },
): Promise<Run[]> { ): Promise<Run[]> {
return this.fetch<Run[]>(`/threads/${threadId}/runs`, { return this.fetch<Run[]>(`/threads/${threadId}/runs`, {
params: { params: {
limit: options?.limit ?? 10, limit: options?.limit ?? 10,
offset: options?.offset ?? 0, offset: options?.offset ?? 0,
status: options?.status ?? undefined,
}, },
}); });
} }
@@ -1021,28 +1013,19 @@ export class RunsClient extends BaseClient {
* *
* @param threadId The ID of the thread. * @param threadId The ID of the thread.
* @param runId The ID of the run. * @param runId The ID of the run.
* @param signal An optional abort signal.
* @returns An async generator yielding stream parts. * @returns An async generator yielding stream parts.
*/ */
async *joinStream( async *joinStream(
threadId: string, threadId: string,
runId: string, runId: string,
options?: signal?: AbortSignal,
| { signal?: AbortSignal; cancelOnDisconnect?: boolean }
| AbortSignal,
): AsyncGenerator<{ event: StreamEvent; data: any }> { ): AsyncGenerator<{ event: StreamEvent; data: any }> {
const opts =
typeof options === "object" &&
options != null &&
options instanceof AbortSignal
? { signal: options }
: options;
const response = await this.asyncCaller.fetch( const response = await this.asyncCaller.fetch(
...this.prepareFetchOptions(`/threads/${threadId}/runs/${runId}/stream`, { ...this.prepareFetchOptions(`/threads/${threadId}/runs/${runId}/stream`, {
method: "GET", method: "GET",
timeoutMs: null, timeoutMs: null,
signal: opts?.signal, signal,
params: { cancel_on_disconnect: opts?.cancelOnDisconnect ? "1" : "0" },
}), }),
); );
@@ -1057,7 +1040,7 @@ export class RunsClient extends BaseClient {
async start(ctrl) { async start(ctrl) {
parser = createParser((event) => { parser = createParser((event) => {
if ( if (
(opts?.signal && opts.signal.aborted) || (signal && signal.aborted) ||
(event.type === "event" && event.data === "[DONE]") (event.type === "event" && event.data === "[DONE]")
) { ) {
ctrl.terminate(); ctrl.terminate();
-1
View File
@@ -15,7 +15,6 @@ export type {
ThreadStatus, ThreadStatus,
Cron, Cron,
Checkpoint, Checkpoint,
Interrupt,
} from "./schema.js"; } from "./schema.js";
export type { OnConflictBehavior, Command } from "./types.js"; export type { OnConflictBehavior, Command } from "./types.js";
+7 -15
View File
@@ -2,7 +2,7 @@ import type { JSONSchema7 } from "json-schema";
type Optional<T> = T | null | undefined; type Optional<T> = T | null | undefined;
export type RunStatus = type RunStatus =
| "pending" | "pending"
| "running" | "running"
| "error" | "error"
@@ -137,16 +137,6 @@ export interface AssistantGraph {
}>; }>;
} }
/**
* An interrupt thrown inside a thread.
*/
export interface Interrupt {
value: unknown;
when: "during";
resumable: boolean;
ns?: string[];
}
export interface Thread<ValuesType = DefaultValues> { export interface Thread<ValuesType = DefaultValues> {
/** The ID of the thread. */ /** The ID of the thread. */
thread_id: string; thread_id: string;
@@ -165,9 +155,6 @@ export interface Thread<ValuesType = DefaultValues> {
/** The current state of the thread. */ /** The current state of the thread. */
values: ValuesType; values: ValuesType;
/** Interrupts which were thrown in this thread */
interrupts: Record<string, Array<Interrupt>>;
} }
export interface Cron { export interface Cron {
@@ -223,7 +210,12 @@ export interface ThreadTask {
name: string; name: string;
result?: unknown; result?: unknown;
error: Optional<string>; error: Optional<string>;
interrupts: Array<Interrupt>; interrupts: Array<{
value: unknown;
when: "during";
resumable: boolean;
ns?: string[];
}>;
checkpoint: Optional<Checkpoint>; checkpoint: Optional<Checkpoint>;
state: Optional<ThreadState>; state: Optional<ThreadState>;
} }
-1
View File
@@ -4,7 +4,6 @@ import PQueueMod from "p-queue";
const STATUS_NO_RETRY = [ const STATUS_NO_RETRY = [
400, // Bad Request 400, // Bad Request
401, // Unauthorized 401, // Unauthorized
402, // Payment required
403, // Forbidden 403, // Forbidden
404, // Not Found 404, // Not Found
405, // Method Not Allowed 405, // Method Not Allowed
-11
View File
@@ -1,11 +0,0 @@
export function getEnvironmentVariable(name: string): string | undefined {
// Certain setups (Deno, frontend) will throw an error if you try to access environment variables
try {
return typeof process !== "undefined"
? // eslint-disable-next-line no-process-env
process.env?.[name]
: undefined;
} catch (e) {
return undefined;
}
}
+14 -63
View File
@@ -18,7 +18,6 @@ from typing import (
Dict, Dict,
Iterator, Iterator,
List, List,
Literal,
Optional, Optional,
Sequence, Sequence,
Union, Union,
@@ -51,7 +50,6 @@ from langgraph_sdk.schema import (
OnConflictBehavior, OnConflictBehavior,
Run, Run,
RunCreate, RunCreate,
RunStatus,
SearchItemsResponse, SearchItemsResponse,
StreamMode, StreamMode,
StreamPart, StreamPart,
@@ -1685,12 +1683,7 @@ class RunsClient:
return response return response
async def list( async def list(
self, self, thread_id: str, *, limit: int = 10, offset: int = 0
thread_id: str,
*,
limit: int = 10,
offset: int = 0,
status: Optional[RunStatus] = None,
) -> List[Run]: ) -> List[Run]:
"""List runs. """List runs.
@@ -1698,7 +1691,6 @@ class RunsClient:
thread_id: The thread ID to list runs for. thread_id: The thread ID to list runs for.
limit: The maximum number of results to return. limit: The maximum number of results to return.
offset: The number of results to skip. offset: The number of results to skip.
status: The status of the run to filter by.
Returns: Returns:
List[Run]: The runs for the thread. List[Run]: The runs for the thread.
@@ -1712,13 +1704,9 @@ class RunsClient:
) )
""" # noqa: E501 """ # noqa: E501
params = { return await self.http.get(
"limit": limit, f"/threads/{thread_id}/runs?limit={limit}&offset={offset}"
"offset": offset, )
}
if status is not None:
params["status"] = status
return await self.http.get(f"/threads/{thread_id}/runs", params=params)
async def get(self, thread_id: str, run_id: str) -> Run: async def get(self, thread_id: str, run_id: str) -> Run:
"""Get a run. """Get a run.
@@ -1796,9 +1784,7 @@ class RunsClient:
""" # noqa: E501 """ # noqa: E501
return await self.http.get(f"/threads/{thread_id}/runs/{run_id}/join") return await self.http.get(f"/threads/{thread_id}/runs/{run_id}/join")
def join_stream( def join_stream(self, thread_id: str, run_id: str) -> AsyncIterator[StreamPart]:
self, thread_id: str, run_id: str, *, cancel_on_disconnect: bool = False
) -> AsyncIterator[StreamPart]:
"""Stream output from a run in real-time, until the run is done. """Stream output from a run in real-time, until the run is done.
Output is not buffered, so any output produced before this call will Output is not buffered, so any output produced before this call will
not be received here. not be received here.
@@ -1806,7 +1792,6 @@ class RunsClient:
Args: Args:
thread_id: The thread ID to join. thread_id: The thread ID to join.
run_id: The run ID to join. run_id: The run ID to join.
cancel_on_disconnect: Whether to cancel the run when the stream is disconnected.
Returns: Returns:
None None
@@ -1819,11 +1804,7 @@ class RunsClient:
) )
""" # noqa: E501 """ # noqa: E501
return self.http.stream( return self.http.stream(f"/threads/{thread_id}/runs/{run_id}/stream", "GET")
f"/threads/{thread_id}/runs/{run_id}/stream",
"GET",
params={"cancel_on_disconnect": cancel_on_disconnect},
)
async def delete(self, thread_id: str, run_id: str) -> None: async def delete(self, thread_id: str, run_id: str) -> None:
"""Delete a run. """Delete a run.
@@ -1965,7 +1946,7 @@ class CronClient:
Example Usage: Example Usage:
cron_run = client.crons.create( cron_run = await client.crons.create(
assistant_id="agent", assistant_id="agent",
schedule="27 15 * * *", schedule="27 15 * * *",
input={"messages": [{"role": "user", "content": "hello!"}]}, input={"messages": [{"role": "user", "content": "hello!"}]},
@@ -2089,12 +2070,7 @@ class StoreClient:
self.http = http self.http = http
async def put_item( async def put_item(
self, self, namespace: Sequence[str], /, key: str, value: dict[str, Any]
namespace: Sequence[str],
/,
key: str,
value: dict[str, Any],
index: Optional[Union[Literal[False], list[str]]] = None,
) -> None: ) -> None:
"""Store or update an item. """Store or update an item.
@@ -2102,7 +2078,6 @@ class StoreClient:
namespace: A list of strings representing the namespace path. namespace: A list of strings representing the namespace path.
key: The unique identifier for the item within the namespace. key: The unique identifier for the item within the namespace.
value: A dictionary containing the item's data. value: A dictionary containing the item's data.
index: Controls search indexing - None (use defaults), False (disable), or list of field paths to index.
Returns: Returns:
None None
@@ -2120,7 +2095,11 @@ class StoreClient:
raise ValueError( raise ValueError(
f"Invalid namespace label '{label}'. Namespace labels cannot contain periods ('.')." f"Invalid namespace label '{label}'. Namespace labels cannot contain periods ('.')."
) )
payload = {"namespace": namespace, "key": key, "value": value, "index": index} payload = {
"namespace": namespace,
"key": key,
"value": value,
}
await self.http.put("/store/items", json=payload) await self.http.put("/store/items", json=payload)
async def get_item(self, namespace: Sequence[str], /, key: str) -> Item: async def get_item(self, namespace: Sequence[str], /, key: str) -> Item:
@@ -2188,7 +2167,6 @@ class StoreClient:
filter: Optional[dict[str, Any]] = None, filter: Optional[dict[str, Any]] = None,
limit: int = 10, limit: int = 10,
offset: int = 0, offset: int = 0,
query: Optional[str] = None,
) -> SearchItemsResponse: ) -> SearchItemsResponse:
"""Search for items within a namespace prefix. """Search for items within a namespace prefix.
@@ -2197,7 +2175,6 @@ class StoreClient:
filter: Optional dictionary of key-value pairs to filter results. filter: Optional dictionary of key-value pairs to filter results.
limit: Maximum number of items to return (default is 10). limit: Maximum number of items to return (default is 10).
offset: Number of items to skip before returning results (default is 0). offset: Number of items to skip before returning results (default is 0).
query: Optional query for natural language search.
Returns: Returns:
List[Item]: A list of items matching the search criteria. List[Item]: A list of items matching the search criteria.
@@ -2235,7 +2212,6 @@ class StoreClient:
"filter": filter, "filter": filter,
"limit": limit, "limit": limit,
"offset": offset, "offset": offset,
"query": query,
} }
return await self.http.post("/store/items/search", json=_provided_vals(payload)) return await self.http.post("/store/items/search", json=_provided_vals(payload))
@@ -3321,7 +3297,6 @@ class SyncRunsClient:
assistant_id: str, assistant_id: str,
*, *,
input: Optional[dict] = None, input: Optional[dict] = None,
command: Optional[Command] = None,
stream_mode: Union[StreamMode, Sequence[StreamMode]] = "values", stream_mode: Union[StreamMode, Sequence[StreamMode]] = "values",
stream_subgraphs: bool = False, stream_subgraphs: bool = False,
metadata: Optional[dict] = None, metadata: Optional[dict] = None,
@@ -3345,7 +3320,6 @@ class SyncRunsClient:
assistant_id: str, assistant_id: str,
*, *,
input: Optional[dict] = None, input: Optional[dict] = None,
command: Optional[Command] = None,
stream_mode: Union[StreamMode, Sequence[StreamMode]] = "values", stream_mode: Union[StreamMode, Sequence[StreamMode]] = "values",
stream_subgraphs: bool = False, stream_subgraphs: bool = False,
metadata: Optional[dict] = None, metadata: Optional[dict] = None,
@@ -3366,7 +3340,6 @@ class SyncRunsClient:
assistant_id: str, assistant_id: str,
*, *,
input: Optional[dict] = None, input: Optional[dict] = None,
command: Optional[Command] = None,
stream_mode: Union[StreamMode, Sequence[StreamMode]] = "values", stream_mode: Union[StreamMode, Sequence[StreamMode]] = "values",
stream_subgraphs: bool = False, stream_subgraphs: bool = False,
metadata: Optional[dict] = None, metadata: Optional[dict] = None,
@@ -3391,7 +3364,6 @@ class SyncRunsClient:
assistant_id: The assistant ID or graph name to stream from. assistant_id: The assistant ID or graph name to stream from.
If using graph name, will default to first assistant created from that graph. If using graph name, will default to first assistant created from that graph.
input: The input to the graph. input: The input to the graph.
command: The command to execute.
stream_mode: The stream mode(s) to use. stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs. stream_subgraphs: Whether to stream output from subgraphs.
metadata: Metadata to assign to the run. metadata: Metadata to assign to the run.
@@ -3442,7 +3414,6 @@ class SyncRunsClient:
""" # noqa: E501 """ # noqa: E501
payload = { payload = {
"input": input, "input": input,
"command": command,
"config": config, "config": config,
"metadata": metadata, "metadata": metadata,
"stream_mode": stream_mode, "stream_mode": stream_mode,
@@ -3476,7 +3447,6 @@ class SyncRunsClient:
assistant_id: str, assistant_id: str,
*, *,
input: Optional[dict] = None, input: Optional[dict] = None,
command: Optional[Command] = None,
stream_mode: Union[StreamMode, Sequence[StreamMode]] = "values", stream_mode: Union[StreamMode, Sequence[StreamMode]] = "values",
stream_subgraphs: bool = False, stream_subgraphs: bool = False,
metadata: Optional[dict] = None, metadata: Optional[dict] = None,
@@ -3496,7 +3466,6 @@ class SyncRunsClient:
assistant_id: str, assistant_id: str,
*, *,
input: Optional[dict] = None, input: Optional[dict] = None,
command: Optional[Command] = None,
stream_mode: Union[StreamMode, Sequence[StreamMode]] = "values", stream_mode: Union[StreamMode, Sequence[StreamMode]] = "values",
stream_subgraphs: bool = False, stream_subgraphs: bool = False,
metadata: Optional[dict] = None, metadata: Optional[dict] = None,
@@ -3517,7 +3486,6 @@ class SyncRunsClient:
assistant_id: str, assistant_id: str,
*, *,
input: Optional[dict] = None, input: Optional[dict] = None,
command: Optional[Command] = None,
stream_mode: Union[StreamMode, Sequence[StreamMode]] = "values", stream_mode: Union[StreamMode, Sequence[StreamMode]] = "values",
stream_subgraphs: bool = False, stream_subgraphs: bool = False,
metadata: Optional[dict] = None, metadata: Optional[dict] = None,
@@ -3540,7 +3508,6 @@ class SyncRunsClient:
assistant_id: The assistant ID or graph name to stream from. assistant_id: The assistant ID or graph name to stream from.
If using graph name, will default to first assistant created from that graph. If using graph name, will default to first assistant created from that graph.
input: The input to the graph. input: The input to the graph.
command: The command to execute.
stream_mode: The stream mode(s) to use. stream_mode: The stream mode(s) to use.
stream_subgraphs: Whether to stream output from subgraphs. stream_subgraphs: Whether to stream output from subgraphs.
metadata: Metadata to assign to the run. metadata: Metadata to assign to the run.
@@ -3627,7 +3594,6 @@ class SyncRunsClient:
""" # noqa: E501 """ # noqa: E501
payload = { payload = {
"input": input, "input": input,
"command": command,
"stream_mode": stream_mode, "stream_mode": stream_mode,
"stream_subgraphs": stream_subgraphs, "stream_subgraphs": stream_subgraphs,
"config": config, "config": config,
@@ -3665,7 +3631,6 @@ class SyncRunsClient:
assistant_id: str, assistant_id: str,
*, *,
input: Optional[dict] = None, input: Optional[dict] = None,
command: Optional[Command] = None,
metadata: Optional[dict] = None, metadata: Optional[dict] = None,
config: Optional[Config] = None, config: Optional[Config] = None,
checkpoint: Optional[Checkpoint] = None, checkpoint: Optional[Checkpoint] = None,
@@ -3686,7 +3651,6 @@ class SyncRunsClient:
assistant_id: str, assistant_id: str,
*, *,
input: Optional[dict] = None, input: Optional[dict] = None,
command: Optional[Command] = None,
metadata: Optional[dict] = None, metadata: Optional[dict] = None,
config: Optional[Config] = None, config: Optional[Config] = None,
interrupt_before: Optional[Union[All, Sequence[str]]] = None, interrupt_before: Optional[Union[All, Sequence[str]]] = None,
@@ -3704,7 +3668,6 @@ class SyncRunsClient:
assistant_id: str, assistant_id: str,
*, *,
input: Optional[dict] = None, input: Optional[dict] = None,
command: Optional[Command] = None,
metadata: Optional[dict] = None, metadata: Optional[dict] = None,
config: Optional[Config] = None, config: Optional[Config] = None,
checkpoint: Optional[Checkpoint] = None, checkpoint: Optional[Checkpoint] = None,
@@ -3726,7 +3689,6 @@ class SyncRunsClient:
assistant_id: The assistant ID or graph name to run. assistant_id: The assistant ID or graph name to run.
If using graph name, will default to first assistant created from that graph. If using graph name, will default to first assistant created from that graph.
input: The input to the graph. input: The input to the graph.
command: The command to execute.
metadata: Metadata to assign to the run. metadata: Metadata to assign to the run.
config: The configuration for the assistant. config: The configuration for the assistant.
checkpoint: The checkpoint to resume from. checkpoint: The checkpoint to resume from.
@@ -3793,7 +3755,6 @@ class SyncRunsClient:
""" # noqa: E501 """ # noqa: E501
payload = { payload = {
"input": input, "input": input,
"command": command,
"config": config, "config": config,
"metadata": metadata, "metadata": metadata,
"assistant_id": assistant_id, "assistant_id": assistant_id,
@@ -4193,12 +4154,7 @@ class SyncStoreClient:
self.http = http self.http = http
def put_item( def put_item(
self, self, namespace: Sequence[str], /, key: str, value: dict[str, Any]
namespace: Sequence[str],
/,
key: str,
value: dict[str, Any],
index: Optional[Union[Literal[False], list[str]]] = None,
) -> None: ) -> None:
"""Store or update an item. """Store or update an item.
@@ -4206,7 +4162,6 @@ class SyncStoreClient:
namespace: A list of strings representing the namespace path. namespace: A list of strings representing the namespace path.
key: The unique identifier for the item within the namespace. key: The unique identifier for the item within the namespace.
value: A dictionary containing the item's data. value: A dictionary containing the item's data.
index: Controls search indexing - None (use defaults), False (disable), or list of field paths to index.
Returns: Returns:
None None
@@ -4228,7 +4183,6 @@ class SyncStoreClient:
"namespace": namespace, "namespace": namespace,
"key": key, "key": key,
"value": value, "value": value,
"index": index,
} }
self.http.put("/store/items", json=payload) self.http.put("/store/items", json=payload)
@@ -4296,7 +4250,6 @@ class SyncStoreClient:
filter: Optional[dict[str, Any]] = None, filter: Optional[dict[str, Any]] = None,
limit: int = 10, limit: int = 10,
offset: int = 0, offset: int = 0,
query: Optional[str] = None,
) -> SearchItemsResponse: ) -> SearchItemsResponse:
"""Search for items within a namespace prefix. """Search for items within a namespace prefix.
@@ -4305,7 +4258,6 @@ class SyncStoreClient:
filter: Optional dictionary of key-value pairs to filter results. filter: Optional dictionary of key-value pairs to filter results.
limit: Maximum number of items to return (default is 10). limit: Maximum number of items to return (default is 10).
offset: Number of items to skip before returning results (default is 0). offset: Number of items to skip before returning results (default is 0).
query: Optional query for natural language search.
Returns: Returns:
List[Item]: A list of items matching the search criteria. List[Item]: A list of items matching the search criteria.
@@ -4343,7 +4295,6 @@ class SyncStoreClient:
"filter": filter, "filter": filter,
"limit": limit, "limit": limit,
"offset": offset, "offset": offset,
"query": query,
} }
return self.http.post("/store/items/search", json=_provided_vals(payload)) return self.http.post("/store/items/search", json=_provided_vals(payload))
+3 -29
View File
@@ -1,7 +1,7 @@
"""Data models for interacting with the LangGraph API.""" """Data models for interacting with the LangGraph API."""
from datetime import datetime from datetime import datetime
from typing import Any, Dict, Literal, NamedTuple, Optional, Sequence, TypedDict, Union from typing import Any, Literal, NamedTuple, Optional, Sequence, TypedDict, Union
Json = Optional[dict[str, Any]] Json = Optional[dict[str, Any]]
"""Represents a JSON-like structure, which can be None or a dictionary with string keys and any values.""" """Represents a JSON-like structure, which can be None or a dictionary with string keys and any values."""
@@ -176,19 +176,6 @@ class Assistant(AssistantBase):
"""The name of the assistant""" """The name of the assistant"""
class Interrupt(TypedDict, total=False):
"""Represents an interruption in the execution flow."""
value: Any
"""The value associated with the interrupt."""
when: Literal["during"]
"""When the interrupt occurred."""
resumable: bool
"""Whether the interrupt can be resumed."""
ns: Optional[list[str]]
"""Optional namespace for the interrupt."""
class Thread(TypedDict): class Thread(TypedDict):
"""Represents a conversation thread.""" """Represents a conversation thread."""
@@ -204,8 +191,6 @@ class Thread(TypedDict):
"""The status of the thread, one of 'idle', 'busy', 'interrupted'.""" """The status of the thread, one of 'idle', 'busy', 'interrupted'."""
values: Json values: Json
"""The current state of the thread.""" """The current state of the thread."""
interrupts: Dict[str, list[Interrupt]]
"""Interrupts which were thrown in this thread"""
class ThreadTask(TypedDict): class ThreadTask(TypedDict):
@@ -214,7 +199,7 @@ class ThreadTask(TypedDict):
id: str id: str
name: str name: str
error: Optional[str] error: Optional[str]
interrupts: list[Interrupt] interrupts: list[dict]
checkpoint: Optional[Checkpoint] checkpoint: Optional[Checkpoint]
state: Optional["ThreadState"] state: Optional["ThreadState"]
result: Optional[dict[str, Any]] result: Optional[dict[str, Any]]
@@ -340,21 +325,10 @@ class ListNamespaceResponse(TypedDict):
"""A list of namespace paths, where each path is a list of strings.""" """A list of namespace paths, where each path is a list of strings."""
class SearchItem(Item, total=False):
"""Item with an optional relevance score from search operations.
Attributes:
score (Optional[float]): Relevance/similarity score. Included when
searching a compatible store with a natural language query.
"""
score: Optional[float]
class SearchItemsResponse(TypedDict): class SearchItemsResponse(TypedDict):
"""Response structure for searching items.""" """Response structure for searching items."""
items: list[SearchItem] items: list[Item]
"""A list of items matching the search criteria.""" """A list of items matching the search criteria."""
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "langgraph-sdk" name = "langgraph-sdk"
version = "0.1.41" version = "0.1.36"
description = "SDK for interacting with LangGraph API" description = "SDK for interacting with LangGraph API"
authors = [] authors = []
license = "MIT" license = "MIT"
Generated
+100 -100
View File
@@ -13,108 +13,108 @@ files = [
[[package]] [[package]]
name = "aiohttp" name = "aiohttp"
version = "3.10.11" version = "3.10.6"
description = "Async http client/server framework (asyncio)" description = "Async http client/server framework (asyncio)"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "aiohttp-3.10.11-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5077b1a5f40ffa3ba1f40d537d3bec4383988ee51fbba6b74aa8fb1bc466599e"}, {file = "aiohttp-3.10.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:682836fc672972cc3101cc9e30d49c5f7e8f1d010478d46119fe725a4545acfd"},
{file = "aiohttp-3.10.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8d6a14a4d93b5b3c2891fca94fa9d41b2322a68194422bef0dd5ec1e57d7d298"}, {file = "aiohttp-3.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:289fa8a20018d0d5aa9e4b35d899bd51bcb80f0d5f365d9a23e30dac3b79159b"},
{file = "aiohttp-3.10.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ffbfde2443696345e23a3c597049b1dd43049bb65337837574205e7368472177"}, {file = "aiohttp-3.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8617c96a20dd57e7e9d398ff9d04f3d11c4d28b1767273a5b1a018ada5a654d3"},
{file = "aiohttp-3.10.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20b3d9e416774d41813bc02fdc0663379c01817b0874b932b81c7f777f67b217"}, {file = "aiohttp-3.10.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bdbeff1b062751c2a2a55b171f7050fb7073633c699299d042e962aacdbe1a07"},
{file = "aiohttp-3.10.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b943011b45ee6bf74b22245c6faab736363678e910504dd7531a58c76c9015a"}, {file = "aiohttp-3.10.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ea35d849cdd4a9268f910bff4497baebbc1aa3f2f625fd8ccd9ac99c860c621"},
{file = "aiohttp-3.10.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48bc1d924490f0d0b3658fe5c4b081a4d56ebb58af80a6729d4bd13ea569797a"}, {file = "aiohttp-3.10.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:473961b3252f3b949bb84873d6e268fb6d8aa0ccc6eb7404fa58c76a326bb8e1"},
{file = "aiohttp-3.10.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e12eb3f4b1f72aaaf6acd27d045753b18101524f72ae071ae1c91c1cd44ef115"}, {file = "aiohttp-3.10.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d2665c5df629eb2f981dab244c01bfa6cdc185f4ffa026639286c4d56fafb54"},
{file = "aiohttp-3.10.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f14ebc419a568c2eff3c1ed35f634435c24ead2fe19c07426af41e7adb68713a"}, {file = "aiohttp-3.10.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25d92f794f1332f656e3765841fc2b7ad5c26c3f3d01e8949eeb3495691cf9f4"},
{file = "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:72b191cdf35a518bfc7ca87d770d30941decc5aaf897ec8b484eb5cc8c7706f3"}, {file = "aiohttp-3.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9bd6b2033993d5ae80883bb29b83fb2b432270bbe067c2f53cc73bb57c46065f"},
{file = "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5ab2328a61fdc86424ee540d0aeb8b73bbcad7351fb7cf7a6546fc0bcffa0038"}, {file = "aiohttp-3.10.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d7f408c43f5e75ea1edc152fb375e8f46ef916f545fb66d4aebcbcfad05e2796"},
{file = "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aa93063d4af05c49276cf14e419550a3f45258b6b9d1f16403e777f1addf4519"}, {file = "aiohttp-3.10.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:cf8b8560aa965f87bf9c13bf9fed7025993a155ca0ce8422da74bf46d18c2f5f"},
{file = "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:30283f9d0ce420363c24c5c2421e71a738a2155f10adbb1a11a4d4d6d2715cfc"}, {file = "aiohttp-3.10.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14477c4e52e2f17437b99893fd220ffe7d7ee41df5ebf931a92b8ca82e6fd094"},
{file = "aiohttp-3.10.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e5358addc8044ee49143c546d2182c15b4ac3a60be01c3209374ace05af5733d"}, {file = "aiohttp-3.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb138fbf9f53928e779650f5ed26d0ea1ed8b2cab67f0ea5d63afa09fdc07593"},
{file = "aiohttp-3.10.11-cp310-cp310-win32.whl", hash = "sha256:e1ffa713d3ea7cdcd4aea9cddccab41edf6882fa9552940344c44e59652e1120"}, {file = "aiohttp-3.10.6-cp310-cp310-win32.whl", hash = "sha256:9843d683b8756971797be171ead21511d2215a2d6e3c899c6e3107fbbe826791"},
{file = "aiohttp-3.10.11-cp310-cp310-win_amd64.whl", hash = "sha256:778cbd01f18ff78b5dd23c77eb82987ee4ba23408cbed233009fd570dda7e674"}, {file = "aiohttp-3.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:f8b8e49fe02f744d38352daca1dbef462c3874900bd8166516f6ea8e82b5aacf"},
{file = "aiohttp-3.10.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:80ff08556c7f59a7972b1e8919f62e9c069c33566a6d28586771711e0eea4f07"}, {file = "aiohttp-3.10.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52e54fd776ad0da1006708762213b079b154644db54bcfc62f06eaa5b896402"},
{file = "aiohttp-3.10.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c8f96e9ee19f04c4914e4e7a42a60861066d3e1abf05c726f38d9d0a466e695"}, {file = "aiohttp-3.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:995ab1a238fd0d19dc65f2d222e5eb064e409665c6426a3e51d5101c1979ee84"},
{file = "aiohttp-3.10.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fb8601394d537da9221947b5d6e62b064c9a43e88a1ecd7414d21a1a6fba9c24"}, {file = "aiohttp-3.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0749c4d5a08a802dd66ecdf59b2df4d76b900004017468a7bb736c3b5a3dd902"},
{file = "aiohttp-3.10.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ea224cf7bc2d8856d6971cea73b1d50c9c51d36971faf1abc169a0d5f85a382"}, {file = "aiohttp-3.10.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e05b39158f2af0e2438cc2075cfc271f4ace0c3cc4a81ec95b27a0432e161951"},
{file = "aiohttp-3.10.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db9503f79e12d5d80b3efd4d01312853565c05367493379df76d2674af881caa"}, {file = "aiohttp-3.10.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f196c970db2dcde4f24317e06615363349dc357cf4d7a3b0716c20ac6d7bcd"},
{file = "aiohttp-3.10.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0f449a50cc33f0384f633894d8d3cd020e3ccef81879c6e6245c3c375c448625"}, {file = "aiohttp-3.10.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:47647c8af04a70e07a2462931b0eba63146a13affa697afb4ecbab9d03a480ce"},
{file = "aiohttp-3.10.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82052be3e6d9e0c123499127782a01a2b224b8af8c62ab46b3f6197035ad94e9"}, {file = "aiohttp-3.10.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:669c0efe7e99f6d94d63274c06344bd0e9c8daf184ce5602a29bc39e00a18720"},
{file = "aiohttp-3.10.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20063c7acf1eec550c8eb098deb5ed9e1bb0521613b03bb93644b810986027ac"}, {file = "aiohttp-3.10.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9721cdd83a994225352ca84cd537760d41a9da3c0eacb3ff534747ab8fba6d0"},
{file = "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:489cced07a4c11488f47aab1f00d0c572506883f877af100a38f1fedaa884c3a"}, {file = "aiohttp-3.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0b82c8ebed66ce182893e7c0b6b60ba2ace45b1df104feb52380edae266a4850"},
{file = "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ea9b3bab329aeaa603ed3bf605f1e2a6f36496ad7e0e1aa42025f368ee2dc07b"}, {file = "aiohttp-3.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b169f8e755e541b72e714b89a831b315bbe70db44e33fead28516c9e13d5f931"},
{file = "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ca117819d8ad113413016cb29774b3f6d99ad23c220069789fc050267b786c16"}, {file = "aiohttp-3.10.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0be3115753baf8b4153e64f9aa7bf6c0c64af57979aa900c31f496301b374570"},
{file = "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2dfb612dcbe70fb7cdcf3499e8d483079b89749c857a8f6e80263b021745c730"}, {file = "aiohttp-3.10.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e1f80cd17d81a404b6e70ef22bfe1870bafc511728397634ad5f5efc8698df56"},
{file = "aiohttp-3.10.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9b615d3da0d60e7d53c62e22b4fd1c70f4ae5993a44687b011ea3a2e49051b8"}, {file = "aiohttp-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6419728b08fb6380c66a470d2319cafcec554c81780e2114b7e150329b9a9a7f"},
{file = "aiohttp-3.10.11-cp311-cp311-win32.whl", hash = "sha256:29103f9099b6068bbdf44d6a3d090e0a0b2be6d3c9f16a070dd9d0d910ec08f9"}, {file = "aiohttp-3.10.6-cp311-cp311-win32.whl", hash = "sha256:bd294dcdc1afdc510bb51d35444003f14e327572877d016d576ac3b9a5888a27"},
{file = "aiohttp-3.10.11-cp311-cp311-win_amd64.whl", hash = "sha256:236b28ceb79532da85d59aa9b9bf873b364e27a0acb2ceaba475dc61cffb6f3f"}, {file = "aiohttp-3.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:bf861da9a43d282d6dd9dcd64c23a0fccf2c5aa5cd7c32024513c8c79fb69de3"},
{file = "aiohttp-3.10.11-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7480519f70e32bfb101d71fb9a1f330fbd291655a4c1c922232a48c458c52710"}, {file = "aiohttp-3.10.6-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:2708baccdc62f4b1251e59c2aac725936a900081f079b88843dabcab0feeeb27"},
{file = "aiohttp-3.10.11-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f65267266c9aeb2287a6622ee2bb39490292552f9fbf851baabc04c9f84e048d"}, {file = "aiohttp-3.10.6-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7475da7a5e2ccf1a1c86c8fee241e277f4874c96564d06f726d8df8e77683ef7"},
{file = "aiohttp-3.10.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7400a93d629a0608dc1d6c55f1e3d6e07f7375745aaa8bd7f085571e4d1cee97"}, {file = "aiohttp-3.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:02108326574ff60267b7b35b17ac5c0bbd0008ccb942ce4c48b657bb90f0b8aa"},
{file = "aiohttp-3.10.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f34b97e4b11b8d4eb2c3a4f975be626cc8af99ff479da7de49ac2c6d02d35725"}, {file = "aiohttp-3.10.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:029a019627b37fa9eac5c75cc54a6bb722c4ebbf5a54d8c8c0fb4dd8facf2702"},
{file = "aiohttp-3.10.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e7b825da878464a252ccff2958838f9caa82f32a8dbc334eb9b34a026e2c636"}, {file = "aiohttp-3.10.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a637d387db6fdad95e293fab5433b775fd104ae6348d2388beaaa60d08b38c4"},
{file = "aiohttp-3.10.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9f92a344c50b9667827da308473005f34767b6a2a60d9acff56ae94f895f385"}, {file = "aiohttp-3.10.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dc1a16f3fc1944c61290d33c88dc3f09ba62d159b284c38c5331868425aca426"},
{file = "aiohttp-3.10.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc6f1ab987a27b83c5268a17218463c2ec08dbb754195113867a27b166cd6087"}, {file = "aiohttp-3.10.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81b292f37969f9cc54f4643f0be7dacabf3612b3b4a65413661cf6c350226787"},
{file = "aiohttp-3.10.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1dc0f4ca54842173d03322793ebcf2c8cc2d34ae91cc762478e295d8e361e03f"}, {file = "aiohttp-3.10.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0754690a3a26e819173a34093798c155bafb21c3c640bff13be1afa1e9d421f9"},
{file = "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7ce6a51469bfaacff146e59e7fb61c9c23006495d11cc24c514a455032bcfa03"}, {file = "aiohttp-3.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:164ecd32e65467d86843dbb121a6666c3deb23b460e3f8aefdcaacae79eb718a"},
{file = "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:aad3cd91d484d065ede16f3cf15408254e2469e3f613b241a1db552c5eb7ab7d"}, {file = "aiohttp-3.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:438c5863feb761f7ca3270d48c292c334814459f61cc12bab5ba5b702d7c9e56"},
{file = "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f4df4b8ca97f658c880fb4b90b1d1ec528315d4030af1ec763247ebfd33d8b9a"}, {file = "aiohttp-3.10.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ba18573bb1de1063d222f41de64a0d3741223982dcea863b3f74646faf618ec7"},
{file = "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2e4e18a0a2d03531edbc06c366954e40a3f8d2a88d2b936bbe78a0c75a3aab3e"}, {file = "aiohttp-3.10.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c82a94ddec996413a905f622f3da02c4359952aab8d817c01cf9915419525e95"},
{file = "aiohttp-3.10.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ce66780fa1a20e45bc753cda2a149daa6dbf1561fc1289fa0c308391c7bc0a4"}, {file = "aiohttp-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:92351aa5363fc3c1f872ca763f86730ced32b01607f0c9662b1fa711087968d0"},
{file = "aiohttp-3.10.11-cp312-cp312-win32.whl", hash = "sha256:a919c8957695ea4c0e7a3e8d16494e3477b86f33067478f43106921c2fef15bb"}, {file = "aiohttp-3.10.6-cp312-cp312-win32.whl", hash = "sha256:3e15e33bfc73fa97c228f72e05e8795e163a693fd5323549f49367c76a6e5883"},
{file = "aiohttp-3.10.11-cp312-cp312-win_amd64.whl", hash = "sha256:b5e29706e6389a2283a91611c91bf24f218962717c8f3b4e528ef529d112ee27"}, {file = "aiohttp-3.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:fe517113fe4d35d9072b826c3e147d63c5f808ca8167d450b4f96c520c8a1d8d"},
{file = "aiohttp-3.10.11-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:703938e22434d7d14ec22f9f310559331f455018389222eed132808cd8f44127"}, {file = "aiohttp-3.10.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:482f74057ea13d387a7549d7a7ecb60e45146d15f3e58a2d93a0ad2d5a8457cd"},
{file = "aiohttp-3.10.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9bc50b63648840854e00084c2b43035a62e033cb9b06d8c22b409d56eb098413"}, {file = "aiohttp-3.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:03fa40d1450ee5196e843315ddf74a51afc7e83d489dbfc380eecefea74158b1"},
{file = "aiohttp-3.10.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f0463bf8b0754bc744e1feb61590706823795041e63edf30118a6f0bf577461"}, {file = "aiohttp-3.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e52e59ed5f4cc3a3acfe2a610f8891f216f486de54d95d6600a2c9ba1581f4d"},
{file = "aiohttp-3.10.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6c6dec398ac5a87cb3a407b068e1106b20ef001c344e34154616183fe684288"}, {file = "aiohttp-3.10.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b3935a22c9e41a8000d90588bed96cf395ef572dbb409be44c6219c61d900d"},
{file = "aiohttp-3.10.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcaf2d79104d53d4dcf934f7ce76d3d155302d07dae24dff6c9fffd217568067"}, {file = "aiohttp-3.10.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bef1480ee50f75abcfcb4b11c12de1005968ca9d0172aec4a5057ba9f2b644f"},
{file = "aiohttp-3.10.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25fd5470922091b5a9aeeb7e75be609e16b4fba81cdeaf12981393fb240dd10e"}, {file = "aiohttp-3.10.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:671745ea7db19693ce867359d503772177f0b20fa8f6ee1e74e00449f4c4151d"},
{file = "aiohttp-3.10.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbde2ca67230923a42161b1f408c3992ae6e0be782dca0c44cb3206bf330dee1"}, {file = "aiohttp-3.10.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b50b367308ca8c12e0b50cba5773bc9abe64c428d3fd2bbf5cd25aab37c77bf"},
{file = "aiohttp-3.10.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:249c8ff8d26a8b41a0f12f9df804e7c685ca35a207e2410adbd3e924217b9006"}, {file = "aiohttp-3.10.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a504d7cdb431a777d05a124fd0b21efb94498efa743103ea01b1e3136d2e4fb"},
{file = "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:878ca6a931ee8c486a8f7b432b65431d095c522cbeb34892bee5be97b3481d0f"}, {file = "aiohttp-3.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66bc81361131763660b969132a22edce2c4d184978ba39614e8f8f95db5c95f8"},
{file = "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8663f7777ce775f0413324be0d96d9730959b2ca73d9b7e2c2c90539139cbdd6"}, {file = "aiohttp-3.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:27cf19a38506e2e9f12fc17e55f118f04897b0a78537055d93a9de4bf3022e3d"},
{file = "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd3f10b01f0c31481fba8d302b61603a2acb37b9d30e1d14e0f5a58b7b18a31"}, {file = "aiohttp-3.10.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3468b39f977a11271517c6925b226720e148311039a380cc9117b1e2258a721f"},
{file = "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4e8d8aad9402d3aa02fdc5ca2fe68bcb9fdfe1f77b40b10410a94c7f408b664d"}, {file = "aiohttp-3.10.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9d26da22a793dfd424be1050712a70c0afd96345245c29aced1e35dbace03413"},
{file = "aiohttp-3.10.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38e3c4f80196b4f6c3a85d134a534a56f52da9cb8d8e7af1b79a32eefee73a00"}, {file = "aiohttp-3.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:844d48ff9173d0b941abed8b2ea6a412f82b56d9ab1edb918c74000c15839362"},
{file = "aiohttp-3.10.11-cp313-cp313-win32.whl", hash = "sha256:fc31820cfc3b2863c6e95e14fcf815dc7afe52480b4dc03393c4873bb5599f71"}, {file = "aiohttp-3.10.6-cp313-cp313-win32.whl", hash = "sha256:2dd56e3c43660ed3bea67fd4c5025f1ac1f9ecf6f0b991a6e5efe2e678c490c5"},
{file = "aiohttp-3.10.11-cp313-cp313-win_amd64.whl", hash = "sha256:4996ff1345704ffdd6d75fb06ed175938c133425af616142e7187f28dc75f14e"}, {file = "aiohttp-3.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:c91781d969fbced1993537f45efe1213bd6fccb4b37bfae2a026e20d6fbed206"},
{file = "aiohttp-3.10.11-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:74baf1a7d948b3d640badeac333af581a367ab916b37e44cf90a0334157cdfd2"}, {file = "aiohttp-3.10.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:4407a80bca3e694f2d2a523058e20e1f9f98a416619e04f6dc09dc910352ac8b"},
{file = "aiohttp-3.10.11-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:473aebc3b871646e1940c05268d451f2543a1d209f47035b594b9d4e91ce8339"}, {file = "aiohttp-3.10.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1cb045ec5961f51af3e2c08cd6fe523f07cc6e345033adee711c49b7b91bb954"},
{file = "aiohttp-3.10.11-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c2f746a6968c54ab2186574e15c3f14f3e7f67aef12b761e043b33b89c5b5f95"}, {file = "aiohttp-3.10.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4fabdcdc781a36b8fd7b2ca9dea8172f29a99e11d00ca0f83ffeb50958da84a1"},
{file = "aiohttp-3.10.11-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d110cabad8360ffa0dec8f6ec60e43286e9d251e77db4763a87dcfe55b4adb92"}, {file = "aiohttp-3.10.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79a9f42efcc2681790595ab3d03c0e52d01edc23a0973ea09f0dc8d295e12b8e"},
{file = "aiohttp-3.10.11-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0099c7d5d7afff4202a0c670e5b723f7718810000b4abcbc96b064129e64bc7"}, {file = "aiohttp-3.10.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cca776a440795db437d82c07455761c85bbcf3956221c3c23b8c93176c278ce7"},
{file = "aiohttp-3.10.11-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0316e624b754dbbf8c872b62fe6dcb395ef20c70e59890dfa0de9eafccd2849d"}, {file = "aiohttp-3.10.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5582de171f0898139cf51dd9fcdc79b848e28d9abd68e837f0803fc9f30807b1"},
{file = "aiohttp-3.10.11-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a5f7ab8baf13314e6b2485965cbacb94afff1e93466ac4d06a47a81c50f9cca"}, {file = "aiohttp-3.10.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:370e2d47575c53c817ee42a18acc34aad8da4dbdaac0a6c836d58878955f1477"},
{file = "aiohttp-3.10.11-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c891011e76041e6508cbfc469dd1a8ea09bc24e87e4c204e05f150c4c455a5fa"}, {file = "aiohttp-3.10.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:444d1704e2af6b30766debed9be8a795958029e552fe77551355badb1944012c"},
{file = "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9208299251370ee815473270c52cd3f7069ee9ed348d941d574d1457d2c73e8b"}, {file = "aiohttp-3.10.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:40271a2a375812967401c9ca8077de9368e09a43a964f4dce0ff603301ec9358"},
{file = "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:459f0f32c8356e8125f45eeff0ecf2b1cb6db1551304972702f34cd9e6c44658"}, {file = "aiohttp-3.10.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f3af26f86863fad12e25395805bb0babbd49d512806af91ec9708a272b696248"},
{file = "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:14cdc8c1810bbd4b4b9f142eeee23cda528ae4e57ea0923551a9af4820980e39"}, {file = "aiohttp-3.10.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4752df44df48fd42b80f51d6a97553b482cda1274d9dc5df214a3a1aa5d8f018"},
{file = "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:971aa438a29701d4b34e4943e91b5e984c3ae6ccbf80dd9efaffb01bd0b243a9"}, {file = "aiohttp-3.10.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:2cd5290ab66cfca2f90045db2cc6434c1f4f9fbf97c9f1c316e785033782e7d2"},
{file = "aiohttp-3.10.11-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:9a309c5de392dfe0f32ee57fa43ed8fc6ddf9985425e84bd51ed66bb16bce3a7"}, {file = "aiohttp-3.10.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3427031064b0d5c95647e6369c4aa3c556402f324a3e18107cb09517abe5f962"},
{file = "aiohttp-3.10.11-cp38-cp38-win32.whl", hash = "sha256:9ec1628180241d906a0840b38f162a3215114b14541f1a8711c368a8739a9be4"}, {file = "aiohttp-3.10.6-cp38-cp38-win32.whl", hash = "sha256:614fc21e86adc28e4165a6391f851a6da6e9cbd7bb232d0df7718b453a89ee98"},
{file = "aiohttp-3.10.11-cp38-cp38-win_amd64.whl", hash = "sha256:9c6e0ffd52c929f985c7258f83185d17c76d4275ad22e90aa29f38e211aacbec"}, {file = "aiohttp-3.10.6-cp38-cp38-win_amd64.whl", hash = "sha256:58c5d7318a136a3874c78717dd6de57519bc64f6363c5827c2b1cb775bea71dd"},
{file = "aiohttp-3.10.11-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cdc493a2e5d8dc79b2df5bec9558425bcd39aff59fc949810cbd0832e294b106"}, {file = "aiohttp-3.10.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5db26bbca8e7968c4c977a0c640e0b9ce7224e1f4dcafa57870dc6ee28e27de6"},
{file = "aiohttp-3.10.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b3e70f24e7d0405be2348da9d5a7836936bf3a9b4fd210f8c37e8d48bc32eca6"}, {file = "aiohttp-3.10.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3fb4216e3ec0dbc01db5ba802f02ed78ad8f07121be54eb9e918448cc3f61b7c"},
{file = "aiohttp-3.10.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:968b8fb2a5eee2770eda9c7b5581587ef9b96fbdf8dcabc6b446d35ccc69df01"}, {file = "aiohttp-3.10.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a976ef488f26e224079deb3d424f29144c6d5ba4ded313198169a8af8f47fb82"},
{file = "aiohttp-3.10.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deef4362af9493d1382ef86732ee2e4cbc0d7c005947bd54ad1a9a16dd59298e"}, {file = "aiohttp-3.10.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a86610174de8a85a920e956e2d4f9945e7da89f29a00e95ac62a4a414c4ef4e"},
{file = "aiohttp-3.10.11-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:686b03196976e327412a1b094f4120778c7c4b9cff9bce8d2fdfeca386b89829"}, {file = "aiohttp-3.10.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:217791c6a399cc4f2e6577bb44344cba1f5714a2aebf6a0bea04cfa956658284"},
{file = "aiohttp-3.10.11-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3bf6d027d9d1d34e1c2e1645f18a6498c98d634f8e373395221121f1c258ace8"}, {file = "aiohttp-3.10.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba3662d41abe2eab0eeec7ee56f33ef4e0b34858f38abf24377687f9e1fb00a5"},
{file = "aiohttp-3.10.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:099fd126bf960f96d34a760e747a629c27fb3634da5d05c7ef4d35ef4ea519fc"}, {file = "aiohttp-3.10.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4dfa5ad4bce9ca30a76117fbaa1c1decf41ebb6c18a4e098df44298941566f9"},
{file = "aiohttp-3.10.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c73c4d3dae0b4644bc21e3de546530531d6cdc88659cdeb6579cd627d3c206aa"}, {file = "aiohttp-3.10.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0009258e97502936d3bd5bf2ced15769629097d0abb81e6495fba1047824fe0"},
{file = "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0c5580f3c51eea91559db3facd45d72e7ec970b04528b4709b1f9c2555bd6d0b"}, {file = "aiohttp-3.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0a75d5c9fb4f06c41d029ae70ad943c3a844c40c0a769d12be4b99b04f473d3d"},
{file = "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fdf6429f0caabfd8a30c4e2eaecb547b3c340e4730ebfe25139779b9815ba138"}, {file = "aiohttp-3.10.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8198b7c002aae2b40b2d16bfe724b9a90bcbc9b78b2566fc96131ef4e382574d"},
{file = "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d97187de3c276263db3564bb9d9fad9e15b51ea10a371ffa5947a5ba93ad6777"}, {file = "aiohttp-3.10.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4611db8c907f90fe86be112efdc2398cd7b4c8eeded5a4f0314b70fdea8feab0"},
{file = "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:0acafb350cfb2eba70eb5d271f55e08bd4502ec35e964e18ad3e7d34d71f7261"}, {file = "aiohttp-3.10.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ff99ae06eef85c7a565854826114ced72765832ee16c7e3e766c5e4c5b98d20e"},
{file = "aiohttp-3.10.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c13ed0c779911c7998a58e7848954bd4d63df3e3575f591e321b19a2aec8df9f"}, {file = "aiohttp-3.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7641920bdcc7cd2d3ddfb8bb9133a6c9536b09dbd49490b79e125180b2d25b93"},
{file = "aiohttp-3.10.11-cp39-cp39-win32.whl", hash = "sha256:22b7c540c55909140f63ab4f54ec2c20d2635c0289cdd8006da46f3327f971b9"}, {file = "aiohttp-3.10.6-cp39-cp39-win32.whl", hash = "sha256:e2e7d5591ea868d5ec82b90bbeb366a198715672841d46281b623e23079593db"},
{file = "aiohttp-3.10.11-cp39-cp39-win_amd64.whl", hash = "sha256:7b26b1551e481012575dab8e3727b16fe7dd27eb2711d2e63ced7368756268fb"}, {file = "aiohttp-3.10.6-cp39-cp39-win_amd64.whl", hash = "sha256:b504c08c45623bf5c7ca41be380156d925f00199b3970efd758aef4a77645feb"},
{file = "aiohttp-3.10.11.tar.gz", hash = "sha256:9dc2b8f3dcab2e39e0fa309c8da50c3b55e6f34ab25f1a71d3288f24924d33a7"}, {file = "aiohttp-3.10.6.tar.gz", hash = "sha256:d2578ef941be0c2ba58f6f421a703527d08427237ed45ecb091fed6f83305336"},
] ]
[package.dependencies] [package.dependencies]
aiohappyeyeballs = ">=2.3.0" aiohappyeyeballs = ">=2.3.0"
aiosignal = ">=1.1.2" aiosignal = ">=1.1.2"
async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""} async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""}
attrs = ">=17.3.0" attrs = ">=17.3.0"
frozenlist = ">=1.1.1" frozenlist = ">=1.1.1"
multidict = ">=4.5,<7.0" multidict = ">=4.5,<7.0"
@@ -1292,18 +1292,18 @@ tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipyth
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.115.5" version = "0.115.0"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "fastapi-0.115.5-py3-none-any.whl", hash = "sha256:596b95adbe1474da47049e802f9a65ab2ffa9c2b07e7efee70eb8a66c9f2f796"}, {file = "fastapi-0.115.0-py3-none-any.whl", hash = "sha256:17ea427674467486e997206a5ab25760f6b09e069f099b96f5b55a32fb6f1631"},
{file = "fastapi-0.115.5.tar.gz", hash = "sha256:0e7a4d0dc0d01c68df21887cce0945e72d3c48b9f4f79dfe7a7d53aa08fbb289"}, {file = "fastapi-0.115.0.tar.gz", hash = "sha256:f93b4ca3529a8ebc6fc3fcf710e5efa8de3df9b41570958abf1d97d843138004"},
] ]
[package.dependencies] [package.dependencies]
pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
starlette = ">=0.40.0,<0.42.0" starlette = ">=0.37.2,<0.39.0"
typing-extensions = ">=4.8.0" typing-extensions = ">=4.8.0"
[package.extras] [package.extras]
@@ -6446,13 +6446,13 @@ tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"]
[[package]] [[package]]
name = "starlette" name = "starlette"
version = "0.40.0" version = "0.38.6"
description = "The little ASGI library that shines." description = "The little ASGI library that shines."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "starlette-0.40.0-py3-none-any.whl", hash = "sha256:c494a22fae73805376ea6bf88439783ecfba9aac88a43911b48c653437e784c4"}, {file = "starlette-0.38.6-py3-none-any.whl", hash = "sha256:4517a1409e2e73ee4951214ba012052b9e16f60e90d73cfb06192c19203bbb05"},
{file = "starlette-0.40.0.tar.gz", hash = "sha256:1a3139688fb298ce5e2d661d37046a66ad996ce94be4d4983be019a23a04ea35"}, {file = "starlette-0.38.6.tar.gz", hash = "sha256:863a1588f5574e70a821dadefb41e4881ea451a47a3cd1b4df359d4ffefe5ead"},
] ]
[package.dependencies] [package.dependencies]