mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-18 13:45:44 +02:00
Compare commits
131
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93e4c8cc1f | ||
|
|
b7e441d781 | ||
|
|
ccd8920eef | ||
|
|
0c379d6cc7 | ||
|
|
1f745ca017 | ||
|
|
aa4fea48dd | ||
|
|
0f0e31df24 | ||
|
|
a275ab26d3 | ||
|
|
b3bf4dd43c | ||
|
|
b7fd391811 | ||
|
|
cf961a286c | ||
|
|
4b83103cf2 | ||
|
|
1a46537c3a | ||
|
|
759a712f57 | ||
|
|
9f73dfa8d5 | ||
|
|
4459952e72 | ||
|
|
8ef82f3578 | ||
|
|
73e3f5a5b0 | ||
|
|
a54587cff5 | ||
|
|
63ea71548b | ||
|
|
f32cf5e984 | ||
|
|
d1aaa9de8c | ||
|
|
f40a2d71ec | ||
|
|
b5a9e9da55 | ||
|
|
1eeb90ae0d | ||
|
|
cd875291ad | ||
|
|
e9cd216887 | ||
|
|
7651f1ab1c | ||
|
|
1a492f727c | ||
|
|
6caaa8cea7 | ||
|
|
f028984b2e | ||
|
|
085395c824 | ||
|
|
19a6e894eb | ||
|
|
5570121c83 | ||
|
|
257e44ccb4 | ||
|
|
dad0f39fa4 | ||
|
|
7a326ef768 | ||
|
|
797b919cf9 | ||
|
|
574ffb02fc | ||
|
|
771b9b28cd | ||
|
|
dcc2617396 | ||
|
|
df70e91dae | ||
|
|
b4b3ac6f57 | ||
|
|
78e6b36b1a | ||
|
|
d457ad3cc2 | ||
|
|
5c7a6689af | ||
|
|
fb01d65dc0 | ||
|
|
ea5ccd7a80 | ||
|
|
d52bb911a4 | ||
|
|
89a739e12b | ||
|
|
0fdf3c9daf | ||
|
|
90eab07ded | ||
|
|
962a969fba | ||
|
|
c89e84fb6a | ||
|
|
3ff1f81333 | ||
|
|
851e6d1d4c | ||
|
|
e5e659c590 | ||
|
|
8db6a78ad9 | ||
|
|
9ab5fbc0f8 | ||
|
|
c141f0fdf0 | ||
|
|
830557d6b7 | ||
|
|
e5b00cdd1e | ||
|
|
8eea7ac401 | ||
|
|
c322f7ffa6 | ||
|
|
e6c83abecd | ||
|
|
a8db511e24 | ||
|
|
84d33f9621 | ||
|
|
9220049b35 | ||
|
|
879df6b52c | ||
|
|
9b8bf70d9e | ||
|
|
aca67107c1 | ||
|
|
5fa196ab38 | ||
|
|
584d9271ce | ||
|
|
1bee33db3a | ||
|
|
a203ddecf7 | ||
|
|
5e3c326424 | ||
|
|
86407aa6e8 | ||
|
|
7a80d6cb87 | ||
|
|
70f323779e | ||
|
|
23d5162945 | ||
|
|
9d755f54e4 | ||
|
|
75cccc4fc4 | ||
|
|
dd010e9230 | ||
|
|
2d87195b59 | ||
|
|
515242d0ba | ||
|
|
3bf92d0b03 | ||
|
|
36b6cd1493 | ||
|
|
0361554fcf | ||
|
|
4332a9515d | ||
|
|
64b99c187a | ||
|
|
afa37d2059 | ||
|
|
b80933c5fb | ||
|
|
d70b659adb | ||
|
|
15f0765d60 | ||
|
|
fe538d4bcb | ||
|
|
4e26a5cf2e | ||
|
|
20f091a277 | ||
|
|
0071bd1e1c | ||
|
|
d36e6ceaaf | ||
|
|
a3feaef2eb | ||
|
|
c6fe26510e | ||
|
|
efbd02a27d | ||
|
|
a91bf116cb | ||
|
|
6a6c3ed84c | ||
|
|
988dd237d2 | ||
|
|
63f5f15c04 | ||
|
|
6fc1c602ab | ||
|
|
2b65308508 | ||
|
|
3cee1d5087 | ||
|
|
2ce2021c39 | ||
|
|
46dd424a7e | ||
|
|
363c6e2e4c | ||
|
|
784821705b | ||
|
|
65172c2a43 | ||
|
|
f4a9d17d24 | ||
|
|
f416480e9d | ||
|
|
61e47cb137 | ||
|
|
d4bbb66963 | ||
|
|
45b5f386e5 | ||
|
|
a1ec55abc5 | ||
|
|
f08155d60b | ||
|
|
24b16908b7 | ||
|
|
c1c2ce8f1b | ||
|
|
3efd4f3406 | ||
|
|
f122ae2eb1 | ||
|
|
05791f5dfc | ||
|
|
416dfe95da | ||
|
|
2d6ddd0a1d | ||
|
|
253090f34d | ||
|
|
7d80176137 | ||
|
|
ca7da2fc41 |
@@ -7,35 +7,29 @@ body:
|
||||
value: >
|
||||
Thank you for taking the time to file a bug report.
|
||||
|
||||
Use this to report bugs in LangChain.
|
||||
|
||||
If you're not certain that your issue is due to a bug in LangChain, please use [GitHub Discussions](https://github.com/langchain-ai/langchain/discussions)
|
||||
to ask for help with your issue.
|
||||
Use this to report BUGS in LangGraph. For usage questions, feature requests and general design questions, please use [GitHub Discussions](https://github.com/langchain-ai/langgraph/discussions).
|
||||
|
||||
Relevant links to check before filing a bug report to see if your issue has already been reported, fixed or
|
||||
if there's another way to solve your problem:
|
||||
|
||||
[LangGraph documentation](https://langchain-ai.github.io/langgraph/).
|
||||
[LangGraph Github Discussions](https://github.com/langchain-ai/langgraph/discussions),
|
||||
[LangGraph Github Issues](https://github.com/langchain-ai/langgraph/issues),
|
||||
[LangGraph how-to guides](https://langchain-ai.github.io/langgraph/how-tos/).
|
||||
[LangChain documentation with the integrated search](https://python.langchain.com/docs/get_started/introduction),
|
||||
[GitHub search](https://github.com/langchain-ai/langgraph),
|
||||
[LangChain Github Discussions](https://github.com/langchain-ai/langgraph/discussions),
|
||||
[LangChain Github Issues](https://github.com/langchain-ai/langgraph/issues),
|
||||
[LangChain ChatBot](https://chat.langchain.com/)
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Checked other resources
|
||||
description: Please confirm and check all the following options.
|
||||
description: Before submitting this issue, please confirm that you have completed all the steps below by checking each option. These steps help ensure your issue is well-defined, relevant, and actionable.
|
||||
options:
|
||||
- label: I added a very descriptive title to this issue.
|
||||
- label: This is a bug, not a usage question. For questions, please use GitHub Discussions.
|
||||
required: true
|
||||
- label: I searched the [LangGraph](https://langchain-ai.github.io/langgraph/)/LangChain documentation with the integrated search.
|
||||
- label: I added a clear and detailed title that summarizes the issue.
|
||||
required: true
|
||||
- label: I used the GitHub search to find a similar question and didn't find it.
|
||||
- label: I read what a minimal reproducible example is (https://stackoverflow.com/help/minimal-reproducible-example).
|
||||
required: true
|
||||
- label: I am sure that this is a bug in LangGraph/LangChain rather than my code.
|
||||
required: true
|
||||
- label: I am sure this is better as an issue [rather than a GitHub discussion](https://github.com/langchain-ai/langgraph/discussions/new/choose), since this is a LangGraph bug and not a design question.
|
||||
- label: I included a self-contained, minimal example that demonstrates the issue INCLUDING all the relevant imports. The code run AS IS to reproduce the issue.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
@@ -45,14 +39,6 @@ body:
|
||||
label: Example Code
|
||||
description: |
|
||||
Please add a self-contained, [minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example) with your use case.
|
||||
|
||||
If a maintainer can copy it, run it, and see it right away, there's a much higher chance that you'll be able to get help.
|
||||
|
||||
**Important!**
|
||||
|
||||
* Reduce your code to the minimum required to reproduce the issue if possible. This makes it much easier for others to help you.
|
||||
* Avoid screenshots when possible, as they are hard to read and (more importantly) don't allow others to copy-and-paste your code.
|
||||
|
||||
placeholder: |
|
||||
from langgraph.graph import StateGraph
|
||||
|
||||
@@ -92,25 +78,8 @@ body:
|
||||
attributes:
|
||||
label: System Info
|
||||
description: |
|
||||
Please share your system info with us.
|
||||
|
||||
"pip freeze | grep langchain"
|
||||
platform (windows / linux / mac)
|
||||
python version
|
||||
|
||||
OR if you're on a recent version of langchain-core you can paste the output of:
|
||||
|
||||
python -m langchain_core.sys_info
|
||||
placeholder: |
|
||||
"pip freeze | grep langgraph"
|
||||
platform
|
||||
python version
|
||||
|
||||
Alternatively, if you're on a recent version of langchain-core you can paste the output of:
|
||||
|
||||
python -m langchain_core.sys_info
|
||||
|
||||
These will only surface LangChain packages, don't forget to include any other relevant
|
||||
packages you're using (if you're not sure what's relevant, you can paste the entire output of `pip freeze`).
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -42,7 +42,6 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
poetry-version: ${{ env.POETRY_VERSION }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
cache-key: lint-${{ inputs.working-directory }}
|
||||
|
||||
- name: Check Poetry File
|
||||
|
||||
@@ -31,7 +31,6 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
poetry-version: ${{ env.POETRY_VERSION }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
cache-key: test-${{ inputs.working-directory }}
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
env:
|
||||
LANGGRAPH_FF_SEND_V2: ${{ matrix.ff-send-v2 }}
|
||||
run: |
|
||||
make test
|
||||
make test_parallel
|
||||
|
||||
- name: Ensure the tests did not create any additional files
|
||||
shell: bash
|
||||
|
||||
@@ -29,7 +29,6 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
poetry-version: ${{ env.POETRY_VERSION }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
cache-key: release
|
||||
|
||||
# We want to keep this build stage *separate* from the release stage,
|
||||
|
||||
@@ -31,7 +31,6 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
poetry-version: ${{ env.POETRY_VERSION }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
cache-key: release
|
||||
|
||||
# We want to keep this build stage *separate* from the release stage,
|
||||
@@ -169,7 +168,6 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
poetry-version: ${{ env.POETRY_VERSION }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- name: Import published package
|
||||
shell: bash
|
||||
@@ -256,7 +254,6 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
poetry-version: ${{ env.POETRY_VERSION }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
cache-key: release
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
@@ -298,7 +295,6 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
poetry-version: ${{ env.POETRY_VERSION }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
cache-key: release
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ gain understanding of concepts and how they interact by showing one way to achie
|
||||
|
||||
They should **avoid** giving
|
||||
multiple permutations of ways to achieve that goal in-depth. Choice is burdensome. Instead, they should guide a new user through a recommended path to accomplishing a concrete goal. While the end result of a tutorial does not necessarily need to
|
||||
be completely production-ready, it should be useful and practically satisfy the the goal that you clearly stated in the tutorial's introduction.
|
||||
be completely production-ready, it should be useful and practically satisfy the goal that you clearly stated in the tutorial's introduction.
|
||||
|
||||
To quote the Diataxis website:
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ serve-clean-docs: clean-docs
|
||||
poetry run python -m mkdocs serve -c -f docs/mkdocs.yml --strict -w ./libs/langgraph
|
||||
|
||||
serve-docs: build-typedoc
|
||||
poetry run python -m mkdocs serve -f docs/mkdocs.yml -w ./libs/langgraph --dirty
|
||||
poetry run python -m mkdocs serve -f docs/mkdocs.yml -w ./libs/langgraph -w ./libs/checkpoint --dirty
|
||||
|
||||
clean-docs:
|
||||
find ./docs/docs -name "*.ipynb" -type f -delete
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,123 @@
|
||||
# How to add semantic search to your LangGraph deployment
|
||||
|
||||
This guide explains how to add semantic search to your LangGraph deployment's cross-thread [store](../../concepts/persistence.md#memory-store), so that your agent can search for memories and other documents by semantic similarity.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A LangGraph deployment (see [how to deploy](setup_pyproject.md))
|
||||
- API keys for your embedding provider (in this case, OpenAI)
|
||||
- `langchain >= 0.3.8` (if you specify using the string format below)
|
||||
|
||||
## Steps
|
||||
|
||||
1. Update your `langgraph.json` configuration file to include the store configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
...
|
||||
"store": {
|
||||
"index": {
|
||||
"embed": "openai:text-embeddings-3-small",
|
||||
"dims": 1536,
|
||||
"fields": ["$"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This configuration:
|
||||
|
||||
- Uses OpenAI's text-embeddings-3-small model for generating embeddings
|
||||
- Sets the embedding dimension to 1536 (matching the model's output)
|
||||
- Indexes all fields in your stored data (`["$"]` means index everything, or specify specific fields like `["text", "metadata.title"]`)
|
||||
|
||||
2. To use the string embedding format above, make sure your dependencies include `langchain >= 0.3.8`:
|
||||
|
||||
```toml
|
||||
# In pyproject.toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"langchain>=0.3.8"
|
||||
]
|
||||
```
|
||||
|
||||
Or if using requirements.txt:
|
||||
|
||||
```
|
||||
langchain>=0.3.8
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Once configured, you can use semantic search in your LangGraph nodes. The store requires a namespace tuple to organize memories:
|
||||
|
||||
```python
|
||||
def search_memory(state: State, *, store: BaseStore):
|
||||
# Search the store using semantic similarity
|
||||
# The namespace tuple helps organize different types of memories
|
||||
# e.g., ("user_facts", "preferences") or ("conversation", "summaries")
|
||||
results = store.search(
|
||||
namespace=("memory", "facts"), # Organize memories by type
|
||||
query="your search query",
|
||||
limit=3 # number of results to return
|
||||
)
|
||||
return results
|
||||
```
|
||||
|
||||
## Custom Embeddings
|
||||
|
||||
If you want to use custom embeddings, you can pass a path to a custom embedding function:
|
||||
|
||||
```json
|
||||
{
|
||||
...
|
||||
"store": {
|
||||
"index": {
|
||||
"embed": "path/to/embedding_function.py:embed",
|
||||
"dims": 1536,
|
||||
"fields": ["$"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The deployment will look for the function in the specified path. The function must be async and accept a list of strings:
|
||||
|
||||
```python
|
||||
# path/to/embedding_function.py
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
client = AsyncOpenAI()
|
||||
|
||||
async def aembed_texts(texts: list[str]) -> list[list[float]]:
|
||||
"""Custom embedding function that must:
|
||||
1. Be async
|
||||
2. Accept a list of strings
|
||||
3. Return a list of float arrays (embeddings)
|
||||
"""
|
||||
response = await client.embeddings.create(
|
||||
model="text-embedding-3-small",
|
||||
input=texts
|
||||
)
|
||||
return [e.embedding for e in response.data]
|
||||
```
|
||||
|
||||
## Querying via the API
|
||||
|
||||
You can also query the store using the LangGraph SDK. Since the SDK uses async operations:
|
||||
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
async def search_store():
|
||||
client = get_client()
|
||||
results = await client.store.search_items(
|
||||
("memory", "facts"),
|
||||
query="your search query",
|
||||
limit=3 # number of results to return
|
||||
)
|
||||
return results
|
||||
|
||||
# Use in an async context
|
||||
results = await search_store()
|
||||
```
|
||||
@@ -36,8 +36,8 @@ Dependencies can optionally be specified in one of the following files: `pyproje
|
||||
The dependencies below will be included in the image, you can also use them in your code, as long as with a compatible version range:
|
||||
|
||||
```
|
||||
langgraph>=0.2.30,<0.3.0
|
||||
langgraph-checkpoint>=1.0.14
|
||||
langgraph>=0.2.56,<0.3.0
|
||||
langgraph-checkpoint>=2.0.5,<3.0
|
||||
langchain-core>=0.2.38,<0.4.0
|
||||
langsmith>=0.1.63
|
||||
orjson>=3.9.7
|
||||
|
||||
@@ -36,8 +36,8 @@ Dependencies can optionally be specified in one of the following files: `pyproje
|
||||
The dependencies below will be included in the image, you can also use them in your code, as long as with a compatible version range:
|
||||
|
||||
```
|
||||
langgraph>=0.2.30,<0.3.0
|
||||
langgraph-checkpoint>=1.0.14
|
||||
langgraph>=0.2.56,<0.3.0
|
||||
langgraph-checkpoint>=2.0.5,<3.0
|
||||
langchain-core>=0.2.38,<0.4.0
|
||||
langsmith>=0.1.63
|
||||
orjson>=3.9.7
|
||||
|
||||
@@ -83,7 +83,7 @@ We can now call `.get_schemas` to get schemas associated with this graph:
|
||||
assistant_id=assistant["assistant_id"]
|
||||
)
|
||||
# There are multiple types of schemas
|
||||
# We can get the `config_schema` to look at the the configurable parameters
|
||||
# We can get the `config_schema` to look at the configurable parameters
|
||||
print(schemas["config_schema"])
|
||||
```
|
||||
|
||||
@@ -94,7 +94,7 @@ We can now call `.get_schemas` to get schemas associated with this graph:
|
||||
assistant["assistant_id"]
|
||||
);
|
||||
// There are multiple types of schemas
|
||||
// We can get the `config_schema` to look at the the configurable parameters
|
||||
// We can get the `config_schema` to look at the configurable parameters
|
||||
console.log(schemas.config_schema);
|
||||
```
|
||||
|
||||
|
||||
@@ -26,10 +26,11 @@ The LangGraph command line interface includes commands to build and run a LangGr
|
||||
The LangGraph CLI requires a JSON configuration file with the following keys:
|
||||
|
||||
| Key | Description |
|
||||
|--------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `dependencies` | **Required**. Array of dependencies for LangGraph Cloud API server. Dependencies can be one of the following: (1) `"."`, which will look for local Python packages, (2) `pyproject.toml`, `setup.py` or `requirements.txt` in the app directory `"./local_package"`, or (3) a package name. |
|
||||
| `graphs` | **Required**. Mapping from graph ID to path where the compiled graph or a function that makes a graph is defined. Example: <ul><li>`./your_package/your_file.py:variable`, where `variable` is an instance of `langgraph.graph.state.CompiledStateGraph`</li><li>`./your_package/your_file.py:make_graph`, where `make_graph` is a function that takes a config dictionary (`langchain_core.runnables.RunnableConfig`) and creates an instance of `langgraph.graph.state.StateGraph` / `langgraph.graph.state.CompiledStateGraph`.</li></ul> |
|
||||
| `env` | Path to `.env` file or a mapping from environment variable to its value. |
|
||||
| `store` | Configuration for adding semantic search to the BaseStore. Contains the following fields: <ul><li>`index`: Configuration for semantic search indexing with fields:<ul><li>`embed`: Embedding provider (e.g., "openai:text-embedding-3-small") or path to custom embedding function</li><li>`dims`: Dimension size of the embedding model. Used to initialize the vector table.</li><li>`fields` (optional): List of fields to index. Defaults to `["$"]`, meaningto index entire documents. Can be specific fields like `["text", "summary", "some.value"]`</li></ul></li></ul> |
|
||||
| `python_version` | `3.11` or `3.12`. Defaults to `3.11`. |
|
||||
| `pip_config_file` | Path to `pip` config file. |
|
||||
| `dockerfile_lines` | Array of additional lines to add to Dockerfile following the import from parent image. |
|
||||
@@ -41,33 +42,84 @@ The LangGraph CLI requires a JSON configuration file with the following keys:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Example:
|
||||
### Examples
|
||||
|
||||
#### Basic Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": ["langchain_openai", "./your_package"],
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"my_graph_id": "./your_package/your_file.py:variable"
|
||||
},
|
||||
"env": "./.env"
|
||||
"chat": "./chat/graph.py:graph"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example with environment variables:
|
||||
#### Adding semantic search to the store
|
||||
|
||||
All deployments come with a DB-backed BaseStore. Adding an "index" configuration to your `langgraph.json` will enable [semantic search](../deployment/semantic_search.md) within the BaseStore of your deployment.
|
||||
|
||||
The `fields` configuration determines which parts of your documents to embed:
|
||||
- If omitted or set to `["$"]`, the entire document will be embedded
|
||||
- To embed specific fields, use JSON path notation: `["metadata.title", "content.text"]`
|
||||
- Documents missing specified fields will still be stored but won't have embeddings for those fields
|
||||
- You can still override which fields to embed on a specific item at `put` time using the `index` parameter
|
||||
|
||||
```json
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["langchain_openai", "."],
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"my_graph_id": "./your_package/your_file.py:make_graph"
|
||||
"memory_agent": "./agent/graph.py:graph"
|
||||
},
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "secret-key"
|
||||
"store": {
|
||||
"index": {
|
||||
"embed": "openai:text-embedding-3-small",
|
||||
"dims": 1536,
|
||||
"fields": ["$"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
!!! note "Common model dimensions"
|
||||
- openai:text-embedding-3-large: 3072
|
||||
- openai:text-embedding-3-small: 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
|
||||
|
||||
#### Semantic search with a custom embedding function
|
||||
|
||||
If you want to use semantic search with a custom embedding function, you can pass a path to a custom embedding function:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"memory_agent": "./agent/graph.py:graph"
|
||||
},
|
||||
"store": {
|
||||
"index": {
|
||||
"embed": "./embeddings.py:embed_texts",
|
||||
"dims": 768,
|
||||
"fields": ["text", "summary"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `embed` field in store configuration can reference a custom function that takes a list of strings and returns a list of embeddings. Example implementation:
|
||||
|
||||
```python
|
||||
# embeddings.py
|
||||
def embed_texts(texts: list[str]) -> list[list[float]]:
|
||||
"""Custom embedding function for semantic search."""
|
||||
# Implementation using your preferred embedding model
|
||||
return [[0.1, 0.2, ...] for _ in texts] # dims-dimensional vectors
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
The base command for the LangGraph CLI is `langgraph`.
|
||||
@@ -82,6 +134,11 @@ langgraph [OPTIONS] COMMAND [ARGS]
|
||||
|
||||
Run LangGraph API server in development mode with hot reloading and debugging capabilities. This lightweight server requires no Docker installation and is suitable for development and testing. State is persisted to a local directory.
|
||||
|
||||
!!! note "Python only"
|
||||
|
||||
Currently, the CLI only supports Python >= 3.11.
|
||||
JS support is coming soon.
|
||||
|
||||
**Installation**
|
||||
|
||||
This command requires the "inmem" extra to be installed:
|
||||
@@ -98,16 +155,16 @@ langgraph dev [OPTIONS]
|
||||
|
||||
**Options**
|
||||
|
||||
| Option | Default | Description |
|
||||
|----------------------------|------------------|--------------------------------------------------------------------------------------------|
|
||||
| `-c, --config FILE` | `langgraph.json` | Path to configuration file declaring dependencies, graphs and environment variables |
|
||||
| `--host TEXT` | `127.0.0.1` | Host to bind the server to |
|
||||
| `--port INTEGER` | `2024` | Port to bind the server to |
|
||||
| `--no-reload` | | Disable auto-reload |
|
||||
| `--n-jobs-per-worker INTEGER` | | Number of jobs per worker. Default is 10 |
|
||||
| `--no-browser` | | Disable automatic browser opening |
|
||||
| `--debug-port INTEGER` | | Port for debugger to listen on |
|
||||
| `--help` | | Display command documentation |
|
||||
| Option | Default | Description |
|
||||
| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------- |
|
||||
| `-c, --config FILE` | `langgraph.json` | Path to configuration file declaring dependencies, graphs and environment variables |
|
||||
| `--host TEXT` | `127.0.0.1` | Host to bind the server to |
|
||||
| `--port INTEGER` | `2024` | Port to bind the server to |
|
||||
| `--no-reload` | | Disable auto-reload |
|
||||
| `--n-jobs-per-worker INTEGER` | | Number of jobs per worker. Default is 10 |
|
||||
| `--no-browser` | | Disable automatic browser opening |
|
||||
| `--debug-port INTEGER` | | Port for debugger to listen on |
|
||||
| `--help` | | Display command documentation |
|
||||
|
||||
### `build`
|
||||
|
||||
@@ -122,7 +179,7 @@ langgraph build [OPTIONS]
|
||||
**Options**
|
||||
|
||||
| Option | Default | Description |
|
||||
|----------------------|------------------|------------------------------------------------------------------------------------------------------------------------------|
|
||||
| -------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--platform TEXT` | | Target platform(s) to build the Docker image for. Example: `langgraph build --platform linux/amd64,linux/arm64` |
|
||||
| `-t, --tag TEXT` | | **Required**. Tag for the Docker image. Example: `langgraph build -t my-image` |
|
||||
| `--pull / --no-pull` | `--pull` | Build with latest remote Docker image. Use `--no-pull` for running the LangGraph Cloud API server with locally built images. |
|
||||
@@ -141,20 +198,20 @@ langgraph up [OPTIONS]
|
||||
|
||||
**Options**
|
||||
|
||||
| Option | Default | Description |
|
||||
|------------------------------|---------------------------|-----------------------------------------------------------------------------------------------------------------------|
|
||||
| `--wait` | | Wait for services to start before returning. Implies --detach |
|
||||
| `--postgres-uri TEXT` | Local database | Postgres URI to use for the database. |
|
||||
| `--watch` | | Restart on file changes |
|
||||
| `--debugger-base-url TEXT` | `http://127.0.0.1:[PORT]` | URL used by the debugger to access LangGraph API. |
|
||||
| `--debugger-port INTEGER` | | Pull the debugger image locally and serve the UI on specified port |
|
||||
| `--verbose` | | Show more output from the server logs. |
|
||||
| `-c, --config FILE` | `langgraph.json` | Path to configuration file declaring dependencies, graphs and environment variables. |
|
||||
| `-d, --docker-compose FILE` | | Path to docker-compose.yml file with additional services to launch. |
|
||||
| `-p, --port INTEGER` | `8123` | Port to expose. Example: `langgraph up --port 8000` |
|
||||
| Option | Default | Description |
|
||||
| ---------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--wait` | | Wait for services to start before returning. Implies --detach |
|
||||
| `--postgres-uri TEXT` | Local database | Postgres URI to use for the database. |
|
||||
| `--watch` | | Restart on file changes |
|
||||
| `--debugger-base-url TEXT` | `http://127.0.0.1:[PORT]` | URL used by the debugger to access LangGraph API. |
|
||||
| `--debugger-port INTEGER` | | Pull the debugger image locally and serve the UI on specified port |
|
||||
| `--verbose` | | Show more output from the server logs. |
|
||||
| `-c, --config FILE` | `langgraph.json` | Path to configuration file declaring dependencies, graphs and environment variables. |
|
||||
| `-d, --docker-compose FILE` | | Path to docker-compose.yml file with additional services to launch. |
|
||||
| `-p, --port INTEGER` | `8123` | Port to expose. Example: `langgraph up --port 8000` |
|
||||
| `--pull / --no-pull` | `pull` | Pull latest images. Use `--no-pull` for running the server with locally-built images. Example: `langgraph up --no-pull` |
|
||||
| `--recreate / --no-recreate` | `no-recreate` | Recreate containers even if their configuration and image haven't changed |
|
||||
| `--help` | | Display command documentation. |
|
||||
| `--recreate / --no-recreate` | `no-recreate` | Recreate containers even if their configuration and image haven't changed |
|
||||
| `--help` | | Display command documentation. |
|
||||
|
||||
### `dockerfile`
|
||||
|
||||
@@ -169,7 +226,7 @@ langgraph dockerfile [OPTIONS] SAVE_PATH
|
||||
**Options**
|
||||
|
||||
| Option | Default | Description |
|
||||
|---------------------|------------------|-----------------------------------------------------------------------------------------------------------------|
|
||||
| ------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `-c, --config FILE` | `langgraph.json` | Path to the [configuration file](#configuration-file) declaring dependencies, graphs and environment variables. |
|
||||
| `--help` | | Show this message and exit. |
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ Adding a [breakpoint](./low_level.md#breakpoints) a specific location in the gra
|
||||
Here, we compile our graph with a checkpointer and a breakpoint at the node we want to interrupt before, `step_for_human_in_the_loop`. We then perform one of the above interaction patterns, which will create a new checkpoint if a human edits the graph state. The new checkpoint is saved to the `thread` and we can resume the graph execution from there by passing in `None` as the input.
|
||||
|
||||
```python
|
||||
# Compile our graph with a checkpoitner and a breakpoint before "step_for_human_in_the_loop"
|
||||
graph = builder.compile(checkpointer=checkpoitner, interrupt_before=["step_for_human_in_the_loop"])
|
||||
# Compile our graph with a checkpointer and a breakpoint before "step_for_human_in_the_loop"
|
||||
graph = builder.compile(checkpointer=checkpointer, interrupt_before=["step_for_human_in_the_loop"])
|
||||
|
||||
# Run the graph up to the breakpoint
|
||||
thread_config = {"configurable": {"thread_id": "1"}}
|
||||
@@ -98,8 +98,8 @@ With persistence, we can surface the current agent state as well as the next ste
|
||||
If approved, the graph resumes execution from the last saved checkpoint, which is saved to the `thread`:
|
||||
|
||||
```python
|
||||
# Compile our graph with a checkpoitner and a breakpoint before the step to approve
|
||||
graph = builder.compile(checkpointer=checkpoitner, interrupt_before=["node_2"])
|
||||
# Compile our graph with a checkpointer and a breakpoint before the step to approve
|
||||
graph = builder.compile(checkpointer=checkpointer, interrupt_before=["node_2"])
|
||||
|
||||
# Run the graph up to the breakpoint
|
||||
for event in graph.stream(inputs, thread, stream_mode="values"):
|
||||
@@ -120,7 +120,7 @@ See [our guide](../how-tos/human_in_the_loop/breakpoints.ipynb) for a detailed h
|
||||
|
||||
Sometimes we want to review and edit the agent's state.
|
||||
|
||||
As with approval, we can interrupt our agent at a [breakpoint](./low_level.md#breakpoints) prior the the step we want to check.
|
||||
As with approval, we can interrupt our agent at a [breakpoint](./low_level.md#breakpoints) prior to the step we want to check.
|
||||
|
||||
We can surface the current state to a user and allow the user to edit the agent state.
|
||||
|
||||
@@ -131,8 +131,8 @@ We can edit the graph state by forking the current checkpoint, which is saved to
|
||||
We can then proceed with the graph from our forked checkpoint as done before.
|
||||
|
||||
```python
|
||||
# Compile our graph with a checkpoitner and a breakpoint before the step to review
|
||||
graph = builder.compile(checkpointer=checkpoitner, interrupt_before=["node_2"])
|
||||
# Compile our graph with a checkpointer and a breakpoint before the step to review
|
||||
graph = builder.compile(checkpointer=checkpointer, interrupt_before=["node_2"])
|
||||
|
||||
# Run the graph up to the breakpoint
|
||||
for event in graph.stream(inputs, thread, stream_mode="values"):
|
||||
@@ -170,11 +170,11 @@ With editing, the user makes a decision about whether or not to edit the graph s
|
||||
|
||||
With input, we explicitly define a node in our graph for collecting human input!
|
||||
|
||||
The the state update with the human input then runs *as this node*.
|
||||
The state update with the human input then runs *as this node*.
|
||||
|
||||
```python
|
||||
# Compile our graph with a checkpoitner and a breakpoint before the step to to collect human input
|
||||
graph = builder.compile(checkpointer=checkpoitner, interrupt_before=["human_input"])
|
||||
# Compile our graph with a checkpointer and a breakpoint before the step to to collect human input
|
||||
graph = builder.compile(checkpointer=checkpointer, interrupt_before=["human_input"])
|
||||
|
||||
# Run the graph up to the breakpoint
|
||||
for event in graph.stream(inputs, thread, stream_mode="values"):
|
||||
@@ -211,8 +211,8 @@ Even if the tool call is correct, we may also want to apply discretion:
|
||||
With these points in mind, we can combine the above ideas to create a human-in-the-loop review of a tool call.
|
||||
|
||||
```python
|
||||
# Compile our graph with a checkpoitner and a breakpoint before the step to to review the tool call from the LLM
|
||||
graph = builder.compile(checkpointer=checkpoitner, interrupt_before=["human_review"])
|
||||
# Compile our graph with a checkpointer and a breakpoint before the step to to review the tool call from the LLM
|
||||
graph = builder.compile(checkpointer=checkpointer, interrupt_before=["human_review"])
|
||||
|
||||
# Run the graph up to the breakpoint
|
||||
for event in graph.stream(inputs, thread, stream_mode="values"):
|
||||
@@ -319,4 +319,4 @@ for event in graph.stream(None, config, stream_mode="values"):
|
||||
|
||||
See [this additional conceptual guide](https://langchain-ai.github.io/langgraph/concepts/persistence/#update-state) for related context on forking.
|
||||
|
||||
See see [this guide](../how-tos/human_in_the_loop/time-travel.ipynb) for a detailed how-to on doing time-travel!
|
||||
See see [this guide](../how-tos/human_in_the_loop/time-travel.ipynb) for a detailed how-to on doing time-travel!
|
||||
|
||||
@@ -33,6 +33,11 @@ The `langgraph build` command builds a Docker image for the [LangGraph API serve
|
||||
!!! note "New in version 0.1.55"
|
||||
The `langgraph dev` command was introduced in langgraph-cli version 0.1.55.
|
||||
|
||||
!!! note "Python only"
|
||||
|
||||
Currently, the CLI only supports Python >= 3.11.
|
||||
JS support is coming soon.
|
||||
|
||||
The `langgraph dev` command starts a lightweight development server that requires no Docker installation. This server is ideal for rapid development and testing, with features like:
|
||||
|
||||
- Hot reloading: Changes to your code are automatically detected and reloaded
|
||||
|
||||
@@ -283,6 +283,9 @@ You can optionally provide a dictionary that maps the `routing_function`'s outpu
|
||||
graph.add_conditional_edges("node_a", routing_function, {True: "node_b", False: "node_c"})
|
||||
```
|
||||
|
||||
!!! tip
|
||||
Use [`Command`](#command) instead of conditional edges if you want to combine state updates and routing in a single function.
|
||||
|
||||
### Entry Point
|
||||
|
||||
The entry point is the first node(s) that are run when the graph starts. You can use the [`add_edge`][langgraph.graph.StateGraph.add_edge] method from the virtual [`START`][langgraph.constants.START] node to the first node to execute to specify where to enter the graph.
|
||||
@@ -322,6 +325,65 @@ def continue_to_jokes(state: OverallState):
|
||||
graph.add_conditional_edges("node_a", continue_to_jokes)
|
||||
```
|
||||
|
||||
## `Command`
|
||||
|
||||
It can be useful to combine control flow (edges) and state updates (nodes). For example, you might want to BOTH perform state updates AND decide which node to go to next in the SAME node. LangGraph provides a way to do so by returning a [`Command`][langgraph.types.Command] object from node functions:
|
||||
|
||||
```python
|
||||
def my_node(state: State) -> Command[Literal["my_other_node"]]:
|
||||
return Command(
|
||||
# state update
|
||||
update={"foo": "bar"},
|
||||
# control flow
|
||||
goto="my_other_node"
|
||||
)
|
||||
```
|
||||
|
||||
`Command` has the following properties:
|
||||
|
||||
| Property | Description |
|
||||
| --- | --- |
|
||||
| `graph` | Graph to send the command to. Supported values:<br>- `None`: the current graph (default)<br>- `Command.PARENT`: closest parent graph |
|
||||
| `update` | Update to apply to the graph's state. |
|
||||
| `resume` | Value to resume execution with. To be used together with [`interrupt()`][langgraph.types.interrupt]. |
|
||||
| `goto` | Can be one of the following:<br>- name of the node to navigate to next (any node that belongs to the specified `graph`)<br>- sequence of node names to navigate to next<br>- `Send` object (to execute a node with the input provided)<br>- sequence of `Send` objects<br>If `goto` is not specified and there are no other tasks left in the graph, the graph will halt after executing the current superstep. |
|
||||
|
||||
```python
|
||||
from langgraph.graph import StateGraph, START
|
||||
from langgraph.types import Command
|
||||
from typing_extensions import Literal, TypedDict
|
||||
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
|
||||
def my_node(state: State) -> Command[Literal["my_other_node"]]:
|
||||
return Command(update={"foo": "bar"}, goto="my_other_node")
|
||||
|
||||
def my_other_node(state: State):
|
||||
return {"foo": state["foo"] + "baz"}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_edge(START, "my_node")
|
||||
builder.add_node("my_node", my_node)
|
||||
builder.add_node("my_other_node", my_other_node)
|
||||
|
||||
graph = builder.compile()
|
||||
```
|
||||
|
||||
With `Command` you can also achieve dynamic control flow behavior (identical to [conditional edges](#conditional-edges)):
|
||||
|
||||
```python
|
||||
def my_node(state: State) -> Command[Literal["my_other_node"]]:
|
||||
if state["foo"] == "bar":
|
||||
return Command(update={"foo": "baz"}, goto="my_other_node")
|
||||
```
|
||||
|
||||
!!! important
|
||||
|
||||
When returning `Command` in your node functions, you must add return type annotations with the list of node names the node is routing to, e.g. `Command[Literal["node_b", "node_c"]]`. This is necessary for the graph compilation and rendering, and tells LangGraph that `node_a` can navigate to `node_b` and `node_c`.
|
||||
|
||||
Check out this [how-to guide](../how-tos/command.ipynb) for an end-to-end example of how to use `Command`.
|
||||
|
||||
## Persistence
|
||||
|
||||
LangGraph provides built-in persistence for your agent's state using [checkpointers][langgraph.checkpoint.base.BaseCheckpointSaver]. Checkpointers save snapshots of the graph state at every superstep, allowing resumption at any time. This enables features like human-in-the-loop interactions, memory management, and fault-tolerance. You can even directly manipulate a graph's state after its execution using the
|
||||
|
||||
@@ -171,7 +171,7 @@ trim_messages(
|
||||
|
||||
## Long-term memory
|
||||
|
||||
Long-term memory in LangGraph allows systems to retain information across different conversations or sessions. Unlike short-term memory, which is thread-scoped, long-term memory is saved within custom "namespaces."
|
||||
Long-term memory in LangGraph allows systems to retain information across different conversations or sessions. Unlike short-term memory, which is **thread-scoped**, long-term memory is saved within custom "namespaces."
|
||||
|
||||
### Storing memories
|
||||
|
||||
@@ -180,16 +180,34 @@ LangGraph stores long-term memories as JSON documents in a [store](persistence.m
|
||||
```python
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
|
||||
def embed(texts: list[str]) -> list[list[float]]:
|
||||
# Replace with an actual embedding function or LangChain embeddings object
|
||||
return [[1.0, 2.0] * len(texts)]
|
||||
|
||||
|
||||
# InMemoryStore saves data to an in-memory dictionary. Use a DB-backed store in production use.
|
||||
store = InMemoryStore()
|
||||
store = InMemoryStore(index={"embed": embed, "dims": 2})
|
||||
user_id = "my-user"
|
||||
application_context = "chitchat"
|
||||
namespace = (user_id, application_context)
|
||||
store.put(namespace, "a-memory", {"rules": ["User likes short, direct language", "User only speaks English & python"], "my-key": "my-value"})
|
||||
store.put(
|
||||
namespace,
|
||||
"a-memory",
|
||||
{
|
||||
"rules": [
|
||||
"User likes short, direct language",
|
||||
"User only speaks English & python",
|
||||
],
|
||||
"my-key": "my-value",
|
||||
},
|
||||
)
|
||||
# get the "memory" by ID
|
||||
item = store.get(namespace, "a-memory")
|
||||
# list "memories" within this namespace, filtering on content equivalence
|
||||
items = store.search(namespace, filter={"my-key": "my-value"})
|
||||
# search for "memories" within this namespace, filtering on content equivalence, sorted by vector similarity
|
||||
items = store.search(
|
||||
namespace, filter={"my-key": "my-value"}, query="language preferences"
|
||||
)
|
||||
```
|
||||
|
||||
### Framework for thinking about long-term memory
|
||||
@@ -218,6 +236,9 @@ Different applications require various types of memory. Although the analogy isn
|
||||
|
||||
[Semantic memory](https://en.wikipedia.org/wiki/Semantic_memory), both in humans and AI agents, involves the retention of specific facts and concepts. In humans, it can include information learned in school and the understanding of concepts and their relationships. For AI agents, semantic memory is often used to personalize applications by remembering facts or concepts from past interactions.
|
||||
|
||||
> Note: Not to be confused with "semantic search" which is a technique for finding similar content using "meaning" (usually as embeddings). Semantic memory is a term from psychology, referring to storing facts and knowledge, while semantic search is a method for retrieving information based on meaning rather than exact matches.
|
||||
|
||||
|
||||
#### Profile
|
||||
|
||||
Semantic memories can be managed in different ways. For example, memories can be a single, continuously updated "profile" of well-scoped and specific information about a user, organization, or other entity (including the agent itself). A profile is generally just a JSON document with various key-value pairs you've selected to represent your domain.
|
||||
@@ -232,7 +253,7 @@ Alternatively, memories can be a collection of documents that are continuously u
|
||||
|
||||
However, this shifts some complexity memory updating. The model must now _delete_ or _update_ existing items in the list, which can be tricky. In addition, some models may default to over-inserting and others may default to over-updating. See the [Trustcall](https://github.com/hinthornw/trustcall) package for one way to manage this and consider evaluation (e.g., with a tool like [LangSmith](https://docs.smith.langchain.com/tutorials/Developers/evaluation)) to help you tune the behavior.
|
||||
|
||||
Working with document collections also shifts complexity to memory **search** over the list. The `Store` currently supports [filtering by metadata](https://langchain-ai.github.io/langgraph/reference/store/#storage) and will soon add [semantic search shortly](https://python.langchain.com/docs/concepts/vectorstores/), but selecting the most relevant documents can be tricky as the list grows.
|
||||
Working with document collections also shifts complexity to memory **search** over the list. The `Store` currently supports both [semantic search](https://langchain-ai.github.io/langgraph/reference/store/#langgraph.store.base.SearchOp.query) and [filtering by content](https://langchain-ai.github.io/langgraph/reference/store/#langgraph.store.base.SearchOp.filter).
|
||||
|
||||
Finally, using a collection of memories can make it challenging to provide comprehensive context to the model. While individual memories may follow a specific schema, this structure might not capture the full context or relationships between memories. As a result, when using these memories to generate responses, the model may lack important contextual information that would be more readily available in a unified profile approach.
|
||||
|
||||
|
||||
@@ -28,12 +28,7 @@ There are several ways to connect agents in a multi-agent system:
|
||||
|
||||
### Network
|
||||
|
||||
In this architecture, agents are defined as graph nodes. Each agent can communicate with every other agent (many-to-many connections) and can decide which agent to call next. While very flexible, this architecture doesn't scale well as the number of agents grows:
|
||||
|
||||
- hard to enforce which agent should be called next
|
||||
- hard to determine how much [information](#shared-message-list) should be passed between the agents
|
||||
|
||||
We recommend avoiding this architecture in production and using one of the below architectures instead.
|
||||
In this architecture, agents are defined as graph nodes. Each agent can communicate with every other agent (many-to-many connections) and can decide which agent to call next. This architecture is good for problems that do not have a clear hierarchy of agents or a specific sequence in which agents should be called.
|
||||
|
||||
### Supervisor
|
||||
|
||||
|
||||
@@ -218,13 +218,16 @@ The final thing you can optionally specify when calling `update_state` is `as_no
|
||||
|
||||
## Memory Store
|
||||
|
||||

|
||||

|
||||
|
||||
A [state schema](low_level.md#schema) specifies a set of keys that are populated as a graph is executed. As discussed above, state can be written by a checkpointer to a thread at each graph step, enabling state persistence.
|
||||
|
||||
But, what if we want to retrain some information *across threads*? Consider the case of a chatbot where we want to retain specific information about the user across *all* chat conversations (e.g., threads) with that user!
|
||||
|
||||
With checkpointers alone, we cannot share information across threads. This motivates the need for the `Store` interface. As an illustration, we can define an `InMemoryStore` to store information about a user across threads. We simply compile our graph with a checkpointer, as before, and will our new `in_memory_store`.
|
||||
With checkpointers alone, we cannot share information across threads. This motivates the need for the [`Store`](../reference/store.md#langgraph.store.base.BaseStore) interface. As an illustration, we can define an `InMemoryStore` to store information about a user across threads. We simply compile our graph with a checkpointer, as before, and with our new `in_memory_store` variable.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
First, let's showcase this in isolation without using LangGraph.
|
||||
|
||||
```python
|
||||
@@ -239,7 +242,7 @@ user_id = "1"
|
||||
namespace_for_memory = (user_id, "memories")
|
||||
```
|
||||
|
||||
We use the `store.put` to save memories to our namespace in the store. When we do this, we specify the namespace, as defined above, and a key-value pair for the memory: the key is simply a unique identifier for the memory (`memory_id`) and the value (a dictionary) is the memory itself.
|
||||
We use the `store.put` method to save memories to our namespace in the store. When we do this, we specify the namespace, as defined above, and a key-value pair for the memory: the key is simply a unique identifier for the memory (`memory_id`) and the value (a dictionary) is the memory itself.
|
||||
|
||||
```python
|
||||
memory_id = str(uuid.uuid4())
|
||||
@@ -247,7 +250,7 @@ memory = {"food_preference" : "I like pizza"}
|
||||
in_memory_store.put(namespace_for_memory, memory_id, memory)
|
||||
```
|
||||
|
||||
We can read out memories in our namespace using `store.search`, which will return all memories for a given user as a list. The most recent memory is the last in the list.
|
||||
We can read out memories in our namespace using the `store.search` method, which will return all memories for a given user as a list. The most recent memory is the last in the list.
|
||||
|
||||
```python
|
||||
memories = in_memory_store.search(namespace_for_memory)
|
||||
@@ -259,16 +262,69 @@ memories[-1].dict()
|
||||
'updated_at': '2024-10-02T17:22:31.590605+00:00'}
|
||||
```
|
||||
|
||||
Each memory type is a Python class with certain attributes. We can access it as a dictionary by converting via `.dict` as above.
|
||||
Each memory type is a Python class ([`Item`](https://langchain-ai.github.io/langgraph/reference/store/#langgraph.store.base.Item)) with certain attributes. We can access it as a dictionary by converting via `.dict` as above.
|
||||
The attributes it has are:
|
||||
|
||||
- `value`: The value (itself a dictionary) of this memory
|
||||
- `key`: The UUID for this memory in this namespace
|
||||
- `key`: A unique key for this memory in this namespace
|
||||
- `namespace`: A list of strings, the namespace of this memory type
|
||||
- `created_at`: Timestamp for when this memory was created
|
||||
- `updated_at`: Timestamp for when this memory was updated
|
||||
|
||||
With this all in place, we use the `in_memory_store` in LangGraph. The `in_memory_store` works hand-in-hand with the checkpointer: the checkpointer saves state to threads, as discussed above, and the the `in_memory_store` allows us to store arbitrary information for access *across* threads. We compile the graph with both the checkpointer and the `in_memory_store` as follows.
|
||||
### Semantic Search
|
||||
|
||||
Beyond simple retrieval, the store also supports semantic search, allowing you to find memories based on meaning rather than exact matches. To enable this, configure the store with an embedding model:
|
||||
|
||||
```python
|
||||
from langchain.embeddings import init_embeddings
|
||||
|
||||
store = InMemoryStore(
|
||||
index={
|
||||
"embed": init_embeddings("openai:text-embedding-3-small"), # Embedding provider
|
||||
"dims": 1536, # Embedding dimensions
|
||||
"fields": ["food_preference", "$"] # Fields to embed
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Now when searching, you can use natural language queries to find relevant memories:
|
||||
|
||||
```python
|
||||
# Find memories about food preferences
|
||||
# (This can be done after putting memories into the store)
|
||||
memories = store.search(
|
||||
namespace_for_memory,
|
||||
query="What does the user like to eat?",
|
||||
limit=3 # Return top 3 matches
|
||||
)
|
||||
```
|
||||
|
||||
You can control which parts of your memories get embedded by configuring the `fields` parameter or by specifying the `index` parameter when storing memories:
|
||||
|
||||
```python
|
||||
# Store with specific fields to embed
|
||||
store.put(
|
||||
namespace_for_memory,
|
||||
str(uuid.uuid4()),
|
||||
{
|
||||
"food_preference": "I love Italian cuisine",
|
||||
"context": "Discussing dinner plans"
|
||||
},
|
||||
index=["food_preference"] # Only embed "food_preferences" field
|
||||
)
|
||||
|
||||
# Store without embedding (still retrievable, but not searchable)
|
||||
store.put(
|
||||
namespace_for_memory,
|
||||
str(uuid.uuid4()),
|
||||
{"system_info": "Last updated: 2024-01-01"},
|
||||
index=False
|
||||
)
|
||||
```
|
||||
|
||||
### Using in LangGraph
|
||||
|
||||
With this all in place, we use the `in_memory_store` in LangGraph. The `in_memory_store` works hand-in-hand with the checkpointer: the checkpointer saves state to threads, as discussed above, and the `in_memory_store` allows us to store arbitrary information for access *across* threads. We compile the graph with both the checkpointer and the `in_memory_store` as follows.
|
||||
|
||||
```python
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
@@ -296,7 +352,7 @@ for update in graph.stream(
|
||||
print(update)
|
||||
```
|
||||
|
||||
We can access the `in_memory_store` and the `user_id` in *any node* by passing `store: BaseStore` and `config: RunnableConfig` as node arguments. Just as we saw above, simply use the `put` method to save memories to the store.
|
||||
We can access the `in_memory_store` and the `user_id` in *any node* by passing `store: BaseStore` and `config: RunnableConfig` as node arguments. Here's how we might use semantic search in a node to find relevant memories:
|
||||
|
||||
```python
|
||||
def update_memory(state: MessagesState, config: RunnableConfig, *, store: BaseStore):
|
||||
@@ -317,7 +373,7 @@ def update_memory(state: MessagesState, config: RunnableConfig, *, store: BaseSt
|
||||
|
||||
```
|
||||
|
||||
As we showed above, we can also access the store in any node and use `search` to get memories. Recall the the memories are returned as a list of objects that can be converted to a dictionary.
|
||||
As we showed above, we can also access the store in any node and use the `store.search` method to get memories. Recall the the memories are returned as a list of objects that can be converted to a dictionary.
|
||||
|
||||
```python
|
||||
memories[-1].dict()
|
||||
@@ -332,12 +388,15 @@ We can access the memories and use them in our model call.
|
||||
|
||||
```python
|
||||
def call_model(state: MessagesState, config: RunnableConfig, *, store: BaseStore):
|
||||
|
||||
# Get the user id from the config
|
||||
user_id = config["configurable"]["user_id"]
|
||||
|
||||
# Get the memories for the user from the store
|
||||
memories = store.search(("memories", user_id))
|
||||
# Search based on the most recent message
|
||||
memories = store.search(
|
||||
namespace,
|
||||
query=state["messages"][-1].content,
|
||||
limit=3
|
||||
)
|
||||
info = "\n".join([d.value["memory"] for d in memories])
|
||||
|
||||
# ... Use memories in the model call
|
||||
@@ -356,7 +415,22 @@ for update in graph.stream(
|
||||
print(update)
|
||||
```
|
||||
|
||||
When we use the LangGraph API, either locally (e.g., in LangGraph Studio) or with LangGraph Cloud, the memory store is available to use by default and does not need to be specified during graph compilation.
|
||||
When we use the LangGraph Platform, either locally (e.g., in LangGraph Studio) or with LangGraph Cloud, the base store is available to use by default and does not need to be specified during graph compilation. To enable semantic search, however, you **do** need to configure the indexing settings in your `langgraph.json` file. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
...
|
||||
"store": {
|
||||
"index": {
|
||||
"embed": "openai:text-embeddings-3-small",
|
||||
"dims": 1536,
|
||||
"fields": ["$"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [deployment guide](../cloud/deployment/semantic_search.md) for more details and configuration options.
|
||||
|
||||
## Checkpointer libraries
|
||||
|
||||
@@ -405,4 +479,4 @@ Lastly, checkpointing also provides fault-tolerance and error recovery: if one o
|
||||
|
||||
#### Pending writes
|
||||
|
||||
Additionally, when a graph node fails mid-execution at a given superstep, LangGraph stores pending checkpoint writes from any other nodes that completed successfully at that superstep, so that whenever we resume graph execution from that superstep we don't re-run the successful nodes.
|
||||
Additionally, when a graph node fails mid-execution at a given superstep, LangGraph stores pending checkpoint writes from any other nodes that completed successfully at that superstep, so that whenever we resume graph execution from that superstep we don't re-run the successful nodes.
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
# Template Applications
|
||||
|
||||
!!! note Prerequisites
|
||||
|
||||
- [LangGraph Studio](./langgraph_studio.md)
|
||||
|
||||
Templates are open source reference applications designed to help you get started quickly when building with LangGraph. They provide working examples of common agentic workflows that can be customized to your needs.
|
||||
|
||||
Templates can be accessed via [LangGraph Studio (macOS only)](langgraph_studio.md), or cloned directly from Github. You can download LangGraph Studio and see available templates [here](https://studio.langchain.com/).
|
||||
You can create an application from a template using the LangGraph CLI.
|
||||
|
||||
## Available templates
|
||||
!!! 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
|
||||
```
|
||||
|
||||
## Available Templates
|
||||
|
||||
| Template | Description | Python | JS/TS |
|
||||
|---------------------------|------------------------------------------------------------------------------------------|------------------------------------------------------------------|---------------------------------------------------------------------|
|
||||
@@ -17,3 +24,39 @@ Templates can be accessed via [LangGraph Studio (macOS only)](langgraph_studio.m
|
||||
| **Memory Agent** | A ReAct-style agent with an additional tool to store memories for use across threads. | [Repo](https://github.com/langchain-ai/memory-agent) | [Repo](https://github.com/langchain-ai/memory-agent-js) |
|
||||
| **Retrieval Agent** | An agent that includes a retrieval-based question-answering system. | [Repo](https://github.com/langchain-ai/retrieval-agent-template) | [Repo](https://github.com/langchain-ai/retrieval-agent-template-js) |
|
||||
| **Data-Enrichment Agent** | An agent that performs web searches and organizes its findings into a structured format. | [Repo](https://github.com/langchain-ai/data-enrichment) | [Repo](https://github.com/langchain-ai/data-enrichment-js) |
|
||||
|
||||
|
||||
## 🌱 Create a LangGraph App
|
||||
|
||||
To create a new app from a template, use the `langgraph new` command.
|
||||
|
||||
```bash
|
||||
langgraph new
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
Review the `README.md` file in the root of your new LangGraph app for more information about the template and how to customize it.
|
||||
|
||||
After configuring the app properly and adding your API keys, you can start the app using the LangGraph CLI:
|
||||
|
||||
```bash
|
||||
langgraph dev
|
||||
```
|
||||
|
||||
See the following guides for more information on how to deploy your app:
|
||||
|
||||
- **[Launch Local LangGraph Server](../tutorials/langgraph-platform/local-server.md)**: This quick start guide shows how to start a LangGraph Server locally for the **ReAct Agent** template. The steps are similar for other templates.
|
||||
- **[Deploy to LangGraph Cloud](../cloud/quick_start.md)**: Deploy your LangGraph app using LangGraph Cloud.
|
||||
|
||||
### LangGraph Framework
|
||||
|
||||
- **[LangGraph Concepts](../concepts/index.md)**: Learn the foundational concepts of LangGraph.
|
||||
- **[LangGraph How-to Guides](../how-tos/index.md)**: Guides for common tasks with LangGraph.
|
||||
|
||||
### 📚 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.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -41,6 +41,9 @@
|
||||
" <p>\n",
|
||||
" Support for the <code><a href=\"https://langchain-ai.github.io/langgraph/reference/store/#langgraph.store.base.BaseStore\">Store</a></code> API that is used in this guide was added in LangGraph <code>v0.2.32</code>.\n",
|
||||
" </p>\n",
|
||||
" <p>\n",
|
||||
" Support for <b>index</b> and <b>query</b> arguments of the <code><a href=\"https://langchain-ai.github.io/langgraph/reference/store/#langgraph.store.base.BaseStore\">Store</a></code> API that is used in this guide was added in LangGraph <code>v0.2.54</code>.\n",
|
||||
" </p>\n",
|
||||
"</div>\n",
|
||||
"\n",
|
||||
"## Setup\n",
|
||||
@@ -114,7 +117,7 @@
|
||||
"\n",
|
||||
"Importantly, to determine the user, we will be passing `user_id` via the config keyword argument of the node function.\n",
|
||||
"\n",
|
||||
"Let's first define an `InMemoryStore` which is already populated with some memories about the users."
|
||||
"Let's first define an `InMemoryStore` already populated with some memories about the users."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -125,8 +128,14 @@
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from langgraph.store.memory import InMemoryStore\n",
|
||||
"from langchain_openai import OpenAIEmbeddings\n",
|
||||
"\n",
|
||||
"in_memory_store = InMemoryStore()"
|
||||
"in_memory_store = InMemoryStore(\n",
|
||||
" index={\n",
|
||||
" \"embed\": OpenAIEmbeddings(model=\"text-embedding-3-small\"),\n",
|
||||
" \"dims\": 1536,\n",
|
||||
" }\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -163,7 +172,7 @@
|
||||
"def call_model(state: MessagesState, config: RunnableConfig, *, store: BaseStore):\n",
|
||||
" user_id = config[\"configurable\"][\"user_id\"]\n",
|
||||
" namespace = (\"memories\", user_id)\n",
|
||||
" memories = store.search(namespace)\n",
|
||||
" memories = store.search(namespace, query=str(state[\"messages\"][-1].content))\n",
|
||||
" info = \"\\n\".join([d.value[\"data\"] for d in memories])\n",
|
||||
" system_msg = f\"You are a helpful assistant talking to the user. User info: {info}\"\n",
|
||||
"\n",
|
||||
|
||||
@@ -20,6 +20,7 @@ These how-to guides show how to achieve that controllability.
|
||||
- [How to create branches for parallel execution](branching.ipynb)
|
||||
- [How to create map-reduce branches for parallel execution](map-reduce.ipynb)
|
||||
- [How to control graph recursion limit](recursion-limit.ipynb)
|
||||
- [How to combine control flow and state updates with Command](command.ipynb)
|
||||
|
||||
### Persistence
|
||||
|
||||
@@ -39,6 +40,8 @@ LangGraph makes it easy to manage conversation [memory](../concepts/memory.md) i
|
||||
- [How to manage conversation history](memory/manage-conversation-history.ipynb)
|
||||
- [How to delete messages](memory/delete-messages.ipynb)
|
||||
- [How to add summary conversation memory](memory/add-summary-conversation-history.ipynb)
|
||||
- [How to add long-term memory (cross-thread)](cross-thread-persistence.ipynb)
|
||||
- [How to use semantic search for long-term memory](memory/semantic-search.ipynb)
|
||||
|
||||
### Human-in-the-loop
|
||||
|
||||
@@ -70,7 +73,7 @@ you to involve humans in the decision-making process of your graph. These how-to
|
||||
|
||||
### Tool calling
|
||||
|
||||
[Tool calling](https://python.langchain.com/docs/concepts/tool_calling/) is a type of chat model API that accepts tool schemas, along with messages, as input and returns invocations of those tools as part of the output message.
|
||||
[Tool calling](https://python.langchain.com/docs/concepts/tool_calling/) is a type of chat model API that accepts tool schemas, along with messages, as input and returns invocations of those tools as part of the output message.
|
||||
|
||||
These how-to guides show common patterns for tool calling with LangGraph:
|
||||
|
||||
@@ -118,12 +121,13 @@ These guides show how to use the prebuilt ReAct agent:
|
||||
- [How to add a custom system prompt to a ReAct agent](create-react-agent-system-prompt.ipynb)
|
||||
- [How to add human-in-the-loop processes to a ReAct agent](create-react-agent-hitl.ipynb)
|
||||
- [How to create prebuilt ReAct agent from scratch](react-agent-from-scratch.ipynb)
|
||||
- [How to add semantic search for long-term memory to a ReAct agent](memory/semantic-search.ipynb#using-in-create-react-agent)
|
||||
|
||||
## LangGraph Platform
|
||||
|
||||
This section includes how-to guides for 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.
|
||||
|
||||
The LangGraph Platform offers a few different deployment options described in the [deployment options guide](../concepts/deployment_options.md).
|
||||
|
||||
@@ -139,6 +143,7 @@ Learn how to set up your app for deployment to LangGraph Platform:
|
||||
- [How to set up app for deployment (requirements.txt)](../cloud/deployment/setup.md)
|
||||
- [How to set up app for deployment (pyproject.toml)](../cloud/deployment/setup_pyproject.md)
|
||||
- [How to set up app for deployment (JavaScript)](../cloud/deployment/setup_javascript.md)
|
||||
- [How to add semantic search](../cloud/deployment/semantic_search.md)
|
||||
- [How to customize Dockerfile](../cloud/deployment/custom_docker.md)
|
||||
- [How to test locally](../cloud/deployment/test_locally.md)
|
||||
- [How to rebuild graph at runtime](../cloud/deployment/graph_rebuild.md)
|
||||
@@ -150,8 +155,8 @@ 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 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](../concepts/assistants.md) is a configured instance of a template.
|
||||
@@ -196,7 +201,7 @@ When designing complex graphs, relying entirely on the LLM for decision-making c
|
||||
|
||||
### 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.
|
||||
|
||||
- [How to use the interrupt option](../cloud/how-tos/interrupt_concurrent.md)
|
||||
- [How to use the rollback option](../cloud/how-tos/rollback_concurrent.md)
|
||||
@@ -216,8 +221,9 @@ Graph execution can take a while, and sometimes users may change their mind abou
|
||||
LangGraph Studio is a built-in UI for visualizing, testing, and debugging your agents.
|
||||
|
||||
- [How to connect to a LangGraph Cloud deployment](../cloud/how-tos/test_deployment.md)
|
||||
- [How to connect to a local deployment](../cloud/how-tos/test_local_deployment.md)
|
||||
- [How to test your graph in LangGraph Studio](../cloud/how-tos/invoke_studio.md)
|
||||
- [How to connect to a local dev server](../how-tos/local-studio.md)
|
||||
- [How to connect to a local deployment (Docker)](../cloud/how-tos/test_local_deployment.md)
|
||||
- [How to test your graph in LangGraph Studio (MacOS only)](../cloud/how-tos/invoke_studio.md)
|
||||
- [How to interact with threads in LangGraph Studio](../cloud/how-tos/threads_studio.md)
|
||||
|
||||
## Troubleshooting
|
||||
@@ -229,5 +235,3 @@ These are the guides for resolving common errors you may find while building wit
|
||||
- [INVALID_GRAPH_NODE_RETURN_VALUE](../troubleshooting/errors/INVALID_GRAPH_NODE_RETURN_VALUE.md)
|
||||
- [MULTIPLE_SUBGRAPHS](../troubleshooting/errors/MULTIPLE_SUBGRAPHS.md)
|
||||
- [INVALID_CHAT_HISTORY](../troubleshooting/errors/INVALID_CHAT_HISTORY.md)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# How to add semantic search to your agent's memory\n",
|
||||
"\n",
|
||||
"This guide shows how to enable semantic search in your agent's memory store. This lets search for items in the store by semantic similarity.\n",
|
||||
"\n",
|
||||
"!!! tip Prerequisites\n",
|
||||
" This guide assumes familiarity with the [memory in LangGraph](https://langchain-ai.github.io/langgraph/concepts/memory/).\n",
|
||||
"\n",
|
||||
"First, install this guide's prerequisites."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"%%capture --no-stderr\n",
|
||||
"%pip install -U langgraph langchain-openai langchain"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import getpass\n",
|
||||
"import os\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def _set_env(var: str):\n",
|
||||
" if not os.environ.get(var):\n",
|
||||
" os.environ[var] = getpass.getpass(f\"{var}: \")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"_set_env(\"OPENAI_API_KEY\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Next, create the store with an [index configuration](https://langchain-ai.github.io/langgraph/reference/store/#langgraph.store.base.IndexConfig). By default, stores are configured without semantic/vector search. You can opt in to indexing items when creating the store by providing an [IndexConfig](https://langchain-ai.github.io/langgraph/reference/store/#langgraph.store.base.IndexConfig) to the store's constructor. If your store class does not implement this interface, or if you do not pass in an index configuration, semantic search is disabled, and all `index` arguments passed to `put` or `aput` will have no effect. Below is an example."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stderr",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"/var/folders/gf/6rnp_mbx5914kx7qmmh7xzmw0000gn/T/ipykernel_83572/2318027494.py:5: LangChainBetaWarning: The function `init_embeddings` is in beta. It is actively being worked on, so the API may change.\n",
|
||||
" embeddings = init_embeddings(\"openai:text-embedding-3-small\")\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from langchain.embeddings import init_embeddings\n",
|
||||
"from langgraph.store.memory import InMemoryStore\n",
|
||||
"\n",
|
||||
"# Create store with semantic search enabled\n",
|
||||
"embeddings = init_embeddings(\"openai:text-embedding-3-small\")\n",
|
||||
"store = InMemoryStore(\n",
|
||||
" index={\n",
|
||||
" \"embed\": embeddings,\n",
|
||||
" \"dims\": 1536,\n",
|
||||
" }\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Now let's store some memories:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Store some memories\n",
|
||||
"store.put((\"user_123\", \"memories\"), \"1\", {\"text\": \"I love pizza\"})\n",
|
||||
"store.put((\"user_123\", \"memories\"), \"2\", {\"text\": \"I prefer Italian food\"})\n",
|
||||
"store.put((\"user_123\", \"memories\"), \"3\", {\"text\": \"I don't like spicy food\"})\n",
|
||||
"store.put((\"user_123\", \"memories\"), \"3\", {\"text\": \"I am studying econometrics\"})\n",
|
||||
"store.put((\"user_123\", \"memories\"), \"3\", {\"text\": \"I am a plumber\"})"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Search memories using natural language:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Memory: I prefer Italian food (similarity: 0.46482669521168163)\n",
|
||||
"Memory: I love pizza (similarity: 0.35514845174380766)\n",
|
||||
"Memory: I am a plumber (similarity: 0.155698702336571)\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"# Find memories about food preferences\n",
|
||||
"memories = store.search((\"user_123\", \"memories\"), query=\"I like food?\", limit=5)\n",
|
||||
"\n",
|
||||
"for memory in memories:\n",
|
||||
" print(f'Memory: {memory.value[\"text\"]} (similarity: {memory.score})')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Using in your agent\n",
|
||||
"\n",
|
||||
"Add semantic search to any node by injecting the store."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"What are you in the mood for? Since you love Italian food and pizza, would you like to order a pizza or try making one at home?"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from typing import Optional\n",
|
||||
"\n",
|
||||
"from langchain.chat_models import init_chat_model\n",
|
||||
"from langgraph.store.base import BaseStore\n",
|
||||
"\n",
|
||||
"from langgraph.graph import START, MessagesState, StateGraph\n",
|
||||
"\n",
|
||||
"llm = init_chat_model(\"openai:gpt-4o-mini\")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def chat(state, *, store: BaseStore):\n",
|
||||
" # Search based on user's last message\n",
|
||||
" items = store.search(\n",
|
||||
" (\"user_123\", \"memories\"), query=state[\"messages\"][-1].content, limit=2\n",
|
||||
" )\n",
|
||||
" memories = \"\\n\".join(item.value[\"text\"] for item in items)\n",
|
||||
" memories = f\"## Memories of user\\n{memories}\" if memories else \"\"\n",
|
||||
" response = llm.invoke(\n",
|
||||
" [\n",
|
||||
" {\"role\": \"system\", \"content\": f\"You are a helpful assistant.\\n{memories}\"},\n",
|
||||
" *state[\"messages\"],\n",
|
||||
" ]\n",
|
||||
" )\n",
|
||||
" return {\"messages\": [response]}\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"builder = StateGraph(MessagesState)\n",
|
||||
"builder.add_node(chat)\n",
|
||||
"builder.add_edge(START, \"chat\")\n",
|
||||
"graph = builder.compile(store=store)\n",
|
||||
"\n",
|
||||
"for message, metadata in graph.stream(\n",
|
||||
" input={\"messages\": [{\"role\": \"user\", \"content\": \"I'm hungry\"}]},\n",
|
||||
" stream_mode=\"messages\",\n",
|
||||
"):\n",
|
||||
" print(message.content, end=\"\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Using in `create_react_agent`\n",
|
||||
"\n",
|
||||
"Add semantic search to your tool calling agent by injecting the store in the `state_modifier`. You can also use the store in a tool to let your agent manually store or search for memories."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import uuid\n",
|
||||
"from typing import Optional\n",
|
||||
"\n",
|
||||
"from langchain.chat_models import init_chat_model\n",
|
||||
"from langchain_core.tools import InjectedToolArg\n",
|
||||
"from langgraph.store.base import BaseStore\n",
|
||||
"from typing_extensions import Annotated\n",
|
||||
"\n",
|
||||
"from langgraph.prebuilt import create_react_agent\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def prepare_messages(state, *, store: BaseStore):\n",
|
||||
" # Search based on user's last message\n",
|
||||
" items = store.search(\n",
|
||||
" (\"user_123\", \"memories\"), query=state[\"messages\"][-1].content, limit=2\n",
|
||||
" )\n",
|
||||
" memories = \"\\n\".join(item.value[\"text\"] for item in items)\n",
|
||||
" memories = f\"## Memories of user\\n{memories}\" if memories else \"\"\n",
|
||||
" return [\n",
|
||||
" {\"role\": \"system\", \"content\": f\"You are a helpful assistant.\\n{memories}\"}\n",
|
||||
" ] + state[\"messages\"]\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# You can also use the store directly within a tool!\n",
|
||||
"def upsert_memory(\n",
|
||||
" content: str,\n",
|
||||
" *,\n",
|
||||
" memory_id: Optional[uuid.UUID] = None,\n",
|
||||
" store: Annotated[BaseStore, InjectedToolArg],\n",
|
||||
"):\n",
|
||||
" \"\"\"Upsert a memory in the database.\"\"\"\n",
|
||||
" # The LLM can use this tool to store a new memory\n",
|
||||
" mem_id = memory_id or uuid.uuid4()\n",
|
||||
" store.put(\n",
|
||||
" (\"user_123\", \"memories\"),\n",
|
||||
" key=str(mem_id),\n",
|
||||
" value={\"text\": content},\n",
|
||||
" )\n",
|
||||
" return f\"Stored memory {mem_id}\"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"agent = create_react_agent(\n",
|
||||
" init_chat_model(\"openai:gpt-4o-mini\"),\n",
|
||||
" tools=[upsert_memory],\n",
|
||||
" # The state_modifier is run to prepare the messages for the LLM. It is called\n",
|
||||
" # right before each LLM call\n",
|
||||
" state_modifier=prepare_messages,\n",
|
||||
" store=store,\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 7,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"What are you in the mood for? Since you love Italian food and pizza, maybe something in that realm would be great! Would you like suggestions for a specific dish or restaurant?"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"for message, metadata in agent.stream(\n",
|
||||
" input={\"messages\": [{\"role\": \"user\", \"content\": \"I'm hungry\"}]},\n",
|
||||
" stream_mode=\"messages\",\n",
|
||||
"):\n",
|
||||
" print(message.content, end=\"\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Advanced Usage\n",
|
||||
"\n",
|
||||
"#### Multi-vector indexing\n",
|
||||
"\n",
|
||||
"Store and search different aspects of memories separately to improve recall or omit certain fields from being indexed."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 8,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Expect mem 2\n",
|
||||
"Item: mem2; Score (0.5895009051396596)\n",
|
||||
"Memory: Ate alone at home\n",
|
||||
"Emotion: felt a bit lonely\n",
|
||||
"\n",
|
||||
"Expect mem1\n",
|
||||
"Item: mem1; Score (0.6207546534134083)\n",
|
||||
"Memory: Had pizza with friends at Mario's\n",
|
||||
"Emotion: felt happy and connected\n",
|
||||
"\n",
|
||||
"Expect random lower score (ravioli not indexed)\n",
|
||||
"Item: mem1; Score (0.2686278787315685)\n",
|
||||
"Memory: Had pizza with friends at Mario's\n",
|
||||
"Emotion: felt happy and connected\n",
|
||||
"\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"# Configure store to embed both memory content and emotional context\n",
|
||||
"store = InMemoryStore(\n",
|
||||
" index={\"embed\": embeddings, \"dims\": 1536, \"fields\": [\"memory\", \"emotional_context\"]}\n",
|
||||
")\n",
|
||||
"# Store memories with different content/emotion pairs\n",
|
||||
"store.put(\n",
|
||||
" (\"user_123\", \"memories\"),\n",
|
||||
" \"mem1\",\n",
|
||||
" {\n",
|
||||
" \"memory\": \"Had pizza with friends at Mario's\",\n",
|
||||
" \"emotional_context\": \"felt happy and connected\",\n",
|
||||
" \"this_isnt_indexed\": \"I prefer ravioli though\",\n",
|
||||
" },\n",
|
||||
")\n",
|
||||
"store.put(\n",
|
||||
" (\"user_123\", \"memories\"),\n",
|
||||
" \"mem2\",\n",
|
||||
" {\n",
|
||||
" \"memory\": \"Ate alone at home\",\n",
|
||||
" \"emotional_context\": \"felt a bit lonely\",\n",
|
||||
" \"this_isnt_indexed\": \"I like pie\",\n",
|
||||
" },\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Search focusing on emotional state - matches mem2\n",
|
||||
"results = store.search(\n",
|
||||
" (\"user_123\", \"memories\"), query=\"times they felt isolated\", limit=1\n",
|
||||
")\n",
|
||||
"print(\"Expect mem 2\")\n",
|
||||
"for r in results:\n",
|
||||
" print(f\"Item: {r.key}; Score ({r.score})\")\n",
|
||||
" print(f\"Memory: {r.value['memory']}\")\n",
|
||||
" print(f\"Emotion: {r.value['emotional_context']}\\n\")\n",
|
||||
"\n",
|
||||
"# Search focusing on social eating - matches mem1\n",
|
||||
"print(\"Expect mem1\")\n",
|
||||
"results = store.search((\"user_123\", \"memories\"), query=\"fun pizza\", limit=1)\n",
|
||||
"for r in results:\n",
|
||||
" print(f\"Item: {r.key}; Score ({r.score})\")\n",
|
||||
" print(f\"Memory: {r.value['memory']}\")\n",
|
||||
" print(f\"Emotion: {r.value['emotional_context']}\\n\")\n",
|
||||
"\n",
|
||||
"print(\"Expect random lower score (ravioli not indexed)\")\n",
|
||||
"results = store.search((\"user_123\", \"memories\"), query=\"ravioli\", limit=1)\n",
|
||||
"for r in results:\n",
|
||||
" print(f\"Item: {r.key}; Score ({r.score})\")\n",
|
||||
" print(f\"Memory: {r.value['memory']}\")\n",
|
||||
" print(f\"Emotion: {r.value['emotional_context']}\\n\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"#### Override fields at storage time\n",
|
||||
"You can override which fields to embed when storing a specific memory using `put(..., index=[...fields])`, regardless of the store's default configuration."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 9,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Expect mem1\n",
|
||||
"Item: mem1; Score (0.3374968677940555)\n",
|
||||
"Memory: I love spicy food\n",
|
||||
"Context: At a Thai restaurant\n",
|
||||
"\n",
|
||||
"Expect mem2\n",
|
||||
"Item: mem2; Score (0.36784461593247436)\n",
|
||||
"Memory: The restaurant was too loud\n",
|
||||
"Context: Dinner at an Italian place\n",
|
||||
"\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"store = InMemoryStore(\n",
|
||||
" index={\n",
|
||||
" \"embed\": embeddings,\n",
|
||||
" \"dims\": 1536,\n",
|
||||
" \"fields\": [\"memory\"],\n",
|
||||
" } # Default to embed memory field\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Store one memory with default indexing\n",
|
||||
"store.put(\n",
|
||||
" (\"user_123\", \"memories\"),\n",
|
||||
" \"mem1\",\n",
|
||||
" {\"memory\": \"I love spicy food\", \"context\": \"At a Thai restaurant\"},\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Store another overriding which fields to embed\n",
|
||||
"store.put(\n",
|
||||
" (\"user_123\", \"memories\"),\n",
|
||||
" \"mem2\",\n",
|
||||
" {\"memory\": \"The restaurant was too loud\", \"context\": \"Dinner at an Italian place\"},\n",
|
||||
" index=[\"context\"], # Override: only embed the context\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Search about food - matches mem1 (using default field)\n",
|
||||
"print(\"Expect mem1\")\n",
|
||||
"results = store.search(\n",
|
||||
" (\"user_123\", \"memories\"), query=\"what food do they like\", limit=1\n",
|
||||
")\n",
|
||||
"for r in results:\n",
|
||||
" print(f\"Item: {r.key}; Score ({r.score})\")\n",
|
||||
" print(f\"Memory: {r.value['memory']}\")\n",
|
||||
" print(f\"Context: {r.value['context']}\\n\")\n",
|
||||
"\n",
|
||||
"# Search about restaurant atmosphere - matches mem2 (using overridden field)\n",
|
||||
"print(\"Expect mem2\")\n",
|
||||
"results = store.search(\n",
|
||||
" (\"user_123\", \"memories\"), query=\"restaurant environment\", limit=1\n",
|
||||
")\n",
|
||||
"for r in results:\n",
|
||||
" print(f\"Item: {r.key}; Score ({r.score})\")\n",
|
||||
" print(f\"Memory: {r.value['memory']}\")\n",
|
||||
" print(f\"Context: {r.value['context']}\\n\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"#### Disable Indexing for Specific Memories\n",
|
||||
"\n",
|
||||
"Some memories shouldn't be searchable by content. You can disable indexing for these while still storing them using \n",
|
||||
"`put(..., index=False)`. Example:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 10,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Expect mem1\n",
|
||||
"Item: mem1; Score (0.32269984224327286)\n",
|
||||
"Memory: I love chocolate ice cream\n",
|
||||
"Type: preference\n",
|
||||
"\n",
|
||||
"Expect low score (mem2 not indexed)\n",
|
||||
"Item: mem1; Score (0.010241633698527089)\n",
|
||||
"Memory: I love chocolate ice cream\n",
|
||||
"Type: preference\n",
|
||||
"\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"store = InMemoryStore(index={\"embed\": embeddings, \"dims\": 1536, \"fields\": [\"memory\"]})\n",
|
||||
"\n",
|
||||
"# Store a normal indexed memory\n",
|
||||
"store.put(\n",
|
||||
" (\"user_123\", \"memories\"),\n",
|
||||
" \"mem1\",\n",
|
||||
" {\"memory\": \"I love chocolate ice cream\", \"type\": \"preference\"},\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Store a system memory without indexing\n",
|
||||
"store.put(\n",
|
||||
" (\"user_123\", \"memories\"),\n",
|
||||
" \"mem2\",\n",
|
||||
" {\"memory\": \"User completed onboarding\", \"type\": \"system\"},\n",
|
||||
" index=False, # Disable indexing entirely\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Search about food preferences - finds mem1\n",
|
||||
"print(\"Expect mem1\")\n",
|
||||
"results = store.search((\"user_123\", \"memories\"), query=\"what food preferences\", limit=1)\n",
|
||||
"for r in results:\n",
|
||||
" print(f\"Item: {r.key}; Score ({r.score})\")\n",
|
||||
" print(f\"Memory: {r.value['memory']}\")\n",
|
||||
" print(f\"Type: {r.value['type']}\\n\")\n",
|
||||
"\n",
|
||||
"# Search about onboarding - won't find mem2 (not indexed)\n",
|
||||
"print(\"Expect low score (mem2 not indexed)\")\n",
|
||||
"results = store.search((\"user_123\", \"memories\"), query=\"onboarding status\", limit=1)\n",
|
||||
"for r in results:\n",
|
||||
" print(f\"Item: {r.key}; Score ({r.score})\")\n",
|
||||
" print(f\"Memory: {r.value['memory']}\")\n",
|
||||
" print(f\"Type: {r.value['type']}\\n\")"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.11.2"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
@@ -13,3 +13,5 @@
|
||||
- PregelExecutableTask
|
||||
- StateSnapshot
|
||||
- Send
|
||||
- Command
|
||||
- interrupt
|
||||
|
||||
@@ -13,6 +13,7 @@ New to LangGraph or LLM app development? Read this material to get up and runnin
|
||||
- [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.
|
||||
- [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.
|
||||
- [LangGraph Template Quickstart](../concepts/template_applications.md): Quickly start building with LangGraph Platform using a template application.
|
||||
|
||||
## Use cases 🛠️
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ This is a quick start guide to help you get a LangGraph app up and running local
|
||||
## Install the LangGraph CLI
|
||||
|
||||
```bash
|
||||
pip install "langgraph-cli[inmem]==0.1.58" python-dotenv
|
||||
pip install -U "langgraph-cli[inmem]" python-dotenv
|
||||
```
|
||||
|
||||
## 🌱 Create a LangGraph App
|
||||
@@ -35,10 +35,10 @@ Create a new app from the `react-agent` template. This template is a simple agen
|
||||
|
||||
## Install Dependencies
|
||||
|
||||
In the root of your new LangGraph app, install the 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 .
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Create a `.env` file
|
||||
@@ -250,4 +250,4 @@ 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.
|
||||
- **[JS/TS SDK Reference](../../cloud/reference/sdk/js_ts_sdk_ref.md)**: Explore the Python SDK API Reference.
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"%%capture --no-stderr\n",
|
||||
"%pip install -U langgraph langchain_anthropic langsmith\n",
|
||||
"%pip install -U langgraph langchain_anthropic langsmith langchain-community\n",
|
||||
"%pip install -U sklearn langchain_openai"
|
||||
]
|
||||
},
|
||||
@@ -632,7 +632,7 @@
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from langchain.cache import InMemoryCache\n",
|
||||
"from langchain_community.cache import InMemoryCache\n",
|
||||
"from langchain.globals import set_llm_cache\n",
|
||||
"\n",
|
||||
"# Optional. If you are running into errors or rate limits and want to avoid repeated computation,\n",
|
||||
|
||||
@@ -151,6 +151,7 @@ nav:
|
||||
- how-tos/branching.ipynb
|
||||
- how-tos/map-reduce.ipynb
|
||||
- how-tos/recursion-limit.ipynb
|
||||
- how-tos/command.ipynb
|
||||
- Persistence:
|
||||
- Persistence: how-tos#persistence
|
||||
- how-tos/persistence.ipynb
|
||||
@@ -164,6 +165,7 @@ nav:
|
||||
- how-tos/memory/manage-conversation-history.ipynb
|
||||
- how-tos/memory/delete-messages.ipynb
|
||||
- how-tos/memory/add-summary-conversation-history.ipynb
|
||||
- how-tos/memory/semantic-search.ipynb
|
||||
- Human-in-the-loop:
|
||||
- Human-in-the-loop: how-tos#human-in-the-loop
|
||||
- how-tos/human_in_the_loop/breakpoints.ipynb
|
||||
@@ -225,6 +227,7 @@ nav:
|
||||
- cloud/deployment/setup.md
|
||||
- cloud/deployment/setup_pyproject.md
|
||||
- cloud/deployment/setup_javascript.md
|
||||
- cloud/deployment/semantic_search.md
|
||||
- cloud/deployment/custom_docker.md
|
||||
- cloud/deployment/test_locally.md
|
||||
- cloud/deployment/graph_rebuild.md
|
||||
|
||||
@@ -42,7 +42,7 @@ class DuckDBSaver(BaseDuckDBSaver):
|
||||
DuckDBSaver: A new DuckDBSaver instance.
|
||||
"""
|
||||
with duckdb.connect(conn_string) as conn:
|
||||
yield DuckDBSaver(conn)
|
||||
yield cls(conn)
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up the checkpoint database asynchronously.
|
||||
|
||||
@@ -45,7 +45,7 @@ class AsyncDuckDBSaver(BaseDuckDBSaver):
|
||||
AsyncDuckDBSaver: A new AsyncDuckDBSaver instance.
|
||||
"""
|
||||
with duckdb.connect(conn_string) as conn:
|
||||
yield AsyncDuckDBSaver(conn)
|
||||
yield cls(conn)
|
||||
|
||||
async def setup(self) -> None:
|
||||
"""Set up the checkpoint database asynchronously.
|
||||
|
||||
@@ -156,7 +156,7 @@ class AsyncDuckDBStore(AsyncBatchedBaseStore, BaseDuckDBStore):
|
||||
AsyncDuckDBStore: A new AsyncDuckDBStore instance.
|
||||
"""
|
||||
with duckdb.connect(conn_string) as conn:
|
||||
yield AsyncDuckDBStore(conn)
|
||||
yield cls(conn)
|
||||
|
||||
async def setup(self) -> None:
|
||||
"""Set up the store database asynchronously.
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import Any, Optional
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from psycopg import Capabilities, Connection, Cursor, Pipeline
|
||||
from psycopg.errors import UndefinedTable
|
||||
from psycopg.rows import DictRow, dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
from psycopg_pool import ConnectionPool
|
||||
@@ -76,16 +75,15 @@ class PostgresSaver(BasePostgresSaver):
|
||||
the first time checkpointer is used.
|
||||
"""
|
||||
with self._cursor() as cur:
|
||||
try:
|
||||
row = cur.execute(
|
||||
"SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
version = -1
|
||||
else:
|
||||
version = row["v"]
|
||||
except UndefinedTable:
|
||||
cur.execute(self.MIGRATIONS[0])
|
||||
results = cur.execute(
|
||||
"SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
|
||||
)
|
||||
row = results.fetchone()
|
||||
if row is None:
|
||||
version = -1
|
||||
else:
|
||||
version = row["v"]
|
||||
for v, migration in zip(
|
||||
range(version + 1, len(self.MIGRATIONS)),
|
||||
self.MIGRATIONS[version + 1 :],
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import Any, Optional
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
|
||||
from psycopg.errors import UndefinedTable
|
||||
from psycopg.rows import DictRow, dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
@@ -55,7 +54,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
pipeline: bool = False,
|
||||
serde: Optional[SerializerProtocol] = None,
|
||||
) -> AsyncIterator["AsyncPostgresSaver"]:
|
||||
"""Create a new PostgresSaver instance from a connection string.
|
||||
"""Create a new AsyncPostgresSaver instance from a connection string.
|
||||
|
||||
Args:
|
||||
conn_string (str): The Postgres connection info string.
|
||||
@@ -81,17 +80,15 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
the first time checkpointer is used.
|
||||
"""
|
||||
async with self._cursor() as cur:
|
||||
try:
|
||||
results = await cur.execute(
|
||||
"SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
|
||||
)
|
||||
row = await results.fetchone()
|
||||
if row is None:
|
||||
version = -1
|
||||
else:
|
||||
version = row["v"]
|
||||
except UndefinedTable:
|
||||
await cur.execute(self.MIGRATIONS[0])
|
||||
results = await cur.execute(
|
||||
"SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
|
||||
)
|
||||
row = await results.fetchone()
|
||||
if row is None:
|
||||
version = -1
|
||||
else:
|
||||
version = row["v"]
|
||||
for v, migration in zip(
|
||||
range(version + 1, len(self.MIGRATIONS)),
|
||||
self.MIGRATIONS[version + 1 :],
|
||||
|
||||
@@ -57,6 +57,17 @@ MIGRATIONS = [
|
||||
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
|
||||
);""",
|
||||
"ALTER TABLE checkpoint_blobs ALTER COLUMN blob DROP not null;",
|
||||
"""
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoints_thread_id_idx ON checkpoints(thread_id);
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoint_blobs_thread_id_idx ON checkpoint_blobs(thread_id);
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoint_writes_thread_id_idx ON checkpoint_writes(thread_id);
|
||||
""",
|
||||
]
|
||||
|
||||
SELECT_SQL = f"""
|
||||
|
||||
@@ -6,7 +6,6 @@ from typing import Any, Callable, Optional, Union, cast
|
||||
|
||||
import orjson
|
||||
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
|
||||
from psycopg.errors import UndefinedTable
|
||||
from psycopg.rows import DictRow, dict_row
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
|
||||
@@ -37,6 +36,75 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Conn]):
|
||||
"""Asynchronous Postgres-backed store with optional vector search using pgvector.
|
||||
|
||||
!!! example "Examples"
|
||||
Basic setup and key-value storage:
|
||||
```python
|
||||
from langgraph.store.postgres import AsyncPostgresStore
|
||||
|
||||
async with AsyncPostgresStore.from_conn_string(
|
||||
"postgresql://user:pass@localhost:5432/dbname"
|
||||
) as store:
|
||||
await store.setup()
|
||||
|
||||
# Store and retrieve data
|
||||
await store.aput(("users", "123"), "prefs", {"theme": "dark"})
|
||||
item = await store.aget(("users", "123"), "prefs")
|
||||
```
|
||||
|
||||
Vector search using LangChain embeddings:
|
||||
```python
|
||||
from langchain.embeddings import init_embeddings
|
||||
from langgraph.store.postgres import AsyncPostgresStore
|
||||
|
||||
async with AsyncPostgresStore.from_conn_string(
|
||||
"postgresql://user:pass@localhost:5432/dbname",
|
||||
index={
|
||||
"dims": 1536,
|
||||
"embed": init_embeddings("openai:text-embedding-3-small"),
|
||||
"fields": ["text"] # specify which fields to embed. Default is the whole serialized value
|
||||
}
|
||||
) as store:
|
||||
await store.setup() # Do this once to run migrations
|
||||
|
||||
# Store documents
|
||||
await store.aput(("docs",), "doc1", {"text": "Python tutorial"})
|
||||
await store.aput(("docs",), "doc2", {"text": "TypeScript guide"})
|
||||
# Don't index the following
|
||||
await store.aput(("docs",), "doc3", {"text": "Other guide"}, index=False)
|
||||
|
||||
# Search by similarity
|
||||
results = await store.asearch(("docs",), query="python programming")
|
||||
```
|
||||
|
||||
Using connection pooling for better performance:
|
||||
```python
|
||||
from langgraph.store.postgres import AsyncPostgresStore, PoolConfig
|
||||
|
||||
async with AsyncPostgresStore.from_conn_string(
|
||||
"postgresql://user:pass@localhost:5432/dbname",
|
||||
pool_config=PoolConfig(
|
||||
min_size=5,
|
||||
max_size=20
|
||||
)
|
||||
) as store:
|
||||
await store.setup()
|
||||
# Use store with connection pooling...
|
||||
```
|
||||
|
||||
Warning:
|
||||
Make sure to:
|
||||
1. Call `setup()` before first use to create necessary tables and indexes
|
||||
2. Have the pgvector extension available to use vector search
|
||||
3. Use Python 3.10+ for async functionality
|
||||
|
||||
Note:
|
||||
Semantic search is disabled by default. You can enable it by providing an `index` configuration
|
||||
when creating the store. Without this configuration, all `index` arguments passed to
|
||||
`put` or `aput`will have no effect.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_deserializer",
|
||||
"pipe",
|
||||
@@ -150,22 +218,19 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
|
||||
"""
|
||||
|
||||
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
|
||||
)
|
||||
"""
|
||||
await cur.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {table} (
|
||||
v INTEGER PRIMARY KEY
|
||||
)
|
||||
"""
|
||||
)
|
||||
await cur.execute(f"SELECT v FROM {table} ORDER BY v DESC LIMIT 1")
|
||||
row = cast(dict, await cur.fetchone())
|
||||
if row is None:
|
||||
version = -1
|
||||
else:
|
||||
version = row["v"]
|
||||
return version
|
||||
|
||||
async with self._cursor() as cur:
|
||||
|
||||
@@ -21,7 +21,6 @@ from typing import (
|
||||
|
||||
import orjson
|
||||
from psycopg import Capabilities, Connection, Cursor, Pipeline
|
||||
from psycopg.errors import UndefinedTable
|
||||
from psycopg.rows import DictRow, dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
from psycopg_pool import ConnectionPool
|
||||
@@ -56,6 +55,7 @@ class Migration(NamedTuple):
|
||||
|
||||
sql: str
|
||||
params: Optional[dict[str, Any]] = None
|
||||
condition: Optional[Callable[["BasePostgresStore"], bool]] = None
|
||||
|
||||
|
||||
MIGRATIONS: Sequence[str] = [
|
||||
@@ -72,7 +72,7 @@ CREATE TABLE IF NOT EXISTS store (
|
||||
""",
|
||||
"""
|
||||
-- For faster lookups by prefix
|
||||
CREATE INDEX IF NOT EXISTS store_prefix_idx ON store USING btree (prefix text_pattern_ops);
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS store_prefix_idx ON store USING btree (prefix text_pattern_ops);
|
||||
""",
|
||||
]
|
||||
|
||||
@@ -104,11 +104,29 @@ CREATE TABLE IF NOT EXISTS store_vectors (
|
||||
),
|
||||
},
|
||||
),
|
||||
# TODO: Add an HNSW or IVFFlat index depending on config
|
||||
# First must improve the search query when filtering by
|
||||
# namespace
|
||||
Migration(
|
||||
"""
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS store_vectors_embedding_idx ON store_vectors
|
||||
USING %(index_type)s (embedding %(ops)s)%(index_params)s;
|
||||
""",
|
||||
condition=lambda store: bool(
|
||||
store.index_config and _get_index_params(store)[0] != "flat"
|
||||
),
|
||||
params={
|
||||
"index_type": lambda store: _get_index_params(store)[0],
|
||||
"ops": lambda store: _get_vector_type_ops(store),
|
||||
"index_params": lambda store: (
|
||||
" WITH ("
|
||||
+ ", ".join(f"{k}={v}" for k, v in _get_index_params(store)[1].items())
|
||||
+ ")"
|
||||
if _get_index_params(store)[1]
|
||||
else ""
|
||||
),
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
C = TypeVar("C", bound=Union[_pg_internal.Conn, _ainternal.Conn])
|
||||
|
||||
|
||||
@@ -140,6 +158,8 @@ class PoolConfig(TypedDict, total=False):
|
||||
class ANNIndexConfig(TypedDict, total=False):
|
||||
"""Configuration for vector index in PostgreSQL store."""
|
||||
|
||||
kind: Literal["hnsw", "ivfflat", "flat"]
|
||||
"""Type of index to use: 'hnsw' for Hierarchical Navigable Small World, or 'ivfflat' for Inverted File Flat."""
|
||||
vector_type: Literal["vector", "halfvec"]
|
||||
"""Type of vector storage to use.
|
||||
Options:
|
||||
@@ -148,6 +168,35 @@ class ANNIndexConfig(TypedDict, total=False):
|
||||
"""
|
||||
|
||||
|
||||
class HNSWConfig(ANNIndexConfig, total=False):
|
||||
"""Configuration for HNSW (Hierarchical Navigable Small World) index."""
|
||||
|
||||
kind: Literal["hnsw"] # type: ignore[misc]
|
||||
m: int
|
||||
"""Maximum number of connections per layer. Default is 16."""
|
||||
ef_construction: int
|
||||
"""Size of dynamic candidate list for index construction. Default is 64."""
|
||||
|
||||
|
||||
class IVFFlatConfig(ANNIndexConfig, total=False):
|
||||
"""IVFFlat index divides vectors into lists, and then searches a subset of those lists that are closest to the query vector. It has faster build times and uses less memory than HNSW, but has lower query performance (in terms of speed-recall tradeoff).
|
||||
|
||||
Three keys to achieving good recall are:
|
||||
1. Create the index after the table has some data
|
||||
2. Choose an appropriate number of lists - a good place to start is rows / 1000 for up to 1M rows and sqrt(rows) for over 1M rows
|
||||
3. When querying, specify an appropriate number of probes (higher is better for recall, lower is better for speed) - a good place to start is sqrt(lists)
|
||||
"""
|
||||
|
||||
kind: Literal["ivfflat"] # type: ignore[misc]
|
||||
nlist: int
|
||||
"""Number of inverted lists (clusters) for IVF index.
|
||||
|
||||
Determines the number of clusters used in the index structure.
|
||||
Higher values can improve search speed but increase index size and build time.
|
||||
Typically set to the square root of the number of vectors in the index.
|
||||
"""
|
||||
|
||||
|
||||
class PostgresIndexConfig(IndexConfig, total=False):
|
||||
"""Configuration for vector embeddings in PostgreSQL store with pgvector-specific options.
|
||||
|
||||
@@ -321,7 +370,7 @@ class BasePostgresStore(Generic[C]):
|
||||
if op.query and self.index_config:
|
||||
embedding_requests.append((idx, op.query))
|
||||
|
||||
score_operator = _get_distance_operator(self)
|
||||
score_operator, post_operator = _get_distance_operator(self)
|
||||
vector_type = (
|
||||
cast(PostgresIndexConfig, self.index_config)
|
||||
.get("ann_index_config", {})
|
||||
@@ -351,18 +400,28 @@ class BasePostgresStore(Generic[C]):
|
||||
if not filter_conditions
|
||||
else " AND " + " AND ".join(filter_conditions)
|
||||
)
|
||||
if op.namespace_prefix:
|
||||
prefix_filter_str = f"WHERE s.prefix LIKE %s {filter_str} "
|
||||
ns_args: Sequence = (f"{_namespace_to_text(op.namespace_prefix)}%",)
|
||||
else:
|
||||
ns_args = ()
|
||||
if filter_str:
|
||||
prefix_filter_str = f"WHERE {filter_str} "
|
||||
else:
|
||||
prefix_filter_str = ""
|
||||
|
||||
base_query = f"""
|
||||
WITH scored AS (
|
||||
SELECT s.prefix, s.key, s.value, s.created_at, s.updated_at, {score_operator} AS score
|
||||
SELECT s.prefix, s.key, s.value, s.created_at, s.updated_at, {score_operator} AS neg_score
|
||||
FROM store s
|
||||
JOIN store_vectors sv ON s.prefix = sv.prefix AND s.key = sv.key
|
||||
WHERE s.prefix LIKE %s {filter_str}
|
||||
ORDER BY {score_operator} DESC
|
||||
{prefix_filter_str}
|
||||
ORDER BY {score_operator} ASC
|
||||
LIMIT %s
|
||||
)
|
||||
SELECT * FROM (
|
||||
SELECT DISTINCT ON (prefix, key)
|
||||
prefix, key, value, created_at, updated_at, score
|
||||
prefix, key, value, created_at, updated_at, {post_operator} as score
|
||||
FROM scored
|
||||
ORDER BY prefix, key, score DESC
|
||||
) AS unique_docs
|
||||
@@ -372,7 +431,7 @@ class BasePostgresStore(Generic[C]):
|
||||
"""
|
||||
params = [
|
||||
_PLACEHOLDER, # Vector placeholder
|
||||
f"{_namespace_to_text(op.namespace_prefix)}%",
|
||||
*ns_args,
|
||||
*filter_params,
|
||||
_PLACEHOLDER,
|
||||
expanded_limit,
|
||||
@@ -474,6 +533,58 @@ class BasePostgresStore(Generic[C]):
|
||||
|
||||
|
||||
class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
|
||||
"""Postgres-backed store with optional vector search using pgvector.
|
||||
|
||||
!!! example "Examples"
|
||||
Basic setup and key-value storage:
|
||||
```python
|
||||
from langgraph.store.postgres import PostgresStore
|
||||
|
||||
store = PostgresStore(
|
||||
connection_string="postgresql://user:pass@localhost:5432/dbname"
|
||||
)
|
||||
store.setup()
|
||||
|
||||
# Store and retrieve data
|
||||
store.put(("users", "123"), "prefs", {"theme": "dark"})
|
||||
item = store.get(("users", "123"), "prefs")
|
||||
```
|
||||
|
||||
Vector search using LangChain embeddings:
|
||||
```python
|
||||
from langchain.embeddings import init_embeddings
|
||||
from langgraph.store.postgres import PostgresStore
|
||||
|
||||
store = PostgresStore(
|
||||
connection_string="postgresql://user:pass@localhost:5432/dbname",
|
||||
index={
|
||||
"dims": 1536,
|
||||
"embed": init_embeddings("openai:text-embedding-3-small"),
|
||||
"fields": ["text"] # specify which fields to embed. Default is the whole serialized value
|
||||
}
|
||||
)
|
||||
store.setup() # Do this once to run migrations
|
||||
|
||||
# Store documents
|
||||
store.put(("docs",), "doc1", {"text": "Python tutorial"})
|
||||
store.put(("docs",), "doc2", {"text": "TypeScript guide"})
|
||||
store.put(("docs",), "doc2", {"text": "Other guide"}, index=False) # don't index
|
||||
|
||||
# Search by similarity
|
||||
results = store.search(("docs",), query="python programming")
|
||||
```
|
||||
|
||||
Note:
|
||||
Semantic search is disabled by default. You can enable it by providing an `index` configuration
|
||||
when creating the store. Without this configuration, all `index` arguments passed to
|
||||
`put` or `aput`will have no effect.
|
||||
|
||||
Warning:
|
||||
Make sure to call `setup()` before first use to create necessary tables and indexes.
|
||||
The pgvector extension must be available to use vector search.
|
||||
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_deserializer",
|
||||
"pipe",
|
||||
@@ -702,7 +813,6 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
|
||||
_paramslist[i] = embedding
|
||||
|
||||
for (idx, _), (query, params) in zip(search_ops, queries):
|
||||
# Execute the actual query
|
||||
cur.execute(query, params)
|
||||
rows = cast(list[Row], cur.fetchall())
|
||||
results[idx] = [
|
||||
@@ -736,22 +846,19 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
|
||||
"""
|
||||
|
||||
def _get_version(cur: Cursor[dict[str, Any]], table: str) -> int:
|
||||
try:
|
||||
cur.execute(f"SELECT v FROM {table} ORDER BY v DESC LIMIT 1")
|
||||
row = cast(dict, cur.fetchone())
|
||||
if row is None:
|
||||
version = -1
|
||||
else:
|
||||
version = row["v"]
|
||||
except UndefinedTable:
|
||||
version = -1
|
||||
cur.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {table} (
|
||||
v INTEGER PRIMARY KEY
|
||||
)
|
||||
"""
|
||||
cur.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {table} (
|
||||
v INTEGER PRIMARY KEY
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(f"SELECT v FROM {table} ORDER BY v DESC LIMIT 1")
|
||||
row = cast(dict, cur.fetchone())
|
||||
if row is None:
|
||||
version = -1
|
||||
else:
|
||||
version = row["v"]
|
||||
return version
|
||||
|
||||
with self._cursor() as cur:
|
||||
@@ -765,6 +872,8 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
|
||||
for v, migration in enumerate(
|
||||
self.VECTOR_MIGRATIONS[version + 1 :], start=version + 1
|
||||
):
|
||||
if migration.condition and not migration.condition(self):
|
||||
continue
|
||||
sql = migration.sql
|
||||
if migration.params:
|
||||
params = {
|
||||
@@ -823,6 +932,18 @@ def _get_vector_type_ops(store: BasePostgresStore) -> str:
|
||||
return f"{type_prefix}_{distance_suffix}"
|
||||
|
||||
|
||||
def _get_index_params(store: Any) -> tuple[str, dict[str, Any]]:
|
||||
"""Get the index type and configuration based on config."""
|
||||
if not store.index_config:
|
||||
return "hnsw", {}
|
||||
|
||||
config = cast(PostgresIndexConfig, store.index_config)
|
||||
index_config = config.get("ann_index_config", _DEFAULT_ANN_CONFIG).copy()
|
||||
kind = index_config.pop("kind", "hnsw")
|
||||
index_config.pop("vector_type", None)
|
||||
return kind, index_config
|
||||
|
||||
|
||||
def _namespace_to_text(
|
||||
namespace: tuple[str, ...], handle_wildcards: bool = False
|
||||
) -> str:
|
||||
@@ -915,7 +1036,7 @@ def _decode_ns_bytes(namespace: Union[str, bytes, list]) -> tuple[str, ...]:
|
||||
return tuple(namespace.split("."))
|
||||
|
||||
|
||||
def _get_distance_operator(store: Any) -> str:
|
||||
def _get_distance_operator(store: Any) -> tuple[str, str]:
|
||||
"""Get the distance operator and score expression based on config."""
|
||||
# Note: Today, we are not using ANN indices due to restrictions
|
||||
# on PGVector's support for mixing vector and non-vector filters
|
||||
@@ -936,12 +1057,22 @@ def _get_distance_operator(store: Any) -> str:
|
||||
config = cast(PostgresIndexConfig, store.index_config)
|
||||
distance_type = config.get("distance_type", "cosine")
|
||||
|
||||
# Return the operator and the score expression
|
||||
# The operator is used in the CTE and will be compatible with an ASCENDING ORDER
|
||||
# sort clause.
|
||||
# The score expression is used in the final query and will be compatible with
|
||||
# a DESCENDING ORDER sort clause and the user's expectations of what the similarity score
|
||||
# should be.
|
||||
if distance_type == "l2":
|
||||
return "1 - (sv.embedding <-> %s::%s)"
|
||||
# Final: "-(sv.embedding <-> %s::%s)"
|
||||
# We return the "l2 similarity" so that the sorting order is the same
|
||||
return "sv.embedding <-> %s::%s", "-scored.neg_score"
|
||||
elif distance_type == "inner_product":
|
||||
return "-(sv.embedding <#> %s::%s)"
|
||||
else: # cosine
|
||||
return "1 - (sv.embedding <=> %s::%s)"
|
||||
# Final: "-(sv.embedding <#> %s::%s)"
|
||||
return "sv.embedding <#> %s::%s", "-(scored.neg_score)"
|
||||
else: # cosine similarity
|
||||
# Final: "1 - (sv.embedding <=> %s::%s)"
|
||||
return "sv.embedding <=> %s::%s", "1 - scored.neg_score"
|
||||
|
||||
|
||||
def _ensure_index_config(
|
||||
|
||||
Generated
+14
-15
@@ -13,24 +13,24 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.6.2.post1"
|
||||
version = "4.7.0"
|
||||
description = "High level compatibility layer for multiple asynchronous event loop implementations"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d"},
|
||||
{file = "anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c"},
|
||||
{file = "anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352"},
|
||||
{file = "anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""}
|
||||
idna = ">=2.8"
|
||||
sniffio = ">=1.1"
|
||||
typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""}
|
||||
typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
|
||||
|
||||
[package.extras]
|
||||
doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
|
||||
test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"]
|
||||
doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
|
||||
test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"]
|
||||
trio = ["trio (>=0.26.1)"]
|
||||
|
||||
[[package]]
|
||||
@@ -244,13 +244,13 @@ trio = ["trio (>=0.22.0,<1.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.27.2"
|
||||
version = "0.28.0"
|
||||
description = "The next generation HTTP client."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"},
|
||||
{file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"},
|
||||
{file = "httpx-0.28.0-py3-none-any.whl", hash = "sha256:dc0b419a0cfeb6e8b34e85167c0da2671206f5095f1baa9663d23bcfd6b535fc"},
|
||||
{file = "httpx-0.28.0.tar.gz", hash = "sha256:0858d3bab51ba7e386637f22a61d8ccddaeec5f3fe4209da3a6168dbb91573e0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -258,7 +258,6 @@ anyio = "*"
|
||||
certifi = "*"
|
||||
httpcore = "==1.*"
|
||||
idna = "*"
|
||||
sniffio = "*"
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotli", "brotlicffi"]
|
||||
@@ -342,7 +341,7 @@ typing-extensions = ">=4.7"
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "2.0.7"
|
||||
version = "2.0.8"
|
||||
description = "Library with base interfaces for LangGraph checkpoint savers."
|
||||
optional = false
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
@@ -741,13 +740,13 @@ typing-extensions = ">=4.6"
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.10.2"
|
||||
version = "2.10.3"
|
||||
description = "Data validation using Python type hints"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "pydantic-2.10.2-py3-none-any.whl", hash = "sha256:cfb96e45951117c3024e6b67b25cdc33a3cb7b2fa62e239f7af1378358a1d99e"},
|
||||
{file = "pydantic-2.10.2.tar.gz", hash = "sha256:2bc2d7f17232e0841cbba4641e65ba1eb6fafb3a08de3a091ff3ce14a197c4fa"},
|
||||
{file = "pydantic-2.10.3-py3-none-any.whl", hash = "sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d"},
|
||||
{file = "pydantic-2.10.3.tar.gz", hash = "sha256:cb5ac360ce894ceacd69c403187900a02c4b20b693a9dd1d643e1effab9eadf9"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1219,4 +1218,4 @@ watchmedo = ["PyYAML (>=3.10)"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
content-hash = "35bd5ff50127337dbbdd1b9937bae6190d8f5c9a6808e334c7f45e5a471773fe"
|
||||
content-hash = "d64fe96797a79103d952c13d4dc0a0296c468b6615b4872aa01cb34479ca4104"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "2.0.5"
|
||||
version = "2.0.8"
|
||||
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
|
||||
authors = []
|
||||
license = "MIT"
|
||||
@@ -12,8 +12,8 @@ packages = [{ include = "langgraph" }]
|
||||
python = "^3.9.0,<4.0"
|
||||
langgraph-checkpoint = "^2.0.7"
|
||||
orjson = ">=3.10.1"
|
||||
psycopg = "^3.0.0"
|
||||
psycopg-pool = "^3.0.0"
|
||||
psycopg = "^3.2.0"
|
||||
psycopg-pool = "^3.2.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "^0.6.2"
|
||||
|
||||
@@ -7,6 +7,7 @@ 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"
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
# type: ignore
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
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 (
|
||||
Checkpoint,
|
||||
@@ -10,104 +17,211 @@ from langgraph.checkpoint.base import (
|
||||
empty_checkpoint,
|
||||
)
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from tests.conftest import DEFAULT_URI
|
||||
from tests.conftest import DEFAULT_POSTGRES_URI
|
||||
|
||||
|
||||
class TestAsyncPostgresSaver:
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup(self) -> None:
|
||||
# objects for test setup
|
||||
self.config_1: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": "thread-1",
|
||||
# for backwards compatibility testing
|
||||
"thread_ts": "1",
|
||||
"checkpoint_ns": "",
|
||||
}
|
||||
}
|
||||
self.config_2: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": "thread-2",
|
||||
"checkpoint_id": "2",
|
||||
"checkpoint_ns": "",
|
||||
}
|
||||
}
|
||||
self.config_3: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": "thread-2",
|
||||
"checkpoint_id": "2-inner",
|
||||
"checkpoint_ns": "inner",
|
||||
}
|
||||
}
|
||||
@asynccontextmanager
|
||||
async def _pool_saver():
|
||||
"""Fixture for pool 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:
|
||||
# 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}")
|
||||
|
||||
self.chkpnt_1: Checkpoint = empty_checkpoint()
|
||||
self.chkpnt_2: Checkpoint = create_checkpoint(self.chkpnt_1, {}, 1)
|
||||
self.chkpnt_3: Checkpoint = empty_checkpoint()
|
||||
|
||||
self.metadata_1: CheckpointMetadata = {
|
||||
"source": "input",
|
||||
"step": 2,
|
||||
"writes": {},
|
||||
"score": 1,
|
||||
@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:
|
||||
checkpointer = AsyncPostgresSaver(conn)
|
||||
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.metadata_2: CheckpointMetadata = {
|
||||
"source": "loop",
|
||||
}
|
||||
config_2: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": "thread-2",
|
||||
"checkpoint_id": "2",
|
||||
"checkpoint_ns": "",
|
||||
}
|
||||
}
|
||||
config_3: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": "thread-2",
|
||||
"checkpoint_id": "2-inner",
|
||||
"checkpoint_ns": "inner",
|
||||
}
|
||||
}
|
||||
|
||||
chkpnt_1: Checkpoint = empty_checkpoint()
|
||||
chkpnt_2: Checkpoint = create_checkpoint(chkpnt_1, {}, 1)
|
||||
chkpnt_3: Checkpoint = empty_checkpoint()
|
||||
|
||||
metadata_1: CheckpointMetadata = {
|
||||
"source": "input",
|
||||
"step": 2,
|
||||
"writes": {},
|
||||
"score": 1,
|
||||
}
|
||||
metadata_2: CheckpointMetadata = {
|
||||
"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,
|
||||
"writes": {"foo": "bar"},
|
||||
"score": None,
|
||||
}
|
||||
self.metadata_3: CheckpointMetadata = {}
|
||||
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
await saver.setup()
|
||||
} # search by multiple keys
|
||||
query_3: dict[str, Any] = {} # search by no keys, return all checkpoints
|
||||
query_4 = {"source": "update", "step": 1} # no match
|
||||
|
||||
async def test_asearch(self) -> None:
|
||||
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
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_1 = [c async for c in saver.alist(None, filter=query_1)]
|
||||
assert len(search_results_1) == 1
|
||||
assert search_results_1[0].metadata == metadata[0]
|
||||
|
||||
# call method / assertions
|
||||
query_1 = {"source": "input"} # search by 1 key
|
||||
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_2 = [c async for c in saver.alist(None, filter=query_2)]
|
||||
assert len(search_results_2) == 1
|
||||
assert search_results_2[0].metadata == metadata[1]
|
||||
|
||||
search_results_1 = [c async for c in saver.alist(None, filter=query_1)]
|
||||
assert len(search_results_1) == 1
|
||||
assert search_results_1[0].metadata == self.metadata_1
|
||||
search_results_3 = [c async for c in saver.alist(None, filter=query_3)]
|
||||
assert len(search_results_3) == 3
|
||||
|
||||
search_results_2 = [c async for c in saver.alist(None, filter=query_2)]
|
||||
assert len(search_results_2) == 1
|
||||
assert search_results_2[0].metadata == self.metadata_2
|
||||
search_results_4 = [c async for c in saver.alist(None, filter=query_4)]
|
||||
assert len(search_results_4) == 0
|
||||
|
||||
search_results_3 = [c async for c in saver.alist(None, filter=query_3)]
|
||||
assert len(search_results_3) == 3
|
||||
# search by config (defaults to checkpoints across all namespaces)
|
||||
search_results_5 = [
|
||||
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
|
||||
|
||||
# search by config (defaults to checkpoints across all namespaces)
|
||||
search_results_5 = [
|
||||
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"}
|
||||
|
||||
# TODO: test before and limit params
|
||||
|
||||
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"
|
||||
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
|
||||
async def test_null_chars(request, saver_name: str, test_data) -> None:
|
||||
async with _saver(saver_name) as saver:
|
||||
config = await saver.aput(
|
||||
test_data["configs"][0],
|
||||
test_data["checkpoints"][0],
|
||||
{"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"
|
||||
|
||||
@@ -634,6 +634,7 @@ def test_embed_with_path_operation_config(
|
||||
distance_type: str,
|
||||
) -> None:
|
||||
"""Test operation-level field configuration for vector search."""
|
||||
|
||||
with _create_vector_store(
|
||||
vector_type,
|
||||
distance_type,
|
||||
@@ -695,3 +696,89 @@ def test_embed_with_path_operation_config(
|
||||
# assert len(results) == 3
|
||||
# doc5_result = next(r for r in results if r.key == "doc5")
|
||||
# assert doc5_result.score is None
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
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 _inner_product(X: list[float], Y: list[list[float]]) -> list[float]:
|
||||
"""
|
||||
Compute inner product between a vector X and a matrix Y.
|
||||
Lazy import numpy for efficiency.
|
||||
"""
|
||||
|
||||
similarities = []
|
||||
for y in Y:
|
||||
similarity = sum(a * b for a, b in zip(X, y))
|
||||
similarities.append(similarity)
|
||||
|
||||
return similarities
|
||||
|
||||
|
||||
def _neg_l2_distance(X: list[float], Y: list[list[float]]) -> list[float]:
|
||||
"""
|
||||
Compute l2 distance between a vector X and a matrix Y.
|
||||
Lazy import numpy for efficiency.
|
||||
"""
|
||||
|
||||
similarities = []
|
||||
for y in Y:
|
||||
similarity = sum((a - b) ** 2 for a, b in zip(X, y)) ** 0.5
|
||||
similarities.append(-similarity)
|
||||
|
||||
return similarities
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"vector_type,distance_type",
|
||||
[
|
||||
("vector", "cosine"),
|
||||
("vector", "inner_product"),
|
||||
("halfvec", "l2"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("query", ["aaa", "bbb", "ccc", "abcd", "poisson"])
|
||||
def test_scores(
|
||||
fake_embeddings: CharacterEmbeddings,
|
||||
vector_type: str,
|
||||
distance_type: str,
|
||||
query: str,
|
||||
) -> None:
|
||||
"""Test operation-level field configuration for vector search."""
|
||||
with _create_vector_store(
|
||||
vector_type,
|
||||
distance_type,
|
||||
fake_embeddings,
|
||||
text_fields=["key0"],
|
||||
) as store:
|
||||
doc = {
|
||||
"key0": "aaa",
|
||||
}
|
||||
store.put(("test",), "doc", doc, index=["key0", "key1"])
|
||||
|
||||
results = store.search((), query=query)
|
||||
vec0 = fake_embeddings.embed_query(doc["key0"])
|
||||
vec1 = fake_embeddings.embed_query(query)
|
||||
if distance_type == "cosine":
|
||||
similarities = _cosine_similarity(vec1, [vec0])
|
||||
elif distance_type == "inner_product":
|
||||
similarities = _inner_product(vec1, [vec0])
|
||||
elif distance_type == "l2":
|
||||
similarities = _neg_l2_distance(vec1, [vec0])
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].score == pytest.approx(similarities[0], abs=1e-3)
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
# type: ignore
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
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 (
|
||||
Checkpoint,
|
||||
@@ -10,103 +17,198 @@ from langgraph.checkpoint.base import (
|
||||
empty_checkpoint,
|
||||
)
|
||||
from langgraph.checkpoint.postgres import PostgresSaver
|
||||
from tests.conftest import DEFAULT_URI
|
||||
from tests.conftest import DEFAULT_POSTGRES_URI
|
||||
|
||||
|
||||
class TestPostgresSaver:
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self) -> None:
|
||||
# objects for test setup
|
||||
self.config_1: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": "thread-1",
|
||||
# for backwards compatibility testing
|
||||
"thread_ts": "1",
|
||||
"checkpoint_ns": "",
|
||||
}
|
||||
}
|
||||
self.config_2: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": "thread-2",
|
||||
"checkpoint_id": "2",
|
||||
"checkpoint_ns": "",
|
||||
}
|
||||
}
|
||||
self.config_3: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": "thread-2",
|
||||
"checkpoint_id": "2-inner",
|
||||
"checkpoint_ns": "inner",
|
||||
}
|
||||
}
|
||||
@contextmanager
|
||||
def _pool_saver():
|
||||
"""Fixture for pool 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:
|
||||
# yield checkpointer
|
||||
with ConnectionPool(
|
||||
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}")
|
||||
|
||||
self.chkpnt_1: Checkpoint = empty_checkpoint()
|
||||
self.chkpnt_2: Checkpoint = create_checkpoint(self.chkpnt_1, {}, 1)
|
||||
self.chkpnt_3: Checkpoint = empty_checkpoint()
|
||||
|
||||
self.metadata_1: CheckpointMetadata = {
|
||||
"source": "input",
|
||||
"step": 2,
|
||||
"writes": {},
|
||||
"score": 1,
|
||||
@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:
|
||||
checkpointer = PostgresSaver(conn)
|
||||
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.metadata_2: CheckpointMetadata = {
|
||||
"source": "loop",
|
||||
}
|
||||
config_2: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": "thread-2",
|
||||
"checkpoint_id": "2",
|
||||
"checkpoint_ns": "",
|
||||
}
|
||||
}
|
||||
config_3: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": "thread-2",
|
||||
"checkpoint_id": "2-inner",
|
||||
"checkpoint_ns": "inner",
|
||||
}
|
||||
}
|
||||
|
||||
chkpnt_1: Checkpoint = empty_checkpoint()
|
||||
chkpnt_2: Checkpoint = create_checkpoint(chkpnt_1, {}, 1)
|
||||
chkpnt_3: Checkpoint = empty_checkpoint()
|
||||
|
||||
metadata_1: CheckpointMetadata = {
|
||||
"source": "input",
|
||||
"step": 2,
|
||||
"writes": {},
|
||||
"score": 1,
|
||||
}
|
||||
metadata_2: CheckpointMetadata = {
|
||||
"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,
|
||||
"writes": {"foo": "bar"},
|
||||
"score": None,
|
||||
}
|
||||
self.metadata_3: CheckpointMetadata = {}
|
||||
with PostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
saver.setup()
|
||||
} # search by multiple keys
|
||||
query_3: dict[str, Any] = {} # search by no keys, return all checkpoints
|
||||
query_4 = {"source": "update", "step": 1} # no match
|
||||
|
||||
def test_search(self) -> None:
|
||||
with PostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||
# 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_1 = list(saver.list(None, filter=query_1))
|
||||
assert len(search_results_1) == 1
|
||||
assert search_results_1[0].metadata == metadata[0]
|
||||
|
||||
# call method / assertions
|
||||
query_1 = {"source": "input"} # search by 1 key
|
||||
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_2 = list(saver.list(None, filter=query_2))
|
||||
assert len(search_results_2) == 1
|
||||
assert search_results_2[0].metadata == metadata[1]
|
||||
|
||||
search_results_1 = list(saver.list(None, filter=query_1))
|
||||
assert len(search_results_1) == 1
|
||||
assert search_results_1[0].metadata == self.metadata_1
|
||||
search_results_3 = list(saver.list(None, filter=query_3))
|
||||
assert len(search_results_3) == 3
|
||||
|
||||
search_results_2 = list(saver.list(None, filter=query_2))
|
||||
assert len(search_results_2) == 1
|
||||
assert search_results_2[0].metadata == self.metadata_2
|
||||
search_results_4 = list(saver.list(None, filter=query_4))
|
||||
assert len(search_results_4) == 0
|
||||
|
||||
search_results_3 = list(saver.list(None, filter=query_3))
|
||||
assert len(search_results_3) == 3
|
||||
# search by config (defaults to checkpoints across all namespaces)
|
||||
search_results_5 = list(saver.list({"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 = list(saver.list(None, filter=query_4))
|
||||
assert len(search_results_4) == 0
|
||||
|
||||
# search by config (defaults to checkpoints across all namespaces)
|
||||
search_results_5 = list(
|
||||
saver.list({"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"}
|
||||
|
||||
# TODO: test before and limit params
|
||||
|
||||
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"
|
||||
)
|
||||
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
|
||||
def test_null_chars(saver_name: str, test_data) -> None:
|
||||
with _saver(saver_name) as saver:
|
||||
config = saver.put(
|
||||
test_data["configs"][0],
|
||||
test_data["checkpoints"][0],
|
||||
{"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"]
|
||||
== "abc"
|
||||
)
|
||||
|
||||
@@ -110,7 +110,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
||||
check_same_thread=False,
|
||||
)
|
||||
) as conn:
|
||||
yield SqliteSaver(conn)
|
||||
yield cls(conn)
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up the checkpoint database.
|
||||
|
||||
@@ -137,7 +137,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
||||
AsyncSqliteSaver: A new AsyncSqliteSaver instance.
|
||||
"""
|
||||
async with aiosqlite.connect(conn_string) as conn:
|
||||
yield AsyncSqliteSaver(conn)
|
||||
yield cls(conn)
|
||||
|
||||
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
||||
"""Get a checkpoint tuple from the database.
|
||||
|
||||
@@ -4,9 +4,9 @@ Stores provide long-term memory that persists across threads and conversations.
|
||||
Supports hierarchical namespaces, key-value storage, and optional vector search.
|
||||
|
||||
Core types:
|
||||
- BaseStore: Store interface with sync/async operations
|
||||
- Item: Stored key-value pairs with metadata
|
||||
- Op: Get/Put/Search/List operations
|
||||
- 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
|
||||
@@ -80,16 +80,19 @@ class Item:
|
||||
|
||||
def dict(self) -> dict:
|
||||
return {
|
||||
"value": self.value,
|
||||
"key": self.key,
|
||||
"namespace": list(self.namespace),
|
||||
"key": self.key,
|
||||
"value": self.value,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Item({', '.join(f'{k}={v!r}' for k, v in self.dict().items())})"
|
||||
|
||||
|
||||
class SearchItem(Item):
|
||||
"""Represents a result item with additional response metadata."""
|
||||
"""Represents an item returned from a search operation with additional metadata."""
|
||||
|
||||
__slots__ = ("score",)
|
||||
|
||||
@@ -133,7 +136,7 @@ class GetOp(NamedTuple):
|
||||
This operation allows precise retrieval of stored items using their full path
|
||||
(namespace) and unique identifier (key) combination.
|
||||
|
||||
??? example "Examples"
|
||||
???+ example "Examples"
|
||||
|
||||
Basic item retrieval:
|
||||
```python
|
||||
@@ -145,7 +148,7 @@ class GetOp(NamedTuple):
|
||||
namespace: tuple[str, ...]
|
||||
"""Hierarchical path that uniquely identifies the item's location.
|
||||
|
||||
??? example "Examples"
|
||||
???+ example "Examples"
|
||||
|
||||
```python
|
||||
("users",) # Root level users namespace
|
||||
@@ -156,7 +159,7 @@ class GetOp(NamedTuple):
|
||||
key: str
|
||||
"""Unique identifier for the item within its specific namespace.
|
||||
|
||||
??? example "Examples"
|
||||
???+ example "Examples"
|
||||
|
||||
```python
|
||||
"user123" # For a user profile
|
||||
@@ -175,7 +178,7 @@ class SearchOp(NamedTuple):
|
||||
Note:
|
||||
Natural language search support depends on your store implementation.
|
||||
|
||||
??? example "Examples"
|
||||
???+ example "Examples"
|
||||
Search with filters and pagination:
|
||||
```python
|
||||
SearchOp(
|
||||
@@ -199,7 +202,7 @@ class SearchOp(NamedTuple):
|
||||
namespace_prefix: tuple[str, ...]
|
||||
"""Hierarchical path prefix defining the search scope.
|
||||
|
||||
??? example "Examples"
|
||||
???+ example "Examples"
|
||||
|
||||
```python
|
||||
() # Search entire store
|
||||
@@ -221,8 +224,7 @@ class SearchOp(NamedTuple):
|
||||
- $lt: Less than
|
||||
- $lte: Less than or equal to
|
||||
|
||||
??? example "Examples"
|
||||
|
||||
???+ example "Examples"
|
||||
Simple exact match:
|
||||
|
||||
```python
|
||||
@@ -243,9 +245,6 @@ class SearchOp(NamedTuple):
|
||||
"color": "red"
|
||||
}
|
||||
```
|
||||
|
||||
Note:
|
||||
Comparison operator support depends on your store implementation.
|
||||
"""
|
||||
|
||||
limit: int = 10
|
||||
@@ -257,7 +256,7 @@ class SearchOp(NamedTuple):
|
||||
query: Optional[str] = None
|
||||
"""Natural language search query for semantic search capabilities.
|
||||
|
||||
??? example "Examples"
|
||||
???+ example "Examples"
|
||||
- "technical documentation about REST APIs"
|
||||
- "machine learning papers from 2023"
|
||||
"""
|
||||
@@ -267,10 +266,12 @@ class SearchOp(NamedTuple):
|
||||
NamespacePath = tuple[Union[str, Literal["*"]], ...]
|
||||
"""A tuple representing a namespace path that can include wildcards.
|
||||
|
||||
Examples:
|
||||
???+ example "Examples"
|
||||
```python
|
||||
("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
|
||||
@@ -290,7 +291,7 @@ class MatchCondition(NamedTuple):
|
||||
pattern that can include wildcards to flexibly match different namespace
|
||||
hierarchies.
|
||||
|
||||
??? example "Examples"
|
||||
???+ example "Examples"
|
||||
Prefix matching:
|
||||
```python
|
||||
MatchCondition(match_type="prefix", path=("users", "profiles"))
|
||||
@@ -320,7 +321,7 @@ class ListNamespacesOp(NamedTuple):
|
||||
This operation allows exploring the organization of data, finding specific
|
||||
collections, and navigating the namespace hierarchy.
|
||||
|
||||
??? example "Examples"
|
||||
???+ example "Examples"
|
||||
|
||||
List all namespaces under the "documents" path:
|
||||
```python
|
||||
@@ -343,7 +344,7 @@ class ListNamespacesOp(NamedTuple):
|
||||
match_conditions: Optional[tuple[MatchCondition, ...]] = None
|
||||
"""Optional conditions for filtering namespaces.
|
||||
|
||||
??? example "Examples"
|
||||
???+ example "Examples"
|
||||
All user namespaces:
|
||||
```python
|
||||
(MatchCondition(match_type="prefix", path=("users",)),)
|
||||
@@ -385,7 +386,7 @@ class PutOp(NamedTuple):
|
||||
The namespace acts as a folder-like structure to organize items.
|
||||
Each element in the tuple represents one level in the hierarchy.
|
||||
|
||||
??? example "Examples"
|
||||
???+ example "Examples"
|
||||
Root level documents
|
||||
```python
|
||||
("documents",)
|
||||
@@ -431,9 +432,9 @@ class PutOp(NamedTuple):
|
||||
"""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
|
||||
- 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
|
||||
@@ -447,15 +448,14 @@ class PutOp(NamedTuple):
|
||||
- Last element: "array[-1]"
|
||||
- All elements (each individually): "array[*]"
|
||||
|
||||
??? example "Examples"
|
||||
- None - Use store defaults
|
||||
- False - Don't index this item
|
||||
???+ example "Examples"
|
||||
- None - Use store defaults (whole item)
|
||||
- list[str] - List of fields to index
|
||||
|
||||
```python
|
||||
[
|
||||
"metadata.title", # Nested field access
|
||||
"chapters[*].content", # Index content from all chapters as separate vectors
|
||||
"context[*].content", # Index content from all context 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
|
||||
@@ -474,28 +474,126 @@ class InvalidNamespaceError(ValueError):
|
||||
|
||||
|
||||
class IndexConfig(TypedDict, total=False):
|
||||
"""Configuration for indexing documents for semantic search in the store."""
|
||||
"""Configuration for indexing documents for semantic search in the store.
|
||||
|
||||
If not provided to the store, the store will not support vector search.
|
||||
In that case, all `index` arguments to put() and `aput()` operations will be ignored.
|
||||
"""
|
||||
|
||||
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
|
||||
- openai:text-embedding-3-large: 3072
|
||||
- openai:text-embedding-3-small: 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: Union[Embeddings, EmbeddingsFunc, AEmbeddingsFunc]
|
||||
"""Optional function to generate embeddings from text."""
|
||||
"""Optional function to generate embeddings from text.
|
||||
|
||||
Can be specified in three ways:
|
||||
1. A LangChain Embeddings instance
|
||||
2. A synchronous embedding function (EmbeddingsFunc)
|
||||
3. An asynchronous embedding function (AEmbeddingsFunc)
|
||||
|
||||
???+ example "Examples"
|
||||
Using LangChain's initialization with InMemoryStore:
|
||||
```python
|
||||
from langchain.embeddings import init_embeddings
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
store = InMemoryStore(
|
||||
index={
|
||||
"dims": 1536,
|
||||
"embed": init_embeddings("openai:text-embedding-3-small")
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Using a custom embedding function with InMemoryStore:
|
||||
```python
|
||||
from openai import OpenAI
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
client = OpenAI()
|
||||
|
||||
def embed_texts(texts: list[str]) -> list[list[float]]:
|
||||
response = client.embeddings.create(
|
||||
model="text-embedding-3-small",
|
||||
input=texts
|
||||
)
|
||||
return [e.embedding for e in response.data]
|
||||
|
||||
store = InMemoryStore(
|
||||
index={
|
||||
"dims": 1536,
|
||||
"embed": embed_texts
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Using an asynchronous embedding function with InMemoryStore:
|
||||
```python
|
||||
from openai import AsyncOpenAI
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
client = AsyncOpenAI()
|
||||
|
||||
async def aembed_texts(texts: list[str]) -> list[list[float]]:
|
||||
response = await client.embeddings.create(
|
||||
model="text-embedding-3-small",
|
||||
input=texts
|
||||
)
|
||||
return [e.embedding for e in response.data]
|
||||
|
||||
store = InMemoryStore(
|
||||
index={
|
||||
"dims": 1536,
|
||||
"embed": aembed_texts
|
||||
}
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
fields: Optional[list[str]]
|
||||
"""Fields to extract text from for embedding generation.
|
||||
|
||||
Defaults to the root ["$"], which embeds the json object as a whole.
|
||||
Controls which parts of stored items are embedded for semantic search. Follows JSON path syntax:
|
||||
|
||||
- ["$"]: Embeds the entire JSON object as one vector (default)
|
||||
- ["field1", "field2"]: Embeds specific top-level fields
|
||||
- ["parent.child"]: Embeds nested fields using dot notation
|
||||
- ["array[*].field"]: Embeds field from each array element separately
|
||||
|
||||
Note:
|
||||
You can always override this behavior when storing an item using the
|
||||
`index` parameter in the `put` or `aput` operations.
|
||||
|
||||
???+ example "Examples"
|
||||
```python
|
||||
# Embed entire document (default)
|
||||
fields=["$"]
|
||||
|
||||
# Embed specific fields
|
||||
fields=["text", "summary"]
|
||||
|
||||
# Embed nested fields
|
||||
fields=["metadata.title", "content.body"]
|
||||
|
||||
# Embed from arrays
|
||||
fields=["messages[*].content"] # Each message content separately
|
||||
fields=["context[0].text"] # First context item's text
|
||||
```
|
||||
|
||||
Note:
|
||||
- Fields missing from a document are skipped
|
||||
- Array notation creates separate embeddings for each element
|
||||
- Complex nested paths are supported (e.g., "a.b[*].c.d")
|
||||
"""
|
||||
|
||||
|
||||
@@ -504,6 +602,15 @@ class BaseStore(ABC):
|
||||
|
||||
Stores enable persistence and memory that can be shared across threads,
|
||||
scoped to user IDs, assistant IDs, or other arbitrary namespaces.
|
||||
Some implementations may support semantic search capabilities through
|
||||
an optional `index` configuration.
|
||||
|
||||
Note:
|
||||
Semantic search capabilities vary by implementation and are typically
|
||||
disabled by default. Stores that support this feature can be configured
|
||||
by providing an `index` configuration at creation time. Without this
|
||||
configuration, semantic search is disabled and any `index` arguments
|
||||
to storage operations will have no effect.
|
||||
"""
|
||||
|
||||
__slots__ = ("__weakref__",)
|
||||
@@ -565,6 +672,39 @@ class BaseStore(ABC):
|
||||
|
||||
Returns:
|
||||
List of items matching the search criteria.
|
||||
|
||||
???+ example "Examples"
|
||||
Basic filtering:
|
||||
```python
|
||||
# Search for documents with specific metadata
|
||||
results = store.search(
|
||||
("docs",),
|
||||
filter={"type": "article", "status": "published"}
|
||||
)
|
||||
```
|
||||
|
||||
Natural language search (requires vector store implementation):
|
||||
```python
|
||||
# Initialize store with embedding configuration
|
||||
store = YourStore( # e.g., InMemoryStore, AsyncPostgresStore
|
||||
index={
|
||||
"dims": 1536, # embedding dimensions
|
||||
"embed": your_embedding_function, # function to create embeddings
|
||||
"fields": ["text"] # fields to embed. Defaults to ["$"]
|
||||
}
|
||||
)
|
||||
|
||||
# Search for semantically similar documents
|
||||
results = store.search(
|
||||
("docs",),
|
||||
query="machine learning applications in healthcare",
|
||||
filter={"type": "research_paper"},
|
||||
limit=5
|
||||
)
|
||||
```
|
||||
|
||||
Note: Natural language search support depends on your store implementation
|
||||
and requires proper embedding configuration.
|
||||
"""
|
||||
return self.batch([SearchOp(namespace_prefix, filter, limit, offset, query)])[0]
|
||||
|
||||
@@ -585,7 +725,10 @@ class BaseStore(ABC):
|
||||
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
|
||||
|
||||
- None (default): Use `fields` you configured when creating the store (if any)
|
||||
If you do not initialize the store with indexing capabilities,
|
||||
the `index` parameter will be ignored
|
||||
- False: Disable indexing for this item
|
||||
- list[str]: List of field paths to index, supporting:
|
||||
- Nested fields: "metadata.title"
|
||||
@@ -593,23 +736,25 @@ class BaseStore(ABC):
|
||||
- Specific indices: "authors[0].name"
|
||||
|
||||
Note:
|
||||
Indexing capabilities depend on your store implementation.
|
||||
Some implementations may support only a subset of indexing features.
|
||||
Indexing support depends on your store implementation.
|
||||
If you do not initialize the store with indexing capabilities,
|
||||
the `index` parameter will be ignored.
|
||||
|
||||
??? example "Examples"
|
||||
Simple storage without special indexing (respects store defaults)
|
||||
???+ example "Examples"
|
||||
Store item. Indexing depends on how you configure the store.
|
||||
```python
|
||||
store.put(("docs",), "report", {"title": "Annual Report"})
|
||||
store.put(("docs",), "report", {"memory": "Will likes ai"})
|
||||
```
|
||||
|
||||
Index specific fields for search
|
||||
Do not index item for semantic search. Still accessible through get()
|
||||
and search() operations but won't have a vector representation.
|
||||
```python
|
||||
store.put(("docs",), "report", {"title": "Annual Report"}, index=["title"])
|
||||
store.put(("docs",), "report", {"memory": "Will likes ai"}, index=False)
|
||||
```
|
||||
|
||||
Do not index for semantic search
|
||||
Index specific fields for search.
|
||||
```python
|
||||
store.put(("docs",), "report", {"title": "Annual Report"}, index=False)
|
||||
store.put(("docs",), "report", {"memory": "Will likes ai"}, index=["memory"])
|
||||
```
|
||||
"""
|
||||
_validate_namespace(namespace)
|
||||
@@ -650,7 +795,7 @@ class BaseStore(ABC):
|
||||
List[Tuple[str, ...]]: A list of namespace tuples that match the criteria.
|
||||
Each tuple represents a full namespace path up to `max_depth`.
|
||||
|
||||
??? example "Examples":
|
||||
???+ example "Examples":
|
||||
Setting max_depth=3. Given the namespaces:
|
||||
```python
|
||||
# Example if you have the following namespaces:
|
||||
@@ -710,6 +855,39 @@ class BaseStore(ABC):
|
||||
|
||||
Returns:
|
||||
List of items matching the search criteria.
|
||||
|
||||
???+ example "Examples"
|
||||
Basic filtering:
|
||||
```python
|
||||
# Search for documents with specific metadata
|
||||
results = await store.asearch(
|
||||
("docs",),
|
||||
filter={"type": "article", "status": "published"}
|
||||
)
|
||||
```
|
||||
|
||||
Natural language search (requires vector store implementation):
|
||||
```python
|
||||
# Initialize store with embedding configuration
|
||||
store = YourStore( # e.g., InMemoryStore, AsyncPostgresStore
|
||||
index={
|
||||
"dims": 1536, # embedding dimensions
|
||||
"embed": your_embedding_function, # function to create embeddings
|
||||
"fields": ["text"] # fields to embed
|
||||
}
|
||||
)
|
||||
|
||||
# Search for semantically similar documents
|
||||
results = await store.asearch(
|
||||
("docs",),
|
||||
query="machine learning applications in healthcare",
|
||||
filter={"type": "research_paper"},
|
||||
limit=5
|
||||
)
|
||||
```
|
||||
|
||||
Note: Natural language search support depends on your store implementation
|
||||
and requires proper embedding configuration.
|
||||
"""
|
||||
return (
|
||||
await self.abatch(
|
||||
@@ -734,7 +912,10 @@ class BaseStore(ABC):
|
||||
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
|
||||
|
||||
- None (default): Use `fields` you configured when creating the store (if any)
|
||||
If you do not initialize the store with indexing capabilities,
|
||||
the `index` parameter will be ignored
|
||||
- False: Disable indexing for this item
|
||||
- list[str]: List of field paths to index, supporting:
|
||||
- Nested fields: "metadata.title"
|
||||
@@ -742,25 +923,32 @@ class BaseStore(ABC):
|
||||
- Specific indices: "authors[0].name"
|
||||
|
||||
Note:
|
||||
Indexing capabilities depend on your store implementation.
|
||||
Some implementations may support only a subset of indexing features.
|
||||
Indexing support depends on your store implementation.
|
||||
If you do not initialize the store with indexing capabilities,
|
||||
the `index` parameter will be ignored.
|
||||
|
||||
??? example "Examples"
|
||||
Simple storage without special indexing:
|
||||
???+ example "Examples"
|
||||
Store item. Indexing depends on how you configure the store.
|
||||
```python
|
||||
await store.aput(("docs",), "report", {"title": "Annual Report"})
|
||||
await store.aput(("docs",), "report", {"memory": "Will likes ai"})
|
||||
```
|
||||
|
||||
Index specific fields for search:
|
||||
Do not index item for semantic search. Still accessible through get()
|
||||
and search() operations but won't have a vector representation.
|
||||
```python
|
||||
await store.aput(("docs",), "report", {"memory": "Will likes ai"}, index=False)
|
||||
```
|
||||
|
||||
Index specific fields for search (if store configured to index items):
|
||||
```python
|
||||
await store.aput(
|
||||
("docs",),
|
||||
"report",
|
||||
{
|
||||
"title": "Q4 Report",
|
||||
"chapters": [{"content": "..."}, {"content": "..."}]
|
||||
"memory": "Will likes ai",
|
||||
"context": [{"content": "..."}, {"content": "..."}]
|
||||
},
|
||||
index=["title", "chapters[*].content"]
|
||||
index=["memory", "context[*].content"]
|
||||
)
|
||||
```
|
||||
"""
|
||||
@@ -802,7 +990,7 @@ class BaseStore(ABC):
|
||||
List[Tuple[str, ...]]: A list of namespace tuples that match the criteria.
|
||||
Each tuple represents a full namespace path up to `max_depth`.
|
||||
|
||||
??? example "Examples"
|
||||
???+ example "Examples"
|
||||
Setting max_depth=3 with existing namespaces:
|
||||
```python
|
||||
# Given the following namespaces:
|
||||
|
||||
@@ -1,31 +1,102 @@
|
||||
"""In-memory key-value store.
|
||||
"""In-memory dictionary-backed store with optional vector search.
|
||||
|
||||
A lightweight store implementation using Python dictionaries. Supports basic
|
||||
key-value operations and vector search when configured with embeddings.
|
||||
|
||||
Examples:
|
||||
!!! example "Examples"
|
||||
Basic key-value storage:
|
||||
store = InMemoryStore()
|
||||
store.put(("users", "123"), "prefs", {"theme": "dark"})
|
||||
item = store.get(("users", "123"), "prefs")
|
||||
```python
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
Vector search with embeddings:
|
||||
from langchain_openai import OpenAIEmbeddings
|
||||
store = InMemoryStore(index={
|
||||
store = InMemoryStore()
|
||||
store.put(("users", "123"), "prefs", {"theme": "dark"})
|
||||
item = store.get(("users", "123"), "prefs")
|
||||
```
|
||||
|
||||
Vector search using LangChain embeddings:
|
||||
```python
|
||||
from langchain.embeddings import init_embeddings
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
store = InMemoryStore(
|
||||
index={
|
||||
"dims": 1536,
|
||||
"embed": OpenAIEmbeddings(model="text-embedding-3-small"),
|
||||
})
|
||||
"embed": init_embeddings("openai:text-embedding-3-small")
|
||||
}
|
||||
)
|
||||
|
||||
# Store documents
|
||||
store.put(("docs",), "doc1", {"text": "Python tutorial"})
|
||||
store.put(("docs",), "doc2", {"text": "TypeScript guide"})
|
||||
# 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")
|
||||
# Search by similarity
|
||||
results = store.search(("docs",), query="python programming")
|
||||
```
|
||||
|
||||
Vector search using OpenAI SDK directly:
|
||||
```python
|
||||
from openai import OpenAI
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
Note:
|
||||
For production use cases requiring persistence, use a database-backed store instead.
|
||||
client = OpenAI()
|
||||
|
||||
def embed_texts(texts: list[str]) -> list[list[float]]:
|
||||
response = client.embeddings.create(
|
||||
model="text-embedding-3-small",
|
||||
input=texts
|
||||
)
|
||||
return [e.embedding for e in response.data]
|
||||
|
||||
store = InMemoryStore(
|
||||
index={
|
||||
"dims": 1536,
|
||||
"embed": embed_texts
|
||||
}
|
||||
)
|
||||
|
||||
# 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")
|
||||
```
|
||||
|
||||
Async vector search using OpenAI SDK:
|
||||
```python
|
||||
from openai import AsyncOpenAI
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
client = AsyncOpenAI()
|
||||
|
||||
async def aembed_texts(texts: list[str]) -> list[list[float]]:
|
||||
response = await client.embeddings.create(
|
||||
model="text-embedding-3-small",
|
||||
input=texts
|
||||
)
|
||||
return [e.embedding for e in response.data]
|
||||
|
||||
store = InMemoryStore(
|
||||
index={
|
||||
"dims": 1536,
|
||||
"embed": aembed_texts
|
||||
}
|
||||
)
|
||||
|
||||
# Store documents
|
||||
await store.aput(("docs",), "doc1", {"text": "Python tutorial"})
|
||||
await store.aput(("docs",), "doc2", {"text": "TypeScript guide"})
|
||||
|
||||
# Search by similarity
|
||||
results = await store.asearch(("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
|
||||
```
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -62,17 +133,18 @@ logger = logging.getLogger(__name__)
|
||||
class InMemoryStore(BaseStore):
|
||||
"""In-memory dictionary-backed store with optional vector search.
|
||||
|
||||
Examples:
|
||||
!!! example "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
|
||||
from langchain.embeddings import init_embeddings
|
||||
store = InMemoryStore(index={
|
||||
"dims": 1536,
|
||||
"embed": OpenAIEmbeddings(model="text-embedding-3-small"),
|
||||
"embed": init_embeddings("openai:text-embedding-3-small"),
|
||||
"fields": ["text"],
|
||||
})
|
||||
|
||||
# Store documents
|
||||
@@ -82,6 +154,11 @@ class InMemoryStore(BaseStore):
|
||||
# Search by similarity
|
||||
results = store.search(("docs",), query="python programming")
|
||||
|
||||
Note:
|
||||
Semantic search is disabled by default. You can enable it by providing an `index` configuration
|
||||
when creating the store. Without this configuration, all `index` arguments passed to
|
||||
`put` or `aput`will have no effect.
|
||||
|
||||
Warning:
|
||||
This store keeps all data in memory. Data is lost when the process exits.
|
||||
For persistence, use a database-backed store like PostgresStore.
|
||||
@@ -413,6 +490,8 @@ 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 not Y:
|
||||
return []
|
||||
if _check_numpy():
|
||||
import numpy as np # type: ignore
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "2.0.7"
|
||||
version = "2.0.8"
|
||||
description = "Library with base interfaces for LangGraph checkpoint savers."
|
||||
authors = []
|
||||
license = "MIT"
|
||||
|
||||
@@ -550,6 +550,12 @@ def dockerfile(save_path: str, config: pathlib.Path, add_docker_compose: bool) -
|
||||
type=int,
|
||||
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(
|
||||
"dev",
|
||||
help="🏃♀️➡️ Run LangGraph API server in development mode with hot reloading and debugging support",
|
||||
@@ -563,6 +569,7 @@ def dev(
|
||||
n_jobs_per_worker: Optional[int],
|
||||
no_browser: bool,
|
||||
debug_port: Optional[int],
|
||||
wait_for_client: bool,
|
||||
):
|
||||
"""CLI entrypoint for running the LangGraph API server."""
|
||||
try:
|
||||
@@ -595,9 +602,6 @@ def dev(
|
||||
sys.path.append(str(dep_path))
|
||||
|
||||
graphs = config_json.get("graphs", {})
|
||||
additional_config = {}
|
||||
if config_json.get("store"):
|
||||
additional_config["store"] = config_json["store"]
|
||||
|
||||
run_server(
|
||||
host,
|
||||
@@ -607,8 +611,9 @@ def dev(
|
||||
n_jobs_per_worker=n_jobs_per_worker,
|
||||
open_browser=not no_browser,
|
||||
debug_port=debug_port,
|
||||
env=config_json.get("env", None),
|
||||
config=additional_config,
|
||||
env=config_json.get("env"),
|
||||
store=config_json.get("store"),
|
||||
wait_for_client=wait_for_client,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,13 +17,13 @@ class IndexConfig(TypedDict, total=False):
|
||||
"""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
|
||||
- openai:text-embedding-3-large: 3072
|
||||
- openai:text-embedding-3-small: 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
|
||||
@@ -392,14 +392,12 @@ RUN set -ex && \\
|
||||
],
|
||||
)
|
||||
)
|
||||
additional_config = {}
|
||||
if config.get("store"):
|
||||
additional_config["store"] = config["store"]
|
||||
store_config = config.get("store")
|
||||
env_additional_config = (
|
||||
""
|
||||
if not additional_config
|
||||
if not store_config
|
||||
else f"""
|
||||
ENV LANGGRAPH_CONFIG='{json.dumps(additional_config)}'
|
||||
ENV LANGGRAPH_STORE='{json.dumps(store_config)}'
|
||||
"""
|
||||
)
|
||||
return f"""FROM {base_image}:{config['python_version']}
|
||||
@@ -439,14 +437,12 @@ def node_config_to_docker(config_path: pathlib.Path, config: Config, base_image:
|
||||
install_cmd = "npm ci"
|
||||
else:
|
||||
install_cmd = "npm i"
|
||||
additional_config = {}
|
||||
if config.get("store"):
|
||||
additional_config["store"] = config["store"]
|
||||
store_config = config.get("store")
|
||||
env_additional_config = (
|
||||
""
|
||||
if not additional_config
|
||||
if not store_config
|
||||
else f"""
|
||||
ENV LANGGRAPH_CONFIG='{json.dumps(additional_config)}'
|
||||
ENV LANGGRAPH_STORE='{json.dumps(store_config)}'
|
||||
"""
|
||||
)
|
||||
return f"""FROM {base_image}:{config['node_version']}
|
||||
|
||||
Generated
+4
-4
@@ -565,13 +565,13 @@ langgraph-sdk = ">=0.1.32,<0.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-api"
|
||||
version = "0.0.5"
|
||||
version = "0.0.6"
|
||||
description = ""
|
||||
optional = true
|
||||
python-versions = "<4.0,>=3.11.0"
|
||||
files = [
|
||||
{file = "langgraph_api-0.0.5-py3-none-any.whl", hash = "sha256:9c981c489924f5d7e67ce7a3a9908ede15ac266e699deff6f5de691af5b49931"},
|
||||
{file = "langgraph_api-0.0.5.tar.gz", hash = "sha256:f7ff041f1706152a2587916f0373f513e456cde067c94f8266ff29f6af908d20"},
|
||||
{file = "langgraph_api-0.0.6-py3-none-any.whl", hash = "sha256:f64b13959d721143f6a023af5b9ffc9aa054064af98d21d5d8090cda7e7bffd2"},
|
||||
{file = "langgraph_api-0.0.6.tar.gz", hash = "sha256:badac44fa1ec979509e56fc0da57eeb5f278ee5871f27803f73ea6d8822c21b9"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1561,4 +1561,4 @@ inmem = ["langgraph-api", "python-dotenv"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
content-hash = "7388b141c48dd6cfa33b504822e5348ac514ac2e2711d37368d7cb7103c8340a"
|
||||
content-hash = "8eaaa66d9e6e447699e3bcee336dfe779b58c956f8c2ad6678008a07be935838"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-cli"
|
||||
version = "0.1.60"
|
||||
version = "0.1.61"
|
||||
description = "CLI for interacting with LangGraph API"
|
||||
authors = []
|
||||
license = "MIT"
|
||||
@@ -14,7 +14,7 @@ langgraph = "langgraph_cli.cli:cli"
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.9.0,<4.0"
|
||||
click = "^8.1.7"
|
||||
langgraph-api = { version = ">=0.0.5,<0.1.0", optional = true, python = ">=3.11,<4.0" }
|
||||
langgraph-api = { version = ">=0.0.6,<0.1.0", optional = true, python = ">=3.11,<4.0" }
|
||||
python-dotenv = { version = ">=0.8.0", optional = true }
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
|
||||
@@ -48,6 +48,12 @@ test:
|
||||
make stop-postgres; \
|
||||
exit $$EXIT_CODE
|
||||
|
||||
test_parallel:
|
||||
make start-postgres && poetry run pytest -n auto --dist worksteal $(TEST); \
|
||||
EXIT_CODE=$$?; \
|
||||
make stop-postgres; \
|
||||
exit $$EXIT_CODE
|
||||
|
||||
WORKERS ?= auto
|
||||
XDIST_ARGS := $(if $(WORKERS),-n $(WORKERS) --dist worksteal,)
|
||||
MAXFAIL ?=
|
||||
|
||||
@@ -72,9 +72,11 @@ CONFIG_KEY_CHECKPOINT_ID = sys.intern("checkpoint_id")
|
||||
CONFIG_KEY_CHECKPOINT_NS = sys.intern("checkpoint_ns")
|
||||
# holds the current checkpoint_ns, "" for root graph
|
||||
CONFIG_KEY_NODE_FINISHED = sys.intern("__pregel_node_finished")
|
||||
# callback to be called when a node is finished
|
||||
CONFIG_KEY_RESUME_VALUE = sys.intern("__pregel_resume_value")
|
||||
# holds the value that "answers" an interrupt() call
|
||||
CONFIG_KEY_WRITES = sys.intern("__pregel_writes")
|
||||
# read-only list of existing task writes
|
||||
CONFIG_KEY_SCRATCHPAD = sys.intern("__pregel_scratchpad")
|
||||
# holds a mutable dict for temporary storage scoped to the current task
|
||||
|
||||
# --- Other constants ---
|
||||
PUSH = sys.intern("__pregel_push")
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
from langgraph.graph.graph import END, START, Graph
|
||||
from langgraph.graph.message import MessageGraph, MessagesState, add_messages
|
||||
from langgraph.graph.state import GraphCommand, StateGraph
|
||||
from langgraph.graph.state import StateGraph
|
||||
|
||||
__all__ = [
|
||||
"END",
|
||||
"START",
|
||||
"Graph",
|
||||
"StateGraph",
|
||||
"GraphCommand",
|
||||
"MessageGraph",
|
||||
"add_messages",
|
||||
"MessagesState",
|
||||
|
||||
@@ -374,6 +374,11 @@ class Graph:
|
||||
if source not in self.nodes and source != START:
|
||||
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
|
||||
all_targets = {end for _, end in self._all_edges}
|
||||
for start, branches in self.branches.items():
|
||||
@@ -395,10 +400,6 @@ class Graph:
|
||||
for name, spec in self.nodes.items():
|
||||
if 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:
|
||||
if target not in self.nodes and target != END:
|
||||
raise ValueError(f"Found edge ending at unknown node `{target}`")
|
||||
@@ -628,3 +629,10 @@ class CompiledGraph(Pregel):
|
||||
add_edge(key, end, conditional=True)
|
||||
|
||||
return graph
|
||||
|
||||
def _repr_mimebundle_(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Mime bundle used by Jupyter to display the graph"""
|
||||
return {
|
||||
"text/plain": repr(self),
|
||||
"image/png": self.get_graph().draw_mermaid_png(),
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import dataclasses
|
||||
import inspect
|
||||
import logging
|
||||
import typing
|
||||
@@ -9,7 +8,6 @@ from types import FunctionType
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Generic,
|
||||
Literal,
|
||||
NamedTuple,
|
||||
Optional,
|
||||
@@ -53,9 +51,13 @@ from langgraph.managed.base import (
|
||||
is_writable_managed_value,
|
||||
)
|
||||
from langgraph.pregel.read import ChannelRead, PregelNode
|
||||
from langgraph.pregel.write import SKIP_WRITE, ChannelWrite, ChannelWriteEntry
|
||||
from langgraph.pregel.write import (
|
||||
ChannelWrite,
|
||||
ChannelWriteEntry,
|
||||
ChannelWriteTupleEntry,
|
||||
)
|
||||
from langgraph.store.base import BaseStore
|
||||
from langgraph.types import _DC_KWARGS, All, Checkpointer, Command, N, RetryPolicy
|
||||
from langgraph.types import All, Checkpointer, Command, RetryPolicy
|
||||
from langgraph.utils.fields import get_field_default
|
||||
from langgraph.utils.pydantic import create_model
|
||||
from langgraph.utils.runnable import RunnableCallable, coerce_to_runnable
|
||||
@@ -84,22 +86,6 @@ def _get_node_name(node: RunnableLike) -> str:
|
||||
raise TypeError(f"Unsupported node type: {type(node)}")
|
||||
|
||||
|
||||
@dataclasses.dataclass(**_DC_KWARGS)
|
||||
class GraphCommand(Generic[N], Command[N]):
|
||||
"""One or more commands to update a StateGraph's state and go to, or send messages to nodes."""
|
||||
|
||||
goto: Union[str, Sequence[str]] = ()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
# get all non-None values
|
||||
contents = ", ".join(
|
||||
f"{key}={value!r}"
|
||||
for key, value in dataclasses.asdict(self).items()
|
||||
if value
|
||||
)
|
||||
return f"Command({contents})"
|
||||
|
||||
|
||||
class StateNodeSpec(NamedTuple):
|
||||
runnable: Runnable
|
||||
metadata: Optional[dict[str, Any]]
|
||||
@@ -392,7 +378,7 @@ class StateGraph(Graph):
|
||||
input = input_hint
|
||||
if (
|
||||
(rtn := hints.get("return"))
|
||||
and get_origin(rtn) in (Command, GraphCommand)
|
||||
and get_origin(rtn) is Command
|
||||
and (rargs := get_args(rtn))
|
||||
and get_origin(rargs[0]) is Literal
|
||||
and (vals := get_args(rargs[0]))
|
||||
@@ -626,33 +612,53 @@ class CompiledStateGraph(CompiledGraph):
|
||||
if is_writable_managed_value(v)
|
||||
]
|
||||
|
||||
def _get_root(input: Any) -> Any:
|
||||
if isinstance(input, Command):
|
||||
if input.graph == Command.PARENT:
|
||||
return SKIP_WRITE
|
||||
return input.update
|
||||
else:
|
||||
return input
|
||||
|
||||
# to avoid name collision below
|
||||
node_key = key
|
||||
|
||||
def _get_state_key(input: Union[None, dict, Any], *, key: str) -> Any:
|
||||
if input is None:
|
||||
return SKIP_WRITE
|
||||
elif isinstance(input, dict):
|
||||
if all(k not in output_keys for k in input):
|
||||
raise InvalidUpdateError(
|
||||
f"Expected node {node_key} to update at least one of {output_keys}, got {input}"
|
||||
)
|
||||
return input.get(key, SKIP_WRITE)
|
||||
def _get_root(input: Any) -> Optional[Sequence[tuple[str, Any]]]:
|
||||
if (
|
||||
isinstance(input, (list, tuple))
|
||||
and input
|
||||
and all(isinstance(i, Command) for i in input)
|
||||
):
|
||||
updates: list[tuple[str, Any]] = []
|
||||
for i in input:
|
||||
if i.graph == Command.PARENT:
|
||||
continue
|
||||
updates.extend(i._update_as_tuples())
|
||||
return updates
|
||||
elif isinstance(input, Command):
|
||||
if input.graph == Command.PARENT:
|
||||
return SKIP_WRITE
|
||||
return _get_state_key(input.update, key=key)
|
||||
return ()
|
||||
return input._update_as_tuples()
|
||||
elif input is not None:
|
||||
return [("__root__", input)]
|
||||
|
||||
def _get_updates(
|
||||
input: Union[None, dict, Any],
|
||||
) -> Optional[Sequence[tuple[str, Any]]]:
|
||||
if input is None:
|
||||
return None
|
||||
elif isinstance(input, dict):
|
||||
return [(k, v) for k, v in input.items() if k in output_keys]
|
||||
elif isinstance(input, Command):
|
||||
if input.graph == Command.PARENT:
|
||||
return None
|
||||
return input._update_as_tuples()
|
||||
elif (
|
||||
isinstance(input, (list, tuple))
|
||||
and input
|
||||
and all(isinstance(i, Command) for i in input)
|
||||
):
|
||||
updates: list[tuple[str, Any]] = []
|
||||
for i in input:
|
||||
if i.graph == Command.PARENT:
|
||||
continue
|
||||
updates.extend(i._update_as_tuples())
|
||||
return updates
|
||||
elif get_type_hints(type(input)):
|
||||
value = getattr(input, key, SKIP_WRITE)
|
||||
return value if value is not None else SKIP_WRITE
|
||||
return [
|
||||
(k, getattr(input, k))
|
||||
for k in output_keys
|
||||
if getattr(input, k, None) is not None
|
||||
]
|
||||
else:
|
||||
msg = create_error_message(
|
||||
message=f"Expected dict, got {input}",
|
||||
@@ -661,14 +667,11 @@ class CompiledStateGraph(CompiledGraph):
|
||||
raise InvalidUpdateError(msg)
|
||||
|
||||
# state updaters
|
||||
write_entries = (
|
||||
[ChannelWriteEntry("__root__", skip_none=True, mapper=_get_root)]
|
||||
if output_keys == ["__root__"]
|
||||
else [
|
||||
ChannelWriteEntry(key, mapper=partial(_get_state_key, key=key))
|
||||
for key in output_keys
|
||||
]
|
||||
)
|
||||
write_entries: list[Union[ChannelWriteEntry, ChannelWriteTupleEntry]] = [
|
||||
ChannelWriteTupleEntry(
|
||||
mapper=_get_root if output_keys == ["__root__"] else _get_updates
|
||||
)
|
||||
]
|
||||
|
||||
# add node and output channel
|
||||
if key == START:
|
||||
@@ -703,7 +706,7 @@ class CompiledStateGraph(CompiledGraph):
|
||||
writers=[
|
||||
# publish to this channel and state keys
|
||||
ChannelWrite(
|
||||
[ChannelWriteEntry(key, key)] + write_entries,
|
||||
write_entries + [ChannelWriteEntry(key, key)],
|
||||
tags=[TAG_HIDDEN],
|
||||
),
|
||||
],
|
||||
@@ -829,38 +832,54 @@ def _coerce_state(schema: Type[Any], input: dict[str, Any]) -> dict[str, Any]:
|
||||
def _control_branch(value: Any) -> Sequence[Union[str, Send]]:
|
||||
if isinstance(value, Send):
|
||||
return [value]
|
||||
if not isinstance(value, GraphCommand):
|
||||
commands: list[Command] = []
|
||||
if isinstance(value, Command):
|
||||
commands.append(value)
|
||||
elif (
|
||||
isinstance(value, (list, tuple))
|
||||
and value
|
||||
and all(isinstance(i, Command) for i in value)
|
||||
):
|
||||
commands.extend(value)
|
||||
else:
|
||||
return EMPTY_SEQ
|
||||
if value.graph == Command.PARENT:
|
||||
raise ParentCommand(value)
|
||||
rtn: list[Union[str, Send]] = []
|
||||
if isinstance(value.goto, str):
|
||||
rtn.append(value.goto)
|
||||
else:
|
||||
rtn.extend(value.goto)
|
||||
if isinstance(value.send, Send):
|
||||
rtn.append(value.send)
|
||||
else:
|
||||
rtn.extend(value.send)
|
||||
for command in commands:
|
||||
if command.graph == Command.PARENT:
|
||||
raise ParentCommand(command)
|
||||
if isinstance(command.goto, Send):
|
||||
rtn.append(command.goto)
|
||||
elif isinstance(command.goto, str):
|
||||
rtn.append(command.goto)
|
||||
else:
|
||||
rtn.extend(command.goto)
|
||||
return rtn
|
||||
|
||||
|
||||
async def _acontrol_branch(value: Any) -> Sequence[Union[str, Send]]:
|
||||
if isinstance(value, Send):
|
||||
return [value]
|
||||
if not isinstance(value, GraphCommand):
|
||||
commands: list[Command] = []
|
||||
if isinstance(value, Command):
|
||||
commands.append(value)
|
||||
elif (
|
||||
isinstance(value, (list, tuple))
|
||||
and value
|
||||
and all(isinstance(i, Command) for i in value)
|
||||
):
|
||||
commands.extend(value)
|
||||
else:
|
||||
return EMPTY_SEQ
|
||||
if value.graph == Command.PARENT:
|
||||
raise ParentCommand(value)
|
||||
rtn: list[Union[str, Send]] = []
|
||||
if isinstance(value.goto, str):
|
||||
rtn.append(value.goto)
|
||||
else:
|
||||
rtn.extend(value.goto)
|
||||
if isinstance(value.send, Send):
|
||||
rtn.append(value.send)
|
||||
else:
|
||||
rtn.extend(value.send)
|
||||
for command in commands:
|
||||
if command.graph == Command.PARENT:
|
||||
raise ParentCommand(command)
|
||||
if isinstance(command.goto, Send):
|
||||
rtn.append(command.goto)
|
||||
elif isinstance(command.goto, str):
|
||||
rtn.append(command.goto)
|
||||
else:
|
||||
rtn.extend(command.goto)
|
||||
return rtn
|
||||
|
||||
|
||||
@@ -933,12 +952,12 @@ def _is_field_binop(typ: Type[Any]) -> Optional[BinaryOperatorAggregate]:
|
||||
if hasattr(typ, "__metadata__"):
|
||||
meta = typ.__metadata__
|
||||
if len(meta) >= 1 and callable(meta[-1]):
|
||||
sig = signature(meta[0])
|
||||
sig = signature(meta[-1])
|
||||
params = list(sig.parameters.values())
|
||||
if len(params) == 2 and all(
|
||||
p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) for p in params
|
||||
):
|
||||
return BinaryOperatorAggregate(typ, meta[0])
|
||||
return BinaryOperatorAggregate(typ, meta[-1])
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid reducer signature. Expected (a, b) -> c. Got {sig}"
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
from copy import copy
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Sequence,
|
||||
@@ -35,22 +30,20 @@ from langchain_core.runnables.utils import Input
|
||||
from langchain_core.tools import BaseTool, InjectedToolArg
|
||||
from langchain_core.tools import tool as create_tool
|
||||
from langchain_core.tools.base import get_all_basemodel_annotations
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import Annotated, get_args, get_origin
|
||||
|
||||
from langgraph.errors import GraphBubbleUp
|
||||
from langgraph.store.base import BaseStore
|
||||
from langgraph.utils.runnable import RunnableCallable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic import BaseModel
|
||||
|
||||
INVALID_TOOL_NAME_ERROR_TEMPLATE = (
|
||||
"Error: {requested_tool} is not a valid tool, try one of [{available_tools}]."
|
||||
)
|
||||
TOOL_CALL_ERROR_TEMPLATE = "Error: {error}\n Please fix your mistakes."
|
||||
|
||||
|
||||
def msg_content_output(output: Any) -> str | List[dict]:
|
||||
def msg_content_output(output: Any) -> Union[str, list[dict]]:
|
||||
recognized_content_block_types = ("image", "image_url", "text", "json")
|
||||
if isinstance(output, str):
|
||||
return output
|
||||
@@ -95,7 +88,7 @@ def _handle_tool_error(
|
||||
return content
|
||||
|
||||
|
||||
def _infer_handled_types(handler: Callable[..., str]) -> tuple[type[Exception]]:
|
||||
def _infer_handled_types(handler: Callable[..., str]) -> tuple[type[Exception], ...]:
|
||||
sig = inspect.signature(handler)
|
||||
params = list(sig.parameters.values())
|
||||
if params:
|
||||
@@ -194,9 +187,9 @@ class ToolNode(RunnableCallable):
|
||||
messages_key: str = "messages",
|
||||
) -> None:
|
||||
super().__init__(self._func, self._afunc, name=name, tags=tags, trace=False)
|
||||
self.tools_by_name: Dict[str, BaseTool] = {}
|
||||
self.tool_to_state_args: Dict[str, Dict[str, Optional[str]]] = {}
|
||||
self.tool_to_store_arg: Dict[str, Optional[str]] = {}
|
||||
self.tools_by_name: dict[str, BaseTool] = {}
|
||||
self.tool_to_state_args: dict[str, dict[str, Optional[str]]] = {}
|
||||
self.tool_to_store_arg: dict[str, Optional[str]] = {}
|
||||
self.handle_tool_errors = handle_tool_errors
|
||||
self.messages_key = messages_key
|
||||
for tool_ in tools:
|
||||
@@ -346,7 +339,7 @@ class ToolNode(RunnableCallable):
|
||||
BaseModel,
|
||||
],
|
||||
store: BaseStore,
|
||||
) -> Tuple[List[ToolCall], Literal["list", "dict"]]:
|
||||
) -> Tuple[list[ToolCall], Literal["list", "dict"]]:
|
||||
if isinstance(input, list):
|
||||
output_type = "list"
|
||||
message: AnyMessage = input[-1]
|
||||
@@ -656,9 +649,9 @@ def _is_injection(
|
||||
return False
|
||||
|
||||
|
||||
def _get_state_args(tool: BaseTool) -> Dict[str, Optional[str]]:
|
||||
def _get_state_args(tool: BaseTool) -> dict[str, Optional[str]]:
|
||||
full_schema = tool.get_input_schema()
|
||||
tool_args_to_state_fields: Dict = {}
|
||||
tool_args_to_state_fields: dict = {}
|
||||
|
||||
for name, type_ in get_all_basemodel_annotations(full_schema).items():
|
||||
injections = [
|
||||
|
||||
@@ -673,7 +673,7 @@ class Pregel(PregelProtocol):
|
||||
self, config: RunnableConfig, *, subgraphs: bool = False
|
||||
) -> StateSnapshot:
|
||||
"""Get the current state of the graph."""
|
||||
checkpointer: Optional[BaseCheckpointSaver] = config[CONF].get(
|
||||
checkpointer: Optional[BaseCheckpointSaver] = ensure_config(config)[CONF].get(
|
||||
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
||||
)
|
||||
if not checkpointer:
|
||||
@@ -710,7 +710,7 @@ class Pregel(PregelProtocol):
|
||||
self, config: RunnableConfig, *, subgraphs: bool = False
|
||||
) -> StateSnapshot:
|
||||
"""Get the current state of the graph."""
|
||||
checkpointer: Optional[BaseCheckpointSaver] = config[CONF].get(
|
||||
checkpointer: Optional[BaseCheckpointSaver] = ensure_config(config)[CONF].get(
|
||||
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
||||
)
|
||||
if not checkpointer:
|
||||
@@ -751,8 +751,9 @@ class Pregel(PregelProtocol):
|
||||
before: Optional[RunnableConfig] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> Iterator[StateSnapshot]:
|
||||
config = ensure_config(config)
|
||||
"""Get the history of the state of the graph."""
|
||||
checkpointer: Optional[BaseCheckpointSaver] = config[CONF].get(
|
||||
checkpointer: Optional[BaseCheckpointSaver] = ensure_config(config)[CONF].get(
|
||||
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
||||
)
|
||||
if not checkpointer:
|
||||
@@ -800,8 +801,9 @@ class Pregel(PregelProtocol):
|
||||
before: Optional[RunnableConfig] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> AsyncIterator[StateSnapshot]:
|
||||
config = ensure_config(config)
|
||||
"""Get the history of the state of the graph."""
|
||||
checkpointer: Optional[BaseCheckpointSaver] = config[CONF].get(
|
||||
checkpointer: Optional[BaseCheckpointSaver] = ensure_config(config)[CONF].get(
|
||||
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
||||
)
|
||||
if not checkpointer:
|
||||
@@ -855,7 +857,7 @@ class Pregel(PregelProtocol):
|
||||
node `as_node`. If `as_node` is not provided, it will be set to the last node
|
||||
that updated the state, if not ambiguous.
|
||||
"""
|
||||
checkpointer: Optional[BaseCheckpointSaver] = config[CONF].get(
|
||||
checkpointer: Optional[BaseCheckpointSaver] = ensure_config(config)[CONF].get(
|
||||
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
||||
)
|
||||
if not checkpointer:
|
||||
@@ -1130,7 +1132,7 @@ class Pregel(PregelProtocol):
|
||||
values: dict[str, Any] | Any,
|
||||
as_node: Optional[str] = None,
|
||||
) -> RunnableConfig:
|
||||
checkpointer: Optional[BaseCheckpointSaver] = config[CONF].get(
|
||||
checkpointer: Optional[BaseCheckpointSaver] = ensure_config(config)[CONF].get(
|
||||
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
||||
)
|
||||
if not checkpointer:
|
||||
|
||||
@@ -37,13 +37,13 @@ from langgraph.constants import (
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_CHECKPOINTER,
|
||||
CONFIG_KEY_READ,
|
||||
CONFIG_KEY_RESUME_VALUE,
|
||||
CONFIG_KEY_SCRATCHPAD,
|
||||
CONFIG_KEY_SEND,
|
||||
CONFIG_KEY_STORE,
|
||||
CONFIG_KEY_TASK_ID,
|
||||
CONFIG_KEY_WRITES,
|
||||
EMPTY_SEQ,
|
||||
INTERRUPT,
|
||||
MISSING,
|
||||
NO_WRITES,
|
||||
NS_END,
|
||||
NS_SEP,
|
||||
@@ -589,14 +589,13 @@ def prepare_single_task(
|
||||
},
|
||||
CONFIG_KEY_CHECKPOINT_ID: None,
|
||||
CONFIG_KEY_CHECKPOINT_NS: task_checkpoint_ns,
|
||||
CONFIG_KEY_RESUME_VALUE: next(
|
||||
(
|
||||
v
|
||||
for tid, c, v in pending_writes
|
||||
if tid in (NULL_TASK_ID, task_id) and c == RESUME
|
||||
),
|
||||
MISSING,
|
||||
),
|
||||
CONFIG_KEY_WRITES: [
|
||||
w
|
||||
for w in pending_writes
|
||||
+ configurable.get(CONFIG_KEY_WRITES, [])
|
||||
if w[0] in (NULL_TASK_ID, task_id)
|
||||
],
|
||||
CONFIG_KEY_SCRATCHPAD: {},
|
||||
},
|
||||
),
|
||||
triggers,
|
||||
@@ -713,15 +712,13 @@ def prepare_single_task(
|
||||
},
|
||||
CONFIG_KEY_CHECKPOINT_ID: None,
|
||||
CONFIG_KEY_CHECKPOINT_NS: task_checkpoint_ns,
|
||||
CONFIG_KEY_RESUME_VALUE: next(
|
||||
(
|
||||
v
|
||||
for tid, c, v in pending_writes
|
||||
if tid in (NULL_TASK_ID, task_id)
|
||||
and c == RESUME
|
||||
),
|
||||
MISSING,
|
||||
),
|
||||
CONFIG_KEY_WRITES: [
|
||||
w
|
||||
for w in pending_writes
|
||||
+ configurable.get(CONFIG_KEY_WRITES, [])
|
||||
if w[0] in (NULL_TASK_ID, task_id)
|
||||
],
|
||||
CONFIG_KEY_SCRATCHPAD: {},
|
||||
},
|
||||
),
|
||||
triggers,
|
||||
|
||||
@@ -4,6 +4,7 @@ from uuid import UUID
|
||||
from langchain_core.runnables.utils import AddableDict
|
||||
|
||||
from langgraph.channels.base import BaseChannel, EmptyChannelError
|
||||
from langgraph.checkpoint.base import PendingWrite
|
||||
from langgraph.constants import (
|
||||
EMPTY_SEQ,
|
||||
ERROR,
|
||||
@@ -66,34 +67,35 @@ def read_channels(
|
||||
|
||||
|
||||
def map_command(
|
||||
cmd: Command,
|
||||
cmd: Command, pending_writes: list[PendingWrite]
|
||||
) -> Iterator[tuple[str, str, Any]]:
|
||||
"""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 isinstance(cmd.send, (tuple, list)):
|
||||
sends = cmd.send
|
||||
if cmd.goto:
|
||||
if isinstance(cmd.goto, (tuple, list)):
|
||||
sends = cmd.goto
|
||||
else:
|
||||
sends = [cmd.send]
|
||||
sends = [cmd.goto]
|
||||
for send in sends:
|
||||
if not isinstance(send, Send):
|
||||
raise TypeError(
|
||||
f"In Command.send, expected Send, got {type(send).__name__}"
|
||||
f"In Command.goto, expected Send, got {type(send).__name__}"
|
||||
)
|
||||
yield (NULL_TASK_ID, PUSH if FF_SEND_V2 else TASKS, send)
|
||||
# TODO handle goto str for state graph
|
||||
if cmd.resume:
|
||||
if isinstance(cmd.resume, dict) and all(is_task_id(k) for k in cmd.resume):
|
||||
for tid, resume in cmd.resume.items():
|
||||
yield (tid, RESUME, resume)
|
||||
existing: list[Any] = next(
|
||||
(w[2] for w in pending_writes if w[0] == tid and w[1] == RESUME), []
|
||||
)
|
||||
existing.append(resume)
|
||||
yield (tid, RESUME, existing)
|
||||
else:
|
||||
yield (NULL_TASK_ID, RESUME, cmd.resume)
|
||||
if cmd.update:
|
||||
if not isinstance(cmd.update, dict):
|
||||
raise TypeError(
|
||||
f"Expected cmd.update to be a dict mapping channel names to update values, got {type(cmd.update).__name__}"
|
||||
)
|
||||
for k, v in cmd.update.items():
|
||||
for k, v in cmd._update_as_tuples():
|
||||
yield (NULL_TASK_ID, k, v)
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from typing_extensions import ParamSpec, Self
|
||||
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.checkpoint.base import (
|
||||
WRITES_IDX_MAP,
|
||||
BaseCheckpointSaver,
|
||||
ChannelVersions,
|
||||
Checkpoint,
|
||||
@@ -263,8 +264,28 @@ class PregelLoop(LoopProtocol):
|
||||
"""Put writes for a task, to be read by the next tick."""
|
||||
if not writes:
|
||||
return
|
||||
# deduplicate writes to special channels, last write wins
|
||||
if all(w[0] in WRITES_IDX_MAP for w in writes):
|
||||
writes = list({w[0]: w for w in writes}.values())
|
||||
# save writes
|
||||
self.checkpoint_pending_writes.extend((task_id, k, v) for k, v in writes)
|
||||
for c, v in writes:
|
||||
if (
|
||||
c in WRITES_IDX_MAP
|
||||
and (
|
||||
idx := next(
|
||||
(
|
||||
i
|
||||
for i, w in enumerate(self.checkpoint_pending_writes)
|
||||
if w[0] == task_id and w[1] == c
|
||||
),
|
||||
None,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
):
|
||||
self.checkpoint_pending_writes[idx] = (task_id, c, v)
|
||||
else:
|
||||
self.checkpoint_pending_writes.append((task_id, c, v))
|
||||
if self.checkpointer_put_writes is not None:
|
||||
self.submit(
|
||||
self.checkpointer_put_writes,
|
||||
@@ -536,7 +557,7 @@ class PregelLoop(LoopProtocol):
|
||||
elif isinstance(self.input, Command):
|
||||
writes: defaultdict[str, list[tuple[str, Any]]] = defaultdict(list)
|
||||
# group writes by task ID
|
||||
for tid, c, v in map_command(self.input):
|
||||
for tid, c, v in map_command(self.input, self.checkpoint_pending_writes):
|
||||
writes[tid].append((c, v))
|
||||
if not writes:
|
||||
raise EmptyInputError("Received empty Command input")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from dataclasses import asdict
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncIterator,
|
||||
@@ -27,6 +28,7 @@ from langgraph_sdk.client import (
|
||||
get_sync_client,
|
||||
)
|
||||
from langgraph_sdk.schema import Checkpoint, ThreadState
|
||||
from langgraph_sdk.schema import Command as CommandSDK
|
||||
from langgraph_sdk.schema import StreamMode as StreamModeSDK
|
||||
from typing_extensions import Self
|
||||
|
||||
@@ -41,7 +43,7 @@ from langgraph.constants import (
|
||||
from langgraph.errors import GraphInterrupt
|
||||
from langgraph.pregel.protocol import PregelProtocol
|
||||
from langgraph.pregel.types import All, PregelTask, StateSnapshot, StreamMode
|
||||
from langgraph.types import Interrupt, StreamProtocol
|
||||
from langgraph.types import Command, Interrupt, StreamProtocol
|
||||
from langgraph.utils.config import merge_configs
|
||||
|
||||
|
||||
@@ -573,6 +575,7 @@ class RemoteGraph(PregelProtocol):
|
||||
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
|
||||
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
|
||||
subgraphs: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[Union[dict[str, Any], Any]]:
|
||||
"""Create a run and stream the results.
|
||||
|
||||
@@ -587,6 +590,7 @@ class RemoteGraph(PregelProtocol):
|
||||
interrupt_before: Interrupt the graph before these nodes.
|
||||
interrupt_after: Interrupt the graph after these nodes.
|
||||
subgraphs: Stream from subgraphs.
|
||||
**kwargs: Additional params to pass to client.runs.stream.
|
||||
|
||||
Yields:
|
||||
The output of the graph.
|
||||
@@ -597,17 +601,24 @@ class RemoteGraph(PregelProtocol):
|
||||
stream_modes, requested, req_single, stream = self._get_stream_modes(
|
||||
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(
|
||||
thread_id=sanitized_config["configurable"].get("thread_id"),
|
||||
assistant_id=self.name,
|
||||
input=input,
|
||||
command=command,
|
||||
config=sanitized_config,
|
||||
stream_mode=stream_modes,
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
stream_subgraphs=subgraphs or stream is not None,
|
||||
if_not_exists="create",
|
||||
**kwargs,
|
||||
):
|
||||
# split mode and ns
|
||||
if NS_SEP in chunk.event:
|
||||
@@ -656,6 +667,7 @@ class RemoteGraph(PregelProtocol):
|
||||
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
|
||||
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
|
||||
subgraphs: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[Union[dict[str, Any], Any]]:
|
||||
"""Create a run and stream the results.
|
||||
|
||||
@@ -670,6 +682,7 @@ class RemoteGraph(PregelProtocol):
|
||||
interrupt_before: Interrupt the graph before these nodes.
|
||||
interrupt_after: Interrupt the graph after these nodes.
|
||||
subgraphs: Stream from subgraphs.
|
||||
**kwargs: Additional params to pass to client.runs.stream.
|
||||
|
||||
Yields:
|
||||
The output of the graph.
|
||||
@@ -680,17 +693,24 @@ class RemoteGraph(PregelProtocol):
|
||||
stream_modes, requested, req_single, stream = self._get_stream_modes(
|
||||
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(
|
||||
thread_id=sanitized_config["configurable"].get("thread_id"),
|
||||
assistant_id=self.name,
|
||||
input=input,
|
||||
command=command,
|
||||
config=sanitized_config,
|
||||
stream_mode=stream_modes,
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
stream_subgraphs=subgraphs or stream is not None,
|
||||
if_not_exists="create",
|
||||
**kwargs,
|
||||
):
|
||||
# split mode and ns
|
||||
if NS_SEP in chunk.event:
|
||||
@@ -753,18 +773,16 @@ class RemoteGraph(PregelProtocol):
|
||||
*,
|
||||
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
|
||||
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Union[dict[str, Any], Any]:
|
||||
"""Create a run, wait until it finishes and return the final state.
|
||||
|
||||
This method calls `POST /threads/{thread_id}/runs/wait` if a `thread_id`
|
||||
is speciffed in the `configurable` field of the config or
|
||||
`POST /runs/wait` otherwise.
|
||||
|
||||
Args:
|
||||
input: Input to the graph.
|
||||
config: A `RunnableConfig` for graph invocation.
|
||||
interrupt_before: Interrupt the graph before these nodes.
|
||||
interrupt_after: Interrupt the graph after these nodes.
|
||||
**kwargs: Additional params to pass to RemoteGraph.stream.
|
||||
|
||||
Returns:
|
||||
The output of the graph.
|
||||
@@ -775,6 +793,7 @@ class RemoteGraph(PregelProtocol):
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
stream_mode="values",
|
||||
**kwargs,
|
||||
):
|
||||
pass
|
||||
try:
|
||||
@@ -789,18 +808,16 @@ class RemoteGraph(PregelProtocol):
|
||||
*,
|
||||
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
|
||||
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Union[dict[str, Any], Any]:
|
||||
"""Create a run, wait until it finishes and return the final state.
|
||||
|
||||
This method calls `POST /threads/{thread_id}/runs/wait` if a `thread_id`
|
||||
is speciffed in the `configurable` field of the config or
|
||||
`POST /runs/wait` otherwise.
|
||||
|
||||
Args:
|
||||
input: Input to the graph.
|
||||
config: A `RunnableConfig` for graph invocation.
|
||||
interrupt_before: Interrupt the graph before these nodes.
|
||||
interrupt_after: Interrupt the graph after these nodes.
|
||||
**kwargs: Additional params to pass to RemoteGraph.astream.
|
||||
|
||||
Returns:
|
||||
The output of the graph.
|
||||
@@ -811,6 +828,7 @@ class RemoteGraph(PregelProtocol):
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
stream_mode="values",
|
||||
**kwargs,
|
||||
):
|
||||
pass
|
||||
try:
|
||||
|
||||
@@ -21,6 +21,7 @@ from langgraph.constants import (
|
||||
INTERRUPT,
|
||||
NO_WRITES,
|
||||
PUSH,
|
||||
RESUME,
|
||||
TAG_HIDDEN,
|
||||
)
|
||||
from langgraph.errors import GraphBubbleUp, GraphInterrupt
|
||||
@@ -297,6 +298,8 @@ class PregelRunner:
|
||||
if isinstance(exception, GraphInterrupt):
|
||||
# save interrupt to checkpointer
|
||||
if interrupts := [(INTERRUPT, i) for i in exception.args[0]]:
|
||||
if resumes := [w for w in task.writes if w[0] == RESUME]:
|
||||
interrupts.extend(resumes)
|
||||
self.put_writes(task.id, interrupts)
|
||||
elif isinstance(exception, GraphBubbleUp):
|
||||
raise exception
|
||||
|
||||
@@ -36,31 +36,40 @@ class ChannelWriteEntry(NamedTuple):
|
||||
"""Function to transform the value before writing."""
|
||||
|
||||
|
||||
class ChannelWriteTupleEntry(NamedTuple):
|
||||
mapper: Callable[[Any], Optional[Sequence[tuple[str, Any]]]]
|
||||
"""Function to extract tuples from value."""
|
||||
value: Any = PASSTHROUGH
|
||||
"""Value to write, or PASSTHROUGH to use the input."""
|
||||
|
||||
|
||||
class ChannelWrite(RunnableCallable):
|
||||
"""Implements th logic for sending writes to CONFIG_KEY_SEND.
|
||||
"""Implements the logic for sending writes to CONFIG_KEY_SEND.
|
||||
Can be used as a runnable or as a static method to call imperatively."""
|
||||
|
||||
writes: list[Union[ChannelWriteEntry, Send]]
|
||||
writes: list[Union[ChannelWriteEntry, ChannelWriteTupleEntry, Send]]
|
||||
"""Sequence of write entries or Send objects to write."""
|
||||
require_at_least_one_of: Optional[Sequence[str]]
|
||||
"""If defined, at least one of these channels must be written to."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
writes: Sequence[Union[ChannelWriteEntry, Send]],
|
||||
writes: Sequence[Union[ChannelWriteEntry, ChannelWriteTupleEntry, Send]],
|
||||
*,
|
||||
tags: Optional[Sequence[str]] = None,
|
||||
require_at_least_one_of: Optional[Sequence[str]] = None,
|
||||
):
|
||||
super().__init__(func=self._write, afunc=self._awrite, name=None, tags=tags)
|
||||
self.writes = cast(list[Union[ChannelWriteEntry, Send]], writes)
|
||||
self.writes = cast(
|
||||
list[Union[ChannelWriteEntry, ChannelWriteTupleEntry, Send]], writes
|
||||
)
|
||||
self.require_at_least_one_of = require_at_least_one_of
|
||||
|
||||
def get_name(
|
||||
self, suffix: Optional[str] = None, *, name: Optional[str] = None
|
||||
) -> str:
|
||||
if not name:
|
||||
name = f"ChannelWrite<{','.join(w.channel if isinstance(w, ChannelWriteEntry) else w.node for w in self.writes)}>"
|
||||
name = f"ChannelWrite<{','.join(w.channel if isinstance(w, ChannelWriteEntry) else '...' if isinstance(w, ChannelWriteTupleEntry) else w.node for w in self.writes)}>"
|
||||
return super().get_name(suffix, name=name)
|
||||
|
||||
@property
|
||||
@@ -79,6 +88,8 @@ class ChannelWrite(RunnableCallable):
|
||||
writes = [
|
||||
ChannelWriteEntry(write.channel, input, write.skip_none, write.mapper)
|
||||
if isinstance(write, ChannelWriteEntry) and write.value is PASSTHROUGH
|
||||
else ChannelWriteTupleEntry(write.mapper, input)
|
||||
if isinstance(write, ChannelWriteTupleEntry) and write.value is PASSTHROUGH
|
||||
else write
|
||||
for write in self.writes
|
||||
]
|
||||
@@ -93,6 +104,8 @@ class ChannelWrite(RunnableCallable):
|
||||
writes = [
|
||||
ChannelWriteEntry(write.channel, input, write.skip_none, write.mapper)
|
||||
if isinstance(write, ChannelWriteEntry) and write.value is PASSTHROUGH
|
||||
else ChannelWriteTupleEntry(write.mapper, input)
|
||||
if isinstance(write, ChannelWriteTupleEntry) and write.value is PASSTHROUGH
|
||||
else write
|
||||
for write in self.writes
|
||||
]
|
||||
@@ -106,7 +119,7 @@ class ChannelWrite(RunnableCallable):
|
||||
@staticmethod
|
||||
def do_write(
|
||||
config: RunnableConfig,
|
||||
writes: Sequence[Union[ChannelWriteEntry, Send]],
|
||||
writes: Sequence[Union[ChannelWriteEntry, ChannelWriteTupleEntry, Send]],
|
||||
require_at_least_one_of: Optional[Sequence[str]] = None,
|
||||
) -> None:
|
||||
# validate
|
||||
@@ -118,32 +131,34 @@ class ChannelWrite(RunnableCallable):
|
||||
)
|
||||
if w.value is PASSTHROUGH:
|
||||
raise InvalidUpdateError("PASSTHROUGH value must be replaced")
|
||||
# split packets and entries
|
||||
sends = [
|
||||
(PUSH if FF_SEND_V2 else TASKS, packet)
|
||||
for packet in writes
|
||||
if isinstance(packet, Send)
|
||||
]
|
||||
entries = [write for write in writes if isinstance(write, ChannelWriteEntry)]
|
||||
# process entries into values
|
||||
values = [
|
||||
write.mapper(write.value) if write.mapper is not None else write.value
|
||||
for write in entries
|
||||
]
|
||||
values = [
|
||||
(write.channel, val)
|
||||
for val, write in zip(values, entries)
|
||||
if not write.skip_none or val is not None
|
||||
]
|
||||
# filter out SKIP_WRITE values
|
||||
filtered = [(chan, val) for chan, val in values if val is not SKIP_WRITE]
|
||||
if isinstance(w, ChannelWriteTupleEntry):
|
||||
if w.value is PASSTHROUGH:
|
||||
raise InvalidUpdateError("PASSTHROUGH value must be replaced")
|
||||
# assemble writes
|
||||
tuples: list[tuple[str, Any]] = []
|
||||
for w in writes:
|
||||
if isinstance(w, Send):
|
||||
tuples.append((PUSH if FF_SEND_V2 else TASKS, w))
|
||||
elif isinstance(w, ChannelWriteTupleEntry):
|
||||
if ww := w.mapper(w.value):
|
||||
tuples.extend(ww)
|
||||
elif isinstance(w, ChannelWriteEntry):
|
||||
value = w.mapper(w.value) if w.mapper is not None else w.value
|
||||
if value is SKIP_WRITE:
|
||||
continue
|
||||
if w.skip_none and value is None:
|
||||
continue
|
||||
tuples.append((w.channel, value))
|
||||
else:
|
||||
raise ValueError(f"Invalid write entry: {w}")
|
||||
# assert required channels
|
||||
if require_at_least_one_of is not None:
|
||||
if not {chan for chan, _ in filtered} & set(require_at_least_one_of):
|
||||
if not {chan for chan, _ in tuples} & set(require_at_least_one_of):
|
||||
raise InvalidUpdateError(
|
||||
f"Must write to at least one of {require_at_least_one_of}"
|
||||
)
|
||||
write: TYPE_SEND = config[CONF][CONFIG_KEY_SEND]
|
||||
write(sends + filtered)
|
||||
write(tuples)
|
||||
|
||||
@staticmethod
|
||||
def is_writer(runnable: Runnable) -> bool:
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import (
|
||||
Optional,
|
||||
Sequence,
|
||||
Type,
|
||||
TypedDict,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
@@ -21,11 +22,16 @@ from typing import (
|
||||
from langchain_core.runnables import Runnable, RunnableConfig
|
||||
from typing_extensions import Self
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointMetadata
|
||||
from langgraph.checkpoint.base import (
|
||||
BaseCheckpointSaver,
|
||||
CheckpointMetadata,
|
||||
PendingWrite,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langgraph.store.base import BaseStore
|
||||
|
||||
|
||||
All = Literal["*"]
|
||||
"""Special value to indicate that graph should interrupt on all nodes."""
|
||||
|
||||
@@ -239,12 +245,27 @@ N = TypeVar("N", bound=Hashable)
|
||||
|
||||
@dataclasses.dataclass(**_DC_KWARGS)
|
||||
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.
|
||||
|
||||
Args:
|
||||
graph: graph to send the command to. Supported values are:
|
||||
|
||||
- None: the current graph (default)
|
||||
- Command.PARENT: closest parent graph
|
||||
update: update to apply to the graph's state.
|
||||
resume: value to resume execution with. To be used together with [`interrupt()`][langgraph.types.interrupt].
|
||||
goto: can be one of the following:
|
||||
|
||||
- name of the node to navigate to next (any node that belongs to the specified `graph`)
|
||||
- sequence of node names to navigate to next
|
||||
- `Send` object (to execute a node with the input provided)
|
||||
- sequence of `Send` objects
|
||||
"""
|
||||
|
||||
graph: Optional[str] = None
|
||||
update: Optional[dict[str, Any]] = None
|
||||
send: Union[Send, Sequence[Send]] = ()
|
||||
update: Union[dict[str, Any], Sequence[tuple[str, Any]]] = ()
|
||||
resume: Optional[Union[Any, dict[str, Any]]] = None
|
||||
goto: Union[Send, Sequence[Union[Send, str]], str] = ()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
# get all non-None values
|
||||
@@ -255,6 +276,17 @@ class Command(Generic[N]):
|
||||
)
|
||||
return f"Command({contents})"
|
||||
|
||||
def _update_as_tuples(self) -> Sequence[tuple[str, Any]]:
|
||||
if isinstance(self.update, dict):
|
||||
return list(self.update.items())
|
||||
elif isinstance(self.update, (list, tuple)) and all(
|
||||
isinstance(t, tuple) and len(t) == 2 and isinstance(t[0], str)
|
||||
for t in self.update
|
||||
):
|
||||
return self.update
|
||||
else:
|
||||
return [("__root__", self.update)]
|
||||
|
||||
PARENT: ClassVar[Literal["__parent__"]] = "__parent__"
|
||||
|
||||
|
||||
@@ -300,26 +332,59 @@ class LoopProtocol:
|
||||
self.stop = stop
|
||||
|
||||
|
||||
class PregelScratchpad(TypedDict, total=False):
|
||||
interrupt_counter: int
|
||||
used_null_resume: bool
|
||||
resume: list[Any]
|
||||
|
||||
|
||||
def interrupt(value: Any) -> Any:
|
||||
from langgraph.constants import (
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_RESUME_VALUE,
|
||||
MISSING,
|
||||
CONFIG_KEY_SCRATCHPAD,
|
||||
CONFIG_KEY_SEND,
|
||||
CONFIG_KEY_TASK_ID,
|
||||
CONFIG_KEY_WRITES,
|
||||
NS_SEP,
|
||||
NULL_TASK_ID,
|
||||
RESUME,
|
||||
)
|
||||
from langgraph.errors import GraphInterrupt
|
||||
from langgraph.utils.config import get_configurable
|
||||
|
||||
conf = get_configurable()
|
||||
if (resume := conf.get(CONFIG_KEY_RESUME_VALUE, MISSING)) and resume is not MISSING:
|
||||
return resume
|
||||
# track interrupt index
|
||||
scratchpad: PregelScratchpad = conf[CONFIG_KEY_SCRATCHPAD]
|
||||
if "interrupt_counter" not in scratchpad:
|
||||
scratchpad["interrupt_counter"] = 0
|
||||
else:
|
||||
raise GraphInterrupt(
|
||||
(
|
||||
Interrupt(
|
||||
value=value,
|
||||
resumable=True,
|
||||
ns=cast(str, conf[CONFIG_KEY_CHECKPOINT_NS]).split(NS_SEP),
|
||||
),
|
||||
)
|
||||
scratchpad["interrupt_counter"] += 1
|
||||
idx = scratchpad["interrupt_counter"]
|
||||
# find previous resume values
|
||||
task_id = conf[CONFIG_KEY_TASK_ID]
|
||||
writes: list[PendingWrite] = conf[CONFIG_KEY_WRITES]
|
||||
scratchpad.setdefault(
|
||||
"resume", next((w[2] for w in writes if w[0] == task_id and w[1] == RESUME), [])
|
||||
)
|
||||
if scratchpad["resume"]:
|
||||
if idx < len(scratchpad["resume"]):
|
||||
return scratchpad["resume"][idx]
|
||||
# find current resume value
|
||||
if not scratchpad.get("used_null_resume"):
|
||||
scratchpad["used_null_resume"] = True
|
||||
for tid, c, v in sorted(writes, key=lambda x: x[0], reverse=True):
|
||||
if tid == NULL_TASK_ID and c == RESUME:
|
||||
assert len(scratchpad["resume"]) == idx, (scratchpad["resume"], idx)
|
||||
scratchpad["resume"].append(v)
|
||||
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad["resume"])])
|
||||
return v
|
||||
# no resume value found
|
||||
raise GraphInterrupt(
|
||||
(
|
||||
Interrupt(
|
||||
value=value,
|
||||
resumable=True,
|
||||
ns=cast(str, conf[CONFIG_KEY_CHECKPOINT_NS]).split(NS_SEP),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
Generated
+7
-19
@@ -791,17 +791,6 @@ cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
|
||||
http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (==1.*)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.0"
|
||||
description = "Consume Server-Sent Event (SSE) messages with HTTPX."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"},
|
||||
{file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
@@ -1359,7 +1348,7 @@ typing-extensions = ">=4.7"
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "2.0.4"
|
||||
version = "2.0.8"
|
||||
description = "Library with base interfaces for LangGraph checkpoint savers."
|
||||
optional = false
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
@@ -1393,7 +1382,7 @@ url = "../checkpoint-duckdb"
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "2.0.2"
|
||||
version = "2.0.7"
|
||||
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
|
||||
optional = false
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
@@ -1401,10 +1390,10 @@ files = []
|
||||
develop = true
|
||||
|
||||
[package.dependencies]
|
||||
langgraph-checkpoint = "^2.0.2"
|
||||
langgraph-checkpoint = "^2.0.7"
|
||||
orjson = ">=3.10.1"
|
||||
psycopg = "^3.0.0"
|
||||
psycopg-pool = "^3.0.0"
|
||||
psycopg = "^3.2.0"
|
||||
psycopg-pool = "^3.2.0"
|
||||
|
||||
[package.source]
|
||||
type = "directory"
|
||||
@@ -1429,7 +1418,7 @@ url = "../checkpoint-sqlite"
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-sdk"
|
||||
version = "0.1.36"
|
||||
version = "0.1.42"
|
||||
description = "SDK for interacting with LangGraph API"
|
||||
optional = false
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
@@ -1438,7 +1427,6 @@ develop = true
|
||||
|
||||
[package.dependencies]
|
||||
httpx = ">=0.25.2"
|
||||
httpx-sse = ">=0.4.0"
|
||||
orjson = ">=3.10.1"
|
||||
|
||||
[package.source]
|
||||
@@ -3425,4 +3413,4 @@ type = ["pytest-mypy"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = ">=3.9.0,<4.0"
|
||||
content-hash = "9bf5668d3f70f3b77457906732404a6401583a5966f70a72ef10a68f2a5b27ad"
|
||||
content-hash = "2df4d5d5e61917bdfff0ba430067a17662666eedee2858d841fa02e594cf69d0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph"
|
||||
version = "0.2.53"
|
||||
version = "0.2.56"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
license = "MIT"
|
||||
@@ -11,7 +11,7 @@ repository = "https://www.github.com/langchain-ai/langgraph"
|
||||
python = ">=3.9.0,<4.0"
|
||||
langchain-core = ">=0.2.43,<0.4.0,!=0.3.0,!=0.3.1,!=0.3.2,!=0.3.3,!=0.3.4,!=0.3.5,!=0.3.6,!=0.3.7,!=0.3.8,!=0.3.9,!=0.3.10,!=0.3.11,!=0.3.12,!=0.3.13,!=0.3.14"
|
||||
langgraph-checkpoint = "^2.0.4"
|
||||
langgraph-sdk = "^0.1.32"
|
||||
langgraph-sdk = "^0.1.42"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^8.3.2"
|
||||
|
||||
@@ -65,7 +65,7 @@ from langgraph.constants import (
|
||||
START,
|
||||
)
|
||||
from langgraph.errors import InvalidUpdateError, MultipleSubgraphsError, NodeInterrupt
|
||||
from langgraph.graph import END, Graph, GraphCommand, StateGraph
|
||||
from langgraph.graph import END, Graph, StateGraph
|
||||
from langgraph.graph.message import MessageGraph, MessagesState, add_messages
|
||||
from langgraph.managed.shared_value import SharedValue
|
||||
from langgraph.prebuilt.chat_agent_executor import create_tool_calling_executor
|
||||
@@ -160,7 +160,7 @@ def test_graph_validation() -> None:
|
||||
workflow = Graph()
|
||||
workflow.add_node("agent", logic)
|
||||
workflow.set_finish_point("agent")
|
||||
with pytest.raises(ValueError, match="not reachable"):
|
||||
with pytest.raises(ValueError, match="must have an entrypoint"):
|
||||
workflow.compile()
|
||||
|
||||
workflow = Graph()
|
||||
@@ -207,18 +207,6 @@ def test_graph_validation() -> None:
|
||||
with pytest.raises(ValueError, match="unknown"): # extra is not defined
|
||||
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.add_node("agent", logic)
|
||||
workflow.add_node("tools", logic)
|
||||
@@ -232,18 +220,6 @@ def test_graph_validation() -> None:
|
||||
class State(TypedDict):
|
||||
hello: str
|
||||
|
||||
def node_a(state: State) -> State:
|
||||
# typo
|
||||
return {"hell": "world"}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("a", node_a)
|
||||
builder.set_entry_point("a")
|
||||
builder.set_finish_point("a")
|
||||
graph = builder.compile()
|
||||
with pytest.raises(InvalidUpdateError):
|
||||
graph.invoke({"hello": "there"})
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_node("start", lambda x: x)
|
||||
graph.add_edge("__start__", "start")
|
||||
@@ -276,6 +252,25 @@ def test_graph_validation() -> None:
|
||||
graph.invoke({"hello": "there"})
|
||||
|
||||
|
||||
def test_graph_validation_with_command() -> None:
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
bar: str
|
||||
|
||||
def node_a(state: State):
|
||||
return Command(goto="b", update={"foo": "bar"})
|
||||
|
||||
def node_b(state: State):
|
||||
return Command(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:
|
||||
class FaultyGetCheckpointer(MemorySaver):
|
||||
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
||||
@@ -1912,14 +1907,14 @@ def test_send_sequences() -> None:
|
||||
else ["|".join((self.name, str(state)))]
|
||||
)
|
||||
if isinstance(state, Command):
|
||||
return replace(state, update=update)
|
||||
return [state, Command(update=update)]
|
||||
else:
|
||||
return update
|
||||
|
||||
def send_for_fun(state):
|
||||
return [
|
||||
Send("2", GraphCommand(send=Send("2", 3))),
|
||||
Send("2", GraphCommand(send=Send("2", 4))),
|
||||
Send("2", Command(goto=Send("2", 3))),
|
||||
Send("2", Command(goto=Send("2", 4))),
|
||||
"3.1",
|
||||
]
|
||||
|
||||
@@ -1940,8 +1935,8 @@ def test_send_sequences() -> None:
|
||||
== [
|
||||
"0",
|
||||
"1",
|
||||
"2|Command(send=Send(node='2', arg=3))",
|
||||
"2|Command(send=Send(node='2', arg=4))",
|
||||
"2|Command(goto=Send(node='2', arg=3))",
|
||||
"2|Command(goto=Send(node='2', arg=4))",
|
||||
"2|3",
|
||||
"2|4",
|
||||
"3",
|
||||
@@ -1952,8 +1947,8 @@ def test_send_sequences() -> None:
|
||||
"0",
|
||||
"1",
|
||||
"3.1",
|
||||
"2|Command(send=Send(node='2', arg=3))",
|
||||
"2|Command(send=Send(node='2', arg=4))",
|
||||
"2|Command(goto=Send(node='2', arg=3))",
|
||||
"2|Command(goto=Send(node='2', arg=4))",
|
||||
"3",
|
||||
"2|3",
|
||||
"2|4",
|
||||
@@ -1962,7 +1957,6 @@ def test_send_sequences() -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.repeat(20)
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
|
||||
def test_send_dedupe_on_resume(
|
||||
request: pytest.FixtureRequest, checkpointer_name: str
|
||||
@@ -1993,15 +1987,15 @@ def test_send_dedupe_on_resume(
|
||||
if isinstance(state, list)
|
||||
else ["|".join((self.name, str(state)))]
|
||||
)
|
||||
if isinstance(state, GraphCommand):
|
||||
if isinstance(state, Command):
|
||||
return replace(state, update=update)
|
||||
else:
|
||||
return update
|
||||
|
||||
def send_for_fun(state):
|
||||
return [
|
||||
Send("2", GraphCommand(send=Send("2", 3))),
|
||||
Send("2", GraphCommand(send=Send("flaky", 4))),
|
||||
Send("2", Command(goto=Send("2", 3))),
|
||||
Send("2", Command(goto=Send("flaky", 4))),
|
||||
"3.1",
|
||||
]
|
||||
|
||||
@@ -2023,8 +2017,8 @@ def test_send_dedupe_on_resume(
|
||||
assert graph.invoke(["0"], thread1, debug=1) == [
|
||||
"0",
|
||||
"1",
|
||||
"2|Command(send=Send(node='2', arg=3))",
|
||||
"2|Command(send=Send(node='flaky', arg=4))",
|
||||
"2|Command(goto=Send(node='2', arg=3))",
|
||||
"2|Command(goto=Send(node='flaky', arg=4))",
|
||||
"2|3",
|
||||
]
|
||||
assert builder.nodes["2"].runnable.func.ticks == 3
|
||||
@@ -2039,8 +2033,8 @@ def test_send_dedupe_on_resume(
|
||||
assert graph.invoke(None, thread1, debug=1) == [
|
||||
"0",
|
||||
"1",
|
||||
"2|Command(send=Send(node='2', arg=3))",
|
||||
"2|Command(send=Send(node='flaky', arg=4))",
|
||||
"2|Command(goto=Send(node='2', arg=3))",
|
||||
"2|Command(goto=Send(node='flaky', arg=4))",
|
||||
"2|3",
|
||||
"flaky|4",
|
||||
"3",
|
||||
@@ -2062,8 +2056,8 @@ def test_send_dedupe_on_resume(
|
||||
values=[
|
||||
"0",
|
||||
"1",
|
||||
"2|Command(send=Send(node='2', arg=3))",
|
||||
"2|Command(send=Send(node='flaky', arg=4))",
|
||||
"2|Command(goto=Send(node='2', arg=3))",
|
||||
"2|Command(goto=Send(node='flaky', arg=4))",
|
||||
"2|3",
|
||||
"flaky|4",
|
||||
"3",
|
||||
@@ -2098,8 +2092,8 @@ def test_send_dedupe_on_resume(
|
||||
values=[
|
||||
"0",
|
||||
"1",
|
||||
"2|Command(send=Send(node='2', arg=3))",
|
||||
"2|Command(send=Send(node='flaky', arg=4))",
|
||||
"2|Command(goto=Send(node='2', arg=3))",
|
||||
"2|Command(goto=Send(node='flaky', arg=4))",
|
||||
"2|3",
|
||||
"flaky|4",
|
||||
],
|
||||
@@ -2116,8 +2110,8 @@ def test_send_dedupe_on_resume(
|
||||
"writes": {
|
||||
"1": ["1"],
|
||||
"2": [
|
||||
["2|Command(send=Send(node='2', arg=3))"],
|
||||
["2|Command(send=Send(node='flaky', arg=4))"],
|
||||
["2|Command(goto=Send(node='2', arg=3))"],
|
||||
["2|Command(goto=Send(node='flaky', arg=4))"],
|
||||
["2|3"],
|
||||
],
|
||||
"flaky": ["flaky|4"],
|
||||
@@ -2202,7 +2196,7 @@ def test_send_dedupe_on_resume(
|
||||
error=None,
|
||||
interrupts=(),
|
||||
state=None,
|
||||
result=["2|Command(send=Send(node='2', arg=3))"],
|
||||
result=["2|Command(goto=Send(node='2', arg=3))"],
|
||||
),
|
||||
PregelTask(
|
||||
id=AnyStr(),
|
||||
@@ -2216,7 +2210,7 @@ def test_send_dedupe_on_resume(
|
||||
error=None,
|
||||
interrupts=(),
|
||||
state=None,
|
||||
result=["2|Command(send=Send(node='flaky', arg=4))"],
|
||||
result=["2|Command(goto=Send(node='flaky', arg=4))"],
|
||||
),
|
||||
PregelTask(
|
||||
id=AnyStr(),
|
||||
@@ -2779,10 +2773,10 @@ def test_send_react_interrupt_control(
|
||||
tool_calls=[ToolCall(name="foo", args={"hi": [1, 2, 3]}, id=AnyStr())],
|
||||
)
|
||||
|
||||
def agent(state) -> GraphCommand[Literal["foo"]]:
|
||||
return GraphCommand(
|
||||
def agent(state) -> Command[Literal["foo"]]:
|
||||
return Command(
|
||||
update={"messages": ai_message},
|
||||
send=[Send(call["name"], call) for call in ai_message.tool_calls],
|
||||
goto=[Send(call["name"], call) for call in ai_message.tool_calls],
|
||||
)
|
||||
|
||||
foo_called = 0
|
||||
@@ -8728,6 +8722,176 @@ 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)
|
||||
def test_start_branch_then(
|
||||
snapshot: SnapshotAssertion, request: pytest.FixtureRequest, checkpointer_name: str
|
||||
@@ -14403,9 +14567,9 @@ def test_parent_command(request: pytest.FixtureRequest, checkpointer_name: str)
|
||||
from langchain_core.tools import tool
|
||||
|
||||
@tool(return_direct=True)
|
||||
def get_user_name() -> GraphCommand:
|
||||
def get_user_name() -> Command:
|
||||
"""Retrieve user name"""
|
||||
return GraphCommand(update={"user_name": "Meow"}, graph=GraphCommand.PARENT)
|
||||
return Command(update={"user_name": "Meow"}, graph=Command.PARENT)
|
||||
|
||||
subgraph_builder = StateGraph(MessagesState)
|
||||
subgraph_builder.add_node("tool", get_user_name)
|
||||
@@ -14471,3 +14635,175 @@ def test_parent_command(request: pytest.FixtureRequest, checkpointer_name: str)
|
||||
},
|
||||
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)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
|
||||
def test_interrupt_multiple(request: pytest.FixtureRequest, checkpointer_name: str):
|
||||
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
|
||||
|
||||
class State(TypedDict):
|
||||
my_key: Annotated[str, operator.add]
|
||||
|
||||
def node(s: State) -> State:
|
||||
answer = interrupt({"value": 1})
|
||||
answer2 = interrupt({"value": 2})
|
||||
return {"my_key": answer + " " + answer2}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("node", node)
|
||||
builder.add_edge(START, "node")
|
||||
|
||||
graph = builder.compile(checkpointer=checkpointer)
|
||||
thread1 = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
assert [e for e in graph.stream({"my_key": "DE", "market": "DE"}, thread1)] == [
|
||||
{
|
||||
"__interrupt__": (
|
||||
Interrupt(
|
||||
value={"value": 1},
|
||||
resumable=True,
|
||||
ns=[AnyStr("node:")],
|
||||
when="during",
|
||||
),
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
assert [
|
||||
event
|
||||
for event in graph.stream(
|
||||
Command(resume="answer 1", update={"my_key": "foofoo"}), thread1
|
||||
)
|
||||
] == [
|
||||
{
|
||||
"__interrupt__": (
|
||||
Interrupt(
|
||||
value={"value": 2},
|
||||
resumable=True,
|
||||
ns=[AnyStr("node:")],
|
||||
when="during",
|
||||
),
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
assert [event for event in graph.stream(Command(resume="answer 2"), thread1)] == [
|
||||
{"node": {"my_key": "answer 1 answer 2"}},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
|
||||
def test_interrupt_loop(request: pytest.FixtureRequest, checkpointer_name: str):
|
||||
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
|
||||
|
||||
class State(TypedDict):
|
||||
age: int
|
||||
other: str
|
||||
|
||||
def ask_age(s: State):
|
||||
"""Ask an expert for help."""
|
||||
question = "How old are you?"
|
||||
value = None
|
||||
for _ in range(10):
|
||||
value: str = interrupt(question)
|
||||
if not value.isdigit() or int(value) < 18:
|
||||
question = "invalid response"
|
||||
value = None
|
||||
else:
|
||||
break
|
||||
|
||||
return {"age": int(value)}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("node", ask_age)
|
||||
builder.add_edge(START, "node")
|
||||
|
||||
graph = builder.compile(checkpointer=checkpointer)
|
||||
thread1 = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
assert [e for e in graph.stream({"other": ""}, thread1)] == [
|
||||
{
|
||||
"__interrupt__": (
|
||||
Interrupt(
|
||||
value="How old are you?",
|
||||
resumable=True,
|
||||
ns=[AnyStr("node:")],
|
||||
when="during",
|
||||
),
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
assert [
|
||||
event
|
||||
for event in graph.stream(
|
||||
Command(resume="13"),
|
||||
thread1,
|
||||
)
|
||||
] == [
|
||||
{
|
||||
"__interrupt__": (
|
||||
Interrupt(
|
||||
value="invalid response",
|
||||
resumable=True,
|
||||
ns=[AnyStr("node:")],
|
||||
when="during",
|
||||
),
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
assert [
|
||||
event
|
||||
for event in graph.stream(
|
||||
Command(resume="15"),
|
||||
thread1,
|
||||
)
|
||||
] == [
|
||||
{
|
||||
"__interrupt__": (
|
||||
Interrupt(
|
||||
value="invalid response",
|
||||
resumable=True,
|
||||
ns=[AnyStr("node:")],
|
||||
when="during",
|
||||
),
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
assert [event for event in graph.stream(Command(resume="19"), thread1)] == [
|
||||
{"node": {"age": 19}},
|
||||
]
|
||||
|
||||
@@ -62,7 +62,7 @@ from langgraph.constants import (
|
||||
START,
|
||||
)
|
||||
from langgraph.errors import InvalidUpdateError, MultipleSubgraphsError, NodeInterrupt
|
||||
from langgraph.graph import END, Graph, GraphCommand, StateGraph
|
||||
from langgraph.graph import END, Graph, StateGraph
|
||||
from langgraph.graph.message import MessageGraph, MessagesState, add_messages
|
||||
from langgraph.managed.shared_value import SharedValue
|
||||
from langgraph.prebuilt.chat_agent_executor import create_tool_calling_executor
|
||||
@@ -429,6 +429,189 @@ 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(
|
||||
sys.version_info < (3, 11),
|
||||
@@ -664,7 +847,6 @@ async def test_node_not_cancelled_on_other_node_interrupted(
|
||||
assert awhiles == 1
|
||||
|
||||
|
||||
@pytest.mark.repeat(10)
|
||||
async def test_step_timeout_on_stream_hang() -> None:
|
||||
inner_task_cancelled = False
|
||||
|
||||
@@ -2376,7 +2558,6 @@ async def test_concurrent_emit_sends() -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.repeat(10)
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
|
||||
async def test_send_sequences(checkpointer_name: str) -> None:
|
||||
class Node:
|
||||
@@ -2390,15 +2571,15 @@ async def test_send_sequences(checkpointer_name: str) -> None:
|
||||
if isinstance(state, list) # or isinstance(state, Control)
|
||||
else ["|".join((self.name, str(state)))]
|
||||
)
|
||||
if isinstance(state, GraphCommand):
|
||||
if isinstance(state, Command):
|
||||
return replace(state, update=update)
|
||||
else:
|
||||
return update
|
||||
|
||||
async def send_for_fun(state):
|
||||
return [
|
||||
Send("2", GraphCommand(send=Send("2", 3))),
|
||||
Send("2", GraphCommand(send=Send("2", 4))),
|
||||
Send("2", Command(goto=Send("2", 3))),
|
||||
Send("2", Command(goto=Send("2", 4))),
|
||||
"3.1",
|
||||
]
|
||||
|
||||
@@ -2419,8 +2600,8 @@ async def test_send_sequences(checkpointer_name: str) -> None:
|
||||
== [
|
||||
"0",
|
||||
"1",
|
||||
"2|Command(send=Send(node='2', arg=3))",
|
||||
"2|Command(send=Send(node='2', arg=4))",
|
||||
"2|Command(goto=Send(node='2', arg=3))",
|
||||
"2|Command(goto=Send(node='2', arg=4))",
|
||||
"2|3",
|
||||
"2|4",
|
||||
"3",
|
||||
@@ -2431,8 +2612,8 @@ async def test_send_sequences(checkpointer_name: str) -> None:
|
||||
"0",
|
||||
"1",
|
||||
"3.1",
|
||||
"2|Command(send=Send(node='2', arg=3))",
|
||||
"2|Command(send=Send(node='2', arg=4))",
|
||||
"2|Command(goto=Send(node='2', arg=3))",
|
||||
"2|Command(goto=Send(node='2', arg=4))",
|
||||
"3",
|
||||
"2|3",
|
||||
"2|4",
|
||||
@@ -2449,16 +2630,16 @@ async def test_send_sequences(checkpointer_name: str) -> None:
|
||||
assert await graph.ainvoke(["0"], thread1) == [
|
||||
"0",
|
||||
"1",
|
||||
"2|Command(send=Send(node='2', arg=3))",
|
||||
"2|Command(send=Send(node='2', arg=4))",
|
||||
"2|Command(goto=Send(node='2', arg=3))",
|
||||
"2|Command(goto=Send(node='2', arg=4))",
|
||||
"2|3",
|
||||
"2|4",
|
||||
]
|
||||
assert await graph.ainvoke(None, thread1) == [
|
||||
"0",
|
||||
"1",
|
||||
"2|Command(send=Send(node='2', arg=3))",
|
||||
"2|Command(send=Send(node='2', arg=4))",
|
||||
"2|Command(goto=Send(node='2', arg=3))",
|
||||
"2|Command(goto=Send(node='2', arg=4))",
|
||||
"2|3",
|
||||
"2|4",
|
||||
"3",
|
||||
@@ -2466,7 +2647,6 @@ async def test_send_sequences(checkpointer_name: str) -> None:
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.repeat(20)
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
|
||||
async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
|
||||
if not FF_SEND_V2:
|
||||
@@ -2494,15 +2674,15 @@ async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
|
||||
if isinstance(state, list)
|
||||
else ["|".join((self.name, str(state)))]
|
||||
)
|
||||
if isinstance(state, GraphCommand):
|
||||
if isinstance(state, Command):
|
||||
return replace(state, update=update)
|
||||
else:
|
||||
return update
|
||||
|
||||
def send_for_fun(state):
|
||||
return [
|
||||
Send("2", GraphCommand(send=Send("2", 3))),
|
||||
Send("2", GraphCommand(send=Send("flaky", 4))),
|
||||
Send("2", Command(goto=Send("2", 3))),
|
||||
Send("2", Command(goto=Send("flaky", 4))),
|
||||
"3.1",
|
||||
]
|
||||
|
||||
@@ -2525,8 +2705,8 @@ async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
|
||||
assert await graph.ainvoke(["0"], thread1, debug=1) == [
|
||||
"0",
|
||||
"1",
|
||||
"2|Command(send=Send(node='2', arg=3))",
|
||||
"2|Command(send=Send(node='flaky', arg=4))",
|
||||
"2|Command(goto=Send(node='2', arg=3))",
|
||||
"2|Command(goto=Send(node='flaky', arg=4))",
|
||||
"2|3",
|
||||
]
|
||||
assert builder.nodes["2"].runnable.func.ticks == 3
|
||||
@@ -2535,8 +2715,8 @@ async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
|
||||
assert await graph.ainvoke(None, thread1, debug=1) == [
|
||||
"0",
|
||||
"1",
|
||||
"2|Command(send=Send(node='2', arg=3))",
|
||||
"2|Command(send=Send(node='flaky', arg=4))",
|
||||
"2|Command(goto=Send(node='2', arg=3))",
|
||||
"2|Command(goto=Send(node='flaky', arg=4))",
|
||||
"2|3",
|
||||
"flaky|4",
|
||||
"3",
|
||||
@@ -2553,8 +2733,8 @@ async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
|
||||
values=[
|
||||
"0",
|
||||
"1",
|
||||
"2|Command(send=Send(node='2', arg=3))",
|
||||
"2|Command(send=Send(node='flaky', arg=4))",
|
||||
"2|Command(goto=Send(node='2', arg=3))",
|
||||
"2|Command(goto=Send(node='flaky', arg=4))",
|
||||
"2|3",
|
||||
"flaky|4",
|
||||
"3",
|
||||
@@ -2589,8 +2769,8 @@ async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
|
||||
values=[
|
||||
"0",
|
||||
"1",
|
||||
"2|Command(send=Send(node='2', arg=3))",
|
||||
"2|Command(send=Send(node='flaky', arg=4))",
|
||||
"2|Command(goto=Send(node='2', arg=3))",
|
||||
"2|Command(goto=Send(node='flaky', arg=4))",
|
||||
"2|3",
|
||||
"flaky|4",
|
||||
],
|
||||
@@ -2607,8 +2787,8 @@ async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
|
||||
"writes": {
|
||||
"1": ["1"],
|
||||
"2": [
|
||||
["2|Command(send=Send(node='2', arg=3))"],
|
||||
["2|Command(send=Send(node='flaky', arg=4))"],
|
||||
["2|Command(goto=Send(node='2', arg=3))"],
|
||||
["2|Command(goto=Send(node='flaky', arg=4))"],
|
||||
["2|3"],
|
||||
],
|
||||
"flaky": ["flaky|4"],
|
||||
@@ -2693,7 +2873,7 @@ async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
|
||||
error=None,
|
||||
interrupts=(),
|
||||
state=None,
|
||||
result=["2|Command(send=Send(node='2', arg=3))"],
|
||||
result=["2|Command(goto=Send(node='2', arg=3))"],
|
||||
),
|
||||
PregelTask(
|
||||
id=AnyStr(),
|
||||
@@ -2707,7 +2887,7 @@ async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
|
||||
error=None,
|
||||
interrupts=(),
|
||||
state=None,
|
||||
result=["2|Command(send=Send(node='flaky', arg=4))"],
|
||||
result=["2|Command(goto=Send(node='flaky', arg=4))"],
|
||||
),
|
||||
PregelTask(
|
||||
id=AnyStr(),
|
||||
@@ -3265,9 +3445,9 @@ async def test_send_react_interrupt_control(
|
||||
)
|
||||
|
||||
async def agent(state) -> Command[Literal["foo"]]:
|
||||
return GraphCommand(
|
||||
return Command(
|
||||
update={"messages": ai_message},
|
||||
send=[Send(call["name"], call) for call in ai_message.tool_calls],
|
||||
goto=[Send(call["name"], call) for call in ai_message.tool_calls],
|
||||
)
|
||||
|
||||
foo_called = 0
|
||||
@@ -3578,13 +3758,13 @@ async def test_max_concurrency(checkpointer_name: str) -> None:
|
||||
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
|
||||
async def test_max_concurrency_control(checkpointer_name: str) -> None:
|
||||
async def node1(state) -> GraphCommand[Literal["2"]]:
|
||||
return GraphCommand(update=["1"], send=[Send("2", idx) for idx in range(100)])
|
||||
async def node1(state) -> Command[Literal["2"]]:
|
||||
return Command(update=["1"], goto=[Send("2", idx) for idx in range(100)])
|
||||
|
||||
node2_currently = 0
|
||||
node2_max_currently = 0
|
||||
|
||||
async def node2(state) -> GraphCommand[Literal["3"]]:
|
||||
async def node2(state) -> Command[Literal["3"]]:
|
||||
nonlocal node2_currently, node2_max_currently
|
||||
node2_currently += 1
|
||||
if node2_currently > node2_max_currently:
|
||||
@@ -3592,7 +3772,7 @@ async def test_max_concurrency_control(checkpointer_name: str) -> None:
|
||||
await asyncio.sleep(0.1)
|
||||
node2_currently -= 1
|
||||
|
||||
return GraphCommand(update=[state], goto="3")
|
||||
return Command(update=[state], goto="3")
|
||||
|
||||
async def node3(state) -> Literal["3"]:
|
||||
return ["3"]
|
||||
@@ -12605,9 +12785,9 @@ async def test_parent_command(checkpointer_name: str) -> None:
|
||||
from langchain_core.tools import tool
|
||||
|
||||
@tool(return_direct=True)
|
||||
def get_user_name() -> GraphCommand:
|
||||
def get_user_name() -> Command:
|
||||
"""Retrieve user name"""
|
||||
return GraphCommand(update={"user_name": "Meow"}, graph=GraphCommand.PARENT)
|
||||
return Command(update={"user_name": "Meow"}, graph=Command.PARENT)
|
||||
|
||||
subgraph_builder = StateGraph(MessagesState)
|
||||
subgraph_builder.add_node("tool", get_user_name)
|
||||
@@ -12677,3 +12857,196 @@ async def test_parent_command(checkpointer_name: str) -> None:
|
||||
},
|
||||
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)
|
||||
|
||||
|
||||
@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_multiple(checkpointer_name: str):
|
||||
class State(TypedDict):
|
||||
my_key: Annotated[str, operator.add]
|
||||
|
||||
async def node(s: State) -> State:
|
||||
answer = interrupt({"value": 1})
|
||||
answer2 = interrupt({"value": 2})
|
||||
return {"my_key": answer + " " + answer2}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("node", node)
|
||||
builder.add_edge(START, "node")
|
||||
|
||||
async with awith_checkpointer(checkpointer_name) as checkpointer:
|
||||
graph = builder.compile(checkpointer=checkpointer)
|
||||
thread1 = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
assert [
|
||||
e async for e in graph.astream({"my_key": "DE", "market": "DE"}, thread1)
|
||||
] == [
|
||||
{
|
||||
"__interrupt__": (
|
||||
Interrupt(
|
||||
value={"value": 1},
|
||||
resumable=True,
|
||||
ns=[AnyStr("node:")],
|
||||
when="during",
|
||||
),
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
assert [
|
||||
event
|
||||
async for event in graph.astream(
|
||||
Command(resume="answer 1", update={"my_key": "foofoo"}),
|
||||
thread1,
|
||||
stream_mode="updates",
|
||||
)
|
||||
] == [
|
||||
{
|
||||
"__interrupt__": (
|
||||
Interrupt(
|
||||
value={"value": 2},
|
||||
resumable=True,
|
||||
ns=[AnyStr("node:")],
|
||||
when="during",
|
||||
),
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
assert [
|
||||
event
|
||||
async for event in graph.astream(
|
||||
Command(resume="answer 2"), thread1, stream_mode="updates"
|
||||
)
|
||||
] == [
|
||||
{"node": {"my_key": "answer 1 answer 2"}},
|
||||
]
|
||||
|
||||
|
||||
@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_loop(checkpointer_name: str):
|
||||
class State(TypedDict):
|
||||
age: int
|
||||
other: str
|
||||
|
||||
async def ask_age(s: State):
|
||||
"""Ask an expert for help."""
|
||||
question = "How old are you?"
|
||||
value = None
|
||||
for _ in range(10):
|
||||
value: str = interrupt(question)
|
||||
if not value.isdigit() or int(value) < 18:
|
||||
question = "invalid response"
|
||||
value = None
|
||||
else:
|
||||
break
|
||||
|
||||
return {"age": int(value)}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("node", ask_age)
|
||||
builder.add_edge(START, "node")
|
||||
|
||||
async with awith_checkpointer(checkpointer_name) as checkpointer:
|
||||
graph = builder.compile(checkpointer=checkpointer)
|
||||
thread1 = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
assert [e async for e in graph.astream({"other": ""}, thread1)] == [
|
||||
{
|
||||
"__interrupt__": (
|
||||
Interrupt(
|
||||
value="How old are you?",
|
||||
resumable=True,
|
||||
ns=[AnyStr("node:")],
|
||||
when="during",
|
||||
),
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
assert [
|
||||
event
|
||||
async for event in graph.astream(
|
||||
Command(resume="13"),
|
||||
thread1,
|
||||
)
|
||||
] == [
|
||||
{
|
||||
"__interrupt__": (
|
||||
Interrupt(
|
||||
value="invalid response",
|
||||
resumable=True,
|
||||
ns=[AnyStr("node:")],
|
||||
when="during",
|
||||
),
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
assert [
|
||||
event
|
||||
async for event in graph.astream(
|
||||
Command(resume="15"),
|
||||
thread1,
|
||||
)
|
||||
] == [
|
||||
{
|
||||
"__interrupt__": (
|
||||
Interrupt(
|
||||
value="invalid response",
|
||||
resumable=True,
|
||||
ns=[AnyStr("node:")],
|
||||
when="during",
|
||||
),
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
assert [
|
||||
event async for event in graph.astream(Command(resume="19"), thread1)
|
||||
] == [
|
||||
{"node": {"age": 19}},
|
||||
]
|
||||
|
||||
@@ -35,3 +35,21 @@ class AnyDict(dict):
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
class AnyList(list):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not self and isinstance(other, list):
|
||||
return True
|
||||
if not isinstance(other, list) or len(self) != len(other):
|
||||
return False
|
||||
for i, v in enumerate(self):
|
||||
if v == other[i]:
|
||||
continue
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
@@ -11,10 +11,10 @@ from aiokafka import AIOKafkaProducer
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from langgraph.constants import FF_SEND_V2, START
|
||||
from langgraph.errors import NodeInterrupt
|
||||
from langgraph.graph.state import CompiledStateGraph, GraphCommand, StateGraph
|
||||
from langgraph.graph.state import CompiledStateGraph, StateGraph
|
||||
from langgraph.scheduler.kafka import serde
|
||||
from langgraph.scheduler.kafka.types import MessageToOrchestrator, Topics
|
||||
from langgraph.types import Send
|
||||
from langgraph.types import Command, Send
|
||||
from tests.any import AnyDict
|
||||
from tests.drain import drain_topics_async
|
||||
|
||||
@@ -48,15 +48,15 @@ def mk_push_graph(
|
||||
if isinstance(state, list)
|
||||
else ["|".join((self.name, str(state)))]
|
||||
)
|
||||
if isinstance(state, GraphCommand):
|
||||
if isinstance(state, Command):
|
||||
return state.copy(update=update)
|
||||
else:
|
||||
return update
|
||||
|
||||
def send_for_fun(state):
|
||||
return [
|
||||
Send("2", GraphCommand(send=Send("2", 3))),
|
||||
Send("2", GraphCommand(send=Send("flaky", 4))),
|
||||
Send("2", Command(goto=Send("2", 3))),
|
||||
Send("2", Command(goto=Send("flaky", 4))),
|
||||
"3.1",
|
||||
]
|
||||
|
||||
@@ -105,8 +105,8 @@ async def test_push_graph(topics: Topics, acheckpointer: BaseCheckpointSaver) ->
|
||||
== [
|
||||
"0",
|
||||
"1",
|
||||
"2|Control(send=Send(node='2', arg=3))",
|
||||
"2|Control(send=Send(node='flaky', arg=4))",
|
||||
"2|Control(goto=Send(node='2', arg=3))",
|
||||
"2|Control(goto=Send(node='flaky', arg=4))",
|
||||
"2|3",
|
||||
]
|
||||
)
|
||||
@@ -182,8 +182,8 @@ async def test_push_graph(topics: Topics, acheckpointer: BaseCheckpointSaver) ->
|
||||
== [
|
||||
"0",
|
||||
"1",
|
||||
"2|Control(send=Send(node='2', arg=3))",
|
||||
"2|Control(send=Send(node='flaky', arg=4))",
|
||||
"2|Control(goto=Send(node='2', arg=3))",
|
||||
"2|Control(goto=Send(node='flaky', arg=4))",
|
||||
"2|3",
|
||||
"flaky|4",
|
||||
"3",
|
||||
|
||||
@@ -10,11 +10,11 @@ import pytest
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from langgraph.constants import FF_SEND_V2, START
|
||||
from langgraph.errors import NodeInterrupt
|
||||
from langgraph.graph.state import CompiledStateGraph, GraphCommand, StateGraph
|
||||
from langgraph.graph.state import CompiledStateGraph, StateGraph
|
||||
from langgraph.scheduler.kafka import serde
|
||||
from langgraph.scheduler.kafka.default_sync import DefaultProducer
|
||||
from langgraph.scheduler.kafka.types import MessageToOrchestrator, Topics
|
||||
from langgraph.types import Send
|
||||
from langgraph.types import Command, Send
|
||||
from tests.any import AnyDict
|
||||
from tests.drain import drain_topics
|
||||
|
||||
@@ -48,15 +48,15 @@ def mk_push_graph(
|
||||
if isinstance(state, list)
|
||||
else ["|".join((self.name, str(state)))]
|
||||
)
|
||||
if isinstance(state, GraphCommand):
|
||||
if isinstance(state, Command):
|
||||
return state.copy(update=update)
|
||||
else:
|
||||
return update
|
||||
|
||||
def send_for_fun(state):
|
||||
return [
|
||||
Send("2", GraphCommand(send=Send("2", 3))),
|
||||
Send("2", GraphCommand(send=Send("flaky", 4))),
|
||||
Send("2", Command(goto=Send("2", 3))),
|
||||
Send("2", Command(goto=Send("flaky", 4))),
|
||||
"3.1",
|
||||
]
|
||||
|
||||
@@ -106,8 +106,8 @@ def test_push_graph(topics: Topics, acheckpointer: BaseCheckpointSaver) -> None:
|
||||
== [
|
||||
"0",
|
||||
"1",
|
||||
"2|Control(send=Send(node='2', arg=3))",
|
||||
"2|Control(send=Send(node='flaky', arg=4))",
|
||||
"2|Control(goto=Send(node='2', arg=3))",
|
||||
"2|Control(goto=Send(node='flaky', arg=4))",
|
||||
"2|3",
|
||||
]
|
||||
)
|
||||
@@ -184,8 +184,8 @@ def test_push_graph(topics: Topics, acheckpointer: BaseCheckpointSaver) -> None:
|
||||
== [
|
||||
"0",
|
||||
"1",
|
||||
"2|Control(send=Send(node='2', arg=3))",
|
||||
"2|Control(send=Send(node='flaky', arg=4))",
|
||||
"2|Control(goto=Send(node='2', arg=3))",
|
||||
"2|Control(goto=Send(node='flaky', arg=4))",
|
||||
"2|3",
|
||||
"flaky|4",
|
||||
"3",
|
||||
|
||||
@@ -15,7 +15,7 @@ from langgraph.graph.state import StateGraph
|
||||
from langgraph.pregel import Pregel
|
||||
from langgraph.scheduler.kafka import serde
|
||||
from langgraph.scheduler.kafka.types import MessageToOrchestrator, Topics
|
||||
from tests.any import AnyDict
|
||||
from tests.any import AnyDict, AnyList
|
||||
from tests.drain import drain_topics_async
|
||||
from tests.messages import _AnyIdAIMessage, _AnyIdHumanMessage
|
||||
|
||||
@@ -196,7 +196,8 @@ async def test_subgraph_w_interrupt(
|
||||
"__pregel_resuming": False,
|
||||
"__pregel_store": None,
|
||||
"__pregel_task_id": history[0].tasks[0].id,
|
||||
"__pregel_resume_value": None,
|
||||
"__pregel_scratchpad": {},
|
||||
"__pregel_writes": AnyList(),
|
||||
"checkpoint_id": None,
|
||||
"checkpoint_map": {
|
||||
"": history[0].config["configurable"]["checkpoint_id"]
|
||||
@@ -261,7 +262,8 @@ async def test_subgraph_w_interrupt(
|
||||
"__pregel_resuming": False,
|
||||
"__pregel_store": None,
|
||||
"__pregel_task_id": history[0].tasks[0].id,
|
||||
"__pregel_resume_value": None,
|
||||
"__pregel_scratchpad": {},
|
||||
"__pregel_writes": AnyList(),
|
||||
"checkpoint_id": c.config["configurable"]["checkpoint_id"],
|
||||
"checkpoint_map": {
|
||||
"": history[0].config["configurable"]["checkpoint_id"]
|
||||
@@ -356,7 +358,8 @@ async def test_subgraph_w_interrupt(
|
||||
"__pregel_resuming": False,
|
||||
"__pregel_store": None,
|
||||
"__pregel_task_id": history[0].tasks[0].id,
|
||||
"__pregel_resume_value": None,
|
||||
"__pregel_scratchpad": {},
|
||||
"__pregel_writes": AnyList(),
|
||||
"checkpoint_id": c.config["configurable"]["checkpoint_id"],
|
||||
"checkpoint_map": {
|
||||
"": history[0].config["configurable"]["checkpoint_id"]
|
||||
@@ -461,7 +464,8 @@ async def test_subgraph_w_interrupt(
|
||||
"__pregel_resuming": True,
|
||||
"__pregel_store": None,
|
||||
"__pregel_task_id": history[1].tasks[0].id,
|
||||
"__pregel_resume_value": None,
|
||||
"__pregel_scratchpad": {},
|
||||
"__pregel_writes": AnyList(),
|
||||
"checkpoint_id": None,
|
||||
"checkpoint_map": {
|
||||
"": history[1].config["configurable"]["checkpoint_id"]
|
||||
@@ -521,7 +525,8 @@ async def test_subgraph_w_interrupt(
|
||||
"__pregel_resuming": True,
|
||||
"__pregel_store": None,
|
||||
"__pregel_task_id": history[1].tasks[0].id,
|
||||
"__pregel_resume_value": None,
|
||||
"__pregel_scratchpad": {},
|
||||
"__pregel_writes": AnyList(),
|
||||
"checkpoint_id": c.config["configurable"]["checkpoint_id"],
|
||||
"checkpoint_map": {
|
||||
"": history[1].config["configurable"]["checkpoint_id"]
|
||||
@@ -637,7 +642,8 @@ async def test_subgraph_w_interrupt(
|
||||
"__pregel_resuming": True,
|
||||
"__pregel_store": None,
|
||||
"__pregel_task_id": history[1].tasks[0].id,
|
||||
"__pregel_resume_value": None,
|
||||
"__pregel_scratchpad": {},
|
||||
"__pregel_writes": AnyList(),
|
||||
"checkpoint_id": c.config["configurable"]["checkpoint_id"],
|
||||
"checkpoint_map": {
|
||||
"": history[1].config["configurable"]["checkpoint_id"]
|
||||
|
||||
@@ -15,7 +15,7 @@ from langgraph.pregel import Pregel
|
||||
from langgraph.scheduler.kafka import serde
|
||||
from langgraph.scheduler.kafka.default_sync import DefaultProducer
|
||||
from langgraph.scheduler.kafka.types import MessageToOrchestrator, Topics
|
||||
from tests.any import AnyDict
|
||||
from tests.any import AnyDict, AnyList
|
||||
from tests.drain import drain_topics
|
||||
from tests.messages import _AnyIdAIMessage, _AnyIdHumanMessage
|
||||
|
||||
@@ -195,7 +195,8 @@ def test_subgraph_w_interrupt(
|
||||
"__pregel_resuming": False,
|
||||
"__pregel_store": None,
|
||||
"__pregel_task_id": history[0].tasks[0].id,
|
||||
"__pregel_resume_value": None,
|
||||
"__pregel_scratchpad": {},
|
||||
"__pregel_writes": AnyList(),
|
||||
"checkpoint_id": None,
|
||||
"checkpoint_map": {
|
||||
"": history[0].config["configurable"]["checkpoint_id"]
|
||||
@@ -260,7 +261,8 @@ def test_subgraph_w_interrupt(
|
||||
"__pregel_dedupe_tasks": True,
|
||||
"__pregel_resuming": False,
|
||||
"__pregel_task_id": history[0].tasks[0].id,
|
||||
"__pregel_resume_value": None,
|
||||
"__pregel_scratchpad": {},
|
||||
"__pregel_writes": AnyList(),
|
||||
"checkpoint_id": c.config["configurable"]["checkpoint_id"],
|
||||
"checkpoint_map": {
|
||||
"": history[0].config["configurable"]["checkpoint_id"]
|
||||
@@ -355,7 +357,8 @@ def test_subgraph_w_interrupt(
|
||||
"__pregel_store": None,
|
||||
"__pregel_resuming": False,
|
||||
"__pregel_task_id": history[0].tasks[0].id,
|
||||
"__pregel_resume_value": None,
|
||||
"__pregel_scratchpad": {},
|
||||
"__pregel_writes": AnyList(),
|
||||
"checkpoint_id": c.config["configurable"]["checkpoint_id"],
|
||||
"checkpoint_map": {
|
||||
"": history[0].config["configurable"]["checkpoint_id"]
|
||||
@@ -459,7 +462,8 @@ def test_subgraph_w_interrupt(
|
||||
"__pregel_store": None,
|
||||
"__pregel_resuming": True,
|
||||
"__pregel_task_id": history[1].tasks[0].id,
|
||||
"__pregel_resume_value": None,
|
||||
"__pregel_scratchpad": {},
|
||||
"__pregel_writes": AnyList(),
|
||||
"checkpoint_id": None,
|
||||
"checkpoint_map": {
|
||||
"": history[1].config["configurable"]["checkpoint_id"]
|
||||
@@ -519,7 +523,8 @@ def test_subgraph_w_interrupt(
|
||||
"__pregel_store": None,
|
||||
"__pregel_resuming": True,
|
||||
"__pregel_task_id": history[1].tasks[0].id,
|
||||
"__pregel_resume_value": None,
|
||||
"__pregel_scratchpad": {},
|
||||
"__pregel_writes": AnyList(),
|
||||
"checkpoint_id": c.config["configurable"]["checkpoint_id"],
|
||||
"checkpoint_map": {
|
||||
"": history[1].config["configurable"]["checkpoint_id"]
|
||||
@@ -635,7 +640,8 @@ def test_subgraph_w_interrupt(
|
||||
"__pregel_resuming": True,
|
||||
"__pregel_store": None,
|
||||
"__pregel_task_id": history[1].tasks[0].id,
|
||||
"__pregel_resume_value": None,
|
||||
"__pregel_scratchpad": {},
|
||||
"__pregel_writes": AnyList(),
|
||||
"checkpoint_id": c.config["configurable"]["checkpoint_id"],
|
||||
"checkpoint_map": {
|
||||
"": history[1].config["configurable"]["checkpoint_id"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@langchain/langgraph-sdk",
|
||||
"version": "0.0.29",
|
||||
"version": "0.0.31",
|
||||
"description": "Client library for interacting with the LangGraph API",
|
||||
"type": "module",
|
||||
"packageManager": "yarn@1.22.19",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
GraphSchema,
|
||||
Metadata,
|
||||
Run,
|
||||
RunStatus,
|
||||
Thread,
|
||||
ThreadState,
|
||||
Cron,
|
||||
@@ -944,12 +945,18 @@ export class RunsClient extends BaseClient {
|
||||
* Defaults to 0.
|
||||
*/
|
||||
offset?: number;
|
||||
|
||||
/**
|
||||
* Status of the run to filter by.
|
||||
*/
|
||||
status?: RunStatus;
|
||||
},
|
||||
): Promise<Run[]> {
|
||||
return this.fetch<Run[]>(`/threads/${threadId}/runs`, {
|
||||
params: {
|
||||
limit: options?.limit ?? 10,
|
||||
offset: options?.offset ?? 0,
|
||||
status: options?.status ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1014,19 +1021,28 @@ export class RunsClient extends BaseClient {
|
||||
*
|
||||
* @param threadId The ID of the thread.
|
||||
* @param runId The ID of the run.
|
||||
* @param signal An optional abort signal.
|
||||
* @returns An async generator yielding stream parts.
|
||||
*/
|
||||
async *joinStream(
|
||||
threadId: string,
|
||||
runId: string,
|
||||
signal?: AbortSignal,
|
||||
options?:
|
||||
| { signal?: AbortSignal; cancelOnDisconnect?: boolean }
|
||||
| AbortSignal,
|
||||
): AsyncGenerator<{ event: StreamEvent; data: any }> {
|
||||
const opts =
|
||||
typeof options === "object" &&
|
||||
options != null &&
|
||||
options instanceof AbortSignal
|
||||
? { signal: options }
|
||||
: options;
|
||||
|
||||
const response = await this.asyncCaller.fetch(
|
||||
...this.prepareFetchOptions(`/threads/${threadId}/runs/${runId}/stream`, {
|
||||
method: "GET",
|
||||
timeoutMs: null,
|
||||
signal,
|
||||
signal: opts?.signal,
|
||||
params: { cancel_on_disconnect: opts?.cancelOnDisconnect ? "1" : "0" },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1041,7 +1057,7 @@ export class RunsClient extends BaseClient {
|
||||
async start(ctrl) {
|
||||
parser = createParser((event) => {
|
||||
if (
|
||||
(signal && signal.aborted) ||
|
||||
(opts?.signal && opts.signal.aborted) ||
|
||||
(event.type === "event" && event.data === "[DONE]")
|
||||
) {
|
||||
ctrl.terminate();
|
||||
@@ -1190,6 +1206,7 @@ export class StoreClient extends BaseClient {
|
||||
* @param options.filter Optional dictionary of key-value pairs to filter results.
|
||||
* @param options.limit Maximum number of items to return (default is 10).
|
||||
* @param options.offset Number of items to skip before returning results (default is 0).
|
||||
* @param options.query Optional search query.
|
||||
* @returns Promise<SearchItemsResponse>
|
||||
*/
|
||||
async searchItems(
|
||||
@@ -1198,6 +1215,7 @@ export class StoreClient extends BaseClient {
|
||||
filter?: Record<string, any>;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
query?: string;
|
||||
},
|
||||
): Promise<SearchItemsResponse> {
|
||||
const payload = {
|
||||
@@ -1205,6 +1223,7 @@ export class StoreClient extends BaseClient {
|
||||
filter: options?.filter,
|
||||
limit: options?.limit ?? 10,
|
||||
offset: options?.offset ?? 0,
|
||||
query: options?.query,
|
||||
};
|
||||
|
||||
const response = await this.fetch<APISearchItemsResponse>(
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { JSONSchema7 } from "json-schema";
|
||||
|
||||
type Optional<T> = T | null | undefined;
|
||||
|
||||
type RunStatus =
|
||||
export type RunStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "error"
|
||||
@@ -264,11 +264,6 @@ export interface Checkpoint {
|
||||
export interface ListNamespaceResponse {
|
||||
namespaces: string[][];
|
||||
}
|
||||
|
||||
export interface SearchItemsResponse {
|
||||
items: Item[];
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
namespace: string[];
|
||||
key: string;
|
||||
@@ -276,3 +271,10 @@ export interface Item {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SearchItem extends Item {
|
||||
score?: number;
|
||||
}
|
||||
export interface SearchItemsResponse {
|
||||
items: SearchItem[];
|
||||
}
|
||||
|
||||
@@ -40,9 +40,11 @@ export interface Command {
|
||||
resume?: unknown;
|
||||
|
||||
/**
|
||||
* A single, or array of `Send` commands to trigger nodes.
|
||||
* Determine the next node to navigate to. Can be one of the following:
|
||||
* - Name(s) of the node names to navigate to next.
|
||||
* - `Send` command(s) to execute node(s) with provided input.
|
||||
*/
|
||||
send?: Send | Send[];
|
||||
goto?: Send | Send[] | string | string[];
|
||||
}
|
||||
|
||||
interface RunsInvokePayload {
|
||||
|
||||
@@ -26,7 +26,6 @@ from typing import (
|
||||
)
|
||||
|
||||
import httpx
|
||||
import httpx_sse
|
||||
import orjson
|
||||
from httpx._types import QueryParamTypes
|
||||
|
||||
@@ -51,6 +50,7 @@ from langgraph_sdk.schema import (
|
||||
OnConflictBehavior,
|
||||
Run,
|
||||
RunCreate,
|
||||
RunStatus,
|
||||
SearchItemsResponse,
|
||||
StreamMode,
|
||||
StreamPart,
|
||||
@@ -60,6 +60,7 @@ from langgraph_sdk.schema import (
|
||||
ThreadStatus,
|
||||
ThreadUpdateStateResponse,
|
||||
)
|
||||
from langgraph_sdk.sse import SSEDecoder, aiter_lines_raw, iter_lines_raw
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -189,7 +190,7 @@ class LangGraphClient:
|
||||
|
||||
|
||||
class HttpClient:
|
||||
"""Hancle async requests to the LangGraph API.
|
||||
"""Handle async requests to the LangGraph API.
|
||||
|
||||
Adds additional error messaging & content handling above the
|
||||
provided httpx client.
|
||||
@@ -277,29 +278,49 @@ class HttpClient:
|
||||
raise e
|
||||
|
||||
async def stream(
|
||||
self, path: str, method: str, *, json: Optional[dict] = None
|
||||
self,
|
||||
path: str,
|
||||
method: str,
|
||||
*,
|
||||
json: Optional[dict] = None,
|
||||
params: Optional[QueryParamTypes] = None,
|
||||
) -> AsyncIterator[StreamPart]:
|
||||
"""Stream results using SSE."""
|
||||
headers, content = await aencode_json(json)
|
||||
async with httpx_sse.aconnect_sse(
|
||||
self.client, method, path, headers=headers, content=content
|
||||
) as sse:
|
||||
headers["Accept"] = "text/event-stream"
|
||||
headers["Cache-Control"] = "no-store"
|
||||
|
||||
async with self.client.stream(
|
||||
method, path, headers=headers, content=content, params=params
|
||||
) as res:
|
||||
# check status
|
||||
try:
|
||||
sse.response.raise_for_status()
|
||||
res.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
body = (await sse.response.aread()).decode()
|
||||
body = (await res.aread()).decode()
|
||||
if sys.version_info >= (3, 11):
|
||||
e.add_note(body)
|
||||
else:
|
||||
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
|
||||
raise e
|
||||
async for event in sse.aiter_sse():
|
||||
yield StreamPart(
|
||||
event.event, orjson.loads(event.data) if event.data else None
|
||||
# check content type
|
||||
content_type = res.headers.get("content-type", "").partition(";")[0]
|
||||
if "text/event-stream" not in content_type:
|
||||
raise httpx.TransportError(
|
||||
"Expected response header Content-Type to contain 'text/event-stream', "
|
||||
f"got {content_type!r}"
|
||||
)
|
||||
# parse SSE
|
||||
decoder = SSEDecoder()
|
||||
async for line in aiter_lines_raw(res):
|
||||
sse = decoder.decode(line=line.rstrip(b"\n"))
|
||||
if sse is not None:
|
||||
yield sse
|
||||
|
||||
|
||||
async def aencode_json(json: Any) -> tuple[dict[str, str], bytes]:
|
||||
if json is None:
|
||||
return {}, None
|
||||
body = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
orjson.dumps,
|
||||
@@ -1684,7 +1705,12 @@ class RunsClient:
|
||||
return response
|
||||
|
||||
async def list(
|
||||
self, thread_id: str, *, limit: int = 10, offset: int = 0
|
||||
self,
|
||||
thread_id: str,
|
||||
*,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
status: Optional[RunStatus] = None,
|
||||
) -> List[Run]:
|
||||
"""List runs.
|
||||
|
||||
@@ -1692,6 +1718,7 @@ class RunsClient:
|
||||
thread_id: The thread ID to list runs for.
|
||||
limit: The maximum number of results to return.
|
||||
offset: The number of results to skip.
|
||||
status: The status of the run to filter by.
|
||||
|
||||
Returns:
|
||||
List[Run]: The runs for the thread.
|
||||
@@ -1705,9 +1732,13 @@ class RunsClient:
|
||||
)
|
||||
|
||||
""" # noqa: E501
|
||||
return await self.http.get(
|
||||
f"/threads/{thread_id}/runs?limit={limit}&offset={offset}"
|
||||
)
|
||||
params = {
|
||||
"limit": limit,
|
||||
"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:
|
||||
"""Get a run.
|
||||
@@ -1785,7 +1816,9 @@ class RunsClient:
|
||||
""" # noqa: E501
|
||||
return await self.http.get(f"/threads/{thread_id}/runs/{run_id}/join")
|
||||
|
||||
def join_stream(self, thread_id: str, run_id: str) -> AsyncIterator[StreamPart]:
|
||||
def join_stream(
|
||||
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.
|
||||
Output is not buffered, so any output produced before this call will
|
||||
not be received here.
|
||||
@@ -1793,6 +1826,7 @@ class RunsClient:
|
||||
Args:
|
||||
thread_id: The thread ID to join.
|
||||
run_id: The run ID to join.
|
||||
cancel_on_disconnect: Whether to cancel the run when the stream is disconnected.
|
||||
|
||||
Returns:
|
||||
None
|
||||
@@ -1805,7 +1839,11 @@ class RunsClient:
|
||||
)
|
||||
|
||||
""" # noqa: E501
|
||||
return self.http.stream(f"/threads/{thread_id}/runs/{run_id}/stream", "GET")
|
||||
return self.http.stream(
|
||||
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:
|
||||
"""Delete a run.
|
||||
@@ -2416,26 +2454,41 @@ class SyncHttpClient:
|
||||
raise e
|
||||
|
||||
def stream(
|
||||
self, path: str, method: str, *, json: Optional[dict] = None
|
||||
self,
|
||||
path: str,
|
||||
method: str,
|
||||
*,
|
||||
json: Optional[dict] = None,
|
||||
params: Optional[QueryParamTypes] = None,
|
||||
) -> Iterator[StreamPart]:
|
||||
"""Stream the results of a request using SSE."""
|
||||
headers, content = encode_json(json)
|
||||
with httpx_sse.connect_sse(
|
||||
self.client, method, path, headers=headers, content=content
|
||||
) as sse:
|
||||
with self.client.stream(
|
||||
method, path, headers=headers, content=content, params=params
|
||||
) as res:
|
||||
# check status
|
||||
try:
|
||||
sse.response.raise_for_status()
|
||||
res.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
body = sse.response.read().decode()
|
||||
body = (res.read()).decode()
|
||||
if sys.version_info >= (3, 11):
|
||||
e.add_note(body)
|
||||
else:
|
||||
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
|
||||
raise e
|
||||
for event in sse.iter_sse():
|
||||
yield StreamPart(
|
||||
event.event, orjson.loads(event.data) if event.data else None
|
||||
# check content type
|
||||
content_type = res.headers.get("content-type", "").partition(";")[0]
|
||||
if "text/event-stream" not in content_type:
|
||||
raise httpx.TransportError(
|
||||
"Expected response header Content-Type to contain 'text/event-stream', "
|
||||
f"got {content_type!r}"
|
||||
)
|
||||
# parse SSE
|
||||
decoder = SSEDecoder()
|
||||
for line in iter_lines_raw(res):
|
||||
sse = decoder.decode(line.rstrip(b"\n"))
|
||||
if sse is not None:
|
||||
yield sse
|
||||
|
||||
|
||||
def encode_json(json: Any) -> tuple[dict[str, str], bytes]:
|
||||
@@ -3303,6 +3356,7 @@ class SyncRunsClient:
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Optional[dict] = None,
|
||||
command: Optional[Command] = None,
|
||||
stream_mode: Union[StreamMode, Sequence[StreamMode]] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
metadata: Optional[dict] = None,
|
||||
@@ -3326,6 +3380,7 @@ class SyncRunsClient:
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Optional[dict] = None,
|
||||
command: Optional[Command] = None,
|
||||
stream_mode: Union[StreamMode, Sequence[StreamMode]] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
metadata: Optional[dict] = None,
|
||||
@@ -3346,6 +3401,7 @@ class SyncRunsClient:
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Optional[dict] = None,
|
||||
command: Optional[Command] = None,
|
||||
stream_mode: Union[StreamMode, Sequence[StreamMode]] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
metadata: Optional[dict] = None,
|
||||
@@ -3370,6 +3426,7 @@ class SyncRunsClient:
|
||||
assistant_id: The assistant ID or graph name to stream from.
|
||||
If using graph name, will default to first assistant created from that graph.
|
||||
input: The input to the graph.
|
||||
command: The command to execute.
|
||||
stream_mode: The stream mode(s) to use.
|
||||
stream_subgraphs: Whether to stream output from subgraphs.
|
||||
metadata: Metadata to assign to the run.
|
||||
@@ -3420,6 +3477,7 @@ class SyncRunsClient:
|
||||
""" # noqa: E501
|
||||
payload = {
|
||||
"input": input,
|
||||
"command": command,
|
||||
"config": config,
|
||||
"metadata": metadata,
|
||||
"stream_mode": stream_mode,
|
||||
@@ -3453,6 +3511,7 @@ class SyncRunsClient:
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Optional[dict] = None,
|
||||
command: Optional[Command] = None,
|
||||
stream_mode: Union[StreamMode, Sequence[StreamMode]] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
metadata: Optional[dict] = None,
|
||||
@@ -3472,6 +3531,7 @@ class SyncRunsClient:
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Optional[dict] = None,
|
||||
command: Optional[Command] = None,
|
||||
stream_mode: Union[StreamMode, Sequence[StreamMode]] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
metadata: Optional[dict] = None,
|
||||
@@ -3492,6 +3552,7 @@ class SyncRunsClient:
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Optional[dict] = None,
|
||||
command: Optional[Command] = None,
|
||||
stream_mode: Union[StreamMode, Sequence[StreamMode]] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
metadata: Optional[dict] = None,
|
||||
@@ -3514,6 +3575,7 @@ class SyncRunsClient:
|
||||
assistant_id: The assistant ID or graph name to stream from.
|
||||
If using graph name, will default to first assistant created from that graph.
|
||||
input: The input to the graph.
|
||||
command: The command to execute.
|
||||
stream_mode: The stream mode(s) to use.
|
||||
stream_subgraphs: Whether to stream output from subgraphs.
|
||||
metadata: Metadata to assign to the run.
|
||||
@@ -3600,6 +3662,7 @@ class SyncRunsClient:
|
||||
""" # noqa: E501
|
||||
payload = {
|
||||
"input": input,
|
||||
"command": command,
|
||||
"stream_mode": stream_mode,
|
||||
"stream_subgraphs": stream_subgraphs,
|
||||
"config": config,
|
||||
@@ -3637,6 +3700,7 @@ class SyncRunsClient:
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Optional[dict] = None,
|
||||
command: Optional[Command] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
config: Optional[Config] = None,
|
||||
checkpoint: Optional[Checkpoint] = None,
|
||||
@@ -3657,6 +3721,7 @@ class SyncRunsClient:
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Optional[dict] = None,
|
||||
command: Optional[Command] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
config: Optional[Config] = None,
|
||||
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
|
||||
@@ -3674,6 +3739,7 @@ class SyncRunsClient:
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Optional[dict] = None,
|
||||
command: Optional[Command] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
config: Optional[Config] = None,
|
||||
checkpoint: Optional[Checkpoint] = None,
|
||||
@@ -3695,6 +3761,7 @@ class SyncRunsClient:
|
||||
assistant_id: The assistant ID or graph name to run.
|
||||
If using graph name, will default to first assistant created from that graph.
|
||||
input: The input to the graph.
|
||||
command: The command to execute.
|
||||
metadata: Metadata to assign to the run.
|
||||
config: The configuration for the assistant.
|
||||
checkpoint: The checkpoint to resume from.
|
||||
@@ -3761,6 +3828,7 @@ class SyncRunsClient:
|
||||
""" # noqa: E501
|
||||
payload = {
|
||||
"input": input,
|
||||
"command": command,
|
||||
"config": config,
|
||||
"metadata": metadata,
|
||||
"assistant_id": assistant_id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Data models for interacting with the LangGraph API."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, NamedTuple, Optional, Sequence, TypedDict, Union
|
||||
from typing import Any, Dict, Literal, NamedTuple, Optional, Sequence, TypedDict, Union
|
||||
|
||||
Json = Optional[dict[str, Any]]
|
||||
"""Represents a JSON-like structure, which can be None or a dictionary with string keys and any values."""
|
||||
@@ -176,6 +176,19 @@ class Assistant(AssistantBase):
|
||||
"""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):
|
||||
"""Represents a conversation thread."""
|
||||
|
||||
@@ -191,6 +204,8 @@ class Thread(TypedDict):
|
||||
"""The status of the thread, one of 'idle', 'busy', 'interrupted'."""
|
||||
values: Json
|
||||
"""The current state of the thread."""
|
||||
interrupts: Dict[str, list[Interrupt]]
|
||||
"""Interrupts which were thrown in this thread"""
|
||||
|
||||
|
||||
class ThreadTask(TypedDict):
|
||||
@@ -199,7 +214,7 @@ class ThreadTask(TypedDict):
|
||||
id: str
|
||||
name: str
|
||||
error: Optional[str]
|
||||
interrupts: list[dict]
|
||||
interrupts: list[Interrupt]
|
||||
checkpoint: Optional[Checkpoint]
|
||||
state: Optional["ThreadState"]
|
||||
result: Optional[dict[str, Any]]
|
||||
@@ -358,6 +373,6 @@ class Send(TypedDict):
|
||||
|
||||
|
||||
class Command(TypedDict, total=False):
|
||||
send: Union[Send, Sequence[Send]]
|
||||
goto: Union[Send, str, Sequence[Union[Send, str]]]
|
||||
update: dict[str, Any]
|
||||
resume: Any
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Adapted from httpx_sse to split lines on \n, \r, \r\n per the SSE spec."""
|
||||
|
||||
from typing import AsyncIterator, Iterator, Optional, Union
|
||||
|
||||
import httpx
|
||||
import orjson
|
||||
|
||||
from langgraph_sdk.schema import StreamPart
|
||||
|
||||
BytesLike = Union[bytes, bytearray, memoryview]
|
||||
|
||||
|
||||
class BytesLineDecoder:
|
||||
"""
|
||||
Handles incrementally reading lines from text.
|
||||
|
||||
Has the same behaviour as the stdllib bytes splitlines,
|
||||
but handling the input iteratively.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.buffer = bytearray()
|
||||
self.trailing_cr: bool = False
|
||||
|
||||
def decode(self, text: bytes) -> list[BytesLike]:
|
||||
# See https://docs.python.org/3/glossary.html#term-universal-newlines
|
||||
NEWLINE_CHARS = b"\n\r"
|
||||
|
||||
# We always push a trailing `\r` into the next decode iteration.
|
||||
if self.trailing_cr:
|
||||
text = b"\r" + text
|
||||
self.trailing_cr = False
|
||||
if text.endswith(b"\r"):
|
||||
self.trailing_cr = True
|
||||
text = text[:-1]
|
||||
|
||||
if not text:
|
||||
# NOTE: the edge case input of empty text doesn't occur in practice,
|
||||
# because other httpx internals filter out this value
|
||||
return [] # pragma: no cover
|
||||
|
||||
trailing_newline = text[-1] in NEWLINE_CHARS
|
||||
lines = text.splitlines()
|
||||
|
||||
if len(lines) == 1 and not trailing_newline:
|
||||
# No new lines, buffer the input and continue.
|
||||
self.buffer.extend(lines[0])
|
||||
return []
|
||||
|
||||
if self.buffer:
|
||||
# Include any existing buffer in the first portion of the
|
||||
# splitlines result.
|
||||
self.buffer.extend(lines[0])
|
||||
lines = [self.buffer] + lines[1:]
|
||||
self.buffer = bytearray()
|
||||
|
||||
if not trailing_newline:
|
||||
# If the last segment of splitlines is not newline terminated,
|
||||
# then drop it from our output and start a new buffer.
|
||||
self.buffer.extend(lines.pop())
|
||||
|
||||
return lines
|
||||
|
||||
def flush(self) -> list[BytesLike]:
|
||||
if not self.buffer and not self.trailing_cr:
|
||||
return []
|
||||
|
||||
lines = [self.buffer]
|
||||
self.buffer = bytearray()
|
||||
self.trailing_cr = False
|
||||
return lines
|
||||
|
||||
|
||||
class SSEDecoder:
|
||||
def __init__(self) -> None:
|
||||
self._event = ""
|
||||
self._data = bytearray()
|
||||
self._last_event_id = ""
|
||||
self._retry: Optional[int] = None
|
||||
|
||||
def decode(self, line: bytes) -> Optional[StreamPart]:
|
||||
# See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501
|
||||
|
||||
if not line:
|
||||
if (
|
||||
not self._event
|
||||
and not self._data
|
||||
and not self._last_event_id
|
||||
and self._retry is None
|
||||
):
|
||||
return None
|
||||
|
||||
sse = StreamPart(
|
||||
event=self._event,
|
||||
data=orjson.loads(self._data) if self._data else None,
|
||||
)
|
||||
|
||||
# NOTE: as per the SSE spec, do not reset last_event_id.
|
||||
self._event = ""
|
||||
self._data = bytearray()
|
||||
self._retry = None
|
||||
|
||||
return sse
|
||||
|
||||
if line.startswith(b":"):
|
||||
return None
|
||||
|
||||
fieldname, _, value = line.partition(b":")
|
||||
|
||||
if value.startswith(b" "):
|
||||
value = value[1:]
|
||||
|
||||
if fieldname == b"event":
|
||||
self._event = value.decode()
|
||||
elif fieldname == b"data":
|
||||
self._data.extend(value)
|
||||
elif fieldname == b"id":
|
||||
if b"\0" in value:
|
||||
pass
|
||||
else:
|
||||
self._last_event_id = value.decode()
|
||||
elif fieldname == b"retry":
|
||||
try:
|
||||
self._retry = int(value)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
else:
|
||||
pass # Field is ignored.
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def aiter_lines_raw(response: httpx.Response) -> AsyncIterator[BytesLike]:
|
||||
decoder = BytesLineDecoder()
|
||||
async for chunk in response.aiter_bytes():
|
||||
for line in decoder.decode(chunk):
|
||||
yield line
|
||||
for line in decoder.flush():
|
||||
yield line
|
||||
|
||||
|
||||
def iter_lines_raw(response: httpx.Response) -> Iterator[BytesLike]:
|
||||
decoder = BytesLineDecoder()
|
||||
for chunk in response.iter_bytes():
|
||||
for line in decoder.decode(chunk):
|
||||
yield line
|
||||
for line in decoder.flush():
|
||||
yield line
|
||||
Generated
+1
-12
@@ -141,17 +141,6 @@ cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
|
||||
http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (==1.*)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.0"
|
||||
description = "Consume Server-Sent Event (SSE) messages with HTTPX."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"},
|
||||
{file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.7"
|
||||
@@ -490,4 +479,4 @@ watchmedo = ["PyYAML (>=3.10)"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
content-hash = "832acea0ad21ce71ae74edef225a1ad6f8fb166f6bf1531d876fe80fac7495f0"
|
||||
content-hash = "1262a6148df18cc44ade00466b6e0f8305897a460eea370c8de649d8d20cd7a2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-sdk"
|
||||
version = "0.1.40"
|
||||
version = "0.1.43"
|
||||
description = "SDK for interacting with LangGraph API"
|
||||
authors = []
|
||||
license = "MIT"
|
||||
@@ -11,7 +11,6 @@ packages = [{ include = "langgraph_sdk" }]
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.9.0,<4.0"
|
||||
httpx = ">=0.25.2"
|
||||
httpx-sse = ">=0.4.0"
|
||||
orjson = ">=3.10.1"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
|
||||
Generated
+20
-23
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiohappyeyeballs"
|
||||
@@ -2862,30 +2862,30 @@ adal = ["adal (>=1.0.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "langchain"
|
||||
version = "0.3.1"
|
||||
version = "0.3.9"
|
||||
description = "Building applications with LLMs through composability"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.9"
|
||||
files = [
|
||||
{file = "langchain-0.3.1-py3-none-any.whl", hash = "sha256:94e5ee7464d4366e4b158aa5704953c39701ea237b9ed4b200096d49e83bb3ae"},
|
||||
{file = "langchain-0.3.1.tar.gz", hash = "sha256:54d6e3abda2ec056875a231a418a4130ba7576e629e899067e499bfc847b7586"},
|
||||
{file = "langchain-0.3.9-py3-none-any.whl", hash = "sha256:ade5a1fee2f94f2e976a6c387f97d62cc7f0b9f26cfe0132a41d2bda761e1045"},
|
||||
{file = "langchain-0.3.9.tar.gz", hash = "sha256:4950c4ad627d0aa95ce6bda7de453e22059b7e7836b562a8f781fb0b05d7294c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
aiohttp = ">=3.8.3,<4.0.0"
|
||||
async-timeout = {version = ">=4.0.0,<5.0.0", markers = "python_version < \"3.11\""}
|
||||
langchain-core = ">=0.3.6,<0.4.0"
|
||||
langchain-core = ">=0.3.21,<0.4.0"
|
||||
langchain-text-splitters = ">=0.3.0,<0.4.0"
|
||||
langsmith = ">=0.1.17,<0.2.0"
|
||||
numpy = [
|
||||
{version = ">=1,<2", markers = "python_version < \"3.12\""},
|
||||
{version = ">=1.26.0,<2.0.0", markers = "python_version >= \"3.12\""},
|
||||
{version = ">=1.22.4,<2", markers = "python_version < \"3.12\""},
|
||||
{version = ">=1.26.2,<3", markers = "python_version >= \"3.12\""},
|
||||
]
|
||||
pydantic = ">=2.7.4,<3.0.0"
|
||||
PyYAML = ">=5.3"
|
||||
requests = ">=2,<3"
|
||||
SQLAlchemy = ">=1.4,<3"
|
||||
tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<9.0.0"
|
||||
tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10"
|
||||
|
||||
[[package]]
|
||||
name = "langchain-anthropic"
|
||||
@@ -2933,13 +2933,13 @@ tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<9.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "0.3.15"
|
||||
version = "0.3.21"
|
||||
description = "Building applications with LLMs through composability"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.9"
|
||||
files = [
|
||||
{file = "langchain_core-0.3.15-py3-none-any.whl", hash = "sha256:3d4ca6dbb8ed396a6ee061063832a2451b0ce8c345570f7b086ffa7288e4fa29"},
|
||||
{file = "langchain_core-0.3.15.tar.gz", hash = "sha256:b1a29787a4ffb7ec2103b4e97d435287201da7809b369740dd1e32f176325aba"},
|
||||
{file = "langchain_core-0.3.21-py3-none-any.whl", hash = "sha256:7e723dff80946a1198976c6876fea8326dc82566ef9bcb5f8d9188f738733665"},
|
||||
{file = "langchain_core-0.3.21.tar.gz", hash = "sha256:561b52b258ffa50a9fb11d7a1940ebfd915654d1ec95b35e81dfd5ee84143411"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -3035,7 +3035,7 @@ langchain-core = ">=0.3.0,<0.4.0"
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "0.2.52"
|
||||
version = "0.2.54"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
optional = false
|
||||
python-versions = ">=3.9.0,<4.0"
|
||||
@@ -3045,7 +3045,7 @@ develop = true
|
||||
[package.dependencies]
|
||||
langchain-core = ">=0.2.43,<0.4.0,!=0.3.0,!=0.3.1,!=0.3.2,!=0.3.3,!=0.3.4,!=0.3.5,!=0.3.6,!=0.3.7,!=0.3.8,!=0.3.9,!=0.3.10,!=0.3.11,!=0.3.12,!=0.3.13,!=0.3.14"
|
||||
langgraph-checkpoint = "^2.0.4"
|
||||
langgraph-sdk = "^0.1.32"
|
||||
langgraph-sdk = "^0.1.42"
|
||||
|
||||
[package.source]
|
||||
type = "directory"
|
||||
@@ -3053,7 +3053,7 @@ url = "libs/langgraph"
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "2.0.5"
|
||||
version = "2.0.8"
|
||||
description = "Library with base interfaces for LangGraph checkpoint savers."
|
||||
optional = false
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
@@ -3070,7 +3070,7 @@ url = "libs/checkpoint"
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "2.0.3"
|
||||
version = "2.0.7"
|
||||
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
|
||||
optional = false
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
@@ -3078,10 +3078,10 @@ files = []
|
||||
develop = true
|
||||
|
||||
[package.dependencies]
|
||||
langgraph-checkpoint = "^2.0.2"
|
||||
langgraph-checkpoint = "^2.0.7"
|
||||
orjson = ">=3.10.1"
|
||||
psycopg = "^3.0.0"
|
||||
psycopg-pool = "^3.0.0"
|
||||
psycopg = "^3.2.0"
|
||||
psycopg-pool = "^3.2.0"
|
||||
|
||||
[package.source]
|
||||
type = "directory"
|
||||
@@ -3106,7 +3106,7 @@ url = "libs/checkpoint-sqlite"
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-sdk"
|
||||
version = "0.1.36"
|
||||
version = "0.1.42"
|
||||
description = "SDK for interacting with LangGraph API"
|
||||
optional = false
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
@@ -3115,7 +3115,6 @@ develop = true
|
||||
|
||||
[package.dependencies]
|
||||
httpx = ">=0.25.2"
|
||||
httpx-sse = ">=0.4.0"
|
||||
orjson = ">=3.10.1"
|
||||
|
||||
[package.source]
|
||||
@@ -3586,7 +3585,6 @@ optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
{file = "mkdocs-redirects-1.2.1.tar.gz", hash = "sha256:9420066d70e2a6bb357adf86e67023dcdca1857f97f07c7fe450f8f1fb42f861"},
|
||||
{file = "mkdocs_redirects-1.2.1-py3-none-any.whl", hash = "sha256:497089f9e0219e7389304cffefccdfa1cac5ff9509f2cb706f4c9b221726dffb"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6964,7 +6962,6 @@ description = "Automatically mock your HTTP interactions to simplify and speed u
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "vcrpy-6.0.1-py2.py3-none-any.whl", hash = "sha256:621c3fb2d6bd8aa9f87532c688e4575bcbbde0c0afeb5ebdb7e14cac409edfdd"},
|
||||
{file = "vcrpy-6.0.1.tar.gz", hash = "sha256:9e023fee7f892baa0bbda2f7da7c8ac51165c1c6e38ff8688683a12a4bde9278"},
|
||||
]
|
||||
|
||||
@@ -7476,4 +7473,4 @@ type = ["pytest-mypy"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.10"
|
||||
content-hash = "776ee42630769f08e3896338f18ec81830166695d32d2208dc31dedb22d3b22d"
|
||||
content-hash = "cf18eed5e183fc4f7786d095540b6c9261e130750f2d1fcc427e08b78d522c61"
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ ruff = "^0.6.8"
|
||||
jupyter = "^1.1.1"
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
langchain = "^0.3.1"
|
||||
langchain = "^0.3.8"
|
||||
langchain-openai = "^0.2.0"
|
||||
langchain-anthropic = "^0.2.1"
|
||||
langchain-nomic = "^0.1.3"
|
||||
|
||||
Reference in New Issue
Block a user