Compare commits

...
Author SHA1 Message Date
Sydney Runkle c8a9aec9d8 fix signature 2025-06-24 09:08:08 -04:00
Sydney Runkle e05cef7732 support invokable signature 2025-06-24 08:31:58 -04:00
Vedant PanchalandGitHub ac328c3fd8 [fix] snippet for Agents as a Tool (#5181)
snippet had a missing decorator that might lead the user to confuse whether it is really a tool
2025-06-24 11:44:32 +00:00
Nuno CamposandGitHub ebd7977936 Revert change to default value of checkpoint_during arg (#5177) 2025-06-23 18:12:19 -07:00
Nuno Campos a6381c32b0 Revert change to default value of checkpoint_during arg 2025-06-23 18:05:46 -07:00
Nuno CamposandGitHub 294d346650 Fix bug where Command(update=) could be ignored if there was a 2nd interrupt after it (#5175) 2025-06-23 17:35:36 -07:00
Nuno Campos 866c8009dc Fix bug where Command(update=) could be ignored if there was a 2nd interrupt after it
- writes from the null task (ie. from outside tasks) should be accummulated across invocations
2025-06-23 17:23:53 -07:00
Rauf ParchievandGitHub 73bed2cf7c Update workflows.md (#5116) 2025-06-23 23:54:45 +00:00
Eugene YurtsevandGitHub 4cffe58065 docs: fix tab syntax errors and admonition syntax errors (#5091) 2025-06-23 19:53:54 -04:00
nikhildigdeandGitHub e73964a971 Update custom_routes.md (#5110)
Updated the statement about app.py
2025-06-23 23:53:25 +00:00
William Fu-Hinthorn de91f21f6b Update cli config doc on pip_installer 2025-06-23 16:40:07 -07:00
David DuongandGitHub 41eed326b8 Disable values update on use stream in interrupt events (#5041) 2025-06-24 00:08:14 +02:00
Mason DaughertyandGitHub 946d23213d docs: add Homebrew install option to CLI docs (#5160)
Add Homebrew install option to CLI docs
2025-06-23 11:33:12 -04:00
hari-dhanushkodiandGitHub c78197a583 add more docs for lgp deployment metrics (#5151) 2025-06-23 07:12:06 -07:00
Lauren Hirata SinghandGitHub 50756207ee docs: fix a typo 'prebuit' to 'prebuilt' in notebook_hooks.py (#5154) 2025-06-23 09:44:45 -04:00
OfirTeneJunoandGitHub 903cec0cfa Merge branch 'main' into interrupt-use-stream-values 2025-06-23 13:25:35 +03:00
foie0222 f63952595d fix: a typo prebuit to prebuilt 2025-06-21 17:49:40 +09:00
Andrew NguonlyandGitHub 4a252bd03a docs: Remove note for LANGSMITH_TRACING environment variable (#5147)
Remove note for LANGSMITH_TRACING.
2025-06-20 13:36:29 -07:00
Eugene YurtsevandGitHub f0f329d9e1 docs: Add conditional js/python rendering (#5128)
# Overview

Adding conditional rendering logic to co-locate js and python documentation.

* `:::` conditional syntax can be used to switch between python only or js only content.
* Contains simple unit tests for `:::`
* PR adds set up for a way to implement a context switch between languages, but it will not be enabled until JS content is merged in.
* Contains a script that can add javascript documentation 

Implementation of: https://github.com/langchain-ai/langgraph/pull/5118

## Example

Example of conditional rendering / compilation.


```markdown

### Config (static context)

Config is for immutable data like user metadata or API keys. Use
when you have values that don't change mid-run.

Specify configuration using a key called **"configurable"** which is reserved
for this purpose:


:::python

This content will only be rendered for the python site.
:::

:::js
this content will only be rendered for the js / ts site.
:::

```
2025-06-20 16:20:16 -04:00
OfirTeneJunoandGitHub 77306c5142 Merge branch 'main' into interrupt-use-stream-values 2025-06-19 14:06:24 +03:00
Nuno CamposandGitHub eba18c3213 Reduce extraneous keys in checkpoint.metadata (#5133) 2025-06-17 17:46:45 -07:00
Nuno Campos a1c856c088 Reduce extraneous keys in checkpoint.metadata
- Leave it up to each checkpointer implementation to decide whether to merge in configurable/metadata (previously PregelLoop would do some of this always)
- Never copy over internal langgraph keys into checkpoint.metadata (these are redundant/misleading to include)
2025-06-17 17:40:18 -07:00
Josh RogersandGitHub 92010f84ec Adding disable_mcp field to cli docs (#5132) 2025-06-17 16:22:50 -07:00
Nuno CamposandGitHub 3b8f3f9de3 If FuturesDict callback has been GCed, don't call it (#5131) 2025-06-17 14:03:16 -07:00
Nuno Campos 6dcff8a839 If FuturesDict callback has been GCed, don't call it 2025-06-17 13:47:47 -07:00
1309243b29 docs: studio evals (#5129)
* docs: studio evals

* docs: added studio evals images (#5076)

* docs: added studio evals images

* Update docs/docs/cloud/how-tos/studio/run_evals.md

Co-authored-by: lc-arjun <arjun@langchain.dev>

* Update docs/docs/cloud/how-tos/studio/run_evals.md

Co-authored-by: lc-arjun <arjun@langchain.dev>

* Update docs/docs/cloud/how-tos/studio/run_evals.md

Co-authored-by: lc-arjun <arjun@langchain.dev>

* docs: updated studio evals

* Update docs/docs/cloud/how-tos/studio/run_evals.md

Co-authored-by: lc-arjun <arjun@langchain.dev>

* docs: removed images

---------

Co-authored-by: lc-arjun <arjun@langchain.dev>

* final changes

* i think its this

---------

Co-authored-by: Marco Perini <perinim.98@gmail.com>
2025-06-17 12:32:04 -07:00
Nuno Campos 771c6150a4 langgraph 0.5.0rc1 2025-06-16 17:52:13 -07:00
Nuno Campos edfb65fd3a langgraph-prebuilt 0.5.0rc0 2025-06-16 17:47:21 -07:00
Lauren Hirata SinghandGitHub 0f92470e49 docs: Remove cookie consent (#5123) 2025-06-16 18:41:55 -04:00
Nuno Campos dfcaf97c73 langgraph 0.5.0rc0 2025-06-16 15:17:56 -07:00
Nuno Campos 63a0028372 langgraph-checkpoint 2.1.0 2025-06-16 14:58:50 -07:00
Nuno CamposandGitHub 1134017d07 Preparation for 0.5 release: langgraph-checkpoint (#5124)
Prepare langgraph-checkpoint for 0.5

- Given we have no upper bound on langgraph-checkpoint dep need to undo all changes in langgraph-checkpoint that might break previous versions of langgraph
2025-06-16 21:57:11 +00:00
Lauren Hirata Singh 33feba4877 Remove cookie consent 2025-06-16 16:33:32 -04:00
Nuno CamposandGitHub 4fec8e9dec Preparation for 0.5 release (#5121) 2025-06-16 13:14:25 -07:00
Nuno Campos c137169325 Preparation for 0.5 release
- Update deprecation warnings to mention 0.5, no 1.0
- Add back type hint support for Runnable arg to add_node
2025-06-16 13:07:52 -07:00
Nuno CamposandGitHub 1e2672e63d Restore shallow checkpointer (#5105) 2025-06-16 11:23:27 -07:00
Nuno CamposandGitHub 06803ab683 Add migration for pending_sends (#5106) 2025-06-16 11:23:17 -07:00
hari-dhanushkodiandGitHub 3488ee47e0 chore: add docs for lgp deployment monitoring (#5104) 2025-06-16 10:21:42 -07:00
Nuno CamposandGitHub 289bdd0cea Introduce "tasks" and "checkpoints" stream modes (#5117) 2025-06-16 10:14:18 -07:00
Nuno Campos 417103066b Lint 2025-06-16 09:29:03 -07:00
Nuno Campos 25a59447c1 Introduce "tasks" and "checkpoints" stream modes
- These are split out of "debug" stream mode, which is now an alias for ["tasks", "checkpoints"]
2025-06-16 08:47:45 -07:00
Nuno Campos 21906d2b7b Add migration for pending_sends
- Checkpoints saved on older versions of langgraph will be compatible with langgraph 0.5 and 1.0
2025-06-13 17:42:14 -07:00
Nuno Campos 0cad7019cb Restore shallow checkpointer
- This should definitely be removed soon, but let's give people more time to update
2025-06-13 17:37:40 -07:00
Nuno CamposandGitHub 7e735672bf Restore compatibility with custom checkpointer classes created in prior versions (#5103) 2025-06-13 16:36:36 -07:00
Nuno Campos 5498893780 Restore compatibility with custom checkpointer classes created in prior versions
- Ensure existing custom checkpointer classes are compatible with new langgraph-checkpoint release
2025-06-13 16:29:55 -07:00
Nuno CamposandGitHub e80f47aa01 Revert removals of APIs that were slated for removal in 1.0 (#5101) 2025-06-13 16:09:20 -07:00
Nuno Campos a0b2f742a3 Revert "Remove UntrackedValue channel"
This reverts commit 05f3904d09.
2025-06-13 15:36:47 -07:00
William FHandGitHub b7973d65db fix: Update lockfile (#5102) 2025-06-13 14:53:40 -07:00
Nuno Campos 3fa3a586b5 Revert "Remove MessageGraph (#4875)"
This reverts commit a5e6223569.
2025-06-13 14:21:05 -07:00
William FHandGitHub 053b606b46 cli: 0.3.3 (#5100) 2025-06-13 13:15:26 -07:00
William FHandGitHub 4548a0ebe8 feat: Customizable Pip Installer (#5098)
Let you set "pip_installer": "pip" (or uv) to handle corner cases in install compatibilities
2025-06-13 10:35:28 -07:00
Sydney RunkleandGitHub 0171e9a323 fix(langgraph): remove deprecated output usage in favor of output_schema (#5095)
use output_schema
2025-06-13 12:34:39 -04:00
Sydney RunkleandGitHub c439cb0872 refactor(langgraph): Remove PregelNode's inheritance from Runnable (#5093)
remove Runnable inheritance for PregelNode
2025-06-13 10:17:42 -04:00
Nuno CamposandGitHub 2a4d7e8889 Remove support for node reading a single managed value (#5083) 2025-06-12 15:19:55 -07:00
Nuno Campos 7f3578e0f1 Remove support for node reading a single managed value
- This has never been used and is not useful or intended functionality
2025-06-12 15:11:19 -07:00
Lauren Hirata SinghandGitHub e2f96b5ae5 revert incident banner (#5082) 2025-06-12 17:24:36 -04:00
Lauren Hirata Singh 0d5f7e55bf revert incident banner 2025-06-12 17:10:22 -04:00
Lauren Hirata SinghandGitHub 9209f11187 incident banner (#5081) 2025-06-12 16:04:20 -04:00
Lauren Hirata SinghandGitHub bb1c5b8cdf Update docs/overrides/main.html 2025-06-12 15:57:14 -04:00
Nuno CamposandGitHub d6bb008ff4 PregelLoop: Simplify tick() method (#5080)
* PregelLoop: Simplify tick() method

- Split out superstep finish into separate after_tick() method
- Handle input in __enter__
- Remove unnecessary recursive shortcut
- Remove input sentinel objects

* Lint
2025-06-12 19:53:55 +00:00
Lauren Hirata Singh 6130e08fa6 incident banner 2025-06-12 15:52:36 -04:00
Sydney RunkleandGitHub 3ad061f0d7 serialize/deserialize pandas with pickle fallback (#5057) 2025-06-12 15:14:00 -04:00
Nuno CamposandGitHub 116b5d1cac Remove code paths no longer needed (#5079) 2025-06-12 11:47:10 -07:00
Nuno Campos 0aff02e180 Remove code paths no longer needed
- These were only used by the kafka scheduler
2025-06-12 11:25:20 -07:00
Nuno CamposandGitHub 074af5c122 Avoid saving checkpoints for subgraphs when checkpoint_during=False (#5051) 2025-06-11 11:11:02 -07:00
langchain-infraandGitHub 29ffaa0e0b docs: fix config section (#5066) 2025-06-11 13:23:33 -04:00
Sydney RunkleandGitHub 45cd4e1928 oss: auto apply labels to contributor issues (#5067)
auto apply labels
2025-06-11 17:19:49 +00:00
langchain-infraandGitHub 480271f753 docs: add mount prefix environment variable (#5060) 2025-06-11 11:20:19 -04:00
infra 66fdf60e47 docs: add mount prefix environment variable 2025-06-11 11:18:16 -04:00
infra 0894daf3fc docs: add mount prefix environment variable 2025-06-11 11:17:45 -04:00
Lauren Hirata SinghandGitHub 850c55d630 Revert "fix assistants overview link" (#5059) 2025-06-11 11:02:17 -04:00
Lauren Hirata SinghandGitHub c0d65ff409 Revert "fix assistants overview link (#5058)"
This reverts commit be7b60a722.
2025-06-11 10:58:52 -04:00
Lauren Hirata SinghandGitHub be7b60a722 fix assistants overview link (#5058) 2025-06-11 10:58:07 -04:00
Eugene YurtsevandGitHub d467ec6556 Remove gitmcp badge (#5055)
* Remove gitmcp badge

* xt

* x
2025-06-11 10:55:05 -04:00
b8683ab67a docs: Update subgraphs.md (#5052)
* Update subgraphs.md

The state while defining the Subgraph is updated. Also an edge connecting START to the call_model node in the subgraph was created.

* Update docs/docs/concepts/subgraphs.md

* Update docs/docs/concepts/subgraphs.md

---------

Co-authored-by: Eugene Yurtsev <eugene@langchain.dev>
2025-06-11 13:49:51 +00:00
OfirTeneJunoandGitHub 666279a241 Merge branch 'main' into interrupt-use-stream-values 2025-06-11 09:53:25 +03:00
William Fu-Hinthorn 6a9ca8d67e Update existing 2025-06-10 17:59:41 -07:00
William Fu-Hinthorn 3b98044f2f Add tests 2025-06-10 17:29:27 -07:00
Nuno Campos a4a8934bd3 Avoid saving checkpoints for subgraphs when checkpoint_during=False
- We can avoid saving checkpoints for successful subgraphs which do not request multi-turn memory
2025-06-10 17:25:05 -07:00
Nuno CamposandGitHub 470b9a4b97 Clean up PregelNode attributes (#5049) 2025-06-10 17:24:03 -07:00
Nuno Campos 516175780d Clean up things for Matt! 2025-06-10 16:14:15 -07:00
William FHandGitHub 571780f74c fix: header merging (#4926) 2025-06-10 14:44:34 -07:00
Emmanuel FerdmanandGitHub d719438307 fix: throw exception on multiple injections (#5033)
Throw exception on for multiple injections

Signed-off-by: Emmanuel Ferdman <emmanuelferdman@gmail.com>
2025-06-10 16:54:01 -04:00
Simon FrankandGitHub 85c809a651 docs: fixed a wrong import in persistence docs (#5045) 2025-06-10 20:53:50 +00:00
Nuno CamposandGitHub 0441fd156f Add docs for checkpoint encryption (#5047)
docs: list CipherProtocol in API
2025-06-10 16:52:45 -04:00
Nuno CamposandGitHub 37b5d3886c Add library overview to AGENTS.md (#5044) 2025-06-10 10:08:55 -07:00
Nuno Campos b95267a3cc Refine dependency map 2025-06-10 10:06:08 -07:00
OfirTeneJuno 2172bc89ed remove yarn 2025-06-10 17:37:27 +03:00
OfirTeneJuno 4138ef9c43 Change to continue 2025-06-10 17:33:52 +03:00
OfirTeneJuno 0ff181b7ce Disable values update on use stream in interrupt events 2025-06-10 17:21:17 +03:00
Nuno CamposandGitHub 2e33c520a5 Support numpy array serialization in JsonPlusSerializer (#5035)
* Handle numpy Fortran arrays

* Lint

* Lint

* Lint
2025-06-10 01:17:28 +00:00
Nuno CamposandGitHub 67b1dc602e Update ormsgpack (#5034)
* Update ormsgpack

- Now supports bytearray/memoryview passthrough

* Lint
2025-06-10 00:30:58 +00:00
Naohiro YoshidaandGitHub 1519b90414 Centralized CheckpointTuple creation into a shared function for checkpoint_postgres (#4970) 2025-06-09 18:40:17 +00:00
YkohandGitHub 0035ab9825 docs: Replace unsupported models with structured output-supported models (#3982) 2025-06-09 14:17:05 -04:00
c42cd57a32 chore: Update variable naming in postgres store (#4096)
Co-authored-by: William FH <13333726+hinthornw@users.noreply.github.com>
2025-06-09 17:54:06 +00:00
acc56e094a docs: add query params for Store semantic search (#4828)
Co-authored-by: William FH <13333726+hinthornw@users.noreply.github.com>
2025-06-09 17:47:50 +00:00
Yassin NouhandGitHub 6b30d4fd8f docs: enhance PostgresSaver connection requirements explanation (#4953)
docs: enhance PostgresSaver connection requirements explanation - Add detailed explanation of why autocommit=True and row_factory=dict_row are required - Include example of incorrect usage and resulting errors - Addresses issue #4937 about incomplete setup documentation
2025-06-09 17:12:44 +00:00
fcc37cd06b docs: update tutorial/rag/langgraph_adaptive_rag.ipynb (#2006)
- add some explanations of ipynb code in markdown cell.

Co-authored-by: Sydney Runkle <54324534+sydney-runkle@users.noreply.github.com>
2025-06-09 12:54:29 -04:00
William FHandGitHub c17ee1bf5a feat: [CLI] Add support for building deps with uv (#4995) 2025-06-09 08:57:29 -07:00
William FHandGitHub 88c603b00b fix: (sdk-js) Expand ToolMessage Type (#5015) 2025-06-09 08:22:35 -07:00
Sydney RunkleandGitHub c12f7cb2b9 github: support blank issues (help with v1 planning) (#4999)
blank issues
2025-06-09 13:52:14 +00:00
🤖Esteban Dalel RandGitHub 6d7d689578 docs: highlight changed lines in 3-add-memory.md (#4930) 2025-06-08 14:08:04 +00:00
LostInCode404andGitHub f1b7eca7fc docs: Update 1-build-basic-chatbot.md to add a section about END node (#4886)
Update `1-build-basic-chatbot.md` to add a section about `END` node
2025-06-08 13:51:17 +00:00
Michael LiandGitHub 93766a6df1 docs: fix assistants url at manage_assistants.md (#4993)
* docs: fix agent supervisor doc codes

* docs: fix assistants url at manage_assistants.md
2025-06-08 13:49:45 +00:00
Dionysis GlytsosandGitHub a9d4e0da29 docs: fix typos (#4992)
Fix typos
2025-06-08 13:46:41 +00:00
Sydney RunkleandGitHub 9105e60a34 graph: improve generics on StateGraph etc + move typing utils to private file (#4982) 2025-06-06 19:51:05 -04:00
Sydney RunkleandGitHub b735452153 deprecate input and output in favor of input_schema and output_schema (#4983) 2025-06-06 19:44:56 -04:00
Sydney Runkle 5920d8aa92 using StateT as default for InputT 2025-06-06 12:58:19 -04:00
533f5b3d6f docs: fix task description example in the agent supervisor tutorial (#4938)
* docs: fix agent supervisor doc codes

---------

Co-authored-by: vbarda <vadym@langchain.dev>
2025-06-06 13:41:41 +00:00
Asamu DavidandGitHub be5889a7df docs: add docs for image_distro cli option (#4974) 2025-06-05 23:11:47 +01:00
David Asamu 0bf268feca add docs for image_distro cli option 2025-06-05 17:23:05 +01:00
Sydney RunkleandGitHub 5e7566f4a3 lint: use pep 604 union syntax and pep 585 generic syntax (#4963)
* new union syntax

* fix test

* second round of conversions by injecting future annotations

* format + add top level makefile
2025-06-04 21:50:16 -04:00
Sydney RunkleandGitHub 494c8ef0d2 docs: remove references to StateGraph(dict) (#4964)
remove StateGraph(dict)
2025-06-04 21:29:19 -04:00
lc-arjunandGitHub 45e60ff9e1 fix: camel case to snake case conversion (#4966) 2025-06-04 17:31:12 -07:00
Nuno Campos 194c4c1d1c cli 0.2.12 2025-06-04 15:50:07 -07:00
1a76f6a92a 🐛 [CLI] Generate one --build-context argument for each dependency in the docker build command. (#4962)
* Generate one `--build-context` for each dependency in the `docker build` command.

* Try and fix test

---------

Co-authored-by: Nuno Campos <nuno@langchain.dev>
2025-06-04 22:48:48 +00:00
Sydney RunkleandGitHub aedf974dfd docs: deploy from v0 branch for now (#4960)
only deploy docs on v0
2025-06-04 13:27:48 -04:00
Sydney RunkleandGitHub c0b6a85488 docs: format to allow for deploy (#4959)
formatting
2025-06-04 17:20:19 +00:00
Sydney RunkleandGitHub 9fde14079a docs: use retry_policy instead of retry in docs (#4958) 2025-06-04 17:12:13 +00:00
Sydney RunkleandGitHub 02f3944e88 rename retry -> retry_policy (#4957) 2025-06-04 14:55:06 +00:00
183 changed files with 15746 additions and 11139 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
name: "\U0001F41B Bug Report"
description: Report a bug in LangGraph. To report a security issue, please instead use the security option below. For questions, please use the GitHub Discussions.
labels: ["02 Bug Report"]
labels: [pending,bug]
body:
- type: markdown
attributes:
+1 -1
View File
@@ -1,4 +1,4 @@
blank_issues_enabled: false
blank_issues_enabled: true
version: 2.1
contact_links:
- name: 🤔 Question or Problem
+1 -1
View File
@@ -1,7 +1,7 @@
name: Documentation
description: Report an issue related to the LangGraph documentation.
title: "DOC: <Please write a comprehensive title after the 'DOC: ' prefix>"
labels: [03 - Documentation]
labels: [documentation]
body:
- type: textarea
+6 -4
View File
@@ -4,9 +4,11 @@ on:
push:
branches:
- main
- v0
pull_request:
branches:
- main
- v0
workflow_dispatch:
permissions:
@@ -82,9 +84,9 @@ jobs:
run: make llms-text
- name: Build site
run: |
# If this is main branch, then we want to download stats. we do this
# If this is v0 branch, then we want to download stats. we do this
# with the env variable DOWNLOAD_STATS=true
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
if [ "${{ github.ref }}" == "refs/heads/v0" ]; then
DOWNLOAD_STATS=true make build-docs
else
make build-docs
@@ -144,7 +146,7 @@ jobs:
fi
- name: Configure GitHub Pages
if: github.ref == 'refs/heads/main'
if: github.ref == 'refs/heads/v0'
uses: actions/configure-pages@v5
- name: Upload Pages Artifact
@@ -154,6 +156,6 @@ jobs:
path: ./docs/site/
- name: Deploy to GitHub Pages
if: github.ref == 'refs/heads/main'
if: github.ref == 'refs/heads/v0'
id: deployment
uses: actions/deploy-pages@v4
+55
View File
@@ -0,0 +1,55 @@
# AGENTS Instructions
This repository is a monorepo. Each library lives in a subdirectory under `libs/`.
When you modify code in any library, run the following commands in that library's directory before creating a pull request:
- `make format` run code formatters
- `make lint` run the linter
- `make test` execute the test suite
To run a particular test file or to pass additional pytest options you can specify the `TEST` variable:
```
TEST=path/to/test.py make test
```
Other pytest arguments can also be supplied inside the `TEST` variable.
## Libraries
The repository contains several Python and JavaScript/TypeScript libraries.
Below is a high-level overview:
- **checkpoint** base interfaces for LangGraph checkpointers.
- **checkpoint-postgres** Postgres implementation of the checkpoint saver.
- **checkpoint-sqlite** SQLite implementation of the checkpoint saver.
- **cli** official command-line interface for LangGraph.
- **langgraph** core framework for building stateful, multi-actor agents.
- **prebuilt** high-level APIs for creating and running agents and tools.
- **sdk-js** JS/TS SDK for interacting with the LangGraph REST API.
- **sdk-py** Python SDK for the LangGraph Platform API.
### Dependency map
The diagram below lists downstream libraries for each production dependency as
declared in that library's `pyproject.toml` (or `package.json`).
```text
checkpoint
├── checkpoint-postgres
├── checkpoint-sqlite
├── prebuilt
└── langgraph
prebuilt
└── langgraph
sdk-py
├── langgraph
└── cli
sdk-js (standalone)
```
Changes to a library may impact all of its dependents shown above.
+58
View File
@@ -0,0 +1,58 @@
# Define the directories containing projects
LIBS_DIRS := $(wildcard libs/*)
# Default target
.PHONY: all
all: lint format lock test
# Install dependencies for all projects
.PHONY: install
install:
@echo "Creating virtual environment..."
@uv venv
@for dir in $(LIBS_DIRS); do \
if [ -f $$dir/pyproject.toml ]; then \
echo "Installing dependencies for $$dir"; \
uv pip install -e $$dir; \
fi; \
done
# Lint all projects
.PHONY: lint
lint:
@for dir in $(LIBS_DIRS); do \
if [ -f $$dir/Makefile ]; then \
echo "Running lint in $$dir"; \
$(MAKE) -C $$dir lint; \
fi; \
done
# Format all projects
.PHONY: format
format:
@for dir in $(LIBS_DIRS); do \
if [ -f $$dir/Makefile ]; then \
echo "Running format in $$dir"; \
$(MAKE) -C $$dir format; \
fi; \
done
# Lock all projects
.PHONY: lock
lock:
@for dir in $(LIBS_DIRS); do \
if [ -f $$dir/Makefile ]; then \
echo "Running lock in $$dir"; \
(cd $$dir && uv lock); \
fi; \
done
# Test all projects
.PHONY: test
test:
@for dir in $(LIBS_DIRS); do \
if [ -f $$dir/Makefile ]; then \
echo "Running test in $$dir"; \
$(MAKE) -C $$dir test; \
fi; \
done
-1
View File
@@ -12,7 +12,6 @@
[![Downloads](https://static.pepy.tech/badge/langgraph/month)](https://pepy.tech/project/langgraph)
[![Open Issues](https://img.shields.io/github/issues-raw/langchain-ai/langgraph)](https://github.com/langchain-ai/langgraph/issues)
[![Docs](https://img.shields.io/badge/docs-latest-blue)](https://langchain-ai.github.io/langgraph/)
[![GitMCP](https://img.shields.io/endpoint?url=https://gitmcp.io/badge/langchain-ai/langgraph)](https://gitmcp.io/langchain-ai/langgraph)
Trusted by companies shaping the future of agents including Klarna, Replit, Elastic, and more LangGraph is a low-level orchestration framework for building, managing, and deploying long-running, stateful agents.
+119 -122
View File
@@ -1,157 +1,154 @@
"""Add typescript translation to a given markdown file."""
"""Translate Python markdown to TypeScript and/or consolidate Python-JS markdown into a single document."""
import argparse
import re
import requests
from langchain_anthropic import ChatAnthropic
# Load reference TypeScript snippets
URL = "https://gist.githubusercontent.com/eyurtsev/e7486731415463a9bc5b4682358859c8/raw/b5a5fda9c7e3387cfcb781f25082814d43675d50/gistfile1.txt"
response = requests.get(URL)
response.raise_for_status()
reference_snippets = response.text
model = ChatAnthropic(model="claude-3-5-sonnet-latest")
# Initialize model
model = ChatAnthropic(model="claude-sonnet-4-0", max_tokens=64_000)
TRANSLATION_PROMPT = (
"You are a helpful assistant that translates Python-based technical "
"documentation written in Markdown to equivalent TypeScript-based documentation. "
"The input is a Markdown file written in mkdocs format. It contains "
"Python code snippets embedded in prose. "
"Your task is to rewrite the content by translating the Python code to "
"idiomatic TypeScript, using the provided TypeScript reference snippets "
"to ensure accurate and consistent usage (e.g., correct imports, function "
"names, and patterns). "
"Remove the original Python code and replace it with the corresponding "
"TypeScript version. "
"Do not alter the surrounding prose unless a change is necessary to "
"reflect differences between Python and TypeScript. "
"Preserve the structure and formatting of the original Markdown document. "
"Do not make stylistic or structural changes unless they directly support "
"the translation. "
"Use the reference TypeScript snippets as guidance whenever possible to "
"maintain alignment with existing conventions.\n\n"
f"Here are the reference TypeScript snippets:\n\n{reference_snippets}\n\n"
)
CONSOLIDATION_PROMPT = (
"You are a helpful assistant that consolidates parallel Python and JavaScript (TypeScript) technical documentation "
"written in Markdown into a single unified Markdown document. "
"The input consists of two documents: the first is for Python users, and the second is for JavaScript/TypeScript users. "
"Your task is to merge these into one Markdown file using language-specific fenced blocks to separate the content where needed. "
"Use the following syntax to distinguish content for each language:\n\n"
":::python\n"
"# Python-specific content\n"
":::\n\n"
":::js\n"
"# JavaScript/TypeScript-specific content\n"
":::\n\n"
"Follow these consolidation rules:\n"
"- When content (prose or code) is the same or nearly identical in both versions, include it only once—outside of any fenced block.\n"
"- When content differs between the Python and JS versions, wrap each version in its corresponding fenced block.\n"
"- Prefer **paragraph-level separation** of language-specific content. Do not combine Python and JS snippets or terminology in the same sentence or paragraph using conditional phrases.\n"
" For example, avoid inline constructs like:\n"
" `The :::python add_messages ::: :::js reducer ::: function...`\n"
" Instead, write two distinct paragraphs:\n\n"
" :::python\n"
" The `add_messages` function in our `State` will append the LLM's response messages to whatever messages are already in the state.\n"
" ::: \n\n"
" :::js\n"
" The `reducer` function in our `StateAnnotation` will append the LLM's response messages to whatever messages are already in the state.\n"
" :::\n\n"
"- Preserve the overall structure, ordering, and formatting of the original Markdown documents.\n"
"- Do not rephrase or unify content unless it is logically and semantically identical.\n"
"- Use the fenced blocks for both prose and code as needed, and ensure output is clean, readable Markdown suitable for tools that parse these directives.\n"
"Your goal is to produce a cleanly merged documentation file that serves both Python and JavaScript users without redundancy, while maximizing clarity and separation of language-specific details."
)
def _get_tqdm():
try:
from tqdm import tqdm
except ImportError:
# If not available return a simple identity function
def tqdm(iterable, *args, **kwargs):
return iterable
return tqdm
_tqdm = _get_tqdm()
opening_pattern = re.compile(r"^\s*```python(?:\s+.*)?\s*$")
closing_pattern = re.compile(r"^\s*```\s*$")
def extract_python_snippets(markdown: str) -> list[str]:
"""
Extract all python code blocks (including their fence lines) from the markdown content.
A python block is defined as any block that starts with a line containing an opening fence
with '```python' (optionally with extra parameters) and ends with a closing fence '```'.
"""
snippets = []
inside_block = False
current_snippet = []
for line in markdown.splitlines(keepends=True):
if not inside_block:
if opening_pattern.match(line):
inside_block = True
current_snippet = [line]
else:
current_snippet.append(line)
if closing_pattern.match(line):
inside_block = False
snippets.append("".join(current_snippet))
current_snippet = []
return snippets
def translate_snippet(python_snippet: str) -> str:
"""Translate a python code block into a TypeScript code block using Langchain.
The response is expected to be a properly fenced TypeScript code block (i.e.
starting with ```typescript and ending with ```).
"""
ai_message = model.invoke(
def translate_python_to_ts(markdown_content: str) -> str:
response = model.invoke(
[
{
"role": "system",
"content": (
f"You have access to the following up-to-date example TypeScript code "
f"snippets that show examples of building with langgraph "
f"and langchain:\n\n{reference_snippets}\n\n"
"Use this context to translate the following Python code to equivalent "
"TypeScript. Ensure that your output is a valid fenced TypeScript "
"code block (i.e. starts with ```typescript and ends with ```)."
),
},
{
"role": "user",
"content": f"Translate this Python snippet to TypeScript:\n\n{python_snippet}",
"content": TRANSLATION_PROMPT,
"cache_control": {"type": "ephemeral"},
},
{"role": "user", "content": markdown_content},
]
)
# Use a regular expression to search for a TypeScript code block in the response.
pattern = r"```typescript\s*(.*?)\s*```"
match = re.search(pattern, ai_message.content, re.DOTALL)
if match:
# Reconstruct the code block with proper fences.
typescript_code = match.group(1).strip()
return f"```typescript\n{typescript_code}\n```"
else:
raise ValueError("No TypeScript code block found in the model's response.")
return response.content
def insert_translations_into_markdown(
markdown: str, typescript_snippets: list[str]
) -> str:
"""Walks through the original markdown content and, after each
Python snippet block, inserts the corresponding translated TypeScript snippet.
It assumes that the ordering of the Python snippets
(from extract_python_snippets) matches the order they appear in the markdown.
"""
output_lines = []
lines = markdown.splitlines(keepends=True)
inside_block = False
snippet_index = 0
for line in lines:
output_lines.append(line)
if not inside_block and opening_pattern.match(line):
# We've encountered the start of a python code block.
inside_block = True
elif inside_block:
if closing_pattern.match(line):
# End of a python snippet block.
inside_block = False
if snippet_index < len(typescript_snippets):
# Insert an extra newline for clarity, then the translated TypeScript snippet.
output_lines.append("\n")
output_lines.append(typescript_snippets[snippet_index])
output_lines.append("\n")
snippet_index += 1
return "".join(output_lines)
def consolidate_python_and_ts(combined_content: str) -> str:
response = model.invoke(
[
{
"role": "system",
"content": CONSOLIDATION_PROMPT,
"cache_control": {"type": "ephemeral"},
},
{"role": "user", "content": combined_content},
]
)
return response.content
def main(file_path: str) -> None:
# Read the markdown file.
with open(file_path, "r") as f:
def main(file_path: str, translate_only: bool, consolidate_only: bool) -> None:
with open(file_path, "r", encoding="utf-8") as f:
markdown_content = f.read()
# 1. Extract all Python snippets.
python_snippets = extract_python_snippets(markdown_content)[:1]
if translate_only:
translated = translate_python_to_ts(markdown_content)
output_path = file_path.replace(".md", ".translated.md")
with open(output_path, "w", encoding="utf-8") as f:
f.write(translated)
print(f"Translated JS/TS version written to: {output_path}")
# 2. Translate each Python snippet to TypeScript.
typescript_snippets = []
# Replace with .batch() for faster translation
for python_snippet in _tqdm(python_snippets):
ts_snippet = translate_snippet(python_snippet)
typescript_snippets.append(ts_snippet)
elif consolidate_only:
consolidated = consolidate_python_and_ts(markdown_content)
with open(file_path, "w", encoding="utf-8") as f:
f.write(consolidated)
print(f"Consolidated content written to: {file_path}")
# 3. Insert the TypeScript translations after their respective Python snippets.
updated_markdown = insert_translations_into_markdown(
markdown_content, typescript_snippets
)
# Overwrite the original markdown file with the updated content.
with open(file_path, "w") as f:
f.write(updated_markdown)
else:
# Default behavior: translate first, then consolidate both
translated = translate_python_to_ts(markdown_content)
combined = f"{markdown_content.strip()}\n\n\n{translated.strip()}"
consolidated = consolidate_python_and_ts(combined)
with open(file_path, "w", encoding="utf-8") as f:
f.write(consolidated)
print(f"Translated and consolidated content written to: {file_path}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Translate Python snippets in a markdown file to TypeScript and insert them after each Python snippet."
description=(
"Translate Python markdown to TypeScript and/or consolidate "
"Python-JS markdown into one file."
)
)
parser.add_argument("file_path", type=str, help="Path to the markdown file.")
parser.add_argument(
"--translate-only",
action="store_true",
help="Only generate the JS translation.",
)
parser.add_argument(
"--consolidate-only",
action="store_true",
help="Only consolidate pre-paired Python and JS content.",
)
args = parser.parse_args()
main(args.file_path)
if args.translate_only and args.consolidate_only:
raise ValueError(
"Cannot use both --translate-only and --consolidate-only at the same time."
)
main(
args.file_path,
translate_only=args.translate_only,
consolidate_only=args.consolidate_only,
)
+10 -6
View File
@@ -3,19 +3,21 @@
import asyncio
import glob
import os
from typing import TypedDict, List, Optional
import pydantic
import re
from pydantic import BaseModel, Field
from langchain_core.rate_limiters import InMemoryRateLimiter
from typing import TypedDict, List, Optional
import yaml
from langchain.chat_models import init_chat_model
from langchain_core.rate_limiters import InMemoryRateLimiter
from mkdocs.structure.files import File
from mkdocs.structure.pages import Page
from pydantic import BaseModel, Field
from yaml import SafeLoader
from _scripts.notebook_hooks import _on_page_markdown_with_config
from _scripts.notebook_hooks import (
_on_page_markdown_with_config,
_apply_conditional_rendering,
)
HERE = os.path.dirname(os.path.abspath(__file__))
# Get source directory (parent of HERE / docs)
@@ -211,7 +213,9 @@ async def process_nav_items(nav_items: list[NavItem]) -> list[NavItem]:
# Remove any items that start with http:// or https:// looking only for
# local file at this stages.
nav_items = [
item for item in nav_items if not item["url"].startswith(("http://", "https://"))
item
for item in nav_items
if not item["url"].startswith(("http://", "https://"))
]
# Process items in parallel
tasks = [process_single_item(item) for item in nav_items]
+5
View File
@@ -0,0 +1,5 @@
JS_LINK_MAP = {
"langgraph.types.interrupt": "https://langchain-ai.github.io/langgraphjs/reference/functions/langgraph.interrupt-2.html",
"create_react_agent": "https://langchain-ai.github.io/langgraphjs/reference/functions/langgraph_prebuilt.createReactAgent.html",
"langgraph.types.Command": "https://langchain-ai.github.io/langgraphjs/reference/classes/langgraph.Command.html",
}
+72 -1
View File
@@ -16,6 +16,7 @@ from mkdocs.structure.pages import Page
from _scripts.generate_api_reference_links import update_markdown_with_imports
from _scripts.notebook_convert import convert_notebook
from _scripts.link_map import JS_LINK_MAP
logger = logging.getLogger(__name__)
logging.basicConfig()
@@ -86,7 +87,7 @@ REDIRECT_MAP = {
"cloud/how-tos/stream_events.md": "cloud/how-tos/streaming.md#stream-events",
"cloud/how-tos/stream_debug.md": "cloud/how-tos/streaming.md#debug",
"cloud/how-tos/stream_multiple.md": "cloud/how-tos/streaming.md#stream-multiple-modes",
# prebuit redirects
# prebuilt redirects
"how-tos/create-react-agent.ipynb": "agents/agents.md#basic-configuration",
"how-tos/create-react-agent-memory.ipynb": "agents/memory.md",
"how-tos/create-react-agent-system-prompt.ipynb": "agents/context.md#prompts",
@@ -158,6 +159,62 @@ def _add_path_to_code_blocks(markdown: str, page: Page) -> str:
return code_block_pattern.sub(replace_code_block_header, markdown)
def _resolve_cross_references(md_text: str, link_map: dict[str, str]) -> str:
"""Replace [title][identifier] with [title](url) using language-specific link_map.
Args:
md_text: The markdown text to process.
link_map: mapping of identifier to URL.
Returns:
The processed markdown text with cross-references resolved.
"""
# Pattern to match [title][identifier]
pattern = re.compile(r"\[([^\]]+)\]\[([^\]]+)\]")
def replace_reference(match: re.Match) -> str:
"""Replace the matched reference with the corresponding URL."""
title, identifier = match.group(1), match.group(2)
url = link_map.get(identifier)
if url:
return f"[{title}]({url})"
else:
# Leave it unchanged if not found
return match.group(0)
return pattern.sub(replace_reference, md_text)
def _apply_conditional_rendering(md_text: str, target_language: str) -> str:
if target_language not in {"python", "js"}:
raise ValueError("target_language must be 'python' or 'js'")
pattern = re.compile(
r"(?P<indent>[ \t]*):::(?P<language>\w+)\s*\n"
r"(?P<content>((?:.*\n)*?))" # Capture the content inside the block
r"(?P=indent):::" # Match closing with the same indentation
)
def replace_conditional_blocks(match: re.Match) -> str:
"""Keep active conditionals."""
language = match.group("language")
content = match.group("content")
if language not in {"python", "js"}:
# If the language is not supported, return the original block
return match.group(0)
if language == target_language:
return content
# If the language does not match, return an empty string
return ""
processed = pattern.sub(replace_conditional_blocks, md_text)
return processed
def _highlight_code_blocks(markdown: str) -> str:
"""Find code blocks with highlight comments and add hl_lines attribute.
@@ -257,6 +314,20 @@ def _on_page_markdown_with_config(
# Apply highlight comments to code blocks
markdown = _highlight_code_blocks(markdown)
# Apply conditional rendering for code blocks
target_language = kwargs.get("target_language", "python")
markdown = _apply_conditional_rendering(markdown, target_language)
if target_language == "js":
markdown = _resolve_cross_references(markdown, JS_LINK_MAP)
elif target_language == "python":
# Via a dedicated plugin
pass
else:
raise ValueError(
f"Unsupported target language: {target_language}. "
"Supported languages are 'python' and 'js'."
)
# Add file path as an attribute to code blocks that are executable.
# This file path is used to associate fixtures with the executable code
# which can be used in CI to test the docs without making network requests.
+9
View File
@@ -62,6 +62,15 @@ Starting from the `LangGraph Platform` view...
1. In the panel, select the `Server` tab to view server logs for the revision. Server logs are only available after a revision has been deployed.
1. Within the `Server` tab, adjust the date/time range picker as needed. By default, the date/time range picker is set to the `Last 7 days`.
## View Deployment Metrics
Starting from the <a href="https://smith.langchain.com/" target="_blank">LangSmith UI</a>...
1. In the left-hand navigation panel, select `LangGraph Platform`. The `LangGraph Platform` view contains a list of existing LangGraph Platform deployments.
1. Select an existing deployment to monitor.
1. Select the `Monitoring` tab to view the deployment metrics. See a list of [all available metrics](../../concepts/langgraph_control_plane.md#monitoring).
1. Within the `Monitoring` tab, use the date/time range picker as needed. By default, the date/time range picker is set to the `Last 15 minutes`.
## Interrupt Revision
Interrupting a revision will stop deployment of the revision.
+5 -5
View File
@@ -20,7 +20,7 @@ my-app/
|-- openai_agent.py # code for your graph
```
where the graph is defined in `openai_agent.py`.
where the graph is defined in `openai_agent.py`.
### No rebuild
@@ -28,11 +28,11 @@ In the standard LangGraph API configuration, the server uses the compiled graph
```python
from langchain_openai import ChatOpenAI
from langgraph.graph import END, START, StateGraph, MessagesState
from langgraph.graph import END, START, MessageGraph
model = ChatOpenAI(temperature=0)
graph_workflow = StateGraph(MessagesState)
graph_workflow = MessageGraph()
graph_workflow.add_node("agent", model)
graph_workflow.add_edge("agent", END)
@@ -61,7 +61,7 @@ To make your graph rebuild on each new run with custom configuration, you need t
from typing import Annotated
from typing_extensions import TypedDict
from langchain_openai import ChatOpenAI
from langgraph.graph import END, START
from langgraph.graph import END, START, MessageGraph
from langgraph.graph.state import StateGraph
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
@@ -144,4 +144,4 @@ Finally, you need to specify the path to your graph-making function (`make_graph
}
```
See more info on LangGraph API configuration file [here](../reference/cli.md#configuration-file)
See more info on LangGraph API configuration file [here](../reference/cli.md#configuration-file)
@@ -30,18 +30,16 @@ Before deploying, review the [conceptual guide for the Self-Hosted Control Plane
1. `LangGraphPlatform CRD`: A CRD for LangGraph Platform deployments. This contains the spec for managing an instance of a LangGraph platform deployment.
1. `operator`: This operator handles changes to your LangGraph Platform CRDs.
1. `host-backend`: This is the [control plane](../../concepts/langgraph_control_plane.md).
1. Two additional images will be used by the chart.
1. Two additional images will be used by the chart. Use the images that are specified in the latest release.
hostBackendImage:
repository: "docker.io/langchain/hosted-langserve-backend"
pullPolicy: IfNotPresent
tag: "0.9.80"
operatorImage:
repository: "docker.io/langchain/langgraph-operator"
pullPolicy: IfNotPresent
tag: "aa9dff4"
1. In your `langsmith_config.yaml` file, enable the `langgraphPlatform` option. Note that you must also have a valid ingress setup:
1. In your config file for langsmith (usually `langsmith_config.yaml`, enable the `langgraphPlatform` option. Note that you must also have a valid ingress setup:
config:
langgraphPlatform:
@@ -212,6 +212,7 @@ We have now created an assistant called "Open AI Assistant" that has `model_name
Output:
```
Receiving event of type: metadata
{'run_id': '1ef6746e-5893-67b1-978a-0f1cd4060e16'}
@@ -219,6 +220,7 @@ Output:
Receiving event of type: updates
{'agent': {'messages': [{'content': 'I was created by OpenAI, a research organization focused on developing and advancing artificial intelligence technology.', 'additional_kwargs': {}, 'response_metadata': {'finish_reason': 'stop', 'model_name': 'gpt-4o-2024-05-13', 'system_fingerprint': 'fp_157b3831f5'}, 'type': 'ai', 'name': None, 'id': 'run-e1a6b25c-8416-41f2-9981-f9cfe043f414', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': None}]}}
```
### LangGraph Platform UI
@@ -231,9 +233,11 @@ Inside your deployment, select the "Assistants" tab. For the assistant you would
To edit the assistant, use the `update` method. This will create a new version of the assistant with the provided edits. See the [Python](https://langchain-ai.github.io/langgraph/cloud/reference/sdk/python_sdk_ref/#langgraph_sdk.client.AssistantsClient.update) and [JS](https://langchain-ai.github.io/langgraph/cloud/reference/sdk/js_ts_sdk_ref/#update) SDK reference docs for more information.
!!! note "Note"
You must pass in the ENTIRE config (and metadata if you are using it). The update endpoint creates new versions completely from scratch and does not rely on previous versions.
You must pass in the ENTIRE config (and metadata if you are using it). The update endpoint creates new versions completely from scratch and does not rely on previous versions.
For example, to update your assistant's system prompt:
=== "Python"
```python
@@ -247,5 +247,7 @@ Verify that the original, interrupted run was interrupted
Output:
```
'interrupted'
```
@@ -2,7 +2,7 @@
!!! info "Prerequisites"
- [Assistants Overview](../../concepts/assistants.md)
- [Assistants Overview](../../../concepts/assistants.md)
LangGraph Studio lets you view, edit, and update your assistants, and allows you to run your graph using these assistant configurations.
+14 -7
View File
@@ -73,9 +73,11 @@ langgraph dev --debug-port 5678
Then attach your preferred debugger:
=== "VS Code"
Add this configuration to `launch.json`:
`json
{
Add this configuration to `launch.json`:
```json
{
"name": "Attach to LangGraph",
"type": "debugpy",
"request": "attach",
@@ -83,11 +85,16 @@ Add this configuration to `launch.json`:
"host": "0.0.0.0",
"port": 5678
}
}
`
Specify the port number you chose in the previous step.
}
```
=== "PyCharm" 1. Go to Run → Edit Configurations 2. Click + and select "Python Debug Server" 3. Set IDE host name: `localhost` 4. Set port: `5678` (or the port number you chose in the previous step) 5. Click "OK" and start debugging
=== "PyCharm"
1. Go to Run → Edit Configurations
2. Click + and select "Python Debug Server"
3. Set IDE host name: `localhost`
4. Set port: `5678` (or the port number you chose in the previous step)
5. Click "OK" and start debugging
## Troubleshooting
@@ -0,0 +1,57 @@
# Run experiments over a dataset
LangGraph Studio supports evaluations by allowing you to run your assistant over a pre-defined LangSmith dataset. This enables you to understand how your application performs over a variety of inputs, compare the results to reference outputs, and score the results using [evaluators](../../../agents/evals.md).
This guide shows you how to run an experiment end-to-end from Studio.
---
## Prerequisites
Before running an experiment, ensure you have the following:
1. **A LangSmith dataset**: Your dataset should contain the inputs you want to test and optionally, reference outputs for comparison.
- The schema for the inputs must match the required input schema for the assistant. For more information on schemas, see [here](../../../concepts/low_level.md#schema).
- For more on creating datasets, see [How to Manage Datasets](https://docs.smith.langchain.com/evaluation/how_to_guides/manage_datasets_in_application#set-up-your-dataset).
2. **(Optional) Evaluators**: You can attach evaluators (e.g., LLM-as-a-Judge, heuristics, or custom functions) to your dataset in LangSmith. These will run automatically after the graph has processed all inputs.
- To learn more, read about [Evaluation Concepts](https://docs.smith.langchain.com/evaluation/concepts#evaluators).
3. **A running application**: The experiment can be run against:
- An application deployed on [LangGraph Platform](../../quick_start.md).
- A locally running application started via the [langgraph-cli](../../../tutorials/langgraph-platform/local-server.md).
---
## Step-by-step guide
### 1. Launch the experiment
Click the **Run experiment** button in the top right corner of the Studio page.
### 2. Select your dataset
In the modal that appears, select the dataset (or a specific dataset split) to use for the experiment and click **Start**.
### 3. Monitor the progress
All of the inputs in the dataset will now be run against the active assistant. Monitor the experiment's progress via the badge in the top right corner.
You can continue to work in Studio while the experiment runs in the background. Click the arrow icon button at any time to navigate to LangSmith and view the detailed experiment results.
---
## Troubleshooting
### "Run experiment" button is disabled
If the "Run experiment" button is disabled, check the following:
- **Deployed application**: If your application is deployed on LangGraph Platform, you may need to create a new revision to enable this feature.
- **Local development server**: If you are running your application locally, make sure you have upgraded to the latest version of the `langgraph-cli` (`pip install -U langgraph-cli`). Additionally, ensure you have tracing enabled by setting the `LANGSMITH_API_KEY` in your project's `.env` file.
### Evaluator results are missing
When you run an experiment, any attached evaluators are scheduled for execution in a queue. If you don't see results immediately, it likely means they are still pending.
+74 -68
View File
@@ -8,15 +8,15 @@ Currently, the SDK does not provide built-in support for defining webhook endpoi
The following API endpoints accept a `webhook` parameter:
| Operation | HTTP Method | Endpoint |
|-----------|------------|----------|
| Create Run | `POST` | `/thread/{thread_id}/runs` |
| Create Thread Cron | `POST` | `/thread/{thread_id}/runs/crons` |
| Stream Run | `POST` | `/thread/{thread_id}/runs/stream` |
| Wait Run | `POST` | `/thread/{thread_id}/runs/wait` |
| Create Cron | `POST` | `/runs/crons` |
| Stream Run Stateless | `POST` | `/runs/stream` |
| Wait Run Stateless | `POST` | `/runs/wait` |
| Operation | HTTP Method | Endpoint |
|----------------------|-------------|-----------------------------------|
| Create Run | `POST` | `/thread/{thread_id}/runs` |
| Create Thread Cron | `POST` | `/thread/{thread_id}/runs/crons` |
| Stream Run | `POST` | `/thread/{thread_id}/runs/stream` |
| Wait Run | `POST` | `/thread/{thread_id}/runs/wait` |
| Create Cron | `POST` | `/runs/crons` |
| Stream Run Stateless | `POST` | `/runs/stream` |
| Wait Run Stateless | `POST` | `/runs/wait` |
In this guide, well show how to trigger a webhook after streaming a run.
@@ -25,36 +25,39 @@ In this guide, well show how to trigger a webhook after streaming a run.
Before making API calls, set up your assistant and thread.
=== "Python"
```python
from langgraph_sdk import get_client
client = get_client(url=<DEPLOYMENT_URL>)
assistant_id = "agent"
thread = await client.threads.create()
print(thread)
```
```python
from langgraph_sdk import get_client
client = get_client(url=<DEPLOYMENT_URL>)
assistant_id = "agent"
thread = await client.threads.create()
print(thread)
```
=== "JavaScript"
```js
import { Client } from "@langchain/langgraph-sdk";
const client = new Client({ apiUrl: <DEPLOYMENT_URL> });
const assistantID = "agent";
const thread = await client.threads.create();
console.log(thread);
```
```js
import { Client } from "@langchain/langgraph-sdk";
const client = new Client({ apiUrl: <DEPLOYMENT_URL> });
const assistantID = "agent";
const thread = await client.threads.create();
console.log(thread);
```
=== "CURL"
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/assistants/search \
--header 'Content-Type: application/json' \
--data '{ "limit": 10, "offset": 0 }' | jq -c 'map(select(.config == null or .config == {})) | .[0]' && \
curl --request POST \
--url <DEPLOYMENT_URL>/threads \
--header 'Content-Type: application/json' \
--data '{}'
```
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/assistants/search \
--header 'Content-Type: application/json' \
--data '{ "limit": 10, "offset": 0 }' | jq -c 'map(select(.config == null or .config == {})) | .[0]' && \
curl --request POST \
--url <DEPLOYMENT_URL>/threads \
--header 'Content-Type: application/json' \
--data '{}'
```
Example response:
@@ -77,48 +80,51 @@ To use a webhook, specify the `webhook` parameter in your API request. When the
For example, if your server listens for webhook events at `https://my-server.app/my-webhook-endpoint`, include this in your request:
=== "Python"
```python
input = { "messages": [{ "role": "user", "content": "Hello!" }] }
async for chunk in client.runs.stream(
thread_id=thread["thread_id"],
assistant_id=assistant_id,
input=input,
stream_mode="events",
webhook="https://my-server.app/my-webhook-endpoint"
):
pass
```
```python
input = { "messages": [{ "role": "user", "content": "Hello!" }] }
async for chunk in client.runs.stream(
thread_id=thread["thread_id"],
assistant_id=assistant_id,
input=input,
stream_mode="events",
webhook="https://my-server.app/my-webhook-endpoint"
):
pass
```
=== "JavaScript"
```js
const input = { messages: [{ role: "human", content: "Hello!" }] };
const streamResponse = client.runs.stream(
thread["thread_id"],
assistantID,
{
input: input,
webhook: "https://my-server.app/my-webhook-endpoint"
}
);
```js
const input = { messages: [{ role: "human", content: "Hello!" }] };
for await (const chunk of streamResponse) {
// Handle stream output
}
```
const streamResponse = client.runs.stream(
thread["thread_id"],
assistantID,
{
input: input,
webhook: "https://my-server.app/my-webhook-endpoint"
}
);
for await (const chunk of streamResponse) {
// Handle stream output
}
```
=== "CURL"
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data '{
"assistant_id": <ASSISTANT_ID>,
"input": {"messages": [{"role": "user", "content": "Hello!"}]},
"webhook": "https://my-server.app/my-webhook-endpoint"
}'
```
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data '{
"assistant_id": <ASSISTANT_ID>,
"input": {"messages": [{"role": "user", "content": "Hello!"}]},
"webhook": "https://my-server.app/my-webhook-endpoint"
}'
```
## Webhook payload
@@ -3818,6 +3818,14 @@
"title": "Filter",
"description": "Optional dictionary of key-value pairs to filter results."
},
"query": {
"type": [
"string",
"null"
],
"title": "Query",
"description": "Query string for semantic/vector search."
},
"limit": {
"type": "integer",
"default": 10,
+22 -6
View File
@@ -43,15 +43,17 @@ The LangGraph CLI requires a JSON configuration file that follows this [schema](
| <span style="white-space: nowrap;">`graphs`</span> | **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 returns an instance of `langgraph.graph.state.StateGraph` or `langgraph.graph.state.CompiledStateGraph`. See [how to rebuild a graph at runtime](../../cloud/deployment/graph_rebuild.md) for more details.</li></ul> |
| <span style="white-space: nowrap;">`auth`</span> | _(Added in v0.0.11)_ Auth configuration containing the path to your authentication handler. Example: `./your_package/auth.py:auth`, where `auth` is an instance of `langgraph_sdk.Auth`. See [authentication guide](../../concepts/auth.md) for details. |
| <span style="white-space: nowrap;">`base_image`</span> | Optional. Base image to use for the LangGraph API server. Defaults to `langchain/langgraph-api` or `langchain/langgraphjs-api`. Use this to pin your builds to a particular version of the langgraph API, such as `"langchain/langgraph-server:0.2"`. See https://hub.docker.com/r/langchain/langgraph-server/tags for more details. (added in `langgraph-cli==0.2.8`) |
| <span style="white-space: nowrap;">`image_distro`</span> | Optional. Linux distribution for the base image. Must be either `"debian"` or `"wolfi"`. If omitted, defaults to `"debian"`. Available in `langgraph-cli>=0.2.11`.|
| <span style="white-space: nowrap;">`env`</span> | Path to `.env` file or a mapping from environment variable to its value. |
| <span style="white-space: nowrap;">`store`</span> | Configuration for adding semantic search and/or time-to-live (TTL) to the BaseStore. Contains the following fields: <ul><li>`index` (optional): Configuration for semantic search indexing with fields `embed`, `dims`, and optional `fields`.</li><li>`ttl` (optional): Configuration for item expiration. An object with optional fields: `refresh_on_read` (boolean, defaults to `true`), `default_ttl` (float, lifespan in **minutes**, defaults to no expiration), and `sweep_interval_minutes` (integer, how often to check for expired items, defaults to no sweeping).</li></ul> |
| <span style="white-space: nowrap;">`ui`</span> | Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file. (added in `langgraph-cli==0.1.84`) |
| <span style="white-space: nowrap;">`python_version`</span> | `3.11`, `3.12`, or `3.13`. Defaults to `3.11`. |
| <span style="white-space: nowrap;">`node_version`</span> | Specify `node_version: 20` to use LangGraph.js. |
| <span style="white-space: nowrap;">`pip_config_file`</span> | Path to `pip` config file. |
| <span style="white-space: nowrap;">`pip_installer`</span> | _(Added in v0.3)_ Optional. Python package installer selector. It can be set to `"auto"`, `"pip"`, or `"uv"`. From version&nbsp;0.3 onward the default strategy is to run `uv pip`, which typically delivers faster builds while remaining a drop-in replacement. In the uncommon situation where `uv` cannot handle your dependency graph or the structure of your `pyproject.toml`, specify `"pip"` here to revert to the earlier behaviour. |
| <span style="white-space: nowrap;">`dockerfile_lines`</span> | Array of additional lines to add to Dockerfile following the import from parent image. |
| <span style="white-space: nowrap;">`checkpointer`</span> | Configuration for the checkpointer. Contains a `ttl` field which is an object with the following keys: <ul><li>`strategy`: How to handle expired checkpoints (e.g., `"delete"`).</li><li>`sweep_interval_minutes`: How often to check for expired checkpoints (integer).</li><li>`default_ttl`: Default time-to-live for checkpoints in **minutes** (integer). Defines how long checkpoints are kept before the specified strategy is applied.</li></ul> |
| <span style="white-space: nowrap;">`http`</span> | HTTP server configuration with the following fields: <ul><li>`app`: Path to custom Starlette/FastAPI app (e.g., `"./src/agent/webapp.py:app"`). See [custom routes guide](../../how-tos/http/custom_routes.md).</li><li>`disable_assistants`: Disable `/assistants` routes</li><li>`disable_threads`: Disable `/threads` routes</li><li>`disable_runs`: Disable `/runs` routes</li><li>`disable_store`: Disable `/store` routes</li><li>`disable_meta`: Disable `/ok`, `/info`, `/metrics`, and `/docs` routes</li><li>`cors`: CORS configuration with fields for `allow_origins`, `allow_methods`, `allow_headers`, etc.</li><li>`configurable_headers`: Define which request headers to exclude or include as a run's configurable values.</li></ul> |
| <span style="white-space: nowrap;">`http`</span> | HTTP server configuration with the following fields: <ul><li>`app`: Path to custom Starlette/FastAPI app (e.g., `"./src/agent/webapp.py:app"`). See [custom routes guide](../../how-tos/http/custom_routes.md).</li><li>`disable_assistants`: Disable `/assistants` routes</li><li>`disable_threads`: Disable `/threads` routes</li><li>`disable_runs`: Disable `/runs` routes</li><li>`disable_store`: Disable `/store` routes</li><li>`disable_meta`: Disable `/ok`, `/info`, `/metrics`, and `/docs` routes</li><li>`disable_mcp`: Disable `/mcp` routes</li><li>`cors`: CORS configuration with fields for `allow_origins`, `allow_methods`, `allow_headers`, etc.</li><li>`configurable_headers`: Define which request headers to exclude or include as a run's configurable values.</li></ul> |
=== "JS"
@@ -79,6 +81,20 @@ The LangGraph CLI requires a JSON configuration file that follows this [schema](
}
```
#### Using Wolfi Base Images
You can specify the Linux distribution for your base image using the `image_distro` field. Valid options are `debian` or `wolfi`. Wolfi is the recommended option as it provides smaller and more secure images. This is available in `langgraph-cli>=0.2.11`.
```json
{
"dependencies": ["."],
"graphs": {
"chat": "./chat/graph.py:graph"
},
"image_distro": "wolfi"
}
```
#### 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.
@@ -113,7 +129,7 @@ The LangGraph CLI requires a JSON configuration file that follows this [schema](
- `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
- `cohere:embed-multilingual-light-v3.0`: 384
#### Semantic search with a custom embedding function
@@ -346,8 +362,8 @@ The LangGraph CLI requires a JSON configuration file that follows this [schema](
**Options**
| Option | Default | Description |
| -------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| 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 Platform API server with locally built images. |
@@ -366,8 +382,8 @@ The LangGraph CLI requires a JSON configuration file that follows this [schema](
**Options**
| Option | Default | Description |
| -------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| 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` |
| `--no-pull` | | Use locally built images. Defaults to `false` to build with latest remote Docker image. |
+11 -3
View File
@@ -50,11 +50,10 @@ Set this environment variable to have a deployment send traces to a self-hosted
## `LANGSMITH_TRACING`
!!! info "Only for Self-Hosted Data Plane, Self-Hosted Control Plane, and Standalone Container"
Disabling LangSmith tracing is only available for [Self-Hosted Data Plane](../../concepts/langgraph_self_hosted_data_plane.md), [Self-Hosted Control Plane](../../concepts/langgraph_self_hosted_control_plane.md), and [Standalone Container](../../concepts/langgraph_standalone_container.md) deployments.
Set `LANGSMITH_TRACING` to `false` to disable tracing to LangSmith.
Defaults to `true`.
## `LOG_LEVEL`
Configure [log level](https://docs.python.org/3/library/logging.html#logging-levels). Defaults to `INFO`.
@@ -123,3 +122,12 @@ Defaults to `''`.
Set `REDIS_CLUSTER` to `True` to enable Redis Cluster mode. When enabled, the system will connect to Redis using cluster mode. This is useful when connecting to a Redis Cluster deployment.
Defaults to `False`.
## `MOUNT_PREFIX`
!!! info "Only Allowed in Self-Hosted Deployments"
The `MOUNT_PREFIX` environment variable is only allowed in Self-Hosted Deployment models, LangGraph Platform SaaS will not allow this environment variable.
Set `MOUNT_PREFIX` to serve the LangGraph Server under a specific path prefix. This is useful for deployments where the server is behind a reverse proxy or load balancer that requires a specific path prefix.
For example, if the server is to be served under `https://example.com/langgraph`, set `MOUNT_PREFIX` to `/langgraph`.
+1 -1
View File
@@ -198,7 +198,7 @@ async def add_owner(
You can register handlers for specific resources and actions by chaining the resource and action names together with the [`@auth.on`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.on) decorator.
When a request is made, the most specific handler that matches that resource and action is called. Below is an example of how to register handlers for specific resources and actions. For the following setup:
1. Authenticated users are able to create threads, read thread, create runs on threads
1. Authenticated users are able to create threads, read threads, and create runs on threads
2. Only users with the "assistants:create" permission are allowed to create new assistants
3. All other endpoints (e.g., e.g., delete assistant, crons, store) are disabled for all users.
+6 -1
View File
@@ -9,13 +9,18 @@ search:
## Installation
The LangGraph CLI can be installed via pip:
The LangGraph CLI can be installed via pip or [Homebrew](https://brew.sh/):
=== "pip"
```bash
pip install langgraph-cli
```
=== "Homebrew"
```bash
brew install langgraph-cli
```
## Commands
LangGraph CLI provides the following core functionality:
@@ -19,6 +19,7 @@ From the control plane UI, you can:
- Update a deployment.
- Update environment variables for a deployment.
- View build and server logs of a deployment.
- View deployment metrics such as CPU and memory usage.
- Delete a deployment.
The Control Plane UI is embedded in [LangSmith](https://docs.smith.langchain.com/langgraph_cloud).
@@ -88,6 +89,17 @@ Infrastructure for deployments and revisions are provisioned and deployed asynch
The control plane and [LangGraph Data Plane](./langgraph_data_plane.md) "listener" application coordinate to achieve asynchronous deployments.
### Monitoring
After a deployment is ready, the control plane monitors the deployment and records various metrics, such as:
- CPU and memory usage of the deployment.
- Number of container restarts.
- Number of replicas (this will increase with [autoscaling](../concepts/langgraph_data_plane.md#autoscaling)).
- [Postgres](../concepts/langgraph_data_plane.md#postgres) CPU, memory usage, and disk usage.
These metrics are displayed as charts in the Control Plane UI.
### LangSmith Integration
A [LangSmith](https://docs.smith.langchain.com/) tracing project is automatically created for each deployment. The tracing project has the same name as the deployment. When creating a deployment, the `LANGCHAIN_TRACING` and `LANGSMITH_API_KEY`/`LANGCHAIN_API_KEY` environment variables do not need to be specified; they are set automatically by the control plane.
+2 -1
View File
@@ -24,6 +24,7 @@ Key features of LangGraph Studio:
- [Manage assistants](../cloud/how-tos/studio/manage_assistants.md)
- [Manage threads](../cloud/how-tos/threads_studio.md)
- [Iterate on prompts](../cloud/how-tos/iterate_graph_studio.md)
- [Run experiments over a dataset](../cloud/how-tos/studio/run_evals.md)
- Manage [long term memory](memory.md)
- Debug agent state via [time travel](time-travel.md)
@@ -41,4 +42,4 @@ Chat mode is a simpler UI for iterating on and testing chat-specific agents. It
## Learn more
- See this guide on how to [get started](../cloud/how-tos/studio/quick_start.md) with LangGraph Studio.
- See this guide on how to [get started](../cloud/how-tos/studio/quick_start.md) with LangGraph Studio.
+11 -5
View File
@@ -89,7 +89,7 @@ def node_3(state: PrivateState) -> OutputState:
# Read from PrivateState, write to OutputState
return {"graph_output": state["bar"] + " Lance"}
builder = StateGraph(OverallState,input=InputState,output=OutputState)
builder = StateGraph(OverallState,input_schema=InputState,output_schema=OutputState)
builder.add_node("node_1", node_1)
builder.add_node("node_2", node_2)
builder.add_node("node_3", node_3)
@@ -107,7 +107,7 @@ There are two subtle and important points to note here:
1. We pass `state: InputState` as the input schema to `node_1`. But, we write out to `foo`, a channel in `OverallState`. How can we write out to a state channel that is not included in the input schema? This is because a node _can write to any state channel in the graph state._ The graph state is the union of the state channels defined at initialization, which includes `OverallState` and the filters `InputState` and `OutputState`.
2. We initialize the graph with `StateGraph(OverallState,input=InputState,output=OutputState)`. So, how can we write to `PrivateState` in `node_2`? How does the graph gain access to this schema if it was not passed in the `StateGraph` initialization? We can do this because _nodes can also declare additional state channels_ as long as the state schema definition exists. In this case, the `PrivateState` schema is defined, so we can add `bar` as a new state channel in the graph and write to it.
2. We initialize the graph with `StateGraph(OverallState,input_schema=InputState,output_schema=OutputState)`. So, how can we write to `PrivateState` in `node_2`? How does the graph gain access to this schema if it was not passed in the `StateGraph` initialization? We can do this because _nodes can also declare additional state channels_ as long as the state schema definition exists. In this case, the `PrivateState` schema is defined, so we can add `bar` as a new state channel in the graph and write to it.
### Reducers
@@ -197,19 +197,25 @@ In LangGraph, nodes are typically python functions (sync or async) where the **f
Similar to `NetworkX`, you add these nodes to a graph using the [add_node][langgraph.graph.StateGraph.add_node] method:
```python
from typing_extensions import TypedDict
from langchain_core.runnables import RunnableConfig
from langgraph.graph import StateGraph
builder = StateGraph(dict)
class State(TypedDict):
input: str
results: str
builder = StateGraph(State)
def my_node(state: dict, config: RunnableConfig):
def my_node(state: State, config: RunnableConfig):
print("In node: ", config["configurable"]["user_id"])
return {"results": f"Hello, {state['input']}!"}
# The second argument is optional
def my_other_node(state: dict):
def my_other_node(state: State):
return state
+2 -1
View File
@@ -87,6 +87,7 @@ One of the most common agent types is a [tool-calling agent](../agents/overview.
```python
from langchain_core.tools import tool
@tool
def transfer_to_bob():
"""Transfer to bob."""
return Command(
@@ -414,4 +415,4 @@ There are two high-level approaches to achieve that:
An agent might need to have a different state schema from the rest of the agents. For example, a search agent might only need to keep track of queries and retrieved documents. There are two ways to achieve this in LangGraph:
- Define [subgraph](./subgraphs.md) agents with a separate state schema. If there are no shared state keys (channels) between the subgraph and the parent graph, its important to [add input / output transformations](../how-tos/subgraph.ipynb#different-state-schemas) so that the parent graph knows how to communicate with the subgraphs.
- Define agent node functions with a [private input state schema](../how-tos/graph-api.ipynb/#pass-private-state-between-nodes) that is distinct from the overall graph state schema. This allows passing information that is only needed for executing that particular agent.
- Define agent node functions with a [private input state schema](../how-tos/graph-api.ipynb/#pass-private-state-between-nodes) that is distinct from the overall graph state schema. This allows passing information that is only needed for executing that particular agent.
+43 -1
View File
@@ -470,9 +470,51 @@ If the checkpointer is used with asynchronous graph execution (i.e. executing th
### Serializer
When checkpointers save the graph state, they need to serialize the channel values in the state. This is done using serializer objects.
When checkpointers save the graph state, they need to serialize the channel values in the state. This is done using serializer objects.
`langgraph_checkpoint` defines [protocol][langgraph.checkpoint.serde.base.SerializerProtocol] for implementing serializers provides a default implementation ([JsonPlusSerializer][langgraph.checkpoint.serde.jsonplus.JsonPlusSerializer]) that handles a wide variety of types, including LangChain and LangGraph primitives, datetimes, enums and more.
#### Serialization with `pickle`
The default serializer, [`JsonPlusSerializer`][langgraph.checkpoint.serde.jsonplus.JsonPlusSerializer], uses ormsgpack and JSON under the hood, which is not suitable for all types of objects.
If you want to fallback to pickle for objects not currently supported by our msgpack encoder (such as Pandas dataframes),
you can use the `pickle_fallback` argument of the `JsonPlusSerializer`:
```python
from langgraph.checkpoint.memory import MemorySaver
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
# ... Define the graph ...
graph.compile(
checkpointer=MemorySaver(serde=JsonPlusSerializer(pickle_fallback=True))
)
```
#### Encryption
Checkpointers can optionally encrypt all persisted state. To enable this, pass an instance of [`EncryptedSerializer`][langgraph.checkpoint.serde.encrypted.EncryptedSerializer] to the `serde` argument of any `BaseCheckpointSaver` implementation. The easiest way to create an encrypted serializer is via [`from_pycryptodome_aes`][langgraph.checkpoint.serde.encrypted.EncryptedSerializer.from_pycryptodome_aes], which reads the AES key from the `LANGGRAPH_AES_KEY` environment variable (or accepts a `key` argument):
```python
import sqlite3
from langgraph.checkpoint.serde.encrypted import EncryptedSerializer
from langgraph.checkpoint.sqlite import SqliteSaver
serde = EncryptedSerializer.from_pycryptodome_aes() # reads LANGGRAPH_AES_KEY
checkpointer = SqliteSaver(sqlite3.connect("checkpoint.db"), serde=serde)
```
```python
from langgraph.checkpoint.serde.encrypted import EncryptedSerializer
from langgraph.checkpoint.postgres import PostgresSaver
serde = EncryptedSerializer.from_pycryptodome_aes()
checkpointer = PostgresSaver.from_conn_string("postgresql://...", serde=serde)
checkpointer.setup()
```
When running on LangGraph Platform, encryption is automatically enabled whenever `LANGGRAPH_AES_KEY` is present, so you only need to provide the environment variable. Other encryption schemes can be used by implementing [`CipherProtocol`][langgraph.checkpoint.serde.base.CipherProtocol] and supplying it to `EncryptedSerializer`.
## Capabilities
### Human-in-the-loop
+1 -1
View File
@@ -94,7 +94,7 @@ def answer_node(state: InputState):
return {"answer": "bye", "question": state["question"]}
# Build the graph with explicit schemas
builder = StateGraph(OverallState, input=InputState, output=OutputState)
builder = StateGraph(OverallState, input_schema=InputState, output_schema=OutputState)
builder.add_node(answer_node)
builder.add_edge(START, "answer_node")
builder.add_edge("answer_node", END)
+3 -2
View File
@@ -59,8 +59,9 @@ The main question when adding subgraphs is how the parent graph and subgraph com
response = model.invoke(state["subgraph_messages"])
return {"subgraph_messages": response}
subgraph_builder = StateGraph(State)
subgraph_builder.add_node(call_model)
subgraph_builder = StateGraph(SubgraphMessagesState)
subgraph_builder.add_node("call_model_from_subgraph", call_model)
subgraph_builder.add_edge(START, "call_model_from_subgraph")
...
# highlight-next-line
subgraph = subgraph_builder.compile()
+9 -9
View File
@@ -439,7 +439,7 @@
},
{
"cell_type": "code",
"execution_count": 6,
"execution_count": null,
"id": "6ec0eb77-874e-443e-8c73-93125b515106",
"metadata": {},
"outputs": [
@@ -478,7 +478,7 @@
"\n",
"\n",
"# Build the graph with input and output schemas specified\n",
"builder = StateGraph(OverallState, input=InputState, output=OutputState)\n",
"builder = StateGraph(OverallState, input_schema=InputState, output_schema=OutputState)\n",
"builder.add_node(answer_node) # Add the answer node\n",
"builder.add_edge(START, \"answer_node\") # Define the starting edge\n",
"builder.add_edge(\"answer_node\", END) # Define the ending edge\n",
@@ -1198,7 +1198,7 @@
"\n",
"There are many use cases where you may wish for your node to have a custom retry policy, for example if you are calling an API, querying a database, or calling an LLM, etc. LangGraph lets you add retry policies to nodes.\n",
"\n",
"To configure a retry policy, pass the `retry` parameter to the [add_node](https://langchain-ai.github.io/langgraph/reference/graphs/#langgraph.graph.state.StateGraph.add_node). The `retry` parameter takes in a `RetryPolicy` named tuple object. Below we instantiate a `RetryPolicy` object with the default parameters and associate it with a node:\n",
"To configure a retry policy, pass the `retry_policy` parameter to the [add_node](https://langchain-ai.github.io/langgraph/reference/graphs/#langgraph.graph.state.StateGraph.add_node). The `retry_policy` parameter takes in a `RetryPolicy` named tuple object. Below we instantiate a `RetryPolicy` object with the default parameters and associate it with a node:\n",
"\n",
"```python\n",
"from langgraph.pregel import RetryPolicy\n",
@@ -1206,7 +1206,7 @@
"builder.add_node(\n",
" \"node_name\",\n",
" node_function,\n",
" retry=RetryPolicy(),\n",
" retry_policy=RetryPolicy(),\n",
")\n",
"```"
]
@@ -1241,7 +1241,7 @@
},
{
"cell_type": "code",
"execution_count": 2,
"execution_count": null,
"id": "ad92598c-b688-42fa-aae0-9de36273d584",
"metadata": {},
"outputs": [],
@@ -1276,9 +1276,9 @@
"builder.add_node(\n",
" \"query_database\",\n",
" query_database,\n",
" retry=RetryPolicy(retry_on=sqlite3.OperationalError),\n",
" retry_policy=RetryPolicy(retry_on=sqlite3.OperationalError),\n",
")\n",
"builder.add_node(\"model\", call_model, retry=RetryPolicy(max_attempts=5))\n",
"builder.add_node(\"model\", call_model, retry_policy=RetryPolicy(max_attempts=5))\n",
"builder.add_edge(START, \"model\")\n",
"builder.add_edge(\"model\", \"query_database\")\n",
"builder.add_edge(\"query_database\", END)\n",
@@ -3416,7 +3416,7 @@
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"display_name": ".venv",
"language": "python",
"name": "python3"
},
@@ -3430,7 +3430,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.10.4"
"version": "3.9.6"
}
},
"nbformat": 4,
+2 -2
View File
@@ -34,7 +34,7 @@ def read_root():
## Configure `langgraph.json`
Add the following to your `langgraph.json` configuration file. Make sure the path points to the `app.py` file you created above.
Add the following to your `langgraph.json` configuration file. Make sure the path points to the FastAPI application instance `app` in the `webapp.py` file you created above.
```json
{
@@ -71,4 +71,4 @@ You can deploy this app as-is to LangGraph Platform or to your self-hosted platf
## Next steps
Now that you've added a custom route to your deployment, you can use this same technique to further customize how your server behaves, such as defining custom [custom middleware](./custom_middleware.md) and [custom lifespan events](./custom_lifespan.md).
Now that you've added a custom route to your deployment, you can use this same technique to further customize how your server behaves, such as defining custom [custom middleware](./custom_middleware.md) and [custom lifespan events](./custom_lifespan.md).
+4 -2
View File
@@ -405,7 +405,7 @@
},
{
"cell_type": "code",
"execution_count": 46,
"execution_count": null,
"id": "1954a5f1-91e4-4b32-9be9-c8bc1cc43cb5",
"metadata": {},
"outputs": [],
@@ -465,7 +465,9 @@
"\n",
"graph_builder = StateGraph(State)\n",
"graph_builder.add_node(\"agent\", agent)\n",
"graph_builder.add_node(\"select_tools\", select_tools, retry=RetryPolicy(max_attempts=3))\n",
"graph_builder.add_node(\n",
" \"select_tools\", select_tools, retry_policy=RetryPolicy(max_attempts=3)\n",
")\n",
"\n",
"tool_node = ToolNode(tools=tools)\n",
"graph_builder.add_node(\"tools\", tool_node)\n",
+2 -2
View File
@@ -1107,10 +1107,10 @@
"source": [
"### Use in production\n",
"\n",
"In production, you would want to use a checkpointer backed by a database:\n",
"In production, you would want to use a store backed by a database:\n",
"\n",
"```python\n",
"from langgraph.checkpoint.postgres import PostgresSaver\n",
"from langgraph.store.postgres import PostgresStore\n",
"\n",
"DB_URI = \"postgresql://postgres:postgres@localhost:5442/postgres?sslmode=disable\"\n",
"# highlight-next-line\n",
+1 -1
View File
@@ -321,7 +321,7 @@ attempts = 0
# The default RetryPolicy is optimized for retrying specific network errors.
retry_policy = RetryPolicy(retry_on=ValueError)
@task(retry=retry_policy)
@task(retry_policy=retry_policy)
def get_info():
global attempts
attempts += 1
+7 -1
View File
@@ -12,12 +12,18 @@
options:
members:
- SerializerProtocol
- CipherProtocol
::: langgraph.checkpoint.serde.jsonplus
options:
members:
- JsonPlusSerializer
::: langgraph.checkpoint.serde.encrypted
options:
members:
- EncryptedSerializer
::: langgraph.checkpoint.memory
::: langgraph.checkpoint.sqlite
@@ -32,4 +38,4 @@
::: langgraph.checkpoint.postgres.aio
options:
members:
- AsyncPostgresSaver
- AsyncPostgresSaver
+1 -1
View File
@@ -22,7 +22,7 @@ Welcome to the LangGraph reference docs! These pages detail the core interfaces
## LangGraph
The core APIs for the LangGraph opens source library.
The core APIs for the LangGraph open source library.
- [Graphs](graphs.md): Main graph abstraction and usage.
- [Functional API](func.md): Functional programming interface for graphs.
@@ -580,9 +580,7 @@
" ]\n",
")\n",
"\n",
"evaluator = prompt | ChatOpenAI(model=\"gpt-4-turbo-preview\").with_structured_output(\n",
" RedTeamingResult, method=\"function_calling\"\n",
")\n",
"evaluator = prompt | ChatOpenAI(model=\"gpt-4o\").with_structured_output(RedTeamingResult)\n",
"\n",
"\n",
"def did_resist(run, example):\n",
+3 -3
View File
@@ -89,7 +89,7 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 2,
"id": "baf669a0-04ee-492d-80d8-8fcb658ed128",
"metadata": {},
"outputs": [],
@@ -313,8 +313,8 @@
"\n",
" builder.add_edge(\"finalizer\", END)\n",
"\n",
" # These functions let the step be used in a\n",
" # StateGraph with 'messages' as the key.\n",
" # These functions let the step be used in a MessageGraph\n",
" # or a StateGraph with 'messages' as the key.\n",
" def encode(x: Union[Sequence[AnyMessage], PromptValue]) -> dict:\n",
" \"\"\"Ensure the input is the correct format.\"\"\"\n",
" if isinstance(x, PromptValue):\n",
@@ -32,7 +32,7 @@ from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
@@ -100,7 +100,16 @@ Add an `entry` point to tell the graph **where to start its work** each time it
graph_builder.add_edge(START, "chatbot")
```
## 5. Compile the graph
## 5. Add an `exit` point
Add an `exit` point to indicate **where the graph should finish execution**. This is helpful for more complex flows, but even in a simple graph like this, adding an end node improves clarity.
```python
graph_builder.add_edge("chatbot", END)
```
This tells the graph to terminate after running the chatbot node.
## 6. Compile the graph
Before running the graph, we'll need to compile it. We can do so by calling `compile()`
on the graph builder. This creates a `CompiledGraph` we can invoke on our state.
@@ -109,7 +118,7 @@ on the graph builder. This creates a `CompiledGraph` we can invoke on our state.
graph = graph_builder.compile()
```
## 6. Visualize the graph (optional)
## 7. Visualize the graph (optional)
You can visualize the graph using the `get_graph` method and one of the "draw" methods, like `draw_ascii` or `draw_png`. The `draw` methods each require additional dependencies.
@@ -126,7 +135,7 @@ except Exception:
![basic chatbot diagram](basic-chatbot.png)
## 7. Run the chatbot
## 8. Run the chatbot
Now run the chatbot!
@@ -171,7 +180,7 @@ from typing import Annotated
from langchain.chat_models import init_chat_model
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
@@ -194,6 +203,7 @@ def chatbot(state: State):
# the node is used.
graph_builder.add_node("chatbot", chatbot)
graph_builder.add_edge(START, "chatbot")
graph_builder.add_edge("chatbot", END)
graph = graph_builder.compile()
```
@@ -164,7 +164,7 @@ llm = init_chat_model("anthropic:claude-3-5-sonnet-latest")
```
-->
```python
```python hl_lines="36 37"
from typing import Annotated
from langchain.chat_models import init_chat_model
@@ -206,4 +206,4 @@ graph = graph_builder.compile(checkpointer=memory)
## Next steps
In the next tutorial, you will [add human-in-the-loop to the chatbot](./4-human-in-the-loop.md) to handle situations where it may need guidance or verification before proceeding.
In the next tutorial, you will [add human-in-the-loop to the chatbot](./4-human-in-the-loop.md) to handle situations where it may need guidance or verification before proceeding.
@@ -471,7 +471,7 @@
"\n",
"_get_pass(\"TAVILY_API_KEY\")\n",
"\n",
"calculate = get_math_tool(ChatOpenAI(model=\"gpt-4-turbo-preview\"))\n",
"calculate = get_math_tool(ChatOpenAI(model=\"gpt-4o\"))\n",
"search = TavilySearchResults(\n",
" max_results=1,\n",
" description='tavily_search_results_json(query=\"the search query\") - a search engine.',\n",
@@ -540,11 +540,11 @@
"name": "stdout",
"output_type": "stream",
"text": [
"================================\u001b[1m System Message \u001b[0m================================\n",
"================================\u001B[1m System Message \u001B[0m================================\n",
"\n",
"Given a user query, create a plan to solve it with the utmost parallelizability. Each plan should comprise an action from the following \u001b[33;1m\u001b[1;3m{num_tools}\u001b[0m types:\n",
"\u001b[33;1m\u001b[1;3m{tool_descriptions}\u001b[0m\n",
"\u001b[33;1m\u001b[1;3m{num_tools}\u001b[0m. join(): Collects and combines results from prior actions.\n",
"Given a user query, create a plan to solve it with the utmost parallelizability. Each plan should comprise an action from the following \u001B[33;1m\u001B[1;3m{num_tools}\u001B[0m types:\n",
"\u001B[33;1m\u001B[1;3m{tool_descriptions}\u001B[0m\n",
"\u001B[33;1m\u001B[1;3m{num_tools}\u001B[0m. join(): Collects and combines results from prior actions.\n",
"\n",
" - An LLM agent is called upon invoking join() to either finalize the user query or wait until the plans are executed.\n",
" - join should always be the last action in the plan, and will be called in two scenarios:\n",
@@ -561,11 +561,11 @@
" - Only use the provided action types. If a query cannot be addressed using these, invoke the join action for the next steps.\n",
" - Never introduce new actions other than the ones provided.\n",
"\n",
"=============================\u001b[1m Messages Placeholder \u001b[0m=============================\n",
"=============================\u001B[1m Messages Placeholder \u001B[0m=============================\n",
"\n",
"\u001b[33;1m\u001b[1;3m{messages}\u001b[0m\n",
"\u001B[33;1m\u001B[1;3m{messages}\u001B[0m\n",
"\n",
"================================\u001b[1m System Message \u001b[0m================================\n",
"================================\u001B[1m System Message \u001B[0m================================\n",
"\n",
"Remember, ONLY respond with the task list in the correct format! E.g.:\n",
"idx. tool(arg_name=args)\n",
@@ -1030,7 +1030,7 @@
"joiner_prompt = hub.pull(\"wfh/llm-compiler-joiner\").partial(\n",
" examples=\"\"\n",
") # You can optionally add examples\n",
"llm = ChatOpenAI(model=\"gpt-4-turbo-preview\")\n",
"llm = ChatOpenAI(model=\"gpt-4o\")\n",
"\n",
"runnable = joiner_prompt | llm.with_structured_output(\n",
" JoinOutputs, method=\"function_calling\"\n",
File diff suppressed because one or more lines are too long
@@ -135,7 +135,6 @@
"metadata": {},
"outputs": [],
"source": [
"from langchain import hub\n",
"from langchain_openai import ChatOpenAI\n",
"\n",
"from langgraph.prebuilt import create_react_agent\n",
@@ -90,7 +90,11 @@
"id": "9ac1c2cd-81fb-40eb-8ba1-e9197800cba6",
"metadata": {},
"source": [
"## Create Index"
"## Create Index\n",
"\n",
"Set up a vector database using OpenAI Embeddings and the Chroma vector database. \n",
"Input URLs of blog posts related to agents, prompt engineering, and large language models (LLMs). \n",
"Generate vector indices for use in Retrieval-Augmented Generation (RAG)."
]
},
{
@@ -159,6 +163,21 @@
"</div>"
]
},
{
"cell_type": "markdown",
"id": "6cdd5ac0-fa18-4ee9-8051-062a0c56268f",
"metadata": {},
"source": [
"### Router for Query Analysis\n",
"\n",
"Lets start with Routing. First, assign the query analysis to the LLM.\n",
"\n",
"Create a RouteQuery data model and specify it in a structured format for the LLM. The decision for routing should be embedded in the prompt. You need to clearly define which parts of the document should be directed to RAG based on the topic.\n",
"\n",
"While you could automate this process by having the LLM summarize the RAG documents again, its more cost-effective to manually manage this when dealing with large documents, as automation could become expensive.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": 4,
@@ -219,6 +238,18 @@
"print(question_router.invoke({\"question\": \"What are the types of agent memory?\"}))"
]
},
{
"cell_type": "markdown",
"id": "cb248c94-0b0c-4d86-8565-32aa8d7424e4",
"metadata": {},
"source": [
"### Retrieval Grader\n",
"\n",
"After performing retrieval, evaluate the results. Although you initially decided to use RAG based on the query, the retrieved documents might not be satisfactory. Assess whether the retrieved documents are sufficiently relevant to the query.\n",
"\n",
"For this, rely on the LLM to evaluate the relevance, providing a binary yes or no decision."
]
},
{
"cell_type": "code",
"execution_count": 5,
@@ -309,6 +340,17 @@
"print(generation)"
]
},
{
"cell_type": "markdown",
"id": "cb0ab54a-4a4f-45fa-b1c5-cea1bf4c59d5",
"metadata": {},
"source": [
"### Hallucination Grader\n",
"\n",
"Verify if the LLM produced any hallucinations by comparing its output to the retrieved facts. \n",
"Provide the LLMs evaluation in a binary yes or no format.\n"
]
},
{
"cell_type": "code",
"execution_count": 7,
@@ -357,6 +399,16 @@
"hallucination_grader.invoke({\"documents\": docs, \"generation\": generation})"
]
},
{
"cell_type": "markdown",
"id": "4f58502a-c25f-4d80-a402-5583b0cd3e41",
"metadata": {},
"source": [
"### Answer Grader\n",
"\n",
"Evaluate the answer finally."
]
},
{
"cell_type": "code",
"execution_count": 8,
@@ -405,6 +457,18 @@
"answer_grader.invoke({\"question\": question, \"generation\": generation})"
]
},
{
"cell_type": "markdown",
"id": "af77946c-2646-4039-86b0-e2fde1ab7459",
"metadata": {},
"source": [
"### Question Rewriting\n",
"\n",
"The original question from user was directly used in RAG. \n",
"However, the users question might not be in a form suitable for RAG. \n",
"To improve retrieval, rephrase the question to ensure it aligns better with vector similarity search."
]
},
{
"cell_type": "code",
"execution_count": 9,
@@ -450,7 +514,9 @@
"id": "d07c0b31-b919-4498-869f-9673125c2473",
"metadata": {},
"source": [
"## Web Search Tool"
"## Web Search Tool\n",
"\n",
"Use Tavily Search tool to get information from the web."
]
},
{
+1 -1
View File
@@ -185,7 +185,7 @@
"\n",
"\n",
"# LLM with function call\n",
"llm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)\n",
"llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n",
"structured_llm_grader = llm.with_structured_output(GradeDocuments)\n",
"\n",
"# Prompt\n",
+1 -1
View File
@@ -1758,7 +1758,7 @@
"id": "4eb67198-c84f-458b-8baf-783d7246dddc",
"metadata": {},
"source": [
"Let's let the agent try again. Call `stream` with `None` to just use the inputs loaded from the memory. We will skip our human review for the next few attempats\n",
"Let's let the agent try again. Call `stream` with `None` to just use the inputs loaded from the memory. We will skip our human review for the next few attempts\n",
"to see if it can correct itself."
]
},
+1 -1
View File
@@ -648,7 +648,7 @@ With orchestrator-worker, an orchestrator breaks down a task and delegates each
Because orchestrator-worker workflows are common, LangGraph **has the `Send` API to support this**. It lets you dynamically create worker nodes and send each one a specific input. Each worker has its own state, and all worker outputs are written to a *shared state key* that is accessible to the orchestrator graph. This gives the orchestrator access to all worker output and allows it to synthesize them into a final output. As you can see below, we iterate over a list of sections and `Send` each to a worker node. See further documentation [here](https://langchain-ai.github.io/langgraph/how-tos/map-reduce/) and [here](https://langchain-ai.github.io/langgraph/concepts/low_level/#send).
```python
from langgraph.constants import Send
from langgraph.types import Send
# Graph state
+1 -29
View File
@@ -179,6 +179,7 @@ nav:
- cloud/how-tos/studio/manage_assistants.md
- cloud/how-tos/threads_studio.md
- cloud/how-tos/iterate_graph_studio.md
- cloud/how-tos/studio/run_evals.md
- cloud/how-tos/clone_traces_studio.md
- cloud/how-tos/datasets_studio.md
- LangGraph SDK: concepts/sdk.md
@@ -364,16 +365,6 @@ markdown_extensions:
hooks:
- _scripts/notebook_hooks.py
extra:
consent:
title: Cookie consent
actions:
- accept
- reject
description: >-
We use cookies to recognize your repeated visits and preferences, as well
as to measure the effectiveness of our documentation and whether users
find what they're searching for. <strong>Clicking "Accept" makes our
documentation better. Thank you!</strong> ❤️
social:
- icon: fontawesome/brands/js
link: https://langchain-ai.github.io/langgraphjs/
@@ -381,25 +372,6 @@ extra:
link: https://github.com/langchain-ai/langgraph
- icon: fontawesome/brands/twitter
link: https://twitter.com/LangChainAI
analytics:
provider: google
property: G-G8X6ELZYE0
feedback:
title: Was this page helpful?
ratings:
- icon: material/emoticon-happy-outline
name: This page was helpful
data: 1
note: >-
Thanks for your feedback!
- icon: material/emoticon-sad-outline
name: This page could be improved
data: 0
note: >-
Thanks for your feedback! Please help us improve this page by adding to the discussion below.
shared_analytics:
provider: google
property: G-47WX3HKKY2
validation:
# https://www.mkdocs.org/user-guide/configuration/
# We are still raising for omitted files because they determine the breadcrumbs for pages.
+41
View File
@@ -0,0 +1,41 @@
.lang-python,
.lang-javascript {
display: none;
}
.language-switcher-global {
display: flex;
align-items: center;
padding-left: 0.5rem;
margin-right: 0.5rem;
}
/* Style the select to match the header */
.language-switcher-global select {
appearance: none;
font: inherit;
border: none;
padding: 0.25rem 0.6rem;
cursor: pointer;
outline: none;
font-weight: bolder;
}
/* Hover/focus effect */
.language-switcher-global select:hover,
.language-switcher-global select:focus {
text-decoration: underline;
}
/* Theme-specific overrides */
html[data-md-color-scheme="default"] .language-switcher-global select,
html[data-md-color-scheme="default"] .language-switcher-global option {
color: #333;
background-color: transparent;
}
html[data-md-color-scheme="slate"] .language-switcher-global select,
html[data-md-color-scheme="slate"] .language-switcher-global option {
color: #eee;
background-color: transparent;
}
+38
View File
@@ -0,0 +1,38 @@
function applyLanguageSwitching() {
const selector = document.getElementById("global-language-selector");
const langBlocks = {
python: document.querySelectorAll(".lang-python"),
javascript: document.querySelectorAll(".lang-javascript"),
};
const setLanguage = (lang) => {
for (const [key, blocks] of Object.entries(langBlocks)) {
blocks.forEach((block) => {
block.style.display = key === lang ? "block" : "none";
});
}
localStorage.setItem("preferredLang", lang);
};
const saved = localStorage.getItem("preferredLang") || "python";
if (selector) {
selector.value = saved;
selector.addEventListener("change", (e) => setLanguage(e.target.value));
}
setLanguage(saved);
}
// Run on initial load
document.addEventListener("DOMContentLoaded", applyLanguageSwitching);
// Re-run after client-side navigation (MkDocs Material)
document.addEventListener("pjax:success", applyLanguageSwitching);
// Optional: observe DOM changes (e.g., for late-loaded content)
if (window.MutationObserver) {
const observer = new MutationObserver(() => applyLanguageSwitching());
observer.observe(document.body, { childList: true, subtree: true });
}
@@ -0,0 +1,6 @@
<div class="md-header__button language-switcher-global" title="Select Language">
<select id="global-language-selector" aria-label="Select Language">
<option value="python">🐍 Python</option>
<option value="javascript">⚡️ JavaScript</option>
</select>
</div>
@@ -0,0 +1,22 @@
from _scripts.notebook_hooks import _apply_conditional_rendering
CONDITIONAL_RENDERING = """
above
:::js
js-content
:::
between
:::python
python-content
:::
below
"""
def test_conditional_rendering() -> None:
"""Test logic for conditional rendering of content."""
output = _apply_conditional_rendering(CONDITIONAL_RENDERING, "js")
assert output.strip() == "above\njs-content\n\nbetween\n\nbelow"
output = _apply_conditional_rendering(CONDITIONAL_RENDERING, "python")
assert output.strip() == "above\n\nbetween\npython-content\n\nbelow"
Generated
+3060 -3059
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -184,7 +184,7 @@
"\n",
"\n",
"# LLM with function call\n",
"llm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)\n",
"llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n",
"structured_llm_router = llm.with_structured_output(RouteQuery)\n",
"\n",
"# Prompt\n",
@@ -235,7 +235,7 @@
"\n",
"\n",
"# LLM with function call\n",
"llm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)\n",
"llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n",
"structured_llm_grader = llm.with_structured_output(GradeDocuments)\n",
"\n",
"# Prompt\n",
@@ -328,7 +328,7 @@
"\n",
"\n",
"# LLM with function call\n",
"llm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)\n",
"llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n",
"structured_llm_grader = llm.with_structured_output(GradeHallucinations)\n",
"\n",
"# Prompt\n",
@@ -376,7 +376,7 @@
"\n",
"\n",
"# LLM with function call\n",
"llm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)\n",
"llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n",
"structured_llm_grader = llm.with_structured_output(GradeAnswer)\n",
"\n",
"# Prompt\n",
+4 -4
View File
@@ -200,11 +200,11 @@
"output_type": "stream",
"text": [
"********************Prompt[rlm/rag-prompt]********************\n",
"================================\u001b[1m Human Message \u001b[0m=================================\n",
"================================\u001B[1m Human Message \u001B[0m=================================\n",
"\n",
"You are an assistant for question-answering tasks. Use the following pieces of retrieved context to answer the question. If you don't know the answer, just say that you don't know. Use three sentences maximum and keep the answer concise.\n",
"Question: \u001b[33;1m\u001b[1;3m{question}\u001b[0m \n",
"Context: \u001b[33;1m\u001b[1;3m{context}\u001b[0m \n",
"Question: \u001B[33;1m\u001B[1;3m{question}\u001B[0m \n",
"Context: \u001B[33;1m\u001B[1;3m{context}\u001B[0m \n",
"Answer:\n"
]
}
@@ -244,7 +244,7 @@
" binary_score: str = Field(description=\"Relevance score 'yes' or 'no'\")\n",
"\n",
" # LLM\n",
" model = ChatOpenAI(temperature=0, model=\"gpt-4-0125-preview\", streaming=True)\n",
" model = ChatOpenAI(temperature=0, model=\"gpt-4o\", streaming=True)\n",
"\n",
" # LLM with tool and validation\n",
" llm_with_tool = model.with_structured_output(grade)\n",
+1 -1
View File
@@ -171,7 +171,7 @@
"\n",
"\n",
"# LLM with function call\n",
"llm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)\n",
"llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n",
"structured_llm_grader = llm.with_structured_output(GradeDocuments)\n",
"\n",
"# Prompt\n",
+3 -3
View File
@@ -191,7 +191,7 @@
"\n",
"\n",
"# LLM with function call\n",
"llm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)\n",
"llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n",
"structured_llm_grader = llm.with_structured_output(GradeDocuments)\n",
"\n",
"# Prompt\n",
@@ -284,7 +284,7 @@
"\n",
"\n",
"# LLM with function call\n",
"llm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)\n",
"llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n",
"structured_llm_grader = llm.with_structured_output(GradeHallucinations)\n",
"\n",
"# Prompt\n",
@@ -332,7 +332,7 @@
"\n",
"\n",
"# LLM with function call\n",
"llm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)\n",
"llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n",
"structured_llm_grader = llm.with_structured_output(GradeAnswer)\n",
"\n",
"# Prompt\n",
@@ -33,7 +33,9 @@
"id": "a384cc48-0425-4e8f-aafc-cfb8e56025c9",
"metadata": {},
"outputs": [],
"source": ["%pip install -qU langchain-pinecone langchain-openai langchainhub langgraph"]
"source": [
"%pip install -qU langchain-pinecone langchain-openai langchainhub langgraph"
]
},
{
"cell_type": "markdown",
@@ -51,7 +53,9 @@
"id": "ccc3dae5-1df6-48ca-af8a-50f0e6128876",
"metadata": {},
"outputs": [],
"source": ["import os\n\nos.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\nos.environ[\"LANGCHAIN_ENDPOINT\"] = \"https://api.smith.langchain.com\"\nos.environ[\"LANGCHAIN_API_KEY\"] = \"<your-api-key>\""]
"source": [
"import os\n\nos.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\nos.environ[\"LANGCHAIN_ENDPOINT\"] = \"https://api.smith.langchain.com\"\nos.environ[\"LANGCHAIN_API_KEY\"] = \"<your-api-key>\""
]
},
{
"cell_type": "code",
@@ -59,7 +63,9 @@
"id": "88637820",
"metadata": {},
"outputs": [],
"source": ["import os\n\nos.environ[\"LANGCHAIN_PROJECT\"] = \"pinecone-devconnect\""]
"source": [
"import os\n\nos.environ[\"LANGCHAIN_PROJECT\"] = \"pinecone-devconnect\""
]
},
{
"cell_type": "markdown",
@@ -77,7 +83,9 @@
"id": "565a6d44-2c9f-4fff-b1ec-eea05df9350d",
"metadata": {},
"outputs": [],
"source": ["from langchain_openai import OpenAIEmbeddings\nfrom langchain_pinecone import PineconeVectorStore\n\n# use pinecone movies database\n\n# Add to vectorDB\nvectorstore = PineconeVectorStore(\n embedding=OpenAIEmbeddings(),\n index_name=\"sample-movies\",\n text_key=\"summary\",\n)\nretriever = vectorstore.as_retriever()"]
"source": [
"from langchain_openai import OpenAIEmbeddings\nfrom langchain_pinecone import PineconeVectorStore\n\n# use pinecone movies database\n\n# Add to vectorDB\nvectorstore = PineconeVectorStore(\n embedding=OpenAIEmbeddings(),\n index_name=\"sample-movies\",\n text_key=\"summary\",\n)\nretriever = vectorstore.as_retriever()"
]
},
{
"cell_type": "code",
@@ -104,7 +112,9 @@
]
}
],
"source": ["docs = retriever.invoke(\"James Cameron\")\nfor doc in docs:\n print(\"# \" + doc.metadata[\"title\"])\n print(doc.page_content)\n print()"]
"source": [
"docs = retriever.invoke(\"James Cameron\")\nfor doc in docs:\n print(\"# \" + doc.metadata[\"title\"])\n print(doc.page_content)\n print()"
]
},
{
"cell_type": "markdown",
@@ -120,7 +130,32 @@
"id": "1fafad21-60cc-483e-92a3-6a7edb1838e3",
"metadata": {},
"outputs": [],
"source": ["### Retrieval Grader\n\nfrom langchain import hub\nfrom langchain_core.pydantic_v1 import BaseModel, Field\nfrom langchain_openai import ChatOpenAI\n\n\n# Data model\nclass GradeDocuments(BaseModel):\n \"\"\"Binary score for relevance check on retrieved documents.\"\"\"\n\n binary_score: str = Field(\n description=\"Documents are relevant to the question, 'yes' or 'no'\"\n )\n\n\n# https://smith.langchain.com/hub/efriis/self-rag-retrieval-grader\ngrade_prompt = hub.pull(\"efriis/self-rag-retrieval-grader\")\n\n# LLM with function call\nllm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)\nstructured_llm_grader = llm.with_structured_output(GradeDocuments)\n\nretrieval_grader = grade_prompt | structured_llm_grader"]
"source": [
"### Retrieval Grader\n",
"\n",
"from langchain import hub\n",
"from langchain_core.pydantic_v1 import BaseModel, Field\n",
"from langchain_openai import ChatOpenAI\n",
"\n",
"\n",
"# Data model\n",
"class GradeDocuments(BaseModel):\n",
" \"\"\"Binary score for relevance check on retrieved documents.\"\"\"\n",
"\n",
" binary_score: str = Field(\n",
" description=\"Documents are relevant to the question, 'yes' or 'no'\"\n",
" )\n",
"\n",
"\n",
"# https://smith.langchain.com/hub/efriis/self-rag-retrieval-grader\n",
"grade_prompt = hub.pull(\"efriis/self-rag-retrieval-grader\")\n",
"\n",
"# LLM with function call\n",
"llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n",
"structured_llm_grader = llm.with_structured_output(GradeDocuments)\n",
"\n",
"retrieval_grader = grade_prompt | structured_llm_grader"
]
},
{
"cell_type": "code",
@@ -137,7 +172,9 @@
]
}
],
"source": ["# Test the retrieval grader\nquestion = \"movies starring jason momoa\"\ndocs = retriever.invoke(question)\ndoc_txt = docs[0].page_content\nprint(doc_txt)\nprint(retrieval_grader.invoke({\"question\": question, \"document\": doc_txt}))"]
"source": [
"# Test the retrieval grader\nquestion = \"movies starring jason momoa\"\ndocs = retriever.invoke(question)\ndoc_txt = docs[0].page_content\nprint(doc_txt)\nprint(retrieval_grader.invoke({\"question\": question, \"document\": doc_txt}))"
]
},
{
"cell_type": "markdown",
@@ -163,7 +200,9 @@
]
}
],
"source": ["### Generate\n\nfrom langchain import hub\nfrom langchain_core.output_parsers import StrOutputParser\n\n# Prompt\nprompt = hub.pull(\"rlm/rag-prompt\")\n\n# LLM\nllm = ChatOpenAI(model_name=\"gpt-3.5-turbo\", temperature=0)\n\n# Chain\nrag_chain = prompt | llm | StrOutputParser()\n\n# Run\ngeneration = rag_chain.invoke({\"context\": docs, \"question\": question})\nprint(generation)"]
"source": [
"### Generate\n\nfrom langchain import hub\nfrom langchain_core.output_parsers import StrOutputParser\n\n# Prompt\nprompt = hub.pull(\"rlm/rag-prompt\")\n\n# LLM\nllm = ChatOpenAI(model_name=\"gpt-3.5-turbo\", temperature=0)\n\n# Chain\nrag_chain = prompt | llm | StrOutputParser()\n\n# Run\ngeneration = rag_chain.invoke({\"context\": docs, \"question\": question})\nprint(generation)"
]
},
{
"cell_type": "code",
@@ -189,7 +228,30 @@
"output_type": "execute_result"
}
],
"source": ["### Hallucination Grader\n\n\n# Data model\nclass GradeHallucinations(BaseModel):\n \"\"\"Binary score for hallucination present in generation answer.\"\"\"\n\n binary_score: str = Field(\n description=\"Answer is grounded in the facts, 'yes' or 'no'\"\n )\n\n\n# LLM with function call\nllm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)\nstructured_llm_grader = llm.with_structured_output(GradeHallucinations)\n\n# https://smith.langchain.com/hub/efriis/self-rag-hallucination-grader\nhallucination_prompt = hub.pull(\"efriis/self-rag-hallucination-grader\")\n\nhallucination_grader = hallucination_prompt | structured_llm_grader\nprint(generation)\nhallucination_grader.invoke({\"documents\": docs, \"generation\": generation})"]
"source": [
"### Hallucination Grader\n",
"\n",
"\n",
"# Data model\n",
"class GradeHallucinations(BaseModel):\n",
" \"\"\"Binary score for hallucination present in generation answer.\"\"\"\n",
"\n",
" binary_score: str = Field(\n",
" description=\"Answer is grounded in the facts, 'yes' or 'no'\"\n",
" )\n",
"\n",
"\n",
"# LLM with function call\n",
"llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n",
"structured_llm_grader = llm.with_structured_output(GradeHallucinations)\n",
"\n",
"# https://smith.langchain.com/hub/efriis/self-rag-hallucination-grader\n",
"hallucination_prompt = hub.pull(\"efriis/self-rag-hallucination-grader\")\n",
"\n",
"hallucination_grader = hallucination_prompt | structured_llm_grader\n",
"print(generation)\n",
"hallucination_grader.invoke({\"documents\": docs, \"generation\": generation})"
]
},
{
"cell_type": "code",
@@ -216,7 +278,31 @@
"output_type": "execute_result"
}
],
"source": ["### Answer Grader\n\n\n# Data model\nclass GradeAnswer(BaseModel):\n \"\"\"Binary score to assess answer addresses question.\"\"\"\n\n binary_score: str = Field(\n description=\"Answer addresses the question, 'yes' or 'no'\"\n )\n\n\n# LLM with function call\nllm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)\nstructured_llm_grader = llm.with_structured_output(GradeAnswer)\n\n# Prompt\nanswer_prompt = hub.pull(\"efriis/self-rag-answer-grader\")\n\nanswer_grader = answer_prompt | structured_llm_grader\nprint(question)\nprint(generation)\nanswer_grader.invoke({\"question\": question, \"generation\": generation})"]
"source": [
"### Answer Grader\n",
"\n",
"\n",
"# Data model\n",
"class GradeAnswer(BaseModel):\n",
" \"\"\"Binary score to assess answer addresses question.\"\"\"\n",
"\n",
" binary_score: str = Field(\n",
" description=\"Answer addresses the question, 'yes' or 'no'\"\n",
" )\n",
"\n",
"\n",
"# LLM with function call\n",
"llm = ChatOpenAI(model=\"gpt-4o-mini\", temperature=0)\n",
"structured_llm_grader = llm.with_structured_output(GradeAnswer)\n",
"\n",
"# Prompt\n",
"answer_prompt = hub.pull(\"efriis/self-rag-answer-grader\")\n",
"\n",
"answer_grader = answer_prompt | structured_llm_grader\n",
"print(question)\n",
"print(generation)\n",
"answer_grader.invoke({\"question\": question, \"generation\": generation})"
]
},
{
"cell_type": "code",
@@ -242,7 +328,9 @@
"output_type": "execute_result"
}
],
"source": ["### Question Re-writer\n\n# LLM\nllm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)\n\n# Prompt\nre_write_prompt = hub.pull(\"efriis/self-rag-question-rewriter\")\n\nquestion_rewriter = re_write_prompt | llm | StrOutputParser()\nprint(question)\nquestion_rewriter.invoke({\"question\": question})"]
"source": [
"### Question Re-writer\n\n# LLM\nllm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)\n\n# Prompt\nre_write_prompt = hub.pull(\"efriis/self-rag-question-rewriter\")\n\nquestion_rewriter = re_write_prompt | llm | StrOutputParser()\nprint(question)\nquestion_rewriter.invoke({\"question\": question})"
]
},
{
"cell_type": "markdown",
@@ -262,7 +350,9 @@
"id": "f1617e9e-66a8-4c1a-a1fe-cc936284c085",
"metadata": {},
"outputs": [],
"source": ["from typing import List\n\nfrom typing_extensions import TypedDict\n\n\nclass GraphState(TypedDict):\n \"\"\"\n Represents the state of our graph.\n\n Attributes:\n question: question\n generation: LLM generation\n documents: list of documents\n \"\"\"\n\n question: str\n generation: str\n documents: List[str]"]
"source": [
"from typing import List\n\nfrom typing_extensions import TypedDict\n\n\nclass GraphState(TypedDict):\n \"\"\"\n Represents the state of our graph.\n\n Attributes:\n question: question\n generation: LLM generation\n documents: list of documents\n \"\"\"\n\n question: str\n generation: str\n documents: List[str]"
]
},
{
"cell_type": "code",
@@ -270,7 +360,9 @@
"id": "add509d8-6682-4127-8d95-13dd37d79702",
"metadata": {},
"outputs": [],
"source": ["### Nodes\n\n\ndef retrieve(state):\n \"\"\"\n Retrieve documents\n\n Args:\n state (dict): The current graph state\n\n Returns:\n state (dict): New key added to state, documents, that contains retrieved documents\n \"\"\"\n print(\"---RETRIEVE---\")\n question = state[\"question\"]\n\n # Retrieval\n documents = retriever.invoke(question)\n return {\"documents\": documents, \"question\": question}\n\n\ndef generate(state):\n \"\"\"\n Generate answer\n\n Args:\n state (dict): The current graph state\n\n Returns:\n state (dict): New key added to state, generation, that contains LLM generation\n \"\"\"\n print(\"---GENERATE---\")\n question = state[\"question\"]\n documents = state[\"documents\"]\n\n # RAG generation\n generation = rag_chain.invoke({\"context\": documents, \"question\": question})\n return {\"documents\": documents, \"question\": question, \"generation\": generation}\n\n\ndef grade_documents(state):\n \"\"\"\n Determines whether the retrieved documents are relevant to the question.\n\n Args:\n state (dict): The current graph state\n\n Returns:\n state (dict): Updates documents key with only filtered relevant documents\n \"\"\"\n\n print(\"---CHECK DOCUMENT RELEVANCE TO QUESTION---\")\n question = state[\"question\"]\n documents = state[\"documents\"]\n\n # Score each doc\n filtered_docs = []\n for d in documents:\n score = retrieval_grader.invoke(\n {\"question\": question, \"document\": d.page_content}\n )\n grade = score.binary_score\n if grade == \"yes\":\n print(\"---GRADE: DOCUMENT RELEVANT---\")\n filtered_docs.append(d)\n else:\n print(\"---GRADE: DOCUMENT NOT RELEVANT---\")\n continue\n return {\"documents\": filtered_docs, \"question\": question}\n\n\ndef transform_query(state):\n \"\"\"\n Transform the query to produce a better question.\n\n Args:\n state (dict): The current graph state\n\n Returns:\n state (dict): Updates question key with a re-phrased question\n \"\"\"\n\n print(\"---TRANSFORM QUERY---\")\n question = state[\"question\"]\n documents = state[\"documents\"]\n\n # Re-write question\n better_question = question_rewriter.invoke({\"question\": question})\n return {\"documents\": documents, \"question\": better_question}"]
"source": [
"### Nodes\n\n\ndef retrieve(state):\n \"\"\"\n Retrieve documents\n\n Args:\n state (dict): The current graph state\n\n Returns:\n state (dict): New key added to state, documents, that contains retrieved documents\n \"\"\"\n print(\"---RETRIEVE---\")\n question = state[\"question\"]\n\n # Retrieval\n documents = retriever.invoke(question)\n return {\"documents\": documents, \"question\": question}\n\n\ndef generate(state):\n \"\"\"\n Generate answer\n\n Args:\n state (dict): The current graph state\n\n Returns:\n state (dict): New key added to state, generation, that contains LLM generation\n \"\"\"\n print(\"---GENERATE---\")\n question = state[\"question\"]\n documents = state[\"documents\"]\n\n # RAG generation\n generation = rag_chain.invoke({\"context\": documents, \"question\": question})\n return {\"documents\": documents, \"question\": question, \"generation\": generation}\n\n\ndef grade_documents(state):\n \"\"\"\n Determines whether the retrieved documents are relevant to the question.\n\n Args:\n state (dict): The current graph state\n\n Returns:\n state (dict): Updates documents key with only filtered relevant documents\n \"\"\"\n\n print(\"---CHECK DOCUMENT RELEVANCE TO QUESTION---\")\n question = state[\"question\"]\n documents = state[\"documents\"]\n\n # Score each doc\n filtered_docs = []\n for d in documents:\n score = retrieval_grader.invoke(\n {\"question\": question, \"document\": d.page_content}\n )\n grade = score.binary_score\n if grade == \"yes\":\n print(\"---GRADE: DOCUMENT RELEVANT---\")\n filtered_docs.append(d)\n else:\n print(\"---GRADE: DOCUMENT NOT RELEVANT---\")\n continue\n return {\"documents\": filtered_docs, \"question\": question}\n\n\ndef transform_query(state):\n \"\"\"\n Transform the query to produce a better question.\n\n Args:\n state (dict): The current graph state\n\n Returns:\n state (dict): Updates question key with a re-phrased question\n \"\"\"\n\n print(\"---TRANSFORM QUERY---\")\n question = state[\"question\"]\n documents = state[\"documents\"]\n\n # Re-write question\n better_question = question_rewriter.invoke({\"question\": question})\n return {\"documents\": documents, \"question\": better_question}"
]
},
{
"cell_type": "code",
@@ -278,7 +370,9 @@
"id": "09fc91b4",
"metadata": {},
"outputs": [],
"source": ["### Edges\n\n\ndef decide_to_generate(state):\n \"\"\"\n Determines whether to generate an answer, or re-generate a question.\n\n Args:\n state (dict): The current graph state\n\n Returns:\n str: Binary decision for next node to call\n \"\"\"\n\n print(\"---ASSESS GRADED DOCUMENTS---\")\n state[\"question\"]\n filtered_documents = state[\"documents\"]\n\n if not filtered_documents:\n # All documents have been filtered check_relevance\n # We will re-generate a new query\n print(\n \"---DECISION: ALL DOCUMENTS ARE NOT RELEVANT TO QUESTION, TRANSFORM QUERY---\"\n )\n return \"transform_query\"\n else:\n # We have relevant documents, so generate answer\n print(\"---DECISION: GENERATE---\")\n return \"generate\"\n\n\ndef grade_generation_v_documents_and_question(state):\n \"\"\"\n Determines whether the generation is grounded in the document and answers question.\n\n Args:\n state (dict): The current graph state\n\n Returns:\n str: Decision for next node to call\n \"\"\"\n\n print(\"---CHECK HALLUCINATIONS---\")\n question = state[\"question\"]\n documents = state[\"documents\"]\n generation = state[\"generation\"]\n\n score = hallucination_grader.invoke(\n {\"documents\": documents, \"generation\": generation}\n )\n grade = score.binary_score\n\n # Check hallucination\n if grade == \"yes\":\n print(\"---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---\")\n # Check question-answering\n print(\"---GRADE GENERATION vs QUESTION---\")\n score = answer_grader.invoke({\"question\": question, \"generation\": generation})\n grade = score.binary_score\n if grade == \"yes\":\n print(\"---DECISION: GENERATION ADDRESSES QUESTION---\")\n return \"useful\"\n else:\n print(\"---DECISION: GENERATION DOES NOT ADDRESS QUESTION---\")\n return \"not useful\"\n else:\n pprint(\"---DECISION: GENERATION IS NOT GROUNDED IN DOCUMENTS, RE-TRY---\")\n return \"not supported\""]
"source": [
"### Edges\n\n\ndef decide_to_generate(state):\n \"\"\"\n Determines whether to generate an answer, or re-generate a question.\n\n Args:\n state (dict): The current graph state\n\n Returns:\n str: Binary decision for next node to call\n \"\"\"\n\n print(\"---ASSESS GRADED DOCUMENTS---\")\n state[\"question\"]\n filtered_documents = state[\"documents\"]\n\n if not filtered_documents:\n # All documents have been filtered check_relevance\n # We will re-generate a new query\n print(\n \"---DECISION: ALL DOCUMENTS ARE NOT RELEVANT TO QUESTION, TRANSFORM QUERY---\"\n )\n return \"transform_query\"\n else:\n # We have relevant documents, so generate answer\n print(\"---DECISION: GENERATE---\")\n return \"generate\"\n\n\ndef grade_generation_v_documents_and_question(state):\n \"\"\"\n Determines whether the generation is grounded in the document and answers question.\n\n Args:\n state (dict): The current graph state\n\n Returns:\n str: Decision for next node to call\n \"\"\"\n\n print(\"---CHECK HALLUCINATIONS---\")\n question = state[\"question\"]\n documents = state[\"documents\"]\n generation = state[\"generation\"]\n\n score = hallucination_grader.invoke(\n {\"documents\": documents, \"generation\": generation}\n )\n grade = score.binary_score\n\n # Check hallucination\n if grade == \"yes\":\n print(\"---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---\")\n # Check question-answering\n print(\"---GRADE GENERATION vs QUESTION---\")\n score = answer_grader.invoke({\"question\": question, \"generation\": generation})\n grade = score.binary_score\n if grade == \"yes\":\n print(\"---DECISION: GENERATION ADDRESSES QUESTION---\")\n return \"useful\"\n else:\n print(\"---DECISION: GENERATION DOES NOT ADDRESS QUESTION---\")\n return \"not useful\"\n else:\n pprint(\"---DECISION: GENERATION IS NOT GROUNDED IN DOCUMENTS, RE-TRY---\")\n return \"not supported\""
]
},
{
"cell_type": "markdown",
@@ -331,7 +425,9 @@
]
}
],
"source": ["from pprint import pprint\n\n# Run\ninputs = {\"question\": \"Movies that star Daniel Craig\"}\nfor output in app.stream(inputs):\n for key, value in output.items():\n # Node\n pprint(f\"Node '{key}':\")\n pprint(\"\\n---\\n\")\n\n# Final generation\npprint(value[\"generation\"])"]
"source": [
"from pprint import pprint\n\n# Run\ninputs = {\"question\": \"Movies that star Daniel Craig\"}\nfor output in app.stream(inputs):\n for key, value in output.items():\n # Node\n pprint(f\"Node '{key}':\")\n pprint(\"\\n---\\n\")\n\n# Final generation\npprint(value[\"generation\"])"
]
},
{
"cell_type": "code",
@@ -339,7 +435,9 @@
"id": "4138bc51-8c84-4b8a-8d24-f7f470721f6f",
"metadata": {},
"outputs": [],
"source": ["inputs = {\"question\": \"Which movies are about aliens?\"}\nfor output in app.stream(inputs):\n for key, value in output.items():\n # Node\n pprint(f\"Node '{key}':\")\n pprint(\"\\n---\\n\")\n\n# Final generation\npprint(value[\"generation\"])"]
"source": [
"inputs = {\"question\": \"Which movies are about aliens?\"}\nfor output in app.stream(inputs):\n for key, value in output.items():\n # Node\n pprint(f\"Node '{key}':\")\n pprint(\"\\n---\\n\")\n\n# Final generation\npprint(value[\"generation\"])"
]
},
{
"cell_type": "code",
@@ -347,7 +445,9 @@
"id": "42369ab8-322d-434a-b5dd-2266e4cb2903",
"metadata": {},
"outputs": [],
"source": [""]
"source": [
""
]
}
],
"metadata": {
+14
View File
@@ -13,6 +13,20 @@ By default `langgraph-checkpoint-postgres` installs `psycopg` (Psycopg 3) withou
> [!IMPORTANT]
> When manually creating Postgres connections and passing them to `PostgresSaver` or `AsyncPostgresSaver`, make sure to include `autocommit=True` and `row_factory=dict_row` (`from psycopg.rows import dict_row`). See a full example in this [how-to guide](https://langchain-ai.github.io/langgraph/how-tos/persistence_postgres/).
>
> **Why these parameters are required:**
> - `autocommit=True`: Required for the `.setup()` method to properly commit the checkpoint tables to the database. Without this, table creation may not be persisted.
> - `row_factory=dict_row`: Required because the PostgresSaver implementation accesses database rows using dictionary-style syntax (e.g., `row["column_name"]`). The default `tuple_row` factory returns tuples that only support index-based access (e.g., `row[0]`), which will cause `TypeError` exceptions when the checkpointer tries to access columns by name.
>
> **Example of incorrect usage:**
> ```python
> # ❌ This will fail with TypeError during checkpointer operations
> with psycopg.connect(DB_URI) as conn: # Missing autocommit=True and row_factory=dict_row
> checkpointer = PostgresSaver(conn)
> checkpointer.setup() # May not persist tables properly
> # Any operation that reads from database will fail with:
> # TypeError: tuple indices must be integers or slices, not str
> ```
```python
from langgraph.checkpoint.postgres import PostgresSaver
@@ -1,8 +1,10 @@
from __future__ import annotations
import threading
from collections import defaultdict
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from typing import Any, Optional
from typing import Any
from langchain_core.runnables import RunnableConfig
from psycopg import Capabilities, Connection, Cursor, Pipeline
@@ -21,6 +23,7 @@ from langgraph.checkpoint.base import (
)
from langgraph.checkpoint.postgres import _internal
from langgraph.checkpoint.postgres.base import BasePostgresSaver
from langgraph.checkpoint.postgres.shallow import ShallowPostgresSaver
from langgraph.checkpoint.serde.base import SerializerProtocol
Conn = _internal.Conn # For backward compatibility
@@ -34,8 +37,8 @@ class PostgresSaver(BasePostgresSaver):
def __init__(
self,
conn: _internal.Conn,
pipe: Optional[Pipeline] = None,
serde: Optional[SerializerProtocol] = None,
pipe: Pipeline | None = None,
serde: SerializerProtocol | None = None,
) -> None:
super().__init__(serde=serde)
if isinstance(conn, ConnectionPool) and pipe is not None:
@@ -52,7 +55,7 @@ class PostgresSaver(BasePostgresSaver):
@contextmanager
def from_conn_string(
cls, conn_string: str, *, pipeline: bool = False
) -> Iterator["PostgresSaver"]:
) -> Iterator[PostgresSaver]:
"""Create a new PostgresSaver instance from a connection string.
Args:
@@ -99,11 +102,11 @@ class PostgresSaver(BasePostgresSaver):
def list(
self,
config: Optional[RunnableConfig],
config: RunnableConfig | None,
*,
filter: Optional[dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
filter: dict[str, Any] | None = None,
before: RunnableConfig | None = None,
limit: int | None = None,
) -> Iterator[CheckpointTuple]:
"""List checkpoints from the database.
@@ -173,34 +176,9 @@ class PostgresSaver(BasePostgresSaver):
value["channel_values"],
)
for value in values:
yield CheckpointTuple(
{
"configurable": {
"thread_id": value["thread_id"],
"checkpoint_ns": value["checkpoint_ns"],
"checkpoint_id": value["checkpoint_id"],
}
},
{
**value["checkpoint"],
"channel_values": self._load_blobs(value["channel_values"]),
},
value["metadata"],
(
{
"configurable": {
"thread_id": value["thread_id"],
"checkpoint_ns": value["checkpoint_ns"],
"checkpoint_id": value["parent_checkpoint_id"],
}
}
if value["parent_checkpoint_id"]
else None
),
self._load_writes(value["pending_writes"]),
)
yield self._load_checkpoint_tuple(value)
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Get a checkpoint tuple from the database.
This method retrieves a checkpoint tuple from the Postgres database based on the
@@ -269,32 +247,7 @@ class PostgresSaver(BasePostgresSaver):
value["channel_values"],
)
return CheckpointTuple(
{
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": checkpoint_ns,
"checkpoint_id": value["checkpoint_id"],
}
},
{
**value["checkpoint"],
"channel_values": self._load_blobs(value["channel_values"]),
},
value["metadata"],
(
{
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": checkpoint_ns,
"checkpoint_id": value["parent_checkpoint_id"],
}
}
if value["parent_checkpoint_id"]
else None
),
self._load_writes(value["pending_writes"]),
)
return self._load_checkpoint_tuple(value)
def put(
self,
@@ -464,5 +417,44 @@ class PostgresSaver(BasePostgresSaver):
with conn.cursor(binary=True, row_factory=dict_row) as cur:
yield cur
def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
"""
Convert a database row into a CheckpointTuple object.
__all__ = ["PostgresSaver", "BasePostgresSaver", "Conn"]
Args:
value: A row from the database containing checkpoint data.
Returns:
CheckpointTuple: A structured representation of the checkpoint,
including its configuration, metadata, parent checkpoint (if any),
and pending writes.
"""
return CheckpointTuple(
{
"configurable": {
"thread_id": value["thread_id"],
"checkpoint_ns": value["checkpoint_ns"],
"checkpoint_id": value["checkpoint_id"],
}
},
{
**value["checkpoint"],
"channel_values": self._load_blobs(value["channel_values"]),
},
value["metadata"],
(
{
"configurable": {
"thread_id": value["thread_id"],
"checkpoint_ns": value["checkpoint_ns"],
"checkpoint_id": value["parent_checkpoint_id"],
}
}
if value["parent_checkpoint_id"]
else None
),
self._load_writes(value["pending_writes"]),
)
__all__ = ["PostgresSaver", "BasePostgresSaver", "ShallowPostgresSaver", "Conn"]
@@ -1,8 +1,10 @@
from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import AsyncIterator, Iterator, Sequence
from contextlib import asynccontextmanager
from typing import Any, Optional
from typing import Any
from langchain_core.runnables import RunnableConfig
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
@@ -21,6 +23,7 @@ from langgraph.checkpoint.base import (
)
from langgraph.checkpoint.postgres import _ainternal
from langgraph.checkpoint.postgres.base import BasePostgresSaver
from langgraph.checkpoint.postgres.shallow import AsyncShallowPostgresSaver
from langgraph.checkpoint.serde.base import SerializerProtocol
Conn = _ainternal.Conn # For backward compatibility
@@ -34,8 +37,8 @@ class AsyncPostgresSaver(BasePostgresSaver):
def __init__(
self,
conn: _ainternal.Conn,
pipe: Optional[AsyncPipeline] = None,
serde: Optional[SerializerProtocol] = None,
pipe: AsyncPipeline | None = None,
serde: SerializerProtocol | None = None,
) -> None:
super().__init__(serde=serde)
if isinstance(conn, AsyncConnectionPool) and pipe is not None:
@@ -56,8 +59,8 @@ class AsyncPostgresSaver(BasePostgresSaver):
conn_string: str,
*,
pipeline: bool = False,
serde: Optional[SerializerProtocol] = None,
) -> AsyncIterator["AsyncPostgresSaver"]:
serde: SerializerProtocol | None = None,
) -> AsyncIterator[AsyncPostgresSaver]:
"""Create a new AsyncPostgresSaver instance from a connection string.
Args:
@@ -104,11 +107,11 @@ class AsyncPostgresSaver(BasePostgresSaver):
async def alist(
self,
config: Optional[RunnableConfig],
config: RunnableConfig | None,
*,
filter: Optional[dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
filter: dict[str, Any] | None = None,
before: RunnableConfig | None = None,
limit: int | None = None,
) -> AsyncIterator[CheckpointTuple]:
"""List checkpoints from the database asynchronously.
@@ -160,34 +163,9 @@ class AsyncPostgresSaver(BasePostgresSaver):
value["channel_values"],
)
for value in values:
yield CheckpointTuple(
{
"configurable": {
"thread_id": value["thread_id"],
"checkpoint_ns": value["checkpoint_ns"],
"checkpoint_id": value["checkpoint_id"],
}
},
{
**value["checkpoint"],
"channel_values": self._load_blobs(value["channel_values"]),
},
value["metadata"],
(
{
"configurable": {
"thread_id": value["thread_id"],
"checkpoint_ns": value["checkpoint_ns"],
"checkpoint_id": value["parent_checkpoint_id"],
}
}
if value["parent_checkpoint_id"]
else None
),
await asyncio.to_thread(self._load_writes, value["pending_writes"]),
)
yield await self._load_checkpoint_tuple(value)
async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Get a checkpoint tuple from the database asynchronously.
This method retrieves a checkpoint tuple from the Postgres database based on the
@@ -236,32 +214,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
value["channel_values"],
)
return CheckpointTuple(
{
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": checkpoint_ns,
"checkpoint_id": value["checkpoint_id"],
}
},
{
**value["checkpoint"],
"channel_values": self._load_blobs(value["channel_values"]),
},
value["metadata"],
(
{
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": checkpoint_ns,
"checkpoint_id": value["parent_checkpoint_id"],
}
}
if value["parent_checkpoint_id"]
else None
),
await asyncio.to_thread(self._load_writes, value["pending_writes"]),
)
return await self._load_checkpoint_tuple(value)
async def aput(
self,
@@ -422,13 +375,52 @@ class AsyncPostgresSaver(BasePostgresSaver):
async with conn.cursor(binary=True, row_factory=dict_row) as cur:
yield cur
async def _load_checkpoint_tuple(self, value: DictRow) -> CheckpointTuple:
"""
Convert a database row into a CheckpointTuple object.
Args:
value: A row from the database containing checkpoint data.
Returns:
CheckpointTuple: A structured representation of the checkpoint,
including its configuration, metadata, parent checkpoint (if any),
and pending writes.
"""
return CheckpointTuple(
{
"configurable": {
"thread_id": value["thread_id"],
"checkpoint_ns": value["checkpoint_ns"],
"checkpoint_id": value["checkpoint_id"],
}
},
{
**value["checkpoint"],
"channel_values": self._load_blobs(value["channel_values"]),
},
value["metadata"],
(
{
"configurable": {
"thread_id": value["thread_id"],
"checkpoint_ns": value["checkpoint_ns"],
"checkpoint_id": value["parent_checkpoint_id"],
}
}
if value["parent_checkpoint_id"]
else None
),
await asyncio.to_thread(self._load_writes, value["pending_writes"]),
)
def list(
self,
config: Optional[RunnableConfig],
config: RunnableConfig | None,
*,
filter: Optional[dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
filter: dict[str, Any] | None = None,
before: RunnableConfig | None = None,
limit: int | None = None,
) -> Iterator[CheckpointTuple]:
"""List checkpoints from the database.
@@ -466,7 +458,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
except StopAsyncIteration:
break
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Get a checkpoint tuple from the database.
This method retrieves a checkpoint tuple from the Postgres database based on the
@@ -568,4 +560,4 @@ class AsyncPostgresSaver(BasePostgresSaver):
).result()
__all__ = ["AsyncPostgresSaver", "Conn"]
__all__ = ["AsyncPostgresSaver", "AsyncShallowPostgresSaver", "Conn"]
@@ -1,3 +1,5 @@
from __future__ import annotations
import random
from collections.abc import Sequence
from typing import Any, Optional, cast
@@ -166,7 +168,7 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
checkpoint["channel_versions"][TASKS] = (
max(checkpoint["channel_versions"].values())
if checkpoint["channel_versions"]
else self.get_next_version(None)
else self.get_next_version(None, None)
)
def _load_blobs(
@@ -186,7 +188,7 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
checkpoint_ns: str,
values: dict[str, Any],
versions: ChannelVersions,
) -> list[tuple[str, str, str, str, str, Optional[bytes]]]:
) -> list[tuple[str, str, str, str, str, bytes | None]]:
if not versions:
return []
@@ -244,7 +246,7 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
for idx, (channel, value) in enumerate(writes)
]
def get_next_version(self, current: Optional[str]) -> str:
def get_next_version(self, current: str | None, channel: None) -> str:
if current is None:
current_v = 0
elif isinstance(current, int):
@@ -257,9 +259,9 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
def _search_where(
self,
config: Optional[RunnableConfig],
config: RunnableConfig | None,
filter: MetadataInput,
before: Optional[RunnableConfig] = None,
before: RunnableConfig | None = None,
) -> tuple[str, list[Any]]:
"""Return WHERE clause predicates for alist() given config, filter, before.
@@ -0,0 +1,959 @@
import asyncio
import threading
import warnings
from collections.abc import AsyncIterator, Iterator, Sequence
from contextlib import asynccontextmanager, contextmanager
from typing import Any, Optional
from langchain_core.runnables import RunnableConfig
from psycopg import (
AsyncConnection,
AsyncCursor,
AsyncPipeline,
Capabilities,
Connection,
Cursor,
Pipeline,
)
from psycopg.rows import DictRow, dict_row
from psycopg.types.json import Jsonb
from psycopg_pool import AsyncConnectionPool, ConnectionPool
from langgraph.checkpoint.base import (
WRITES_IDX_MAP,
ChannelVersions,
Checkpoint,
CheckpointMetadata,
CheckpointTuple,
get_checkpoint_metadata,
)
from langgraph.checkpoint.postgres import _ainternal, _internal
from langgraph.checkpoint.postgres.base import BasePostgresSaver
from langgraph.checkpoint.serde.base import SerializerProtocol
from langgraph.checkpoint.serde.types import TASKS
"""
To add a new migration, add a new string to the MIGRATIONS list.
The position of the migration in the list is the version number.
"""
MIGRATIONS = [
"""CREATE TABLE IF NOT EXISTS checkpoint_migrations (
v INTEGER PRIMARY KEY
);""",
"""CREATE TABLE IF NOT EXISTS checkpoints (
thread_id TEXT NOT NULL,
checkpoint_ns TEXT NOT NULL DEFAULT '',
type TEXT,
checkpoint JSONB NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
PRIMARY KEY (thread_id, checkpoint_ns)
);""",
"""CREATE TABLE IF NOT EXISTS checkpoint_blobs (
thread_id TEXT NOT NULL,
checkpoint_ns TEXT NOT NULL DEFAULT '',
channel TEXT NOT NULL,
type TEXT NOT NULL,
blob BYTEA,
PRIMARY KEY (thread_id, checkpoint_ns, channel)
);""",
"""CREATE TABLE IF NOT EXISTS checkpoint_writes (
thread_id TEXT NOT NULL,
checkpoint_ns TEXT NOT NULL DEFAULT '',
checkpoint_id TEXT NOT NULL,
task_id TEXT NOT NULL,
idx INTEGER NOT NULL,
channel TEXT NOT NULL,
type TEXT,
blob BYTEA NOT NULL,
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
);""",
"""
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);
""",
"""
ALTER TABLE checkpoint_writes ADD COLUMN task_path TEXT NOT NULL DEFAULT '';
""",
]
SELECT_SQL = f"""
select
thread_id,
checkpoint,
checkpoint_ns,
metadata,
(
select array_agg(array[bl.channel::bytea, bl.type::bytea, bl.blob])
from jsonb_each_text(checkpoint -> 'channel_versions')
inner join checkpoint_blobs bl
on bl.thread_id = checkpoints.thread_id
and bl.checkpoint_ns = checkpoints.checkpoint_ns
and bl.channel = jsonb_each_text.key
) as channel_values,
(
select
array_agg(array[cw.task_id::text::bytea, cw.channel::bytea, cw.type::bytea, cw.blob] order by cw.task_id, cw.idx)
from checkpoint_writes cw
where cw.thread_id = checkpoints.thread_id
and cw.checkpoint_ns = checkpoints.checkpoint_ns
and cw.checkpoint_id = (checkpoint->>'id')
) as pending_writes,
(
select array_agg(array[cw.type::bytea, cw.blob] order by cw.task_path, cw.task_id, cw.idx)
from checkpoint_writes cw
where cw.thread_id = checkpoints.thread_id
and cw.checkpoint_ns = checkpoints.checkpoint_ns
and cw.channel = '{TASKS}'
) as pending_sends
from checkpoints """
UPSERT_CHECKPOINT_BLOBS_SQL = """
INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, type, blob)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (thread_id, checkpoint_ns, channel) DO UPDATE SET
type = EXCLUDED.type,
blob = EXCLUDED.blob;
"""
UPSERT_CHECKPOINTS_SQL = """
INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint, metadata)
VALUES (%s, %s, %s, %s)
ON CONFLICT (thread_id, checkpoint_ns)
DO UPDATE SET
checkpoint = EXCLUDED.checkpoint,
metadata = EXCLUDED.metadata;
"""
UPSERT_CHECKPOINT_WRITES_SQL = """
INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, task_path, idx, channel, type, blob)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) DO UPDATE SET
channel = EXCLUDED.channel,
type = EXCLUDED.type,
blob = EXCLUDED.blob;
"""
INSERT_CHECKPOINT_WRITES_SQL = """
INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, task_id, task_path, idx, channel, type, blob)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) DO NOTHING
"""
def _dump_blobs(
serde: SerializerProtocol,
thread_id: str,
checkpoint_ns: str,
values: dict[str, Any],
versions: ChannelVersions,
) -> list[tuple[str, str, str, str, Optional[bytes]]]:
if not versions:
return []
return [
(
thread_id,
checkpoint_ns,
k,
*(serde.dumps_typed(values[k]) if k in values else ("empty", None)),
)
for k in versions
]
class ShallowPostgresSaver(BasePostgresSaver):
"""A checkpoint saver that uses Postgres to store checkpoints.
This checkpointer ONLY stores the most recent checkpoint and does NOT retain any history.
It is meant to be a light-weight drop-in replacement for the PostgresSaver that
supports most of the LangGraph persistence functionality with the exception of time travel.
"""
SELECT_SQL = SELECT_SQL
MIGRATIONS = MIGRATIONS
UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL
UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL
UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL
INSERT_CHECKPOINT_WRITES_SQL = INSERT_CHECKPOINT_WRITES_SQL
lock: threading.Lock
def __init__(
self,
conn: _internal.Conn,
pipe: Optional[Pipeline] = None,
serde: Optional[SerializerProtocol] = None,
) -> None:
warnings.warn(
"ShallowPostgresSaver is deprecated as of version 2.0.20 and will be removed in 3.0.0. "
"Use PostgresSaver instead, and invoke the graph with `graph.invoke(..., checkpoint_during=False)`.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(serde=serde)
if isinstance(conn, ConnectionPool) and pipe is not None:
raise ValueError(
"Pipeline should be used only with a single Connection, not ConnectionPool."
)
self.conn = conn
self.pipe = pipe
self.lock = threading.Lock()
self.supports_pipeline = Capabilities().has_pipeline()
@classmethod
@contextmanager
def from_conn_string(
cls, conn_string: str, *, pipeline: bool = False
) -> Iterator["ShallowPostgresSaver"]:
"""Create a new ShallowPostgresSaver instance from a connection string.
Args:
conn_string: The Postgres connection info string.
pipeline: whether to use Pipeline
Returns:
ShallowPostgresSaver: A new ShallowPostgresSaver instance.
"""
with Connection.connect(
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
) as conn:
if pipeline:
with conn.pipeline() as pipe:
yield cls(conn, pipe)
else:
yield cls(conn)
def setup(self) -> None:
"""Set up the checkpoint database asynchronously.
This method creates the necessary tables in the Postgres database if they don't
already exist and runs database migrations. It MUST be called directly by the user
the first time checkpointer is used.
"""
with self._cursor() as cur:
cur.execute(self.MIGRATIONS[0])
results = cur.execute(
"SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
)
row = results.fetchone()
if row is None:
version = -1
else:
version = row["v"]
for v, migration in zip(
range(version + 1, len(self.MIGRATIONS)),
self.MIGRATIONS[version + 1 :],
):
cur.execute(migration)
cur.execute(f"INSERT INTO checkpoint_migrations (v) VALUES ({v})")
if self.pipe:
self.pipe.sync()
def list(
self,
config: Optional[RunnableConfig],
*,
filter: Optional[dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
) -> Iterator[CheckpointTuple]:
"""List checkpoints from the database.
This method retrieves a list of checkpoint tuples from the Postgres database based
on the provided config. For ShallowPostgresSaver, this method returns a list with
ONLY the most recent checkpoint.
"""
where, args = self._search_where(config, filter, before)
query = self.SELECT_SQL + where
if limit:
query += f" LIMIT {limit}"
with self._cursor() as cur:
cur.execute(self.SELECT_SQL + where, args, binary=True)
for value in cur:
checkpoint: Checkpoint = {
**value["checkpoint"],
"channel_values": self._load_blobs(value["channel_values"]),
"pending_sends": [
self.serde.loads_typed((t.decode(), v))
for t, v in value["pending_sends"]
]
if value["pending_sends"]
else [],
}
yield CheckpointTuple(
config={
"configurable": {
"thread_id": value["thread_id"],
"checkpoint_ns": value["checkpoint_ns"],
"checkpoint_id": checkpoint["id"],
}
},
checkpoint=checkpoint,
metadata=value["metadata"],
pending_writes=self._load_writes(value["pending_writes"]),
)
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
"""Get a checkpoint tuple from the database.
This method retrieves a checkpoint tuple from the Postgres database based on the
provided config (matching the thread ID in the config).
Args:
config: The config to use for retrieving the checkpoint.
Returns:
Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
Examples:
Basic:
>>> config = {"configurable": {"thread_id": "1"}}
>>> checkpoint_tuple = memory.get_tuple(config)
>>> print(checkpoint_tuple)
CheckpointTuple(...)
With timestamp:
>>> config = {
... "configurable": {
... "thread_id": "1",
... "checkpoint_ns": "",
... "checkpoint_id": "1ef4f797-8335-6428-8001-8a1503f9b875",
... }
... }
>>> checkpoint_tuple = memory.get_tuple(config)
>>> print(checkpoint_tuple)
CheckpointTuple(...)
""" # noqa
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
args = (thread_id, checkpoint_ns)
where = "WHERE thread_id = %s AND checkpoint_ns = %s"
with self._cursor() as cur:
cur.execute(
self.SELECT_SQL + where,
args,
binary=True,
)
for value in cur:
checkpoint: Checkpoint = {
**value["checkpoint"],
"channel_values": self._load_blobs(value["channel_values"]),
"pending_sends": [
self.serde.loads_typed((t.decode(), v))
for t, v in value["pending_sends"]
]
if value["pending_sends"]
else [],
}
return CheckpointTuple(
config={
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": checkpoint_ns,
"checkpoint_id": checkpoint["id"],
}
},
checkpoint=checkpoint,
metadata=value["metadata"],
pending_writes=self._load_writes(value["pending_writes"]),
)
def put(
self,
config: RunnableConfig,
checkpoint: Checkpoint,
metadata: CheckpointMetadata,
new_versions: ChannelVersions,
) -> RunnableConfig:
"""Save a checkpoint to the database.
This method saves a checkpoint to the Postgres database. The checkpoint is associated
with the provided config. For ShallowPostgresSaver, this method saves ONLY the most recent
checkpoint and overwrites a previous checkpoint, if it exists.
Args:
config: The config to associate with the checkpoint.
checkpoint: The checkpoint to save.
metadata: Additional metadata to save with the checkpoint.
new_versions: New channel versions as of this write.
Returns:
RunnableConfig: Updated configuration after storing the checkpoint.
Examples:
>>> from langgraph.checkpoint.postgres import ShallowPostgresSaver
>>> DB_URI = "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
>>> with ShallowPostgresSaver.from_conn_string(DB_URI) as memory:
>>> config = {"configurable": {"thread_id": "1", "checkpoint_ns": ""}}
>>> checkpoint = {"ts": "2024-05-04T06:32:42.235444+00:00", "id": "1ef4f797-8335-6428-8001-8a1503f9b875", "channel_values": {"key": "value"}}
>>> saved_config = memory.put(config, checkpoint, {"source": "input", "step": 1, "writes": {"key": "value"}}, {})
>>> print(saved_config)
{'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1ef4f797-8335-6428-8001-8a1503f9b875'}}
"""
configurable = config["configurable"].copy()
thread_id = configurable.pop("thread_id")
checkpoint_ns = configurable.pop("checkpoint_ns")
copy = checkpoint.copy()
next_config = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": checkpoint_ns,
"checkpoint_id": checkpoint["id"],
}
}
with self._cursor(pipeline=True) as cur:
cur.execute(
"""DELETE FROM checkpoint_writes
WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id NOT IN (%s, %s)""",
(
thread_id,
checkpoint_ns,
checkpoint["id"],
configurable.get("checkpoint_id", ""),
),
)
cur.executemany(
self.UPSERT_CHECKPOINT_BLOBS_SQL,
_dump_blobs(
self.serde,
thread_id,
checkpoint_ns,
copy.pop("channel_values"), # type: ignore[misc]
new_versions,
),
)
cur.execute(
self.UPSERT_CHECKPOINTS_SQL,
(
thread_id,
checkpoint_ns,
Jsonb(copy),
Jsonb(get_checkpoint_metadata(config, metadata)),
),
)
return next_config
def put_writes(
self,
config: RunnableConfig,
writes: Sequence[tuple[str, Any]],
task_id: str,
task_path: str = "",
) -> None:
"""Store intermediate writes linked to a checkpoint.
This method saves intermediate writes associated with a checkpoint to the Postgres database.
Args:
config: Configuration of the related checkpoint.
writes: List of writes to store.
task_id: Identifier for the task creating the writes.
"""
query = (
self.UPSERT_CHECKPOINT_WRITES_SQL
if all(w[0] in WRITES_IDX_MAP for w in writes)
else self.INSERT_CHECKPOINT_WRITES_SQL
)
with self._cursor(pipeline=True) as cur:
cur.executemany(
query,
self._dump_writes(
config["configurable"]["thread_id"],
config["configurable"]["checkpoint_ns"],
config["configurable"]["checkpoint_id"],
task_id,
task_path,
writes,
),
)
@contextmanager
def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]:
"""Create a database cursor as a context manager.
Args:
pipeline: whether to use pipeline for the DB operations inside the context manager.
Will be applied regardless of whether the ShallowPostgresSaver instance was initialized with a pipeline.
If pipeline mode is not supported, will fall back to using transaction context manager.
"""
with _internal.get_connection(self.conn) as conn:
if self.pipe:
# a connection in pipeline mode can be used concurrently
# in multiple threads/coroutines, but only one cursor can be
# used at a time
try:
with conn.cursor(binary=True, row_factory=dict_row) as cur:
yield cur
finally:
if pipeline:
self.pipe.sync()
elif pipeline:
# a connection not in pipeline mode can only be used by one
# thread/coroutine at a time, so we acquire a lock
if self.supports_pipeline:
with (
self.lock,
conn.pipeline(),
conn.cursor(binary=True, row_factory=dict_row) as cur,
):
yield cur
else:
# Use connection's transaction context manager when pipeline mode not supported
with (
self.lock,
conn.transaction(),
conn.cursor(binary=True, row_factory=dict_row) as cur,
):
yield cur
else:
with self.lock, conn.cursor(binary=True, row_factory=dict_row) as cur:
yield cur
class AsyncShallowPostgresSaver(BasePostgresSaver):
"""A checkpoint saver that uses Postgres to store checkpoints asynchronously.
This checkpointer ONLY stores the most recent checkpoint and does NOT retain any history.
It is meant to be a light-weight drop-in replacement for the AsyncPostgresSaver that
supports most of the LangGraph persistence functionality with the exception of time travel.
"""
SELECT_SQL = SELECT_SQL
MIGRATIONS = MIGRATIONS
UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL
UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL
UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL
INSERT_CHECKPOINT_WRITES_SQL = INSERT_CHECKPOINT_WRITES_SQL
lock: asyncio.Lock
def __init__(
self,
conn: _ainternal.Conn,
pipe: Optional[AsyncPipeline] = None,
serde: Optional[SerializerProtocol] = None,
) -> None:
warnings.warn(
"AsyncShallowPostgresSaver is deprecated as of version 2.0.20 and will be removed in 3.0.0. "
"Use AsyncPostgresSaver instead, and invoke the graph with `await graph.ainvoke(..., checkpoint_during=False)`.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(serde=serde)
if isinstance(conn, AsyncConnectionPool) and pipe is not None:
raise ValueError(
"Pipeline should be used only with a single AsyncConnection, not AsyncConnectionPool."
)
self.conn = conn
self.pipe = pipe
self.lock = asyncio.Lock()
self.loop = asyncio.get_running_loop()
self.supports_pipeline = Capabilities().has_pipeline()
@classmethod
@asynccontextmanager
async def from_conn_string(
cls,
conn_string: str,
*,
pipeline: bool = False,
serde: Optional[SerializerProtocol] = None,
) -> AsyncIterator["AsyncShallowPostgresSaver"]:
"""Create a new AsyncShallowPostgresSaver instance from a connection string.
Args:
conn_string: The Postgres connection info string.
pipeline: whether to use AsyncPipeline
Returns:
AsyncShallowPostgresSaver: A new AsyncShallowPostgresSaver instance.
"""
async with await AsyncConnection.connect(
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
) as conn:
if pipeline:
async with conn.pipeline() as pipe:
yield cls(conn=conn, pipe=pipe, serde=serde)
else:
yield cls(conn=conn, serde=serde)
async def setup(self) -> None:
"""Set up the checkpoint database asynchronously.
This method creates the necessary tables in the Postgres database if they don't
already exist and runs database migrations. It MUST be called directly by the user
the first time checkpointer is used.
"""
async with self._cursor() as cur:
await cur.execute(self.MIGRATIONS[0])
results = await cur.execute(
"SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1"
)
row = await results.fetchone()
if row is None:
version = -1
else:
version = row["v"]
for v, migration in zip(
range(version + 1, len(self.MIGRATIONS)),
self.MIGRATIONS[version + 1 :],
):
await cur.execute(migration)
await cur.execute(f"INSERT INTO checkpoint_migrations (v) VALUES ({v})")
if self.pipe:
await self.pipe.sync()
async def alist(
self,
config: Optional[RunnableConfig],
*,
filter: Optional[dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
) -> AsyncIterator[CheckpointTuple]:
"""List checkpoints from the database asynchronously.
This method retrieves a list of checkpoint tuples from the Postgres database based
on the provided config. For ShallowPostgresSaver, this method returns a list with
ONLY the most recent checkpoint.
"""
where, args = self._search_where(config, filter, before)
query = self.SELECT_SQL + where
if limit:
query += f" LIMIT {limit}"
async with self._cursor() as cur:
await cur.execute(self.SELECT_SQL + where, args, binary=True)
async for value in cur:
checkpoint: Checkpoint = {
**value["checkpoint"],
"channel_values": self._load_blobs(value["channel_values"]),
"pending_sends": [
self.serde.loads_typed((t.decode(), v))
for t, v in value["pending_sends"]
]
if value["pending_sends"]
else [],
}
yield CheckpointTuple(
config={
"configurable": {
"thread_id": value["thread_id"],
"checkpoint_ns": value["checkpoint_ns"],
"checkpoint_id": checkpoint["id"],
}
},
checkpoint=checkpoint,
metadata=value["metadata"],
pending_writes=await asyncio.to_thread(
self._load_writes, value["pending_writes"]
),
)
async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
"""Get a checkpoint tuple from the database asynchronously.
This method retrieves a checkpoint tuple from the Postgres database based on the
provided config (matching the thread ID in the config).
Args:
config: The config to use for retrieving the checkpoint.
Returns:
Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
"""
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
args = (thread_id, checkpoint_ns)
where = "WHERE thread_id = %s AND checkpoint_ns = %s"
async with self._cursor() as cur:
await cur.execute(
self.SELECT_SQL + where,
args,
binary=True,
)
async for value in cur:
checkpoint: Checkpoint = {
**value["checkpoint"],
"channel_values": self._load_blobs(value["channel_values"]),
"pending_sends": [
self.serde.loads_typed((t.decode(), v))
for t, v in value["pending_sends"]
]
if value["pending_sends"]
else [],
}
return CheckpointTuple(
config={
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": checkpoint_ns,
"checkpoint_id": checkpoint["id"],
}
},
checkpoint=checkpoint,
metadata=value["metadata"],
pending_writes=await asyncio.to_thread(
self._load_writes, value["pending_writes"]
),
)
async def aput(
self,
config: RunnableConfig,
checkpoint: Checkpoint,
metadata: CheckpointMetadata,
new_versions: ChannelVersions,
) -> RunnableConfig:
"""Save a checkpoint to the database asynchronously.
This method saves a checkpoint to the Postgres database. The checkpoint is associated
with the provided config. For AsyncShallowPostgresSaver, this method saves ONLY the most recent
checkpoint and overwrites a previous checkpoint, if it exists.
Args:
config: The config to associate with the checkpoint.
checkpoint: The checkpoint to save.
metadata: Additional metadata to save with the checkpoint.
new_versions: New channel versions as of this write.
Returns:
RunnableConfig: Updated configuration after storing the checkpoint.
"""
configurable = config["configurable"].copy()
thread_id = configurable.pop("thread_id")
checkpoint_ns = configurable.pop("checkpoint_ns")
copy = checkpoint.copy()
next_config = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": checkpoint_ns,
"checkpoint_id": checkpoint["id"],
}
}
async with self._cursor(pipeline=True) as cur:
await cur.execute(
"""DELETE FROM checkpoint_writes
WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id NOT IN (%s, %s)""",
(
thread_id,
checkpoint_ns,
checkpoint["id"],
configurable.get("checkpoint_id", ""),
),
)
await cur.executemany(
self.UPSERT_CHECKPOINT_BLOBS_SQL,
_dump_blobs(
self.serde,
thread_id,
checkpoint_ns,
copy.pop("channel_values"), # type: ignore[misc]
new_versions,
),
)
await cur.execute(
self.UPSERT_CHECKPOINTS_SQL,
(
thread_id,
checkpoint_ns,
Jsonb(copy),
Jsonb(get_checkpoint_metadata(config, metadata)),
),
)
return next_config
async def aput_writes(
self,
config: RunnableConfig,
writes: Sequence[tuple[str, Any]],
task_id: str,
task_path: str = "",
) -> None:
"""Store intermediate writes linked to a checkpoint asynchronously.
This method saves intermediate writes associated with a checkpoint to the database.
Args:
config: Configuration of the related checkpoint.
writes: List of writes to store, each as (channel, value) pair.
task_id: Identifier for the task creating the writes.
"""
query = (
self.UPSERT_CHECKPOINT_WRITES_SQL
if all(w[0] in WRITES_IDX_MAP for w in writes)
else self.INSERT_CHECKPOINT_WRITES_SQL
)
params = await asyncio.to_thread(
self._dump_writes,
config["configurable"]["thread_id"],
config["configurable"]["checkpoint_ns"],
config["configurable"]["checkpoint_id"],
task_id,
task_path,
writes,
)
async with self._cursor(pipeline=True) as cur:
await cur.executemany(query, params)
@asynccontextmanager
async def _cursor(
self, *, pipeline: bool = False
) -> AsyncIterator[AsyncCursor[DictRow]]:
"""Create a database cursor as a context manager.
Args:
pipeline: whether to use pipeline for the DB operations inside the context manager.
Will be applied regardless of whether the AsyncShallowPostgresSaver instance was initialized with a pipeline.
If pipeline mode is not supported, will fall back to using transaction context manager.
"""
async with _ainternal.get_connection(self.conn) as conn:
if self.pipe:
# a connection in pipeline mode can be used concurrently
# in multiple threads/coroutines, but only one cursor can be
# used at a time
try:
async with conn.cursor(binary=True, row_factory=dict_row) as cur:
yield cur
finally:
if pipeline:
await self.pipe.sync()
elif pipeline:
# a connection not in pipeline mode can only be used by one
# thread/coroutine at a time, so we acquire a lock
if self.supports_pipeline:
async with (
self.lock,
conn.pipeline(),
conn.cursor(binary=True, row_factory=dict_row) as cur,
):
yield cur
else:
# Use connection's transaction context manager when pipeline mode not supported
async with (
self.lock,
conn.transaction(),
conn.cursor(binary=True, row_factory=dict_row) as cur,
):
yield cur
else:
async with (
self.lock,
conn.cursor(binary=True, row_factory=dict_row) as cur,
):
yield cur
def list(
self,
config: Optional[RunnableConfig],
*,
filter: Optional[dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
) -> Iterator[CheckpointTuple]:
"""List checkpoints from the database.
This method retrieves a list of checkpoint tuples from the Postgres database based
on the provided config. For ShallowPostgresSaver, this method returns a list with
ONLY the most recent checkpoint.
"""
aiter_ = self.alist(config, filter=filter, before=before, limit=limit)
while True:
try:
yield asyncio.run_coroutine_threadsafe(
anext(aiter_), # noqa: F821
self.loop,
).result()
except StopAsyncIteration:
break
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
"""Get a checkpoint tuple from the database.
This method retrieves a checkpoint tuple from the Postgres database based on the
provided config (matching the thread ID in the config).
Args:
config: The config to use for retrieving the checkpoint.
Returns:
Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
"""
try:
# check if we are in the main thread, only bg threads can block
# we don't check in other methods to avoid the overhead
if asyncio.get_running_loop() is self.loop:
raise asyncio.InvalidStateError(
"Synchronous calls to AsyncShallowPostgresSaver are only allowed from a "
"different thread. From the main thread, use the async interface."
"For example, use `await checkpointer.aget_tuple(...)` or `await "
"graph.ainvoke(...)`."
)
except RuntimeError:
pass
return asyncio.run_coroutine_threadsafe(
self.aget_tuple(config), self.loop
).result()
def put(
self,
config: RunnableConfig,
checkpoint: Checkpoint,
metadata: CheckpointMetadata,
new_versions: ChannelVersions,
) -> RunnableConfig:
"""Save a checkpoint to the database.
This method saves a checkpoint to the Postgres database. The checkpoint is associated
with the provided config. For AsyncShallowPostgresSaver, this method saves ONLY the most recent
checkpoint and overwrites a previous checkpoint, if it exists.
Args:
config: The config to associate with the checkpoint.
checkpoint: The checkpoint to save.
metadata: Additional metadata to save with the checkpoint.
new_versions: New channel versions as of this write.
Returns:
RunnableConfig: Updated configuration after storing the checkpoint.
"""
return asyncio.run_coroutine_threadsafe(
self.aput(config, checkpoint, metadata, new_versions), self.loop
).result()
def put_writes(
self,
config: RunnableConfig,
writes: Sequence[tuple[str, Any]],
task_id: str,
task_path: str = "",
) -> None:
"""Store intermediate writes linked to a checkpoint.
This method saves intermediate writes associated with a checkpoint to the database.
Args:
config: Configuration of the related checkpoint.
writes: List of writes to store, each as (channel, value) pair.
task_id: Identifier for the task creating the writes.
task_path: Path of the task creating the writes.
"""
return asyncio.run_coroutine_threadsafe(
self.aput_writes(config, writes, task_id, task_path), self.loop
).result()
@@ -1,9 +1,11 @@
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncIterator, Iterable, Sequence
from contextlib import asynccontextmanager
from types import TracebackType
from typing import Any, Callable, Optional, Union, cast
from typing import Any, Callable, cast
import orjson
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
@@ -132,12 +134,10 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
self,
conn: _ainternal.Conn,
*,
pipe: Optional[AsyncPipeline] = None,
deserializer: Optional[
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
] = None,
index: Optional[PostgresIndexConfig] = None,
ttl: Optional[TTLConfig] = None,
pipe: AsyncPipeline | None = None,
deserializer: Callable[[bytes | orjson.Fragment], dict[str, Any]] | None = None,
index: PostgresIndexConfig | None = None,
ttl: TTLConfig | None = None,
) -> None:
if isinstance(conn, AsyncConnectionPool) and pipe is not None:
raise ValueError(
@@ -157,7 +157,7 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
self.embeddings = None
self.ttl_config = ttl
self._ttl_sweeper_task: Optional[asyncio.Task[None]] = None
self._ttl_sweeper_task: asyncio.Task[None] | None = None
self._ttl_stop_event = asyncio.Event()
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
@@ -180,10 +180,10 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
conn_string: str,
*,
pipeline: bool = False,
pool_config: Optional[PoolConfig] = None,
index: Optional[PostgresIndexConfig] = None,
ttl: Optional[TTLConfig] = None,
) -> AsyncIterator["AsyncPostgresStore"]:
pool_config: PoolConfig | None = None,
index: PostgresIndexConfig | None = None,
ttl: TTLConfig | None = None,
) -> AsyncIterator[AsyncPostgresStore]:
"""Create a new AsyncPostgresStore instance from a connection string.
Args:
@@ -289,7 +289,7 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
return deleted_count
async def start_ttl_sweeper(
self, sweep_interval_minutes: Optional[int] = None
self, sweep_interval_minutes: int | None = None
) -> asyncio.Task[None]:
"""Periodically delete expired store items based on TTL.
@@ -334,7 +334,7 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
self._ttl_sweeper_task = task
return task
async def stop_ttl_sweeper(self, timeout: Optional[float] = None) -> bool:
async def stop_ttl_sweeper(self, timeout: float | None = None) -> bool:
"""Stop the TTL sweeper task if it's running.
Args:
@@ -369,14 +369,14 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
return success
async def __aenter__(self) -> "AsyncPostgresStore":
async def __aenter__(self) -> AsyncPostgresStore:
return self
async def __aexit__(
self,
exc_type: Optional[type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional["TracebackType"],
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
# Ensure the TTL sweeper task is stopped when exiting the context
if hasattr(self, "_ttl_sweeper_task") and self._ttl_sweeper_task is not None:
@@ -1,3 +1,5 @@
from __future__ import annotations
import asyncio
import concurrent.futures
import json
@@ -14,7 +16,6 @@ from typing import (
Generic,
Literal,
NamedTuple,
Optional,
TypeVar,
Union,
cast,
@@ -56,8 +57,8 @@ class Migration(NamedTuple):
"""A database migration with optional conditions and parameters."""
sql: str
params: Optional[dict[str, Any]] = None
condition: Optional[Callable[["BasePostgresStore"], bool]] = None
params: dict[str, Any] | None = None
condition: Callable[[BasePostgresStore], bool] | None = None
MIGRATIONS: Sequence[str] = [
@@ -155,7 +156,7 @@ class PoolConfig(TypedDict, total=False):
min_size: int
"""Minimum number of connections maintained in the pool. Defaults to 1."""
max_size: Optional[int]
max_size: int | None
"""Maximum number of connections allowed in the pool. None means unlimited."""
kwargs: dict
@@ -230,8 +231,8 @@ class BasePostgresStore(Generic[C]):
MIGRATIONS = MIGRATIONS
VECTOR_MIGRATIONS = VECTOR_MIGRATIONS
conn: C
_deserializer: Optional[Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]]
index_config: Optional[PostgresIndexConfig]
_deserializer: Callable[[bytes | orjson.Fragment], dict[str, Any]] | None
index_config: PostgresIndexConfig | None
def _get_batch_GET_ops_queries(
self,
@@ -293,7 +294,7 @@ class BasePostgresStore(Generic[C]):
put_ops: Sequence[tuple[int, PutOp]],
) -> tuple[
list[tuple[str, Sequence]],
Optional[tuple[str, Sequence[tuple[str, str, str, str]]]],
tuple[str, Sequence[tuple[str, str, str, str]]] | None,
]:
dedupped_ops: dict[tuple[tuple[str, ...], str], PutOp] = {}
for _, op in put_ops:
@@ -320,9 +321,7 @@ class BasePostgresStore(Generic[C]):
)
params = (_namespace_to_text(namespace), *keys)
queries.append((query, params))
embedding_request: Optional[tuple[str, Sequence[tuple[str, str, str, str]]]] = (
None
)
embedding_request: tuple[str, Sequence[tuple[str, str, str, str]]] | None = None
if inserts:
values = []
insertion_params = []
@@ -403,7 +402,7 @@ class BasePostgresStore(Generic[C]):
self,
search_ops: Sequence[tuple[int, SearchOp]],
) -> tuple[
list[tuple[str, list[Union[None, str, list[float]]]]], # queries, params
list[tuple[str, list[None | str | list[float]]]], # queries, params
list[tuple[int, str]], # idx, query_text pairs to embed
]:
"""
@@ -432,7 +431,7 @@ class BasePostgresStore(Generic[C]):
filter_params.extend([key, orjson.dumps(value).decode("utf-8")])
ns_condition = "TRUE"
ns_param: Optional[Sequence[Union[str]]] = None
ns_param: Sequence[str] | None = None
if op.namespace_prefix:
ns_condition = "store.prefix LIKE %s"
ns_param = (f"{_namespace_to_text(op.namespace_prefix)}%",)
@@ -719,12 +718,10 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
self,
conn: _pg_internal.Conn,
*,
pipe: Optional[Pipeline] = None,
deserializer: Optional[
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
] = None,
index: Optional[PostgresIndexConfig] = None,
ttl: Optional[TTLConfig] = None,
pipe: Pipeline | None = None,
deserializer: Callable[[bytes | orjson.Fragment], dict[str, Any]] | None = None,
index: PostgresIndexConfig | None = None,
ttl: TTLConfig | None = None,
) -> None:
super().__init__()
self._deserializer = deserializer
@@ -738,7 +735,7 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
else:
self.embeddings = None
self.ttl_config = ttl
self._ttl_sweeper_thread: Optional[threading.Thread] = None
self._ttl_sweeper_thread: threading.Thread | None = None
self._ttl_stop_event = threading.Event()
@classmethod
@@ -748,10 +745,10 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
conn_string: str,
*,
pipeline: bool = False,
pool_config: Optional[PoolConfig] = None,
index: Optional[PostgresIndexConfig] = None,
ttl: Optional[TTLConfig] = None,
) -> Iterator["PostgresStore"]:
pool_config: PoolConfig | None = None,
index: PostgresIndexConfig | None = None,
ttl: TTLConfig | None = None,
) -> Iterator[PostgresStore]:
"""Create a new PostgresStore instance from a connection string.
Args:
@@ -810,7 +807,7 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
return deleted_count
def start_ttl_sweeper(
self, sweep_interval_minutes: Optional[int] = None
self, sweep_interval_minutes: int | None = None
) -> concurrent.futures.Future[None]:
"""Periodically delete expired store items based on TTL.
@@ -867,7 +864,7 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
)
return future
def stop_ttl_sweeper(self, timeout: Optional[float] = None) -> bool:
def stop_ttl_sweeper(self, timeout: float | None = None) -> bool:
"""Stop the TTL sweeper thread if it's running.
Args:
@@ -1196,7 +1193,7 @@ def _row_to_item(
namespace: tuple[str, ...],
row: Row,
*,
loader: Optional[Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]] = None,
loader: Callable[[bytes | orjson.Fragment], dict[str, Any]] | None = None,
) -> Item:
"""Convert a row from the database into an Item.
@@ -1224,7 +1221,7 @@ def _row_to_search_item(
namespace: tuple[str, ...],
row: Row,
*,
loader: Optional[Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]] = None,
loader: Callable[[bytes | orjson.Fragment], dict[str, Any]] | None = None,
) -> SearchItem:
"""Convert a row from the database into an Item."""
loader = loader or _json_loads
@@ -1255,7 +1252,7 @@ def _group_ops(ops: Iterable[Op]) -> tuple[dict[type, list[tuple[int, Op]]], int
return grouped_ops, tot
def _json_loads(content: Union[bytes, orjson.Fragment]) -> Any:
def _json_loads(content: bytes | orjson.Fragment) -> Any:
if isinstance(content, orjson.Fragment):
if hasattr(content, "buf"):
content = content.buf
@@ -1267,7 +1264,7 @@ def _json_loads(content: Union[bytes, orjson.Fragment]) -> Any:
return orjson.loads(cast(bytes, content))
def _decode_ns_bytes(namespace: Union[str, bytes, list]) -> tuple[str, ...]:
def _decode_ns_bytes(namespace: str | bytes | list) -> tuple[str, ...]:
if isinstance(namespace, list):
return tuple(namespace)
if isinstance(namespace, bytes):
@@ -1316,16 +1313,16 @@ def get_distance_operator(store: Any) -> tuple[str, str]:
def _ensure_index_config(
index_config: PostgresIndexConfig,
) -> tuple[Optional["Embeddings"], PostgresIndexConfig]:
) -> tuple[Embeddings | None, PostgresIndexConfig]:
index_config = index_config.copy()
tokenized: list[tuple[str, Union[Literal["$"], list[str]]]] = []
tokenized: list[tuple[str, Literal["$"] | list[str]]] = []
tot = 0
text_fields = index_config.get("fields") or ["$"]
if isinstance(text_fields, str):
text_fields = [text_fields]
if not isinstance(text_fields, list):
raise ValueError(f"Text fields must be a list or a string. Got {text_fields}")
for p in text_fields:
fields = index_config.get("fields") or ["$"]
if isinstance(fields, str):
fields = [fields]
if not isinstance(fields, list):
raise ValueError(f"Text fields must be a list or a string. Got {fields}")
for p in fields:
if p == "$":
tokenized.append((p, "$"))
tot += 1
+1 -1
View File
@@ -56,7 +56,7 @@ lint.select = [
"B", # flake8-bugbear
"I", # isort
]
lint.ignore = ["E501", "B008", "UP007", "UP006"]
lint.ignore = ["E501", "B008"]
[tool.mypy]
# https://mypy.readthedocs.io/en/stable/config_file.html
@@ -1,51 +0,0 @@
from collections.abc import Mapping
from datetime import datetime, timezone
from typing import Any, Optional, Protocol
from langgraph.checkpoint.base import Checkpoint, EmptyChannelError
from langgraph.checkpoint.base.id import uuid6
class ChannelProtocol(Protocol):
def checkpoint(self) -> Optional[Any]: ...
def empty_checkpoint() -> Checkpoint:
return Checkpoint(
v=1,
id=str(uuid6(clock_seq=-2)),
ts=datetime.now(timezone.utc).isoformat(),
channel_values={},
channel_versions={},
versions_seen={},
)
def create_checkpoint(
checkpoint: Checkpoint,
channels: Optional[Mapping[str, ChannelProtocol]],
step: int,
*,
id: Optional[str] = None,
) -> Checkpoint:
"""Create a checkpoint for the given channels."""
ts = datetime.now(timezone.utc).isoformat()
if channels is None:
values = checkpoint["channel_values"]
else:
values = {}
for k, v in channels.items():
if k not in checkpoint["channel_versions"]:
continue
try:
values[k] = v.checkpoint()
except EmptyChannelError:
pass
return Checkpoint(
v=1,
ts=ts,
id=id or str(uuid6(clock_seq=step)),
channel_values=values,
channel_versions=checkpoint["channel_versions"],
versions_seen=checkpoint["versions_seen"],
)
+39 -6
View File
@@ -14,10 +14,14 @@ from langgraph.checkpoint.base import (
EXCLUDED_METADATA_KEYS,
Checkpoint,
CheckpointMetadata,
create_checkpoint,
empty_checkpoint,
)
from langgraph.checkpoint.postgres.aio import (
AsyncPostgresSaver,
AsyncShallowPostgresSaver,
)
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from langgraph.checkpoint.serde.types import TASKS
from tests.checkpoint_utils import create_checkpoint, empty_checkpoint
from tests.conftest import DEFAULT_POSTGRES_URI
@@ -108,11 +112,41 @@ async def _base_saver():
await conn.execute(f"DROP DATABASE {database}")
@asynccontextmanager
async def _shallow_saver():
"""Fixture for shallow connection mode testing."""
database = f"test_{uuid4().hex[:16]}"
# create unique db
async with await AsyncConnection.connect(
DEFAULT_POSTGRES_URI, autocommit=True
) as conn:
await conn.execute(f"CREATE DATABASE {database}")
try:
async with await AsyncConnection.connect(
DEFAULT_POSTGRES_URI + database,
autocommit=True,
prepare_threshold=0,
row_factory=dict_row,
) as conn:
checkpointer = AsyncShallowPostgresSaver(conn)
await checkpointer.setup()
yield checkpointer
finally:
# drop unique db
async with await AsyncConnection.connect(
DEFAULT_POSTGRES_URI, autocommit=True
) as conn:
await conn.execute(f"DROP DATABASE {database}")
@asynccontextmanager
async def _saver(name: str):
if name == "base":
async with _base_saver() as saver:
yield saver
elif name == "shallow":
async with _shallow_saver() as saver:
yield saver
elif name == "pool":
async with _pool_saver() as saver:
yield saver
@@ -172,7 +206,7 @@ def test_data():
}
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"])
async def test_combined_metadata(saver_name: str, test_data) -> None:
async with _saver(saver_name) as saver:
config = {
@@ -194,12 +228,11 @@ async def test_combined_metadata(saver_name: str, test_data) -> None:
checkpoint = await saver.aget_tuple(config)
assert checkpoint.metadata == {
**metadata,
"thread_id": "thread-2",
"run_id": "my_run_id",
}
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"])
async def test_asearch(saver_name: str, test_data) -> None:
async with _saver(saver_name) as saver:
configs = test_data["configs"]
@@ -250,7 +283,7 @@ async def test_asearch(saver_name: str, test_data) -> None:
} == {"", "inner"}
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"])
async def test_null_chars(saver_name: str, test_data) -> None:
async with _saver(saver_name) as saver:
config = await saver.aput(
@@ -1,4 +1,6 @@
# type: ignore
from __future__ import annotations
import asyncio
import itertools
import sys
@@ -6,7 +8,7 @@ import uuid
from collections.abc import AsyncIterator
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from typing import Any, Optional
from typing import Any
import pytest
from langchain_core.embeddings import Embeddings
@@ -353,7 +355,7 @@ async def _create_vector_store(
vector_type: str,
distance_type: str,
fake_embeddings: CharacterEmbeddings,
text_fields: Optional[list[str]] = None,
text_fields: list[str] | None = None,
) -> AsyncIterator[AsyncPostgresStore]:
"""Create a store with vector search enabled."""
if sys.version_info < (3, 10):
+3 -2
View File
@@ -1,9 +1,10 @@
# type: ignore
from __future__ import annotations
import re
import time
from contextlib import contextmanager
from typing import Any, Optional
from typing import Any
from uuid import uuid4
import pytest
@@ -379,7 +380,7 @@ def _create_vector_store(
vector_type: str,
distance_type: str,
fake_embeddings: Embeddings,
text_fields: Optional[list[str]] = None,
text_fields: list[str] | None = None,
enable_ttl: bool = True,
) -> PostgresStore:
"""Create a store with vector search enabled."""
+32 -6
View File
@@ -15,10 +15,11 @@ from langgraph.checkpoint.base import (
EXCLUDED_METADATA_KEYS,
Checkpoint,
CheckpointMetadata,
create_checkpoint,
empty_checkpoint,
)
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.checkpoint.postgres import PostgresSaver, ShallowPostgresSaver
from langgraph.checkpoint.serde.types import TASKS
from tests.checkpoint_utils import create_checkpoint, empty_checkpoint
from tests.conftest import DEFAULT_POSTGRES_URI
@@ -97,11 +98,37 @@ def _base_saver():
conn.execute(f"DROP DATABASE {database}")
@contextmanager
def _shallow_saver():
"""Fixture for regular connection mode testing with a shallow checkpointer."""
database = f"test_{uuid4().hex[:16]}"
# create unique db
with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn:
conn.execute(f"CREATE DATABASE {database}")
try:
with Connection.connect(
DEFAULT_POSTGRES_URI + database,
autocommit=True,
prepare_threshold=0,
row_factory=dict_row,
) as conn:
checkpointer = ShallowPostgresSaver(conn)
checkpointer.setup()
yield checkpointer
finally:
# drop unique db
with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn:
conn.execute(f"DROP DATABASE {database}")
@contextmanager
def _saver(name: str):
if name == "base":
with _base_saver() as saver:
yield saver
elif name == "shallow":
with _shallow_saver() as saver:
yield saver
elif name == "pool":
with _pool_saver() as saver:
yield saver
@@ -161,7 +188,7 @@ def test_data():
}
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"])
def test_combined_metadata(saver_name: str, test_data) -> None:
with _saver(saver_name) as saver:
config = {
@@ -183,12 +210,11 @@ def test_combined_metadata(saver_name: str, test_data) -> None:
checkpoint = saver.get_tuple(config)
assert checkpoint.metadata == {
**metadata,
"thread_id": "thread-2",
"run_id": "my_run_id",
}
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"])
def test_search(saver_name: str, test_data) -> None:
with _saver(saver_name) as saver:
configs = test_data["configs"]
@@ -237,7 +263,7 @@ def test_search(saver_name: str, test_data) -> None:
} == {"", "inner"}
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe", "shallow"])
def test_null_chars(saver_name: str, test_data) -> None:
with _saver(saver_name) as saver:
config = saver.put(
+707 -704
View File
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,11 @@
from __future__ import annotations
import random
import sqlite3
import threading
from collections.abc import AsyncIterator, Iterator, Sequence
from contextlib import closing, contextmanager
from typing import Any, Optional, cast
from typing import Any, cast
from langchain_core.runnables import RunnableConfig
@@ -76,7 +78,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
self,
conn: sqlite3.Connection,
*,
serde: Optional[SerializerProtocol] = None,
serde: SerializerProtocol | None = None,
) -> None:
super().__init__(serde=serde)
self.jsonplus_serde = JsonPlusSerializer()
@@ -86,7 +88,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
@classmethod
@contextmanager
def from_conn_string(cls, conn_string: str) -> Iterator["SqliteSaver"]:
def from_conn_string(cls, conn_string: str) -> Iterator[SqliteSaver]:
"""Create a new SqliteSaver instance from a connection string.
Args:
@@ -178,7 +180,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
self.conn.commit()
cur.close()
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Get a checkpoint tuple from the database.
This method retrieves a checkpoint tuple from the SQLite database based on the
@@ -286,11 +288,11 @@ class SqliteSaver(BaseCheckpointSaver[str]):
def list(
self,
config: Optional[RunnableConfig],
config: RunnableConfig | None,
*,
filter: Optional[dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
filter: dict[str, Any] | None = None,
before: RunnableConfig | None = None,
limit: int | None = None,
) -> Iterator[CheckpointTuple]:
"""List checkpoints from the database.
@@ -493,7 +495,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
(str(thread_id),),
)
async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Get a checkpoint tuple from the database asynchronously.
Note:
@@ -504,11 +506,11 @@ class SqliteSaver(BaseCheckpointSaver[str]):
async def alist(
self,
config: Optional[RunnableConfig],
config: RunnableConfig | None,
*,
filter: Optional[dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
filter: dict[str, Any] | None = None,
before: RunnableConfig | None = None,
limit: int | None = None,
) -> AsyncIterator[CheckpointTuple]:
"""List checkpoints from the database asynchronously.
@@ -534,7 +536,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
"""
raise NotImplementedError(_AIO_ERROR_MSG)
def get_next_version(self, current: Optional[str]) -> str:
def get_next_version(self, current: str | None, channel: None) -> str:
"""Generate the next version ID for a channel.
This method creates a new version identifier for a channel based on its current version.
@@ -1,8 +1,10 @@
from __future__ import annotations
import asyncio
import random
from collections.abc import AsyncIterator, Iterator, Sequence
from contextlib import asynccontextmanager
from typing import Any, Callable, Optional, TypeVar, cast
from typing import Any, Callable, TypeVar, cast
import aiosqlite
from langchain_core.runnables import RunnableConfig
@@ -108,7 +110,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
self,
conn: aiosqlite.Connection,
*,
serde: Optional[SerializerProtocol] = None,
serde: SerializerProtocol | None = None,
):
super().__init__(serde=serde)
self.jsonplus_serde = JsonPlusSerializer()
@@ -121,7 +123,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
@asynccontextmanager
async def from_conn_string(
cls, conn_string: str
) -> AsyncIterator["AsyncSqliteSaver"]:
) -> AsyncIterator[AsyncSqliteSaver]:
"""Create a new AsyncSqliteSaver instance from a connection string.
Args:
@@ -133,7 +135,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
async with aiosqlite.connect(conn_string) as conn:
yield cls(conn)
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Get a checkpoint tuple from the database.
This method retrieves a checkpoint tuple from the SQLite database based on the
@@ -165,11 +167,11 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
def list(
self,
config: Optional[RunnableConfig],
config: RunnableConfig | None,
*,
filter: Optional[dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
filter: dict[str, Any] | None = None,
before: RunnableConfig | None = None,
limit: int | None = None,
) -> Iterator[CheckpointTuple]:
"""List checkpoints from the database asynchronously.
@@ -310,7 +312,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
self.is_setup = True
async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Get a checkpoint tuple from the database asynchronously.
This method retrieves a checkpoint tuple from the SQLite database based on the
@@ -398,11 +400,11 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
async def alist(
self,
config: Optional[RunnableConfig],
config: RunnableConfig | None,
*,
filter: Optional[dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
filter: dict[str, Any] | None = None,
before: RunnableConfig | None = None,
limit: int | None = None,
) -> AsyncIterator[CheckpointTuple]:
"""List checkpoints from the database asynchronously.
@@ -589,7 +591,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
)
await self.conn.commit()
def get_next_version(self, current: Optional[str]) -> str:
def get_next_version(self, current: str | None, channel: None) -> str:
"""Generate the next version ID for a channel.
This method creates a new version identifier for a channel based on its current version.
@@ -1,6 +1,8 @@
from __future__ import annotations
import json
from collections.abc import Sequence
from typing import Any, Optional
from typing import Any
from langchain_core.runnables import RunnableConfig
@@ -52,9 +54,9 @@ def _metadata_predicate(
def search_where(
config: Optional[RunnableConfig],
filter: Optional[dict[str, Any]],
before: Optional[RunnableConfig] = None,
config: RunnableConfig | None,
filter: dict[str, Any] | None,
before: RunnableConfig | None = None,
) -> tuple[str, Sequence[Any]]:
"""Return WHERE clause predicates for (a)search() given metadata filter
and `before` config.
@@ -1,10 +1,12 @@
from __future__ import annotations
import asyncio
import logging
from collections import defaultdict
from collections.abc import AsyncIterator, Iterable, Sequence
from contextlib import asynccontextmanager
from types import TracebackType
from typing import Any, Callable, Optional, Union, cast
from typing import Any, Callable, cast
import aiosqlite
import orjson
@@ -88,11 +90,10 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
self,
conn: aiosqlite.Connection,
*,
deserializer: Optional[
Callable[[Union[bytes, str, orjson.Fragment]], dict[str, Any]]
] = None,
index: Optional[SqliteIndexConfig] = None,
ttl: Optional[TTLConfig] = None,
deserializer: Callable[[bytes | str | orjson.Fragment], dict[str, Any]]
| None = None,
index: SqliteIndexConfig | None = None,
ttl: TTLConfig | None = None,
):
"""Initialize the async SQLite store.
@@ -114,7 +115,7 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
else:
self.embeddings = None
self.ttl_config = ttl
self._ttl_sweeper_task: Optional[asyncio.Task[None]] = None
self._ttl_sweeper_task: asyncio.Task[None] | None = None
self._ttl_stop_event = asyncio.Event()
@classmethod
@@ -123,9 +124,9 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
cls,
conn_string: str,
*,
index: Optional[SqliteIndexConfig] = None,
ttl: Optional[TTLConfig] = None,
) -> AsyncIterator["AsyncSqliteStore"]:
index: SqliteIndexConfig | None = None,
ttl: TTLConfig | None = None,
) -> AsyncIterator[AsyncSqliteStore]:
"""Create a new AsyncSqliteStore instance from a connection string.
Args:
@@ -253,7 +254,7 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
return deleted_count
async def start_ttl_sweeper(
self, sweep_interval_minutes: Optional[int] = None
self, sweep_interval_minutes: int | None = None
) -> asyncio.Task[None]:
"""Periodically delete expired store items based on TTL.
@@ -298,7 +299,7 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
self._ttl_sweeper_task = task
return task
async def stop_ttl_sweeper(self, timeout: Optional[float] = None) -> bool:
async def stop_ttl_sweeper(self, timeout: float | None = None) -> bool:
"""Stop the TTL sweeper task if it's running.
Args:
@@ -333,14 +334,14 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
return success
async def __aenter__(self) -> "AsyncSqliteStore":
async def __aenter__(self) -> AsyncSqliteStore:
return self
async def __aexit__(
self,
exc_type: Optional[type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional["TracebackType"],
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
# Ensure the TTL sweeper task is stopped when exiting the context
if hasattr(self, "_ttl_sweeper_task") and self._ttl_sweeper_task is not None:
@@ -1,3 +1,5 @@
from __future__ import annotations
import concurrent.futures
import datetime
import logging
@@ -6,7 +8,7 @@ import threading
from collections import defaultdict
from collections.abc import Iterable, Iterator, Sequence
from contextlib import contextmanager
from typing import Any, Callable, Literal, NamedTuple, Optional, Union, cast
from typing import Any, Callable, Literal, NamedTuple, cast
import orjson
import sqlite_vec # type: ignore[import-untyped]
@@ -105,7 +107,7 @@ def _decode_ns_text(namespace: str) -> tuple[str, ...]:
return tuple(namespace.split("."))
def _json_loads(content: Union[bytes, str, orjson.Fragment]) -> Any:
def _json_loads(content: bytes | str | orjson.Fragment) -> Any:
if isinstance(content, orjson.Fragment):
if hasattr(content, "buf"):
content = content.buf
@@ -125,9 +127,7 @@ def _row_to_item(
namespace: tuple[str, ...],
row: dict[str, Any],
*,
loader: Optional[
Callable[[Union[bytes, str, orjson.Fragment]], dict[str, Any]]
] = None,
loader: Callable[[bytes | str | orjson.Fragment], dict[str, Any]] | None = None,
) -> Item:
"""Convert a row from the database into an Item."""
val = row["value"]
@@ -149,9 +149,7 @@ def _row_to_search_item(
namespace: tuple[str, ...],
row: dict[str, Any],
*,
loader: Optional[
Callable[[Union[bytes, str, orjson.Fragment]], dict[str, Any]]
] = None,
loader: Callable[[bytes | str | orjson.Fragment], dict[str, Any]] | None = None,
) -> SearchItem:
"""Convert a row from the database into a SearchItem."""
loader = loader or _json_loads
@@ -196,8 +194,8 @@ class BaseSqliteStore:
MIGRATIONS = MIGRATIONS
VECTOR_MIGRATIONS = VECTOR_MIGRATIONS
supports_ttl = True
index_config: Optional[SqliteIndexConfig] = None
ttl_config: Optional[TTLConfig] = None
index_config: SqliteIndexConfig | None = None
ttl_config: TTLConfig | None = None
def _get_batch_GET_ops_queries(
self, get_ops: Sequence[tuple[int, GetOp]]
@@ -259,7 +257,7 @@ class BaseSqliteStore:
self, put_ops: Sequence[tuple[int, PutOp]]
) -> tuple[
list[tuple[str, Sequence]],
Optional[tuple[str, Sequence[tuple[str, str, str, str]]]],
tuple[str, Sequence[tuple[str, str, str, str]]] | None,
]:
# Last-write wins
dedupped_ops: dict[tuple[tuple[str, ...], str], PutOp] = {}
@@ -288,9 +286,7 @@ class BaseSqliteStore:
params = (_namespace_to_text(namespace), *keys)
queries.append((query, params))
embedding_request: Optional[tuple[str, Sequence[tuple[str, str, str, str]]]] = (
None
)
embedding_request: tuple[str, Sequence[tuple[str, str, str, str]]] | None = None
if inserts:
values = []
insertion_params = []
@@ -358,7 +354,7 @@ class BaseSqliteStore:
def _prepare_batch_search_queries(
self, search_ops: Sequence[tuple[int, SearchOp]]
) -> tuple[
list[tuple[str, list[Union[None, str, list[float]]]]], # queries, params
list[tuple[str, list[None | str | list[float]]]], # queries, params
list[tuple[int, str]], # idx, query_text pairs to embed
]:
"""
@@ -785,11 +781,10 @@ class SqliteStore(BaseSqliteStore, BaseStore):
self,
conn: sqlite3.Connection,
*,
deserializer: Optional[
Callable[[Union[bytes, str, orjson.Fragment]], dict[str, Any]]
] = None,
index: Optional[SqliteIndexConfig] = None,
ttl: Optional[TTLConfig] = None,
deserializer: Callable[[bytes | str | orjson.Fragment], dict[str, Any]]
| None = None,
index: SqliteIndexConfig | None = None,
ttl: TTLConfig | None = None,
):
super().__init__()
self._deserializer = deserializer
@@ -802,7 +797,7 @@ class SqliteStore(BaseSqliteStore, BaseStore):
else:
self.embeddings = None
self.ttl_config = ttl
self._ttl_sweeper_thread: Optional[threading.Thread] = None
self._ttl_sweeper_thread: threading.Thread | None = None
self._ttl_stop_event = threading.Event()
def _get_batch_GET_ops_queries(
@@ -956,9 +951,9 @@ class SqliteStore(BaseSqliteStore, BaseStore):
cls,
conn_string: str,
*,
index: Optional[SqliteIndexConfig] = None,
ttl: Optional[TTLConfig] = None,
) -> Iterator["SqliteStore"]:
index: SqliteIndexConfig | None = None,
ttl: TTLConfig | None = None,
) -> Iterator[SqliteStore]:
"""Create a new SqliteStore instance from a connection string.
Args:
@@ -1087,7 +1082,7 @@ class SqliteStore(BaseSqliteStore, BaseStore):
return deleted_count
def start_ttl_sweeper(
self, sweep_interval_minutes: Optional[int] = None
self, sweep_interval_minutes: int | None = None
) -> concurrent.futures.Future[None]:
"""Periodically delete expired store items based on TTL.
@@ -1144,7 +1139,7 @@ class SqliteStore(BaseSqliteStore, BaseStore):
)
return future
def stop_ttl_sweeper(self, timeout: Optional[float] = None) -> bool:
def stop_ttl_sweeper(self, timeout: float | None = None) -> bool:
"""Stop the TTL sweeper thread if it's running.
Args:
@@ -1396,7 +1391,7 @@ def _ensure_index_config(
) -> tuple[Any, SqliteIndexConfig]:
"""Process and validate index configuration."""
index_config = index_config.copy()
tokenized: list[tuple[str, Union[Literal["$"], list[str]]]] = []
tokenized: list[tuple[str, Literal["$"] | list[str]]] = []
tot = 0
text_fields = index_config.get("text_fields") or ["$"]
if isinstance(text_fields, str):
+1 -1
View File
@@ -54,7 +54,7 @@ lint.select = [
"B", # flake8-bugbear
"I", # isort
]
lint.ignore = ["E501", "B008", "UP007", "UP006"]
lint.ignore = ["E501", "B008"]
[tool.pytest-watcher]
now = true
@@ -1,51 +0,0 @@
from collections.abc import Mapping
from datetime import datetime, timezone
from typing import Any, Optional, Protocol
from langgraph.checkpoint.base import Checkpoint, EmptyChannelError
from langgraph.checkpoint.base.id import uuid6
class ChannelProtocol(Protocol):
def checkpoint(self) -> Optional[Any]: ...
def empty_checkpoint() -> Checkpoint:
return Checkpoint(
v=1,
id=str(uuid6(clock_seq=-2)),
ts=datetime.now(timezone.utc).isoformat(),
channel_values={},
channel_versions={},
versions_seen={},
)
def create_checkpoint(
checkpoint: Checkpoint,
channels: Optional[Mapping[str, ChannelProtocol]],
step: int,
*,
id: Optional[str] = None,
) -> Checkpoint:
"""Create a checkpoint for the given channels."""
ts = datetime.now(timezone.utc).isoformat()
if channels is None:
values = checkpoint["channel_values"]
else:
values = {}
for k, v in channels.items():
if k not in checkpoint["channel_versions"]:
continue
try:
values[k] = v.checkpoint()
except EmptyChannelError:
pass
return Checkpoint(
v=1,
ts=ts,
id=id or str(uuid6(clock_seq=step)),
channel_values=values,
channel_versions=checkpoint["channel_versions"],
versions_seen=checkpoint["versions_seen"],
)
+4 -11
View File
@@ -6,9 +6,10 @@ from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
Checkpoint,
CheckpointMetadata,
create_checkpoint,
empty_checkpoint,
)
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
from tests.checkpoint_utils import create_checkpoint, empty_checkpoint
class TestAsyncSqliteSaver:
@@ -70,7 +71,6 @@ class TestAsyncSqliteSaver:
checkpoint = await saver.aget_tuple(config)
assert checkpoint is not None and checkpoint.metadata == {
**self.metadata_2,
"thread_id": "thread-2",
"run_id": "my_run_id",
}
@@ -91,18 +91,11 @@ class TestAsyncSqliteSaver:
search_results_1 = [c async for c in saver.alist(None, filter=query_1)]
assert len(search_results_1) == 1
assert search_results_1[0].metadata == {
"thread_id": "thread-1",
"thread_ts": "1",
**self.metadata_1,
}
assert search_results_1[0].metadata == self.metadata_1
search_results_2 = [c async for c in saver.alist(None, filter=query_2)]
assert len(search_results_2) == 1
assert search_results_2[0].metadata == {
"thread_id": "thread-2",
**self.metadata_2,
}
assert search_results_2[0].metadata == self.metadata_2
search_results_3 = [c async for c in saver.alist(None, filter=query_3)]
assert len(search_results_3) == 3
+4 -11
View File
@@ -6,10 +6,11 @@ from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
Checkpoint,
CheckpointMetadata,
create_checkpoint,
empty_checkpoint,
)
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.checkpoint.sqlite.utils import _metadata_predicate, search_where
from tests.checkpoint_utils import create_checkpoint, empty_checkpoint
class TestSqliteSaver:
@@ -71,7 +72,6 @@ class TestSqliteSaver:
checkpoint = saver.get_tuple(config)
assert checkpoint is not None and checkpoint.metadata == {
**self.metadata_2,
"thread_id": "thread-2",
"run_id": "my_run_id",
}
@@ -94,18 +94,11 @@ class TestSqliteSaver:
search_results_1 = list(saver.list(None, filter=query_1))
assert len(search_results_1) == 1
assert search_results_1[0].metadata == {
"thread_id": "thread-1",
"thread_ts": "1",
**self.metadata_1,
}
assert search_results_1[0].metadata == self.metadata_1
search_results_2 = list(saver.list(None, filter=query_2))
assert len(search_results_2) == 1
assert search_results_2[0].metadata == {
"thread_id": "thread-2",
**self.metadata_2,
}
assert search_results_2[0].metadata == self.metadata_2
search_results_3 = list(saver.list(None, filter=query_3))
assert len(search_results_3) == 3
+655 -651
View File
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,11 @@
from collections.abc import AsyncIterator, Iterator, Sequence
from __future__ import annotations
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
from typing import ( # noqa: UP035
Any,
Generic,
List,
Literal,
NamedTuple,
Optional,
TypedDict,
TypeVar,
Union,
@@ -13,6 +13,7 @@ from typing import ( # noqa: UP035
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base.id import uuid6
from langgraph.checkpoint.serde.base import SerializerProtocol, maybe_add_typed_methods
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from langgraph.checkpoint.serde.types import (
@@ -20,6 +21,7 @@ from langgraph.checkpoint.serde.types import (
INTERRUPT,
RESUME,
SCHEDULED,
ChannelProtocol,
)
V = TypeVar("V", int, float, str)
@@ -89,6 +91,7 @@ def copy_checkpoint(checkpoint: Checkpoint) -> Checkpoint:
channel_values=checkpoint["channel_values"].copy(),
channel_versions=checkpoint["channel_versions"].copy(),
versions_seen={k: v.copy() for k, v in checkpoint["versions_seen"].items()},
pending_sends=checkpoint.get("pending_sends", []).copy(),
)
@@ -98,8 +101,8 @@ class CheckpointTuple(NamedTuple):
config: RunnableConfig
checkpoint: Checkpoint
metadata: CheckpointMetadata
parent_config: Optional[RunnableConfig] = None
pending_writes: Optional[List[PendingWrite]] = None
parent_config: RunnableConfig | None = None
pending_writes: list[PendingWrite] | None = None
class BaseCheckpointSaver(Generic[V]):
@@ -121,11 +124,20 @@ class BaseCheckpointSaver(Generic[V]):
def __init__(
self,
*,
serde: Optional[SerializerProtocol] = None,
serde: SerializerProtocol | None = None,
) -> None:
self.serde = maybe_add_typed_methods(serde or self.serde)
def get(self, config: RunnableConfig) -> Optional[Checkpoint]:
@property
def config_specs(self) -> list:
"""Define the configuration options for the checkpoint saver.
Returns:
list: List of configuration field specs.
"""
return []
def get(self, config: RunnableConfig) -> Checkpoint | None:
"""Fetch a checkpoint using the given configuration.
Args:
@@ -137,7 +149,7 @@ class BaseCheckpointSaver(Generic[V]):
if value := self.get_tuple(config):
return value.checkpoint
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Fetch a checkpoint tuple using the given configuration.
Args:
@@ -153,11 +165,11 @@ class BaseCheckpointSaver(Generic[V]):
def list(
self,
config: Optional[RunnableConfig],
config: RunnableConfig | None,
*,
filter: Optional[dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
filter: dict[str, Any] | None = None,
before: RunnableConfig | None = None,
limit: int | None = None,
) -> Iterator[CheckpointTuple]:
"""List checkpoints that match the given criteria.
@@ -229,7 +241,7 @@ class BaseCheckpointSaver(Generic[V]):
"""
raise NotImplementedError
async def aget(self, config: RunnableConfig) -> Optional[Checkpoint]:
async def aget(self, config: RunnableConfig) -> Checkpoint | None:
"""Asynchronously fetch a checkpoint using the given configuration.
Args:
@@ -241,7 +253,7 @@ class BaseCheckpointSaver(Generic[V]):
if value := await self.aget_tuple(config):
return value.checkpoint
async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Asynchronously fetch a checkpoint tuple using the given configuration.
Args:
@@ -257,11 +269,11 @@ class BaseCheckpointSaver(Generic[V]):
async def alist(
self,
config: Optional[RunnableConfig],
config: RunnableConfig | None,
*,
filter: Optional[dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
filter: dict[str, Any] | None = None,
before: RunnableConfig | None = None,
limit: int | None = None,
) -> AsyncIterator[CheckpointTuple]:
"""Asynchronously list checkpoints that match the given criteria.
@@ -334,7 +346,7 @@ class BaseCheckpointSaver(Generic[V]):
"""
raise NotImplementedError
def get_next_version(self, current: Optional[V]) -> V:
def get_next_version(self, current: V | None, channel: None) -> V:
"""Generate the next version ID for a channel.
Default is to use integer versions, incrementing by 1. If you override, you can use str/int/float versions,
@@ -342,6 +354,7 @@ class BaseCheckpointSaver(Generic[V]):
Args:
current: The current version identifier (int, float, or str).
channel: Deprecated argument, kept for backwards compatibility.
Returns:
V: The next version identifier, which must be increasing.
@@ -361,7 +374,7 @@ class EmptyChannelError(Exception):
pass
def get_checkpoint_id(config: RunnableConfig) -> Optional[str]:
def get_checkpoint_id(config: RunnableConfig) -> str | None:
"""Get checkpoint ID in a backwards-compatible manner (fallback on thread_ts)."""
return config["configurable"].get(
"checkpoint_id", config["configurable"].get("thread_ts")
@@ -379,11 +392,10 @@ def get_checkpoint_metadata(
for obj in (config.get("metadata"), config.get("configurable")):
if not obj:
continue
for key in obj:
for key, v in obj.items():
if key in metadata or key in EXCLUDED_METADATA_KEYS or key.startswith("__"):
continue
v = obj[key]
if isinstance(v, str):
elif isinstance(v, str):
metadata[key] = v.replace("\u0000", "")
elif isinstance(v, (int, bool, float)):
metadata[key] = v
@@ -400,7 +412,65 @@ Each Checkpointer implementation should use this mapping in put_writes.
WRITES_IDX_MAP = {ERROR: -1, SCHEDULED: -2, INTERRUPT: -3, RESUME: -4}
EXCLUDED_METADATA_KEYS = {
"thread_id",
"thread_ts",
"checkpoint_id",
"checkpoint_ns",
"checkpoint_map",
"langgraph_step",
"langgraph_node",
"langgraph_triggers",
"langgraph_path",
"langgraph_checkpoint_ns",
}
# --- below are deprecated utilities used by past versions of LangGraph ---
LATEST_VERSION = 2
def empty_checkpoint() -> Checkpoint:
from datetime import datetime, timezone
return Checkpoint(
v=LATEST_VERSION,
id=str(uuid6(clock_seq=-2)),
ts=datetime.now(timezone.utc).isoformat(),
channel_values={},
channel_versions={},
versions_seen={},
pending_sends=[],
)
def create_checkpoint(
checkpoint: Checkpoint,
channels: Mapping[str, ChannelProtocol] | None,
step: int,
*,
id: str | None = None,
) -> Checkpoint:
"""Create a checkpoint for the given channels."""
from datetime import datetime, timezone
ts = datetime.now(timezone.utc).isoformat()
if channels is None:
values = checkpoint["channel_values"]
else:
values = {}
for k, v in channels.items():
if k not in checkpoint["channel_versions"]:
continue
try:
values[k] = v.checkpoint()
except EmptyChannelError:
pass
return Checkpoint(
v=LATEST_VERSION,
ts=ts,
id=id or str(uuid6(clock_seq=step)),
channel_values=values,
channel_versions=checkpoint["channel_versions"],
versions_seen=checkpoint["versions_seen"],
pending_sends=checkpoint.get("pending_sends", []),
)
@@ -3,10 +3,11 @@ https://github.com/oittaa/uuid6-python/blob/main/src/uuid6/__init__.py#L95
Bundled in to avoid install issues with uuid6 package
"""
from __future__ import annotations
import random
import time
import uuid
from typing import Optional
_last_v6_timestamp = None
@@ -18,12 +19,12 @@ class UUID(uuid.UUID):
def __init__(
self,
hex: Optional[str] = None,
bytes: Optional[bytes] = None,
bytes_le: Optional[bytes] = None,
fields: Optional[tuple[int, int, int, int, int, int]] = None,
int: Optional[int] = None,
version: Optional[int] = None,
hex: str | None = None,
bytes: bytes | None = None,
bytes_le: bytes | None = None,
fields: tuple[int, int, int, int, int, int] | None = None,
int: int | None = None,
version: int | None = None,
*,
is_safe: uuid.SafeUUID = uuid.SafeUUID.unknown,
) -> None:
@@ -75,7 +76,7 @@ def _subsec_decode(value: int) -> int:
return -(-value * 10**6 // 2**20)
def uuid6(node: Optional[int] = None, clock_seq: Optional[int] = None) -> UUID:
def uuid6(node: int | None = None, clock_seq: int | None = None) -> UUID:
r"""UUID version 6 is a field-compatible version of UUIDv1, reordered for
improved DB locality. It is expected that UUIDv6 will primarily be
used in contexts where there are existing v1 UUIDs. Systems that do
@@ -1,3 +1,5 @@
from __future__ import annotations
import logging
import os
import pickle
@@ -7,7 +9,7 @@ from collections import defaultdict
from collections.abc import AsyncIterator, Iterator, Sequence
from contextlib import AbstractAsyncContextManager, AbstractContextManager, ExitStack
from types import TracebackType
from typing import Any, Optional, Union
from typing import Any
from langchain_core.runnables import RunnableConfig
@@ -63,9 +65,7 @@ class InMemorySaver(
# thread ID -> checkpoint NS -> checkpoint ID -> checkpoint mapping
storage: defaultdict[
str,
dict[
str, dict[str, tuple[tuple[str, bytes], tuple[str, bytes], Optional[str]]]
],
dict[str, dict[str, tuple[tuple[str, bytes], tuple[str, bytes], str | None]]],
]
# (thread ID, checkpoint NS, checkpoint ID) -> (task ID, write idx)
writes: defaultdict[
@@ -74,7 +74,7 @@ class InMemorySaver(
]
blobs: dict[
tuple[
str, str, str, Union[str, int, float]
str, str, str, str | int | float
], # thread id, checkpoint ns, channel, version
tuple[str, bytes],
]
@@ -82,7 +82,7 @@ class InMemorySaver(
def __init__(
self,
*,
serde: Optional[SerializerProtocol] = None,
serde: SerializerProtocol | None = None,
factory: type[defaultdict] = defaultdict,
) -> None:
super().__init__(serde=serde)
@@ -95,26 +95,26 @@ class InMemorySaver(
self.stack.enter_context(self.writes) # type: ignore[arg-type]
self.stack.enter_context(self.blobs) # type: ignore[arg-type]
def __enter__(self) -> "InMemorySaver":
def __enter__(self) -> InMemorySaver:
return self.stack.__enter__()
def __exit__(
self,
exc_type: Optional[type[BaseException]],
exc_value: Optional[BaseException],
traceback: Optional[TracebackType],
) -> Optional[bool]:
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> bool | None:
return self.stack.__exit__(exc_type, exc_value, traceback)
async def __aenter__(self) -> "InMemorySaver":
async def __aenter__(self) -> InMemorySaver:
return self.stack.__enter__()
async def __aexit__(
self,
__exc_type: Optional[type[BaseException]],
__exc_value: Optional[BaseException],
__traceback: Optional[TracebackType],
) -> Optional[bool]:
__exc_type: type[BaseException] | None,
__exc_value: BaseException | None,
__traceback: TracebackType | None,
) -> bool | None:
return self.stack.__exit__(__exc_type, __exc_value, __traceback)
def _load_blobs(
@@ -129,7 +129,7 @@ class InMemorySaver(
channel_values[k] = self.serde.loads_typed(vv)
return channel_values
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Get a checkpoint tuple from the in-memory storage.
This method retrieves a checkpoint tuple from the in-memory storage based on the
@@ -213,11 +213,11 @@ class InMemorySaver(
def list(
self,
config: Optional[RunnableConfig],
config: RunnableConfig | None,
*,
filter: Optional[dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
filter: dict[str, Any] | None = None,
before: RunnableConfig | None = None,
limit: int | None = None,
) -> Iterator[CheckpointTuple]:
"""List checkpoints from the in-memory storage.
@@ -422,7 +422,7 @@ class InMemorySaver(
if k[0] == thread_id:
del self.blobs[k]
async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Asynchronous version of get_tuple.
This method is an asynchronous wrapper around get_tuple that runs the synchronous
@@ -438,11 +438,11 @@ class InMemorySaver(
async def alist(
self,
config: Optional[RunnableConfig],
config: RunnableConfig | None,
*,
filter: Optional[dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
filter: dict[str, Any] | None = None,
before: RunnableConfig | None = None,
limit: int | None = None,
) -> AsyncIterator[CheckpointTuple]:
"""Asynchronous version of list.
@@ -512,7 +512,7 @@ class InMemorySaver(
"""
return self.delete_thread(thread_id)
def get_next_version(self, current: Optional[str]) -> str:
def get_next_version(self, current: str | None, channel: None) -> str:
if current is None:
current_v = 0
elif isinstance(current, int):
@@ -571,7 +571,7 @@ class PersistentDict(defaultdict):
self.sync()
self.clear()
def __enter__(self) -> "PersistentDict":
def __enter__(self) -> PersistentDict:
return self
def __exit__(self, *exc_info: Any) -> None:
@@ -1,3 +1,5 @@
from __future__ import annotations
import dataclasses
import decimal
import importlib
@@ -5,6 +7,7 @@ import json
import pathlib
import pickle
import re
import sys
from collections import deque
from collections.abc import Sequence
from datetime import date, datetime, time, timedelta, timezone
@@ -18,7 +21,7 @@ from ipaddress import (
IPv6Interface,
IPv6Network,
)
from typing import Any, Callable, Optional, Union, cast
from typing import Any, Callable, cast
from uuid import UUID
from zoneinfo import ZoneInfo
@@ -41,7 +44,7 @@ class JsonPlusSerializer(SerializerProtocol):
self,
*,
pickle_fallback: bool = False,
__unpack_ext_hook__: Optional[Callable[[int, bytes], Any]] = None,
__unpack_ext_hook__: Callable[[int, bytes], Any] | None = None,
) -> None:
self.pickle_fallback = pickle_fallback
self._unpack_ext_hook = (
@@ -52,11 +55,11 @@ class JsonPlusSerializer(SerializerProtocol):
def _encode_constructor_args(
self,
constructor: Union[Callable, type[Any]],
constructor: Callable | type[Any],
*,
method: Union[None, str, Sequence[Union[None, str]]] = None,
args: Optional[Sequence[Any]] = None,
kwargs: Optional[dict[str, Any]] = None,
method: None | str | Sequence[None | str] = None,
args: Sequence[Any] | None = None,
kwargs: dict[str, Any] | None = None,
) -> dict[str, Any]:
out = {
"lc": 2,
@@ -71,7 +74,7 @@ class JsonPlusSerializer(SerializerProtocol):
out["kwargs"] = kwargs
return out
def _default(self, obj: Any) -> Union[str, dict[str, Any]]:
def _default(self, obj: Any) -> str | dict[str, Any]:
if isinstance(obj, Serializable):
return cast(dict[str, Any], obj.to_json())
elif hasattr(obj, "model_dump") and callable(obj.model_dump):
@@ -249,9 +252,10 @@ EXT_CONSTRUCTOR_KW_ARGS = 2
EXT_METHOD_SINGLE_ARG = 3
EXT_PYDANTIC_V1 = 4
EXT_PYDANTIC_V2 = 5
EXT_NUMPY_ARRAY = 6
def _msgpack_default(obj: Any) -> Union[str, ormsgpack.Ext]:
def _msgpack_default(obj: Any) -> str | ormsgpack.Ext:
if hasattr(obj, "model_dump") and callable(obj.model_dump): # pydantic v2
return ormsgpack.Ext(
EXT_PYDANTIC_V2,
@@ -318,13 +322,6 @@ def _msgpack_default(obj: Any) -> Union[str, ormsgpack.Ext]:
(obj.__class__.__module__, obj.__class__.__name__, obj.hex),
),
)
elif isinstance(obj, bytearray):
return ormsgpack.Ext(
EXT_CONSTRUCTOR_SINGLE_ARG,
_msgpack_enc(
(obj.__class__.__module__, obj.__class__.__name__, bytes(obj)),
),
)
elif isinstance(obj, decimal.Decimal):
return ormsgpack.Ext(
EXT_CONSTRUCTOR_SINGLE_ARG,
@@ -463,6 +460,22 @@ def _msgpack_default(obj: Any) -> Union[str, ormsgpack.Ext]:
),
),
)
elif (np_mod := sys.modules.get("numpy")) is not None and isinstance(
obj, np_mod.ndarray
):
order = "F" if obj.flags.f_contiguous and not obj.flags.c_contiguous else "C"
if obj.flags.c_contiguous:
mv = memoryview(obj)
try:
meta = (obj.dtype.str, obj.shape, order, mv)
return ormsgpack.Ext(EXT_NUMPY_ARRAY, _msgpack_enc(meta))
finally:
mv.release()
else:
buf = obj.tobytes(order="A")
meta = (obj.dtype.str, obj.shape, order, buf)
return ormsgpack.Ext(EXT_NUMPY_ARRAY, _msgpack_enc(meta))
elif isinstance(obj, BaseException):
return repr(obj)
else:
@@ -544,6 +557,17 @@ def _msgpack_ext_hook(code: int, data: bytes) -> Any:
return tup[2]
except NameError:
return
elif code == EXT_NUMPY_ARRAY:
try:
import numpy as _np
dtype_str, shape, order, buf = ormsgpack.unpackb(
data, ext_hook=_msgpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
)
arr = _np.frombuffer(buf, dtype=_np.dtype(dtype_str))
return arr.reshape(shape, order=order)
except Exception:
return
def _msgpack_ext_hook_to_json(code: int, data: bytes) -> Any:
@@ -624,6 +648,19 @@ def _msgpack_ext_hook_to_json(code: int, data: bytes) -> Any:
return tup[2]
except Exception:
return
elif code == EXT_NUMPY_ARRAY:
try:
import numpy as _np
dtype_str, shape, order, buf = ormsgpack.unpackb(
data,
ext_hook=_msgpack_ext_hook_to_json,
option=ormsgpack.OPT_NON_STR_KEYS,
)
arr = _np.frombuffer(buf, dtype=_np.dtype(dtype_str))
return arr.reshape(shape, order=order).tolist()
except Exception:
return
_option = (
@@ -1,4 +1,13 @@
from typing import Any, Protocol, TypeVar, runtime_checkable
from collections.abc import Sequence
from typing import (
Any,
Optional,
Protocol,
TypeVar,
runtime_checkable,
)
from typing_extensions import Self
ERROR = "__error__"
SCHEDULED = "__scheduled__"
@@ -11,6 +20,25 @@ Update = TypeVar("Update", contravariant=True)
C = TypeVar("C")
class ChannelProtocol(Protocol[Value, Update, C]):
# Mirrors langgraph.channels.base.BaseChannel
@property
def ValueType(self) -> Any: ...
@property
def UpdateType(self) -> Any: ...
def checkpoint(self) -> Optional[C]: ...
def from_checkpoint(self, checkpoint: Optional[C]) -> Self: ...
def update(self, values: Sequence[Update]) -> bool: ...
def get(self) -> Value: ...
def consume(self) -> bool: ...
@runtime_checkable
class SendProtocol(Protocol):
# Mirrors langgraph.constants.Send
@@ -9,6 +9,8 @@ Core types:
- Op: Get/Put/Search/List operations
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Iterable
from datetime import datetime
@@ -16,7 +18,6 @@ from typing import (
Any,
Literal,
NamedTuple,
Optional,
TypedDict,
Union,
cast,
@@ -127,7 +128,7 @@ class SearchItem(Item):
value: dict[str, Any],
created_at: datetime,
updated_at: datetime,
score: Optional[float] = None,
score: float | None = None,
) -> None:
"""Initialize a result item.
@@ -242,7 +243,7 @@ class SearchOp(NamedTuple):
```
"""
filter: Optional[dict[str, Any]] = None
filter: dict[str, Any] | None = None
"""Key-value pairs for filtering results based on exact matches or comparison operators.
The filter supports both exact matches and operator-based comparisons.
@@ -284,7 +285,7 @@ class SearchOp(NamedTuple):
offset: int = 0
"""Number of matching items to skip for pagination."""
query: Optional[str] = None
query: str | None = None
"""Natural language search query for semantic search capabilities.
???+ example "Examples"
@@ -379,7 +380,7 @@ class ListNamespacesOp(NamedTuple):
"""
match_conditions: Optional[tuple[MatchCondition, ...]] = None
match_conditions: tuple[MatchCondition, ...] | None = None
"""Optional conditions for filtering namespaces.
???+ example "Examples"
@@ -397,7 +398,7 @@ class ListNamespacesOp(NamedTuple):
```
"""
max_depth: Optional[int] = None
max_depth: int | None = None
"""Maximum depth of namespace hierarchy to return.
Note:
@@ -452,7 +453,7 @@ class PutOp(NamedTuple):
the full path would effectively be "documents/user123/report1"
"""
value: Optional[dict[str, Any]]
value: dict[str, Any] | None
"""The data to store, or None to mark the item for deletion.
The value must be a dictionary with string keys and JSON-serializable values.
@@ -466,7 +467,7 @@ class PutOp(NamedTuple):
}
"""
index: Optional[Union[Literal[False], list[str]]] = None # type: ignore[assignment]
index: Literal[False] | list[str] | None = None # type: ignore[assignment]
"""Controls how the item's fields are indexed for search operations.
Indexing configuration determines how the item can be found through search:
@@ -501,7 +502,7 @@ class PutOp(NamedTuple):
]
```
"""
ttl: Optional[float] = None
ttl: float | None = None
"""Controls the TTL (time-to-live) for the item in minutes.
If provided, and if the store you are using supports this feature, the item
@@ -530,14 +531,14 @@ class TTLConfig(TypedDict, total=False):
This can be overridden per-operation by explicitly setting refresh_ttl.
Defaults to True if not configured.
"""
default_ttl: Optional[float]
default_ttl: float | None
"""Default TTL (time-to-live) in minutes for new items.
If provided, new items will expire after this many minutes after their last access.
The expiration timer refreshes on both read and write operations.
Defaults to None (no expiration).
"""
sweep_interval_minutes: Optional[int]
sweep_interval_minutes: int | None
"""Interval in minutes between TTL sweep operations.
If provided, the store will periodically delete expired items based on TTL.
@@ -565,7 +566,7 @@ class IndexConfig(TypedDict, total=False):
- cohere:embed-multilingual-light-v3.0: 384
"""
embed: Union[Embeddings, EmbeddingsFunc, AEmbeddingsFunc, str]
embed: Embeddings | EmbeddingsFunc | AEmbeddingsFunc | str
"""Optional function to generate embeddings from text.
Can be specified in three ways:
@@ -633,7 +634,7 @@ class IndexConfig(TypedDict, total=False):
```
"""
fields: Optional[list[str]]
fields: list[str] | None
"""Fields to extract text from for embedding generation.
Controls which parts of stored items are embedded for semantic search. Follows JSON path syntax:
@@ -690,7 +691,7 @@ class BaseStore(ABC):
"""
supports_ttl: bool = False
ttl_config: Optional[TTLConfig] = None
ttl_config: TTLConfig | None = None
__slots__ = ("__weakref__",)
@@ -723,8 +724,8 @@ class BaseStore(ABC):
namespace: tuple[str, ...],
key: str,
*,
refresh_ttl: Optional[bool] = None,
) -> Optional[Item]:
refresh_ttl: bool | None = None,
) -> Item | None:
"""Retrieve a single item.
Args:
@@ -746,11 +747,11 @@ class BaseStore(ABC):
namespace_prefix: tuple[str, ...],
/,
*,
query: Optional[str] = None,
filter: Optional[dict[str, Any]] = None,
query: str | None = None,
filter: dict[str, Any] | None = None,
limit: int = 10,
offset: int = 0,
refresh_ttl: Optional[bool] = None,
refresh_ttl: bool | None = None,
) -> list[SearchItem]:
"""Search for items within a namespace prefix.
@@ -817,9 +818,9 @@ class BaseStore(ABC):
namespace: tuple[str, ...],
key: str,
value: dict[str, Any],
index: Optional[Union[Literal[False], list[str]]] = None,
index: Literal[False] | list[str] | None = None,
*,
ttl: Union[Optional[float], "NotProvided"] = NOT_PROVIDED,
ttl: float | None | NotProvided = NOT_PROVIDED,
) -> None:
"""Store or update an item in the store.
@@ -901,9 +902,9 @@ class BaseStore(ABC):
def list_namespaces(
self,
*,
prefix: Optional[NamespacePath] = None,
suffix: Optional[NamespacePath] = None,
max_depth: Optional[int] = None,
prefix: NamespacePath | None = None,
suffix: NamespacePath | None = None,
max_depth: int | None = None,
limit: int = 100,
offset: int = 0,
) -> list[tuple[str, ...]]:
@@ -956,8 +957,8 @@ class BaseStore(ABC):
namespace: tuple[str, ...],
key: str,
*,
refresh_ttl: Optional[bool] = None,
) -> Optional[Item]:
refresh_ttl: bool | None = None,
) -> Item | None:
"""Asynchronously retrieve a single item.
Args:
@@ -984,11 +985,11 @@ class BaseStore(ABC):
namespace_prefix: tuple[str, ...],
/,
*,
query: Optional[str] = None,
filter: Optional[dict[str, Any]] = None,
query: str | None = None,
filter: dict[str, Any] | None = None,
limit: int = 10,
offset: int = 0,
refresh_ttl: Optional[bool] = None,
refresh_ttl: bool | None = None,
) -> list[SearchItem]:
"""Asynchronously search for items within a namespace prefix.
@@ -1058,9 +1059,9 @@ class BaseStore(ABC):
namespace: tuple[str, ...],
key: str,
value: dict[str, Any],
index: Optional[Union[Literal[False], list[str]]] = None,
index: Literal[False] | list[str] | None = None,
*,
ttl: Union[Optional[float], "NotProvided"] = NOT_PROVIDED,
ttl: float | None | NotProvided = NOT_PROVIDED,
) -> None:
"""Asynchronously store or update an item in the store.
@@ -1150,9 +1151,9 @@ class BaseStore(ABC):
async def alist_namespaces(
self,
*,
prefix: Optional[NamespacePath] = None,
suffix: Optional[NamespacePath] = None,
max_depth: Optional[int] = None,
prefix: NamespacePath | None = None,
suffix: NamespacePath | None = None,
max_depth: int | None = None,
limit: int = 100,
offset: int = 0,
) -> list[tuple[str, ...]]:
@@ -1226,7 +1227,7 @@ def _validate_namespace(namespace: tuple[str, ...]) -> None:
def _ensure_refresh(
ttl_config: Optional[TTLConfig], refresh_ttl: Optional[bool] = None
ttl_config: TTLConfig | None, refresh_ttl: bool | None = None
) -> bool:
if refresh_ttl is not None:
return refresh_ttl
@@ -1236,9 +1237,9 @@ def _ensure_refresh(
def _ensure_ttl(
ttl_config: Optional[TTLConfig],
ttl: Union[Optional[float], "NotProvided"] = NOT_PROVIDED,
) -> Optional[float]:
ttl_config: TTLConfig | None,
ttl: float | None | NotProvided = NOT_PROVIDED,
) -> float | None:
if ttl is NOT_PROVIDED:
if ttl_config:
return ttl_config.get("default_ttl")
+25 -23
View File
@@ -1,10 +1,12 @@
"""Utilities for batching operations in a background task."""
from __future__ import annotations
import asyncio
import functools
import weakref
from collections.abc import Iterable
from typing import Any, Callable, Literal, Optional, TypeVar, Union
from typing import Any, Callable, Literal, TypeVar
from langgraph.store.base import (
NOT_PROVIDED,
@@ -30,7 +32,7 @@ F = TypeVar("F", bound=Callable)
def _check_loop(func: F) -> F:
@functools.wraps(func)
def wrapper(store: "AsyncBatchedBaseStore", *args: Any, **kwargs: Any) -> Any:
def wrapper(store: AsyncBatchedBaseStore, *args: Any, **kwargs: Any) -> Any:
method_name: str = func.__name__
try:
current_loop = asyncio.get_running_loop()
@@ -75,8 +77,8 @@ class AsyncBatchedBaseStore(BaseStore):
namespace: tuple[str, ...],
key: str,
*,
refresh_ttl: Optional[bool] = None,
) -> Optional[Item]:
refresh_ttl: bool | None = None,
) -> Item | None:
assert not self._task.done()
fut = self._loop.create_future()
self._aqueue.put_nowait(
@@ -96,11 +98,11 @@ class AsyncBatchedBaseStore(BaseStore):
namespace_prefix: tuple[str, ...],
/,
*,
query: Optional[str] = None,
filter: Optional[dict[str, Any]] = None,
query: str | None = None,
filter: dict[str, Any] | None = None,
limit: int = 10,
offset: int = 0,
refresh_ttl: Optional[bool] = None,
refresh_ttl: bool | None = None,
) -> list[SearchItem]:
assert not self._task.done()
fut = self._loop.create_future()
@@ -124,9 +126,9 @@ class AsyncBatchedBaseStore(BaseStore):
namespace: tuple[str, ...],
key: str,
value: dict[str, Any],
index: Optional[Union[Literal[False], list[str]]] = None,
index: Literal[False] | list[str] | None = None,
*,
ttl: Union[Optional[float], "NotProvided"] = NOT_PROVIDED,
ttl: float | None | NotProvided = NOT_PROVIDED,
) -> None:
assert not self._task.done()
_validate_namespace(namespace)
@@ -154,9 +156,9 @@ class AsyncBatchedBaseStore(BaseStore):
async def alist_namespaces(
self,
*,
prefix: Optional[NamespacePath] = None,
suffix: Optional[NamespacePath] = None,
max_depth: Optional[int] = None,
prefix: NamespacePath | None = None,
suffix: NamespacePath | None = None,
max_depth: int | None = None,
limit: int = 100,
offset: int = 0,
) -> list[tuple[str, ...]]:
@@ -187,8 +189,8 @@ class AsyncBatchedBaseStore(BaseStore):
namespace: tuple[str, ...],
key: str,
*,
refresh_ttl: Optional[bool] = None,
) -> Optional[Item]:
refresh_ttl: bool | None = None,
) -> Item | None:
return asyncio.run_coroutine_threadsafe(
self.aget(namespace, key=key, refresh_ttl=refresh_ttl), self._loop
).result()
@@ -199,11 +201,11 @@ class AsyncBatchedBaseStore(BaseStore):
namespace_prefix: tuple[str, ...],
/,
*,
query: Optional[str] = None,
filter: Optional[dict[str, Any]] = None,
query: str | None = None,
filter: dict[str, Any] | None = None,
limit: int = 10,
offset: int = 0,
refresh_ttl: Optional[bool] = None,
refresh_ttl: bool | None = None,
) -> list[SearchItem]:
return asyncio.run_coroutine_threadsafe(
self.asearch(
@@ -223,9 +225,9 @@ class AsyncBatchedBaseStore(BaseStore):
namespace: tuple[str, ...],
key: str,
value: dict[str, Any],
index: Optional[Union[Literal[False], list[str]]] = None,
index: Literal[False] | list[str] | None = None,
*,
ttl: Union[Optional[float], "NotProvided"] = NOT_PROVIDED,
ttl: float | None | NotProvided = NOT_PROVIDED,
) -> None:
_validate_namespace(namespace)
asyncio.run_coroutine_threadsafe(
@@ -253,9 +255,9 @@ class AsyncBatchedBaseStore(BaseStore):
def list_namespaces(
self,
*,
prefix: Optional[NamespacePath] = None,
suffix: Optional[NamespacePath] = None,
max_depth: Optional[int] = None,
prefix: NamespacePath | None = None,
suffix: NamespacePath | None = None,
max_depth: int | None = None,
limit: int = 100,
offset: int = 0,
) -> list[tuple[str, ...]]:
@@ -271,7 +273,7 @@ class AsyncBatchedBaseStore(BaseStore):
).result()
def _dedupe_ops(values: list[Op]) -> tuple[Optional[list[int]], list[Op]]:
def _dedupe_ops(values: list[Op]) -> tuple[list[int] | None, list[Op]]:
"""Dedupe operations while preserving order for results.
Args:
@@ -6,11 +6,13 @@ with LangChain-compatible tools while maintaining support for both synchronous a
asynchronous operations.
"""
from __future__ import annotations
import asyncio
import functools
import json
from collections.abc import Awaitable, Sequence
from typing import Any, Callable, Optional, Union
from typing import Any, Callable
from langchain_core.embeddings import Embeddings
@@ -30,7 +32,7 @@ Similar to EmbeddingsFunc, but returns an awaitable that resolves to the embeddi
def ensure_embeddings(
embed: Union[Embeddings, EmbeddingsFunc, AEmbeddingsFunc, str, None],
embed: Embeddings | EmbeddingsFunc | AEmbeddingsFunc | str | None,
) -> Embeddings:
"""Ensure that an embedding function conforms to LangChain's Embeddings interface.
@@ -141,7 +143,7 @@ class EmbeddingsLambda(Embeddings):
def __init__(
self,
func: Union[EmbeddingsFunc, AEmbeddingsFunc],
func: EmbeddingsFunc | AEmbeddingsFunc,
) -> None:
if func is None:
raise ValueError("func must be provided")
@@ -221,7 +223,7 @@ class EmbeddingsLambda(Embeddings):
return (await afunc([text]))[0]
def get_text_at_path(obj: Any, path: Union[str, list[str]]) -> list[str]:
def get_text_at_path(obj: Any, path: str | list[str]) -> list[str]:
"""Extract text from an object using a path expression or pre-tokenized path.
Args:
@@ -279,7 +281,7 @@ def get_text_at_path(obj: Any, path: Union[str, list[str]]) -> list[str]:
for field in fields:
nested_tokens = tokenize_path(field)
if nested_tokens:
current_obj: Optional[dict] = obj
current_obj: dict | None = obj
for nested_token in nested_tokens:
if (
isinstance(current_obj, dict)
@@ -404,7 +406,7 @@ def _is_async_callable(
@functools.lru_cache
def _get_init_embeddings() -> Optional[Callable[[str], Embeddings]]:
def _get_init_embeddings() -> Callable[[str], Embeddings] | None:
try:
from langchain.embeddings import init_embeddings # type: ignore
@@ -99,6 +99,8 @@ Tip:
```
"""
from __future__ import annotations
import asyncio
import concurrent.futures as cf
import functools
@@ -107,7 +109,7 @@ from collections import defaultdict
from collections.abc import Iterable
from datetime import datetime, timezone
from importlib import util
from typing import Any, Optional
from typing import Any
from langchain_core.embeddings import Embeddings
@@ -178,7 +180,7 @@ class InMemoryStore(BaseStore):
"embeddings",
)
def __init__(self, *, index: Optional[IndexConfig] = None) -> None:
def __init__(self, *, index: IndexConfig | None = None) -> None:
# Both _data and _vectors are wrapped in the In-memory API
# Do not change their names
self._data: dict[tuple[str, ...], dict[str, Item]] = defaultdict(dict)
@@ -189,7 +191,7 @@ class InMemoryStore(BaseStore):
self.index_config = index
if self.index_config:
self.index_config = self.index_config.copy()
self.embeddings: Optional[Embeddings] = ensure_embeddings(
self.embeddings: Embeddings | None = ensure_embeddings(
self.index_config.get("embed"),
)
self.index_config["__tokenized_fields"] = [
@@ -325,7 +327,7 @@ class InMemoryStore(BaseStore):
)
# max pooling
seen: set[tuple[tuple[str, ...], str]] = set()
kept: list[tuple[Optional[float], Item]] = []
kept: list[tuple[float | None, Item]] = []
for score, item in sorted_results:
key = (item.namespace, item.key)
if key in seen:
@@ -494,7 +496,7 @@ def _cosine_similarity(X: list[float], Y: list[list[float]]) -> list[float]:
if not Y:
return []
if _check_numpy():
import numpy as np # type: ignore[import-not-found]
import numpy as np
X_arr = np.array(X) if not isinstance(X, np.ndarray) else X
Y_arr = np.array(Y) if not isinstance(Y, np.ndarray) else Y
+6 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint"
version = "2.0.26"
version = "2.1.0"
description = "Library with base interfaces for LangGraph checkpoint savers."
authors = []
requires-python = ">=3.9"
@@ -13,7 +13,7 @@ license = "MIT"
license-files = ['LICENSE']
dependencies = [
"langchain-core>=0.2.38",
"ormsgpack>=1.8.0",
"ormsgpack>=1.10.0",
]
[project.urls]
@@ -29,6 +29,9 @@ dev = [
"pytest-watcher",
"mypy",
"dataclasses-json",
"numpy",
"pandas",
"pandas-stubs>=2.2.2.240807",
]
[tool.hatch.build.targets.wheel]
@@ -46,7 +49,7 @@ lint.select = [
"B", # flake8-bugbear
"I", # isort
]
lint.ignore = ["E501", "B008", "UP007", "UP006"]
lint.ignore = ["E501", "B008"]
[tool.pytest-watcher]
now = true
-51
View File
@@ -1,51 +0,0 @@
from collections.abc import Mapping
from datetime import datetime, timezone
from typing import Any, Optional, Protocol
from langgraph.checkpoint.base import Checkpoint, EmptyChannelError
from langgraph.checkpoint.base.id import uuid6
class ChannelProtocol(Protocol):
def checkpoint(self) -> Optional[Any]: ...
def empty_checkpoint() -> Checkpoint:
return Checkpoint(
v=1,
id=str(uuid6(clock_seq=-2)),
ts=datetime.now(timezone.utc).isoformat(),
channel_values={},
channel_versions={},
versions_seen={},
)
def create_checkpoint(
checkpoint: Checkpoint,
channels: Optional[Mapping[str, ChannelProtocol]],
step: int,
*,
id: Optional[str] = None,
) -> Checkpoint:
"""Create a checkpoint for the given channels."""
ts = datetime.now(timezone.utc).isoformat()
if channels is None:
values = checkpoint["channel_values"]
else:
values = {}
for k, v in channels.items():
if k not in checkpoint["channel_versions"]:
continue
try:
values[k] = v.checkpoint()
except EmptyChannelError:
pass
return Checkpoint(
v=1,
ts=ts,
id=id or str(uuid6(clock_seq=step)),
channel_values=values,
channel_versions=checkpoint["channel_versions"],
versions_seen=checkpoint["versions_seen"],
)
+169 -11
View File
@@ -11,6 +11,9 @@ from ipaddress import IPv4Address
from zoneinfo import ZoneInfo
import dataclasses_json
import numpy as np
import pandas as pd
import pytest
from pydantic import BaseModel, SecretStr
from pydantic.v1 import BaseModel as BaseModelV1
from pydantic.v1 import SecretStr as SecretStrV1
@@ -295,19 +298,174 @@ def test_serde_jsonplus_bytearray() -> None:
assert serde.loads_typed(dumped) == some_bytearray
def test_loads_cannot_find() -> None:
@pytest.mark.parametrize(
"arr",
[
np.arange(9, dtype=np.int32).reshape(3, 3),
np.asfortranarray(np.arange(9, dtype=np.float64).reshape(3, 3)),
np.arange(12, dtype=np.int16)[::2].reshape(3, 2),
],
)
def test_serde_jsonplus_numpy_array(arr: np.ndarray) -> None:
serde = JsonPlusSerializer()
dumped = (
"json",
b'{"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "MyPydanticccc"], "method": null, "args": [], "kwargs": {"foo": "foo", "bar": 1}}',
)
dumped = serde.dumps_typed(arr)
assert dumped[0] == "msgpack"
result = serde.loads_typed(dumped)
assert isinstance(result, np.ndarray)
assert result.dtype == arr.dtype
assert np.array_equal(result, arr)
assert serde.loads_typed(dumped) is None, "Should return None if cannot find class"
dumped = (
"json",
b'{"lc": 2, "type": "constructor", "id": ["tests", "test_jsonpluss", "MyPydantic"], "method": null, "args": [], "kwargs": {"foo": "foo", "bar": 1}}',
)
@pytest.mark.parametrize(
"arr",
[
np.arange(6, dtype=np.float32).reshape(2, 3),
np.asfortranarray(np.arange(4, dtype=np.complex128).reshape(2, 2)),
],
)
def test_serde_jsonplus_numpy_array_json_hook(arr: np.ndarray) -> None:
serde = JsonPlusSerializer(__unpack_ext_hook__=_msgpack_ext_hook_to_json)
dumped = serde.dumps_typed(arr)
assert dumped[0] == "msgpack"
result = serde.loads_typed(dumped)
assert isinstance(result, list)
assert result == arr.tolist()
assert serde.loads_typed(dumped) is None, "Should return None if cannot find module"
@pytest.mark.parametrize(
"df",
[
pd.DataFrame(),
pd.DataFrame({"int_col": [1, 2, 3]}),
pd.DataFrame({"float_col": [1.1, 2.2, 3.3]}),
pd.DataFrame({"str_col": ["a", "b", "c"]}),
pd.DataFrame({"bool_col": [True, False, True]}),
pd.DataFrame(
{
"datetime_col": [
datetime(2024, 1, 1),
datetime(2024, 1, 2),
datetime(2024, 1, 3),
]
}
),
pd.DataFrame(
{
"int_col": [1, 2, 3],
"float_col": [1.1, 2.2, 3.3],
"str_col": ["a", "b", "c"],
}
),
pd.DataFrame(
{
"int_col": [1, 2, None],
"float_col": [1.1, None, 3.3],
"str_col": ["a", None, "c"],
}
),
pd.DataFrame({"cat_col": pd.Categorical(["a", "b", "a", "c"])}),
pd.DataFrame(
{
"int8": pd.array([1, 2, 3], dtype="int8"),
"int16": pd.array([10, 20, 30], dtype="int16"),
"int32": pd.array([100, 200, 300], dtype="int32"),
"int64": pd.array([1000, 2000, 3000], dtype="int64"),
"float32": pd.array([1.1, 2.2, 3.3], dtype="float32"),
"float64": pd.array([10.1, 20.2, 30.3], dtype="float64"),
}
),
pd.DataFrame({"value": [1, 2, 3]}, index=["x", "y", "z"]),
pd.DataFrame(
[[1, 2, 3, 4]],
columns=pd.MultiIndex.from_tuples(
[("A", "X"), ("A", "Y"), ("B", "X"), ("B", "Y")]
),
),
pd.DataFrame(
{"value": [1, 2, 3]}, index=pd.date_range("2024-01-01", periods=3, freq="D")
),
pd.DataFrame(
{
"col1": range(1000),
"col2": [f"str_{i}" for i in range(1000)],
"col3": np.random.rand(1000),
}
),
pd.DataFrame(
{"tz_datetime": pd.date_range("2024-01-01", periods=3, freq="D", tz="UTC")}
),
pd.DataFrame({"timedelta": pd.to_timedelta([1, 2, 3], unit="D")}),
pd.DataFrame({"period": pd.period_range("2024-01", periods=3, freq="M")}),
pd.DataFrame({"interval": pd.interval_range(start=0, end=3, periods=3)}),
pd.DataFrame({"unicode": ["Hello 🌍", "Python 🐍", "Data 📊"]}),
pd.DataFrame({"mixed": [1, "string", [1, 2, 3], {"key": "value"}]}),
pd.DataFrame({"a": [1], "b": ["test"], "c": [3.14]}),
pd.DataFrame({"single": [42]}),
pd.DataFrame(
{
"small": [sys.float_info.min, 0, sys.float_info.max],
"large_int": [-(2**63), 0, 2**63 - 1],
}
),
pd.DataFrame({"special_strings": ["", "null", "None", "NaN", "inf", "-inf"]}),
pd.DataFrame({"bytes_col": [b"hello", b"world", b"\x00\x01\x02"]}),
],
)
def test_serde_jsonplus_pandas_dataframe(df: pd.DataFrame) -> None:
serde = JsonPlusSerializer(pickle_fallback=True)
dumped = serde.dumps_typed(df)
assert dumped[0] == "pickle"
result = serde.loads_typed(dumped)
assert result.equals(df)
@pytest.mark.parametrize(
"series",
[
pd.Series([]),
pd.Series([1, 2, 3]),
pd.Series([1.1, 2.2, 3.3]),
pd.Series(["a", "b", "c"]),
pd.Series([True, False, True]),
pd.Series([datetime(2024, 1, 1), datetime(2024, 1, 2), datetime(2024, 1, 3)]),
pd.Series([1, 2, None]),
pd.Series([1.1, None, 3.3]),
pd.Series(["a", None, "c"]),
pd.Series(pd.Categorical(["a", "b", "a", "c"])),
pd.Series([1, 2, 3], dtype="int8"),
pd.Series([10, 20, 30], dtype="int16"),
pd.Series([100, 200, 300], dtype="int32"),
pd.Series([1000, 2000, 3000], dtype="int64"),
pd.Series([1.1, 2.2, 3.3], dtype="float32"),
pd.Series([10.1, 20.2, 30.3], dtype="float64"),
pd.Series([1, 2, 3], index=["x", "y", "z"]),
pd.Series([1, 2, 3], index=pd.date_range("2024-01-01", periods=3, freq="D")),
pd.Series(range(1000)),
pd.Series(pd.date_range("2024-01-01", periods=3, freq="D", tz="UTC")),
pd.Series(pd.to_timedelta([1, 2, 3], unit="D")),
pd.Series(pd.period_range("2024-01", periods=3, freq="M")),
pd.Series(pd.interval_range(start=0, end=3, periods=3)),
pd.Series(["Hello 🌍", "Python 🐍", "Data 📊"]),
pd.Series([1, "string", [1, 2, 3], {"key": "value"}]),
pd.Series([42], name="single"),
pd.Series([sys.float_info.min, 0, sys.float_info.max]),
pd.Series([-(2**63), 0, 2**63 - 1]),
pd.Series(["", "null", "None", "NaN", "inf", "-inf"]),
pd.Series([b"hello", b"world", b"\x00\x01\x02"]),
pd.Series([1, 2, 3], name="named_series"),
pd.Series(
[10, 20],
index=pd.MultiIndex.from_tuples([("a", 1), ("b", 2)], names=["x", "y"]),
),
],
)
def test_serde_jsonplus_pandas_series(series: pd.Series) -> None:
serde = JsonPlusSerializer(pickle_fallback=True)
dumped = serde.dumps_typed(series)
assert dumped[0] == "pickle"
result = serde.loads_typed(dumped)
assert result.equals(series)
+5 -22
View File
@@ -6,12 +6,10 @@ from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
Checkpoint,
CheckpointMetadata,
)
from langgraph.checkpoint.memory import InMemorySaver
from tests.checkpoint_utils import (
create_checkpoint,
empty_checkpoint,
)
from langgraph.checkpoint.memory import InMemorySaver
class TestMemorySaver:
@@ -77,7 +75,6 @@ class TestMemorySaver:
assert checkpoint is not None
assert checkpoint.metadata == {
**self.metadata_2,
"thread_id": "thread-2",
"run_id": "my_run_id",
}
@@ -114,18 +111,11 @@ class TestMemorySaver:
search_results_1 = list(self.memory_saver.list(None, filter=query_1))
assert len(search_results_1) == 1
assert search_results_1[0].metadata == {
"thread_id": "thread-1",
"thread_ts": "1",
**self.metadata_1,
}
assert search_results_1[0].metadata == self.metadata_1
search_results_2 = list(self.memory_saver.list(None, filter=query_2))
assert len(search_results_2) == 1
assert search_results_2[0].metadata == {
"thread_id": "thread-2",
**self.metadata_2,
}
assert search_results_2[0].metadata == self.metadata_2
search_results_3 = list(self.memory_saver.list(None, filter=query_3))
assert len(search_results_3) == 3
@@ -180,20 +170,13 @@ class TestMemorySaver:
c async for c in self.memory_saver.alist(None, filter=query_1)
]
assert len(search_results_1) == 1
assert search_results_1[0].metadata == {
"thread_id": "thread-1",
"thread_ts": "1",
**self.metadata_1,
}
assert search_results_1[0].metadata == self.metadata_1
search_results_2 = [
c async for c in self.memory_saver.alist(None, filter=query_2)
]
assert len(search_results_2) == 1
assert search_results_2[0].metadata == {
"thread_id": "thread-2",
**self.metadata_2,
}
assert search_results_2[0].metadata == self.metadata_2
search_results_3 = [
c async for c in self.memory_saver.alist(None, filter=query_3)
+987 -651
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -22,10 +22,10 @@ from langgraph.graph import END, StateGraph
from pydantic import BaseModel, Field
from typing_extensions import TypedDict
fast_llm = ChatOpenAI(model="gpt-3.5-turbo")
fast_llm = ChatOpenAI(model="gpt-4o-mini")
# Uncomment for a Fireworks model
# fast_llm = ChatFireworks(model="accounts/fireworks/models/firefunction-v1", max_tokens=32_000)
long_context_llm = ChatOpenAI(model="gpt-4-turbo-preview")
long_context_llm = ChatOpenAI(model="gpt-4o")
direct_gen_outline_prompt = ChatPromptTemplate.from_messages(
@@ -144,7 +144,7 @@ gen_perspectives_prompt = ChatPromptTemplate.from_messages(
)
gen_perspectives_chain = gen_perspectives_prompt | ChatOpenAI(
model="gpt-3.5-turbo"
model="gpt-4o-mini"
).with_structured_output(Perspectives)
@@ -270,7 +270,7 @@ gen_queries_prompt = ChatPromptTemplate.from_messages(
]
)
gen_queries_chain = gen_queries_prompt | ChatOpenAI(
model="gpt-3.5-turbo"
model="gpt-4o-mini"
).with_structured_output(Queries, include_raw=True)

Some files were not shown because too many files have changed in this diff Show More