Compare commits

...
Author SHA1 Message Date
William FHandGitHub 93e4c8cc1f Create index concurrently (#2659) 2024-12-05 15:56:39 -08:00
Nuno CamposandGitHub b7e441d781 Merge pull request #2658 from langchain-ai/nc/5dec/return-multiple-commands
lib: Add support for returning multiple commands from a node
2024-12-05 15:16:06 -08:00
Nuno Campos ccd8920eef Lint 2024-12-05 15:09:13 -08:00
Vadym BardaandGitHub 0c379d6cc7 fix docstring (#2660) 2024-12-05 17:55:25 -05:00
Nuno Campos 1f745ca017 Lint 2024-12-05 13:50:42 -08:00
Nuno Campos aa4fea48dd lib: Add support for returning multiple commands from a node 2024-12-05 13:47:38 -08:00
William FHandGitHub 0f0e31df24 Nicer item repr (#2655) 2024-12-05 10:52:44 -08:00
Nuno CamposandGitHub a275ab26d3 Merge pull request #2468 from cab938/issue2159
feat: Make CompiledGraph displayable in Jupyter with display()
2024-12-05 10:03:40 -08:00
William FHandGitHub b3bf4dd43c [docs] Update guidance on min bounds for deployment (#2652) 2024-12-05 17:50:36 +00:00
David DuongandGitHub b7fd391811 Merge pull request #2653 from langchain-ai/dqbd/sdk-command
fix(sdk-js): rename Command["send"] to `goto`
2024-12-05 20:49:47 +04:00
Tat Dat Duong cf961a286c fix(sdk-js): rename Command["send"] to goto 2024-12-05 17:14:07 +01:00
Vadym BardaandGitHub 4b83103cf2 docs: relax pinned version in langgraph server tutorial (#2651) 2024-12-05 09:11:00 -05:00
William FHandGitHub 1a46537c3a Codeblock ref rendering (#2649) 2024-12-05 05:48:00 -08:00
Nuno CamposandGitHub 759a712f57 Merge pull request #2502 from langchain-ai/vb/fix-annotation
langgraph: fix issue w/ type annotations in tools_condition
2024-12-04 20:47:21 -05:00
Nuno Campos 9f73dfa8d5 Fix 2024-12-04 17:43:05 -08:00
Nuno CamposandGitHub 4459952e72 Merge branch 'main' into issue2159 2024-12-04 20:42:03 -05:00
Nuno Campos 8ef82f3578 Update 2024-12-04 17:40:27 -08:00
Nuno CamposandGitHub 73e3f5a5b0 Merge pull request #2517 from langchain-ai/eugene/how_to_use_tempalte
docs: Add template quickstart
2024-12-04 20:37:04 -05:00
Nuno Campos a54587cff5 Remove unknown arg 2024-12-04 17:33:54 -08:00
Nuno Campos 63ea71548b sdk-py 0.1.43 2024-12-04 17:27:24 -08:00
Nuno CamposandGitHub f32cf5e984 Merge pull request #2642 from langchain-ai/nc/4dec/fix-stream-params
sdk-py: Handle stream(params=)
2024-12-04 20:26:59 -05:00
Nuno Campos d1aaa9de8c sdk-py: Handle stream(params=) 2024-12-04 17:25:51 -08:00
Nuno Campos f40a2d71ec lib 0.2.56 2024-12-04 17:15:17 -08:00
Nuno CamposandGitHub b5a9e9da55 Merge pull request #2635 from langchain-ai/vb/add-graph-command-docs
docs: add Command docs
2024-12-04 20:14:35 -05:00
vbarda 1eeb90ae0d cr 2024-12-04 19:41:21 -05:00
William FHandGitHub cd875291ad Link to conceptual doc (#2641) 2024-12-05 00:21:01 +00:00
e9cd216887 Update docs/docs/concepts/low_level.md
Co-authored-by: Nuno Campos <nuno@langchain.dev>
2024-12-04 19:18:37 -05:00
7651f1ab1c Update libs/langgraph/langgraph/types.py
Co-authored-by: Nuno Campos <nuno@langchain.dev>
2024-12-04 19:17:22 -05:00
1a492f727c Update libs/langgraph/langgraph/types.py
Co-authored-by: Nuno Campos <nuno@langchain.dev>
2024-12-04 19:16:33 -05:00
6caaa8cea7 Update libs/langgraph/langgraph/types.py
Co-authored-by: Nuno Campos <nuno@langchain.dev>
2024-12-04 19:16:26 -05:00
Vadym BardaandGitHub f028984b2e langgraph: remove print (#2640) 2024-12-04 19:07:50 -05:00
vbarda 085395c824 rename 2024-12-04 19:04:42 -05:00
vbarda 19a6e894eb more updates 2024-12-04 19:02:49 -05:00
vbarda 5570121c83 update 2024-12-04 18:56:46 -05:00
vbarda 257e44ccb4 update 2024-12-04 18:54:57 -05:00
Eugene YurtsevandGitHub dad0f39fa4 concepts: reword network architecture (#2625) 2024-12-04 23:49:17 +00:00
Nuno Campos 7a326ef768 lib 0.2.55 2024-12-04 15:44:24 -08:00
vbarda 797b919cf9 Merge branch 'main' into vb/add-graph-command-docs 2024-12-04 18:37:20 -05:00
Nuno CamposandGitHub 574ffb02fc Merge pull request #2639 from langchain-ai/nc/4dec/speed-up-tests
Speed up tests
2024-12-04 18:37:03 -05:00
Nuno Campos 771b9b28cd Speed up tests 2024-12-04 15:29:51 -08:00
Nuno CamposandGitHub dcc2617396 Merge pull request #2638 from langchain-ai/nc/4dec/command
lib: Merge GraphCommand and Command
2024-12-04 18:26:11 -05:00
Nuno Campos df70e91dae Lint 2024-12-04 15:13:55 -08:00
Nuno Campos b4b3ac6f57 lib: Merge GraphCommand and Command
- Now we have only Command
- Command(goto=) combines the previous functionality of Command(send=) and Command(goto=)
2024-12-04 15:12:03 -08:00
Nuno CamposandGitHub 78e6b36b1a Merge pull request #2636 from langchain-ai/nc/4dec/interrupt-loop
lib: Add support for multiple interrupts per node
2024-12-04 17:58:26 -05:00
William FHandGitHub d457ad3cc2 Clean up code snippet (#2637) 2024-12-04 14:50:23 -08:00
Nuno Campos 5c7a6689af Update tests 2024-12-04 14:41:30 -08:00
Nuno Campos fb01d65dc0 Lint 2024-12-04 14:31:22 -08:00
Nuno Campos ea5ccd7a80 lib: Add support for multiple interrupts per node
- Includes support for interrupt loops
2024-12-04 14:15:30 -08:00
vbarda d52bb911a4 lint 2024-12-04 16:50:56 -05:00
Vadym BardaandGitHub 89a739e12b Merge branch 'main' into vb/add-graph-command-docs 2024-12-04 16:48:05 -05:00
vbarda 0fdf3c9daf cr 2024-12-04 16:47:51 -05:00
vbarda 90eab07ded docs: add Command/GraphCommand docs 2024-12-04 15:46:40 -05:00
William FHandGitHub 962a969fba Update link (#2634) 2024-12-04 12:09:04 -08:00
William FHandGitHub c89e84fb6a nit: Spelling (#2633) 2024-12-04 10:24:35 -08:00
William FHandGitHub 3ff1f81333 Add doc to index (#2632) 2024-12-04 18:19:14 +00:00
Vadym BardaandGitHub 851e6d1d4c issue template: replace langchain w/ langgraph (#2631) 2024-12-04 12:51:00 -05:00
William FHandGitHub e5e659c590 Add langgraph.json snippet to concept doc (#2630) 2024-12-04 17:11:13 +00:00
Eugene YurtsevandGitHub 8db6a78ad9 ci: update bug template (#2626) 2024-12-04 12:08:21 -05:00
Nuno CamposandGitHub 9ab5fbc0f8 Merge pull request #2627 from langchain-ai/nc/4dec/state-ensure-config
lib: Call ensure_config in state crud methods
2024-12-04 11:53:03 -05:00
William FHandGitHub c141f0fdf0 Add memory how-to (#2629) 2024-12-04 08:39:53 -08:00
William FHandGitHub 830557d6b7 Clarify behavior in docstring (#2628) 2024-12-04 16:38:09 +00:00
Nuno Campos e5b00cdd1e Fix 2024-12-04 08:30:10 -08:00
Nuno Campos 8eea7ac401 lib: Call ensure_config in state crud methods
- this ensures that config from context vars is merged in
2024-12-04 08:15:01 -08:00
William FHandGitHub c322f7ffa6 Add Memory Store conceptual doc section (#2624)
On semantic search
2024-12-04 15:19:49 +00:00
William FHandGitHub e6c83abecd Fix ref doc formatting (#2623) 2024-12-04 06:55:15 -08:00
ACMCMCandGitHub a8db511e24 Fix typo (#2620) 2024-12-04 06:30:05 -08:00
湛露先生andGitHub 84d33f9621 Fix typos in langgraph_sdk client. (#2621)
Fix typos in langgraph_sdk client.

Signed-off-by: zhanluxianshen <zhanluxianshen@163.com>
2024-12-04 06:29:28 -08:00
William FHandGitHub 9220049b35 Add store langgraph.json config ref (#2622) 2024-12-04 06:28:54 -08:00
William FHandGitHub 879df6b52c [JS] Update SDK version (#2619) 2024-12-03 23:01:19 -08:00
William FHandGitHub 9b8bf70d9e Add link to local studio testing (#2617) 2024-12-04 04:36:59 +00:00
Phoenix LoganandGitHub aca67107c1 fix: make database saver classes inheritance-friendly (#2615)
Replace hardcoded database saver class names with `cls` in
`from_conn_string` factory methods to improve subclassing support

## Changes
* Replaced direct class instantiations with `cls(conn)` in
`from_conn_string` classmethods across all database implementations
* Updated both synchronous and asynchronous variants for DuckDB,
PostgreSQL, and SQLite savers

## Why
This refactor makes the database saver classes more extensible by
following Python's convention of using `cls` in class methods. This
enables proper inheritance patterns where subclasses can reuse the
factory methods without needing to override them. Previously, the
hardcoded class names would always instantiate the parent class, even
when called from a subclass.

## Testing
The change is backward compatible and doesn't alter existing
functionality. All existing tests should continue to pass as this is
purely a structural refactoring that preserves the current behavior
while improving extensibility.

## Notes
This PR addresses follow up on comments from #2518 - AsyncPostgresSaver
didn't need to be fixed but many of the other DB saver classes did.
2024-12-03 20:26:06 -08:00
William FHandGitHub 5fa196ab38 Update docstrings for store classes (#2616) 2024-12-03 19:51:25 -08:00
Nuno CamposandGitHub 584d9271ce Merge pull request #2614 from langchain-ai/nc/3dec/handle-command
Handle Command returned from node (in addition to GraphCommand)
2024-12-03 19:05:05 -05:00
Nuno Campos 1bee33db3a Fix 2024-12-03 15:52:41 -08:00
Nuno Campos a203ddecf7 Handle Command returned from node (in addition to GraphCommand) 2024-12-03 15:48:59 -08:00
Vadym BardaandGitHub 5e3c326424 langgraph: bump sdk, release 0.2.54 (#2613) 2024-12-03 16:38:59 -05:00
Nuno CamposandGitHub 86407aa6e8 Merge pull request #2071 from langchain-ai/brace/doc-nits
fix(docs): Small nits & typo fixes
2024-12-03 16:38:36 -05:00
Vadym BardaandGitHub 7a80d6cb87 sdk-py: release 0.1.42 (#2612) 2024-12-03 16:34:08 -05:00
Nuno Campos 70f323779e Update persistence.md 2024-12-03 16:26:36 -05:00
23d5162945 Update human_in_the_loop.md
Co-authored-by: Vadym Barda <vadym@langchain.dev>
2024-12-03 16:26:36 -05:00
bracesproulandNuno Campos 9d755f54e4 fix(docs): Small nits & typo fixes 2024-12-03 16:26:36 -05:00
Nuno CamposandGitHub 75cccc4fc4 Merge pull request #2589 from stneng/main
fix: get correct reducer when type has multiple metadata.
2024-12-03 16:23:33 -05:00
Nuno CamposandGitHub dd010e9230 Merge pull request #2593 from langchain-ai/nc/2dec/sdk-sse
sdk-py: Fix SSE parsing to split lines only \n \r , remove httpx-sse, fix missing decoder flush
2024-12-03 16:23:11 -05:00
Nuno CamposandGitHub 2d87195b59 Merge pull request #2611 from langchain-ai/vb/remote-graph-kwargs
langgraph: allow passing kwargs to SDK methods in RemoteGraph's invoke/stream
2024-12-03 16:21:09 -05:00
vbarda 515242d0ba langgraph: allow passing kwargs to SDK methods in RemoteGraph's invoke/stream 2024-12-03 15:40:18 -05:00
Nuno Campos 3bf92d0b03 Fix 2024-12-03 11:04:28 -08:00
William FHandGitHub 36b6cd1493 fix: Handle empty store similarity (numpy) (#2602) 2024-12-02 18:20:19 -08:00
Nuno CamposandGitHub b80933c5fb Merge branch 'main' into main 2024-12-02 20:50:44 -05:00
Nuno Campos 3cee1d5087 Remove httpx_sse, fix missing flush of sse decoder 2024-12-02 12:03:31 -08:00
Nuno Campos 2ce2021c39 Revert "Revert "sdk-py: Fix SSE parsing to split lines only \n \r \r\n per SSE spec""
This reverts commit 53ec7c41b2.
2024-12-02 11:29:06 -08:00
stneng 363c6e2e4c fix 2024-12-01 16:07:15 -08:00
Eugene Yurtsev f08155d60b x 2024-11-22 14:43:28 -05:00
Eugene Yurtsev 24b16908b7 x 2024-11-22 14:43:08 -05:00
Eugene Yurtsev c1c2ce8f1b x 2024-11-22 14:42:36 -05:00
Eugene Yurtsev 3efd4f3406 Merge branch 'main' into eugene/how_to_use_tempalte 2024-11-22 14:21:07 -05:00
Eugene Yurtsev f122ae2eb1 qxqx 2024-11-22 14:20:56 -05:00
Eugene Yurtsev 05791f5dfc qxqx 2024-11-22 13:26:46 -05:00
Eugene Yurtsev 416dfe95da qxqx 2024-11-22 13:16:41 -05:00
vbarda 2d6ddd0a1d langgraph: fix issue w/ type annotations in tools_condition 2024-11-21 14:31:34 -05:00
Nuno Campos 253090f34d lint 2024-11-19 10:29:32 -08:00
Christopher BrooksandGitHub 7d80176137 Merge branch 'langchain-ai:main' into issue2159 2024-11-19 11:19:34 -05:00
Christopher Brooks ca7da2fc41 feat: Make CompiledGraph displayable in Juypyter with display() (#2159) 2024-11-19 11:18:53 -05:00
90 changed files with 2855 additions and 699 deletions
+9 -40
View File
@@ -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
-1
View File
@@ -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
-1
View 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
+1 -1
View File
@@ -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
-1
View File
@@ -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,
-4
View File
@@ -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
View File
@@ -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:
+1 -1
View File
@@ -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
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
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()
```
+2 -2
View File
@@ -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);
```
+94 -37
View File
@@ -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. |
+13 -13
View File
@@ -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!
+5
View File
@@ -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
+62
View File
@@ -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
+27 -6
View File
@@ -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.
+1 -6
View File
@@ -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
+88 -14
View File
@@ -218,13 +218,16 @@ The final thing you can optionally specify when calling `update_state` is `as_no
## Memory Store
![Update](img/persistence/shared_state.png)
![Model of shared state](img/persistence/shared_state.png)
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.
+49 -6
View File
@@ -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",
+13 -9
View File
@@ -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
}
+2
View File
@@ -13,3 +13,5 @@
- PregelExecutableTask
- StateSnapshot
- Send
- Command
- interrupt
+1
View File
@@ -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
@@ -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.
+2 -2
View File
@@ -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",
+3
View File
@@ -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.
@@ -54,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.
@@ -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
@@ -73,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);
""",
]
@@ -107,7 +106,7 @@ CREATE TABLE IF NOT EXISTS store_vectors (
),
Migration(
"""
CREATE INDEX IF NOT EXISTS store_vectors_embedding_idx ON store_vectors
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(
@@ -534,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",
@@ -795,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:
+13 -14
View File
@@ -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]
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-checkpoint-postgres"
version = "2.0.7"
version = "2.0.8"
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
authors = []
license = "MIT"
+2 -3
View File
@@ -63,9 +63,8 @@ async def _pipe_saver():
prepare_threshold=0,
row_factory=dict_row,
) as conn:
async with conn.pipeline() as pipe:
checkpointer = AsyncPostgresSaver(conn, pipe=pipe)
await checkpointer.setup()
checkpointer = AsyncPostgresSaver(conn)
await checkpointer.setup()
async with conn.pipeline() as pipe:
checkpointer = AsyncPostgresSaver(conn, pipe=pipe)
yield checkpointer
+2 -3
View File
@@ -57,9 +57,8 @@ def _pipe_saver():
prepare_threshold=0,
row_factory=dict_row,
) as conn:
with conn.pipeline() as pipe:
checkpointer = PostgresSaver(conn, pipe=pipe)
checkpointer.setup()
checkpointer = PostgresSaver(conn)
checkpointer.setup()
with conn.pipeline() as pipe:
checkpointer = PostgresSaver(conn, pipe=pipe)
yield checkpointer
@@ -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.
+249 -61
View File
@@ -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 -1
View File
@@ -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"
+7 -7
View File
@@ -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
+6
View File
@@ -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 ?=
+4 -2
View File
@@ -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 -2
View File
@@ -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",
+7
View File
@@ -629,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(),
}
+97 -78
View File
@@ -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}"
+9 -16
View File
@@ -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 = [
+8 -6
View File
@@ -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:
+16 -19
View File
@@ -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
),
configurable.get(CONFIG_KEY_RESUME_VALUE, 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
),
configurable.get(CONFIG_KEY_RESUME_VALUE, 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,
+14 -12
View File
@@ -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)
+23 -2
View File
@@ -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")
+12 -8
View File
@@ -575,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.
@@ -589,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.
@@ -616,6 +618,7 @@ class RemoteGraph(PregelProtocol):
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:
@@ -664,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.
@@ -678,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.
@@ -705,6 +710,7 @@ class RemoteGraph(PregelProtocol):
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:
@@ -767,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.
@@ -789,6 +793,7 @@ class RemoteGraph(PregelProtocol):
interrupt_before=interrupt_before,
interrupt_after=interrupt_after,
stream_mode="values",
**kwargs,
):
pass
try:
@@ -803,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.
@@ -825,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
+42 -27
View File
@@ -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:
+81 -16
View File
@@ -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),
),
)
)
+7 -19
View File
@@ -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"
+2 -2
View File
@@ -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"
+170 -43
View File
@@ -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
@@ -220,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")
@@ -270,10 +258,10 @@ def test_graph_validation_with_command() -> None:
bar: str
def node_a(state: State):
return GraphCommand(goto="b", update={"foo": "bar"})
return Command(goto="b", update={"foo": "bar"})
def node_b(state: State):
return GraphCommand(goto=END, update={"bar": "baz"})
return Command(goto=END, update={"bar": "baz"})
builder = StateGraph(State)
builder.add_node("a", node_a)
@@ -1919,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",
]
@@ -1947,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",
@@ -1959,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",
@@ -1969,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
@@ -2000,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",
]
@@ -2030,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
@@ -2046,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",
@@ -2069,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",
@@ -2105,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",
],
@@ -2123,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"],
@@ -2209,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(),
@@ -2223,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(),
@@ -2786,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
@@ -14580,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)
@@ -14680,3 +14667,143 @@ def test_interrupt_subgraph(request: pytest.FixtureRequest, checkpointer_name: s
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}},
]
+192 -38
View File
@@ -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
@@ -847,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
@@ -2559,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:
@@ -2573,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",
]
@@ -2602,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",
@@ -2614,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",
@@ -2632,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",
@@ -2649,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:
@@ -2677,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",
]
@@ -2708,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
@@ -2718,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",
@@ -2736,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",
@@ -2772,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",
],
@@ -2790,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"],
@@ -2876,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(),
@@ -2890,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(),
@@ -3448,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
@@ -3761,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:
@@ -3775,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"]
@@ -12788,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)
@@ -12896,3 +12893,160 @@ async def test_interrupt_subgraph(checkpointer_name: str):
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}},
]
+18
View File
@@ -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
+9 -9
View File
@@ -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",
+9 -9
View File
@@ -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",
+13 -7
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@langchain/langgraph-sdk",
"version": "0.0.30",
"version": "0.0.31",
"description": "Client library for interacting with the LangGraph API",
"type": "module",
"packageManager": "yarn@1.22.19",
+3
View File
@@ -1206,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(
@@ -1214,6 +1215,7 @@ export class StoreClient extends BaseClient {
filter?: Record<string, any>;
limit?: number;
offset?: number;
query?: string;
},
): Promise<SearchItemsResponse> {
const payload = {
@@ -1221,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>(
+7 -5
View File
@@ -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[];
}
+4 -2
View File
@@ -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 {
+55 -20
View File
@@ -26,7 +26,6 @@ from typing import (
)
import httpx
import httpx_sse
import orjson
from httpx._types import QueryParamTypes
@@ -61,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__)
@@ -190,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.
@@ -278,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,
@@ -2434,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]:
+1 -1
View File
@@ -373,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
+148
View File
@@ -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
+1 -12
View File
@@ -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 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-sdk"
version = "0.1.41"
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
View File
@@ -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
View File
@@ -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"