Compare commits

..
Author SHA1 Message Date
Sydney RunkleandGitHub 8c0e729e68 chore(ci): allow non-main release (#6032) 2025-08-27 16:40:07 -04:00
Sydney RunkleandGitHub adcfa19391 chore(ci): allow release off non main (#6031) 2025-08-27 16:35:57 -04:00
1335f1eb20 release(langgraph): prep for v1 alpha (#6025)
* Bump version of langgraph to v1.0.0a1
* Bump version of prebuilt to v0.7.0a1
* Fix tests w/ new langchain-core alpha version
* Bump min supported python version

Deprecating prebuilts tools w/ helpful IDE hinting + deprecation
messages.

---------

Co-authored-by: ccurme <chester.curme@gmail.com>
2025-08-27 16:31:34 -04:00
284 changed files with 11097 additions and 26693 deletions
-6
View File
@@ -1,6 +0,0 @@
# Contributing to LangGraph
Hi there! Thank you for even being interested in contributing to LangGraph.
As an open-source project in a rapidly developing field, we are extremely open to contributions, whether they involve new features, improved infrastructure, better documentation, or bug fixes.
To learn how to contribute to LangGraph, please follow the [contribution guide here](https://docs.langchain.com/oss/python/contributing).
+5 -5
View File
@@ -1,21 +1,21 @@
name: "\U0001F41B Bug Report" 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 LangChain Forum at forum.langchain.com. description: Report a bug in LangGraph. To report a security issue, please instead use the security option below. For questions, please use the LangChain Forum at forum.langchain.com.
labels: [pending, bug] labels: [pending,bug]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
Thank you for taking the time to file a bug report. Thank you for taking the time to file a bug report.
Use this to report BUGS in LangGraph. For usage questions, feature requests and general design questions, please use the [LangChain Forum](https://forum.langchain.com/). Use this to report BUGS in LangGraph. For usage questions, feature requests and general design questions, please use the [LangChain Forum](https://forum.langchain.com/).
Relevant links to check before filing a bug report to see if your issue has already been reported, fixed or Relevant links to check before filing a bug report to see if your issue has already been reported, fixed or
if there's another way to solve your problem: if there's another way to solve your problem:
* [LangChain Forum](https://forum.langchain.com/), * [LangChain Forum](https://forum.langchain.com/),
* [LangGraph Github Issues](https://github.com/langchain-ai/langgraph/issues), * [LangGraph Github Issues](https://github.com/langchain-ai/langgraph/issues),
* [LangGraph how-to guides](https://langchain-ai.github.io/langgraph/how-tos/). * [LangGraph how-to guides](https://langchain-ai.github.io/langgraph/how-tos/).
* [LangChain documentation with the integrated search](https://docs.langchain.com/), * [LangChain documentation with the integrated search](https://python.langchain.com/docs/get_started/introduction),
* [GitHub search](https://github.com/langchain-ai/langgraph), * [GitHub search](https://github.com/langchain-ai/langgraph),
- type: checkboxes - type: checkboxes
id: checks id: checks
+1 -4
View File
@@ -1,9 +1,6 @@
blank_issues_enabled: false blank_issues_enabled: false
version: 2.1 version: 2.1
contact_links: contact_links:
- name: Documentation
url: https://github.com/langchain-ai/docs/issues/new?template=langgraph.yml
about: Report an issue related to the LangGraph documentation
- name: LangChain Forum - name: LangChain Forum
url: https://forum.langchain.com/ url: https://forum.langchain.com/
about: General community discussions and support about: General community discussions, support, and feature requests
+19
View File
@@ -0,0 +1,19 @@
name: Documentation
description: Report an issue related to the LangGraph documentation.
title: "DOC: <Please write a comprehensive title after the 'DOC: ' prefix>"
labels: [documentation]
body:
- type: textarea
attributes:
label: "Issue with current documentation:"
description: >
Please make sure to leave a reference to the document/code you're
referring to.
- type: textarea
attributes:
label: "Idea or request for content:"
description: >
Please describe as clearly as possible what topics you think are missing
from the current documentation.
+1 -1
View File
@@ -38,7 +38,7 @@ def main():
tree = ast.parse(file.read()) tree = ast.parse(file.read())
classes = find_classes(tree) classes = find_classes(tree)
def is_sync(class_spec: Tuple[str, List[str]]) -> bool: def is_sync(class_spec: Tuple[str, List[str]]) -> bool:
return class_spec[0].startswith("Sync") return class_spec[0].startswith("Sync")
+13 -37
View File
@@ -1,8 +1,7 @@
import logging
import pathlib import pathlib
import sys import sys
import time import time
from urllib import error, request from urllib import request, error
import langgraph_cli import langgraph_cli
import langgraph_cli.config import langgraph_cli.config
@@ -12,13 +11,9 @@ from langgraph_cli.constants import DEFAULT_PORT
from langgraph_cli.exec import Runner, subp_exec from langgraph_cli.exec import Runner, subp_exec
from langgraph_cli.progress import Progress from langgraph_cli.progress import Progress
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
def test(config: pathlib.Path, port: int, tag: str, verbose: bool): def test(config: pathlib.Path, port: int, tag: str, verbose: bool):
"""Spin up API with Postgres/Redis via docker compose and wait until ready.""" """Spin up API with Postgres/Redis via docker compose and wait until ready."""
logger.info("Starting test...")
with Runner() as runner, Progress(message="Pulling...") as set: with Runner() as runner, Progress(message="Pulling...") as set:
# Detect docker/compose capabilities # Detect docker/compose capabilities
capabilities = langgraph_cli.docker.check_capabilities(runner) capabilities = langgraph_cli.docker.check_capabilities(runner)
@@ -62,9 +57,7 @@ def test(config: pathlib.Path, port: int, tag: str, verbose: bool):
sys.stderr.write(f"docker compose up failed: {e}\n") sys.stderr.write(f"docker compose up failed: {e}\n")
try: try:
sys.stderr.write("\n== docker compose ps ==\n") sys.stderr.write("\n== docker compose ps ==\n")
runner.run( runner.run(subp_exec(*compose_cmd, *args, "ps", input=stdin, verbose=False))
subp_exec(*compose_cmd, *args, "ps", input=stdin, verbose=False)
)
except Exception: except Exception:
pass pass
try: try:
@@ -100,7 +93,7 @@ def test(config: pathlib.Path, port: int, tag: str, verbose: bool):
set("") set("")
base_url = f"http://localhost:{port}" base_url = f"http://localhost:{port}"
ok_url = f"{base_url}/ok" ok_url = f"{base_url}/ok"
logger.info(f"Waiting for {ok_url} to respond with 200...") print(f"Waiting for {ok_url} to respond with 200...")
deadline = time.time() + 30 deadline = time.time() + 30
last_err: Exception | None = None last_err: Exception | None = None
while time.time() < deadline: while time.time() < deadline:
@@ -114,16 +107,13 @@ def test(config: pathlib.Path, port: int, tag: str, verbose: bool):
break break
else: else:
last_err = RuntimeError(f"Unexpected status: {resp.status}") last_err = RuntimeError(f"Unexpected status: {resp.status}")
logger.error(f"Unexpected status: {resp.status}") print(f"Unexpected status: {resp.status}")
except error.URLError as e: except error.URLError as e:
logger.error(f"URLError: {e}")
last_err = e last_err = e
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.error(f"Exception: {e}")
last_err = e last_err = e
time.sleep(0.5) time.sleep(0.5)
else: else:
logger.error("Timeout waiting for /ok to return 200")
# Bring stack down before raising # Bring stack down before raising
args_down = [*args, "down", "-v", "--remove-orphans"] args_down = [*args, "down", "-v", "--remove-orphans"]
try: try:
@@ -141,23 +131,15 @@ def test(config: pathlib.Path, port: int, tag: str, verbose: bool):
) )
# Clean up: bring compose stack down to free ports for next test # Clean up: bring compose stack down to free ports for next test
logger.info("Test succeeded. Bringing down compose stack...") args_down = [*args, "down", "-v", "--remove-orphans"]
try: runner.run(
args_down = [*args, "down", "-v", "--remove-orphans"] subp_exec(
runner.run( *compose_cmd,
subp_exec( *args_down,
*compose_cmd, input=stdin,
*args_down, verbose=verbose,
input=stdin,
verbose=verbose,
)
) )
logger.info("Compose stack down. Finishing...") )
except Exception:
logger.exception("Failed to bring down compose stack")
pass
logger.info("Test finished")
if __name__ == "__main__": if __name__ == "__main__":
@@ -168,10 +150,4 @@ if __name__ == "__main__":
parser.add_argument("-c", "--config", type=str, default="./langgraph.json") parser.add_argument("-c", "--config", type=str, default="./langgraph.json")
parser.add_argument("-p", "--port", type=int, default=DEFAULT_PORT) parser.add_argument("-p", "--port", type=int, default=DEFAULT_PORT)
args = parser.parse_args() args = parser.parse_args()
try: test(pathlib.Path(args.config), args.port, args.tag, verbose=True)
test(pathlib.Path(args.config), args.port, args.tag, verbose=True)
except BaseException:
logger.exception("Test failed")
raise
logger.info("Test execution finished")
+45 -74
View File
@@ -13,26 +13,13 @@ jobs:
matrix: matrix:
python-version: python-version:
- "3.10" - "3.10"
- "3.14" - "3.11"
example:
- name: A
workdir: libs/cli/examples
tag: langgraph-test-a
- name: B
workdir: libs/cli/examples/graphs
tag: langgraph-test-b
- name: C
workdir: libs/cli/examples/graphs_reqs_a
tag: langgraph-test-c
- name: D
workdir: libs/cli/examples/graphs_reqs_b
tag: langgraph-test-d
name: "CLI integration test" name: "CLI integration test"
defaults: defaults:
run: run:
working-directory: libs/cli working-directory: libs/cli
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Get changed files - name: Get changed files
id: changed-files id: changed-files
uses: Ana06/get-changed-files@v2.3.0 uses: Ana06/get-changed-files@v2.3.0
@@ -40,79 +27,63 @@ jobs:
filter: "libs/cli/**" filter: "libs/cli/**"
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
if: steps.changed-files.outputs.all if: steps.changed-files.outputs.all
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
enable-cache: true enable-cache: true
cache-suffix: "cli-integration-test" cache-suffix: "cli-integration-test"
ignore-nothing-to-cache: true ignore-nothing-to-cache: true
- name: Setup env
if: steps.changed-files.outputs.all
working-directory: libs/cli/examples
run: cat .env.example > .env
- name: Install cli globally - name: Install cli globally
if: steps.changed-files.outputs.all if: steps.changed-files.outputs.all
run: pip install -e . run: pip install -e .
- name: Build and test service ${{ matrix.example.name }} - name: Build and test service A
if: steps.changed-files.outputs.all if: steps.changed-files.outputs.all
working-directory: ${{ matrix.example.workdir }} working-directory: libs/cli/examples
env: env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }} LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
run: | run: |
# Build the image for this example # The build-arg isn't used; just testing that we accept other args
langgraph build -t ${{ matrix.example.tag }} langgraph build -t langgraph-test-a
# Prepare environment file from local or parent example directory cp .env.example .env
if [ -f .env.example ]; then cp .env.example .env; elif [ -f ../.env.example ]; then cp ../.env.example .env && cp ../.env.example ../.env; fi if [ -n "${{ secrets.LANGSMITH_API_KEY }}" ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env; fi
if [ -n "${{ secrets.LANGSMITH_API_KEY }}" ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env; if [ -f ../.env ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> ../.env; fi; fi timeout 60 python ../../../.github/scripts/run_langgraph_cli_test.py -c langgraph.json -t langgraph-test-a
# Run the integration test using the built tag - name: Build and test service B
# Compute repo root to reference the shared script robustly if: steps.changed-files.outputs.all
REPO_ROOT=$(git rev-parse --show-toplevel) working-directory: libs/cli/examples/graphs
timeout 60 python "$REPO_ROOT/.github/scripts/run_langgraph_cli_test.py" -t ${{ matrix.example.tag }} env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
run: |
langgraph build -t langgraph-test-b
cp ../.env.example .env
if [ -n "${{ secrets.LANGSMITH_API_KEY }}" ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env; fi
timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-b
- name: Build and test service C
if: steps.changed-files.outputs.all
working-directory: libs/cli/examples/graphs_reqs_a
env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
run: |
langgraph build -t langgraph-test-c
cp ../.env.example .env
if [ -n "${{ secrets.LANGSMITH_API_KEY }}" ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env; fi
timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-c
- name: Build and test service D
if: steps.changed-files.outputs.all
working-directory: libs/cli/examples/graphs_reqs_b
env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
run: |
langgraph build -t langgraph-test-d
cp ../.env.example .env
if [ -n "${{ secrets.LANGSMITH_API_KEY }}" ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env; fi
timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-d
- name: Build JS service - name: Build JS service
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }} if: steps.changed-files.outputs.all
working-directory: libs/cli/js-examples working-directory: libs/cli/js-examples
run: | run: |
langgraph build -t langgraph-test-e langgraph build -t langgraph-test-e
- name: Build JS monorepo service
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
working-directory: libs/cli/js-monorepo-example
run: |
langgraph build -t langgraph-test-f -c apps/agent/langgraph.json --build-command "yarn run turbo build" --install-command "yarn install"
- name: Build Python monorepo service
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
working-directory: libs/cli/python-monorepo-example
run: |
langgraph build -t langgraph-test-g -c apps/agent/langgraph.json
cp apps/agent/.env.example apps/agent/.env
if [ -n "${{ secrets.LANGSMITH_API_KEY }}" ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> apps/agent/.env; fi
timeout 60 python ../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-g -c apps/agent/langgraph.json
- name: Build and test prerelease reqs service
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
working-directory: libs/cli/examples/graph_prerelease_reqs
run: |
langgraph build -t langgraph-test-h
cp ../.env.example .env
if [ -n "${{ secrets.LANGSMITH_API_KEY }}" ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env; fi
timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-h
echo "Finished starting up langgraph-test-h"
LANGGRAPH_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langgraph'); print(v);")
if [ "$LANGGRAPH_VERSION" != "1.0.2" ]; then
echo "LANGGRAPH_VERSION != 1.0.2; $LANGGRAPH_VERSION"
exit 1
fi
LANGCHAIN_OPENAI_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-openai'); print(v);")
if [ "$LANGCHAIN_OPENAI_VERSION" != "1.0.1" ]; then
echo "LANGCHAIN_OPENAI_VERSION != 1.0.1; $LANGCHAIN_OPENAI_VERSION"
exit 1
fi
LANGCHAIN_ANTHROPIC_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-anthropic'); print(v);")
if [ "$LANGCHAIN_ANTHROPIC_VERSION" != "1.0.0a5" ]; then
echo "LANGCHAIN_ANTHROPIC_VERSION != 1.0.0a5; $LANGCHAIN_ANTHROPIC_VERSION"
exit 1
fi
- name: Build and test prerelease reqs fail service
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
working-directory: libs/cli/examples/graph_prerelease_reqs_fail
run: |
langgraph build -t langgraph-test-i || [ $? -eq 1 ]
+4 -4
View File
@@ -31,7 +31,7 @@ jobs:
- "3.12" - "3.12"
name: "lint #${{ matrix.python-version }}" name: "lint #${{ matrix.python-version }}"
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Get changed files - name: Get changed files
id: changed-files id: changed-files
uses: Ana06/get-changed-files@v2.3.0 uses: Ana06/get-changed-files@v2.3.0
@@ -39,7 +39,7 @@ jobs:
filter: "${{ inputs.working-directory }}/**" filter: "${{ inputs.working-directory }}/**"
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
if: steps.changed-files.outputs.all if: steps.changed-files.outputs.all
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
enable-cache: true enable-cache: true
@@ -48,7 +48,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
if: steps.changed-files.outputs.all if: steps.changed-files.outputs.all
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
run: uv sync --frozen --group lint run: uv sync --frozen --group dev
- name: Get .mypy_cache to speed up mypy - name: Get .mypy_cache to speed up mypy
if: steps.changed-files.outputs.all if: steps.changed-files.outputs.all
@@ -74,7 +74,7 @@ jobs:
- name: Install test dependencies - name: Install test dependencies
if: steps.changed-files.outputs.all if: steps.changed-files.outputs.all
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
run: uv sync --group lint run: uv sync --group dev
- name: Get .mypy_cache_test to speed up mypy - name: Get .mypy_cache_test to speed up mypy
if: steps.changed-files.outputs.all if: steps.changed-files.outputs.all
+3 -4
View File
@@ -21,13 +21,12 @@ jobs:
- "3.11" - "3.11"
- "3.12" - "3.12"
- "3.13" - "3.13"
- "3.14"
name: "test #${{ matrix.python-version }}" name: "test #${{ matrix.python-version }}"
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
enable-cache: true enable-cache: true
@@ -42,7 +41,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
shell: bash shell: bash
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
run: uv sync --frozen --group test --no-dev run: uv sync --frozen --group dev
- name: Run tests - name: Run tests
shell: bash shell: bash
+3 -4
View File
@@ -16,16 +16,15 @@ jobs:
- "3.11" - "3.11"
- "3.12" - "3.12"
- "3.13" - "3.13"
- "3.14"
defaults: defaults:
run: run:
working-directory: libs/langgraph working-directory: libs/langgraph
name: "test #${{ matrix.python-version }}" name: "test #${{ matrix.python-version }}"
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
enable-cache: true enable-cache: true
@@ -39,7 +38,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
shell: bash shell: bash
run: uv sync --frozen --group test --no-dev run: uv sync --frozen --group dev
- name: Run tests - name: Run tests
shell: bash shell: bash
+5 -5
View File
@@ -23,10 +23,10 @@ jobs:
version: ${{ steps.check-version.outputs.version }} version: ${{ steps.check-version.outputs.version }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python $${ env.PYTHON_VERSION }} - name: Set up Python $${ env.PYTHON_VERSION }}
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v6
with: with:
python-version: ${{ env.PYTHON_VERSION }} python-version: ${{ env.PYTHON_VERSION }}
enable-cache: true enable-cache: true
@@ -48,7 +48,7 @@ jobs:
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
- name: Upload build - name: Upload build
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v4
with: with:
name: test-dist name: test-dist
path: ${{ inputs.working-directory }}/dist/ path: ${{ inputs.working-directory }}/dist/
@@ -74,9 +74,9 @@ jobs:
id-token: write id-token: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: actions/download-artifact@v6 - uses: actions/download-artifact@v4
with: with:
name: test-dist name: test-dist
path: ${{ inputs.working-directory }}/dist/ path: ${{ inputs.working-directory }}/dist/
+3 -3
View File
@@ -17,16 +17,16 @@ jobs:
run: run:
working-directory: libs/langgraph working-directory: libs/langgraph
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- run: SHA=$(git rev-parse HEAD) && echo "SHA=$SHA" >> $GITHUB_ENV - run: SHA=$(git rev-parse HEAD) && echo "SHA=$SHA" >> $GITHUB_ENV
- name: Set up Python 3.11 - name: Set up Python 3.11
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v6
with: with:
python-version: "3.11" python-version: "3.11"
enable-cache: true enable-cache: true
cache-suffix: "bench" cache-suffix: "bench"
- name: Install dependencies - name: Install dependencies
run: uv sync --group test run: uv sync --group dev
- name: Run benchmarks - name: Run benchmarks
run: OUTPUT=out/benchmark-baseline.json make -s benchmark run: OUTPUT=out/benchmark-baseline.json make -s benchmark
- name: Save outputs - name: Save outputs
+4 -4
View File
@@ -15,20 +15,20 @@ jobs:
run: run:
working-directory: libs/langgraph working-directory: libs/langgraph
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- id: files - id: files
name: Get changed files name: Get changed files
uses: Ana06/get-changed-files@v2.3.0 uses: Ana06/get-changed-files@v2.3.0
with: with:
format: json format: json
- name: Set up Python 3.11 - name: Set up Python 3.11
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v6
with: with:
python-version: "3.11" python-version: "3.11"
enable-cache: true enable-cache: true
cache-suffix: "bench" cache-suffix: "bench"
- name: Install dependencies - name: Install dependencies
run: uv sync --group test run: uv sync --group dev
- name: Download baseline - name: Download baseline
uses: actions/cache/restore@v4 uses: actions/cache/restore@v4
with: with:
@@ -57,7 +57,7 @@ jobs:
echo EOF echo EOF
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
- name: Annotation - name: Annotation
uses: actions/github-script@v8 uses: actions/github-script@v7
with: with:
script: | script: |
const file = JSON.parse(`${{ steps.files.outputs.added_modified_renamed }}`)[0] const file = JSON.parse(`${{ steps.files.outputs.added_modified_renamed }}`)[0]
+7 -7
View File
@@ -27,7 +27,7 @@ jobs:
python: ${{ steps.filter.outputs.python }} python: ${{ steps.filter.outputs.python }}
deps: ${{ steps.filter.outputs.deps }} deps: ${{ steps.filter.outputs.deps }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: dorny/paths-filter@v3 - uses: dorny/paths-filter@v3
id: filter id: filter
with: with:
@@ -100,9 +100,9 @@ jobs:
name: "Check SDK methods matching" name: "Check SDK methods matching"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v5
with: with:
python-version: "3.11" python-version: "3.11"
- name: Run check_sdk_methods script - name: Run check_sdk_methods script
@@ -116,13 +116,13 @@ jobs:
strategy: strategy:
matrix: matrix:
python-version: python-version:
- "3.13" - "3.11"
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v6
with: with:
python-version: "3.13" python-version: "3.11"
enable-cache: true enable-cache: true
cache-suffix: "schema-check-cli" cache-suffix: "schema-check-cli"
- name: Install CLI dependencies - name: Install CLI dependencies
+1 -1
View File
@@ -21,7 +21,7 @@
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Install Dependencies - name: Install Dependencies
run: | run: |
+150
View File
@@ -0,0 +1,150 @@
name: Deploy Docs
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
defaults:
run:
working-directory: docs
jobs:
get-changed-files:
runs-on: ubuntu-latest
outputs:
changed-files: ${{ steps.changed-files.outputs.added_modified }}
steps:
- uses: actions/checkout@v4
- name: Get changed files
id: changed-files
uses: Ana06/get-changed-files@v2.3.0
with:
filter: "docs/docs/**"
deploy:
runs-on: ubuntu-latest
timeout-minutes: 10 # Job will be cancelled if it runs for more than 10 minutes
env:
GITHUB_TOKEN: ${{ secrets.MKDOCS_GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: astral-sh/setup-uv@v6
with:
python-version: "3.12"
enable-cache: true
cache-suffix: "docs"
- name: Install dependencies
run: |
yarn
uv sync --all-groups
# we run this installation only for internal PRs
# as GITHUB_TOKEN is not available for PRs from outside contributors
if [ -n "${GITHUB_TOKEN}" ]; then
uv run pip install "git+https://${GITHUB_TOKEN}@github.com/langchain-ai/mkdocs-material-insiders.git"
fi
- name: Run unit tests
# Run unit tests on the docs build pipeline
run: make tests
- name: Lint Docs
# This step lints the docs using the existing linting set up.
# It should be very fast and should not require any external services.
run: make lint-docs
- name: Build llms-text
run: make llms-text
- name: Build site
run: |
# If this is main branch, then we want to download stats. we do this
# with the env variable DOWNLOAD_STATS=true
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
DOWNLOAD_STATS=true make build-docs
else
make build-docs
fi
env:
MKDOCS_GIT_COMMITTERS_APIKEY: ${{ secrets.MKDOCS_GIT_COMMITTERS_APIKEY }}
OPENAI_API_KEY: sf-proj-1234567890 # fake placeholder, shouldn't actually be used
ANTHROPIC_API_KEY: sk-ant-api03-1234567890 # fake placeholder, shouldn't actually be used
- name: Check links in notebooks
env:
LANGCHAIN_API_KEY: test
if: github.event_name == 'schedule'
run: |
if [ "${{ github.event_name }}" == "schedule" ]; then
echo "Running link check on all HTML files matching notebooks in docs directory..."
uv run pytest -v \
--check-links-ignore "https://(api|web|docs)\.smith\.langchain\.com/.*" \
--check-links-ignore "https://academy\.langchain\.com/.*" \
--check-links-ignore "https://x.com/.*" \
--check-links-ignore "https://twitter.com/.*" \
--check-links-ignore "https://github\.com/.*" \
--check-links-ignore "http://localhost:8123/.*" \
--check-links-ignore "http://localhost:2024.*" \
--check-links-ignore "http://127.0.0.1:.*" \
--check-links-ignore "/.*\.(ipynb|html)$" \
--check-links-ignore "https://python\.langchain\.com/.*" \
--check-links-ignore "https://openai\.com/.*" \
--check-links-ignore "https://www\.uber\.com/.*" \
--check-links-ignore "https://pepy\.tech/.*" \
--check-links-ignore "docs/docs/static/wordmark_*" \
--check-links $(find site -name "index.html" | grep -v 'storm/index.html')
else
echo "Fetching changes from origin/main..."
git fetch origin main
echo "Checking for changed notebook files..."
CHANGED_FILES=$(git diff --name-only --diff-filter=d origin/main | grep 'docs/docs/.*\.ipynb$' | grep -v 'storm.ipynb' | sed -E 's|^docs/docs/|site/|; s/\.ipynb$/\/index.html/' || true)
echo "Changed files: ${CHANGED_FILES}"
if [ -n "${CHANGED_FILES}" ]; then
echo "Running link check on HTML files matching changed notebook files..."
uv run pytest -v \
--check-links-ignore "https://(api|web|docs)\.smith\.langchain\.com/.*" \
--check-links-ignore "https://academy\.langchain\.com/.*" \
--check-links-ignore "http://localhost:8123/.*" \
--check-links-ignore "http://localhost:2024.*" \
--check-links-ignore "http://127.0.0.1:.*" \
--check-links-ignore "https://x.com/.*" \
--check-links-ignore "https://twitter.com/.*" \
--check-links-ignore "https://github\.com/.*" \
--check-links-ignore "/.*\.(ipynb|html)$" \
--check-links-ignore "docs/docs/static/wordmark_*" \
--check-links ${CHANGED_FILES} \
|| ([ $? = 5 ] && exit 0 || exit $?)
else
echo "No notebook files changed."
fi
fi
- name: Configure GitHub Pages
if: github.ref == 'refs/heads/main'
uses: actions/configure-pages@v5
- name: Upload Pages Artifact
# if: github.ref == 'refs/heads/main'
uses: actions/upload-pages-artifact@v3
with:
path: ./docs/site/
- name: Deploy to GitHub Pages
if: github.ref == 'refs/heads/main'
id: deployment
uses: actions/deploy-pages@v4
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -36,7 +36,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 1
+1 -2
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Validate PR Title - name: Validate PR Title
uses: amannn/action-semantic-pull-request@v6 uses: amannn/action-semantic-pull-request@v5
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
@@ -40,7 +40,6 @@ jobs:
sdk-py sdk-py
docs docs
ci ci
deps
requireScope: false requireScope: false
ignoreLabels: | ignoreLabels: |
ignore-lint-pr-title ignore-lint-pr-title
+13 -14
View File
@@ -25,10 +25,10 @@ jobs:
tag: ${{ steps.check-version.outputs.tag }} tag: ${{ steps.check-version.outputs.tag }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v6
with: with:
python-version: ${{ env.PYTHON_VERSION }} python-version: ${{ env.PYTHON_VERSION }}
enable-cache: true enable-cache: true
@@ -50,7 +50,7 @@ jobs:
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
- name: Upload build - name: Upload build
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v4
with: with:
name: dist name: dist
path: ${{ inputs.working-directory }}/dist/ path: ${{ inputs.working-directory }}/dist/
@@ -86,7 +86,7 @@ jobs:
outputs: outputs:
release-body: ${{ steps.generate-release-body.outputs.release-body }} release-body: ${{ steps.generate-release-body.outputs.release-body }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
repository: langchain-ai/langgraph repository: langchain-ai/langgraph
path: langgraph path: langgraph
@@ -157,7 +157,7 @@ jobs:
- test-pypi-publish - test-pypi-publish
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
# We explicitly *don't* set up caching here. This ensures our tests are # We explicitly *don't* set up caching here. This ensures our tests are
# maximally sensitive to catching breakage. # maximally sensitive to catching breakage.
@@ -173,7 +173,7 @@ jobs:
# used in the real world. # used in the real world.
- name: Set up Python - name: Set up Python
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v6
with: with:
python-version: ${{ env.PYTHON_VERSION }} python-version: ${{ env.PYTHON_VERSION }}
enable-cache: true enable-cache: true
@@ -221,7 +221,7 @@ jobs:
uv run python -c "import $IMPORT_NAME; print(dir($IMPORT_NAME))" uv run python -c "import $IMPORT_NAME; print(dir($IMPORT_NAME))"
- name: Import test dependencies - name: Import test dependencies
run: uv sync --group test run: uv sync --group dev
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
# Overwrite the local version of the package with the test PyPI version. # Overwrite the local version of the package with the test PyPI version.
@@ -260,16 +260,16 @@ jobs:
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v6
with: with:
python-version: ${{ env.PYTHON_VERSION }} python-version: ${{ env.PYTHON_VERSION }}
enable-cache: true enable-cache: true
cache-suffix: "release" cache-suffix: "release"
- uses: actions/download-artifact@v6 - uses: actions/download-artifact@v4
with: with:
name: dist name: dist
path: ${{ inputs.working-directory }}/dist/ path: ${{ inputs.working-directory }}/dist/
@@ -301,16 +301,16 @@ jobs:
working-directory: ${{ inputs.working-directory }} working-directory: ${{ inputs.working-directory }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v6
with: with:
python-version: ${{ env.PYTHON_VERSION }} python-version: ${{ env.PYTHON_VERSION }}
enable-cache: true enable-cache: true
cache-suffix: "release" cache-suffix: "release"
- uses: actions/download-artifact@v6 - uses: actions/download-artifact@v4
with: with:
name: dist name: dist
path: ${{ inputs.working-directory }}/dist/ path: ${{ inputs.working-directory }}/dist/
@@ -322,6 +322,5 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
generateReleaseNotes: false generateReleaseNotes: false
tag: ${{needs.build.outputs.tag}} tag: ${{needs.build.outputs.tag}}
name: ${{ needs.build.outputs.pkg-name }}==${{ needs.build.outputs.version }}
body: ${{ needs.release-notes.outputs.release-body }} body: ${{ needs.release-notes.outputs.release-body }}
commit: ${{ github.sha }} commit: ${{ github.sha }}
+2 -2
View File
@@ -28,9 +28,9 @@ jobs:
- "latest" - "latest"
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Python + Poetry - name: Set up Python + Poetry
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v6
with: with:
python-version: "3.11" python-version: "3.11"
enable-cache: true enable-cache: true
+4 -4
View File
@@ -16,10 +16,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up uv - name: Set up uv
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v6
with: with:
# use minimum supported Python version # use minimum supported Python version
python-version: "3.10" python-version: "3.10"
@@ -33,8 +33,8 @@ jobs:
uses: peter-evans/create-pull-request@v7 uses: peter-evans/create-pull-request@v7
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "chore(deps): upgrade dependencies with `uv lock --upgrade`" commit-message: "chore[deps]: upgrade dependencies with `uv lock --upgrade`"
title: "chore(deps): upgrade dependencies with `uv lock --upgrade`" title: "chore[deps]: upgrade dependencies with `uv lock --upgrade`"
body: | body: |
This PR updates the dependencies in all Python packages using `uv lock --upgrade`. This PR updates the dependencies in all Python packages using `uv lock --upgrade`.
+1 -1
View File
@@ -28,7 +28,7 @@ Below is a high-level overview:
- **langgraph** core framework for building stateful, multi-actor agents. - **langgraph** core framework for building stateful, multi-actor agents.
- **prebuilt** high-level APIs for creating and running agents and tools. - **prebuilt** high-level APIs for creating and running agents and tools.
- **sdk-js** JS/TS SDK for interacting with the LangGraph REST API. - **sdk-js** JS/TS SDK for interacting with the LangGraph REST API.
- **sdk-py** Python SDK for the LangGraph Server API. - **sdk-py** Python SDK for the LangGraph Platform API.
### Dependency map ### Dependency map
-55
View File
@@ -1,55 +0,0 @@
# 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 Server 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.
+293
View File
@@ -0,0 +1,293 @@
# Contributing to LangGraph
Thank you for being interested in contributing to LangGraph!
## General guidelines
Here are some things to keep in mind for all types of contributions:
- Follow the ["fork and pull request"](https://docs.github.com/en/get-started/exploring-projects-on-github/contributing-to-a-project) workflow.
- Fill out the checked-in pull request template when opening pull requests. Note related issues and tag relevant maintainers.
- Ensure your PR passes formatting, linting, and testing checks before requesting a review.
- If you would like comments or feedback, please tag a maintainer.
- Backwards compatibility is key. Your changes must not be breaking, except in case of critical bug and security fixes.
- Look for duplicate PRs or issues that have already been opened before opening a new one.
- Keep scope as isolated as possible. As a general rule, your changes should not affect more than one package at a time.
### Bugfixes
For bug fixes, please open up an issue before proposing a fix to ensure the proposal properly addresses the underlying problem. In general, bug fixes should all have an accompanying unit test that fails before the fix.
### New features
For new features, please start a new [discussion](https://forum.langchain.com/), where the maintainers will help with scoping out the necessary changes.
## Contribute Documentation
Documentation is a vital part of LangGraph. We welcome both new documentation for new features and
community improvements to our current documentation. Please read the resources below before getting started:
- [Documentation style guide](#documentation-style-guide)
- [Documentation setup](#setup)
## Documentation Style Guide
As LangGraph continues to grow, the surface area of documentation required to cover it continues to grow too.
This page provides guidelines for anyone writing documentation for LangGraph, as well as some of our philosophies around organization and structure.
## Philosophy
LangGraph's documentation follows the [Diataxis framework](https://diataxis.fr).
Under this framework, all documentation falls under one of four categories: [Tutorials](#tutorials),
[How-to guides](#how-to-guides),
[References](#references), and [Explanations (aka conceptual guides)](#conceptual-guide).
### Tutorials
Tutorials are lessons that take the reader through a practical activity. Their purpose is to help the user
gain understanding of concepts and how they interact by showing one way to achieve some goal in a hands-on way.
They should **avoid** giving
multiple permutations of ways to achieve that goal in-depth. Choice is burdensome. Instead, they should guide a new user through a recommended path to accomplishing a concrete goal. While the end result of a tutorial does not necessarily need to
be completely production-ready, it should be useful and practically satisfy the goal that you clearly stated in the tutorial's introduction.
To quote the Diataxis website:
> A tutorial serves the users *acquisition* of skills and knowledge - their study. Its purpose is not to help the user get something done, but to help them learn.
In LangGraph, these are often higher level guides that show off end-to-end use cases.
Some examples include:
- [Build a Customer Support Bot](https://langchain-ai.github.io/langgraph/tutorials/customer-support/customer-support/)
- [Build a SQL Agent](https://langchain-ai.github.io/langgraph/tutorials/sql/sql-agent/)
Here are some high-level tips on writing a good tutorial:
- Focus on guiding the user to get something done, but keep in mind the end-goal is more to impart principles than to create a perfect production system.
- Be specific, not abstract and follow one path.
- No need to go deeply into alternative approaches, but its ok to reference them, ideally with a link to an appropriate how-to guide.
- Get "a point on the board" as soon as possible - something the user can run that outputs something.
- You can iterate and expand afterwards.
- Try to frequently checkpoint at given steps where the user can run code and see progress.
- Focus on results, not technical explanation.
- Crosslink heavily to appropriate conceptual/reference pages
- The first time you mention a LangGraph concept, use its full name (e.g. "human-in-the-loop"), and link to its conceptual/other documentation page.
- It's also helpful to add a prerequisite callout that links to any pages with necessary background information.
- End with a recap/next steps section summarizing what the tutorial covered and future reading, such as related how-to guides.
- Use phrases like "Next we can run X & Y. We will expect Z.". Then afterwards, use language like "Notice Z" that recalls our expectations and directs the reader's attention to the topic we are trying to teach.
- Do not shy away from repetition.
### How-to guides
A how-to guide, as the name implies, demonstrates how to do something discrete and specific.
It should assume that the user is already familiar with underlying concepts, and is trying to solve an immediate problem, but
should still give some background or list the scenarios where the information contained within can be relevant.
They can and should discuss alternatives if one approach may be better than another in certain cases.
To quote the Diataxis website:
> A how-to guide serves the work of the already-competent user, whom you can assume to know what they want to do, and to be able to follow your instructions correctly.
Some examples include:
- [How to add persistence to your graph](https://langchain-ai.github.io/langgraph/how-tos/persistence/)
- [How to view and update past graph state](https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/time-travel/)
Here are some high-level tips on writing a good how-to guide:
- Clearly explain what you are guiding the user through at the start
- Assume higher intent than a tutorial and show what the user needs to do to get that task done
- Assume familiarity of concepts, but explain why suggested actions are helpful
- Crosslink heavily to conceptual/reference pages
- Discuss alternatives and responses to real-world tradeoffs that may arise when solving a problem
- Use lots of example code, ideally within complete code blocks that the reader can copy and run.
- End with a recap/next steps section summarizing what the tutorial covered and future reading, such as other related how-to guides
### Conceptual guides
LangGraph's conceptual guides fall under the **Explanation** quadrant of Diataxis. They should cover LangChain terms and concepts
in a more abstract way than how-to guides or tutorials, and should be geared towards curious users interested in
gaining a deeper understanding of the framework. Try to avoid excessively large code examples. The goal here is to
impart perspective to the user rather than to finish a practical project. These guides should cover **why** things work the way they do.
To quote the Diataxis website:
> The perspective of explanation is higher and wider than that of the other types. It does not take the users eye-level view, as in a how-to guide, or a close-up view of the machinery, like reference material. Its scope in each case is a topic - “an area of knowledge”, that somehow has to be bounded in a reasonable, meaningful way.
Some examples include:
- [What does it mean to be agentic?](https://langchain-ai.github.io/langgraph/concepts/high_level/)
- [Tool calling](https://langchain-ai.github.io/langgraph/concepts/agentic_concepts/#tool-calling)
Here are some high-level tips on writing a good conceptual guide:
- Explain design decisions. Why does concept X exist and why was it designed this way?
- Use analogies and reference other concepts and alternatives
- Avoid blending in too much reference content
- You can and should reference content covered in other guides, but make sure to link to them
### References
References contain detailed, low-level information that describes exactly what functionality exists and how to use it.
In LangGraph, this is mainly our API reference pages, which are populated from docstrings within code.
References pages are generally not read end-to-end, but are consulted as necessary when a user needs to know
how to use something specific.
To quote the Diataxis website:
> The only purpose of a reference guide is to describe, as succinctly as possible, and in an orderly way. Whereas the content of tutorials and how-to guides are led by needs of the user, reference material is led by the product it describes.
Many of the reference pages in LangChain are automatically generated from code,
but here are some high-level tips on writing a good docstring:
- Be concise
- Discuss special cases and deviations from a user's expectations
- Go into detail on required inputs and outputs
- Light details on when one might use the feature are fine, but in-depth details belong in other sections.
Each category serves a distinct purpose and requires a specific approach to writing and structuring the content.
## General guidelines
Here are some other guidelines you should think about when writing and organizing documentation.
We generally do not merge new tutorials from outside contributors without an actual need.
We welcome updates as well as new integration docs, how-tos, and references.
### Avoid duplication
Multiple pages that cover the same material in depth are difficult to maintain and cause confusion. There should
be only one (very rarely two), canonical pages for a given concept or feature. Instead, you should link to other guides.
### Link to other sections
Because sections of the docs do not exist in a vacuum, it is important to link to other sections as often as possible
to allow a developer to learn more about an unfamiliar topic inline.
This includes linking to the API references as well as conceptual sections!
### Be concise
In general, take a less-is-more approach. If a section with a good explanation of a concept already exists, you should link to it rather than
re-explain it, unless the concept you are documenting presents some new wrinkle.
Be concise, including in code samples.
### General style
- Use active voice and present tense whenever possible
- Use examples and code snippets to illustrate concepts and usage
- Use appropriate header levels (`#`, `##`, `###`, etc.) to organize the content hierarchically
- Use fewer cells with more code to make copy/paste easier
- Use bullet points and numbered lists to break down information into easily digestible chunks
- Use tables (especially for **Reference** sections) and diagrams often to present information visually
- Include the table of contents for longer documentation pages to help readers navigate the content, but hide it for shorter pages
## Setup
LangGraph documentation consists of two components:
1. Main Documentation: Hosted at [https://langchain-ai.github.io/langgraph/](https://langchain-ai.github.io/langgraph/),
this comprehensive resource serves as the primary user-facing documentation.
It covers a wide array of topics, including tutorials, use cases, integrations,
and more, offering extensive guidance on building with LangGraph.
The content for this documentation lives in the `/docs` directory of the monorepo.
2. In-code Documentation: This is documentation of the codebase itself, which is also
used to generate the externally facing [API Reference](https://langchain-ai.github.io/langgraph/reference/graphs/).
The content for the API reference is autogenerated by scanning the docstrings in the codebase. For this reason we ask that developers document their code well.
We appreciate all contributions to the documentation, whether it be fixing a typo,
adding a new tutorial or example and whether it be in the main documentation or the API Reference.
### 📜 Main Documentation
The content for the main documentation is located in the `/docs` directory of the monorepo.
The documentation is written using a combination of ipython notebooks (`.ipynb` files)
and markdown (`.md` files). The notebooks are converted to markdown
and then built using [MkDocs](https://www.mkdocs.org/).
Feel free to make contributions to the main documentation! 🥰
After modifying the documentation:
1. Run the linting and formatting commands (see below) to ensure that the documentation is well-formatted and free of errors.
2. Optionally build the documentation locally to verify that the changes look good.
3. Make a pull request with the changes.
### ⚒️ Linting and Building Documentation Locally
After writing up the documentation, you may want to lint and build the documentation
locally to ensure that it looks good and is free of errors.
If you're unable to build it locally that's okay as well, as you will be able to
see a preview of the documentation on the pull request page.
From the **monorepo root**, run the following command to install the dependencies:
<!-- TODO -->
```bash
poetry install --with docs --no-root
```
#### Building
The code that builds the documentation is located in the `/docs` directory of the monorepo.
Before building the documentation, it is always a good idea to clean the build directory:
```bash
make clean-docs
```
You can build and preview the documentation as outlined below:
```bash
make serve-docs
```
#### Linting
To spell check the docs, run the following from the `docs` directory:
```bash
codespell --skip="*.ambr,*.lock,*.ipynb,*.yaml,*.zlib,*.css.map,*.js.map" --ignore-words-list="infor,thead,stdio,nd,jupyter,lets,lite,uis,deque" .
```
### In-code Documentation
The in-code documentation is autogenerated from docstrings.
For the API reference to be useful, the codebase must be well-documented. This means that all functions, classes, and methods should have a docstring that explains what they do, what the arguments are, and what the return value is. This is a good practice in general, but it is especially important for LangGraph because the API reference is the primary resource for developers to understand how to use the codebase.
We generally follow the [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) for docstrings.
Here is an example of a well-documented function:
```python
def my_function(arg1: int, arg2: str) -> float:
"""This is a short description of the function. (It should be a single sentence.)
This is a longer description of the function. It should explain what
the function does, what the arguments are, and what the return value is.
It should wrap at 88 characters.
Examples:
This is a section for examples of how to use the function.
.. code-block:: python
my_function(1, "hello")
Args:
arg1: This is a description of arg1. We do not need to specify the type since
it is already specified in the function signature.
arg2: This is a description of arg2.
Returns:
This is a description of the return value.
"""
return 3.14
```
+5 -5
View File
@@ -63,15 +63,15 @@ LangGraph provides low-level supporting infrastructure for *any* long-running, s
While LangGraph can be used standalone, it also integrates seamlessly with any LangChain product, giving developers a full suite of tools for building agents. To improve your LLM application development, pair LangGraph with: While LangGraph can be used standalone, it also integrates seamlessly with any LangChain product, giving developers a full suite of tools for building agents. To improve your LLM application development, pair LangGraph with:
- [LangSmith](http://www.langchain.com/langsmith) — Helpful for agent evals and observability. Debug poor-performing LLM app runs, evaluate agent trajectories, gain visibility in production, and improve performance over time. - [LangSmith](http://www.langchain.com/langsmith) — Helpful for agent evals and observability. Debug poor-performing LLM app runs, evaluate agent trajectories, gain visibility in production, and improve performance over time.
- [LangSmith Deployment](https://langchain-ai.github.io/langgraph/concepts/langgraph_platform/) — Deploy and scale agents effortlessly with a purpose-built deployment platform for long running, stateful workflows. Discover, reuse, configure, and share agents across teams — and iterate quickly with visual prototyping in [LangGraph Studio](https://langchain-ai.github.io/langgraph/concepts/langgraph_studio/). - [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/langgraph_platform/) — Deploy and scale agents effortlessly with a purpose-built deployment platform for long running, stateful workflows. Discover, reuse, configure, and share agents across teams — and iterate quickly with visual prototyping in [LangGraph Studio](https://langchain-ai.github.io/langgraph/concepts/langgraph_studio/).
- [LangChain](https://docs.langchain.com/oss/python/langchain/overview) Provides integrations and composable components to streamline LLM application development. - [LangChain](https://python.langchain.com/docs/introduction/) Provides integrations and composable components to streamline LLM application development.
> [!NOTE] > [!NOTE]
> Looking for the JS version of LangGraph? See the [JS repo](https://github.com/langchain-ai/langgraphjs) and the [JS docs](https://docs.langchain.com/oss/javascript/langgraph/overview). > Looking for the JS version of LangGraph? See the [JS repo](https://github.com/langchain-ai/langgraphjs) and the [JS docs](https://langchain-ai.github.io/langgraphjs/).
## Additional resources ## Additional resources
- [Guides](https://langchain-ai.github.io/langgraph/guides/): Quick, actionable code snippets for topics such as streaming, adding memory & persistence, and design patterns (e.g. branching, subgraphs, etc.). - [Guides](https://langchain-ai.github.io/langgraph/how-tos/): Quick, actionable code snippets for topics such as streaming, adding memory & persistence, and design patterns (e.g. branching, subgraphs, etc.).
- [Reference](https://langchain-ai.github.io/langgraph/reference/graphs/): Detailed reference on core classes, methods, how to use the graph and checkpointing APIs, and higher-level prebuilt components. - [Reference](https://langchain-ai.github.io/langgraph/reference/graphs/): Detailed reference on core classes, methods, how to use the graph and checkpointing APIs, and higher-level prebuilt components.
- [Examples](https://langchain-ai.github.io/langgraph/examples/): Guided examples on getting started with LangGraph. - [Examples](https://langchain-ai.github.io/langgraph/examples/): Guided examples on getting started with LangGraph.
- [LangChain Forum](https://forum.langchain.com/): Connect with the community and share all of your technical questions, ideas, and feedback. - [LangChain Forum](https://forum.langchain.com/): Connect with the community and share all of your technical questions, ideas, and feedback.
@@ -81,4 +81,4 @@ While LangGraph can be used standalone, it also integrates seamlessly with any L
## Acknowledgements ## Acknowledgements
LangGraph is inspired by [Pregel](https://research.google/pubs/pub37252/) and [Apache Beam](https://beam.apache.org/). The public interface draws inspiration from [NetworkX](https://networkx.org/documentation/latest/). LangGraph is built by LangChain Inc, the creators of LangChain, but can be used without LangChain. LangGraph is inspired by [Pregel](https://research.google/pubs/pub37252/) and [Apache Beam](https://beam.apache.org/). The public interface draws inspiration from [NetworkX](https://networkx.org/documentation/latest/). LangGraph is built by LangChain Inc, the creators of LangChain, but can be used without LangChain.
+14 -117
View File
@@ -1,126 +1,24 @@
# LangGraph Documentation # Setup
For more information on contributing to our documentation, see the [Contributing Guide](../CONTRIBUTING.md). To setup requirements for building docs you can run:
## Structure ```bash
uv sync --group test
The primary documentation is located in the `docs/` directory. This directory contains both the source files for the main documentation as well as the API reference doc build process. ```
### Main Documentation ## Serving documentation locally
Main documentation files are located in `docs/docs/` and are written in Markdown format. The site uses [**MkDocs**](https://www.mkdocs.org/) with the [Material theme](https://squidfunk.github.io/mkdocs-material/) and includes: To run the documentation server locally you can run:
- **Concepts**: Core LangGraph concepts and explanations
- **Tutorials**: Step-by-step learning guides
- **How-tos**: Task-focused guides for specific use cases
- **Examples**: Real-world applications and use cases
- **Jupyter Notebooks**: Interactive tutorials that are automatically converted to markdown
### API Reference
API reference documentation is defined in `docs/docs/reference/`. Each `.md` file outlines the "template" that each page is built from. Reference content is automatically generated from docstrings in the codebase using the **mkdocstrings** plugin. Once generated, the content is plugged into the corresponding markdown file where it is referenced by using manual directives to specify which classes and/or functions are documented:
```markdown
::: langgraph.graph.state.StateGraph
options:
show_if_no_docstring: true
show_root_heading: true
show_root_full_path: false
members:
- add_node
- add_edge
- add_conditional_edges
- add_sequence
- compile
```
## Build Process
Docs are built following these steps:
1. **Content Processing:**
- `_scripts/notebook_hooks.py` - Main processing pipeline that:
- Converts how-tos/tutorial Jupyter notebooks to markdown using `notebook_convert.py`
- Adds automatic API reference links to code blocks using `generate_api_reference_links.py`
- Handles conditional rendering for Python/JS versions
- Processes highlight comments and custom syntax
2. **API Reference Generation:**
- **mkdocstrings** plugin extracts docstrings from Python source code
- Manual `::: module.Class` directives in reference pages (`/docs/docs/*`) specify what to document
- Cross-references are automatically generated between docs and API
3. **Site Generation:**
- **MkDocs** processes all markdown files and generates static HTML
- Custom hooks handle redirects and inject additional functionality
4. **Deployment:**
- Site is deployed with Vercel
- `make build-docs` generates production build (also usable for local testing)
- Automatic redirects handle URL changes between versions
### Local Development
For local development, use the Makefile targets:
```bash ```bash
# Serve docs locally with hot reloading
make serve-docs make serve-docs
# Clean build for production testing
make build-docs
# Serve with clean build
make serve-clean-docs
``` ```
The `serve-docs` command: This will start the documentation server on [http://127.0.0.1:8000/langgraph/](http://127.0.0.1:8000/langgraph/).
- Watches source files for changes
- Includes dirty builds for faster iteration
- Serves on [http://127.0.0.1:8000/langgraph/](http://127.0.0.1:8000/langgraph/)
## Standards
**Docstring Format:**
The API reference uses **Google-style docstrings** with Markdown markup. The `mkdocstrings` plugin processes these to generate documentation.
**Required format:**
```python
def example_function(param1: str, param2: int = 5) -> bool:
"""Brief description of the function.
Longer description can go here. Use Markdown syntax for
rich formatting like **bold** and *italic*.
Args:
param1: Description of the first parameter.
param2: Description of the second parameter with default value.
Returns:
Description of the return value.
Raises:
ValueError: When param1 is empty.
TypeError: When param2 is not an integer.
!!! warning
This function is experimental and may change.
!!! version-added "Added in version 0.2.0"
"""
```
**Special Markers:**
- **MkDocs admonitions**: `!!! warning`, `!!! note`, `!!! version-added`
- **Code blocks**: Standard markdown ``` syntax
- **Cross-references**: Automatic linking via `generate_api_reference_links.py`
## Execute notebooks ## Execute notebooks
If you would like to automatically execute all of the notebooks, to mimic the "Run notebooks" GitHub action, you can run: If you would like to automatically execute all of the notebooks, to mimic the "Run notebooks" GHA, you can run:
```bash ```bash
python _scripts/prepare_notebooks_for_ci.py python _scripts/prepare_notebooks_for_ci.py
@@ -135,9 +33,8 @@ python _scripts/prepare_notebooks_for_ci.py --comment-install-cells
``` ```
`prepare_notebooks_for_ci.py` script will add VCR cassette context manager for each cell in the notebook, so that: `prepare_notebooks_for_ci.py` script will add VCR cassette context manager for each cell in the notebook, so that:
* when the notebook is run for the first time, cells with network requests will be recorded to a VCR cassette file
- when the notebook is run for the first time, cells with network requests will be recorded to a VCR cassette file * when the notebook is run subsequently, the cells with network requests will be replayed from the cassettes
- when the notebook is run subsequently, the cells with network requests will be replayed from the cassettes
## Adding new notebooks ## Adding new notebooks
+2 -14
View File
@@ -1,5 +1,3 @@
"""Generate API reference links for imports in Python code blocks within markdown files."""
import ast import ast
import importlib import importlib
import logging import logging
@@ -72,18 +70,8 @@ MANUAL_API_REFERENCES_LANGGRAPH = [
([], "langgraph.checkpoint.postgres.aio", "AsyncPostgresSaver", "checkpoints"), ([], "langgraph.checkpoint.postgres.aio", "AsyncPostgresSaver", "checkpoints"),
([], "langgraph.checkpoint.postgres", "PostgresSaver", "checkpoints"), ([], "langgraph.checkpoint.postgres", "PostgresSaver", "checkpoints"),
# other prebuilts # other prebuilts
( (["langgraph_supervisor"], "langgraph_supervisor.supervisor", "create_supervisor", "supervisor"),
["langgraph_supervisor"], (["langgraph_supervisor"], "langgraph_supervisor.handoff", "create_handoff_tool", "supervisor"),
"langgraph_supervisor.supervisor",
"create_supervisor",
"supervisor",
),
(
["langgraph_supervisor"],
"langgraph_supervisor.handoff",
"create_handoff_tool",
"supervisor",
),
([], "langgraph_supervisor.handoff", "create_forward_message_tool", "supervisor"), ([], "langgraph_supervisor.handoff", "create_forward_message_tool", "supervisor"),
(["langgraph_swarm"], "langgraph_swarm.swarm", "create_swarm", "swarm"), (["langgraph_swarm"], "langgraph_swarm.swarm", "create_swarm", "swarm"),
(["langgraph_swarm"], "langgraph_swarm.swarm", "add_active_agent_router", "swarm"), (["langgraph_swarm"], "langgraph_swarm.swarm", "add_active_agent_router", "swarm"),
+4 -10
View File
@@ -29,11 +29,7 @@ logger = logging.getLogger(__name__)
def _transform_link( def _transform_link(
link_name: str, link_name: str, scope: str, file_path: str, line_number: int, custom_title: Optional[str] = None
scope: str,
file_path: str,
line_number: int,
custom_title: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
"""Transform a cross-reference link based on the current scope. """Transform a cross-reference link based on the current scope.
@@ -42,7 +38,7 @@ def _transform_link(
scope: The current scope context ("global", "python", "js", etc.). scope: The current scope context ("global", "python", "js", etc.).
file_path: The file path for error reporting. file_path: The file path for error reporting.
line_number: The line number for error reporting. line_number: The line number for error reporting.
custom_title: Optional custom title for the link. If `None`, uses link_name. custom_title: Optional custom title for the link. If None, uses link_name.
Returns: Returns:
A formatted markdown link if the link is found in the scope mapping, A formatted markdown link if the link is found in the scope mapping,
@@ -121,9 +117,7 @@ CROSS_REFERENCE_PATTERN = re.compile(
) )
def _replace_autolinks( def _replace_autolinks(markdown: str, file_path: str, *, default_scope: str = "python") -> str:
markdown: str, file_path: str, *, default_scope: str = "python"
) -> str:
"""Preprocess markdown lines to handle @[links] with conditional fence scopes. """Preprocess markdown lines to handle @[links] with conditional fence scopes.
This function processes markdown content to transform @[link_name] references This function processes markdown content to transform @[link_name] references
@@ -175,7 +169,7 @@ def _replace_autolinks(
# This is @[ref] format # This is @[ref] format
link_name = match.group("link_name") link_name = match.group("link_name")
custom_title = None custom_title = None
transformed = _transform_link( transformed = _transform_link(
link_name, current_scope, file_path, line_number, custom_title link_name, current_scope, file_path, line_number, custom_title
) )
@@ -2108,9 +2108,9 @@ __metadata:
linkType: hard linkType: hard
"hono@npm:^4.5.4": "hono@npm:^4.5.4":
version: 4.10.3 version: 4.8.9
resolution: "hono@npm:4.10.3" resolution: "hono@npm:4.8.9"
checksum: 10c0/bdcc4c7066c74ba7cfa63ed6550768a0f43a420286c8f8f74b7012ea4901b8b06778fa8e98264b46f1a86920f056b7ede1f07814da4934912f9945def4977c29 checksum: 10c0/385539d1787fdc747bc869ef0e5ccc9f39cbe40289b94f23eecfc82c6ca440f059704647cd6381a5066d2cf7baa43ab25184c78d44af4c5c98a5c5b07670059e
languageName: node languageName: node
linkType: hard linkType: hard
@@ -2340,13 +2340,13 @@ __metadata:
linkType: hard linkType: hard
"js-yaml@npm:^4.1.0": "js-yaml@npm:^4.1.0":
version: 4.1.1 version: 4.1.0
resolution: "js-yaml@npm:4.1.1" resolution: "js-yaml@npm:4.1.0"
dependencies: dependencies:
argparse: "npm:^2.0.1" argparse: "npm:^2.0.1"
bin: bin:
js-yaml: bin/js-yaml.js js-yaml: bin/js-yaml.js
checksum: 10c0/561c7d7088c40a9bb53cc75becbfb1df6ae49b34b5e6e5a81744b14ae8667ec564ad2527709d1a6e7d5e5fa6d483aa0f373a50ad98d42fde368ec4a190d4fae7 checksum: 10c0/184a24b4eaacfce40ad9074c64fd42ac83cf74d8c8cd137718d456ced75051229e5061b8633c3366b8aada17945a7a356b337828c19da92b51ae62126575018f
languageName: node languageName: node
linkType: hard linkType: hard
-2
View File
@@ -1,5 +1,3 @@
"""Convert Jupyter notebooks to markdown with custom processing."""
import ast import ast
import os import os
import re import re
+170 -494
View File
@@ -27,430 +27,185 @@ DISABLED = os.getenv("DISABLE_NOTEBOOK_CONVERT") in ("1", "true", "True")
REDIRECT_MAP = { REDIRECT_MAP = {
# lib redirects # lib redirects
"how-tos/stream-values.ipynb": "https://docs.langchain.com/oss/python/langgraph/streaming", "how-tos/stream-values.ipynb": "how-tos/streaming.md#stream-graph-state",
"how-tos/stream-updates.ipynb": "https://docs.langchain.com/oss/python/langgraph/streaming", "how-tos/stream-updates.ipynb": "how-tos/streaming.md#stream-graph-state",
"how-tos/streaming-content.ipynb": "https://docs.langchain.com/oss/python/langgraph/streaming", "how-tos/streaming-content.ipynb": "how-tos/streaming.md",
"how-tos/stream-multiple.ipynb": "https://docs.langchain.com/oss/python/langgraph/streaming", "how-tos/stream-multiple.ipynb": "how-tos/streaming.md#stream-multiple-nodes",
"how-tos/streaming-tokens-without-langchain.ipynb": "https://docs.langchain.com/oss/python/langgraph/streaming", "how-tos/streaming-tokens-without-langchain.ipynb": "how-tos/streaming.md#use-with-any-llm",
"how-tos/streaming-from-final-node.ipynb": "https://docs.langchain.com/oss/python/langgraph/streaming", "how-tos/streaming-from-final-node.ipynb": "how-tos/streaming-specific-nodes.ipynb",
"how-tos/streaming-events-from-within-tools-without-langchain.ipynb": "https://docs.langchain.com/oss/python/langgraph/streaming", "how-tos/streaming-events-from-within-tools-without-langchain.ipynb": "how-tos/streaming-events-from-within-tools.ipynb#example-without-langchain",
# graph-api # graph-api
"how-tos/state-reducers.ipynb": "https://docs.langchain.com/oss/python/langgraph/graph-api#define-and-update-state", "how-tos/state-reducers.ipynb": "how-tos/graph-api.md#define-and-update-state",
"how-tos/sequence.ipynb": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-a-sequence-of-steps", "how-tos/sequence.ipynb": "how-tos/graph-api.md#create-a-sequence-of-steps",
"how-tos/branching.ipynb": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-branches", "how-tos/branching.ipynb": "how-tos/graph-api.md#create-branches",
"how-tos/recursion-limit.ipynb": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-and-control-loops", "how-tos/recursion-limit.ipynb": "how-tos/graph-api.md#create-and-control-loops",
"how-tos/visualization.ipynb": "https://docs.langchain.com/oss/python/langgraph/graph-api#visualize-your-graph", "how-tos/visualization.ipynb": "how-tos/graph-api.md#visualize-your-graph",
"how-tos/input_output_schema.ipynb": "https://docs.langchain.com/oss/python/langgraph/graph-api#define-input-and-output-schemas", "how-tos/input_output_schema.ipynb": "how-tos/graph-api.md#define-input-and-output-schemas",
"how-tos/pass_private_state.ipynb": "https://docs.langchain.com/oss/python/langgraph/graph-api#pass-private-state-between-nodes", "how-tos/pass_private_state.ipynb": "how-tos/graph-api.md#pass-private-state-between-nodes",
"how-tos/state-model.ipynb": "https://docs.langchain.com/oss/python/langgraph/graph-api#use-pydantic-models-for-graph-state", "how-tos/state-model.ipynb": "how-tos/graph-api.md#use-pydantic-models-for-graph-state",
"how-tos/map-reduce.ipynb": "https://docs.langchain.com/oss/python/langgraph/graph-api#map-reduce-and-the-send-api", "how-tos/map-reduce.ipynb": "how-tos/graph-api.md#map-reduce-and-the-send-api",
"how-tos/command.ipynb": "https://docs.langchain.com/oss/python/langgraph/graph-api#combine-control-flow-and-state-updates-with-command", "how-tos/command.ipynb": "how-tos/graph-api.md#combine-control-flow-and-state-updates-with-command",
"how-tos/configuration.ipynb": "https://docs.langchain.com/oss/python/langgraph/graph-api#add-runtime-configuration", "how-tos/configuration.ipynb": "how-tos/graph-api.md#add-runtime-configuration",
"how-tos/node-retries.ipynb": "https://docs.langchain.com/oss/python/langgraph/graph-api#add-retry-policies", "how-tos/node-retries.ipynb": "how-tos/graph-api.md#add-retry-policies",
"how-tos/return-when-recursion-limit-hits.ipynb": "https://docs.langchain.com/oss/python/langgraph/graph-api#impose-a-recursion-limit", "how-tos/return-when-recursion-limit-hits.ipynb": "how-tos/graph-api.md#impose-a-recursion-limit",
"how-tos/async.ipynb": "https://docs.langchain.com/oss/python/langgraph/graph-api#async", "how-tos/async.ipynb": "how-tos/graph-api.md#async",
# memory how-tos # memory how-tos
"how-tos/memory/manage-conversation-history.ipynb": "https://docs.langchain.com/oss/python/langgraph/add-memory", "how-tos/memory/manage-conversation-history.ipynb": "how-tos/memory/add-memory.md",
"how-tos/memory/delete-messages.ipynb": "https://docs.langchain.com/oss/python/langgraph/add-memory#delete-messages", "how-tos/memory/delete-messages.ipynb": "how-tos/memory/add-memory.md#delete-messages",
"how-tos/memory/add-summary-conversation-history.ipynb": "https://docs.langchain.com/oss/python/langgraph/add-memory#summarize-messages", "how-tos/memory/add-summary-conversation-history.ipynb": "how-tos/memory/add-memory.md#summarize-messages",
"how-tos/memory.ipynb": "https://docs.langchain.com/oss/python/langgraph/add-memory", "how-tos/memory.ipynb": "how-tos/memory/add-memory.md",
"agents/memory.ipynb": "https://docs.langchain.com/oss/python/langgraph/add-memory", "agents/memory.ipynb": "how-tos/memory/add-memory.md",
# subgraph how-tos # subgraph how-tos
"how-tos/subgraph-transform-state.ipynb": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs#different-state-schemas", "how-tos/subgraph-transform-state.ipynb": "how-tos/subgraph.md#different-state-schemas",
"how-tos/subgraphs-manage-state.ipynb": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs#add-persistence", "how-tos/subgraphs-manage-state.ipynb": "how-tos/subgraph.md#add-persistence",
# persistence how-tos # persistence how-tos
"how-tos/persistence_postgres.ipynb": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production", "how-tos/persistence_postgres.ipynb": "how-tos/memory/add-memory.md#use-in-production",
"how-tos/persistence_mongodb.ipynb": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production", "how-tos/persistence_mongodb.ipynb": "how-tos/memory/add-memory.md#use-in-production",
"how-tos/persistence_redis.ipynb": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production", "how-tos/persistence_redis.ipynb": "how-tos/memory/add-memory.md#use-in-production",
"how-tos/subgraph-persistence.ipynb": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-with-subgraphs", "how-tos/subgraph-persistence.ipynb": "how-tos/memory/add-memory.md#use-with-subgraphs",
"how-tos/cross-thread-persistence.ipynb": "https://docs.langchain.com/oss/python/langgraph/add-memory#add-long-term-memory", "how-tos/cross-thread-persistence.ipynb": "how-tos/memory/add-memory.md#add-long-term-memory",
"cloud/how-tos/copy_threads": "https://docs.langchain.com/langsmith/use-threads", "cloud/how-tos/copy_threads": "cloud/how-tos/use_threads",
"cloud/how-tos/check-thread-status": "https://docs.langchain.com/langsmith/use-threads", "cloud/how-tos/check-thread-status": "cloud/how-tos/use_threads",
"cloud/concepts/threads.md": "https://docs.langchain.com/oss/python/langgraph/persistence#threads", "cloud/concepts/threads.md": "concepts/persistence.md#threads",
"how-tos/persistence.ipynb": "https://docs.langchain.com/oss/python/langgraph/add-memory", "how-tos/persistence.ipynb": "how-tos/memory/add-memory.md",
# tool calling how-tos # tool calling how-tos
"how-tos/tool-calling-errors.ipynb": "https://docs.langchain.com/oss/python/langgraph/workflows-agents", "how-tos/tool-calling-errors.ipynb": "how-tos/tool-calling.ipynb#handle-errors",
"how-tos/pass-config-to-tools.ipynb": "https://docs.langchain.com/oss/python/langgraph/workflows-agents", "how-tos/pass-config-to-tools.ipynb": "how-tos/tool-calling.ipynb#access-config",
"how-tos/pass-run-time-values-to-tools.ipynb": "https://docs.langchain.com/oss/python/langgraph/workflows-agents", "how-tos/pass-run-time-values-to-tools.ipynb": "how-tos/tool-calling.ipynb#read-state",
"how-tos/update-state-from-tools.ipynb": "https://docs.langchain.com/oss/python/langgraph/workflows-agents", "how-tos/update-state-from-tools.ipynb": "how-tos/tool-calling.ipynb#update-state",
"agents/tools.md": "https://docs.langchain.com/oss/python/langgraph/workflows-agents", "agents/tools.md": "how-tos/tool-calling.md",
# multi-agent how-tos # multi-agent how-tos
"how-tos/agent-handoffs.ipynb": "https://docs.langchain.com/oss/python/langgraph/graph-api", "how-tos/agent-handoffs.ipynb": "how-tos/multi_agent.md#handoffs",
"how-tos/multi-agent-network.ipynb": "https://docs.langchain.com/oss/python/langgraph/graph-api", "how-tos/multi-agent-network.ipynb": "how-tos/multi_agent.md#use-in-a-multi-agent-system",
"how-tos/multi-agent-multi-turn-convo.ipynb": "https://docs.langchain.com/oss/python/langgraph/graph-api", "how-tos/multi-agent-multi-turn-convo.ipynb": "how-tos/multi_agent.md#multi-turn-conversation",
# cloud redirects # cloud redirects
"cloud/index.md": "https://docs.langchain.com/oss/python/langgraph/overview", "cloud/index.md": "index.md",
"cloud/how-tos/index.md": "https://docs.langchain.com/langsmith/home", "cloud/how-tos/index.md": "concepts/langgraph_platform",
"cloud/concepts/api.md": "https://docs.langchain.com/langsmith/agent-server", "cloud/concepts/api.md": "concepts/langgraph_server.md",
"cloud/concepts/cloud.md": "https://docs.langchain.com/langsmith/cloud", "cloud/concepts/cloud.md": "concepts/langgraph_cloud.md",
"cloud/faq/studio.md": "https://docs.langchain.com/langsmith/studio", "cloud/faq/studio.md": "concepts/langgraph_studio.md#studio-faqs",
"cloud/how-tos/human_in_the_loop_edit_state.md": "https://docs.langchain.com/langsmith/add-human-in-the-loop", "cloud/how-tos/human_in_the_loop_edit_state.md": "cloud/how-tos/add-human-in-the-loop.md",
"cloud/how-tos/human_in_the_loop_user_input.md": "https://docs.langchain.com/langsmith/add-human-in-the-loop", "cloud/how-tos/human_in_the_loop_user_input.md": "cloud/how-tos/add-human-in-the-loop.md",
"concepts/platform_architecture.md": "https://docs.langchain.com/langsmith/cloud#architecture", "concepts/platform_architecture.md": "concepts/langgraph_cloud#architecture",
# cloud streaming redirects # cloud streaming redirects
"cloud/how-tos/stream_values.md": "https://docs.langchain.com/langsmith/streaming", "cloud/how-tos/stream_values.md": "https://docs.langchain.com/langgraph-platform/streaming",
"cloud/how-tos/stream_updates.md": "https://docs.langchain.com/langsmith/streaming", "cloud/how-tos/stream_updates.md": "https://docs.langchain.com/langgraph-platform/streaming",
"cloud/how-tos/stream_messages.md": "https://docs.langchain.com/langsmith/streaming", "cloud/how-tos/stream_messages.md": "https://docs.langchain.com/langgraph-platform/streaming",
"cloud/how-tos/stream_events.md": "https://docs.langchain.com/langsmith/streaming", "cloud/how-tos/stream_events.md": "https://docs.langchain.com/langgraph-platform/streaming",
"cloud/how-tos/stream_debug.md": "https://docs.langchain.com/langsmith/streaming", "cloud/how-tos/stream_debug.md": "https://docs.langchain.com/langgraph-platform/streaming",
"cloud/how-tos/stream_multiple.md": "https://docs.langchain.com/langsmith/streaming", "cloud/how-tos/stream_multiple.md": "https://docs.langchain.com/langgraph-platform/streaming",
"cloud/concepts/streaming.md": "https://docs.langchain.com/oss/python/langgraph/streaming", "cloud/concepts/streaming.md": "concepts/streaming.md",
"agents/streaming.md": "https://docs.langchain.com/oss/python/langgraph/streaming", "agents/streaming.md": "how-tos/streaming.md",
# prebuilt redirects # prebuilt redirects
"how-tos/create-react-agent.ipynb": "https://docs.langchain.com/oss/python/langchain/agents#basic-configuration", "how-tos/create-react-agent.ipynb": "agents/agents.md#basic-configuration",
"how-tos/create-react-agent-memory.ipynb": "https://docs.langchain.com/oss/python/langgraph/add-memory", "how-tos/create-react-agent-memory.ipynb": "agents/memory.md",
"how-tos/create-react-agent-system-prompt.ipynb": "https://docs.langchain.com/oss/python/langgraph/add-memory", "how-tos/create-react-agent-system-prompt.ipynb": "agents/context.md#prompts",
"how-tos/create-react-agent-structured-output.ipynb": "https://docs.langchain.com/oss/python/langchain/agents#structured-output", "how-tos/create-react-agent-structured-output.ipynb": "agents/agents.md#structured-output",
# misc # misc
"prebuilt.md": "https://docs.langchain.com/oss/python/langchain/agents", "prebuilt.md": "agents/prebuilt.md",
"reference/prebuilt.md": "https://reference.langchain.com/python/langgraph/agents/", "reference/prebuilt.md": "reference/agents.md",
"concepts/high_level.md": "https://docs.langchain.com/oss/python/langgraph/overview", "concepts/high_level.md": "index.md",
"concepts/index.md": "https://docs.langchain.com/oss/python/langgraph/overview", "concepts/index.md": "index.md",
"concepts/v0-human-in-the-loop.md": "https://docs.langchain.com/oss/python/langgraph/interrupts", "concepts/v0-human-in-the-loop.md": "concepts/human-in-the-loop.md",
"how-tos/index.md": "https://docs.langchain.com/oss/python/langgraph/overview", "how-tos/index.md": "index.md",
"tutorials/introduction.ipynb": "https://docs.langchain.com/oss/python/langgraph/overview", "tutorials/introduction.ipynb": "concepts/why-langgraph.md",
"agents/deployment.md": "https://docs.langchain.com/oss/python/langgraph/local-server", "agents/deployment.md": "tutorials/langgraph-platform/local-server.md",
# deployment redirects # deployment redirects
"how-tos/deploy-self-hosted.md": "https://docs.langchain.com/langsmith/platform-setup", "how-tos/deploy-self-hosted.md": "cloud/deployment/self_hosted_data_plane.md",
"concepts/self_hosted.md": "https://docs.langchain.com/langsmith/platform-setup", "concepts/self_hosted.md": "concepts/langgraph_self_hosted_data_plane.md",
"tutorials/deployment.md": "https://docs.langchain.com/langsmith/deployments", "tutorials/deployment.md": "concepts/deployment_options.md",
# assistant redirects # assistant redirects
"cloud/how-tos/assistant_versioning.md": "https://docs.langchain.com/langsmith/configuration-cloud", "cloud/how-tos/assistant_versioning.md": "cloud/how-tos/configuration_cloud.md",
"cloud/concepts/runs.md": "https://docs.langchain.com/langsmith/assistants#execution", "cloud/concepts/runs.md": "concepts/assistants.md#execution",
# hitl redirects # hitl redirects
"how-tos/wait-user-input-functional.ipynb": "https://docs.langchain.com/oss/python/langgraph/functional-api", "how-tos/wait-user-input-functional.ipynb": "how-tos/use-functional-api.md",
"how-tos/review-tool-calls-functional.ipynb": "https://docs.langchain.com/oss/python/langgraph/functional-api", "how-tos/review-tool-calls-functional.ipynb": "how-tos/use-functional-api.md",
"how-tos/create-react-agent-hitl.ipynb": "https://docs.langchain.com/oss/python/langgraph/interrupts", "how-tos/create-react-agent-hitl.ipynb": "how-tos/human_in_the_loop/add-human-in-the-loop.md",
"agents/human-in-the-loop.md": "https://docs.langchain.com/oss/python/langgraph/interrupts", "agents/human-in-the-loop.md": "how-tos/human_in_the_loop/add-human-in-the-loop.md",
"how-tos/human_in_the_loop/dynamic_breakpoints.ipynb": "https://docs.langchain.com/oss/python/langgraph/interrupts", "how-tos/human_in_the_loop/dynamic_breakpoints.ipynb": "how-tos/human_in_the_loop/breakpoints.md",
"concepts/breakpoints.md": "https://docs.langchain.com/oss/python/langgraph/interrupts", "concepts/breakpoints.md": "concepts/human_in_the_loop.md",
"how-tos/human_in_the_loop/breakpoints.md": "https://docs.langchain.com/oss/python/langgraph/interrupts", "how-tos/human_in_the_loop/breakpoints.md": "how-tos/human_in_the_loop/add-human-in-the-loop.md",
"cloud/how-tos/human_in_the_loop_breakpoint.md": "https://docs.langchain.com/langsmith/add-human-in-the-loop", "cloud/how-tos/human_in_the_loop_breakpoint.md": "cloud/how-tos/add-human-in-the-loop.md",
"how-tos/human_in_the_loop/edit-graph-state.ipynb": "https://docs.langchain.com/oss/python/langgraph/use-time-travel", "how-tos/human_in_the_loop/edit-graph-state.ipynb": "how-tos/human_in_the_loop/time-travel.md",
# LGP mintlify migration redirects # LGP mintlify migration redirects
"examples/index.md": "https://docs.langchain.com/oss/python/learn", "tutorials/auth/getting_started.md": "https://docs.langchain.com/langgraph-platform/auth",
"guides/index.md": "https://docs.langchain.com/oss/python/langgraph/overview", "tutorials/auth/resource_auth.md": "https://docs.langchain.com/langgraph-platform/resource-auth",
"concepts/index.md": "https://docs.langchain.com/oss/python/langgraph/overview", "tutorials/auth/add_auth_server.md": "https://docs.langchain.com/langgraph-platform/add-auth-server",
"tutorials/index.md": "https://docs.langchain.com/oss/python/learn", "how-tos/use-remote-graph.md": "https://docs.langchain.com/langgraph-platform/use-remote-graph",
"llms-txt-overview.md": "https://docs.langchain.com/llms.txt", "how-tos/autogen-integration.md": "https://docs.langchain.com/langgraph-platform/autogen-integration",
"tutorials/rag/langgraph_adaptive_rag.md": "https://docs.langchain.com/oss/python/langgraph/agentic-rag", "cloud/how-tos/use_stream_react.md": "https://docs.langchain.com/langgraph-platform/use-stream-react",
"tutorials/multi_agent/multi-agent-collaboration.ipynb": "https://docs.langchain.com/oss/python/langchain/multi-agent", "cloud/how-tos/generative_ui_react.md": "https://docs.langchain.com/langgraph-platform/generative-ui-react",
"how-tos/create-react-agent-manage-message-history.ipynb": "https://docs.langchain.com/oss/python/langgraph/add-memory", "concepts/langgraph_platform.md": "https://docs.langchain.com/langgraph-platform/index",
"how-tos/many-tools.ipynb": "https://docs.langchain.com/oss/python/langchain/tools", "concepts/langgraph_components.md": "https://docs.langchain.com/langgraph-platform/components",
"tutorials/customer-support/customer-support.ipynb": "https://docs.langchain.com/oss/python/langgraph/agentic-rag", "concepts/langgraph_server.md": "https://docs.langchain.com/langgraph-platform/langgraph-server",
"how-tos/react-agent-structured-output.ipynb": "https://docs.langchain.com/oss/python/langchain/agents#structured-output", "concepts/langgraph_data_plane.md": "https://docs.langchain.com/langgraph-platform/data-plane",
"tutorials/code_assistant/langgraph_code_assistant.ipynb": "https://docs.langchain.com/oss/python/langgraph/agentic-rag", "concepts/langgraph_control_plane.md": "https://docs.langchain.com/langgraph-platform/control-plane",
"tutorials/multi_agent/hierarchical_agent_teams.ipynb": "https://docs.langchain.com/oss/python/langchain/supervisor", "concepts/langgraph_cli.md": "https://docs.langchain.com/langgraph-platform/langgraph-cli",
"tutorials/auth/getting_started.md": "https://docs.langchain.com/langsmith/auth", "concepts/langgraph_studio.md": "https://docs.langchain.com/langgraph-platform/langgraph-studio",
"tutorials/auth/resource_auth.md": "https://docs.langchain.com/langsmith/resource-auth", "cloud/how-tos/studio/quick_start.md": "https://docs.langchain.com/langgraph-platform/quick-start-studio",
"tutorials/auth/add_auth_server.md": "https://docs.langchain.com/langsmith/add-auth-server", "cloud/how-tos/invoke_studio.md": "https://docs.langchain.com/langgraph-platform/invoke-studio",
"how-tos/use-remote-graph.md": "https://docs.langchain.com/langsmith/use-remote-graph", "cloud/how-tos/studio/manage_assistants.md": "https://docs.langchain.com/langgraph-platform/manage-assistants-studio",
"how-tos/autogen-integration.md": "https://docs.langchain.com/langsmith/autogen-integration", "cloud/how-tos/threads_studio.md": "https://docs.langchain.com/langgraph-platform/threads-studio",
"how-tos/human_in_the_loop/wait-user-input.ipynb": "https://docs.langchain.com/oss/python/langgraph/interrupts", "cloud/how-tos/iterate_graph_studio.md": "https://docs.langchain.com/langgraph-platform/iterate-graph-studio",
"cloud/how-tos/use_stream_react.md": "https://docs.langchain.com/langsmith/use-stream-react", "cloud/how-tos/studio/run_evals.md": "https://docs.langchain.com/langgraph-platform/run-evals-studio",
"cloud/how-tos/generative_ui_react.md": "https://docs.langchain.com/langsmith/generative-ui-react", "cloud/how-tos/clone_traces_studio.md": "https://docs.langchain.com/langgraph-platform/clone-traces-studio",
"concepts/langgraph_platform.md": "https://docs.langchain.com/langsmith/deployments", "cloud/how-tos/datasets_studio.md": "https://docs.langchain.com/langgraph-platform/datasets-studio",
"concepts/langgraph_components.md": "https://docs.langchain.com/langsmith/components", "concepts/sdk.md": "https://docs.langchain.com/langgraph-platform/sdk",
"concepts/langgraph_server.md": "https://docs.langchain.com/langsmith/agent-server", "concepts/plans.md": "https://docs.langchain.com/langgraph-platform/plans",
"concepts/langgraph_data_plane.md": "https://docs.langchain.com/langsmith/data-plane", "concepts/application_structure.md": "https://docs.langchain.com/langgraph-platform/application-structure",
"concepts/langgraph_control_plane.md": "https://docs.langchain.com/langsmith/control-plane", "concepts/scalability_and_resilience.md": "https://docs.langchain.com/langgraph-platform/scalability-and-resilience",
"concepts/langgraph_cli.md": "https://docs.langchain.com/langsmith/cli", "concepts/auth.md": "https://docs.langchain.com/langgraph-platform/auth",
"concepts/langgraph_studio.md": "https://docs.langchain.com/langsmith/studio", "how-tos/auth/custom_auth.md": "https://docs.langchain.com/langgraph-platform/custom-auth",
"cloud/how-tos/studio/quick_start.md": "https://docs.langchain.com/langsmith/quick-start-studio", "how-tos/auth/openapi_security.md": "https://docs.langchain.com/langgraph-platform/openapi-security",
"cloud/how-tos/invoke_studio.md": "https://docs.langchain.com/langsmith/use-studio#run-application", "concepts/assistants.md": "https://docs.langchain.com/langgraph-platform/assistants",
"cloud/how-tos/studio/manage_assistants.md": "https://docs.langchain.com/langsmith/use-studio#manage-assistants", "cloud/how-tos/configuration_cloud.md": "https://docs.langchain.com/langgraph-platform/configuration-cloud",
"cloud/how-tos/threads_studio.md": "https://docs.langchain.com/langsmith/use-studio#manage-threads", "cloud/how-tos/use_threads.md": "https://docs.langchain.com/langgraph-platform/use-threads",
"cloud/how-tos/iterate_graph_studio.md": "https://docs.langchain.com/langsmith/observability-studio#iterate-on-prompts", "cloud/how-tos/background_run.md": "https://docs.langchain.com/langgraph-platform/background-run",
"cloud/how-tos/studio/run_evals.md": "https://docs.langchain.com/langsmith/observability-studio#run-experiments-over-a-dataset", "cloud/how-tos/same-thread.md": "https://docs.langchain.com/langgraph-platform/same-thread",
"cloud/how-tos/clone_traces_studio.md": "https://docs.langchain.com/langsmith/observability-studio#debug-langsmith-traces", "cloud/how-tos/stateless_runs.md": "https://docs.langchain.com/langgraph-platform/stateless-runs",
"cloud/how-tos/datasets_studio.md": "https://docs.langchain.com/langsmith/observability-studio#add-node-to-dataset", "cloud/how-tos/configurable_headers.md": "https://docs.langchain.com/langgraph-platform/configurable-headers",
"concepts/sdk.md": "https://docs.langchain.com/langsmith/sdk", "concepts/double_texting.md": "https://docs.langchain.com/langgraph-platform/double-texting",
"concepts/plans.md": "https://langchain.com/pricing", "cloud/how-tos/interrupt_concurrent.md": "https://docs.langchain.com/langgraph-platform/interrupt-concurrent",
"concepts/application_structure.md": "https://docs.langchain.com/langsmith/application-structure", "cloud/how-tos/rollback_concurrent.md": "https://docs.langchain.com/langgraph-platform/rollback-concurrent",
"concepts/scalability_and_resilience.md": "https://docs.langchain.com/langsmith/scalability-and-resilience", "cloud/how-tos/reject_concurrent.md": "https://docs.langchain.com/langgraph-platform/reject-concurrent",
"concepts/auth.md": "https://docs.langchain.com/langsmith/authentication-methods", "cloud/how-tos/enqueue_concurrent.md": "https://docs.langchain.com/langgraph-platform/enqueue-concurrent",
"how-tos/auth/custom_auth.md": "https://docs.langchain.com/langsmith/custom-auth", "cloud/concepts/webhooks.md": "https://docs.langchain.com/langgraph-platform/use-webhooks",
"how-tos/auth/openapi_security.md": "https://docs.langchain.com/langsmith/openapi-security", "cloud/how-tos/webhooks.md": "https://docs.langchain.com/langgraph-platform/use-webhooks",
"concepts/assistants.md": "https://docs.langchain.com/langsmith/assistants", "cloud/concepts/cron_jobs.md": "https://docs.langchain.com/langgraph-platform/cron-jobs",
"cloud/how-tos/configuration_cloud.md": "https://docs.langchain.com/langsmith/cloud", "cloud/how-tos/cron_jobs.md": "https://docs.langchain.com/langgraph-platform/cron-jobs",
"cloud/how-tos/use_threads.md": "https://docs.langchain.com/langsmith/use-threads", "how-tos/http/custom_lifespan.md": "https://docs.langchain.com/langgraph-platform/custom-lifespan",
"cloud/how-tos/background_run.md": "https://docs.langchain.com/langsmith/background-run", "how-tos/http/custom_middleware.md": "https://docs.langchain.com/langgraph-platform/custom-middleware",
"cloud/how-tos/same-thread.md": "https://docs.langchain.com/langsmith/same-thread", "how-tos/http/custom_routes.md": "https://docs.langchain.com/langgraph-platform/custom-routes",
"cloud/how-tos/stateless_runs.md": "https://docs.langchain.com/langsmith/stateless-runs", "cloud/concepts/data_storage_and_privacy.md": "https://docs.langchain.com/langgraph-platform/data-storage-and-privacy",
"cloud/how-tos/configurable_headers.md": "https://docs.langchain.com/langsmith/configurable-headers", "cloud/deployment/semantic_search.md": "https://docs.langchain.com/langgraph-platform/semantic-search",
"concepts/double_texting.md": "https://docs.langchain.com/langsmith/double-texting", "how-tos/ttl/configure_ttl.md": "https://docs.langchain.com/langgraph-platform/configure-ttl",
"cloud/how-tos/interrupt_concurrent.md": "https://docs.langchain.com/langsmith/interrupt-concurrent", "concepts/deployment_options.md": "https://docs.langchain.com/langgraph-platform/deployment-options",
"cloud/how-tos/rollback_concurrent.md": "https://docs.langchain.com/langsmith/rollback-concurrent", "cloud/quick_start.md": "https://docs.langchain.com/langgraph-platform/deployment-quickstart",
"cloud/how-tos/reject_concurrent.md": "https://docs.langchain.com/langsmith/reject-concurrent", "cloud/deployment/setup.md": "https://docs.langchain.com/langgraph-platform/setup-app-requirements-txt",
"cloud/how-tos/enqueue_concurrent.md": "https://docs.langchain.com/langsmith/enqueue-concurrent", "cloud/deployment/setup_pyproject.md": "https://docs.langchain.com/langgraph-platform/setup-pyproject",
"cloud/concepts/webhooks.md": "https://docs.langchain.com/langsmith/use-webhooks", "cloud/deployment/setup_javascript.md": "https://docs.langchain.com/langgraph-platform/setup-javascript",
"cloud/how-tos/webhooks.md": "https://docs.langchain.com/langsmith/use-webhooks", "cloud/deployment/custom_docker.md": "https://docs.langchain.com/langgraph-platform/custom-docker",
"cloud/concepts/cron_jobs.md": "https://docs.langchain.com/langsmith/cron-jobs", "cloud/deployment/graph_rebuild.md": "https://docs.langchain.com/langgraph-platform/graph-rebuild",
"cloud/how-tos/cron_jobs.md": "https://docs.langchain.com/langsmith/cron-jobs", "concepts/langgraph_cloud.md": "https://docs.langchain.com/langgraph-platform/cloud",
"how-tos/http/custom_lifespan.md": "https://docs.langchain.com/langsmith/custom-lifespan", "concepts/langgraph_self_hosted_data_plane.md": "https://docs.langchain.com/langgraph-platform/hybrid",
"how-tos/http/custom_middleware.md": "https://docs.langchain.com/langsmith/custom-middleware", "concepts/langgraph_self_hosted_control_plane.md": "https://docs.langchain.com/langgraph-platform/self-hosted",
"how-tos/http/custom_routes.md": "https://docs.langchain.com/langsmith/custom-routes", "concepts/langgraph_standalone_container.md": "https://docs.langchain.com/langgraph-platform/self-hosted#standalone-server",
"cloud/concepts/data_storage_and_privacy.md": "https://docs.langchain.com/langsmith/data-storage-and-privacy", "cloud/deployment/cloud.md": "https://docs.langchain.com/langgraph-platform/cloud",
"cloud/deployment/semantic_search.md": "https://docs.langchain.com/langsmith/semantic-search", "cloud/deployment/self_hosted_data_plane.md": "https://docs.langchain.com/langgraph-platform/deploy-hybrid",
"how-tos/ttl/configure_ttl.md": "https://docs.langchain.com/langsmith/configure-ttl", "cloud/deployment/self_hosted_control_plane.md": "https://docs.langchain.com/langgraph-platform/deploy-self-hosted-full-platform",
"concepts/deployment_options.md": "https://docs.langchain.com/langsmith/platform-setup", "cloud/deployment/standalone_container.md": "https://docs.langchain.com/langgraph-platform/deploy-standalone-server",
"cloud/quick_start.md": "https://docs.langchain.com/langsmith/deployment-quickstart", "concepts/server-mcp.md": "https://docs.langchain.com/langgraph-platform/server-mcp",
"cloud/deployment/setup.md": "https://docs.langchain.com/langsmith/setup-app-requirements-txt", "cloud/how-tos/human_in_the_loop_time_travel.md": "https://docs.langchain.com/langgraph-platform/human-in-the-loop-time-travel",
"cloud/deployment/setup_pyproject.md": "https://docs.langchain.com/langsmith/setup-pyproject", "cloud/how-tos/add-human-in-the-loop.md": "https://docs.langchain.com/langgraph-platform/add-human-in-the-loop",
"cloud/deployment/setup_javascript.md": "https://docs.langchain.com/langsmith/setup-javascript", "cloud/deployment/egress.md": "https://docs.langchain.com/langgraph-platform/env-var",
"cloud/deployment/custom_docker.md": "https://docs.langchain.com/langsmith/custom-docker", "cloud/how-tos/streaming.md": "https://docs.langchain.com/langgraph-platform/streaming",
"cloud/deployment/graph_rebuild.md": "https://docs.langchain.com/langsmith/graph-rebuild", "cloud/reference/api/api_ref.md": "https://docs.langchain.com/langgraph-platform/server-api-ref",
"concepts/langgraph_cloud.md": "https://docs.langchain.com/langsmith/cloud", "cloud/reference/langgraph_server_changelog.md": "https://docs.langchain.com/langgraph-platform/langgraph-server-changelog",
"concepts/langgraph_self_hosted_data_plane.md": "https://docs.langchain.com/langsmith/hybrid", "cloud/reference/api/api_ref_control_plane.md": "https://docs.langchain.com/langgraph-platform/api-ref-control-plane",
"concepts/langgraph_self_hosted_control_plane.md": "https://docs.langchain.com/langsmith/self-hosted", "cloud/reference/cli.md": "https://docs.langchain.com/langgraph-platform/cli",
"concepts/langgraph_standalone_container.md": "https://docs.langchain.com/langsmith/self-hosted#standalone-server", "cloud/reference/env_var.md": "https://docs.langchain.com/langgraph-platform/env-var",
"cloud/deployment/cloud.md": "https://docs.langchain.com/langsmith/cloud", "troubleshooting/studio.md": "https://docs.langchain.com/langgraph-platform/troubleshooting-studio",
"cloud/deployment/self_hosted_data_plane.md": "https://docs.langchain.com/langsmith/deploy-hybrid",
"cloud/deployment/self_hosted_control_plane.md": "https://docs.langchain.com/langsmith/deploy-self-hosted-full-platform",
"cloud/deployment/standalone_container.md": "https://docs.langchain.com/langsmith/deploy-standalone-server",
"concepts/server-mcp.md": "https://docs.langchain.com/langsmith/server-mcp",
"cloud/how-tos/human_in_the_loop_time_travel.md": "https://docs.langchain.com/langsmith/human-in-the-loop-time-travel",
"cloud/how-tos/add-human-in-the-loop.md": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
"cloud/deployment/egress.md": "https://docs.langchain.com/langsmith/env-var",
"cloud/how-tos/streaming.md": "https://docs.langchain.com/langsmith/streaming",
"cloud/reference/api/api_ref.md": "https://docs.langchain.com/langsmith/server-api-ref",
"cloud/reference/langgraph_server_changelog.md": "https://docs.langchain.com/langsmith/agent-server-changelog",
"cloud/reference/api/api_ref_control_plane.md": "https://docs.langchain.com/langsmith/api-ref-control-plane",
"cloud/reference/cli.md": "https://docs.langchain.com/langsmith/cli",
"cloud/reference/env_var.md": "https://docs.langchain.com/langsmith/env-var",
"troubleshooting/studio.md": "https://docs.langchain.com/langsmith/troubleshooting-studio",
# LangGraph mintlify migration redirects
"index.md": "https://docs.langchain.com/oss/python/langgraph/overview",
"agents/agents.md": "https://docs.langchain.com/oss/python/langchain/agents",
"concepts/why-langgraph.md": "https://docs.langchain.com/oss/python/langgraph/overview",
"tutorials/get-started/1-build-basic-chatbot.md": "https://docs.langchain.com/oss/python/langgraph/quickstart",
"tutorials/get-started/2-add-tools.md": "https://docs.langchain.com/oss/python/langgraph/quickstart",
"tutorials/get-started/3-add-memory.md": "https://docs.langchain.com/oss/python/langgraph/quickstart",
"tutorials/get-started/4-human-in-the-loop.md": "https://docs.langchain.com/oss/python/langgraph/quickstart",
"tutorials/get-started/5-customize-state.md": "https://docs.langchain.com/oss/python/langgraph/quickstart",
"tutorials/get-started/6-time-travel.md": "https://docs.langchain.com/oss/python/langgraph/quickstart",
"tutorials/langsmith/local-server.md": "https://docs.langchain.com/oss/python/langgraph/local-server",
"tutorials/workflows.md": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"tutorials/plan-and-execute/plan-and-execute.ipynb": "https://docs.langchain.com/oss/python/langchain/middleware/built-in#to-do-list",
"tutorials/langgraph-platform/local-server/local-server.md": "https://docs.langchain.com/langsmith/local-server",
"concepts/agentic_concepts.md": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"guides/index.md": "https://docs.langchain.com/oss/python/langchain/overview",
"agents/overview.md": "https://docs.langchain.com/oss/python/langchain/agents",
"agents/run_agents.md": "https://docs.langchain.com/oss/python/langgraph/quickstart",
"concepts/low_level.md": "https://docs.langchain.com/oss/python/langgraph/graph-api",
"how-tos/graph-api.md": "https://docs.langchain.com/oss/python/langgraph/graph-api",
"how-tos/react-agent-from-scratch.ipynb": "https://docs.langchain.com/oss/python/langchain/quickstart",
"concepts/functional_api.md": "https://docs.langchain.com/oss/python/langgraph/functional-api",
"how-tos/use-functional-api.md": "https://docs.langchain.com/oss/python/langgraph/functional-api",
"concepts/pregel.md": "https://docs.langchain.com/oss/python/langgraph/pregel",
"concepts/streaming.md": "https://docs.langchain.com/oss/python/langgraph/streaming",
"how-tos/streaming.md": "https://docs.langchain.com/oss/python/langgraph/streaming",
"concepts/persistence.md": "https://docs.langchain.com/oss/python/langgraph/persistence",
"concepts/durable_execution.md": "https://docs.langchain.com/oss/python/langgraph/durable-execution",
"concepts/memory.md": "https://docs.langchain.com/oss/python/langgraph/memory",
"how-tos/memory/add-memory.md": "https://docs.langchain.com/oss/python/langgraph/add-memory",
"agents/context.md": "https://docs.langchain.com/oss/python/langgraph/add-memory",
"agents/models.md": "https://docs.langchain.com/oss/python/langgraph/overview",
"concepts/tools.md": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"how-tos/tool-calling.md": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"concepts/human_in_the_loop.md": "https://docs.langchain.com/oss/python/langgraph/interrupts",
"how-tos/human_in_the_loop/add-human-in-the-loop.md": "https://docs.langchain.com/oss/python/langgraph/interrupts",
"concepts/time-travel.md": "https://docs.langchain.com/oss/python/langgraph/persistence",
"how-tos/human_in_the_loop/time-travel.md": "https://docs.langchain.com/oss/python/langgraph/use-time-travel",
"concepts/subgraphs.md": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs",
"how-tos/subgraph.md": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs",
"concepts/multi_agent.md": "https://docs.langchain.com/oss/python/langgraph/graph-api",
"agents/multi-agent.md": "https://docs.langchain.com/oss/python/langchain/multi-agent",
"how-tos/multi_agent.md": "https://docs.langchain.com/oss/python/langgraph/graph-api",
"concepts/mcp.md": "https://docs.langchain.com/oss/python/langgraph/overview",
"agents/mcp.md": "https://docs.langchain.com/oss/python/langgraph/overview",
"concepts/tracing.md": "https://docs.langchain.com/oss/python/langgraph/observability",
"how-tos/enable-tracing.md": "https://docs.langchain.com/oss/python/langgraph/observability",
"agents/evals.md": "https://docs.langchain.com/oss/python/langgraph/overview",
"examples/index.md": "https://docs.langchain.com/oss/python/langgraph/case-studies",
"concepts/template_applications.md": "https://docs.langchain.com/oss/python/langgraph/overview",
"tutorials/rag/langgraph_agentic_rag.md": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
"tutorials/multi_agent/agent_supervisor.md": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"tutorials/sql/sql-agent.md": "https://docs.langchain.com/oss/python/langgraph/sql-agent",
"agents/ui.md": "https://docs.langchain.com/oss/python/langgraph/ui",
"how-tos/run-id-langsmith.md": "https://docs.langchain.com/oss/python/langgraph/observability",
"troubleshooting/errors/index.md": "https://docs.langchain.com/oss/python/langgraph/common-errors",
"troubleshooting/errors/INVALID_CHAT_HISTORY.md": "https://docs.langchain.com/oss/python/langgraph/INVALID_CHAT_HISTORY",
"troubleshooting/errors/INVALID_LICENSE.md": "https://docs.langchain.com/oss/python/langgraph/common-errors",
"adopters.md": "https://docs.langchain.com/oss/python/langgraph/case-studies",
"concepts/faq.md": "https://docs.langchain.com/oss/python/langgraph/overview",
"agents/prebuilt.md": "https://docs.langchain.com/oss/python/langchain/agents",
"reference/index.md": "https://reference.langchain.com/python/langgraph/",
"reference/graphs.md": "https://reference.langchain.com/python/langgraph/graphs/",
"reference/func.md": "https://reference.langchain.com/python/langgraph/func/",
"reference/pregel.md": "https://reference.langchain.com/python/langgraph/pregel/",
"reference/checkpoints.md": "https://reference.langchain.com/python/langgraph/checkpoints/",
"reference/store.md": "https://reference.langchain.com/python/langgraph/store/",
"reference/cache.md": "https://reference.langchain.com/python/langgraph/cache/",
"reference/types.md": "https://reference.langchain.com/python/langgraph/types/",
"reference/runtime.md": "https://reference.langchain.com/python/langgraph/runtime/",
"reference/config.md": "https://reference.langchain.com/python/langgraph/config/",
"reference/errors.md": "https://reference.langchain.com/python/langgraph/errors/",
"reference/constants.md": "https://reference.langchain.com/python/langgraph/constants/",
"reference/channels.md": "https://reference.langchain.com/python/langgraph/channels/",
"reference/agents.md": "https://reference.langchain.com/python/langgraph/agents/",
"reference/supervisor.md": "https://reference.langchain.com/python/langgraph/supervisor/",
"reference/swarm.md": "https://reference.langchain.com/python/langgraph/swarm/",
"reference/mcp.md": "https://reference.langchain.com/python/langgraph/mcp/",
"cloud/reference/sdk/python_sdk_ref.md": "https://reference.langchain.com/python/langsmith/deployment/sdk/",
"reference/remote_graph.md": "https://reference.langchain.com/python/langsmith/deployment/remote_graph/",
# additional exclude-search entries from mkdocs.yml
"additional-resources/index.md": "https://docs.langchain.com/oss/python/langchain/overview",
"cloud/concepts/cron_jobs.md": "https://docs.langchain.com/langsmith/cron-jobs",
"cloud/concepts/data_storage_and_privacy.md": "https://docs.langchain.com/langsmith/data-storage-and-privacy",
"cloud/concepts/webhooks.md": "https://docs.langchain.com/langsmith/use-webhooks",
"cloud/deployment/cloud.md": "https://docs.langchain.com/langsmith/cloud",
"cloud/deployment/custom_docker.md": "https://docs.langchain.com/langsmith/custom-docker",
"cloud/deployment/egress.md": "https://docs.langchain.com/langsmith/env-var",
"cloud/deployment/graph_rebuild.md": "https://docs.langchain.com/langsmith/graph-rebuild",
"cloud/deployment/self_hosted_control_plane.md": "https://docs.langchain.com/langsmith/platform-setup",
"cloud/deployment/self_hosted_data_plane.md": "https://docs.langchain.com/langsmith/platform-setup",
"cloud/deployment/semantic_search.md": "https://docs.langchain.com/langsmith/semantic-search",
"cloud/deployment/setup_javascript.md": "https://docs.langchain.com/langsmith/setup-javascript",
"cloud/deployment/setup_pyproject.md": "https://docs.langchain.com/langsmith/setup-pyproject",
"cloud/deployment/setup.md": "https://docs.langchain.com/langsmith/setup-app-requirements-txt",
"cloud/deployment/standalone_container.md": "https://docs.langchain.com/langsmith/docker",
"cloud/how-tos/add-human-in-the-loop.md": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
"cloud/how-tos/background_run.md": "https://docs.langchain.com/langsmith/background-run",
"cloud/how-tos/clone_traces_studio.md": "https://docs.langchain.com/langsmith/observability",
"cloud/how-tos/configurable_headers.md": "https://docs.langchain.com/langsmith/configurable-headers",
"cloud/how-tos/configuration_cloud.md": "https://docs.langchain.com/langsmith/configuration-cloud",
"cloud/how-tos/cron_jobs.md": "https://docs.langchain.com/langsmith/cron-jobs",
"cloud/how-tos/datasets_studio.md": "https://docs.langchain.com/langsmith/use-studio",
"cloud/how-tos/enqueue_concurrent.md": "https://docs.langchain.com/langsmith/enqueue-concurrent",
"cloud/how-tos/generative_ui_react.md": "https://docs.langchain.com/langsmith/generative-ui-react",
"cloud/how-tos/human_in_the_loop_time_travel.md": "https://docs.langchain.com/langsmith/human-in-the-loop-time-travel",
"cloud/how-tos/interrupt_concurrent.md": "https://docs.langchain.com/langsmith/interrupt-concurrent",
"cloud/how-tos/invoke_studio.md": "https://docs.langchain.com/langsmith/use-studio",
"cloud/how-tos/iterate_graph_studio.md": "https://docs.langchain.com/langsmith/use-studio",
"cloud/how-tos/reject_concurrent.md": "https://docs.langchain.com/langsmith/reject-concurrent",
"cloud/how-tos/rollback_concurrent.md": "https://docs.langchain.com/langsmith/rollback-concurrent",
"cloud/how-tos/same-thread.md": "https://docs.langchain.com/langsmith/same-thread",
"cloud/how-tos/stateless_runs.md": "https://docs.langchain.com/langsmith/stateless-runs",
"cloud/how-tos/streaming.md": "https://docs.langchain.com/langsmith/streaming",
"cloud/how-tos/studio/manage_assistants.md": "https://docs.langchain.com/langsmith/use-studio",
"cloud/how-tos/studio/quick_start.md": "https://docs.langchain.com/langsmith/quick-start-studio",
"cloud/how-tos/studio/run_evals.md": "https://docs.langchain.com/langsmith/observability",
"cloud/how-tos/threads_studio.md": "https://docs.langchain.com/langsmith/use-threads",
"cloud/how-tos/use_stream_react.md": "https://docs.langchain.com/langsmith/use-stream-react",
"cloud/how-tos/use_threads.md": "https://docs.langchain.com/langsmith/use-threads",
"cloud/how-tos/webhooks.md": "https://docs.langchain.com/langsmith/use-webhooks",
"cloud/quick_start.md": "https://docs.langchain.com/langsmith/deployment-quickstart",
"cloud/reference/api/api_ref_control_plane.md": "https://docs.langchain.com/langsmith/api-ref-control-plane",
"cloud/reference/api/api_ref.md": "https://docs.langchain.com/langsmith/server-api-ref",
"cloud/reference/cli.md": "https://docs.langchain.com/langsmith/cli",
"cloud/reference/env_var.md": "https://docs.langchain.com/langsmith/env-var",
"cloud/reference/langgraph_server_changelog.md": "https://docs.langchain.com/langsmith/agent-server-changelog",
"cloud/reference/sdk/js_ts_sdk_ref.md": "https://reference.langchain.com/javascript/modules/langsmith.html",
"concepts/application_structure.md": "https://docs.langchain.com/langsmith/application-structure",
"concepts/assistants.md": "https://docs.langchain.com/langsmith/assistants",
"concepts/auth.md": "https://docs.langchain.com/langsmith/auth",
"concepts/deployment_options.md": "https://docs.langchain.com/langsmith/deployments",
"concepts/double_texting.md": "https://docs.langchain.com/langsmith/double-texting",
"concepts/faq.md": "https://docs.langchain.com/langsmith/faq",
"concepts/langgraph_cli.md": "https://docs.langchain.com/langsmith/cli",
"concepts/langgraph_cloud.md": "https://docs.langchain.com/langsmith/cloud",
"concepts/langgraph_components.md": "https://docs.langchain.com/langsmith/components",
"concepts/langgraph_control_plane.md": "https://docs.langchain.com/langsmith/control-plane",
"concepts/langgraph_data_plane.md": "https://docs.langchain.com/langsmith/data-plane",
"concepts/langgraph_platform.md": "https://docs.langchain.com/langsmith/home",
"concepts/langgraph_self_hosted_control_plane.md": "https://docs.langchain.com/langsmith/platform-setup",
"concepts/langgraph_self_hosted_data_plane.md": "https://docs.langchain.com/langsmith/platform-setup",
"concepts/langgraph_server.md": "https://docs.langchain.com/langsmith/agent-server",
"concepts/langgraph_standalone_container.md": "https://docs.langchain.com/langsmith/docker",
"concepts/langgraph_studio.md": "https://docs.langchain.com/langsmith/studio",
"concepts/plans.md": "https://docs.langchain.com/langsmith/home",
"concepts/scalability_and_resilience.md": "https://docs.langchain.com/langsmith/scalability-and-resilience",
"concepts/sdk.md": "https://docs.langchain.com/langsmith/sdk",
"concepts/server-mcp.md": "https://docs.langchain.com/langsmith/server-mcp",
"concepts/template_applications.md": "https://docs.langchain.com/oss/python/langgraph/overview",
"concepts/why-langgraph.md": "https://docs.langchain.com/oss/python/langgraph/overview",
"examples/index.md": "https://docs.langchain.com/oss/python/langgraph/case-studies",
"guides/index.md": "https://docs.langchain.com/oss/python/langchain/overview",
"how-tos/auth/custom_auth.md": "https://docs.langchain.com/langsmith/custom-auth",
"how-tos/auth/openapi_security.md": "https://docs.langchain.com/langsmith/openapi-security",
"how-tos/autogen-integration.md": "https://docs.langchain.com/langsmith/autogen-integration",
"how-tos/http/custom_lifespan.md": "https://docs.langchain.com/langsmith/custom-lifespan",
"how-tos/http/custom_middleware.md": "https://docs.langchain.com/langsmith/custom-middleware",
"how-tos/http/custom_routes.md": "https://docs.langchain.com/langsmith/custom-routes",
"how-tos/ttl/configure_ttl.md": "https://docs.langchain.com/langsmith/configure-ttl",
"how-tos/use-remote-graph.md": "https://docs.langchain.com/langsmith/use-remote-graph",
"index.md": "https://docs.langchain.com/oss/python/langgraph/overview",
"snippets/chat_model_tabs.md": "https://docs.langchain.com/oss/python/langchain/overview",
"troubleshooting/errors/GRAPH_RECURSION_LIMIT.md": "https://docs.langchain.com/oss/python/langgraph/GRAPH_RECURSION_LIMIT",
"troubleshooting/errors/index.md": "https://docs.langchain.com/oss/python/langgraph/common-errors",
"troubleshooting/errors/INVALID_CHAT_HISTORY.md": "https://docs.langchain.com/oss/python/langgraph/INVALID_CHAT_HISTORY",
"troubleshooting/errors/INVALID_CONCURRENT_GRAPH_UPDATE.md": "https://docs.langchain.com/oss/python/langgraph/INVALID_CONCURRENT_GRAPH_UPDATE",
"troubleshooting/errors/INVALID_GRAPH_NODE_RETURN_VALUE.md": "https://docs.langchain.com/oss/python/langgraph/INVALID_GRAPH_NODE_RETURN_VALUE",
"troubleshooting/errors/INVALID_LICENSE.md": "https://docs.langchain.com/oss/python/langgraph/common-errors",
"troubleshooting/errors/MULTIPLE_SUBGRAPHS.md": "https://docs.langchain.com/oss/python/langgraph/MULTIPLE_SUBGRAPHS",
"troubleshooting/studio.md": "https://docs.langchain.com/langsmith/troubleshooting-studio",
"tutorials/auth/add_auth_server.md": "https://docs.langchain.com/langsmith/add-auth-server",
"tutorials/auth/getting_started.md": "https://docs.langchain.com/langsmith/auth",
"tutorials/auth/resource_auth.md": "https://docs.langchain.com/langsmith/resource-auth",
"agents/agents.md": "https://docs.langchain.com/oss/python/langchain/agents",
"concepts/why-langgraph.md": "https://docs.langchain.com/oss/python/langgraph/overview",
"tutorials/langsmith/local-server.md": "https://docs.langchain.com/oss/python/langgraph/local-server",
"tutorials/workflows.md": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"concepts/agentic_concepts.md": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"guides/index.md": "https://docs.langchain.com/oss/python/langchain/overview",
"agents/overview.md": "https://docs.langchain.com/oss/python/langchain/agents",
"concepts/agentic_concepts.md": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"agents/run_agents.md": "https://docs.langchain.com/oss/python/langgraph/quickstart",
"concepts/low_level.md": "https://docs.langchain.com/oss/python/langgraph/graph-api",
"how-tos/graph-api.md": "https://docs.langchain.com/oss/python/langgraph/graph-api",
"concepts/functional_api.md": "https://docs.langchain.com/oss/python/langgraph/functional-api",
"how-tos/use-functional-api.md": "https://docs.langchain.com/oss/python/langgraph/functional-api",
"concepts/pregel.md": "https://docs.langchain.com/oss/python/langgraph/pregel",
"concepts/streaming.md": "https://docs.langchain.com/oss/python/langgraph/streaming",
"how-tos/streaming.md": "https://docs.langchain.com/oss/python/langgraph/streaming",
"concepts/persistence.md": "https://docs.langchain.com/oss/python/langgraph/persistence",
"concepts/durable_execution.md": "https://docs.langchain.com/oss/python/langgraph/durable-execution",
"concepts/memory.md": "https://docs.langchain.com/oss/python/langgraph/memory",
"how-tos/memory/add-memory.md": "https://docs.langchain.com/oss/python/langgraph/add-memory",
"agents/context.md": "https://docs.langchain.com/oss/python/langgraph/add-memory",
"agents/models.md": "https://docs.langchain.com/oss/python/langgraph/overview",
"concepts/tools.md": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"how-tos/tool-calling.md": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"concepts/human_in_the_loop.md": "https://docs.langchain.com/oss/python/langgraph/interrupts",
"how-tos/human_in_the_loop/add-human-in-the-loop.md": "https://docs.langchain.com/oss/python/langgraph/interrupts",
"concepts/time-travel.md": "https://docs.langchain.com/oss/python/langgraph/persistence",
"how-tos/human_in_the_loop/time-travel.md": "https://docs.langchain.com/oss/python/langgraph/use-time-travel",
"concepts/subgraphs.md": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs",
"how-tos/subgraph.md": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs",
"concepts/multi_agent.md": "https://docs.langchain.com/oss/python/langgraph/graph-api",
"agents/multi-agent.md": "https://docs.langchain.com/oss/python/langchain/multi-agent",
"how-tos/multi_agent.md": "https://docs.langchain.com/oss/python/langgraph/graph-api",
"concepts/mcp.md": "https://docs.langchain.com/oss/python/langgraph/overview",
"agents/mcp.md": "https://docs.langchain.com/oss/python/langgraph/overview",
"concepts/tracing.md": "https://docs.langchain.com/oss/python/langgraph/observability",
"how-tos/enable-tracing.md": "https://docs.langchain.com/oss/python/langgraph/observability",
"agents/evals.md": "https://docs.langchain.com/oss/python/langgraph/overview",
"examples/index.md": "https://docs.langchain.com/oss/python/langgraph/case-studies",
"concepts/template_applications.md": "https://docs.langchain.com/oss/python/langgraph/overview",
"tutorials/rag/langgraph_agentic_rag.md": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
"tutorials/multi_agent/agent_supervisor.md": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
"tutorials/sql/sql-agent.md": "https://docs.langchain.com/oss/python/langgraph/sql-agent",
"agents/ui.md": "https://docs.langchain.com/oss/python/langgraph/ui",
"how-tos/run-id-langsmith.md": "https://docs.langchain.com/oss/python/langgraph/observability",
"troubleshooting/errors/index.md": "https://docs.langchain.com/oss/python/langgraph/common-errors",
"troubleshooting/errors/GRAPH_RECURSION_LIMIT.md": "https://docs.langchain.com/oss/python/langgraph/GRAPH_RECURSION_LIMIT",
"troubleshooting/errors/INVALID_CONCURRENT_GRAPH_UPDATE.md": "https://docs.langchain.com/oss/python/langgraph/INVALID_CONCURRENT_GRAPH_UPDATE",
"troubleshooting/errors/INVALID_GRAPH_NODE_RETURN_VALUE.md": "https://docs.langchain.com/oss/python/langgraph/INVALID_GRAPH_NODE_RETURN_VALUE",
"troubleshooting/errors/MULTIPLE_SUBGRAPHS.md": "https://docs.langchain.com/oss/python/langgraph/MULTIPLE_SUBGRAPHS",
"troubleshooting/errors/INVALID_CHAT_HISTORY.md": "https://docs.langchain.com/oss/python/langgraph/INVALID_CHAT_HISTORY",
"troubleshooting/errors/INVALID_LICENSE.md": "https://docs.langchain.com/oss/python/langgraph/common-errors",
"adopters.md": "https://docs.langchain.com/oss/python/langgraph/case-studies",
"concepts/faq.md": "https://docs.langchain.com/oss/python/langgraph/overview",
"agents/prebuilt.md": "https://docs.langchain.com/oss/python/langchain/agents",
} }
@@ -805,27 +560,10 @@ def on_post_page(html: str, page: Page, config: MkDocsConfig) -> str:
# Create HTML files for redirects after site dir has been built # Create HTML files for redirects after site dir has been built
def on_post_build(config): def on_post_build(config):
use_directory_urls = config.get("use_directory_urls") use_directory_urls = config.get("use_directory_urls")
site_dir = config["site_dir"]
# Track which paths have explicit redirects
redirected_paths = set()
# Collect all existing HTML files in the site
all_html_files = set()
for root, dirs, files in os.walk(site_dir):
for file in files:
if file.endswith(".html"):
# Get relative path from site_dir
html_path = os.path.relpath(os.path.join(root, file), site_dir)
# Normalize path separators to forward slashes
html_path = html_path.replace(os.sep, "/")
all_html_files.add(html_path)
# Process explicit redirects from REDIRECT_MAP
for page_old, page_new in REDIRECT_MAP.items(): for page_old, page_new in REDIRECT_MAP.items():
# Convert .ipynb to .md for path calculation # Convert .ipynb to .md for path calculation
page_old = page_old.replace(".ipynb", ".md") page_old = page_old.replace(".ipynb", ".md")
# Calculate the HTML path for the old page (whether it exists or not) # Calculate the HTML path for the old page (whether it exists or not)
if use_directory_urls: if use_directory_urls:
# With directory URLs: /path/to/page/ becomes /path/to/page/index.html # With directory URLs: /path/to/page/ becomes /path/to/page/index.html
@@ -839,18 +577,15 @@ def on_post_build(config):
old_html_path = page_old[:-3] + ".html" old_html_path = page_old[:-3] + ".html"
else: else:
old_html_path = page_old + ".html" old_html_path = page_old + ".html"
# Track this path as redirected
redirected_paths.add(old_html_path)
if isinstance(page_new, str) and page_new.startswith("http"): if isinstance(page_new, str) and page_new.startswith("http"):
# Handle external redirects # Handle external redirects
_write_html(site_dir, old_html_path, page_new) _write_html(config["site_dir"], old_html_path, page_new)
else: else:
# Handle internal redirects # Handle internal redirects
page_new = page_new.replace(".ipynb", ".md") page_new = page_new.replace(".ipynb", ".md")
page_new_before_hash, hash, suffix = page_new.partition("#") page_new_before_hash, hash, suffix = page_new.partition("#")
# Try to get the new path using File class, but fallback to manual calculation # Try to get the new path using File class, but fallback to manual calculation
try: try:
new_html_path = File(page_new_before_hash, "", "", True).url new_html_path = File(page_new_before_hash, "", "", True).url
@@ -872,64 +607,5 @@ def on_post_build(config):
else: else:
new_html_path = page_new_before_hash + ".html" new_html_path = page_new_before_hash + ".html"
new_html_path += hash + suffix new_html_path += hash + suffix
_write_html(site_dir, old_html_path, new_html_path) _write_html(config["site_dir"], old_html_path, new_html_path)
# Create catch-all redirects for any HTML files not explicitly redirected
catchall_url = "https://docs.langchain.com/oss/python/langgraph/overview"
for html_file in all_html_files:
# Skip if this file is already explicitly redirected
if html_file in redirected_paths:
continue
# Skip the root index.html (we handle that separately)
if html_file == "index.html":
continue
# Skip reference documentation (keep those accessible)
if html_file.startswith("reference/"):
continue
# Create redirect for this unmapped file
_write_html(site_dir, html_file, catchall_url)
# Create root index.html redirect
root_redirect_html = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Redirecting to LangGraph Documentation</title>
<link rel="canonical" href="https://docs.langchain.com/oss/python/langgraph/overview">
<meta name="robots" content="noindex">
<script>var anchor=window.location.hash.substr(1);location.href="https://docs.langchain.com/oss/python/langgraph/overview"+(anchor?"#"+anchor:"")</script>
<meta http-equiv="refresh" content="0; url=https://docs.langchain.com/oss/python/langgraph/overview">
</head>
<body>
<h1>Documentation has moved</h1>
<p>The LangGraph documentation has moved to <a href="https://docs.langchain.com/oss/python/langgraph/overview">docs.langchain.com</a>.</p>
<p>Redirecting you now...</p>
</body>
</html>
"""
root_index_path = os.path.join(site_dir, "index.html")
with open(root_index_path, "w", encoding="utf-8") as f:
f.write(root_redirect_html)
# Create server-side catch-all redirect file for Netlify/Cloudflare Pages
# This handles any pages not explicitly mapped in REDIRECT_MAP
# Note: This won't work on GitHub Pages, but kept for potential future use
redirects_content = """# Netlify/Cloudflare Pages redirect rules
# Specific redirects are handled by individual HTML redirect pages
# This is the catch-all for any unmapped pages
# Exclude reference docs from catch-all
/reference/* 200
# Catch-all: redirect any page not explicitly mapped
/* https://docs.langchain.com/oss/python/langgraph/overview 301
"""
redirects_path = os.path.join(site_dir, "_redirects")
with open(redirects_path, "w", encoding="utf-8") as f:
f.write(redirects_content)
@@ -20,19 +20,16 @@ class Package(TypedDict):
description: str description: str
"""A brief description of what the package does.""" """A brief description of what the package does."""
class ResolvedPackage(Package): class ResolvedPackage(Package):
weekly_downloads: int | None weekly_downloads: int | None
"""The weekly download count of the package.""" """The weekly download count of the package."""
language: str language: str
"""The language of the package. (either 'python' or 'js')""" """The language of the package. (either 'python' or 'js')"""
HERE = pathlib.Path(__file__).parent HERE = pathlib.Path(__file__).parent
PACKAGES_FILE = HERE / "packages.yml" PACKAGES_FILE = HERE / "packages.yml"
PACKAGES = yaml.safe_load(PACKAGES_FILE.read_text())["packages"] PACKAGES = yaml.safe_load(PACKAGES_FILE.read_text())["packages"]
def _get_pypi_downloads(package: Package) -> int: def _get_pypi_downloads(package: Package) -> int:
"""Retrieve the weekly download count for a package from PyPIStats.""" """Retrieve the weekly download count for a package from PyPIStats."""
@@ -75,8 +72,7 @@ def _get_pypi_downloads(package: Package) -> int:
return sum(entry["downloads"] for entry in sorted_data[:7]) return sum(entry["downloads"] for entry in sorted_data[:7])
else: else:
return None return None
def _get_npm_downloads(package: Package) -> int: def _get_npm_downloads(package: Package) -> int:
"""Retrieve the weekly download count for a package on the npm registry.""" """Retrieve the weekly download count for a package on the npm registry."""
@@ -86,18 +82,14 @@ def _get_npm_downloads(package: Package) -> int:
npm_response = requests.get(npm_url) npm_response = requests.get(npm_url)
npm_response.raise_for_status() npm_response.raise_for_status()
except requests.exceptions.HTTPError: except requests.exceptions.HTTPError:
raise AssertionError( raise AssertionError(f"Package {package['name']} does not exist on npm registry")
f"Package {package['name']} does not exist on npm registry"
)
npm_data = npm_response.json() npm_data = npm_response.json()
# Retrieve the first publish date using the 'created' timestamp from the 'time' field. # Retrieve the first publish date using the 'created' timestamp from the 'time' field.
created_str = npm_data.get("time", {}).get("created") created_str = npm_data.get("time", {}).get("created")
if created_str is None: if created_str is None:
raise AssertionError( raise AssertionError(f"Package {package['name']} has no creation time in registry data")
f"Package {package['name']} has no creation time in registry data"
)
# Remove the trailing 'Z' if present and parse the ISO format timestamp # Remove the trailing 'Z' if present and parse the ISO format timestamp
first_publish_date = datetime.fromisoformat(created_str.rstrip("Z")) first_publish_date = datetime.fromisoformat(created_str.rstrip("Z"))
@@ -111,10 +103,7 @@ def _get_npm_downloads(package: Package) -> int:
else: else:
return None return None
def _get_weekly_downloads(packages: dict[str, list[Package]], fake: bool) -> list[ResolvedPackage]:
def _get_weekly_downloads(
packages: dict[str, list[Package]], fake: bool
) -> list[ResolvedPackage]:
"""Retrieve the weekly download count for a dictionary of python or js packages.""" """Retrieve the weekly download count for a dictionary of python or js packages."""
resolved_packages: list[ResolvedPackage] = [] resolved_packages: list[ResolvedPackage] = []
@@ -142,7 +131,7 @@ def _get_weekly_downloads(
num_downloads = _get_npm_downloads(package) num_downloads = _get_npm_downloads(package)
else: else:
num_downloads = None num_downloads = None
resolved_packages.append( resolved_packages.append(
{ {
"name": package["name"], "name": package["name"],
@@ -156,13 +145,12 @@ def _get_weekly_downloads(
return resolved_packages return resolved_packages
def main(output_file: str, fake: bool) -> None: def main(output_file: str, fake: bool) -> None:
"""Main function to generate package download information. """Main function to generate package download information.
Args: Args:
output_file: Path to the output YAML file. output_file: Path to the output YAML file.
fake: If `True`, use fake download counts for testing purposes. fake: If True, use fake download counts for testing purposes.
""" """
resolved_packages: list[ResolvedPackage] = _get_weekly_downloads(PACKAGES, fake) resolved_packages: list[ResolvedPackage] = _get_weekly_downloads(PACKAGES, fake)
+2 -2
View File
@@ -33,7 +33,7 @@ LangGraph provides three ways to manage context, which combines the mutability a
**Static runtime context** represents immutable data like user metadata, tools, and database connections that are passed to an application at the start of a run via the `context` argument to `invoke`/`stream`. This data does not change during execution. **Static runtime context** represents immutable data like user metadata, tools, and database connections that are passed to an application at the start of a run via the `context` argument to `invoke`/`stream`. This data does not change during execution.
!!! version-added "Added in version 0.6.0: `context` replaces `config['configurable']`" !!! version-added "New in LangGraph v0.6: `context` replaces `config['configurable']`"
Runtime context is now passed to the `context` argument of `invoke`/`stream`, Runtime context is now passed to the `context` argument of `invoke`/`stream`,
which replaces the previous pattern of passing application configuration to `config['configurable']`. which replaces the previous pattern of passing application configuration to `config['configurable']`.
@@ -90,7 +90,7 @@ graph.invoke( # (1)!
from langgraph.runtime import Runtime from langgraph.runtime import Runtime
# highlight-next-line # highlight-next-line
def node(state: State, runtime: Runtime[ContextSchema]): def node(state: State, config: Runtime[ContextSchema]):
user_name = runtime.context.user_name user_name = runtime.context.user_name
... ...
``` ```
+1 -5
View File
@@ -211,7 +211,7 @@ output = agent.invoke(
print(output["messages"][-1].text()) print(output["messages"][-1].text())
``` ```
!!! version-added "Added in version 0.6.0" !!! version-added "New in LangGraph v0.6"
::: :::
@@ -351,13 +351,11 @@ If your desired LLM isn't officially supported by LangChain, consider these opti
:::python :::python
1. **Implement a custom LangChain chat model**: Create a model conforming to the [LangChain chat model interface](https://python.langchain.com/docs/how_to/custom_chat_model/). This enables full compatibility with LangGraph's agents and workflows but requires understanding of the LangChain framework. 1. **Implement a custom LangChain chat model**: Create a model conforming to the [LangChain chat model interface](https://python.langchain.com/docs/how_to/custom_chat_model/). This enables full compatibility with LangGraph's agents and workflows but requires understanding of the LangChain framework.
::: :::
:::js :::js
1. **Implement a custom LangChain chat model**: Create a model conforming to the [LangChain chat model interface](https://js.langchain.com/docs/how_to/custom_chat/). This enables full compatibility with LangGraph's agents and workflows but requires understanding of the LangChain framework. 1. **Implement a custom LangChain chat model**: Create a model conforming to the [LangChain chat model interface](https://js.langchain.com/docs/how_to/custom_chat/). This enables full compatibility with LangGraph's agents and workflows but requires understanding of the LangChain framework.
::: :::
2. **Direct invocation with custom streaming**: Use your model directly by [adding custom streaming logic](../how-tos/streaming.md#use-with-any-llm) with `StreamWriter`. 2. **Direct invocation with custom streaming**: Use your model directly by [adding custom streaming logic](../how-tos/streaming.md#use-with-any-llm) with `StreamWriter`.
@@ -373,7 +371,6 @@ If your desired LLM isn't officially supported by LangChain, consider these opti
- [Force model to call a specific tool](https://python.langchain.com/docs/how_to/tool_choice/) - [Force model to call a specific tool](https://python.langchain.com/docs/how_to/tool_choice/)
- [All chat model how-to guides](https://python.langchain.com/docs/how_to/#chat-models) - [All chat model how-to guides](https://python.langchain.com/docs/how_to/#chat-models)
- [Chat model integrations](https://python.langchain.com/docs/integrations/chat/) - [Chat model integrations](https://python.langchain.com/docs/integrations/chat/)
::: :::
:::js :::js
@@ -384,5 +381,4 @@ If your desired LLM isn't officially supported by LangChain, consider these opti
- [Force model to call a specific tool](https://js.langchain.com/docs/how_to/tool_choice/) - [Force model to call a specific tool](https://js.langchain.com/docs/how_to/tool_choice/)
- [All chat model how-to guides](https://js.langchain.com/docs/how_to/#chat-models) - [All chat model how-to guides](https://js.langchain.com/docs/how_to/#chat-models)
- [Chat model integrations](https://js.langchain.com/docs/integrations/chat/) - [Chat model integrations](https://js.langchain.com/docs/integrations/chat/)
::: :::
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -483,19 +483,19 @@ The LangGraph CLI requires a JSON configuration file that follows this [schema](
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt langchain_community langchain_anthropic langchain_openai wikipedia scikit-learn RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt langchain_community langchain_anthropic langchain_openai wikipedia scikit-learn
ADD ./graphs /deps/outer-graphs/src ADD ./graphs /deps/__outer_graphs/src
RUN set -ex && \ RUN set -ex && \
for line in '[project]' \ for line in '[project]' \
'name = "graphs"' \ 'name = "graphs"' \
'version = "0.1"' \ 'version = "0.1"' \
'[tool.setuptools.package-data]' \ '[tool.setuptools.package-data]' \
'"*" = ["**/*"]'; do \ '"*" = ["**/*"]'; do \
echo "$line" >> /deps/outer-graphs/pyproject.toml; \ echo "$line" >> /deps/__outer_graphs/pyproject.toml; \
done done
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/* RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
ENV LANGSERVE_GRAPHS='{"agent": "/deps/outer-graphs/src/agent.py:graph", "storm": "/deps/outer-graphs/src/storm.py:graph"}' ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_graphs/src/agent.py:graph", "storm": "/deps/__outer_graphs/src/storm.py:graph"}'
``` ```
???+ note "Updating your langgraph.json file" ???+ note "Updating your langgraph.json file"
+4 -14
View File
@@ -21,16 +21,12 @@ To leverage durable execution in LangGraph, you need to:
1. Enable [persistence](./persistence.md) in your workflow by specifying a [checkpointer](./persistence.md#checkpointer-libraries) that will save workflow progress. 1. Enable [persistence](./persistence.md) in your workflow by specifying a [checkpointer](./persistence.md#checkpointer-libraries) that will save workflow progress.
2. Specify a [thread identifier](./persistence.md#threads) when executing a workflow. This will track the execution history for a particular instance of the workflow. 2. Specify a [thread identifier](./persistence.md#threads) when executing a workflow. This will track the execution history for a particular instance of the workflow.
:::python :::python
3. Wrap any non-deterministic operations (e.g., random number generation) or operations with side effects (e.g., file writes, API calls) inside @[tasks][task] to ensure that when a workflow is resumed, these operations are not repeated for the particular run, and instead their results are retrieved from the persistence layer. For more information, see [Determinism and Consistent Replay](#determinism-and-consistent-replay). 3. Wrap any non-deterministic operations (e.g., random number generation) or operations with side effects (e.g., file writes, API calls) inside @[tasks][task] to ensure that when a workflow is resumed, these operations are not repeated for the particular run, and instead their results are retrieved from the persistence layer. For more information, see [Determinism and Consistent Replay](#determinism-and-consistent-replay).
::: :::
:::js :::js
3. Wrap any non-deterministic operations (e.g., random number generation) or operations with side effects (e.g., file writes, API calls) inside @[tasks][task] to ensure that when a workflow is resumed, these operations are not repeated for the particular run, and instead their results are retrieved from the persistence layer. For more information, see [Determinism and Consistent Replay](#determinism-and-consistent-replay). 3. Wrap any non-deterministic operations (e.g., random number generation) or operations with side effects (e.g., file writes, API calls) inside @[tasks][task] to ensure that when a workflow is resumed, these operations are not repeated for the particular run, and instead their results are retrieved from the persistence layer. For more information, see [Determinism and Consistent Replay](#determinism-and-consistent-replay).
::: :::
## Determinism and Consistent Replay ## Determinism and Consistent Replay
@@ -65,7 +61,7 @@ LangGraph supports three durability modes that allow you to balance performance
A higher durability mode add more overhead to the workflow execution. A higher durability mode add more overhead to the workflow execution.
!!! version-added "Added in version 0.6.0" !!! version-added "Added in v0.6.0"
Use the `durability` parameter instead of `checkpoint_during` (deprecated in v0.6.0) for persistence policy management: Use the `durability` parameter instead of `checkpoint_during` (deprecated in v0.6.0) for persistence policy management:
@@ -77,16 +73,14 @@ A higher durability mode add more overhead to the workflow execution.
* `checkpoint_during=True` -> `durability="async"` * `checkpoint_during=True` -> `durability="async"`
* `checkpoint_during=False` -> `durability="exit"` * `checkpoint_during=False` -> `durability="exit"`
### `"exit"`
### `"exit"`
Changes are persisted only when graph execution completes (either successfully or with an error). This provides the best performance for long-running graphs but means intermediate state is not saved, so you cannot recover from mid-execution failures or interrupt the graph execution. Changes are persisted only when graph execution completes (either successfully or with an error). This provides the best performance for long-running graphs but means intermediate state is not saved, so you cannot recover from mid-execution failures or interrupt the graph execution.
### `"async"` ### `"async"`
Changes are persisted asynchronously while the next step executes. This provides good performance and durability, but there's a small risk that checkpoints might not be written if the process crashes during execution. Changes are persisted asynchronously while the next step executes. This provides good performance and durability, but there's a small risk that checkpoints might not be written if the process crashes during execution.
### `"sync"` ### `"sync"`
Changes are persisted synchronously before the next step starts. This ensures that every checkpoint is written before continuing execution, providing high durability at the cost of some performance overhead. Changes are persisted synchronously before the next step starts. This ensures that every checkpoint is written before continuing execution, providing high durability at the cost of some performance overhead.
You can specify the durability mode when calling any graph execution method: You can specify the durability mode when calling any graph execution method:
@@ -316,14 +310,12 @@ Once you have enabled durable execution in your workflow, you can resume executi
- **Pausing and Resuming Workflows:** Use the @[interrupt][interrupt] function to pause a workflow at specific points and the @[Command] primitive to resume it with updated state. See [**Human-in-the-Loop**](./human_in_the_loop.md) for more details. - **Pausing and Resuming Workflows:** Use the @[interrupt][interrupt] function to pause a workflow at specific points and the @[Command] primitive to resume it with updated state. See [**Human-in-the-Loop**](./human_in_the_loop.md) for more details.
- **Recovering from Failures:** Automatically resume workflows from the last successful checkpoint after an exception (e.g., LLM provider outage). This involves executing the workflow with the same thread identifier by providing it with a `None` as the input value (see this [example](../how-tos/use-functional-api.md#resuming-after-an-error) with the functional API). - **Recovering from Failures:** Automatically resume workflows from the last successful checkpoint after an exception (e.g., LLM provider outage). This involves executing the workflow with the same thread identifier by providing it with a `None` as the input value (see this [example](../how-tos/use-functional-api.md#resuming-after-an-error) with the functional API).
::: :::
:::js :::js
- **Pausing and Resuming Workflows:** Use the @[interrupt][interrupt] function to pause a workflow at specific points and the @[Command] primitive to resume it with updated state. See [**Human-in-the-Loop**](./human_in_the_loop.md) for more details. - **Pausing and Resuming Workflows:** Use the @[interrupt][interrupt] function to pause a workflow at specific points and the @[Command] primitive to resume it with updated state. See [**Human-in-the-Loop**](./human_in_the_loop.md) for more details.
- **Recovering from Failures:** Automatically resume workflows from the last successful checkpoint after an exception (e.g., LLM provider outage). This involves executing the workflow with the same thread identifier by providing it with a `null` as the input value (see this [example](../how-tos/use-functional-api.md#resuming-after-an-error) with the functional API). - **Recovering from Failures:** Automatically resume workflows from the last successful checkpoint after an exception (e.g., LLM provider outage). This involves executing the workflow with the same thread identifier by providing it with a `null` as the input value (see this [example](../how-tos/use-functional-api.md#resuming-after-an-error) with the functional API).
::: :::
## Starting Points for Resuming Workflows ## Starting Points for Resuming Workflows
@@ -334,7 +326,6 @@ Once you have enabled durable execution in your workflow, you can resume executi
- If you're making a subgraph call inside a node, the starting point will be the **parent** node that called the subgraph that was halted. - If you're making a subgraph call inside a node, the starting point will be the **parent** node that called the subgraph that was halted.
Inside the subgraph, the starting point will be the specific [**node**](./low_level.md#nodes) where execution stopped. Inside the subgraph, the starting point will be the specific [**node**](./low_level.md#nodes) where execution stopped.
- If you're using the Functional API, the starting point is the beginning of the [**entrypoint**](./functional_api.md#entrypoint) where execution stopped. - If you're using the Functional API, the starting point is the beginning of the [**entrypoint**](./functional_api.md#entrypoint) where execution stopped.
::: :::
:::js :::js
@@ -343,5 +334,4 @@ Once you have enabled durable execution in your workflow, you can resume executi
- If you're making a subgraph call inside a node, the starting point will be the **parent** node that called the subgraph that was halted. - If you're making a subgraph call inside a node, the starting point will be the **parent** node that called the subgraph that was halted.
Inside the subgraph, the starting point will be the specific [**node**](./low_level.md#nodes) where execution stopped. Inside the subgraph, the starting point will be the specific [**node**](./low_level.md#nodes) where execution stopped.
- If you're using the Functional API, the starting point is the beginning of the [**entrypoint**](./functional_api.md#entrypoint) where execution stopped. - If you're using the Functional API, the starting point is the beginning of the [**entrypoint**](./functional_api.md#entrypoint) where execution stopped.
::: :::
+1 -1
View File
@@ -1040,7 +1040,7 @@ def node_a(state: State, runtime: Runtime[ContextSchema]):
... ...
``` ```
See [this guide](../how-tos/graph-api.md#add-runtime-configuration) for a full breakdown on configuration. See [this guide](../how-tos/graph-api.ipynb#add-runtime-configuration) for a full breakdown on configuration.
::: :::
:::js :::js
+2 -2
View File
@@ -134,7 +134,7 @@ def update_instructions(state: State, store: BaseStore):
namespace = ("instructions",) namespace = ("instructions",)
current_instructions = store.search(namespace)[0] current_instructions = store.search(namespace)[0]
# Memory logic # Memory logic
prompt = prompt_template.format(instructions=current_instructions.value["instructions"], conversation=state["messages"]) prompt = prompt_template.format(instructions=instructions.value["instructions"], conversation=state["messages"])
output = llm.invoke(prompt) output = llm.invoke(prompt)
new_instructions = output['new_instructions'] new_instructions = output['new_instructions']
store.put(("agent_instructions",), "agent_a", {"instructions": new_instructions}) store.put(("agent_instructions",), "agent_a", {"instructions": new_instructions})
@@ -278,4 +278,4 @@ const items = await store.search(
``` ```
::: :::
For more information about the memory store, see the [Persistence](persistence.md#memory-store) guide. For more information about the memory store, see the [Persistence](persistence.md#memory-store) guide.
+2 -2
View File
@@ -897,5 +897,5 @@ 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: 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, it's important to [add input / output transformations](../how-tos/subgraph.md#different-state-schemas) so that the parent graph knows how to communicate with the subgraphs. - 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, it's 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.md#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.
+14 -13
View File
@@ -1019,7 +1019,7 @@ console.log(await graph.invoke({}, { configurable: { myRuntimeValue: "b" } }));
# Usage # Usage
input_message = {"role": "user", "content": "hi"} input_message = {"role": "user", "content": "hi"}
# With no configuration, uses default (Anthropic) # With no configuration, uses default (Anthropic)
response_1 = graph.invoke({"messages": [input_message]}, context=ContextSchema())["messages"][-1] response_1 = graph.invoke({"messages": [input_message]})["messages"][-1]
# Or, can set OpenAI # Or, can set OpenAI
response_2 = graph.invoke({"messages": [input_message]}, context={"model_provider": "openai"})["messages"][-1] response_2 = graph.invoke({"messages": [input_message]}, context={"model_provider": "openai"})["messages"][-1]
@@ -1205,7 +1205,7 @@ There are many use cases where you may wish for your node to have a custom retry
To configure a retry policy, pass the `retry_policy` parameter to the [add_node](../reference/graphs.md#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: To configure a retry policy, pass the `retry_policy` parameter to the [add_node](../reference/graphs.md#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:
```python ```python
from langgraph.types import RetryPolicy from langgraph.pregel import RetryPolicy
builder.add_node( builder.add_node(
"node_name", "node_name",
@@ -1260,7 +1260,7 @@ By default, the retry policy retries on any exception except for the following:
from typing_extensions import TypedDict from typing_extensions import TypedDict
from langchain.chat_models import init_chat_model from langchain.chat_models import init_chat_model
from langgraph.graph import END, MessagesState, StateGraph, START from langgraph.graph import END, MessagesState, StateGraph, START
from langgraph.types import RetryPolicy from langgraph.pregel import RetryPolicy
from langchain_community.utilities import SQLDatabase from langchain_community.utilities import SQLDatabase
from langchain_core.messages import AIMessage from langchain_core.messages import AIMessage
@@ -1422,15 +1422,15 @@ const builder = new StateGraph(State)
::: :::
??? info "Why split application steps into a sequence with LangGraph?" ??? info "Why split application steps into a sequence with LangGraph?"
LangGraph makes it easy to add an underlying persistence layer to your application. LangGraph makes it easy to add an underlying persistence layer to your application.
This allows state to be checkpointed in between the execution of nodes, so your LangGraph nodes govern: This allows state to be checkpointed in between the execution of nodes, so your LangGraph nodes govern:
- How state updates are [checkpointed](../concepts/persistence.md) - How state updates are [checkpointed](../concepts/persistence.md)
- How interruptions are resumed in [human-in-the-loop](../concepts/human_in_the_loop.md) workflows - How interruptions are resumed in [human-in-the-loop](../concepts/human_in_the_loop.md) workflows
- How we can "rewind" and branch-off executions using LangGraph's [time travel](../concepts/time-travel.md) features - How we can "rewind" and branch-off executions using LangGraph's [time travel](../concepts/time-travel.md) features
They also determine how execution steps are [streamed](../concepts/streaming.md), and how your application is visualized They also determine how execution steps are [streamed](../concepts/streaming.md), and how your application is visualized
and debugged using [LangGraph Studio](../concepts/langgraph_studio.md). and debugged using [LangGraph Studio](../concepts/langgraph_studio.md).
Let's demonstrate an end-to-end example. We will create a sequence of three steps: Let's demonstrate an end-to-end example. We will create a sequence of three steps:
@@ -2110,6 +2110,7 @@ builder.add_edge(START, "generate_topics")
builder.add_conditional_edges("generate_topics", continue_to_jokes, ["generate_joke"]) builder.add_conditional_edges("generate_topics", continue_to_jokes, ["generate_joke"])
builder.add_edge("generate_joke", "best_joke") builder.add_edge("generate_joke", "best_joke")
builder.add_edge("best_joke", END) builder.add_edge("best_joke", END)
builder.add_edge("generate_topics", END)
graph = builder.compile() graph = builder.compile()
``` ```
@@ -2332,7 +2333,7 @@ from IPython.display import Image, display
display(Image(graph.get_graph().draw_mermaid_png())) display(Image(graph.get_graph().draw_mermaid_png()))
``` ```
![Simple loop graph](assets/graph_api_image_7.png) ![Simple loop graph](assets/graph_api_image_3.png)
::: :::
:::js :::js
@@ -3271,7 +3272,7 @@ from langchain_core.runnables.graph import CurveStyle, MermaidDrawMethod, NodeSt
display(Image(app.get_graph().draw_mermaid_png())) display(Image(app.get_graph().draw_mermaid_png()))
``` ```
![Fractal graph visualization](assets/graph_api_image_10.png) ![Fractal graph visualization](assets/graph_api_image_5.png)
**Using Mermaid + Pyppeteer** **Using Mermaid + Pyppeteer**
@@ -3319,4 +3320,4 @@ const imageBuffer = new Uint8Array(await image.arrayBuffer());
await fs.writeFile("graph.png", imageBuffer); await fs.writeFile("graph.png", imageBuffer);
``` ```
::: :::
@@ -366,8 +366,8 @@ result = graph.invoke(
# Resume with mapping of interrupt IDs to values # Resume with mapping of interrupt IDs to values
resume_map = { resume_map = {
i.id: f"edited text for {i.value['text_to_revise']}" i.interrupt_id: f"human input for prompt {i.value}"
for i in graph.get_state(config).interrupts for i in parent.get_state(thread_config).interrupts
} }
print(graph.invoke(Command(resume=resume_map), config=config)) print(graph.invoke(Command(resume=resume_map), config=config))
# > {'text_1': 'edited text for original text 1', 'text_2': 'edited text for original text 2'} # > {'text_1': 'edited text for original text 1', 'text_2': 'edited text for original text 2'}
+1 -1
View File
@@ -244,7 +244,7 @@ output = agent.invoke(
print(output["messages"][-1].text()) print(output["messages"][-1].text())
``` ```
!!! version-added "Added in version 0.6.0" !!! version-added "New in langgraph>=0.6"
::: :::
+1 -1
View File
@@ -2,4 +2,4 @@
::: langgraph.cache.base ::: langgraph.cache.base
::: langgraph.cache.memory ::: langgraph.cache.memory
::: langgraph.cache.sqlite ::: langgraph.cache.sqlite
+1 -1
View File
@@ -68,7 +68,7 @@ The server will start and open the studio in your browser:
> - 📚 API Docs: http://127.0.0.1:2024/docs > - 📚 API Docs: http://127.0.0.1:2024/docs
> >
> This in-memory server is designed for development and testing. > This in-memory server is designed for development and testing.
> For production use, please use LangSmith Deployment. > For production use, please use LangGraph Platform.
``` ```
If you were to self-host this on the public internet, anyone could access it! If you were to self-host this on the public internet, anyone could access it!
@@ -294,9 +294,9 @@ Now that you have a LangGraph app running locally, take your journey further by
:::python :::python
- [Python SDK Reference](../../cloud/reference/sdk/python_sdk_ref.md): Explore the Python SDK API Reference. - [Python SDK Reference](../../cloud/reference/sdk/python_sdk_ref.md): Explore the Python SDK API Reference.
::: :::
:::js :::js
- [JS/TS SDK Reference](../../cloud/reference/sdk/js_ts_sdk_ref.md): Explore the JS/TS SDK API Reference. - [JS/TS SDK Reference](../../cloud/reference/sdk/js_ts_sdk_ref.md): Explore the JS/TS SDK API Reference.
::: :::
+1 -1
View File
@@ -1948,7 +1948,7 @@ const llmWithTools = llm.bindTools(tools);
# Conditional edge function to route to the tool node or end based upon whether the LLM made a tool call # Conditional edge function to route to the tool node or end based upon whether the LLM made a tool call
def should_continue(state: MessagesState) -> Literal["Action", END]: def should_continue(state: MessagesState) -> Literal["environment", END]:
"""Decide if we should continue the loop or stop based upon whether the LLM made a tool call""" """Decide if we should continue the loop or stop based upon whether the LLM made a tool call"""
messages = state["messages"] messages = state["messages"]
+98 -62
View File
@@ -149,67 +149,6 @@ plugins:
- tutorials/auth/add_auth_server.md - tutorials/auth/add_auth_server.md
- tutorials/auth/getting_started.md - tutorials/auth/getting_started.md
- tutorials/auth/resource_auth.md - tutorials/auth/resource_auth.md
- agents/agents.md
- concepts/why-langgraph.md
- tutorials/get-started/1-build-basic-chatbot.md
- tutorials/get-started/2-add-tools.md
- tutorials/get-started/3-add-memory.md
- tutorials/get-started/4-human-in-the-loop.md
- tutorials/get-started/5-customize-state.md
- tutorials/get-started/6-time-travel.md
- tutorials/langgraph-platform/local-server.md
- tutorials/workflows.md
- concepts/agentic_concepts.md
- guides/index.md
- agents/overview.md
- agents/run_agents.md
- concepts/low_level.md
- how-tos/graph-api.md
- concepts/functional_api.md
- how-tos/use-functional-api.md
- concepts/pregel.md
- concepts/streaming.md
- how-tos/streaming.md
- concepts/persistence.md
- concepts/durable_execution.md
- concepts/memory.md
- how-tos/memory/add-memory.md
- agents/context.md
- agents/models.md
- concepts/tools.md
- how-tos/tool-calling.md
- concepts/human_in_the_loop.md
- how-tos/human_in_the_loop/add-human-in-the-loop.md
- concepts/time-travel.md
- how-tos/human_in_the_loop/time-travel.md
- concepts/subgraphs.md
- how-tos/subgraph.md
- concepts/multi_agent.md
- agents/multi-agent.md
- how-tos/multi_agent.md
- concepts/mcp.md
- agents/mcp.md
- concepts/tracing.md
- how-tos/enable-tracing.md
- agents/evals.md
- examples/index.md
- concepts/template_applications.md # TODO: make tutorial
- tutorials/rag/langgraph_agentic_rag.md
- tutorials/multi_agent/agent_supervisor.md
- tutorials/sql/sql-agent.md
- agents/ui.md
- how-tos/run-id-langsmith.md
- troubleshooting/errors/index.md
- troubleshooting/errors/GRAPH_RECURSION_LIMIT.md
- troubleshooting/errors/INVALID_CONCURRENT_GRAPH_UPDATE.md
- troubleshooting/errors/INVALID_GRAPH_NODE_RETURN_VALUE.md
- troubleshooting/errors/MULTIPLE_SUBGRAPHS.md
- troubleshooting/errors/INVALID_CHAT_HISTORY.md
- troubleshooting/errors/INVALID_LICENSE.md
- adopters.md
- concepts/faq.md
- agents/prebuilt.md # NOTE: prebuilt.md is auto-generated by `make build-prebuilt`
- tags - tags
- include-markdown - include-markdown
- mkdocstrings: - mkdocstrings:
@@ -247,6 +186,75 @@ plugins:
- "!^_" - "!^_"
nav: nav:
- Get started:
- index.md
- Quickstarts:
- Start with a prebuilt agent: agents/agents.md
- Build a custom workflow:
- concepts/why-langgraph.md
- 1. Build a basic chatbot: tutorials/get-started/1-build-basic-chatbot.md
- 2. Add tools: tutorials/get-started/2-add-tools.md
- 3. Add memory: tutorials/get-started/3-add-memory.md
- 4. Add human-in-the-loop: tutorials/get-started/4-human-in-the-loop.md
- 5. Customize state: tutorials/get-started/5-customize-state.md
- 6. Time travel: tutorials/get-started/6-time-travel.md
- Run a local server: tutorials/langgraph-platform/local-server.md
- General concepts:
- Workflows & agents: tutorials/workflows.md
- Agent architectures: concepts/agentic_concepts.md
- Guides:
- guides/index.md
- Agent development:
- Overview: agents/overview.md
- Run an agent: agents/run_agents.md
- LangGraph APIs:
- Graph API:
- Overview: concepts/low_level.md
- Use the Graph API: how-tos/graph-api.md
- Functional API:
- Overview: concepts/functional_api.md
- Use the Functional API: how-tos/use-functional-api.md
- Runtime: concepts/pregel.md
- Core capabilities:
- Streaming:
- Overview: concepts/streaming.md
- Stream outputs: how-tos/streaming.md
- Persistence:
- Overview: concepts/persistence.md
- Durable execution:
- Overview: concepts/durable_execution.md
- Memory:
- Overview: concepts/memory.md
- Add memory: how-tos/memory/add-memory.md
- Context:
- Add context: agents/context.md
- Models:
- Configure model: agents/models.md
- Tools:
- Overview: concepts/tools.md
- Call tools: how-tos/tool-calling.md
- Human-in-the-loop:
- Overview: concepts/human_in_the_loop.md
- Add human intervention: how-tos/human_in_the_loop/add-human-in-the-loop.md
- Time travel:
- Overview: concepts/time-travel.md
- Use time travel: how-tos/human_in_the_loop/time-travel.md
- Subgraphs:
- Overview: concepts/subgraphs.md
- Use subgraphs: how-tos/subgraph.md
- Multi-agent:
- Overview: concepts/multi_agent.md
- Prebuilt implementation: agents/multi-agent.md
- Custom implementation: how-tos/multi_agent.md
- MCP:
- Overview: concepts/mcp.md
- Use MCP: agents/mcp.md
- Tracing:
- Overview: concepts/tracing.md
- Enable tracing: how-tos/enable-tracing.md
- Evaluate performance: agents/evals.md
- Reference: - Reference:
- reference/index.md - reference/index.md
- LangGraph: - LangGraph:
@@ -270,7 +278,35 @@ nav:
- LangGraph Platform: - LangGraph Platform:
- SDK (Python): cloud/reference/sdk/python_sdk_ref.md - SDK (Python): cloud/reference/sdk/python_sdk_ref.md
- SDK (JS/TS): https://langchain-ai.github.io/langgraphjs/reference/modules/sdk.html - SDK (JS/TS): https://langchain-ai.github.io/langgraphjs/reference/modules/sdk.html
- RemoteGraph: reference/remote_graph.md - RemoteGraph: reference/remote_graph.md
- Examples:
- examples/index.md
- Template applications: concepts/template_applications.md # TODO: make tutorial
- Agentic RAG: tutorials/rag/langgraph_agentic_rag.md
- Agent Supervisor: tutorials/multi_agent/agent_supervisor.md
- SQL agent: tutorials/sql/sql-agent.md
- Prebuilt chat UI: agents/ui.md
- Graph runs in LangSmith: how-tos/run-id-langsmith.md
- Additional resources:
- additional-resources/index.md
- agents/prebuilt.md # NOTE: prebuilt.md is auto-generated by `make build-prebuilt`
- LangGraph Academy course: https://academy.langchain.com/courses/intro-to-langgraph
- Case studies: adopters.md
- concepts/faq.md
- llms.txt: llms-txt-overview.md
- LangChain Forum: https://forum.langchain.com/
- Troubleshooting:
- Errors:
- troubleshooting/errors/index.md
- troubleshooting/errors/GRAPH_RECURSION_LIMIT.md
- troubleshooting/errors/INVALID_CONCURRENT_GRAPH_UPDATE.md
- troubleshooting/errors/INVALID_GRAPH_NODE_RETURN_VALUE.md
- troubleshooting/errors/MULTIPLE_SUBGRAPHS.md
- troubleshooting/errors/INVALID_CHAT_HISTORY.md
- troubleshooting/errors/INVALID_LICENSE.md
markdown_extensions: markdown_extensions:
- abbr - abbr
+2 -2
View File
@@ -291,7 +291,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
} }
.md-banner { .md-banner {
background-color: #FFAE42; background-color: #CFC9FA;
color: #000000; color: #000000;
} }
@@ -360,5 +360,5 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
{% endblock %} {% endblock %}
{% block announce %} {% block announce %}
These docs will be deprecated and removed with the release of LangGraph v1.0 in October 2025. <a href="https://docs.langchain.com/oss/python/langgraph/overview" target="_blank">Visit the v1.0 alpha docs</a> Our new LangChain Academy Course Deep Research with LangGraph is now live! <a href="https://academy.langchain.com/courses/deep-research-with-langgraph/?utm_medium=internal&utm_source=docs&utm_campaign=q3-2025_deep-research-course_co" target="_blank">Enroll for free</a>.
{% endblock %} {% endblock %}
+4 -4
View File
@@ -7,14 +7,14 @@ name = "langgraph-docs"
version = "0.0.1" version = "0.0.1"
description = "LangGraph docs" description = "LangGraph docs"
authors = [] authors = []
requires-python = ">=3.11.0,<4.0.0" requires-python = "~=3.11"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
dependencies = [ dependencies = [
"aiohappyeyeballs==2.4.3", "aiohappyeyeballs==2.4.3",
"hub>=3.0.1,<4.0.0", "hub>=3.0.1,<4",
"xxhash>=3.5.0,<4.0.0", "xxhash>=3.5.0,<4",
"black>=25.1.0,<26.0.0", "black>=25.1.0,<26",
] ]
[dependency-groups] [dependency-groups]
Generated
+4 -5
View File
@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 3 revision = 2
requires-python = ">=3.11, <4" requires-python = ">=3.11, <4"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'",
@@ -2337,7 +2337,7 @@ wheels = [
[[package]] [[package]]
name = "langgraph" name = "langgraph"
version = "0.6.7" version = "0.6.2"
source = { editable = "../libs/langgraph" } source = { editable = "../libs/langgraph" }
dependencies = [ dependencies = [
{ name = "langchain-core" }, { name = "langchain-core" },
@@ -2380,7 +2380,6 @@ dev = [
{ name = "pytest-repeat" }, { name = "pytest-repeat" },
{ name = "pytest-watcher" }, { name = "pytest-watcher" },
{ name = "pytest-xdist", extras = ["psutil"] }, { name = "pytest-xdist", extras = ["psutil"] },
{ name = "redis" },
{ name = "ruff" }, { name = "ruff" },
{ name = "syrupy" }, { name = "syrupy" },
{ name = "types-requests" }, { name = "types-requests" },
@@ -2414,7 +2413,6 @@ dev = [
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pytest-mock" }, { name = "pytest-mock" },
{ name = "pytest-watcher" }, { name = "pytest-watcher" },
{ name = "redis" },
{ name = "ruff" }, { name = "ruff" },
] ]
@@ -2645,7 +2643,7 @@ test = [
[[package]] [[package]]
name = "langgraph-prebuilt" name = "langgraph-prebuilt"
version = "0.6.4" version = "0.6.2"
source = { editable = "../libs/prebuilt" } source = { editable = "../libs/prebuilt" }
dependencies = [ dependencies = [
{ name = "langchain-core" }, { name = "langchain-core" },
@@ -2676,6 +2674,7 @@ dev = [
[[package]] [[package]]
name = "langgraph-sdk" name = "langgraph-sdk"
version = "0.2.0"
source = { editable = "../libs/sdk-py" } source = { editable = "../libs/sdk-py" }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
+1 -1
View File
@@ -5,7 +5,7 @@
"id": "18526f23", "id": "18526f23",
"metadata": {}, "metadata": {},
"source": [ "source": [
"This file has been moved to https://github.com/langchain-ai/langgraph/blob/main/docs/docs/how-tos/memory/add-memory.md" "This file has been moved to https://github.com/langchain-ai/langgraph/blob/main/docs/docs/how-tos/persistence_postgres.ipynb"
] ]
} }
], ],
+1 -3
View File
@@ -707,9 +707,7 @@
" \"\"\"\n", " \"\"\"\n",
" Find all tool calls in the messages returned\n", " Find all tool calls in the messages returned\n",
" \"\"\"\n", " \"\"\"\n",
" tool_calls = [\n", " tool_calls = [tc['name'] for m in messages['messages'] for tc in getattr(m, 'tool_calls', [])]\n",
" tc[\"name\"] for m in messages[\"messages\"] for tc in getattr(m, \"tool_calls\", [])\n",
" ]\n",
" return tool_calls\n", " return tool_calls\n",
"\n", "\n",
"\n", "\n",
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 LangChain, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -7,6 +7,11 @@ from contextlib import contextmanager
from typing import Any from typing import Any
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from psycopg import Capabilities, Connection, Cursor, Pipeline
from psycopg.rows import DictRow, dict_row
from psycopg.types.json import Jsonb
from psycopg_pool import ConnectionPool
from langgraph.checkpoint.base import ( from langgraph.checkpoint.base import (
WRITES_IDX_MAP, WRITES_IDX_MAP,
ChannelVersions, ChannelVersions,
@@ -14,17 +19,12 @@ from langgraph.checkpoint.base import (
CheckpointMetadata, CheckpointMetadata,
CheckpointTuple, CheckpointTuple,
get_checkpoint_id, get_checkpoint_id,
get_serializable_checkpoint_metadata, get_checkpoint_metadata,
) )
from langgraph.checkpoint.serde.base import SerializerProtocol
from psycopg import Capabilities, Connection, Cursor, Pipeline
from psycopg.rows import DictRow, dict_row
from psycopg.types.json import Jsonb
from psycopg_pool import ConnectionPool
from langgraph.checkpoint.postgres import _internal from langgraph.checkpoint.postgres import _internal
from langgraph.checkpoint.postgres.base import BasePostgresSaver from langgraph.checkpoint.postgres.base import BasePostgresSaver
from langgraph.checkpoint.postgres.shallow import ShallowPostgresSaver from langgraph.checkpoint.postgres.shallow import ShallowPostgresSaver
from langgraph.checkpoint.serde.base import SerializerProtocol
Conn = _internal.Conn # For backward compatibility Conn = _internal.Conn # For backward compatibility
@@ -94,10 +94,9 @@ class PostgresSaver(BasePostgresSaver):
for v, migration in zip( for v, migration in zip(
range(version + 1, len(self.MIGRATIONS)), range(version + 1, len(self.MIGRATIONS)),
self.MIGRATIONS[version + 1 :], self.MIGRATIONS[version + 1 :],
strict=False,
): ):
cur.execute(migration) cur.execute(migration)
cur.execute("INSERT INTO checkpoint_migrations (v) VALUES (%s)", (v,)) cur.execute(f"INSERT INTO checkpoint_migrations (v) VALUES ({v})")
if self.pipe: if self.pipe:
self.pipe.sync() self.pipe.sync()
@@ -116,12 +115,12 @@ class PostgresSaver(BasePostgresSaver):
Args: Args:
config: The config to use for listing the checkpoints. config: The config to use for listing the checkpoints.
filter: Additional filtering criteria for metadata. filter: Additional filtering criteria for metadata. Defaults to None.
before: If provided, only checkpoints before the specified checkpoint ID are returned. before: If provided, only checkpoints before the specified checkpoint ID are returned. Defaults to None.
limit: The maximum number of checkpoints to return. limit: The maximum number of checkpoints to return. Defaults to None.
Yields: Yields:
An iterator of checkpoint tuples. Iterator[CheckpointTuple]: An iterator of checkpoint tuples.
Examples: Examples:
>>> from langgraph.checkpoint.postgres import PostgresSaver >>> from langgraph.checkpoint.postgres import PostgresSaver
@@ -143,13 +142,11 @@ class PostgresSaver(BasePostgresSaver):
""" """
where, args = self._search_where(config, filter, before) where, args = self._search_where(config, filter, before)
query = self.SELECT_SQL + where + " ORDER BY checkpoint_id DESC" query = self.SELECT_SQL + where + " ORDER BY checkpoint_id DESC"
params = list(args) if limit:
if limit is not None: query += f" LIMIT {limit}"
query += " LIMIT %s"
params.append(int(limit))
# if we change this to use .stream() we need to make sure to close the cursor # if we change this to use .stream() we need to make sure to close the cursor
with self._cursor() as cur: with self._cursor() as cur:
cur.execute(query, params) cur.execute(query, args)
values = cur.fetchall() values = cur.fetchall()
if not values: if not values:
return return
@@ -185,7 +182,7 @@ class PostgresSaver(BasePostgresSaver):
"""Get a checkpoint tuple from the database. """Get a checkpoint tuple from the database.
This method retrieves a checkpoint tuple from the Postgres database based on the This method retrieves a checkpoint tuple from the Postgres database based on the
provided config. If the config contains a `checkpoint_id` key, the checkpoint with provided config. If the config contains a "checkpoint_id" key, the checkpoint with
the matching thread ID and timestamp is retrieved. Otherwise, the latest checkpoint the matching thread ID and timestamp is retrieved. Otherwise, the latest checkpoint
for the given thread ID is retrieved. for the given thread ID is retrieved.
@@ -193,7 +190,7 @@ class PostgresSaver(BasePostgresSaver):
config: The config to use for retrieving the checkpoint. config: The config to use for retrieving the checkpoint.
Returns: Returns:
The retrieved checkpoint tuple, or None if no matching checkpoint was found. Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
Examples: Examples:
@@ -328,7 +325,7 @@ class PostgresSaver(BasePostgresSaver):
checkpoint["id"], checkpoint["id"],
checkpoint_id, checkpoint_id,
Jsonb(copy), Jsonb(copy),
Jsonb(get_serializable_checkpoint_metadata(config, metadata)), Jsonb(get_checkpoint_metadata(config, metadata)),
), ),
) )
return next_config return next_config
@@ -453,7 +450,7 @@ class PostgresSaver(BasePostgresSaver):
{ {
**value["checkpoint"], **value["checkpoint"],
"channel_values": { "channel_values": {
**(value["checkpoint"].get("channel_values") or {}), **value["checkpoint"].get("channel_values"),
**self._load_blobs(value["channel_values"]), **self._load_blobs(value["channel_values"]),
}, },
}, },
@@ -2,12 +2,13 @@
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Union
from psycopg import AsyncConnection from psycopg import AsyncConnection
from psycopg.rows import DictRow from psycopg.rows import DictRow
from psycopg_pool import AsyncConnectionPool from psycopg_pool import AsyncConnectionPool
Conn = AsyncConnection[DictRow] | AsyncConnectionPool[AsyncConnection[DictRow]] Conn = Union[AsyncConnection[DictRow], AsyncConnectionPool[AsyncConnection[DictRow]]]
@asynccontextmanager @asynccontextmanager
@@ -2,12 +2,13 @@
from collections.abc import Iterator from collections.abc import Iterator
from contextlib import contextmanager from contextlib import contextmanager
from typing import Union
from psycopg import Connection from psycopg import Connection
from psycopg.rows import DictRow from psycopg.rows import DictRow
from psycopg_pool import ConnectionPool from psycopg_pool import ConnectionPool
Conn = Connection[DictRow] | ConnectionPool[Connection[DictRow]] Conn = Union[Connection[DictRow], ConnectionPool[Connection[DictRow]]]
@contextmanager @contextmanager
@@ -7,6 +7,11 @@ from contextlib import asynccontextmanager
from typing import Any from typing import Any
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
from psycopg.rows import DictRow, dict_row
from psycopg.types.json import Jsonb
from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.base import ( from langgraph.checkpoint.base import (
WRITES_IDX_MAP, WRITES_IDX_MAP,
ChannelVersions, ChannelVersions,
@@ -14,17 +19,12 @@ from langgraph.checkpoint.base import (
CheckpointMetadata, CheckpointMetadata,
CheckpointTuple, CheckpointTuple,
get_checkpoint_id, get_checkpoint_id,
get_serializable_checkpoint_metadata, get_checkpoint_metadata,
) )
from langgraph.checkpoint.serde.base import SerializerProtocol
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
from psycopg.rows import DictRow, dict_row
from psycopg.types.json import Jsonb
from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.postgres import _ainternal from langgraph.checkpoint.postgres import _ainternal
from langgraph.checkpoint.postgres.base import BasePostgresSaver from langgraph.checkpoint.postgres.base import BasePostgresSaver
from langgraph.checkpoint.postgres.shallow import AsyncShallowPostgresSaver from langgraph.checkpoint.postgres.shallow import AsyncShallowPostgresSaver
from langgraph.checkpoint.serde.base import SerializerProtocol
Conn = _ainternal.Conn # For backward compatibility Conn = _ainternal.Conn # For backward compatibility
@@ -99,12 +99,9 @@ class AsyncPostgresSaver(BasePostgresSaver):
for v, migration in zip( for v, migration in zip(
range(version + 1, len(self.MIGRATIONS)), range(version + 1, len(self.MIGRATIONS)),
self.MIGRATIONS[version + 1 :], self.MIGRATIONS[version + 1 :],
strict=False,
): ):
await cur.execute(migration) await cur.execute(migration)
await cur.execute( await cur.execute(f"INSERT INTO checkpoint_migrations (v) VALUES ({v})")
"INSERT INTO checkpoint_migrations (v) VALUES (%s)", (v,)
)
if self.pipe: if self.pipe:
await self.pipe.sync() await self.pipe.sync()
@@ -124,21 +121,19 @@ class AsyncPostgresSaver(BasePostgresSaver):
Args: Args:
config: Base configuration for filtering checkpoints. config: Base configuration for filtering checkpoints.
filter: Additional filtering criteria for metadata. filter: Additional filtering criteria for metadata.
before: If provided, only checkpoints before the specified checkpoint ID are returned. before: If provided, only checkpoints before the specified checkpoint ID are returned. Defaults to None.
limit: Maximum number of checkpoints to return. limit: Maximum number of checkpoints to return.
Yields: Yields:
An asynchronous iterator of matching checkpoint tuples. AsyncIterator[CheckpointTuple]: An asynchronous iterator of matching checkpoint tuples.
""" """
where, args = self._search_where(config, filter, before) where, args = self._search_where(config, filter, before)
query = self.SELECT_SQL + where + " ORDER BY checkpoint_id DESC" query = self.SELECT_SQL + where + " ORDER BY checkpoint_id DESC"
params = list(args) if limit:
if limit is not None: query += f" LIMIT {limit}"
query += " LIMIT %s"
params.append(int(limit))
# if we change this to use .stream() we need to make sure to close the cursor # if we change this to use .stream() we need to make sure to close the cursor
async with self._cursor() as cur: async with self._cursor() as cur:
await cur.execute(query, params, binary=True) await cur.execute(query, args, binary=True)
values = await cur.fetchall() values = await cur.fetchall()
if not values: if not values:
return return
@@ -174,7 +169,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
"""Get a checkpoint tuple from the database asynchronously. """Get a checkpoint tuple from the database asynchronously.
This method retrieves a checkpoint tuple from the Postgres database based on the This method retrieves a checkpoint tuple from the Postgres database based on the
provided config. If the config contains a `checkpoint_id` key, the checkpoint with provided config. If the config contains a "checkpoint_id" key, the checkpoint with
the matching thread ID and "checkpoint_id" is retrieved. Otherwise, the latest checkpoint the matching thread ID and "checkpoint_id" is retrieved. Otherwise, the latest checkpoint
for the given thread ID is retrieved. for the given thread ID is retrieved.
@@ -182,7 +177,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
config: The config to use for retrieving the checkpoint. config: The config to use for retrieving the checkpoint.
Returns: Returns:
The retrieved checkpoint tuple, or None if no matching checkpoint was found. Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
""" """
thread_id = config["configurable"]["thread_id"] thread_id = config["configurable"]["thread_id"]
checkpoint_id = get_checkpoint_id(config) checkpoint_id = get_checkpoint_id(config)
@@ -288,7 +283,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
checkpoint["id"], checkpoint["id"],
checkpoint_id, checkpoint_id,
Jsonb(copy), Jsonb(copy),
Jsonb(get_serializable_checkpoint_metadata(config, metadata)), Jsonb(get_checkpoint_metadata(config, metadata)),
), ),
) )
return next_config return next_config
@@ -414,7 +409,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
{ {
**value["checkpoint"], **value["checkpoint"],
"channel_values": { "channel_values": {
**(value["checkpoint"].get("channel_values") or {}), **value["checkpoint"].get("channel_values"),
**self._load_blobs(value["channel_values"]), **self._load_blobs(value["channel_values"]),
}, },
}, },
@@ -449,11 +444,11 @@ class AsyncPostgresSaver(BasePostgresSaver):
Args: Args:
config: Base configuration for filtering checkpoints. config: Base configuration for filtering checkpoints.
filter: Additional filtering criteria for metadata. filter: Additional filtering criteria for metadata.
before: If provided, only checkpoints before the specified checkpoint ID are returned. before: If provided, only checkpoints before the specified checkpoint ID are returned. Defaults to None.
limit: Maximum number of checkpoints to return. limit: Maximum number of checkpoints to return.
Yields: Yields:
An iterator of matching checkpoint tuples. Iterator[CheckpointTuple]: An iterator of matching checkpoint tuples.
""" """
try: try:
# check if we are in the main thread, only bg threads can block # check if we are in the main thread, only bg threads can block
@@ -481,7 +476,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
"""Get a checkpoint tuple from the database. """Get a checkpoint tuple from the database.
This method retrieves a checkpoint tuple from the Postgres database based on the This method retrieves a checkpoint tuple from the Postgres database based on the
provided config. If the config contains a `checkpoint_id` key, the checkpoint with provided config. If the config contains a "checkpoint_id" key, the checkpoint with
the matching thread ID and "checkpoint_id" is retrieved. Otherwise, the latest checkpoint the matching thread ID and "checkpoint_id" is retrieved. Otherwise, the latest checkpoint
for the given thread ID is retrieved. for the given thread ID is retrieved.
@@ -489,7 +484,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
config: The config to use for retrieving the checkpoint. config: The config to use for retrieving the checkpoint.
Returns: Returns:
The retrieved checkpoint tuple, or None if no matching checkpoint was found. Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
""" """
try: try:
# check if we are in the main thread, only bg threads can block # check if we are in the main thread, only bg threads can block
@@ -1,12 +1,12 @@
from __future__ import annotations from __future__ import annotations
import random import random
import warnings
from collections.abc import Sequence from collections.abc import Sequence
from importlib.metadata import version as get_version from typing import Any, Optional, cast
from typing import Any, cast
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from psycopg.types.json import Jsonb
from langgraph.checkpoint.base import ( from langgraph.checkpoint.base import (
WRITES_IDX_MAP, WRITES_IDX_MAP,
BaseCheckpointSaver, BaseCheckpointSaver,
@@ -14,21 +14,8 @@ from langgraph.checkpoint.base import (
get_checkpoint_id, get_checkpoint_id,
) )
from langgraph.checkpoint.serde.types import TASKS from langgraph.checkpoint.serde.types import TASKS
from psycopg.types.json import Jsonb
MetadataInput = dict[str, Any] | None MetadataInput = Optional[dict[str, Any]]
try:
major, minor = get_version("langgraph").split(".")[:2]
if int(major) == 0 and int(minor) < 5:
warnings.warn(
"You're using incompatible versions of langgraph and checkpoint-postgres. Please upgrade langgraph to avoid unexpected behavior.",
DeprecationWarning,
stacklevel=2,
)
except Exception:
# skip version check if running from source
pass
""" """
To add a new migration, add a new string to the MIGRATIONS list. To add a new migration, add a new string to the MIGRATIONS list.
@@ -81,7 +68,7 @@ MIGRATIONS = [
""" """
CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoint_writes_thread_id_idx ON checkpoint_writes(thread_id); CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoint_writes_thread_id_idx ON checkpoint_writes(thread_id);
""", """,
"""ALTER TABLE checkpoint_writes ADD COLUMN IF NOT EXISTS task_path TEXT NOT NULL DEFAULT '';""", """ALTER TABLE checkpoint_writes ADD COLUMN task_path TEXT NOT NULL DEFAULT '';""",
] ]
SELECT_SQL = """ SELECT_SQL = """
@@ -3,19 +3,9 @@ import threading
import warnings import warnings
from collections.abc import AsyncIterator, Iterator, Sequence from collections.abc import AsyncIterator, Iterator, Sequence
from contextlib import asynccontextmanager, contextmanager from contextlib import asynccontextmanager, contextmanager
from typing import Any from typing import Any, Optional
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
WRITES_IDX_MAP,
ChannelVersions,
Checkpoint,
CheckpointMetadata,
CheckpointTuple,
get_serializable_checkpoint_metadata,
)
from langgraph.checkpoint.serde.base import SerializerProtocol
from langgraph.checkpoint.serde.types import TASKS
from psycopg import ( from psycopg import (
AsyncConnection, AsyncConnection,
AsyncCursor, AsyncCursor,
@@ -29,8 +19,18 @@ from psycopg.rows import DictRow, dict_row
from psycopg.types.json import Jsonb from psycopg.types.json import Jsonb
from psycopg_pool import AsyncConnectionPool, ConnectionPool 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 import _ainternal, _internal
from langgraph.checkpoint.postgres.base import BasePostgresSaver from langgraph.checkpoint.postgres.base import BasePostgresSaver
from langgraph.checkpoint.serde.base import SerializerProtocol
from langgraph.checkpoint.serde.types import TASKS
""" """
To add a new migration, add a new string to the MIGRATIONS list. To add a new migration, add a new string to the MIGRATIONS list.
@@ -77,7 +77,7 @@ MIGRATIONS = [
CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoint_writes_thread_id_idx ON checkpoint_writes(thread_id); CREATE INDEX CONCURRENTLY IF NOT EXISTS checkpoint_writes_thread_id_idx ON checkpoint_writes(thread_id);
""", """,
""" """
ALTER TABLE checkpoint_writes ADD COLUMN IF NOT EXISTS task_path TEXT NOT NULL DEFAULT ''; ALTER TABLE checkpoint_writes ADD COLUMN task_path TEXT NOT NULL DEFAULT '';
""", """,
] ]
@@ -151,7 +151,7 @@ def _dump_blobs(
checkpoint_ns: str, checkpoint_ns: str,
values: dict[str, Any], values: dict[str, Any],
versions: ChannelVersions, versions: ChannelVersions,
) -> list[tuple[str, str, str, str, bytes | None]]: ) -> list[tuple[str, str, str, str, Optional[bytes]]]:
if not versions: if not versions:
return [] return []
@@ -186,8 +186,8 @@ class ShallowPostgresSaver(BasePostgresSaver):
def __init__( def __init__(
self, self,
conn: _internal.Conn, conn: _internal.Conn,
pipe: Pipeline | None = None, pipe: Optional[Pipeline] = None,
serde: SerializerProtocol | None = None, serde: Optional[SerializerProtocol] = None,
) -> None: ) -> None:
warnings.warn( warnings.warn(
"ShallowPostgresSaver is deprecated as of version 2.0.20 and will be removed in 3.0.0. " "ShallowPostgresSaver is deprecated as of version 2.0.20 and will be removed in 3.0.0. "
@@ -249,20 +249,19 @@ class ShallowPostgresSaver(BasePostgresSaver):
for v, migration in zip( for v, migration in zip(
range(version + 1, len(self.MIGRATIONS)), range(version + 1, len(self.MIGRATIONS)),
self.MIGRATIONS[version + 1 :], self.MIGRATIONS[version + 1 :],
strict=False,
): ):
cur.execute(migration) cur.execute(migration)
cur.execute("INSERT INTO checkpoint_migrations (v) VALUES (%s)", (v,)) cur.execute(f"INSERT INTO checkpoint_migrations (v) VALUES ({v})")
if self.pipe: if self.pipe:
self.pipe.sync() self.pipe.sync()
def list( def list(
self, self,
config: RunnableConfig | None, config: Optional[RunnableConfig],
*, *,
filter: dict[str, Any] | None = None, filter: Optional[dict[str, Any]] = None,
before: RunnableConfig | None = None, before: Optional[RunnableConfig] = None,
limit: int | None = None, limit: Optional[int] = None,
) -> Iterator[CheckpointTuple]: ) -> Iterator[CheckpointTuple]:
"""List checkpoints from the database. """List checkpoints from the database.
@@ -272,12 +271,10 @@ class ShallowPostgresSaver(BasePostgresSaver):
""" """
where, args = self._search_where(config, filter, before) where, args = self._search_where(config, filter, before)
query = self.SELECT_SQL + where query = self.SELECT_SQL + where
params = list(args) if limit:
if limit is not None: query += f" LIMIT {limit}"
query += " LIMIT %s"
params.append(int(limit))
with self._cursor() as cur: with self._cursor() as cur:
cur.execute(query, params, binary=True) cur.execute(self.SELECT_SQL + where, args, binary=True)
for value in cur: for value in cur:
checkpoint: Checkpoint = { checkpoint: Checkpoint = {
**value["checkpoint"], **value["checkpoint"],
@@ -302,7 +299,7 @@ class ShallowPostgresSaver(BasePostgresSaver):
pending_writes=self._load_writes(value["pending_writes"]), pending_writes=self._load_writes(value["pending_writes"]),
) )
def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
"""Get a checkpoint tuple from the database. """Get a checkpoint tuple from the database.
This method retrieves a checkpoint tuple from the Postgres database based on the This method retrieves a checkpoint tuple from the Postgres database based on the
@@ -312,7 +309,7 @@ class ShallowPostgresSaver(BasePostgresSaver):
config: The config to use for retrieving the checkpoint. config: The config to use for retrieving the checkpoint.
Returns: Returns:
The retrieved checkpoint tuple, or None if no matching checkpoint was found. Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
Examples: Examples:
@@ -444,7 +441,7 @@ class ShallowPostgresSaver(BasePostgresSaver):
thread_id, thread_id,
checkpoint_ns, checkpoint_ns,
Jsonb(copy), Jsonb(copy),
Jsonb(get_serializable_checkpoint_metadata(config, metadata)), Jsonb(get_checkpoint_metadata(config, metadata)),
), ),
) )
return next_config return next_config
@@ -545,8 +542,8 @@ class AsyncShallowPostgresSaver(BasePostgresSaver):
def __init__( def __init__(
self, self,
conn: _ainternal.Conn, conn: _ainternal.Conn,
pipe: AsyncPipeline | None = None, pipe: Optional[AsyncPipeline] = None,
serde: SerializerProtocol | None = None, serde: Optional[SerializerProtocol] = None,
) -> None: ) -> None:
warnings.warn( warnings.warn(
"AsyncShallowPostgresSaver is deprecated as of version 2.0.20 and will be removed in 3.0.0. " "AsyncShallowPostgresSaver is deprecated as of version 2.0.20 and will be removed in 3.0.0. "
@@ -573,7 +570,7 @@ class AsyncShallowPostgresSaver(BasePostgresSaver):
conn_string: str, conn_string: str,
*, *,
pipeline: bool = False, pipeline: bool = False,
serde: SerializerProtocol | None = None, serde: Optional[SerializerProtocol] = None,
) -> AsyncIterator["AsyncShallowPostgresSaver"]: ) -> AsyncIterator["AsyncShallowPostgresSaver"]:
"""Create a new AsyncShallowPostgresSaver instance from a connection string. """Create a new AsyncShallowPostgresSaver instance from a connection string.
@@ -613,22 +610,19 @@ class AsyncShallowPostgresSaver(BasePostgresSaver):
for v, migration in zip( for v, migration in zip(
range(version + 1, len(self.MIGRATIONS)), range(version + 1, len(self.MIGRATIONS)),
self.MIGRATIONS[version + 1 :], self.MIGRATIONS[version + 1 :],
strict=False,
): ):
await cur.execute(migration) await cur.execute(migration)
await cur.execute( await cur.execute(f"INSERT INTO checkpoint_migrations (v) VALUES ({v})")
"INSERT INTO checkpoint_migrations (v) VALUES (%s)", (v,)
)
if self.pipe: if self.pipe:
await self.pipe.sync() await self.pipe.sync()
async def alist( async def alist(
self, self,
config: RunnableConfig | None, config: Optional[RunnableConfig],
*, *,
filter: dict[str, Any] | None = None, filter: Optional[dict[str, Any]] = None,
before: RunnableConfig | None = None, before: Optional[RunnableConfig] = None,
limit: int | None = None, limit: Optional[int] = None,
) -> AsyncIterator[CheckpointTuple]: ) -> AsyncIterator[CheckpointTuple]:
"""List checkpoints from the database asynchronously. """List checkpoints from the database asynchronously.
@@ -638,12 +632,10 @@ class AsyncShallowPostgresSaver(BasePostgresSaver):
""" """
where, args = self._search_where(config, filter, before) where, args = self._search_where(config, filter, before)
query = self.SELECT_SQL + where query = self.SELECT_SQL + where
params = list(args) if limit:
if limit is not None: query += f" LIMIT {limit}"
query += " LIMIT %s"
params.append(int(limit))
async with self._cursor() as cur: async with self._cursor() as cur:
await cur.execute(query, params, binary=True) await cur.execute(self.SELECT_SQL + where, args, binary=True)
async for value in cur: async for value in cur:
checkpoint: Checkpoint = { checkpoint: Checkpoint = {
**value["checkpoint"], **value["checkpoint"],
@@ -670,7 +662,7 @@ class AsyncShallowPostgresSaver(BasePostgresSaver):
), ),
) )
async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
"""Get a checkpoint tuple from the database asynchronously. """Get a checkpoint tuple from the database asynchronously.
This method retrieves a checkpoint tuple from the Postgres database based on the This method retrieves a checkpoint tuple from the Postgres database based on the
@@ -680,7 +672,7 @@ class AsyncShallowPostgresSaver(BasePostgresSaver):
config: The config to use for retrieving the checkpoint. config: The config to use for retrieving the checkpoint.
Returns: Returns:
The retrieved checkpoint tuple, or None if no matching checkpoint was found. Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
""" """
thread_id = config["configurable"]["thread_id"] thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"].get("checkpoint_ns", "") checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
@@ -782,7 +774,7 @@ class AsyncShallowPostgresSaver(BasePostgresSaver):
thread_id, thread_id,
checkpoint_ns, checkpoint_ns,
Jsonb(copy), Jsonb(copy),
Jsonb(get_serializable_checkpoint_metadata(config, metadata)), Jsonb(get_checkpoint_metadata(config, metadata)),
), ),
) )
return next_config return next_config
@@ -869,11 +861,11 @@ class AsyncShallowPostgresSaver(BasePostgresSaver):
def list( def list(
self, self,
config: RunnableConfig | None, config: Optional[RunnableConfig],
*, *,
filter: dict[str, Any] | None = None, filter: Optional[dict[str, Any]] = None,
before: RunnableConfig | None = None, before: Optional[RunnableConfig] = None,
limit: int | None = None, limit: Optional[int] = None,
) -> Iterator[CheckpointTuple]: ) -> Iterator[CheckpointTuple]:
"""List checkpoints from the database. """List checkpoints from the database.
@@ -891,7 +883,7 @@ class AsyncShallowPostgresSaver(BasePostgresSaver):
except StopAsyncIteration: except StopAsyncIteration:
break break
def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
"""Get a checkpoint tuple from the database. """Get a checkpoint tuple from the database.
This method retrieves a checkpoint tuple from the Postgres database based on the This method retrieves a checkpoint tuple from the Postgres database based on the
@@ -901,7 +893,7 @@ class AsyncShallowPostgresSaver(BasePostgresSaver):
config: The config to use for retrieving the checkpoint. config: The config to use for retrieving the checkpoint.
Returns: Returns:
The retrieved checkpoint tuple, or None if no matching checkpoint was found. Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
""" """
try: try:
# check if we are in the main thread, only bg threads can block # check if we are in the main thread, only bg threads can block
@@ -1,4 +1,4 @@
from langgraph.store.postgres.aio import AsyncPostgresStore from langgraph.store.postgres.aio import AsyncPostgresStore
from langgraph.store.postgres.base import PoolConfig, PostgresStore from langgraph.store.postgres.base import PostgresStore
__all__ = ["AsyncPostgresStore", "PoolConfig", "PostgresStore"] __all__ = ["AsyncPostgresStore", "PostgresStore"]
@@ -2,12 +2,17 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from collections.abc import AsyncIterator, Callable, Iterable, Sequence from collections.abc import AsyncIterator, Iterable, Sequence
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from types import TracebackType from types import TracebackType
from typing import Any, cast from typing import Any, Callable, cast
import orjson import orjson
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
from psycopg.rows import DictRow, dict_row
from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.postgres import _ainternal
from langgraph.store.base import ( from langgraph.store.base import (
GetOp, GetOp,
ListNamespacesOp, ListNamespacesOp,
@@ -17,11 +22,6 @@ from langgraph.store.base import (
SearchOp, SearchOp,
) )
from langgraph.store.base.batch import AsyncBatchedBaseStore from langgraph.store.base.batch import AsyncBatchedBaseStore
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
from psycopg.rows import DictRow, dict_row
from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.postgres import _ainternal
from langgraph.store.postgres.base import ( from langgraph.store.postgres.base import (
PLACEHOLDER, PLACEHOLDER,
BasePostgresStore, BasePostgresStore,
@@ -266,27 +266,6 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
k: v(self) if v is not None and callable(v) else v k: v(self) if v is not None and callable(v) else v
for k, v in migration.params.items() for k, v in migration.params.items()
} }
if "dims" in params:
try:
params["dims"] = int(params["dims"])
except Exception as e:
raise ValueError(
f"Invalid dims for vector index: {params['dims']}"
) from e
if "vector_type" in params:
vt = str(params["vector_type"])
if vt not in ("vector", "halfvec"):
raise ValueError(
f"Invalid vector_type for pgvector: {vt}"
)
params["vector_type"] = vt
if "index_type" in params:
it = str(params["index_type"])
if it not in ("hnsw", "ivfflat"):
raise ValueError(
f"Invalid index_type for pgvector: {it}"
)
params["index_type"] = it
sql = sql % params sql = sql % params
await cur.execute(sql) await cur.execute(sql)
await cur.execute( await cur.execute(
@@ -360,7 +339,7 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
Args: Args:
timeout: Maximum time to wait for the task to stop, in seconds. timeout: Maximum time to wait for the task to stop, in seconds.
If `None`, wait indefinitely. If None, wait indefinitely.
Returns: Returns:
bool: True if the task was successfully stopped or wasn't running, bool: True if the task was successfully stopped or wasn't running,
@@ -486,9 +465,7 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
query, query,
[ [
p p
for (ns, k, pathname, _), vector in zip( for (ns, k, pathname, _), vector in zip(txt_params, vectors)
txt_params, vectors, strict=False
)
for p in (ns, k, pathname, vector) for p in (ns, k, pathname, vector)
], ],
) )
@@ -509,13 +486,13 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
vectors = await self.embeddings.aembed_documents( vectors = await self.embeddings.aembed_documents(
[query for _, query in embedding_requests] [query for _, query in embedding_requests]
) )
for (idx, _), vector in zip(embedding_requests, vectors, strict=False): for (idx, _), vector in zip(embedding_requests, vectors):
_paramslist = queries[idx][1] _paramslist = queries[idx][1]
for i in range(len(_paramslist)): for i in range(len(_paramslist)):
if _paramslist[i] is PLACEHOLDER: if _paramslist[i] is PLACEHOLDER:
_paramslist[i] = vector _paramslist[i] = vector
for (idx, _), (query, params) in zip(search_ops, queries, strict=False): for (idx, _), (query, params) in zip(search_ops, queries):
await cur.execute(query, params) await cur.execute(query, params)
rows = cast(list[Row], await cur.fetchall()) rows = cast(list[Row], await cur.fetchall())
items = [ items = [
@@ -533,7 +510,7 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
cur: AsyncCursor[DictRow], cur: AsyncCursor[DictRow],
) -> None: ) -> None:
queries = self._get_batch_list_namespaces_queries(list_ops) queries = self._get_batch_list_namespaces_queries(list_ops)
for (query, params), (idx, _) in zip(queries, list_ops, strict=False): for (query, params), (idx, _) in zip(queries, list_ops):
await cur.execute(query, params) await cur.execute(query, params)
rows = cast(list[dict], await cur.fetchall()) rows = cast(list[dict], await cur.fetchall())
namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows] namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows]
@@ -6,20 +6,30 @@ import json
import logging import logging
import threading import threading
from collections import defaultdict from collections import defaultdict
from collections.abc import Callable, Iterable, Iterator, Sequence from collections.abc import Iterable, Iterator, Sequence
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime from datetime import datetime
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Any, Any,
Callable,
Generic, Generic,
Literal, Literal,
NamedTuple, NamedTuple,
TypeVar, TypeVar,
Union,
cast, cast,
) )
import orjson import orjson
from psycopg import Capabilities, Connection, Cursor, Pipeline
from psycopg.rows import DictRow, dict_row
from psycopg.types.json import Jsonb
from psycopg_pool import ConnectionPool
from typing_extensions import TypedDict
from langgraph.checkpoint.postgres import _ainternal as _ainternal
from langgraph.checkpoint.postgres import _internal as _pg_internal
from langgraph.store.base import ( from langgraph.store.base import (
BaseStore, BaseStore,
GetOp, GetOp,
@@ -36,14 +46,6 @@ from langgraph.store.base import (
get_text_at_path, get_text_at_path,
tokenize_path, tokenize_path,
) )
from psycopg import Capabilities, Connection, Cursor, Pipeline
from psycopg.rows import DictRow, dict_row
from psycopg.types.json import Jsonb
from psycopg_pool import ConnectionPool
from typing_extensions import TypedDict
from langgraph.checkpoint.postgres import _ainternal as _ainternal
from langgraph.checkpoint.postgres import _internal as _pg_internal
if TYPE_CHECKING: if TYPE_CHECKING:
from langchain_core.embeddings import Embeddings from langchain_core.embeddings import Embeddings
@@ -91,12 +93,7 @@ WHERE expires_at IS NOT NULL;
VECTOR_MIGRATIONS: Sequence[Migration] = [ VECTOR_MIGRATIONS: Sequence[Migration] = [
Migration( Migration(
""" """
DO $$ CREATE EXTENSION IF NOT EXISTS vector;
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN
CREATE EXTENSION vector;
END IF;
END $$;
""", """,
), ),
Migration( Migration(
@@ -144,7 +141,7 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS store_vectors_embedding_idx ON store_vec
] ]
C = TypeVar("C", bound=_pg_internal.Conn | _ainternal.Conn) C = TypeVar("C", bound=Union[_pg_internal.Conn, _ainternal.Conn])
class PoolConfig(TypedDict, total=False): class PoolConfig(TypedDict, total=False):
@@ -258,7 +255,7 @@ class BasePostgresStore(Generic[C]):
results = [] results = []
for namespace, items in namespace_groups.items(): for namespace, items in namespace_groups.items():
_, keys = zip(*items, strict=False) _, keys = zip(*items)
this_refresh_ttls = refresh_ttls[namespace] this_refresh_ttls = refresh_ttls[namespace]
query = """ query = """
@@ -327,36 +324,31 @@ class BasePostgresStore(Generic[C]):
embedding_request: tuple[str, Sequence[tuple[str, str, str, str]]] | None = None embedding_request: tuple[str, Sequence[tuple[str, str, str, str]]] | None = None
if inserts: if inserts:
values = [] values = []
insertion_params: list[Any] = [] insertion_params = []
vector_values = [] vector_values = []
embedding_request_params = [] embedding_request_params = []
# Handle TTL expiration # Handle TTL expiration
# First handle main store insertions # First handle main store insertions
for op in inserts: for op in inserts:
if op.ttl is not None:
expires_at_str = f"NOW() + INTERVAL '{op.ttl * 60} seconds'"
ttl_minutes = op.ttl
else:
expires_at_str = "NULL"
ttl_minutes = None
values.append(
f"(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, {expires_at_str}, %s)"
)
insertion_params.extend( insertion_params.extend(
( [
_namespace_to_text(op.namespace), _namespace_to_text(op.namespace),
op.key, op.key,
Jsonb(cast(dict, op.value)), Jsonb(cast(dict, op.value)),
) ttl_minutes,
]
) )
if op.ttl is not None:
values.append(
"(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NOW() + %s::interval, %s)"
)
ttl_minutes = float(op.ttl)
insertion_params.extend(
(
f"{ttl_minutes * 60} seconds",
ttl_minutes,
)
)
else:
values.append(
"(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, %s)"
)
insertion_params.append(None)
# Then handle embeddings if configured # Then handle embeddings if configured
if self.index_config: if self.index_config:
@@ -470,10 +462,6 @@ class BasePostgresStore(Generic[C]):
cast(dict, self.index_config)["dims"], cast(dict, self.index_config)["dims"],
) )
else: else:
if vector_type not in ("vector", "halfvec"):
raise ValueError(
f"Invalid vector_type for pgvector: {vector_type}"
)
score_operator = score_operator % ("%s", vector_type) score_operator = score_operator % ("%s", vector_type)
vectors_per_doc_estimate = cast(dict, self.index_config)[ vectors_per_doc_estimate = cast(dict, self.index_config)[
@@ -880,7 +868,7 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
Args: Args:
timeout: Maximum time to wait for the thread to stop, in seconds. timeout: Maximum time to wait for the thread to stop, in seconds.
If `None`, wait indefinitely. If None, wait indefinitely.
Returns: Returns:
bool: True if the thread was successfully stopped or wasn't running, bool: True if the thread was successfully stopped or wasn't running,
@@ -1026,9 +1014,7 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
query, query,
[ [
p p
for (ns, k, pathname, _), vector in zip( for (ns, k, pathname, _), vector in zip(txt_params, vectors)
txt_params, vectors, strict=False
)
for p in (ns, k, pathname, vector) for p in (ns, k, pathname, vector)
], ],
) )
@@ -1049,15 +1035,13 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
embeddings = self.embeddings.embed_documents( embeddings = self.embeddings.embed_documents(
[query for _, query in embedding_requests] [query for _, query in embedding_requests]
) )
for (idx, _), embedding in zip( for (idx, _), embedding in zip(embedding_requests, embeddings):
embedding_requests, embeddings, strict=False
):
_paramslist = queries[idx][1] _paramslist = queries[idx][1]
for i in range(len(_paramslist)): for i in range(len(_paramslist)):
if _paramslist[i] is PLACEHOLDER: if _paramslist[i] is PLACEHOLDER:
_paramslist[i] = embedding _paramslist[i] = embedding
for (idx, _), (query, params) in zip(search_ops, queries, strict=False): for (idx, _), (query, params) in zip(search_ops, queries):
cur.execute(query, params) cur.execute(query, params)
rows = cast(list[Row], cur.fetchall()) rows = cast(list[Row], cur.fetchall())
results[idx] = [ results[idx] = [
@@ -1074,7 +1058,7 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
cur: Cursor[DictRow], cur: Cursor[DictRow],
) -> None: ) -> None:
for (query, params), (idx, _) in zip( for (query, params), (idx, _) in zip(
self._get_batch_list_namespaces_queries(list_ops), list_ops, strict=False self._get_batch_list_namespaces_queries(list_ops), list_ops
): ):
cur.execute(query, params) cur.execute(query, params)
results[idx] = [_decode_ns_bytes(row["truncated_prefix"]) for row in cur] results[idx] = [_decode_ns_bytes(row["truncated_prefix"]) for row in cur]
@@ -1131,27 +1115,6 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
k: v(self) if v is not None and callable(v) else v k: v(self) if v is not None and callable(v) else v
for k, v in migration.params.items() for k, v in migration.params.items()
} }
if "dims" in params:
try:
params["dims"] = int(params["dims"])
except Exception as e:
raise ValueError(
f"Invalid dims for vector index: {params['dims']}"
) from e
if "vector_type" in params:
vt = str(params["vector_type"])
if vt not in ("vector", "halfvec"):
raise ValueError(
f"Invalid vector_type for pgvector: {vt}"
)
params["vector_type"] = vt
if "index_type" in params:
it = str(params["index_type"])
if it not in ("hnsw", "ivfflat"):
raise ValueError(
f"Invalid index_type for pgvector: {it}"
)
params["index_type"] = it
sql = sql % params sql = sql % params
cur.execute(sql) cur.execute(sql)
cur.execute("INSERT INTO vector_migrations (v) VALUES (%s)", (v,)) cur.execute("INSERT INTO vector_migrations (v) VALUES (%s)", (v,))
@@ -1205,44 +1168,15 @@ def _get_vector_type_ops(store: BasePostgresStore) -> str:
def _get_index_params(store: Any) -> tuple[str, dict[str, Any]]: def _get_index_params(store: Any) -> tuple[str, dict[str, Any]]:
"""Get a sanitized index type and configuration based on config. """Get the index type and configuration based on config."""
Only allow known-safe kinds and integer parameters to avoid SQL injection
when constructing DDL strings for index creation.
"""
if not store.index_config: if not store.index_config:
return "hnsw", {} return "hnsw", {}
config = cast(PostgresIndexConfig, store.index_config) config = cast(PostgresIndexConfig, store.index_config)
raw = config.get("ann_index_config", _DEFAULT_ANN_CONFIG).copy() index_config = config.get("ann_index_config", _DEFAULT_ANN_CONFIG).copy()
kind = index_config.pop("kind", "hnsw")
kind = str(raw.pop("kind", "hnsw")) index_config.pop("vector_type", None)
if kind not in ("hnsw", "ivfflat", "flat"): return kind, index_config
raise ValueError(
f"Invalid index kind for pgvector: {kind}. Expected 'hnsw', 'ivfflat', or 'flat'."
)
raw.pop("vector_type", None)
if kind == "hnsw":
allowed_keys = {"m", "ef_construction"}
else: # ivfflat/flat
allowed_keys = {"lists", "nlist"}
sanitized: dict[str, int] = {}
for k, v in list(raw.items()):
if k not in allowed_keys:
continue
key = "lists" if k == "nlist" else k
try:
ivalue = int(v) # type: ignore[call-overload]
except Exception as e:
raise ValueError(f"Invalid index parameter value for {k}: {v}") from e
if ivalue <= 0:
continue
sanitized[key] = ivalue
return kind, sanitized
def _namespace_to_text( def _namespace_to_text(
+8 -19
View File
@@ -4,45 +4,36 @@ build-backend = "hatchling.build"
[project] [project]
name = "langgraph-checkpoint-postgres" name = "langgraph-checkpoint-postgres"
version = "3.0.3" version = "2.0.23"
description = "Library with a Postgres implementation of LangGraph checkpoint saver." description = "Library with a Postgres implementation of LangGraph checkpoint saver."
authors = [] authors = []
requires-python = ">=3.10" requires-python = ">=3.9"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
license-files = ['LICENSE'] license-files = ['LICENSE']
dependencies = [ dependencies = [
"langgraph-checkpoint>=2.1.2,<5.0.0", "langgraph-checkpoint>=2.0.21,<3.0.0",
"orjson>=3.10.1", "orjson>=3.10.1",
"psycopg>=3.2.0", "psycopg>=3.2.0",
"psycopg-pool>=3.2.0", "psycopg-pool>=3.2.0",
] ]
[project.urls] [project.urls]
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres" Repository = "https://www.github.com/langchain-ai/langgraph"
Twitter = "https://x.com/LangChainAI"
Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
[dependency-groups] [dependency-groups]
test = [ dev = [
"ruff",
"codespell",
"pytest", "pytest",
"anyio", "anyio",
"pytest-asyncio", "pytest-asyncio",
"pytest-mock", "pytest-mock",
"mypy",
"psycopg[binary]", "psycopg[binary]",
"langgraph-checkpoint", "langgraph-checkpoint",
"pytest-watcher", "pytest-watcher",
] ]
lint = [
"ruff",
"codespell",
"mypy",
]
dev = [
{include-group = "test"},
{include-group = "lint"},
]
[tool.uv] [tool.uv]
default-groups = ['dev'] default-groups = ['dev']
@@ -64,10 +55,8 @@ lint.select = [
"UP", # pyupgrade "UP", # pyupgrade
"B", # flake8-bugbear "B", # flake8-bugbear
"I", # isort "I", # isort
"UP", # pyupgrade
] ]
lint.ignore = ["E501", "B008"] lint.ignore = ["E501", "B008"]
target-version = "py310"
[tool.mypy] [tool.mypy]
# https://mypy.readthedocs.io/en/stable/config_file.html # https://mypy.readthedocs.io/en/stable/config_file.html
+9 -36
View File
@@ -6,6 +6,10 @@ from uuid import uuid4
import pytest import pytest
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from psycopg import AsyncConnection
from psycopg.rows import dict_row
from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.base import ( from langgraph.checkpoint.base import (
EXCLUDED_METADATA_KEYS, EXCLUDED_METADATA_KEYS,
Checkpoint, Checkpoint,
@@ -13,15 +17,11 @@ from langgraph.checkpoint.base import (
create_checkpoint, create_checkpoint,
empty_checkpoint, empty_checkpoint,
) )
from langgraph.checkpoint.serde.types import TASKS
from psycopg import AsyncConnection
from psycopg.rows import dict_row
from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.postgres.aio import ( from langgraph.checkpoint.postgres.aio import (
AsyncPostgresSaver, AsyncPostgresSaver,
AsyncShallowPostgresSaver, AsyncShallowPostgresSaver,
) )
from langgraph.checkpoint.serde.types import TASKS
from tests.conftest import DEFAULT_POSTGRES_URI from tests.conftest import DEFAULT_POSTGRES_URI
@@ -187,11 +187,13 @@ def test_data():
metadata_1: CheckpointMetadata = { metadata_1: CheckpointMetadata = {
"source": "input", "source": "input",
"step": 2, "step": 2,
"writes": {},
"score": 1, "score": 1,
} }
metadata_2: CheckpointMetadata = { metadata_2: CheckpointMetadata = {
"source": "loop", "source": "loop",
"step": 1, "step": 1,
"writes": {"foo": "bar"},
"score": None, "score": None,
} }
metadata_3: CheckpointMetadata = {} metadata_3: CheckpointMetadata = {}
@@ -218,6 +220,7 @@ async def test_combined_metadata(saver_name: str, test_data) -> None:
metadata: CheckpointMetadata = { metadata: CheckpointMetadata = {
"source": "loop", "source": "loop",
"step": 1, "step": 1,
"writes": {"foo": "bar"},
"score": None, "score": None,
} }
await saver.aput(config, chkpnt, metadata, {}) await saver.aput(config, chkpnt, metadata, {})
@@ -243,6 +246,7 @@ async def test_asearch(saver_name: str, test_data) -> None:
query_1 = {"source": "input"} # search by 1 key query_1 = {"source": "input"} # search by 1 key
query_2 = { query_2 = {
"step": 1, "step": 1,
"writes": {"foo": "bar"},
} # search by multiple keys } # search by multiple keys
query_3: dict[str, Any] = {} # search by no keys, return all checkpoints query_3: dict[str, Any] = {} # search by no keys, return all checkpoints
query_4 = {"source": "update", "step": 1} # no match query_4 = {"source": "update", "step": 1} # no match
@@ -340,34 +344,3 @@ async def test_pending_sends_migration(saver_name: str) -> None:
TASKS: ["send-1", "send-2", "send-3"] TASKS: ["send-1", "send-2", "send-3"]
} }
assert TASKS in search_results[0].checkpoint["channel_versions"] assert TASKS in search_results[0].checkpoint["channel_versions"]
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
async def test_get_checkpoint_no_channel_values(
monkeypatch, saver_name: str, test_data
) -> None:
"""Backwards compatibility test that verifies a checkpoint with no channel_values key can be retrieved without throwing an error."""
async with _saver(saver_name) as saver:
config = {
"configurable": {
"thread_id": "thread-2",
"checkpoint_ns": "",
"__super_private_key": "super_private_value",
},
"metadata": {"run_id": "my_run_id"},
}
chkpnt: Checkpoint = create_checkpoint(empty_checkpoint(), {}, 1)
await saver.aput(config, chkpnt, {}, {})
load_checkpoint_tuple = saver._load_checkpoint_tuple
def patched_load_checkpoint_tuple(value):
value["checkpoint"].pop("channel_values", None)
return load_checkpoint_tuple(value)
monkeypatch.setattr(
saver, "_load_checkpoint_tuple", patched_load_checkpoint_tuple
)
checkpoint = await saver.aget_tuple(config)
assert checkpoint.checkpoint["channel_values"] == {}
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import itertools import itertools
import sys
import uuid import uuid
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@@ -11,6 +12,8 @@ from typing import Any
import pytest import pytest
from langchain_core.embeddings import Embeddings from langchain_core.embeddings import Embeddings
from psycopg import AsyncConnection
from langgraph.store.base import ( from langgraph.store.base import (
GetOp, GetOp,
Item, Item,
@@ -18,8 +21,6 @@ from langgraph.store.base import (
PutOp, PutOp,
SearchOp, SearchOp,
) )
from psycopg import AsyncConnection
from langgraph.store.postgres import AsyncPostgresStore from langgraph.store.postgres import AsyncPostgresStore
from tests.conftest import ( from tests.conftest import (
DEFAULT_URI, DEFAULT_URI,
@@ -33,6 +34,9 @@ TTL_MINUTES = TTL_SECONDS / 60
@pytest.fixture(scope="function", params=["default", "pipe", "pool"]) @pytest.fixture(scope="function", params=["default", "pipe", "pool"])
async def store(request) -> AsyncIterator[AsyncPostgresStore]: async def store(request) -> AsyncIterator[AsyncPostgresStore]:
if sys.version_info < (3, 10):
pytest.skip("Async Postgres tests require Python 3.10+")
database = f"test_{uuid.uuid4().hex[:16]}" database = f"test_{uuid.uuid4().hex[:16]}"
uri_parts = DEFAULT_URI.split("/") uri_parts = DEFAULT_URI.split("/")
uri_base = "/".join(uri_parts[:-1]) uri_base = "/".join(uri_parts[:-1])
@@ -354,6 +358,8 @@ async def _create_vector_store(
text_fields: list[str] | None = None, text_fields: list[str] | None = None,
) -> AsyncIterator[AsyncPostgresStore]: ) -> AsyncIterator[AsyncPostgresStore]:
"""Create a store with vector search enabled.""" """Create a store with vector search enabled."""
if sys.version_info < (3, 10):
pytest.skip("Async Postgres tests require Python 3.10+")
database = f"test_{uuid.uuid4().hex[:16]}" database = f"test_{uuid.uuid4().hex[:16]}"
uri_parts = DEFAULT_URI.split("/") uri_parts = DEFAULT_URI.split("/")
+5 -43
View File
@@ -9,6 +9,8 @@ from uuid import uuid4
import pytest import pytest
from langchain_core.embeddings import Embeddings from langchain_core.embeddings import Embeddings
from psycopg import Connection
from langgraph.store.base import ( from langgraph.store.base import (
GetOp, GetOp,
Item, Item,
@@ -17,8 +19,6 @@ from langgraph.store.base import (
PutOp, PutOp,
SearchOp, SearchOp,
) )
from psycopg import Connection
from langgraph.store.postgres import PostgresStore from langgraph.store.postgres import PostgresStore
from tests.conftest import ( from tests.conftest import (
DEFAULT_URI, DEFAULT_URI,
@@ -754,7 +754,7 @@ def _cosine_similarity(X: list[float], Y: list[list[float]]) -> list[float]:
similarities = [] similarities = []
for y in Y: for y in Y:
dot_product = sum(a * b for a, b in zip(X, y, strict=False)) dot_product = sum(a * b for a, b in zip(X, y))
norm1 = sum(a * a for a in X) ** 0.5 norm1 = sum(a * a for a in X) ** 0.5
norm2 = sum(a * a for a in y) ** 0.5 norm2 = sum(a * a for a in y) ** 0.5
similarity = dot_product / (norm1 * norm2) if norm1 > 0 and norm2 > 0 else 0.0 similarity = dot_product / (norm1 * norm2) if norm1 > 0 and norm2 > 0 else 0.0
@@ -771,7 +771,7 @@ def _inner_product(X: list[float], Y: list[list[float]]) -> list[float]:
similarities = [] similarities = []
for y in Y: for y in Y:
similarity = sum(a * b for a, b in zip(X, y, strict=False)) similarity = sum(a * b for a, b in zip(X, y))
similarities.append(similarity) similarities.append(similarity)
return similarities return similarities
@@ -785,7 +785,7 @@ def _neg_l2_distance(X: list[float], Y: list[list[float]]) -> list[float]:
similarities = [] similarities = []
for y in Y: for y in Y:
similarity = sum((a - b) ** 2 for a, b in zip(X, y, strict=False)) ** 0.5 similarity = sum((a - b) ** 2 for a, b in zip(X, y)) ** 0.5
similarities.append(-similarity) similarities.append(-similarity)
return similarities return similarities
@@ -861,41 +861,3 @@ def test_store_ttl(store):
# Now has been (TTL_SECONDS-2)*2 > TTL_SECONDS + TTL_SECONDS/2 # Now has been (TTL_SECONDS-2)*2 > TTL_SECONDS + TTL_SECONDS/2
res = store.search(ns, query="bar", refresh_ttl=False) res = store.search(ns, query="bar", refresh_ttl=False)
assert len(res) == 0 assert len(res) == 0
@pytest.mark.parametrize(
"vector_type,distance_type",
[
("vector", "cosine"),
("vector", "inner_product"),
("halfvec", "cosine"),
("halfvec", "inner_product"),
],
)
def test_non_ascii(
request: Any,
fake_embeddings: CharacterEmbeddings,
vector_type: str,
distance_type: str,
) -> None:
"""Test support for non-ascii characters"""
with _create_vector_store(vector_type, distance_type, fake_embeddings) as store:
store.put(("user_123", "memories"), "1", {"text": "这是中文"}) # Chinese
store.put(
("user_123", "memories"), "2", {"text": "これは日本語です"}
) # Japanese
store.put(("user_123", "memories"), "3", {"text": "이건 한국어야"}) # Korean
store.put(("user_123", "memories"), "4", {"text": "Это русский"}) # Russian
store.put(("user_123", "memories"), "5", {"text": "यह रूसी है"}) # Hindi
result1 = store.search(("user_123", "memories"), query="这是中文")
result2 = store.search(("user_123", "memories"), query="これは日本語です")
result3 = store.search(("user_123", "memories"), query="이건 한국어야")
result4 = store.search(("user_123", "memories"), query="Это русский")
result5 = store.search(("user_123", "memories"), query="यह रूसी है")
assert result1[0].key == "1"
assert result2[0].key == "2"
assert result3[0].key == "3"
assert result4[0].key == "4"
assert result5[0].key == "5"
+9 -35
View File
@@ -7,6 +7,10 @@ from uuid import uuid4
import pytest import pytest
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from psycopg import Connection
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from langgraph.checkpoint.base import ( from langgraph.checkpoint.base import (
EXCLUDED_METADATA_KEYS, EXCLUDED_METADATA_KEYS,
Checkpoint, Checkpoint,
@@ -14,12 +18,8 @@ from langgraph.checkpoint.base import (
create_checkpoint, create_checkpoint,
empty_checkpoint, empty_checkpoint,
) )
from langgraph.checkpoint.serde.types import TASKS
from psycopg import Connection
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from langgraph.checkpoint.postgres import PostgresSaver, ShallowPostgresSaver from langgraph.checkpoint.postgres import PostgresSaver, ShallowPostgresSaver
from langgraph.checkpoint.serde.types import TASKS
from tests.conftest import DEFAULT_POSTGRES_URI from tests.conftest import DEFAULT_POSTGRES_URI
@@ -169,11 +169,13 @@ def test_data():
metadata_1: CheckpointMetadata = { metadata_1: CheckpointMetadata = {
"source": "input", "source": "input",
"step": 2, "step": 2,
"writes": {},
"score": 1, "score": 1,
} }
metadata_2: CheckpointMetadata = { metadata_2: CheckpointMetadata = {
"source": "loop", "source": "loop",
"step": 1, "step": 1,
"writes": {"foo": "bar"},
"score": None, "score": None,
} }
metadata_3: CheckpointMetadata = {} metadata_3: CheckpointMetadata = {}
@@ -200,6 +202,7 @@ def test_combined_metadata(saver_name: str, test_data) -> None:
metadata: CheckpointMetadata = { metadata: CheckpointMetadata = {
"source": "loop", "source": "loop",
"step": 1, "step": 1,
"writes": {"foo": "bar"},
"score": None, "score": None,
} }
saver.put(config, chkpnt, metadata, {}) saver.put(config, chkpnt, metadata, {})
@@ -225,6 +228,7 @@ def test_search(saver_name: str, test_data) -> None:
query_1 = {"source": "input"} # search by 1 key query_1 = {"source": "input"} # search by 1 key
query_2 = { query_2 = {
"step": 1, "step": 1,
"writes": {"foo": "bar"},
} # search by multiple keys } # search by multiple keys
query_3: dict[str, Any] = {} # search by no keys, return all checkpoints query_3: dict[str, Any] = {} # search by no keys, return all checkpoints
query_4 = {"source": "update", "step": 1} # no match query_4 = {"source": "update", "step": 1} # no match
@@ -328,33 +332,3 @@ def test_pending_sends_migration(saver_name: str) -> None:
TASKS: ["send-1", "send-2", "send-3"] TASKS: ["send-1", "send-2", "send-3"]
} }
assert TASKS in search_results[0].checkpoint["channel_versions"] assert TASKS in search_results[0].checkpoint["channel_versions"]
@pytest.mark.parametrize("saver_name", ["base", "pool", "pipe"])
def test_get_checkpoint_no_channel_values(
monkeypatch, saver_name: str, test_data
) -> None:
"""Backwards compatibility test that verifies a checkpoint with no channel_values key can be retrieved without throwing an error."""
with _saver(saver_name) as saver:
config = {
"configurable": {
"thread_id": "thread-2",
"checkpoint_ns": "",
"__super_private_key": "super_private_value",
},
}
chkpnt: Checkpoint = create_checkpoint(empty_checkpoint(), {}, 1)
saver.put(config, chkpnt, {}, {})
load_checkpoint_tuple = saver._load_checkpoint_tuple
def patched_load_checkpoint_tuple(value):
value["checkpoint"].pop("channel_values", None)
return load_checkpoint_tuple(value)
monkeypatch.setattr(
saver, "_load_checkpoint_tuple", patched_load_checkpoint_tuple
)
checkpoint = saver.get_tuple(config)
assert checkpoint.checkpoint["channel_values"] == {}
+710 -830
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 LangChain, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import json
import random import random
import sqlite3 import sqlite3
import threading import threading
@@ -9,6 +8,7 @@ from contextlib import closing, contextmanager
from typing import Any, cast from typing import Any, cast
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import ( from langgraph.checkpoint.base import (
WRITES_IDX_MAP, WRITES_IDX_MAP,
BaseCheckpointSaver, BaseCheckpointSaver,
@@ -21,7 +21,6 @@ from langgraph.checkpoint.base import (
get_checkpoint_metadata, get_checkpoint_metadata,
) )
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from langgraph.checkpoint.sqlite.utils import search_where from langgraph.checkpoint.sqlite.utils import search_where
_AIO_ERROR_MSG = ( _AIO_ERROR_MSG = (
@@ -185,7 +184,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
"""Get a checkpoint tuple from the database. """Get a checkpoint tuple from the database.
This method retrieves a checkpoint tuple from the SQLite database based on the This method retrieves a checkpoint tuple from the SQLite database based on the
provided config. If the config contains a `checkpoint_id` key, the checkpoint with provided config. If the config contains a "checkpoint_id" key, the checkpoint with
the matching thread ID and checkpoint ID is retrieved. Otherwise, the latest checkpoint the matching thread ID and checkpoint ID is retrieved. Otherwise, the latest checkpoint
for the given thread ID is retrieved. for the given thread ID is retrieved.
@@ -193,7 +192,7 @@ class SqliteSaver(BaseCheckpointSaver[str]):
config: The config to use for retrieving the checkpoint. config: The config to use for retrieving the checkpoint.
Returns: Returns:
The retrieved checkpoint tuple, or None if no matching checkpoint was found. Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
Examples: Examples:
@@ -266,7 +265,9 @@ class SqliteSaver(BaseCheckpointSaver[str]):
self.serde.loads_typed((type, checkpoint)), self.serde.loads_typed((type, checkpoint)),
cast( cast(
CheckpointMetadata, CheckpointMetadata,
json.loads(metadata) if metadata is not None else {}, self.jsonplus_serde.loads(metadata)
if metadata is not None
else {},
), ),
( (
{ {
@@ -300,12 +301,12 @@ class SqliteSaver(BaseCheckpointSaver[str]):
Args: Args:
config: The config to use for listing the checkpoints. config: The config to use for listing the checkpoints.
filter: Additional filtering criteria for metadata. filter: Additional filtering criteria for metadata. Defaults to None.
before: If provided, only checkpoints before the specified checkpoint ID are returned. before: If provided, only checkpoints before the specified checkpoint ID are returned. Defaults to None.
limit: The maximum number of checkpoints to return. limit: The maximum number of checkpoints to return. Defaults to None.
Yields: Yields:
An iterator of checkpoint tuples. Iterator[CheckpointTuple]: An iterator of checkpoint tuples.
Examples: Examples:
>>> from langgraph.checkpoint.sqlite import SqliteSaver >>> from langgraph.checkpoint.sqlite import SqliteSaver
@@ -329,9 +330,8 @@ class SqliteSaver(BaseCheckpointSaver[str]):
FROM checkpoints FROM checkpoints
{where} {where}
ORDER BY checkpoint_id DESC""" ORDER BY checkpoint_id DESC"""
if limit is not None: if limit:
query += " LIMIT ?" query += f" LIMIT {limit}"
param_values = (*param_values, limit)
with self.cursor(transaction=False) as cur, closing(self.conn.cursor()) as wcur: with self.cursor(transaction=False) as cur, closing(self.conn.cursor()) as wcur:
cur.execute(query, param_values) cur.execute(query, param_values)
for ( for (
@@ -358,7 +358,9 @@ class SqliteSaver(BaseCheckpointSaver[str]):
self.serde.loads_typed((type, checkpoint)), self.serde.loads_typed((type, checkpoint)),
cast( cast(
CheckpointMetadata, CheckpointMetadata,
json.loads(metadata) if metadata is not None else {}, self.jsonplus_serde.loads(metadata)
if metadata is not None
else {},
), ),
( (
{ {
@@ -411,9 +413,9 @@ class SqliteSaver(BaseCheckpointSaver[str]):
thread_id = config["configurable"]["thread_id"] thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"]["checkpoint_ns"] checkpoint_ns = config["configurable"]["checkpoint_ns"]
type_, serialized_checkpoint = self.serde.dumps_typed(checkpoint) type_, serialized_checkpoint = self.serde.dumps_typed(checkpoint)
serialized_metadata = json.dumps( serialized_metadata = self.jsonplus_serde.dumps(
get_checkpoint_metadata(config, metadata), ensure_ascii=False get_checkpoint_metadata(config, metadata)
).encode("utf-8", "ignore") )
with self.cursor() as cur: with self.cursor() as cur:
cur.execute( cur.execute(
"INSERT OR REPLACE INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)", "INSERT OR REPLACE INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)",
@@ -1,14 +1,14 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import random import random
from collections.abc import AsyncIterator, Callable, Iterator, Sequence from collections.abc import AsyncIterator, Iterator, Sequence
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any, TypeVar, cast from typing import Any, Callable, TypeVar, cast
import aiosqlite import aiosqlite
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import ( from langgraph.checkpoint.base import (
WRITES_IDX_MAP, WRITES_IDX_MAP,
BaseCheckpointSaver, BaseCheckpointSaver,
@@ -21,7 +21,6 @@ from langgraph.checkpoint.base import (
get_checkpoint_metadata, get_checkpoint_metadata,
) )
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from langgraph.checkpoint.sqlite.utils import search_where from langgraph.checkpoint.sqlite.utils import search_where
T = TypeVar("T", bound=Callable) T = TypeVar("T", bound=Callable)
@@ -140,7 +139,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
"""Get a checkpoint tuple from the database. """Get a checkpoint tuple from the database.
This method retrieves a checkpoint tuple from the SQLite database based on the This method retrieves a checkpoint tuple from the SQLite database based on the
provided config. If the config contains a `checkpoint_id` key, the checkpoint with provided config. If the config contains a "checkpoint_id" key, the checkpoint with
the matching thread ID and checkpoint ID is retrieved. Otherwise, the latest checkpoint the matching thread ID and checkpoint ID is retrieved. Otherwise, the latest checkpoint
for the given thread ID is retrieved. for the given thread ID is retrieved.
@@ -148,7 +147,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
config: The config to use for retrieving the checkpoint. config: The config to use for retrieving the checkpoint.
Returns: Returns:
The retrieved checkpoint tuple, or None if no matching checkpoint was found. Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
""" """
try: try:
# check if we are in the main thread, only bg threads can block # check if we are in the main thread, only bg threads can block
@@ -182,11 +181,11 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
Args: Args:
config: Base configuration for filtering checkpoints. config: Base configuration for filtering checkpoints.
filter: Additional filtering criteria for metadata. filter: Additional filtering criteria for metadata.
before: If provided, only checkpoints before the specified checkpoint ID are returned. before: If provided, only checkpoints before the specified checkpoint ID are returned. Defaults to None.
limit: Maximum number of checkpoints to return. limit: Maximum number of checkpoints to return.
Yields: Yields:
An iterator of matching checkpoint tuples. Iterator[CheckpointTuple]: An iterator of matching checkpoint tuples.
""" """
try: try:
# check if we are in the main thread, only bg threads can block # check if we are in the main thread, only bg threads can block
@@ -317,7 +316,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
"""Get a checkpoint tuple from the database asynchronously. """Get a checkpoint tuple from the database asynchronously.
This method retrieves a checkpoint tuple from the SQLite database based on the This method retrieves a checkpoint tuple from the SQLite database based on the
provided config. If the config contains a `checkpoint_id` key, the checkpoint with provided config. If the config contains a "checkpoint_id" key, the checkpoint with
the matching thread ID and checkpoint ID is retrieved. Otherwise, the latest checkpoint the matching thread ID and checkpoint ID is retrieved. Otherwise, the latest checkpoint
for the given thread ID is retrieved. for the given thread ID is retrieved.
@@ -325,7 +324,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
config: The config to use for retrieving the checkpoint. config: The config to use for retrieving the checkpoint.
Returns: Returns:
The retrieved checkpoint tuple, or None if no matching checkpoint was found. Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
""" """
await self.setup() await self.setup()
checkpoint_ns = config["configurable"].get("checkpoint_ns", "") checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
@@ -378,7 +377,9 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
self.serde.loads_typed((type, checkpoint)), self.serde.loads_typed((type, checkpoint)),
cast( cast(
CheckpointMetadata, CheckpointMetadata,
(json.loads(metadata) if metadata is not None else {}), self.jsonplus_serde.loads(metadata)
if metadata is not None
else {},
), ),
( (
{ {
@@ -413,11 +414,11 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
Args: Args:
config: Base configuration for filtering checkpoints. config: Base configuration for filtering checkpoints.
filter: Additional filtering criteria for metadata. filter: Additional filtering criteria for metadata.
before: If provided, only checkpoints before the specified checkpoint ID are returned. before: If provided, only checkpoints before the specified checkpoint ID are returned. Defaults to None.
limit: Maximum number of checkpoints to return. limit: Maximum number of checkpoints to return.
Yields: Yields:
An asynchronous iterator of matching checkpoint tuples. AsyncIterator[CheckpointTuple]: An asynchronous iterator of matching checkpoint tuples.
""" """
await self.setup() await self.setup()
where, params = search_where(config, filter, before) where, params = search_where(config, filter, before)
@@ -425,9 +426,8 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
FROM checkpoints FROM checkpoints
{where} {where}
ORDER BY checkpoint_id DESC""" ORDER BY checkpoint_id DESC"""
if limit is not None: if limit:
query += " LIMIT ?" query += f" LIMIT {limit}"
params = (*params, limit)
async with ( async with (
self.lock, self.lock,
self.conn.execute(query, params) as cur, self.conn.execute(query, params) as cur,
@@ -457,7 +457,9 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
self.serde.loads_typed((type, checkpoint)), self.serde.loads_typed((type, checkpoint)),
cast( cast(
CheckpointMetadata, CheckpointMetadata,
(json.loads(metadata) if metadata is not None else {}), self.jsonplus_serde.loads(metadata)
if metadata is not None
else {},
), ),
( (
{ {
@@ -501,9 +503,9 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
thread_id = config["configurable"]["thread_id"] thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"]["checkpoint_ns"] checkpoint_ns = config["configurable"]["checkpoint_ns"]
type_, serialized_checkpoint = self.serde.dumps_typed(checkpoint) type_, serialized_checkpoint = self.serde.dumps_typed(checkpoint)
serialized_metadata = json.dumps( serialized_metadata = self.jsonplus_serde.dumps(
get_checkpoint_metadata(config, metadata), ensure_ascii=False get_checkpoint_metadata(config, metadata)
).encode("utf-8", "ignore") )
async with ( async with (
self.lock, self.lock,
self.conn.execute( self.conn.execute(
@@ -1,32 +1,13 @@
from __future__ import annotations from __future__ import annotations
import json import json
import re
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any from typing import Any
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import get_checkpoint_id from langgraph.checkpoint.base import get_checkpoint_id
_FILTER_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+$")
def _validate_filter_key(key: str) -> None:
"""Validate that a filter key is safe for use in SQL queries.
Args:
key: The filter key to validate
Raises:
ValueError: If the key contains invalid characters that could enable SQL injection
"""
# Allow alphanumeric characters, underscores, dots, and hyphens
# This covers typical JSON property names while preventing SQL injection
if not _FILTER_PATTERN.match(key):
raise ValueError(
f"Invalid filter key: '{key}'. Filter keys must contain only alphanumeric characters, underscores, dots, and hyphens."
)
def _metadata_predicate( def _metadata_predicate(
metadata_filter: dict[str, Any], metadata_filter: dict[str, Any],
@@ -63,7 +44,6 @@ def _metadata_predicate(
# process metadata query # process metadata query
for query_key, query_value in metadata_filter.items(): for query_key, query_value in metadata_filter.items():
_validate_filter_key(query_key)
operator, param_value = _where_value(query_value) operator, param_value = _where_value(query_value)
predicates.append( predicates.append(
f"json_extract(CAST(metadata AS TEXT), '$.{query_key}') {operator}" f"json_extract(CAST(metadata AS TEXT), '$.{query_key}') {operator}"
@@ -3,14 +3,15 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from collections import defaultdict from collections import defaultdict
from collections.abc import AsyncIterator, Callable, Iterable, Sequence from collections.abc import AsyncIterator, Iterable, Sequence
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from types import TracebackType from types import TracebackType
from typing import Any, cast from typing import Any, Callable, cast
import aiosqlite import aiosqlite
import orjson import orjson
import sqlite_vec # type: ignore[import-untyped] import sqlite_vec # type: ignore[import-untyped]
from langgraph.store.base import ( from langgraph.store.base import (
GetOp, GetOp,
ListNamespacesOp, ListNamespacesOp,
@@ -21,7 +22,6 @@ from langgraph.store.base import (
TTLConfig, TTLConfig,
) )
from langgraph.store.base.batch import AsyncBatchedBaseStore from langgraph.store.base.batch import AsyncBatchedBaseStore
from langgraph.store.sqlite.base import ( from langgraph.store.sqlite.base import (
_PLACEHOLDER, _PLACEHOLDER,
BaseSqliteStore, BaseSqliteStore,
@@ -303,7 +303,7 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
Args: Args:
timeout: Maximum time to wait for the task to stop, in seconds. timeout: Maximum time to wait for the task to stop, in seconds.
If `None`, wait indefinitely. If None, wait indefinitely.
Returns: Returns:
bool: True if the task was successfully stopped or wasn't running, bool: True if the task was successfully stopped or wasn't running,
@@ -484,7 +484,7 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
# Convert vectors to SQLite-friendly format # Convert vectors to SQLite-friendly format
vector_params = [] vector_params = []
for (ns, k, pathname, _), vector in zip(txt_params, vectors, strict=False): for (ns, k, pathname, _), vector in zip(txt_params, vectors):
vector_params.extend( vector_params.extend(
[ns, k, pathname, sqlite_vec.serialize_float32(vector)] [ns, k, pathname, sqlite_vec.serialize_float32(vector)]
) )
@@ -507,9 +507,7 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
results: List to store results in. results: List to store results in.
cur: Database cursor. cur: Database cursor.
""" """
prepared_queries, embedding_requests = self._prepare_batch_search_queries( queries, embedding_requests = self._prepare_batch_search_queries(search_ops)
search_ops
)
# Setup dot_product function if it doesn't exist # Setup dot_product function if it doesn't exist
if embedding_requests and self.embeddings: if embedding_requests and self.embeddings:
@@ -517,62 +515,23 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
[query for _, query in embedding_requests] [query for _, query in embedding_requests]
) )
for (embed_req_idx, _), embedding in zip( for (idx, _), embedding in zip(embedding_requests, vectors):
embedding_requests, vectors, strict=False _params_list: list = queries[idx][1]
): for i, param in enumerate(_params_list):
# Find the corresponding query in prepared_queries if param is _PLACEHOLDER:
# The embed_req_idx is the original index in search_ops, which should map to prepared_queries _params_list[i] = sqlite_vec.serialize_float32(embedding)
if embed_req_idx < len(prepared_queries):
_params_list: list = prepared_queries[embed_req_idx][1]
for i, param in enumerate(_params_list):
if param is _PLACEHOLDER:
_params_list[i] = sqlite_vec.serialize_float32(embedding)
else:
logger.warning(
f"Embedding request index {embed_req_idx} out of bounds for prepared_queries."
)
for (original_op_idx, _), (query, params, needs_refresh) in zip( for (idx, _), (query, params) in zip(search_ops, queries):
search_ops, prepared_queries, strict=False
):
await cur.execute(query, params) await cur.execute(query, params)
rows = await cur.fetchall() rows = await cur.fetchall()
if needs_refresh and rows and self.ttl_config: if "score" in query:
keys_to_refresh = []
for row_data in rows:
# Assuming row_data[0] is prefix (text), row_data[1] is key (text)
# These are raw text values directly from the DB.
keys_to_refresh.append((row_data[0], row_data[1]))
if keys_to_refresh:
updates_by_prefix = defaultdict(list)
for prefix_text, key_text in keys_to_refresh:
updates_by_prefix[prefix_text].append(key_text)
for prefix_text, key_list in updates_by_prefix.items():
placeholders = ",".join(["?"] * len(key_list))
update_query = f"""
UPDATE store
SET expires_at = DATETIME(CURRENT_TIMESTAMP, '+' || ttl_minutes || ' minutes')
WHERE prefix = ? AND key IN ({placeholders}) AND ttl_minutes IS NOT NULL
"""
update_params = (prefix_text, *key_list)
try:
await cur.execute(update_query, update_params)
except Exception as e:
logger.error(
f"Error during TTL refresh update for search: {e}"
)
# Process rows into items
if "score" in query: # Vector search query
items = [ items = [
_row_to_search_item( _row_to_search_item(
_decode_ns_text(row[0]), # prefix _decode_ns_text(row[0]),
{ {
"key": row[1], # key "key": row[1],
"value": row[2], # value "value": row[2],
"created_at": row[3], "created_at": row[3],
"updated_at": row[4], "updated_at": row[4],
"expires_at": row[5] if len(row) > 5 else None, "expires_at": row[5] if len(row) > 5 else None,
@@ -586,10 +545,10 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
else: # Regular search query else: # Regular search query
items = [ items = [
_row_to_search_item( _row_to_search_item(
_decode_ns_text(row[0]), # prefix _decode_ns_text(row[0]),
{ {
"key": row[1], # key "key": row[1],
"value": row[2], # value "value": row[2],
"created_at": row[3], "created_at": row[3],
"updated_at": row[4], "updated_at": row[4],
"expires_at": row[5] if len(row) > 5 else None, "expires_at": row[5] if len(row) > 5 else None,
@@ -600,7 +559,7 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
for row in rows for row in rows
] ]
results[original_op_idx] = items results[idx] = items
async def _batch_list_namespaces_ops( async def _batch_list_namespaces_ops(
self, self,
@@ -616,7 +575,7 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
cur: Database cursor. cur: Database cursor.
""" """
queries = self._get_batch_list_namespaces_queries(list_ops) queries = self._get_batch_list_namespaces_queries(list_ops)
for (query, params), (idx, _) in zip(queries, list_ops, strict=False): for (query, params), (idx, _) in zip(queries, list_ops):
await cur.execute(query, params) await cur.execute(query, params)
rows = await cur.fetchall() rows = await cur.fetchall()
@@ -7,12 +7,13 @@ import re
import sqlite3 import sqlite3
import threading import threading
from collections import defaultdict from collections import defaultdict
from collections.abc import Callable, Iterable, Iterator, Sequence from collections.abc import Iterable, Iterator, Sequence
from contextlib import contextmanager from contextlib import contextmanager
from typing import Any, Literal, NamedTuple, cast from typing import Any, Callable, Literal, NamedTuple, cast
import orjson import orjson
import sqlite_vec # type: ignore[import-untyped] import sqlite_vec # type: ignore[import-untyped]
from langgraph.store.base import ( from langgraph.store.base import (
BaseStore, BaseStore,
GetOp, GetOp,
@@ -107,9 +108,6 @@ def _decode_ns_text(namespace: str) -> tuple[str, ...]:
return tuple(namespace.split(".")) return tuple(namespace.split("."))
_FILTER_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+$")
def _validate_filter_key(key: str) -> None: def _validate_filter_key(key: str) -> None:
"""Validate that a filter key is safe for use in SQL queries. """Validate that a filter key is safe for use in SQL queries.
@@ -121,7 +119,7 @@ def _validate_filter_key(key: str) -> None:
""" """
# Allow alphanumeric characters, underscores, dots, and hyphens # Allow alphanumeric characters, underscores, dots, and hyphens
# This covers typical JSON property names while preventing SQL injection # This covers typical JSON property names while preventing SQL injection
if not _FILTER_PATTERN.match(key): if not re.match(r"^[a-zA-Z0-9_.-]+$", key):
raise ValueError( raise ValueError(
f"Invalid filter key: '{key}'. Filter keys must contain only alphanumeric characters, underscores, dots, and hyphens." f"Invalid filter key: '{key}'. Filter keys must contain only alphanumeric characters, underscores, dots, and hyphens."
) )
@@ -235,7 +233,7 @@ class BaseSqliteStore:
results = [] results = []
for namespace, items in namespace_groups.items(): for namespace, items in namespace_groups.items():
_, keys = zip(*items, strict=False) _, keys = zip(*items)
this_refresh_ttls = refresh_ttls[namespace] this_refresh_ttls = refresh_ttls[namespace]
refresh_ttl_any = any(this_refresh_ttls) refresh_ttl_any = any(this_refresh_ttls)
@@ -374,15 +372,13 @@ class BaseSqliteStore:
def _prepare_batch_search_queries( def _prepare_batch_search_queries(
self, search_ops: Sequence[tuple[int, SearchOp]] self, search_ops: Sequence[tuple[int, SearchOp]]
) -> tuple[ ) -> tuple[
list[ list[tuple[str, list[None | str | list[float]]]], # queries, params
tuple[str, list[None | str | list[float]], bool]
], # queries, params, needs_refresh
list[tuple[int, str]], # idx, query_text pairs to embed list[tuple[int, str]], # idx, query_text pairs to embed
]: ]:
""" """
Build per-SearchOp SQL queries (with optional TTL refresh flag) plus embedding requests. Build per-SearchOp SQL queries (with optional TTL refresh) plus embedding requests.
Returns: Returns:
- queries: list of (SQL, param_list, needs_ttl_refresh_flag) - queries: list of (SQL, param_list)
- embedding_requests: list of (original_index_in_search_ops, text_query) - embedding_requests: list of (original_index_in_search_ops, text_query)
""" """
queries = [] queries = []
@@ -407,9 +403,12 @@ class BaseSqliteStore:
# SQLite json_extract returns unquoted string values # SQLite json_extract returns unquoted string values
if isinstance(value, str): if isinstance(value, str):
filter_conditions.append( filter_conditions.append(
"json_extract(value, '$." + key + "') = ?" "json_extract(value, '$."
+ key
+ "') = '"
+ value.replace("'", "''")
+ "'"
) )
filter_params.append(value)
elif value is None: elif value is None:
filter_conditions.append( filter_conditions.append(
"json_extract(value, '$." + key + "') IS NULL" "json_extract(value, '$." + key + "') IS NULL"
@@ -423,11 +422,9 @@ class BaseSqliteStore:
+ ("1" if value else "0") + ("1" if value else "0")
) )
elif isinstance(value, (int, float)): elif isinstance(value, (int, float)):
# Use parameterized query to handle special floats and large integers
filter_conditions.append( filter_conditions.append(
"json_extract(value, '$." + key + "') = ?" "json_extract(value, '$." + key + "') = " + str(value)
) )
filter_params.append(float(value))
else: else:
# Complex objects (list, dict, …) compare JSON text # Complex objects (list, dict, …) compare JSON text
filter_conditions.append( filter_conditions.append(
@@ -522,18 +519,30 @@ class BaseSqliteStore:
logger.debug(f"Search query: {base_query}") logger.debug(f"Search query: {base_query}")
logger.debug(f"Search params: {params}") logger.debug(f"Search params: {params}")
# Determine if TTL refresh is needed # Handle TTL refresh if requested
needs_ttl_refresh = bool( if (
op.refresh_ttl op.refresh_ttl
and self.ttl_config and self.ttl_config
and self.ttl_config.get("refresh_on_read", False) and self.ttl_config.get("refresh_on_read", False)
) ):
final_sql = f"""
WITH search_results AS (
{base_query}
),
updated AS (
UPDATE store
SET expires_at = DATETIME(CURRENT_TIMESTAMP, '+' || ttl_minutes || ' minutes')
WHERE (prefix, key) IN (SELECT prefix, key FROM search_results)
AND ttl_minutes IS NOT NULL
)
SELECT * FROM search_results
"""
final_params = params[:] # copy params
else:
final_sql = base_query
final_params = params
# The base_query is now the final_sql, and we pass the refresh flag queries.append((final_sql, final_params))
final_sql = base_query
final_params = params
queries.append((final_sql, final_params, needs_ttl_refresh))
return queries, embedding_requests return queries, embedding_requests
@@ -638,66 +647,85 @@ class BaseSqliteStore:
# We need to properly format values for SQLite JSON extraction comparison # We need to properly format values for SQLite JSON extraction comparison
if op == "$eq": if op == "$eq":
if isinstance(value, str): if isinstance(value, str):
return f"json_extract(value, '$.{key}') = ?", [value] # Direct string comparison with proper quoting for unquoted json_extract result
return (
f"json_extract(value, '$.{key}') = '"
+ value.replace("'", "''")
+ "'",
[],
)
elif value is None: elif value is None:
return f"json_extract(value, '$.{key}') IS NULL", [] return f"json_extract(value, '$.{key}') IS NULL", []
elif isinstance(value, bool): elif isinstance(value, bool):
# SQLite JSON stores booleans as integers # SQLite JSON stores booleans as integers
return f"json_extract(value, '$.{key}') = {1 if value else 0}", [] return f"json_extract(value, '$.{key}') = {1 if value else 0}", []
elif isinstance(value, (int, float)): elif isinstance(value, (int, float)):
# Convert to float to handle inf, -inf, nan, and very large integers return f"json_extract(value, '$.{key}') = {value}", []
# SQLite REAL can handle these cases better than INTEGER
return f"json_extract(value, '$.{key}') = ?", [float(value)]
else: else:
return f"json_extract(value, '$.{key}') = ?", [orjson.dumps(value)] return f"json_extract(value, '$.{key}') = ?", [orjson.dumps(value)]
elif op == "$gt": elif op == "$gt":
# For numeric values, SQLite needs to compare as numbers, not strings # For numeric values, SQLite needs to compare as numbers, not strings
if isinstance(value, (int, float)): if isinstance(value, (int, float)):
# Convert to float to handle special values and very large integers return f"CAST(json_extract(value, '$.{key}') AS REAL) > {value}", []
return f"CAST(json_extract(value, '$.{key}') AS REAL) > ?", [
float(value)
]
elif isinstance(value, str): elif isinstance(value, str):
return f"json_extract(value, '$.{key}') > ?", [value] return (
f"json_extract(value, '$.{key}') > '"
+ value.replace("'", "''")
+ "'",
[],
)
else: else:
return f"json_extract(value, '$.{key}') > ?", [orjson.dumps(value)] return f"json_extract(value, '$.{key}') > ?", [orjson.dumps(value)]
elif op == "$gte": elif op == "$gte":
if isinstance(value, (int, float)): if isinstance(value, (int, float)):
return f"CAST(json_extract(value, '$.{key}') AS REAL) >= ?", [ return f"CAST(json_extract(value, '$.{key}') AS REAL) >= {value}", []
float(value)
]
elif isinstance(value, str): elif isinstance(value, str):
return f"json_extract(value, '$.{key}') >= ?", [value] return (
f"json_extract(value, '$.{key}') >= '"
+ value.replace("'", "''")
+ "'",
[],
)
else: else:
return f"json_extract(value, '$.{key}') >= ?", [orjson.dumps(value)] return f"json_extract(value, '$.{key}') >= ?", [orjson.dumps(value)]
elif op == "$lt": elif op == "$lt":
if isinstance(value, (int, float)): if isinstance(value, (int, float)):
return f"CAST(json_extract(value, '$.{key}') AS REAL) < ?", [ return f"CAST(json_extract(value, '$.{key}') AS REAL) < {value}", []
float(value)
]
elif isinstance(value, str): elif isinstance(value, str):
return f"json_extract(value, '$.{key}') < ?", [value] return (
f"json_extract(value, '$.{key}') < '"
+ value.replace("'", "''")
+ "'",
[],
)
else: else:
return f"json_extract(value, '$.{key}') < ?", [orjson.dumps(value)] return f"json_extract(value, '$.{key}') < ?", [orjson.dumps(value)]
elif op == "$lte": elif op == "$lte":
if isinstance(value, (int, float)): if isinstance(value, (int, float)):
return f"CAST(json_extract(value, '$.{key}') AS REAL) <= ?", [ return f"CAST(json_extract(value, '$.{key}') AS REAL) <= {value}", []
float(value)
]
elif isinstance(value, str): elif isinstance(value, str):
return f"json_extract(value, '$.{key}') <= ?", [value] return (
f"json_extract(value, '$.{key}') <= '"
+ value.replace("'", "''")
+ "'",
[],
)
else: else:
return f"json_extract(value, '$.{key}') <= ?", [orjson.dumps(value)] return f"json_extract(value, '$.{key}') <= ?", [orjson.dumps(value)]
elif op == "$ne": elif op == "$ne":
if isinstance(value, str): if isinstance(value, str):
return f"json_extract(value, '$.{key}') != ?", [value] return (
f"json_extract(value, '$.{key}') != '"
+ value.replace("'", "''")
+ "'",
[],
)
elif value is None: elif value is None:
return f"json_extract(value, '$.{key}') IS NOT NULL", [] return f"json_extract(value, '$.{key}') IS NOT NULL", []
elif isinstance(value, bool): elif isinstance(value, bool):
return f"json_extract(value, '$.{key}') != {1 if value else 0}", [] return f"json_extract(value, '$.{key}') != {1 if value else 0}", []
elif isinstance(value, (int, float)): elif isinstance(value, (int, float)):
# Convert to float for consistency return f"json_extract(value, '$.{key}') != {value}", []
return f"json_extract(value, '$.{key}') != ?", [float(value)]
else: else:
return f"json_extract(value, '$.{key}') != ?", [orjson.dumps(value)] return f"json_extract(value, '$.{key}') != ?", [orjson.dumps(value)]
else: else:
@@ -775,9 +803,8 @@ class SqliteStore(BaseSqliteStore, BaseStore):
self, self,
conn: sqlite3.Connection, conn: sqlite3.Connection,
*, *,
deserializer: ( deserializer: Callable[[bytes | str | orjson.Fragment], dict[str, Any]]
Callable[[bytes | str | orjson.Fragment], dict[str, Any]] | None | None = None,
) = None,
index: SqliteIndexConfig | None = None, index: SqliteIndexConfig | None = None,
ttl: TTLConfig | None = None, ttl: TTLConfig | None = None,
): ):
@@ -813,7 +840,7 @@ class SqliteStore(BaseSqliteStore, BaseStore):
results = [] results = []
for namespace, items in namespace_groups.items(): for namespace, items in namespace_groups.items():
_, keys = zip(*items, strict=False) _, keys = zip(*items)
this_refresh_ttls = refresh_ttls[namespace] this_refresh_ttls = refresh_ttls[namespace]
refresh_ttl_any = any(this_refresh_ttls) refresh_ttl_any = any(this_refresh_ttls)
@@ -858,66 +885,85 @@ class SqliteStore(BaseSqliteStore, BaseStore):
# We need to properly format values for SQLite JSON extraction comparison # We need to properly format values for SQLite JSON extraction comparison
if op == "$eq": if op == "$eq":
if isinstance(value, str): if isinstance(value, str):
return f"json_extract(value, '$.{key}') = ?", [value] # Direct string comparison with proper quoting for unquoted json_extract result
return (
f"json_extract(value, '$.{key}') = '"
+ value.replace("'", "''")
+ "'",
[],
)
elif value is None: elif value is None:
return f"json_extract(value, '$.{key}') IS NULL", [] return f"json_extract(value, '$.{key}') IS NULL", []
elif isinstance(value, bool): elif isinstance(value, bool):
# SQLite JSON stores booleans as integers # SQLite JSON stores booleans as integers
return f"json_extract(value, '$.{key}') = {1 if value else 0}", [] return f"json_extract(value, '$.{key}') = {1 if value else 0}", []
elif isinstance(value, (int, float)): elif isinstance(value, (int, float)):
# Convert to float to handle inf, -inf, nan, and very large integers return f"json_extract(value, '$.{key}') = {value}", []
# SQLite REAL can handle these cases better than INTEGER
return f"json_extract(value, '$.{key}') = ?", [float(value)]
else: else:
return f"json_extract(value, '$.{key}') = ?", [orjson.dumps(value)] return f"json_extract(value, '$.{key}') = ?", [orjson.dumps(value)]
elif op == "$gt": elif op == "$gt":
# For numeric values, SQLite needs to compare as numbers, not strings # For numeric values, SQLite needs to compare as numbers, not strings
if isinstance(value, (int, float)): if isinstance(value, (int, float)):
# Convert to float to handle special values and very large integers return f"CAST(json_extract(value, '$.{key}') AS REAL) > {value}", []
return f"CAST(json_extract(value, '$.{key}') AS REAL) > ?", [
float(value)
]
elif isinstance(value, str): elif isinstance(value, str):
return f"json_extract(value, '$.{key}') > ?", [value] return (
f"json_extract(value, '$.{key}') > '"
+ value.replace("'", "''")
+ "'",
[],
)
else: else:
return f"json_extract(value, '$.{key}') > ?", [orjson.dumps(value)] return f"json_extract(value, '$.{key}') > ?", [orjson.dumps(value)]
elif op == "$gte": elif op == "$gte":
if isinstance(value, (int, float)): if isinstance(value, (int, float)):
return f"CAST(json_extract(value, '$.{key}') AS REAL) >= ?", [ return f"CAST(json_extract(value, '$.{key}') AS REAL) >= {value}", []
float(value)
]
elif isinstance(value, str): elif isinstance(value, str):
return f"json_extract(value, '$.{key}') >= ?", [value] return (
f"json_extract(value, '$.{key}') >= '"
+ value.replace("'", "''")
+ "'",
[],
)
else: else:
return f"json_extract(value, '$.{key}') >= ?", [orjson.dumps(value)] return f"json_extract(value, '$.{key}') >= ?", [orjson.dumps(value)]
elif op == "$lt": elif op == "$lt":
if isinstance(value, (int, float)): if isinstance(value, (int, float)):
return f"CAST(json_extract(value, '$.{key}') AS REAL) < ?", [ return f"CAST(json_extract(value, '$.{key}') AS REAL) < {value}", []
float(value)
]
elif isinstance(value, str): elif isinstance(value, str):
return f"json_extract(value, '$.{key}') < ?", [value] return (
f"json_extract(value, '$.{key}') < '"
+ value.replace("'", "''")
+ "'",
[],
)
else: else:
return f"json_extract(value, '$.{key}') < ?", [orjson.dumps(value)] return f"json_extract(value, '$.{key}') < ?", [orjson.dumps(value)]
elif op == "$lte": elif op == "$lte":
if isinstance(value, (int, float)): if isinstance(value, (int, float)):
return f"CAST(json_extract(value, '$.{key}') AS REAL) <= ?", [ return f"CAST(json_extract(value, '$.{key}') AS REAL) <= {value}", []
float(value)
]
elif isinstance(value, str): elif isinstance(value, str):
return f"json_extract(value, '$.{key}') <= ?", [value] return (
f"json_extract(value, '$.{key}') <= '"
+ value.replace("'", "''")
+ "'",
[],
)
else: else:
return f"json_extract(value, '$.{key}') <= ?", [orjson.dumps(value)] return f"json_extract(value, '$.{key}') <= ?", [orjson.dumps(value)]
elif op == "$ne": elif op == "$ne":
if isinstance(value, str): if isinstance(value, str):
return f"json_extract(value, '$.{key}') != ?", [value] return (
f"json_extract(value, '$.{key}') != '"
+ value.replace("'", "''")
+ "'",
[],
)
elif value is None: elif value is None:
return f"json_extract(value, '$.{key}') IS NOT NULL", [] return f"json_extract(value, '$.{key}') IS NOT NULL", []
elif isinstance(value, bool): elif isinstance(value, bool):
return f"json_extract(value, '$.{key}') != {1 if value else 0}", [] return f"json_extract(value, '$.{key}') != {1 if value else 0}", []
elif isinstance(value, (int, float)): elif isinstance(value, (int, float)):
# Convert to float for consistency return f"json_extract(value, '$.{key}') != {value}", []
return f"json_extract(value, '$.{key}') != ?", [float(value)]
else: else:
return f"json_extract(value, '$.{key}') != ?", [orjson.dumps(value)] return f"json_extract(value, '$.{key}') != ?", [orjson.dumps(value)]
else: else:
@@ -1121,7 +1167,7 @@ class SqliteStore(BaseSqliteStore, BaseStore):
Args: Args:
timeout: Maximum time to wait for the thread to stop, in seconds. timeout: Maximum time to wait for the thread to stop, in seconds.
If `None`, wait indefinitely. If None, wait indefinitely.
Returns: Returns:
bool: True if the thread was successfully stopped or wasn't running, bool: True if the thread was successfully stopped or wasn't running,
@@ -1269,7 +1315,7 @@ class SqliteStore(BaseSqliteStore, BaseStore):
# Convert vectors to SQLite-friendly format # Convert vectors to SQLite-friendly format
vector_params = [] vector_params = []
for (ns, k, pathname, _), vector in zip(txt_params, vectors, strict=False): for (ns, k, pathname, _), vector in zip(txt_params, vectors):
vector_params.extend( vector_params.extend(
[ns, k, pathname, sqlite_vec.serialize_float32(vector)] [ns, k, pathname, sqlite_vec.serialize_float32(vector)]
) )
@@ -1285,9 +1331,7 @@ class SqliteStore(BaseSqliteStore, BaseStore):
results: list[Result], results: list[Result],
cur: sqlite3.Cursor, cur: sqlite3.Cursor,
) -> None: ) -> None:
prepared_queries, embedding_requests = self._prepare_batch_search_queries( queries, embedding_requests = self._prepare_batch_search_queries(search_ops)
search_ops
)
# Setup similarity functions if they don't exist # Setup similarity functions if they don't exist
if embedding_requests and self.embeddings: if embedding_requests and self.embeddings:
@@ -1297,50 +1341,16 @@ class SqliteStore(BaseSqliteStore, BaseStore):
) )
# Replace placeholders with actual embeddings # Replace placeholders with actual embeddings
for (embed_req_idx, _), embedding in zip( for (idx, _), embedding in zip(embedding_requests, embeddings):
embedding_requests, embeddings, strict=False _params_list: list = queries[idx][1]
): for i, param in enumerate(_params_list):
if embed_req_idx < len(prepared_queries): if param is _PLACEHOLDER:
_params_list: list = prepared_queries[embed_req_idx][1] _params_list[i] = sqlite_vec.serialize_float32(embedding)
for i, param in enumerate(_params_list):
if param is _PLACEHOLDER:
_params_list[i] = sqlite_vec.serialize_float32(embedding)
else:
logger.warning(
f"Embedding request index {embed_req_idx} out of bounds for prepared_queries."
)
for (original_op_idx, _), (query, params, needs_refresh) in zip( for (idx, _), (query, params) in zip(search_ops, queries):
search_ops, prepared_queries, strict=False
):
cur.execute(query, params) cur.execute(query, params)
rows = cur.fetchall() rows = cur.fetchall()
if needs_refresh and rows and self.ttl_config:
keys_to_refresh = []
for row_data in rows:
keys_to_refresh.append((row_data[0], row_data[1]))
if keys_to_refresh:
updates_by_prefix = defaultdict(list)
for prefix_text, key_text in keys_to_refresh:
updates_by_prefix[prefix_text].append(key_text)
for prefix_text, key_list in updates_by_prefix.items():
placeholders = ",".join(["?"] * len(key_list))
update_query = f"""
UPDATE store
SET expires_at = DATETIME(CURRENT_TIMESTAMP, '+' || ttl_minutes || ' minutes')
WHERE prefix = ? AND key IN ({placeholders}) AND ttl_minutes IS NOT NULL
"""
update_params = (prefix_text, *key_list)
try:
cur.execute(update_query, update_params)
except Exception as e:
logger.error(
f"Error during TTL refresh update for search: {e}"
)
if "score" in query: # Vector search query if "score" in query: # Vector search query
items = [ items = [
_row_to_search_item( _row_to_search_item(
@@ -1375,7 +1385,7 @@ class SqliteStore(BaseSqliteStore, BaseStore):
for row in rows for row in rows
] ]
results[original_op_idx] = items results[idx] = items
def _batch_list_namespaces_ops( def _batch_list_namespaces_ops(
self, self,
@@ -1384,7 +1394,7 @@ class SqliteStore(BaseSqliteStore, BaseStore):
cur: sqlite3.Cursor, cur: sqlite3.Cursor,
) -> None: ) -> None:
queries = self._get_batch_list_namespaces_queries(list_ops) queries = self._get_batch_list_namespaces_queries(list_ops)
for (query, params), (idx, _) in zip(queries, list_ops, strict=False): for (query, params), (idx, _) in zip(queries, list_ops):
cur.execute(query, params) cur.execute(query, params)
results[idx] = [_decode_ns_text(row[0]) for row in cur.fetchall()] results[idx] = [_decode_ns_text(row[0]) for row in cur.fetchall()]
+8 -19
View File
@@ -4,43 +4,34 @@ build-backend = "hatchling.build"
[project] [project]
name = "langgraph-checkpoint-sqlite" name = "langgraph-checkpoint-sqlite"
version = "3.0.2" version = "2.0.11"
description = "Library with a SQLite implementation of LangGraph checkpoint saver." description = "Library with a SQLite implementation of LangGraph checkpoint saver."
authors = [] authors = []
requires-python = ">=3.10" requires-python = ">=3.9"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
license-files = ['LICENSE'] license-files = ['LICENSE']
dependencies = [ dependencies = [
"langgraph-checkpoint>=3,<5.0.0", "langgraph-checkpoint>=2.0.21,<3.0.0",
"aiosqlite>=0.20", "aiosqlite>=0.20",
"sqlite-vec>=0.1.6", "sqlite-vec>=0.1.6",
] ]
[project.urls] [project.urls]
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-sqlite" Repository = "https://www.github.com/langchain-ai/langgraph"
Twitter = "https://x.com/LangChainAI"
Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
[dependency-groups] [dependency-groups]
test = [ dev = [
"ruff",
"codespell",
"pytest", "pytest",
"pytest-asyncio", "pytest-asyncio",
"pytest-mock", "pytest-mock",
"pytest-watcher", "pytest-watcher",
"mypy",
"langgraph-checkpoint", "langgraph-checkpoint",
"pytest-retry>=1.7.0", "pytest-retry>=1.7.0",
] ]
lint = [
"ruff",
"codespell",
"mypy",
]
dev = [
{include-group = "test"},
{include-group = "lint"},
]
[tool.uv] [tool.uv]
default-groups = ['dev'] default-groups = ['dev']
@@ -62,10 +53,8 @@ lint.select = [
"UP", # pyupgrade "UP", # pyupgrade
"B", # flake8-bugbear "B", # flake8-bugbear
"I", # isort "I", # isort
"UP", # pyupgrade
] ]
lint.ignore = ["E501", "B008"] lint.ignore = ["E501", "B008"]
target-version = "py310"
[tool.pytest-watcher] [tool.pytest-watcher]
now = true now = true
+2 -76
View File
@@ -2,13 +2,13 @@ from typing import Any
import pytest import pytest
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import ( from langgraph.checkpoint.base import (
Checkpoint, Checkpoint,
CheckpointMetadata, CheckpointMetadata,
create_checkpoint, create_checkpoint,
empty_checkpoint, empty_checkpoint,
) )
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
@@ -113,78 +113,4 @@ class TestAsyncSqliteSaver:
search_results_5[1].config["configurable"]["checkpoint_ns"], search_results_5[1].config["configurable"]["checkpoint_ns"],
} == {"", "inner"} } == {"", "inner"}
# Test limit param # TODO: test before and limit params
search_results_6 = [
c
async for c in saver.alist(
{"configurable": {"thread_id": "thread-2"}}, limit=1
)
]
assert len(search_results_6) == 1
assert search_results_6[0].config["configurable"]["thread_id"] == "thread-2"
# Test before param
search_results_7 = [
c async for c in saver.alist(None, before=search_results_5[1].config)
]
assert len(search_results_7) == 1
assert search_results_7[0].config["configurable"]["thread_id"] == "thread-1"
async def test_limit_parameter_sql_injection_prevention(self) -> None:
"""Test that the limit parameter properly uses parameterized queries to prevent SQL injection."""
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
# Setup: Create multiple checkpoints
for i in range(5):
config: RunnableConfig = {
"configurable": {
"thread_id": f"thread-{i}",
"checkpoint_ns": "",
}
}
checkpoint = empty_checkpoint()
metadata: CheckpointMetadata = {"index": i}
await saver.aput(config, checkpoint, metadata, {})
# Test that limit works correctly with valid integer
results = [c async for c in saver.alist(None, limit=2)]
assert len(results) == 2
# Test that limit=0 returns no results
results = [c async for c in saver.alist(None, limit=0)]
assert len(results) == 0
# Test that limit=None returns all results
results = [c async for c in saver.alist(None, limit=None)]
assert len(results) == 5
# Test explicit SQL injection attempt via limit parameter
# Even if type checking is bypassed and a malicious string is passed,
# the parameterized query will treat it as a value, not SQL code
# This would cause an error (can't convert string to int for LIMIT),
# which is the correct secure behavior
malicious_limits = [
"1; DROP TABLE checkpoints; --",
"1 OR 1=1",
"999999 UNION SELECT * FROM checkpoints",
]
for malicious_limit in malicious_limits:
# The parameterized query should safely reject non-integer limits
# or convert them in a way that prevents SQL injection
try:
# Bypass type checking by casting
results = [
c
async for c in saver.alist(None, limit=malicious_limit) # type: ignore
]
# If it doesn't raise an error, it should at least not execute the injection
# SQLite's parameter binding will try to convert the string to an integer
# which will either fail or treat it as 0
except Exception:
# Expected: SQLite should reject invalid limit values
pass
# Verify the checkpoints table still exists and has all data
# (would have been dropped if injection succeeded)
results = [c async for c in saver.alist(None, limit=None)]
assert len(results) == 5
@@ -5,9 +5,10 @@ import tempfile
import uuid import uuid
from collections.abc import AsyncIterator, Generator, Iterable from collections.abc import AsyncIterator, Generator, Iterable
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import cast from typing import Optional, Union, cast
import pytest import pytest
from langgraph.store.base import ( from langgraph.store.base import (
GetOp, GetOp,
Item, Item,
@@ -15,7 +16,6 @@ from langgraph.store.base import (
PutOp, PutOp,
SearchOp, SearchOp,
) )
from langgraph.store.sqlite import AsyncSqliteStore from langgraph.store.sqlite import AsyncSqliteStore
from langgraph.store.sqlite.base import SqliteIndexConfig from langgraph.store.sqlite.base import SqliteIndexConfig
from tests.test_store import CharacterEmbeddings from tests.test_store import CharacterEmbeddings
@@ -51,7 +51,7 @@ def fake_embeddings() -> CharacterEmbeddings:
async def create_vector_store( async def create_vector_store(
fake_embeddings: CharacterEmbeddings, fake_embeddings: CharacterEmbeddings,
conn_string: str = ":memory:", conn_string: str = ":memory:",
text_fields: list[str] | None = None, text_fields: Optional[list[str]] = None,
) -> AsyncIterator[AsyncSqliteStore]: ) -> AsyncIterator[AsyncSqliteStore]:
"""Create an AsyncSqliteStore with vector search capabilities.""" """Create an AsyncSqliteStore with vector search capabilities."""
index_config: SqliteIndexConfig = { index_config: SqliteIndexConfig = {
@@ -168,7 +168,7 @@ async def test_abatch_order(store: AsyncSqliteStore) -> None:
] ]
results = await store.abatch( results = await store.abatch(
cast(Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp], ops) cast(Iterable[Union[GetOp, PutOp, SearchOp, ListNamespacesOp]], ops)
) )
assert len(results) == 5 assert len(results) == 5
assert isinstance(results[0], Item) assert isinstance(results[0], Item)
@@ -193,7 +193,7 @@ async def test_abatch_order(store: AsyncSqliteStore) -> None:
] ]
results_reordered = await store.abatch( results_reordered = await store.abatch(
cast(Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp], ops_reordered) cast(Iterable[Union[GetOp, PutOp, SearchOp, ListNamespacesOp]], ops_reordered)
) )
assert len(results_reordered) == 5 assert len(results_reordered) == 5
assert isinstance(results_reordered[0], list) assert isinstance(results_reordered[0], list)
@@ -681,7 +681,7 @@ async def test_search_items(
fake_embeddings, text_fields=["key0", "key1", "key3"] fake_embeddings, text_fields=["key0", "key1", "key3"]
) as store: ) as store:
# Insert test data # Insert test data
for ns, item in zip(test_namespaces, test_items, strict=False): for ns, item in zip(test_namespaces, test_items):
key = f"item_{ns[-1]}" key = f"item_{ns[-1]}"
await store.aput(ns, key, item) await store.aput(ns, key, item)
+2 -137
View File
@@ -2,13 +2,13 @@ from typing import Any, cast
import pytest import pytest
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import ( from langgraph.checkpoint.base import (
Checkpoint, Checkpoint,
CheckpointMetadata, CheckpointMetadata,
create_checkpoint, create_checkpoint,
empty_checkpoint, empty_checkpoint,
) )
from langgraph.checkpoint.sqlite import SqliteSaver from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.checkpoint.sqlite.utils import _metadata_predicate, search_where from langgraph.checkpoint.sqlite.utils import _metadata_predicate, search_where
@@ -116,17 +116,7 @@ class TestSqliteSaver:
search_results_5[1].config["configurable"]["checkpoint_ns"], search_results_5[1].config["configurable"]["checkpoint_ns"],
} == {"", "inner"} } == {"", "inner"}
# search with before param # TODO: test before and limit params
search_results_6 = list(saver.list(None, before=search_results_5[1].config))
assert len(search_results_6) == 1
assert search_results_6[0].config["configurable"]["thread_id"] == "thread-1"
# search with limit param
search_results_7 = list(
saver.list({"configurable": {"thread_id": "thread-2"}}, limit=1)
)
assert len(search_results_7) == 1
assert search_results_7[0].config["configurable"]["thread_id"] == "thread-2"
def test_search_where(self) -> None: def test_search_where(self) -> None:
# call method / assertions # call method / assertions
@@ -182,128 +172,3 @@ class TestSqliteSaver:
with pytest.raises(NotImplementedError, match="AsyncSqliteSaver"): with pytest.raises(NotImplementedError, match="AsyncSqliteSaver"):
async for _ in saver.alist(self.config_1): async for _ in saver.alist(self.config_1):
pass pass
def test_metadata_predicate_sql_injection_prevention(self) -> None:
"""Test that _metadata_predicate rejects malicious filter keys."""
# Test various SQL injection payloads
malicious_keys = [
"x') OR '1'='1", # Boolean-based injection
"x') OR 1=1 --", # Comment-based injection
"x') UNION SELECT 1,2,3,4,5,6,7 --", # UNION-based injection
"access') = 'public' OR '1'='1' OR json_extract(value, '$.", # Complex injection
"'; DROP TABLE checkpoints; --", # Destructive injection
]
for malicious_key in malicious_keys:
with pytest.raises(ValueError, match="Invalid filter key"):
_metadata_predicate({malicious_key: "dummy"})
def test_checkpoint_search_sql_injection_prevention(self) -> None:
"""Test that SQL injection via malicious filter keys is prevented in checkpoint search."""
with SqliteSaver.from_conn_string(":memory:") as saver:
# Setup: Create checkpoints with different metadata
config_public: RunnableConfig = {
"configurable": {
"thread_id": "thread-public",
"checkpoint_ns": "",
}
}
config_private: RunnableConfig = {
"configurable": {
"thread_id": "thread-private",
"checkpoint_ns": "",
}
}
checkpoint_public = empty_checkpoint()
checkpoint_private = empty_checkpoint()
metadata_public: CheckpointMetadata = {
"access": "public",
"data": "public information",
}
metadata_private: CheckpointMetadata = {
"access": "private",
"data": "secret information",
"password": "secret123",
}
saver.put(config_public, checkpoint_public, metadata_public, {})
saver.put(config_private, checkpoint_private, metadata_private, {})
# Normal query - should return only public checkpoint
normal_results = list(saver.list(None, filter={"access": "public"}))
assert len(normal_results) == 1
assert normal_results[0].metadata["access"] == "public"
# SQL injection attempt should raise ValueError
malicious_key = (
"access') = 'public' OR '1'='1' OR json_extract(metadata, '$."
)
with pytest.raises(ValueError, match="Invalid filter key"):
list(saver.list(None, filter={malicious_key: "dummy"}))
def test_limit_parameter_sql_injection_prevention(self) -> None:
"""Test that the limit parameter properly uses parameterized queries to prevent SQL injection."""
with SqliteSaver.from_conn_string(":memory:") as saver:
# Setup: Create multiple checkpoints
for i in range(5):
config: RunnableConfig = {
"configurable": {
"thread_id": f"thread-{i}",
"checkpoint_ns": "",
}
}
checkpoint = empty_checkpoint()
metadata: CheckpointMetadata = {"index": i}
saver.put(config, checkpoint, metadata, {})
# Test that limit works correctly with valid integer
results = list(saver.list(None, limit=2))
assert len(results) == 2
# Test that limit=0 returns no results
results = list(saver.list(None, limit=0))
assert len(results) == 0
# Test that limit=None returns all results
results = list(saver.list(None, limit=None))
assert len(results) == 5
def test_metadata_filter_keys_with_hyphens_and_digits(self) -> None:
"""Metadata keys with hyphens and digit-start should be filterable.
This exposes incorrect JSON path handling (unquoted segments) by asserting
that such filters successfully match saved checkpoints.
"""
with SqliteSaver.from_conn_string(":memory:") as saver:
config: RunnableConfig = {
"configurable": {
"thread_id": "thread-hyphen-digit",
"checkpoint_ns": "",
}
}
checkpoint = empty_checkpoint()
metadata: CheckpointMetadata = {
"access-level": "public",
"user": {"access-level": "nested", "123abc": "ok2"},
"123abc": "ok",
}
saver.put(config, checkpoint, metadata, {})
# Top-level hyphenated key
results = list(saver.list(None, filter={"access-level": "public"}))
assert len(results) == 1
# Nested hyphenated key via dotted path
results = list(saver.list(None, filter={"user.access-level": "nested"}))
assert len(results) == 1
# Top-level digit-starting key
results = list(saver.list(None, filter={"123abc": "ok"}))
assert len(results) == 1
# Nested digit-starting key via dotted path
results = list(saver.list(None, filter={"user.123abc": "ok2"}))
assert len(results) == 1
+8 -171
View File
@@ -5,10 +5,11 @@ import tempfile
import uuid import uuid
from collections.abc import Generator, Iterable from collections.abc import Generator, Iterable
from contextlib import contextmanager from contextlib import contextmanager
from typing import Any, Literal, cast from typing import Any, Literal, Optional, Union, cast
import pytest import pytest
from langchain_core.embeddings import Embeddings from langchain_core.embeddings import Embeddings
from langgraph.store.base import ( from langgraph.store.base import (
GetOp, GetOp,
Item, Item,
@@ -17,7 +18,6 @@ from langgraph.store.base import (
PutOp, PutOp,
SearchOp, SearchOp,
) )
from langgraph.store.sqlite import SqliteStore from langgraph.store.sqlite import SqliteStore
from langgraph.store.sqlite.base import SqliteIndexConfig from langgraph.store.sqlite.base import SqliteIndexConfig
@@ -110,7 +110,7 @@ VECTOR_TYPES = ["cosine"] # SQLite only supports cosine similarity
@contextmanager @contextmanager
def create_vector_store( def create_vector_store(
fake_embeddings: CharacterEmbeddings, fake_embeddings: CharacterEmbeddings,
text_fields: list[str] | None = None, text_fields: Optional[list[str]] = None,
distance_type: str = "cosine", distance_type: str = "cosine",
conn_type: Literal["memory", "file"] = "memory", conn_type: Literal["memory", "file"] = "memory",
) -> Generator[SqliteStore, None, None]: ) -> Generator[SqliteStore, None, None]:
@@ -153,7 +153,7 @@ def test_batch_order(store: SqliteStore) -> None:
] ]
results = store.batch( results = store.batch(
cast(Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp], ops) cast(Iterable[Union[GetOp, PutOp, SearchOp, ListNamespacesOp]], ops)
) )
assert len(results) == 5 assert len(results) == 5
assert isinstance(results[0], Item) assert isinstance(results[0], Item)
@@ -182,7 +182,7 @@ def test_batch_order(store: SqliteStore) -> None:
] ]
results_reordered = store.batch( results_reordered = store.batch(
cast(Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp], ops_reordered) cast(Iterable[Union[GetOp, PutOp, SearchOp, ListNamespacesOp]], ops_reordered)
) )
assert len(results_reordered) == 5 assert len(results_reordered) == 5
assert isinstance(results_reordered[0], list) assert isinstance(results_reordered[0], list)
@@ -301,7 +301,7 @@ def test_batch_list_namespaces_ops(store: SqliteStore) -> None:
] ]
results = store.batch( results = store.batch(
cast(Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp], ops) cast(Iterable[Union[GetOp, PutOp, SearchOp, ListNamespacesOp]], ops)
) )
assert len(results) == 3 assert len(results) == 3
@@ -778,7 +778,7 @@ def _cosine_similarity(X: list[float], Y: list[list[float]]) -> list[float]:
similarities = [] similarities = []
for y in Y: for y in Y:
dot_product = sum(a * b for a, b in zip(X, y, strict=False)) dot_product = sum(a * b for a, b in zip(X, y))
norm1 = sum(a * a for a in X) ** 0.5 norm1 = sum(a * a for a in X) ** 0.5
norm2 = sum(a * a for a in y) ** 0.5 norm2 = sum(a * a for a in y) ** 0.5
similarity = dot_product / (norm1 * norm2) if norm1 > 0 and norm2 > 0 else 0.0 similarity = dot_product / (norm1 * norm2) if norm1 > 0 and norm2 > 0 else 0.0
@@ -1011,7 +1011,7 @@ def test_search_items(
fake_embeddings, text_fields=["key0", "key1", "key3"] fake_embeddings, text_fields=["key0", "key1", "key3"]
) as store: ) as store:
# Insert test data # Insert test data
for ns, item in zip(test_namespaces, test_items, strict=False): for ns, item in zip(test_namespaces, test_items):
key = f"item_{ns[-1]}" key = f"item_{ns[-1]}"
store.put(ns, key, item) store.put(ns, key, item)
@@ -1067,166 +1067,3 @@ def test_sql_injection_vulnerability(store: SqliteStore) -> None:
with pytest.raises(ValueError, match="Invalid filter key"): with pytest.raises(ValueError, match="Invalid filter key"):
store.search(("docs",), filter={malicious_key: "dummy"}) store.search(("docs",), filter={malicious_key: "dummy"})
def test_sql_injection_filter_values(store: SqliteStore) -> None:
"""Test that SQL injection via malicious filter values is properly escaped."""
# Setup: Create documents with different access levels
store.put(("docs",), "doc1", {"access": "public", "title": "Public Document"})
store.put(("docs",), "doc2", {"access": "private", "title": "Private Document"})
store.put(("docs",), "doc3", {"access": "secret", "title": "Secret Document"})
# Test 1: Basic SQL injection attempt with single quote
malicious_value = "public' OR '1'='1"
results = store.search(("docs",), filter={"access": malicious_value})
# Should return 0 results because the malicious value is escaped and won't match anything
assert len(results) == 0, "SQL injection via string value should be blocked"
# Test 2: SQL injection with comment
malicious_value = "public'; --"
results = store.search(("docs",), filter={"access": malicious_value})
assert len(results) == 0, "SQL comment injection should be blocked"
# Test 3: UNION injection attempt
malicious_value = "public' UNION SELECT * FROM store --"
results = store.search(("docs",), filter={"access": malicious_value})
assert len(results) == 0, "UNION injection should be blocked"
# Test 4: Parameterized queries handle strings with null bytes and SQL injection attempts safely
malicious_value = "public\x00' OR '1'='1"
results = store.search(("docs",), filter={"access": malicious_value})
assert len(results) == 0, (
"Parameterized queries treat injection attempts as literal strings"
)
# Test 5: Multiple single quotes
malicious_value = "''''"
results = store.search(("docs",), filter={"access": malicious_value})
assert len(results) == 0, "Multiple quotes should be handled safely"
# Test 6: Legitimate value with single quote should work
store.put(("docs",), "doc4", {"title": "O'Brien's Document", "access": "public"})
results = store.search(("docs",), filter={"title": "O'Brien's Document"})
assert len(results) == 1, "Legitimate single quotes should work"
assert results[0].value["title"] == "O'Brien's Document"
# Test 7: Unicode characters with injection attempt
malicious_value = "public' OR 'א'='א"
results = store.search(("docs",), filter={"access": malicious_value})
assert len(results) == 0, "Unicode-based injection should be blocked"
def test_numeric_filter_safety(store: SqliteStore) -> None:
"""Test that numeric filter values are handled safely."""
# Setup: Create documents with numeric fields
store.put(("items",), "item1", {"price": 10, "quantity": 5})
store.put(("items",), "item2", {"price": 20, "quantity": 3})
store.put(("items",), "item3", {"price": 30, "quantity": 1})
# Test 1: Normal numeric comparison
results = store.search(("items",), filter={"price": {"$gt": 15}})
assert len(results) == 2
assert all(r.value["price"] > 15 for r in results)
# Test 2: Special float values (infinity)
results = store.search(("items",), filter={"price": {"$lt": float("inf")}})
assert len(results) == 3, "All finite values should be less than infinity"
# Test 3: Special float values (negative infinity)
results = store.search(("items",), filter={"price": {"$gt": float("-inf")}})
assert len(results) == 3, (
"All finite values should be greater than negative infinity"
)
# Test 4: NaN handling - NaN comparisons should not cause errors
try:
results = store.search(("items",), filter={"price": {"$eq": float("nan")}})
# NaN never equals anything, including itself, so should return 0 results
assert len(results) == 0
except Exception as e:
pytest.fail(f"NaN handling should not raise exception: {e}")
# Test 5: Very large numbers
results = store.search(("items",), filter={"price": {"$lt": 10**100}})
assert len(results) == 3, "Very large numbers should be handled safely"
# Test 6: Negative numbers
store.put(("items",), "item4", {"price": -10, "quantity": 0})
results = store.search(("items",), filter={"price": {"$lt": 0}})
assert len(results) == 1
assert results[0].key == "item4"
def test_boolean_filter_safety(store: SqliteStore) -> None:
"""Test that boolean filter values are handled safely."""
store.put(("flags",), "flag1", {"active": True, "name": "Feature A"})
store.put(("flags",), "flag2", {"active": False, "name": "Feature B"})
store.put(("flags",), "flag3", {"active": True, "name": "Feature C"})
# Test boolean filters
results = store.search(("flags",), filter={"active": True})
assert len(results) == 2
assert all(r.value["active"] is True for r in results)
results = store.search(("flags",), filter={"active": False})
assert len(results) == 1
assert results[0].value["active"] is False
def test_filter_keys_with_hyphens_and_digits(store: SqliteStore) -> None:
"""Keys with hyphens or leading digits should be queryable via filters.
Current unquoted JSON path construction (e.g., '$.access-level' or '$.123abc')
is not valid JSON1 syntax, so this test will catch regressions in path handling.
"""
# Documents with top-level and nested keys requiring bracket-quoted JSON paths
store.put(
("docs",),
"hyphen",
{"access-level": "public", "user": {"access-level": "nested"}},
)
store.put(("docs",), "digit", {"123abc": "ok", "user": {"123abc": "ok2"}})
# Top-level hyphenated key
results = store.search(("docs",), filter={"access-level": "public"})
assert [r.key for r in results] == ["hyphen"]
# Nested hyphenated key via dotted path
results = store.search(("docs",), filter={"user.access-level": "nested"})
assert [r.key for r in results] == ["hyphen"]
# Top-level digit-starting key
results = store.search(("docs",), filter={"123abc": "ok"})
assert [r.key for r in results] == ["digit"]
# Nested digit-starting key via dotted path
results = store.search(("docs",), filter={"user.123abc": "ok2"})
assert [r.key for r in results] == ["digit"]
@pytest.mark.parametrize("distance_type", VECTOR_TYPES)
def test_non_ascii(
fake_embeddings: CharacterEmbeddings,
distance_type: str,
) -> None:
"""Test support for non-ascii characters"""
with create_vector_store(fake_embeddings, distance_type=distance_type) as store:
store.put(("user_123", "memories"), "1", {"text": "这是中文"}) # Chinese
store.put(
("user_123", "memories"), "2", {"text": "これは日本語です"}
) # Japanese
store.put(("user_123", "memories"), "3", {"text": "이건 한국어야"}) # Korean
store.put(("user_123", "memories"), "4", {"text": "Это русский"}) # Russian
store.put(("user_123", "memories"), "5", {"text": "यह रूसी है"}) # Hindi
result1 = store.search(("user_123", "memories"), query="这是中文")
result2 = store.search(("user_123", "memories"), query="これは日本語です")
result3 = store.search(("user_123", "memories"), query="이건 한국어야")
result4 = store.search(("user_123", "memories"), query="Это русский")
result5 = store.search(("user_123", "memories"), query="यह रूसी है")
assert result1[0].key == "1"
assert result2[0].key == "2"
assert result3[0].key == "3"
assert result4[0].key == "4"
assert result5[0].key == "5"
+2 -76
View File
@@ -7,7 +7,6 @@ import time
from collections.abc import Generator from collections.abc import Generator
import pytest import pytest
from langgraph.store.base import TTLConfig
from langgraph.store.sqlite import SqliteStore from langgraph.store.sqlite import SqliteStore
from langgraph.store.sqlite.aio import AsyncSqliteStore from langgraph.store.sqlite.aio import AsyncSqliteStore
@@ -94,13 +93,9 @@ def test_ttl_sweeper(temp_db_file: str) -> None:
ttl_seconds = 2 ttl_seconds = 2
ttl_minutes = ttl_seconds / 60 ttl_minutes = ttl_seconds / 60
ttl_config: TTLConfig = {
"default_ttl": ttl_minutes,
"sweep_interval_minutes": ttl_minutes / 2,
}
with SqliteStore.from_conn_string( with SqliteStore.from_conn_string(
temp_db_file, temp_db_file,
ttl=ttl_config, ttl={"default_ttl": ttl_minutes, "sweep_interval_minutes": ttl_minutes / 2},
) as store: ) as store:
store.setup() store.setup()
@@ -303,14 +298,9 @@ async def test_async_ttl_sweeper(temp_db_file: str) -> None:
ttl_seconds = 2 ttl_seconds = 2
ttl_minutes = ttl_seconds / 60 ttl_minutes = ttl_seconds / 60
ttl_config: TTLConfig = {
"default_ttl": ttl_minutes,
"sweep_interval_minutes": ttl_minutes / 2,
}
async with AsyncSqliteStore.from_conn_string( async with AsyncSqliteStore.from_conn_string(
temp_db_file, temp_db_file,
ttl=ttl_config, ttl={"default_ttl": ttl_minutes, "sweep_interval_minutes": ttl_minutes / 2},
) as store: ) as store:
await store.setup() await store.setup()
@@ -363,67 +353,3 @@ async def test_async_search_with_ttl(temp_db_file: str) -> None:
# Search after expiration # Search after expiration
results = await store.asearch(("test",), filter={"value": "apple"}) results = await store.asearch(("test",), filter={"value": "apple"})
assert len(results) == 0 assert len(results) == 0
@pytest.mark.asyncio
@pytest.mark.flaky(retries=3)
async def test_async_asearch_refresh_ttl(temp_db_file: str) -> None:
"""Test TTL refresh on asearch with async API."""
ttl_seconds = 4.0 # Increased TTL for less sensitivity to timing
ttl_minutes = ttl_seconds / 60.0
async with AsyncSqliteStore.from_conn_string(
temp_db_file, ttl={"default_ttl": ttl_minutes, "refresh_on_read": True}
) as store:
await store.setup()
namespace = ("docs", "user1")
# t=0: items put, expire at t=4.0s
await store.aput(namespace, "item1", {"text": "content1", "id": 1})
await store.aput(namespace, "item2", {"text": "content2", "id": 2})
# t=3.0s: (after sleep ttl_seconds * 0.75 = 3s)
await asyncio.sleep(ttl_seconds * 0.75)
# Perform asearch with refresh_ttl=True for item1.
# item1's TTL should be refreshed. New expiry: t=3.0s + 4.0s = t=7.0s.
# item2's TTL is not affected. Expires at t=4.0s.
searched_items = await store.asearch(
namespace, filter={"id": 1}, refresh_ttl=True
)
assert len(searched_items) == 1
assert searched_items[0].key == "item1"
# t=5.0s: (after sleep ttl_seconds * 0.5 = 2s more. Total elapsed: 3s + 2s = 5s)
await asyncio.sleep(ttl_seconds * 0.5)
# At this point:
# - item1 (refreshed by asearch) should expire at t=7.0s. Should be ALIVE.
# - item2 (original TTL) should have expired at t=4.0s. Should be GONE after sweep.
await store.sweep_ttl()
# Check item1 (should exist due to asearch refresh)
item1_check1 = await store.aget(namespace, "item1", refresh_ttl=False)
assert item1_check1 is not None, (
"Item1 should exist after asearch refresh and first sweep"
)
assert item1_check1.value["text"] == "content1"
# Check item2 (should be gone)
item2_check1 = await store.aget(namespace, "item2", refresh_ttl=False)
assert item2_check1 is None, (
"Item2 should be gone after its original TTL expired"
)
# t=7.5s: (after sleep ttl_seconds * 0.625 = 2.5s more. Total elapsed: 5s + 2.5s = 7.5s)
await asyncio.sleep(ttl_seconds * 0.625)
# At this point:
# - item1 (refreshed by asearch, expired at t=7.0s) should be GONE after sweep.
await store.sweep_ttl()
# Check item1 again (should be gone now)
item1_final_check = await store.aget(namespace, "item1", refresh_ttl=False)
assert item1_final_check is None, (
"Item1 should be gone after its refreshed TTL expired"
)
+593 -573
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -38,10 +38,8 @@ Each checkpointer should conform to `langgraph.checkpoint.base.BaseCheckpointSav
- `.put_writes` - Store intermediate writes linked to a checkpoint (i.e. pending writes). - `.put_writes` - Store intermediate writes linked to a checkpoint (i.e. pending writes).
- `.get_tuple` - Fetch a checkpoint tuple using for a given configuration (`thread_id` and `checkpoint_id`). - `.get_tuple` - Fetch a checkpoint tuple using for a given configuration (`thread_id` and `checkpoint_id`).
- `.list` - List checkpoints that match a given configuration and filter criteria. - `.list` - List checkpoints that match a given configuration and filter criteria.
- `.delete_thread()` - Delete all checkpoints and writes associated with a thread.
- `.get_next_version()` - Generate the next version ID for a channel.
If the checkpointer will be used with asynchronous graph execution (i.e. executing the graph via `.ainvoke`, `.astream`, `.abatch`), checkpointer must implement asynchronous versions of the above methods (`.aput`, `.aput_writes`, `.aget_tuple`, `.alist`). Similarly, the checkpointer must implement `.adelete_thread()` if asynchronous thread cleanup is desired. The base class provides a default implementation of `.get_next_version()` that generates an integer sequence starting from 1, but this method should be overridden for custom versioning schemes. If the checkpointer will be used with asynchronous graph execution (i.e. executing the graph via `.ainvoke`, `.astream`, `.abatch`), checkpointer must implement asynchronous versions of the above methods (`.aput`, `.aput_writes`, `.aget_tuple`, `.alist`).
## Usage ## Usage
+1 -1
View File
@@ -15,7 +15,7 @@ FullKey = tuple[Namespace, str]
class BaseCache(ABC, Generic[ValueT]): class BaseCache(ABC, Generic[ValueT]):
"""Base class for a cache.""" """Base class for a cache."""
serde: SerializerProtocol = JsonPlusSerializer(pickle_fallback=False) serde: SerializerProtocol = JsonPlusSerializer(pickle_fallback=True)
def __init__(self, *, serde: SerializerProtocol | None = None) -> None: def __init__(self, *, serde: SerializerProtocol | None = None) -> None:
"""Initialize the cache with a serializer.""" """Initialize the cache with a serializer."""
@@ -8,6 +8,7 @@ from typing import ( # noqa: UP035
NamedTuple, NamedTuple,
TypedDict, TypedDict,
TypeVar, TypeVar,
Union,
) )
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
@@ -34,17 +35,17 @@ class CheckpointMetadata(TypedDict, total=False):
source: Literal["input", "loop", "update", "fork"] source: Literal["input", "loop", "update", "fork"]
"""The source of the checkpoint. """The source of the checkpoint.
- `"input"`: The checkpoint was created from an input to invoke/stream/batch. - "input": The checkpoint was created from an input to invoke/stream/batch.
- `"loop"`: The checkpoint was created from inside the pregel loop. - "loop": The checkpoint was created from inside the pregel loop.
- `"update"`: The checkpoint was created from a manual state update. - "update": The checkpoint was created from a manual state update.
- `"fork"`: The checkpoint was created as a copy of another checkpoint. - "fork": The checkpoint was created as a copy of another checkpoint.
""" """
step: int step: int
"""The step number of the checkpoint. """The step number of the checkpoint.
`-1` for the first `"input"` checkpoint. -1 for the first "input" checkpoint.
`0` for the first `"loop"` checkpoint. 0 for the first "loop" checkpoint.
`...` for the `nth` checkpoint afterwards. ... for the nth checkpoint afterwards.
""" """
parents: dict[str, str] parents: dict[str, str]
"""The IDs of the parent checkpoints. """The IDs of the parent checkpoints.
@@ -53,35 +54,30 @@ class CheckpointMetadata(TypedDict, total=False):
""" """
ChannelVersions = dict[str, str | int | float] ChannelVersions = dict[str, Union[str, int, float]]
class Checkpoint(TypedDict): class Checkpoint(TypedDict):
"""State snapshot at a given point in time.""" """State snapshot at a given point in time."""
v: int v: int
"""The version of the checkpoint format. Currently `1`.""" """The version of the checkpoint format. Currently 1."""
id: str id: str
"""The ID of the checkpoint. """The ID of the checkpoint. This is both unique and monotonically
increasing, so can be used for sorting checkpoints from first to last."""
This is both unique and monotonically increasing, so can be used for sorting
checkpoints from first to last."""
ts: str ts: str
"""The timestamp of the checkpoint in ISO 8601 format.""" """The timestamp of the checkpoint in ISO 8601 format."""
channel_values: dict[str, Any] channel_values: dict[str, Any]
"""The values of the channels at the time of the checkpoint. """The values of the channels at the time of the checkpoint.
Mapping from channel name to deserialized channel snapshot value. Mapping from channel name to deserialized channel snapshot value.
""" """
channel_versions: ChannelVersions channel_versions: ChannelVersions
"""The versions of the channels at the time of the checkpoint. """The versions of the channels at the time of the checkpoint.
The keys are channel names and the values are monotonically increasing The keys are channel names and the values are monotonically increasing
version strings for each channel. version strings for each channel.
""" """
versions_seen: dict[str, ChannelVersions] versions_seen: dict[str, ChannelVersions]
"""Map from node ID to map from channel name to version seen. """Map from node ID to map from channel name to version seen.
This keeps track of the versions of the channels that each node has seen. This keeps track of the versions of the channels that each node has seen.
Used to determine which nodes to execute next. Used to determine which nodes to execute next.
""" """
@@ -152,7 +148,7 @@ class BaseCheckpointSaver(Generic[V]):
config: Configuration specifying which checkpoint to retrieve. config: Configuration specifying which checkpoint to retrieve.
Returns: Returns:
The requested checkpoint, or `None` if not found. Optional[Checkpoint]: The requested checkpoint, or None if not found.
""" """
if value := self.get_tuple(config): if value := self.get_tuple(config):
return value.checkpoint return value.checkpoint
@@ -164,7 +160,7 @@ class BaseCheckpointSaver(Generic[V]):
config: Configuration specifying which checkpoint to retrieve. config: Configuration specifying which checkpoint to retrieve.
Returns: Returns:
The requested checkpoint tuple, or `None` if not found. Optional[CheckpointTuple]: The requested checkpoint tuple, or None if not found.
Raises: Raises:
NotImplementedError: Implement this method in your custom checkpoint saver. NotImplementedError: Implement this method in your custom checkpoint saver.
@@ -188,7 +184,7 @@ class BaseCheckpointSaver(Generic[V]):
limit: Maximum number of checkpoints to return. limit: Maximum number of checkpoints to return.
Returns: Returns:
Iterator of matching checkpoint tuples. Iterator[CheckpointTuple]: Iterator of matching checkpoint tuples.
Raises: Raises:
NotImplementedError: Implement this method in your custom checkpoint saver. NotImplementedError: Implement this method in your custom checkpoint saver.
@@ -256,7 +252,7 @@ class BaseCheckpointSaver(Generic[V]):
config: Configuration specifying which checkpoint to retrieve. config: Configuration specifying which checkpoint to retrieve.
Returns: Returns:
The requested checkpoint, or `None` if not found. Optional[Checkpoint]: The requested checkpoint, or None if not found.
""" """
if value := await self.aget_tuple(config): if value := await self.aget_tuple(config):
return value.checkpoint return value.checkpoint
@@ -268,7 +264,7 @@ class BaseCheckpointSaver(Generic[V]):
config: Configuration specifying which checkpoint to retrieve. config: Configuration specifying which checkpoint to retrieve.
Returns: Returns:
The requested checkpoint tuple, or `None` if not found. Optional[CheckpointTuple]: The requested checkpoint tuple, or None if not found.
Raises: Raises:
NotImplementedError: Implement this method in your custom checkpoint saver. NotImplementedError: Implement this method in your custom checkpoint saver.
@@ -292,7 +288,7 @@ class BaseCheckpointSaver(Generic[V]):
limit: Maximum number of checkpoints to return. limit: Maximum number of checkpoints to return.
Returns: Returns:
Async iterator of matching checkpoint tuples. AsyncIterator[CheckpointTuple]: Async iterator of matching checkpoint tuples.
Raises: Raises:
NotImplementedError: Implement this method in your custom checkpoint saver. NotImplementedError: Implement this method in your custom checkpoint saver.
@@ -357,12 +353,11 @@ class BaseCheckpointSaver(Generic[V]):
def get_next_version(self, current: V | None, channel: None) -> V: def get_next_version(self, current: V | None, channel: None) -> V:
"""Generate the next version ID for a channel. """Generate the next version ID for a channel.
Default is to use integer versions, incrementing by `1`. Default is to use integer versions, incrementing by 1. If you override, you can use str/int/float versions,
as long as they are monotonically increasing.
If you override, you can use `str`/`int`/`float` versions, as long as they are monotonically increasing.
Args: Args:
current: The current version identifier (`int`, `float`, or `str`). current: The current version identifier (int, float, or str).
channel: Deprecated argument, kept for backwards compatibility. channel: Deprecated argument, kept for backwards compatibility.
Returns: Returns:
@@ -409,16 +404,6 @@ def get_checkpoint_metadata(
return metadata return metadata
def get_serializable_checkpoint_metadata(
config: RunnableConfig, metadata: CheckpointMetadata
) -> CheckpointMetadata:
"""Get checkpoint metadata in a backwards-compatible manner."""
checkpoint_metadata = get_checkpoint_metadata(config, metadata)
if "writes" in checkpoint_metadata:
checkpoint_metadata.pop("writes")
return checkpoint_metadata
""" """
Mapping from error type to error index. Mapping from error type to error index.
Regular writes just map to their index in the list of writes being saved. Regular writes just map to their index in the list of writes being saved.
@@ -33,34 +33,33 @@ class InMemorySaver(
): ):
"""An in-memory checkpoint saver. """An in-memory checkpoint saver.
This checkpoint saver stores checkpoints in memory using a `defaultdict`. This checkpoint saver stores checkpoints in memory using a defaultdict.
Note: Note:
Only use `InMemorySaver` for debugging or testing purposes. Only use `InMemorySaver` for debugging or testing purposes.
For production use cases we recommend installing [langgraph-checkpoint-postgres](https://pypi.org/project/langgraph-checkpoint-postgres/) and using `PostgresSaver` / `AsyncPostgresSaver`. For production use cases we recommend installing [langgraph-checkpoint-postgres](https://pypi.org/project/langgraph-checkpoint-postgres/) and using `PostgresSaver` / `AsyncPostgresSaver`.
If you are using LangSmith Deployment, no checkpointer needs to be specified. The correct managed checkpointer will be used automatically. If you are using the LangGraph Platform, no checkpointer needs to be specified. The correct managed checkpointer will be used automatically.
Args: Args:
serde: The serializer to use for serializing and deserializing checkpoints. serde: The serializer to use for serializing and deserializing checkpoints. Defaults to None.
Example: Examples:
```python
import asyncio
from langgraph.checkpoint.memory import InMemorySaver import asyncio
from langgraph.graph import StateGraph
builder = StateGraph(int) from langgraph.checkpoint.memory import InMemorySaver
builder.add_node("add_one", lambda x: x + 1) from langgraph.graph import StateGraph
builder.set_entry_point("add_one")
builder.set_finish_point("add_one")
memory = InMemorySaver() builder = StateGraph(int)
graph = builder.compile(checkpointer=memory) builder.add_node("add_one", lambda x: x + 1)
coro = graph.ainvoke(1, {"configurable": {"thread_id": "thread-1"}}) builder.set_entry_point("add_one")
asyncio.run(coro) # Output: 2 builder.set_finish_point("add_one")
```
memory = InMemorySaver()
graph = builder.compile(checkpointer=memory)
coro = graph.ainvoke(1, {"configurable": {"thread_id": "thread-1"}})
asyncio.run(coro) # Output: 2
""" """
# thread ID -> checkpoint NS -> checkpoint ID -> checkpoint mapping # thread ID -> checkpoint NS -> checkpoint ID -> checkpoint mapping
@@ -97,8 +96,7 @@ class InMemorySaver(
self.stack.enter_context(self.blobs) # type: ignore[arg-type] self.stack.enter_context(self.blobs) # type: ignore[arg-type]
def __enter__(self) -> InMemorySaver: def __enter__(self) -> InMemorySaver:
self.stack.__enter__() return self.stack.__enter__()
return self
def __exit__( def __exit__(
self, self,
@@ -109,8 +107,7 @@ class InMemorySaver(
return self.stack.__exit__(exc_type, exc_value, traceback) return self.stack.__exit__(exc_type, exc_value, traceback)
async def __aenter__(self) -> InMemorySaver: async def __aenter__(self) -> InMemorySaver:
self.stack.__enter__() return self.stack.__enter__()
return self
async def __aexit__( async def __aexit__(
self, self,
@@ -136,7 +133,7 @@ class InMemorySaver(
"""Get a checkpoint tuple from the in-memory storage. """Get a checkpoint tuple from the in-memory storage.
This method retrieves a checkpoint tuple from the in-memory storage based on the This method retrieves a checkpoint tuple from the in-memory storage based on the
provided config. If the config contains a `checkpoint_id` key, the checkpoint with provided config. If the config contains a "checkpoint_id" key, the checkpoint with
the matching thread ID and timestamp is retrieved. Otherwise, the latest checkpoint the matching thread ID and timestamp is retrieved. Otherwise, the latest checkpoint
for the given thread ID is retrieved. for the given thread ID is retrieved.
@@ -144,7 +141,7 @@ class InMemorySaver(
config: The config to use for retrieving the checkpoint. config: The config to use for retrieving the checkpoint.
Returns: Returns:
The retrieved checkpoint tuple, or None if no matching checkpoint was found. Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
""" """
thread_id: str = config["configurable"]["thread_id"] thread_id: str = config["configurable"]["thread_id"]
checkpoint_ns: str = config["configurable"].get("checkpoint_ns", "") checkpoint_ns: str = config["configurable"].get("checkpoint_ns", "")
@@ -234,7 +231,7 @@ class InMemorySaver(
limit: Maximum number of checkpoints to return. limit: Maximum number of checkpoints to return.
Yields: Yields:
An iterator of matching checkpoint tuples. Iterator[CheckpointTuple]: An iterator of matching checkpoint tuples.
""" """
thread_ids = (config["configurable"]["thread_id"],) if config else self.storage thread_ids = (config["configurable"]["thread_id"],) if config else self.storage
config_checkpoint_ns = ( config_checkpoint_ns = (
@@ -426,16 +423,16 @@ class InMemorySaver(
del self.blobs[k] del self.blobs[k]
async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Asynchronous version of `get_tuple`. """Asynchronous version of get_tuple.
This method is an asynchronous wrapper around `get_tuple` that runs the synchronous This method is an asynchronous wrapper around get_tuple that runs the synchronous
method in a separate thread using asyncio. method in a separate thread using asyncio.
Args: Args:
config: The config to use for retrieving the checkpoint. config: The config to use for retrieving the checkpoint.
Returns: Returns:
The retrieved checkpoint tuple, or None if no matching checkpoint was found. Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
""" """
return self.get_tuple(config) return self.get_tuple(config)
@@ -447,16 +444,16 @@ class InMemorySaver(
before: RunnableConfig | None = None, before: RunnableConfig | None = None,
limit: int | None = None, limit: int | None = None,
) -> AsyncIterator[CheckpointTuple]: ) -> AsyncIterator[CheckpointTuple]:
"""Asynchronous version of `list`. """Asynchronous version of list.
This method is an asynchronous wrapper around `list` that runs the synchronous This method is an asynchronous wrapper around list that runs the synchronous
method in a separate thread using asyncio. method in a separate thread using asyncio.
Args: Args:
config: The config to use for listing the checkpoints. config: The config to use for listing the checkpoints.
Yields: Yields:
An asynchronous iterator of checkpoint tuples. AsyncIterator[CheckpointTuple]: An asynchronous iterator of checkpoint tuples.
""" """
for item in self.list(config, filter=filter, before=before, limit=limit): for item in self.list(config, filter=filter, before=before, limit=limit):
yield item yield item
@@ -468,7 +465,7 @@ class InMemorySaver(
metadata: CheckpointMetadata, metadata: CheckpointMetadata,
new_versions: ChannelVersions, new_versions: ChannelVersions,
) -> RunnableConfig: ) -> RunnableConfig:
"""Asynchronous version of `put`. """Asynchronous version of put.
Args: Args:
config: The config to associate with the checkpoint. config: The config to associate with the checkpoint.
@@ -488,9 +485,9 @@ class InMemorySaver(
task_id: str, task_id: str,
task_path: str = "", task_path: str = "",
) -> None: ) -> None:
"""Asynchronous version of `put_writes`. """Asynchronous version of put_writes.
This method is an asynchronous wrapper around `put_writes` that runs the synchronous This method is an asynchronous wrapper around put_writes that runs the synchronous
method in a separate thread using asyncio. method in a separate thread using asyncio.
Args: Args:
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Protocol, runtime_checkable from typing import Any, Protocol
class UntypedSerializerProtocol(Protocol): class UntypedSerializerProtocol(Protocol):
@@ -11,12 +11,13 @@ class UntypedSerializerProtocol(Protocol):
def loads(self, data: bytes) -> Any: ... def loads(self, data: bytes) -> Any: ...
@runtime_checkable class SerializerProtocol(UntypedSerializerProtocol, Protocol):
class SerializerProtocol(Protocol):
"""Protocol for serialization and deserialization of objects. """Protocol for serialization and deserialization of objects.
- `dumps_typed`: Serialize an object to a tuple `(type, bytes)`. - `dumps`: Serialize an object to bytes.
- `loads_typed`: Deserialize an object from a tuple `(type, bytes)`. - `dumps_typed`: Serialize an object to a tuple (type, bytes).
- `loads`: Deserialize an object from bytes.
- `loads_typed`: Deserialize an object from a tuple (type, bytes).
Valid implementations include the `pickle`, `json` and `orjson` modules. Valid implementations include the `pickle`, `json` and `orjson` modules.
""" """
@@ -30,6 +31,12 @@ class SerializerCompat(SerializerProtocol):
def __init__(self, serde: UntypedSerializerProtocol) -> None: def __init__(self, serde: UntypedSerializerProtocol) -> None:
self.serde = serde self.serde = serde
def dumps(self, obj: Any) -> bytes:
return self.serde.dumps(obj)
def loads(self, data: bytes) -> Any:
return self.serde.loads(data)
def dumps_typed(self, obj: Any) -> tuple[str, bytes]: def dumps_typed(self, obj: Any) -> tuple[str, bytes]:
return type(obj).__name__, self.serde.dumps(obj) return type(obj).__name__, self.serde.dumps(obj)
@@ -42,7 +49,7 @@ def maybe_add_typed_methods(
) -> SerializerProtocol: ) -> SerializerProtocol:
"""Wrap serde old serde implementations in a class with loads_typed and dumps_typed for backwards compatibility.""" """Wrap serde old serde implementations in a class with loads_typed and dumps_typed for backwards compatibility."""
if not isinstance(serde, SerializerProtocol): if not hasattr(serde, "loads_typed") or not hasattr(serde, "dumps_typed"):
return SerializerCompat(serde) return SerializerCompat(serde)
return serde return serde
@@ -50,13 +57,12 @@ def maybe_add_typed_methods(
class CipherProtocol(Protocol): class CipherProtocol(Protocol):
"""Protocol for encryption and decryption of data. """Protocol for encryption and decryption of data.
- `encrypt`: Encrypt plaintext. - `encrypt`: Encrypt plaintext.
- `decrypt`: Decrypt ciphertext. - `decrypt`: Decrypt ciphertext.
""" """
def encrypt(self, plaintext: bytes) -> tuple[str, bytes]: def encrypt(self, plaintext: bytes) -> tuple[str, bytes]:
"""Encrypt plaintext. Returns a tuple `(cipher name, ciphertext)`.""" """Encrypt plaintext. Returns a tuple (cipher name, ciphertext)."""
... ...
def decrypt(self, ciphername: str, ciphertext: bytes) -> bytes: def decrypt(self, ciphername: str, ciphertext: bytes) -> bytes:
@@ -14,8 +14,14 @@ class EncryptedSerializer(SerializerProtocol):
self.cipher = cipher self.cipher = cipher
self.serde = serde self.serde = serde
def dumps(self, obj: Any) -> bytes:
return self.serde.dumps(obj)
def loads(self, data: bytes) -> Any:
return self.serde.loads(data)
def dumps_typed(self, obj: Any) -> tuple[str, bytes]: def dumps_typed(self, obj: Any) -> tuple[str, bytes]:
"""Serialize an object to a tuple `(type, bytes)` and encrypt the bytes.""" """Serialize an object to a tuple (type, bytes) and encrypt the bytes."""
# serialize data # serialize data
typ, data = self.serde.dumps_typed(obj) typ, data = self.serde.dumps_typed(obj)
# encrypt data # encrypt data
@@ -39,7 +45,7 @@ class EncryptedSerializer(SerializerProtocol):
def from_pycryptodome_aes( def from_pycryptodome_aes(
cls, serde: SerializerProtocol = JsonPlusSerializer(), **kwargs: Any cls, serde: SerializerProtocol = JsonPlusSerializer(), **kwargs: Any
) -> "EncryptedSerializer": ) -> "EncryptedSerializer":
"""Create an `EncryptedSerializer` using AES encryption.""" """Create an EncryptedSerializer using AES encryption."""
try: try:
from Crypto.Cipher import AES # type: ignore from Crypto.Cipher import AES # type: ignore
except ImportError: except ImportError:
@@ -4,13 +4,12 @@ import dataclasses
import decimal import decimal
import importlib import importlib
import json import json
import logging
import pathlib import pathlib
import pickle import pickle
import re import re
import sys import sys
from collections import deque from collections import deque
from collections.abc import Callable, Sequence from collections.abc import Sequence
from datetime import date, datetime, time, timedelta, timezone from datetime import date, datetime, time, timedelta, timezone
from enum import Enum from enum import Enum
from inspect import isclass from inspect import isclass
@@ -22,12 +21,13 @@ from ipaddress import (
IPv6Interface, IPv6Interface,
IPv6Network, IPv6Network,
) )
from typing import Any, Literal from typing import Any, Callable, cast
from uuid import UUID from uuid import UUID
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import ormsgpack import ormsgpack
from langchain_core.load.load import Reviver from langchain_core.load.load import Reviver
from langchain_core.load.serializable import Serializable
from langgraph.checkpoint.serde.base import SerializerProtocol from langgraph.checkpoint.serde.base import SerializerProtocol
from langgraph.checkpoint.serde.types import SendProtocol from langgraph.checkpoint.serde.types import SendProtocol
@@ -35,33 +35,18 @@ from langgraph.store.base import Item
LC_REVIVER = Reviver() LC_REVIVER = Reviver()
EMPTY_BYTES = b"" EMPTY_BYTES = b""
logger = logging.getLogger(__name__)
class JsonPlusSerializer(SerializerProtocol): class JsonPlusSerializer(SerializerProtocol):
"""Serializer that uses ormsgpack, with optional fallbacks. """Serializer that uses ormsgpack, with a fallback to extended JSON serializer."""
!!! warning
Security note: This serializer is intended for use within the `BaseCheckpointSaver`
class and called within the Pregel loop. It should not be used on untrusted
python objects. If an attacker can write directly to your checkpoint database,
they may be able to trigger code execution when data is deserialized.
"""
def __init__( def __init__(
self, self,
*, *,
pickle_fallback: bool = False, pickle_fallback: bool = False,
allowed_json_modules: Sequence[tuple[str, ...]] | Literal[True] | None = None,
__unpack_ext_hook__: Callable[[int, bytes], Any] | None = None, __unpack_ext_hook__: Callable[[int, bytes], Any] | None = None,
) -> None: ) -> None:
self.pickle_fallback = pickle_fallback self.pickle_fallback = pickle_fallback
self._allowed_modules = (
{mod_and_name for mod_and_name in allowed_json_modules}
if allowed_json_modules and allowed_json_modules is not True
else (allowed_json_modules if allowed_json_modules is True else None)
)
self._unpack_ext_hook = ( self._unpack_ext_hook = (
__unpack_ext_hook__ __unpack_ext_hook__
if __unpack_ext_hook__ is not None if __unpack_ext_hook__ is not None
@@ -89,90 +74,134 @@ class JsonPlusSerializer(SerializerProtocol):
out["kwargs"] = kwargs out["kwargs"] = kwargs
return out return out
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):
return self._encode_constructor_args(
obj.__class__, method=(None, "model_construct"), kwargs=obj.model_dump()
)
elif hasattr(obj, "dict") and callable(obj.dict):
return self._encode_constructor_args(
obj.__class__, method=(None, "construct"), kwargs=obj.dict()
)
elif hasattr(obj, "_asdict") and callable(obj._asdict):
return self._encode_constructor_args(obj.__class__, kwargs=obj._asdict())
elif isinstance(obj, pathlib.Path):
return self._encode_constructor_args(pathlib.Path, args=obj.parts)
elif isinstance(obj, re.Pattern):
return self._encode_constructor_args(
re.compile, args=(obj.pattern, obj.flags)
)
elif isinstance(obj, UUID):
return self._encode_constructor_args(UUID, args=(obj.hex,))
elif isinstance(obj, decimal.Decimal):
return self._encode_constructor_args(decimal.Decimal, args=(str(obj),))
elif isinstance(obj, (set, frozenset, deque)):
return self._encode_constructor_args(type(obj), args=(tuple(obj),))
elif isinstance(obj, (IPv4Address, IPv4Interface, IPv4Network)):
return self._encode_constructor_args(obj.__class__, args=(str(obj),))
elif isinstance(obj, (IPv6Address, IPv6Interface, IPv6Network)):
return self._encode_constructor_args(obj.__class__, args=(str(obj),))
elif isinstance(obj, datetime):
return self._encode_constructor_args(
datetime, method="fromisoformat", args=(obj.isoformat(),)
)
elif isinstance(obj, timezone):
return self._encode_constructor_args(
timezone,
args=obj.__getinitargs__(), # type: ignore[attr-defined]
)
elif isinstance(obj, ZoneInfo):
return self._encode_constructor_args(ZoneInfo, args=(obj.key,))
elif isinstance(obj, timedelta):
return self._encode_constructor_args(
timedelta, args=(obj.days, obj.seconds, obj.microseconds)
)
elif isinstance(obj, date):
return self._encode_constructor_args(
date, args=(obj.year, obj.month, obj.day)
)
elif isinstance(obj, time):
return self._encode_constructor_args(
time,
args=(obj.hour, obj.minute, obj.second, obj.microsecond, obj.tzinfo),
kwargs={"fold": obj.fold},
)
elif dataclasses.is_dataclass(obj):
return self._encode_constructor_args(
obj.__class__,
kwargs={
field.name: getattr(obj, field.name)
for field in dataclasses.fields(obj)
},
)
elif isinstance(obj, Enum):
return self._encode_constructor_args(obj.__class__, args=(obj.value,))
elif isinstance(obj, SendProtocol):
return self._encode_constructor_args(
obj.__class__, kwargs={"node": obj.node, "arg": obj.arg}
)
elif isinstance(obj, (bytes, bytearray)):
return self._encode_constructor_args(
obj.__class__, method="fromhex", args=(obj.hex(),)
)
elif isinstance(obj, BaseException):
return repr(obj)
else:
raise TypeError(
f"Object of type {obj.__class__.__name__} is not JSON serializable"
)
def _reviver(self, value: dict[str, Any]) -> Any: def _reviver(self, value: dict[str, Any]) -> Any:
if self._allowed_modules and ( if (
value.get("lc", None) == 2 value.get("lc", None) == 2
and value.get("type", None) == "constructor" and value.get("type", None) == "constructor"
and value.get("id", None) is not None and value.get("id", None) is not None
): ):
try: try:
return self._revive_lc2(value) # Get module and class name
except InvalidModuleError as e: [*module, name] = value["id"]
logger.warning( # Import module
"Object %s is not in the deserialization allowlist.\n%s", mod = importlib.import_module(".".join(module))
value["id"], # Import class
e.message, cls = getattr(mod, name)
) # Instantiate class
method = value.get("method")
if isinstance(method, str):
methods = [getattr(cls, method)]
elif isinstance(method, list):
methods = [
cls if method is None else getattr(cls, method)
for method in method
]
else:
methods = [cls]
args = value.get("args")
kwargs = value.get("kwargs")
for method in methods:
try:
if isclass(method) and issubclass(method, BaseException):
return None
if args and kwargs:
return method(*args, **kwargs)
elif args:
return method(*args)
elif kwargs:
return method(**kwargs)
else:
return method()
except Exception:
continue
except Exception:
return None
return LC_REVIVER(value) return LC_REVIVER(value)
def _revive_lc2(self, value: dict[str, Any]) -> Any: def dumps(self, obj: Any) -> bytes:
self._check_allowed_modules(value) return json.dumps(obj, default=self._default, ensure_ascii=False).encode(
"utf-8", "ignore"
[*module, name] = value["id"]
try:
mod = importlib.import_module(".".join(module))
cls = getattr(mod, name)
method = value.get("method")
if isinstance(method, str):
methods = [getattr(cls, method)]
elif isinstance(method, list):
methods = [cls if m is None else getattr(cls, m) for m in method]
else:
methods = [cls]
args = value.get("args")
kwargs = value.get("kwargs")
for method in methods:
try:
if isclass(method) and issubclass(method, BaseException):
return None
if args and kwargs:
return method(*args, **kwargs)
elif args:
return method(*args)
elif kwargs:
return method(**kwargs)
else:
return method()
except Exception:
continue
except Exception:
return None
def _check_allowed_modules(self, value: dict[str, Any]) -> None:
needed = tuple(value["id"])
method = value.get("method")
if isinstance(method, list):
method_display = ",".join(m or "<init>" for m in method)
elif isinstance(method, str):
method_display = method
else:
method_display = "<init>"
dotted = ".".join(needed)
if not self._allowed_modules:
raise InvalidModuleError(
f"Refused to deserialize JSON constructor: {dotted} (method: {method_display}). "
"No allowed_json_modules configured.\n\n"
"Unblock with ONE of:\n"
f" • JsonPlusSerializer(allowed_json_modules=[{needed!r}, ...])\n"
" • (DANGEROUS) JsonPlusSerializer(allowed_json_modules=True)\n\n"
"Note: Prefix allowlists are intentionally unsupported; prefer exact symbols "
"or plain-JSON representations revived without import-time side effects."
)
if self._allowed_modules is True:
return
if needed in self._allowed_modules:
return
raise InvalidModuleError(
f"Refused to deserialize JSON constructor: {dotted} (method: {method_display}). "
"Symbol is not in the deserialization allowlist.\n\n"
"Add exactly this symbol to unblock:\n"
f" JsonPlusSerializer(allowed_json_modules=[{needed!r}, ...])\n"
"Or, as a last resort (DANGEROUS):\n"
" JsonPlusSerializer(allowed_json_modules=True)"
) )
def dumps_typed(self, obj: Any) -> tuple[str, bytes]: def dumps_typed(self, obj: Any) -> tuple[str, bytes]:
@@ -186,10 +215,15 @@ class JsonPlusSerializer(SerializerProtocol):
try: try:
return "msgpack", _msgpack_enc(obj) return "msgpack", _msgpack_enc(obj)
except ormsgpack.MsgpackEncodeError as exc: except ormsgpack.MsgpackEncodeError as exc:
if self.pickle_fallback: if "valid UTF-8" in str(exc):
return "json", self.dumps(obj)
elif self.pickle_fallback:
return "pickle", pickle.dumps(obj) return "pickle", pickle.dumps(obj)
raise exc raise exc
def loads(self, data: bytes) -> Any:
return json.loads(data, object_hook=self._reviver)
def loads_typed(self, data: tuple[str, bytes]) -> Any: def loads_typed(self, data: tuple[str, bytes]) -> Any:
type_, data_ = data type_, data_ = data
if type_ == "null": if type_ == "null":
@@ -199,7 +233,7 @@ class JsonPlusSerializer(SerializerProtocol):
elif type_ == "bytearray": elif type_ == "bytearray":
return bytearray(data_) return bytearray(data_)
elif type_ == "json": elif type_ == "json":
return json.loads(data_, object_hook=self._reviver) return self.loads(data_)
elif type_ == "msgpack": elif type_ == "msgpack":
return ormsgpack.unpackb( return ormsgpack.unpackb(
data_, ext_hook=self._unpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS data_, ext_hook=self._unpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
@@ -629,20 +663,12 @@ def _msgpack_ext_hook_to_json(code: int, data: bytes) -> Any:
return return
class InvalidModuleError(Exception):
"""Exception raised when a module is not in the allowlist."""
def __init__(self, message: str):
self.message = message
_option = ( _option = (
ormsgpack.OPT_NON_STR_KEYS ormsgpack.OPT_NON_STR_KEYS
| ormsgpack.OPT_PASSTHROUGH_DATACLASS | ormsgpack.OPT_PASSTHROUGH_DATACLASS
| ormsgpack.OPT_PASSTHROUGH_DATETIME | ormsgpack.OPT_PASSTHROUGH_DATETIME
| ormsgpack.OPT_PASSTHROUGH_ENUM | ormsgpack.OPT_PASSTHROUGH_ENUM
| ormsgpack.OPT_PASSTHROUGH_UUID | ormsgpack.OPT_PASSTHROUGH_UUID
| ormsgpack.OPT_REPLACE_SURROGATES
) )
@@ -1,6 +1,7 @@
from collections.abc import Sequence from collections.abc import Sequence
from typing import ( from typing import (
Any, Any,
Optional,
Protocol, Protocol,
TypeVar, TypeVar,
runtime_checkable, runtime_checkable,
@@ -27,9 +28,9 @@ class ChannelProtocol(Protocol[Value, Update, C]):
@property @property
def UpdateType(self) -> Any: ... def UpdateType(self) -> Any: ...
def checkpoint(self) -> C | None: ... def checkpoint(self) -> Optional[C]: ...
def from_checkpoint(self, checkpoint: C | None) -> Self: ... def from_checkpoint(self, checkpoint: Optional[C]) -> Self: ...
def update(self, values: Sequence[Update]) -> bool: ... def update(self, values: Sequence[Update]) -> bool: ...
+97 -146
View File
@@ -4,9 +4,9 @@ Stores provide long-term memory that persists across threads and conversations.
Supports hierarchical namespaces, key-value storage, and optional vector search. Supports hierarchical namespaces, key-value storage, and optional vector search.
Core types: Core types:
- `BaseStore`: Store interface with sync/async operations - BaseStore: Store interface with sync/async operations
- `Item`: Stored key-value pairs with metadata - Item: Stored key-value pairs with metadata
- `Op`: Get/Put/Search/List operations - Op: Get/Put/Search/List operations
""" """
from __future__ import annotations from __future__ import annotations
@@ -19,6 +19,7 @@ from typing import (
Literal, Literal,
NamedTuple, NamedTuple,
TypedDict, TypedDict,
Union,
cast, cast,
) )
@@ -56,7 +57,7 @@ class Item:
key: Unique identifier within the namespace. key: Unique identifier within the namespace.
namespace: Hierarchical path defining the collection in which this document resides. namespace: Hierarchical path defining the collection in which this document resides.
Represented as a tuple of strings, allowing for nested categorization. Represented as a tuple of strings, allowing for nested categorization.
For example: `("documents", 'user123')` For example: ("documents", 'user123')
created_at: Timestamp of item creation. created_at: Timestamp of item creation.
updated_at: Timestamp of last update. updated_at: Timestamp of last update.
""" """
@@ -163,7 +164,6 @@ class GetOp(NamedTuple):
???+ example "Examples" ???+ example "Examples"
Basic item retrieval: Basic item retrieval:
```python ```python
GetOp(namespace=("users", "profiles"), key="user123") GetOp(namespace=("users", "profiles"), key="user123")
GetOp(namespace=("cache", "embeddings"), key="doc456") GetOp(namespace=("cache", "embeddings"), key="doc456")
@@ -207,14 +207,11 @@ class SearchOp(NamedTuple):
within a given namespace prefix. It provides pagination through limit and offset within a given namespace prefix. It provides pagination through limit and offset
parameters. parameters.
!!! note Note:
Natural language search support depends on your store implementation. Natural language search support depends on your store implementation.
???+ example "Examples" ???+ example "Examples"
Search with filters and pagination: Search with filters and pagination:
```python ```python
SearchOp( SearchOp(
namespace_prefix=("documents",), namespace_prefix=("documents",),
@@ -225,7 +222,6 @@ class SearchOp(NamedTuple):
``` ```
Natural language search: Natural language search:
```python ```python
SearchOp( SearchOp(
namespace_prefix=("users", "content"), namespace_prefix=("users", "content"),
@@ -253,15 +249,14 @@ class SearchOp(NamedTuple):
The filter supports both exact matches and operator-based comparisons. The filter supports both exact matches and operator-based comparisons.
Supported Operators: Supported Operators:
- `$eq`: Equal to (same as direct value comparison) - $eq: Equal to (same as direct value comparison)
- `$ne`: Not equal to - $ne: Not equal to
- `$gt`: Greater than - $gt: Greater than
- `$gte`: Greater than or equal to - $gte: Greater than or equal to
- `$lt`: Less than - $lt: Less than
- `$lte`: Less than or equal to - $lte: Less than or equal to
???+ example "Examples" ???+ example "Examples"
Simple exact match: Simple exact match:
```python ```python
@@ -294,7 +289,6 @@ class SearchOp(NamedTuple):
"""Natural language search query for semantic search capabilities. """Natural language search query for semantic search capabilities.
???+ example "Examples" ???+ example "Examples"
- "technical documentation about REST APIs" - "technical documentation about REST APIs"
- "machine learning papers from 2023" - "machine learning papers from 2023"
""" """
@@ -308,11 +302,10 @@ class SearchOp(NamedTuple):
# Type representing a namespace path that can include wildcards # Type representing a namespace path that can include wildcards
NamespacePath = tuple[str | Literal["*"], ...] NamespacePath = tuple[Union[str, Literal["*"]], ...]
"""A tuple representing a namespace path that can include wildcards. """A tuple representing a namespace path that can include wildcards.
???+ example "Examples" ???+ example "Examples"
```python ```python
("users",) # Exact users namespace ("users",) # Exact users namespace
("documents", "*") # Any sub-namespace under documents ("documents", "*") # Any sub-namespace under documents
@@ -338,21 +331,17 @@ class MatchCondition(NamedTuple):
hierarchies. hierarchies.
???+ example "Examples" ???+ example "Examples"
Prefix matching: Prefix matching:
```python ```python
MatchCondition(match_type="prefix", path=("users", "profiles")) MatchCondition(match_type="prefix", path=("users", "profiles"))
``` ```
Suffix matching with wildcard: Suffix matching with wildcard:
```python ```python
MatchCondition(match_type="suffix", path=("cache", "*")) MatchCondition(match_type="suffix", path=("cache", "*"))
``` ```
Simple suffix matching: Simple suffix matching:
```python ```python
MatchCondition(match_type="suffix", path=("v1",)) MatchCondition(match_type="suffix", path=("v1",))
``` ```
@@ -373,8 +362,7 @@ class ListNamespacesOp(NamedTuple):
???+ example "Examples" ???+ example "Examples"
List all namespaces under the `"documents"` path: List all namespaces under the "documents" path:
```python ```python
ListNamespacesOp( ListNamespacesOp(
match_conditions=(MatchCondition(match_type="prefix", path=("documents",)),), match_conditions=(MatchCondition(match_type="prefix", path=("documents",)),),
@@ -382,8 +370,7 @@ class ListNamespacesOp(NamedTuple):
) )
``` ```
List all namespaces that end with `"v1"`: List all namespaces that end with "v1":
```python ```python
ListNamespacesOp( ListNamespacesOp(
match_conditions=(MatchCondition(match_type="suffix", path=("v1",)),), match_conditions=(MatchCondition(match_type="suffix", path=("v1",)),),
@@ -397,15 +384,12 @@ class ListNamespacesOp(NamedTuple):
"""Optional conditions for filtering namespaces. """Optional conditions for filtering namespaces.
???+ example "Examples" ???+ example "Examples"
All user namespaces: All user namespaces:
```python ```python
(MatchCondition(match_type="prefix", path=("users",)),) (MatchCondition(match_type="prefix", path=("users",)),)
``` ```
All namespaces that start with `"docs"` and end with `"draft"`: All namespaces that start with "docs" and end with "draft":
```python ```python
( (
MatchCondition(match_type="prefix", path=("docs",)), MatchCondition(match_type="prefix", path=("docs",)),
@@ -442,21 +426,17 @@ class PutOp(NamedTuple):
Each element in the tuple represents one level in the hierarchy. Each element in the tuple represents one level in the hierarchy.
???+ example "Examples" ???+ example "Examples"
Root level documents
Root level documents:
```python ```python
("documents",) ("documents",)
``` ```
User-specific documents: User-specific documents
```python ```python
("documents", "user123") ("documents", "user123")
``` ```
Nested cache structure: Nested cache structure
```python ```python
("cache", "embeddings", "v1") ("cache", "embeddings", "v1")
``` ```
@@ -469,15 +449,15 @@ class PutOp(NamedTuple):
Together with the namespace, it forms a complete path to the item. Together with the namespace, it forms a complete path to the item.
Example: Example:
If namespace is `("documents", "user123")` and key is `"report1"`, If namespace is ("documents", "user123") and key is "report1",
the full path would effectively be `"documents/user123/report1"` the full path would effectively be "documents/user123/report1"
""" """
value: dict[str, Any] | None value: dict[str, Any] | None
"""The data to store, or `None` to mark the item for deletion. """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. The value must be a dictionary with string keys and JSON-serializable values.
Setting this to `None` signals that the item should be deleted. Setting this to None signals that the item should be deleted.
Example: Example:
{ {
@@ -491,26 +471,25 @@ class PutOp(NamedTuple):
"""Controls how the item's fields are indexed for search operations. """Controls how the item's fields are indexed for search operations.
Indexing configuration determines how the item can be found through search: Indexing configuration determines how the item can be found through search:
- `None` (default): Uses the store's default indexing configuration (if provided) - None (default): Uses the store's default indexing configuration (if provided)
- `False`: Disables indexing for this item - False: Disables indexing for this item
- `list[str]`: Specifies which json path fields to index for search - list[str]: Specifies which json path fields to index for search
The item remains accessible through direct get() operations regardless of indexing. The item remains accessible through direct get() operations regardless of indexing.
When indexed, fields can be searched using natural language queries through When indexed, fields can be searched using natural language queries through
vector similarity search (if supported by the store implementation). vector similarity search (if supported by the store implementation).
Path Syntax: Path Syntax:
- Simple field access: `"field"` - Simple field access: "field"
- Nested fields: `"parent.child.grandchild"` - Nested fields: "parent.child.grandchild"
- Array indexing: - Array indexing:
- Specific index: `"array[0]"` - Specific index: "array[0]"
- Last element: `"array[-1]"` - Last element: "array[-1]"
- All elements (each individually): `"array[*]"` - All elements (each individually): "array[*]"
???+ example "Examples" ???+ example "Examples"
- None - Use store defaults (whole item)
- `None` - Use store defaults (whole item) - list[str] - List of fields to index
- `list[str]` - List of fields to index
```python ```python
[ [
@@ -530,12 +509,12 @@ class PutOp(NamedTuple):
will expire this many minutes after it was last accessed. The expiration timer will expire this many minutes after it was last accessed. The expiration timer
refreshes on both read operations (get/search) and write operations (put/update). refreshes on both read operations (get/search) and write operations (put/update).
When the TTL expires, the item will be scheduled for deletion on a best-effort basis. When the TTL expires, the item will be scheduled for deletion on a best-effort basis.
Defaults to `None` (no expiration). Defaults to None (no expiration).
""" """
Op = GetOp | SearchOp | PutOp | ListNamespacesOp Op = Union[GetOp, SearchOp, PutOp, ListNamespacesOp]
Result = Item | list[Item] | list[SearchItem] | list[tuple[str, ...]] | None Result = Union[Item, list[Item], list[SearchItem], list[tuple[str, ...]], None]
class InvalidNamespaceError(ValueError): class InvalidNamespaceError(ValueError):
@@ -546,18 +525,18 @@ class TTLConfig(TypedDict, total=False):
"""Configuration for TTL (time-to-live) behavior in the store.""" """Configuration for TTL (time-to-live) behavior in the store."""
refresh_on_read: bool refresh_on_read: bool
"""Default behavior for refreshing TTLs on read operations (`GET` and `SEARCH`). """Default behavior for refreshing TTLs on read operations (GET and SEARCH).
If `True`, TTLs will be refreshed on read operations (get/search) by default. If True, TTLs will be refreshed on read operations (get/search) by default.
This can be overridden per-operation by explicitly setting `refresh_ttl`. This can be overridden per-operation by explicitly setting refresh_ttl.
Defaults to `True` if not configured. Defaults to True if not configured.
""" """
default_ttl: float | None default_ttl: float | None
"""Default TTL (time-to-live) in minutes for new items. """Default TTL (time-to-live) in minutes for new items.
If provided, new items will expire after this many minutes after their last access. If provided, new items will expire after this many minutes after their last access.
The expiration timer refreshes on both read and write operations. The expiration timer refreshes on both read and write operations.
Defaults to `None` (no expiration). Defaults to None (no expiration).
""" """
sweep_interval_minutes: int | None sweep_interval_minutes: int | None
"""Interval in minutes between TTL sweep operations. """Interval in minutes between TTL sweep operations.
@@ -571,35 +550,33 @@ class IndexConfig(TypedDict, total=False):
"""Configuration for indexing documents for semantic search in the store. """Configuration for indexing documents for semantic search in the store.
If not provided to the store, the store will not support vector search. If not provided to the store, the store will not support vector search.
In that case, all `index` arguments to `put()` and `aput()` operations will be ignored. In that case, all `index` arguments to put() and `aput()` operations will be ignored.
""" """
dims: int dims: int
"""Number of dimensions in the embedding vectors. """Number of dimensions in the embedding vectors.
Common embedding models have the following dimensions: Common embedding models have the following dimensions:
- `openai:text-embedding-3-large`: `3072` - openai:text-embedding-3-large: 3072
- `openai:text-embedding-3-small`: `1536` - openai:text-embedding-3-small: 1536
- `openai:text-embedding-ada-002`: `1536` - openai:text-embedding-ada-002: 1536
- `cohere:embed-english-v3.0`: `1024` - cohere:embed-english-v3.0: 1024
- `cohere:embed-english-light-v3.0`: `384` - cohere:embed-english-light-v3.0: 384
- `cohere:embed-multilingual-v3.0`: `1024` - cohere:embed-multilingual-v3.0: 1024
- `cohere:embed-multilingual-light-v3.0`: `384` - cohere:embed-multilingual-light-v3.0: 384
""" """
embed: Embeddings | EmbeddingsFunc | AEmbeddingsFunc | str embed: Embeddings | EmbeddingsFunc | AEmbeddingsFunc | str
"""Optional function to generate embeddings from text. """Optional function to generate embeddings from text.
Can be specified in three ways: Can be specified in three ways:
1. A LangChain `Embeddings` instance 1. A LangChain Embeddings instance
2. A synchronous embedding function (`EmbeddingsFunc`) 2. A synchronous embedding function (EmbeddingsFunc)
3. An asynchronous embedding function (`AEmbeddingsFunc`) 3. An asynchronous embedding function (AEmbeddingsFunc)
4. A provider string (e.g., `"openai:text-embedding-3-small"`) 4. A provider string (e.g., "openai:text-embedding-3-small")
???+ example "Examples" ???+ example "Examples"
Using LangChain's initialization with InMemoryStore:
Using LangChain's initialization with `InMemoryStore`:
```python ```python
from langchain.embeddings import init_embeddings from langchain.embeddings import init_embeddings
from langgraph.store.memory import InMemoryStore from langgraph.store.memory import InMemoryStore
@@ -612,8 +589,7 @@ class IndexConfig(TypedDict, total=False):
) )
``` ```
Using a custom embedding function with `InMemoryStore`: Using a custom embedding function with InMemoryStore:
```python ```python
from openai import OpenAI from openai import OpenAI
from langgraph.store.memory import InMemoryStore from langgraph.store.memory import InMemoryStore
@@ -635,8 +611,7 @@ class IndexConfig(TypedDict, total=False):
) )
``` ```
Using an asynchronous embedding function with `InMemoryStore`: Using an asynchronous embedding function with InMemoryStore:
```python ```python
from openai import AsyncOpenAI from openai import AsyncOpenAI
from langgraph.store.memory import InMemoryStore from langgraph.store.memory import InMemoryStore
@@ -664,17 +639,16 @@ class IndexConfig(TypedDict, total=False):
Controls which parts of stored items are embedded for semantic search. Follows JSON path syntax: Controls which parts of stored items are embedded for semantic search. Follows JSON path syntax:
- `["$"]`: Embeds the entire JSON object as one vector (default) - ["$"]: Embeds the entire JSON object as one vector (default)
- `["field1", "field2"]`: Embeds specific top-level fields - ["field1", "field2"]: Embeds specific top-level fields
- `["parent.child"]`: Embeds nested fields using dot notation - ["parent.child"]: Embeds nested fields using dot notation
- `["array[*].field"]`: Embeds field from each array element separately - ["array[*].field"]: Embeds field from each array element separately
Note: Note:
You can always override this behavior when storing an item using the You can always override this behavior when storing an item using the
`index` parameter in the `put` or `aput` operations. `index` parameter in the `put` or `aput` operations.
???+ example "Examples" ???+ example "Examples"
```python ```python
# Embed entire document (default) # Embed entire document (default)
fields=["$"] fields=["$"]
@@ -693,7 +667,7 @@ class IndexConfig(TypedDict, total=False):
Note: Note:
- Fields missing from a document are skipped - Fields missing from a document are skipped
- Array notation creates separate embeddings for each element - Array notation creates separate embeddings for each element
- Complex nested paths are supported (e.g., `"a.b[*].c.d"`) - Complex nested paths are supported (e.g., "a.b[*].c.d")
""" """
@@ -758,11 +732,11 @@ class BaseStore(ABC):
namespace: Hierarchical path for the item. namespace: Hierarchical path for the item.
key: Unique identifier within the namespace. key: Unique identifier within the namespace.
refresh_ttl: Whether to refresh TTLs for the returned item. refresh_ttl: Whether to refresh TTLs for the returned item.
If `None`, uses the store's default `refresh_ttl` setting. If None (default), uses the store's default refresh_ttl setting.
If no TTL is specified, this argument is ignored. If no TTL is specified, this argument is ignored.
Returns: Returns:
The retrieved item or `None` if not found. The retrieved item or None if not found.
""" """
return self.batch( return self.batch(
[GetOp(namespace, str(key), _ensure_refresh(self.ttl_config, refresh_ttl))] [GetOp(namespace, str(key), _ensure_refresh(self.ttl_config, refresh_ttl))]
@@ -794,9 +768,7 @@ class BaseStore(ABC):
List of items matching the search criteria. List of items matching the search criteria.
???+ example "Examples" ???+ example "Examples"
Basic filtering: Basic filtering:
```python ```python
# Search for documents with specific metadata # Search for documents with specific metadata
results = store.search( results = store.search(
@@ -806,7 +778,6 @@ class BaseStore(ABC):
``` ```
Natural language search (requires vector store implementation): Natural language search (requires vector store implementation):
```python ```python
# Initialize store with embedding configuration # Initialize store with embedding configuration
store = YourStore( # e.g., InMemoryStore, AsyncPostgresStore store = YourStore( # e.g., InMemoryStore, AsyncPostgresStore
@@ -818,7 +789,6 @@ class BaseStore(ABC):
) )
# Search for semantically similar documents # Search for semantically similar documents
results = store.search( results = store.search(
("docs",), ("docs",),
query="machine learning applications in healthcare", query="machine learning applications in healthcare",
@@ -827,10 +797,8 @@ class BaseStore(ABC):
) )
``` ```
!!! note Note: Natural language search support depends on your store implementation
and requires proper embedding configuration.
Natural language search support depends on your store implementation
and requires proper embedding configuration.
""" """
return self.batch( return self.batch(
[ [
@@ -858,7 +826,7 @@ class BaseStore(ABC):
Args: Args:
namespace: Hierarchical path for the item, represented as a tuple of strings. namespace: Hierarchical path for the item, represented as a tuple of strings.
Example: `("documents", "user123")` Example: ("documents", "user123")
key: Unique identifier within the namespace. Together with namespace forms key: Unique identifier within the namespace. Together with namespace forms
the complete path to the item. the complete path to the item.
value: Dictionary containing the item's data. Must contain string keys value: Dictionary containing the item's data. Must contain string keys
@@ -869,10 +837,10 @@ class BaseStore(ABC):
If you do not initialize the store with indexing capabilities, If you do not initialize the store with indexing capabilities,
the `index` parameter will be ignored the `index` parameter will be ignored
- False: Disable indexing for this item - False: Disable indexing for this item
- `list[str]`: List of field paths to index, supporting: - list[str]: List of field paths to index, supporting:
- Nested fields: `"metadata.title"` - Nested fields: "metadata.title"
- Array access: `"chapters[*].content"` (each indexed separately) - Array access: "chapters[*].content" (each indexed separately)
- Specific indices: `"authors[0].name"` - Specific indices: "authors[0].name"
ttl: Time to live in minutes. Support for this argument depends on your store adapter. ttl: Time to live in minutes. Support for this argument depends on your store adapter.
If specified, the item will expire after this many minutes from when it was last accessed. If specified, the item will expire after this many minutes from when it was last accessed.
None means no expiration. Expired runs will be deleted opportunistically. None means no expiration. Expired runs will be deleted opportunistically.
@@ -888,22 +856,18 @@ class BaseStore(ABC):
Some implementations may not support expiration of items. Some implementations may not support expiration of items.
???+ example "Examples" ???+ example "Examples"
Store item. Indexing depends on how you configure the store.
Store item. Indexing depends on how you configure the store:
```python ```python
store.put(("docs",), "report", {"memory": "Will likes ai"}) store.put(("docs",), "report", {"memory": "Will likes ai"})
``` ```
Do not index item for semantic search. Still accessible through `get()` Do not index item for semantic search. Still accessible through get()
and `search()` operations but won't have a vector representation. and search() operations but won't have a vector representation.
```python ```python
store.put(("docs",), "report", {"memory": "Will likes ai"}, index=False) store.put(("docs",), "report", {"memory": "Will likes ai"}, index=False)
``` ```
Index specific fields for search: Index specific fields for search.
```python ```python
store.put(("docs",), "report", {"memory": "Will likes ai"}, index=["memory"]) store.put(("docs",), "report", {"memory": "Will likes ai"}, index=["memory"])
``` ```
@@ -954,17 +918,15 @@ class BaseStore(ABC):
suffix: Filter namespaces that end with this path. suffix: Filter namespaces that end with this path.
max_depth: Return namespaces up to this depth in the hierarchy. max_depth: Return namespaces up to this depth in the hierarchy.
Namespaces deeper than this level will be truncated. Namespaces deeper than this level will be truncated.
limit: Maximum number of namespaces to return. limit: Maximum number of namespaces to return (default 100).
offset: Number of namespaces to skip for pagination. offset: Number of namespaces to skip for pagination (default 0).
Returns: Returns:
A list of namespace tuples that match the criteria. Each tuple represents a List[Tuple[str, ...]]: A list of namespace tuples that match the criteria.
full namespace path up to `max_depth`. Each tuple represents a full namespace path up to `max_depth`.
???+ example "Examples": ???+ example "Examples":
Setting max_depth=3. Given the namespaces:
Setting `max_depth=3`. Given the namespaces:
```python ```python
# Example if you have the following namespaces: # Example if you have the following namespaces:
# ("a", "b", "c") # ("a", "b", "c")
@@ -1004,7 +966,7 @@ class BaseStore(ABC):
key: Unique identifier within the namespace. key: Unique identifier within the namespace.
Returns: Returns:
The retrieved item or `None` if not found. The retrieved item or None if not found.
""" """
return ( return (
await self.abatch( await self.abatch(
@@ -1038,16 +1000,14 @@ class BaseStore(ABC):
limit: Maximum number of items to return. limit: Maximum number of items to return.
offset: Number of items to skip before returning results. offset: Number of items to skip before returning results.
refresh_ttl: Whether to refresh TTLs for the returned items. refresh_ttl: Whether to refresh TTLs for the returned items.
If `None`, uses the store's `TTLConfig.refresh_default` setting. If None (default), uses the store's TTLConfig.refresh_default setting.
If `TTLConfig` is not provided or no TTL is specified, this argument is ignored. If TTLConfig is not provided or no TTL is specified, this argument is ignored.
Returns: Returns:
List of items matching the search criteria. List of items matching the search criteria.
???+ example "Examples" ???+ example "Examples"
Basic filtering: Basic filtering:
```python ```python
# Search for documents with specific metadata # Search for documents with specific metadata
results = await store.asearch( results = await store.asearch(
@@ -1057,7 +1017,6 @@ class BaseStore(ABC):
``` ```
Natural language search (requires vector store implementation): Natural language search (requires vector store implementation):
```python ```python
# Initialize store with embedding configuration # Initialize store with embedding configuration
store = YourStore( # e.g., InMemoryStore, AsyncPostgresStore store = YourStore( # e.g., InMemoryStore, AsyncPostgresStore
@@ -1069,7 +1028,6 @@ class BaseStore(ABC):
) )
# Search for semantically similar documents # Search for semantically similar documents
results = await store.asearch( results = await store.asearch(
("docs",), ("docs",),
query="machine learning applications in healthcare", query="machine learning applications in healthcare",
@@ -1078,10 +1036,8 @@ class BaseStore(ABC):
) )
``` ```
!!! note Note: Natural language search support depends on your store implementation
and requires proper embedding configuration.
Natural language search support depends on your store implementation
and requires proper embedding configuration.
""" """
return ( return (
await self.abatch( await self.abatch(
@@ -1111,7 +1067,7 @@ class BaseStore(ABC):
Args: Args:
namespace: Hierarchical path for the item, represented as a tuple of strings. namespace: Hierarchical path for the item, represented as a tuple of strings.
Example: `("documents", "user123")` Example: ("documents", "user123")
key: Unique identifier within the namespace. Together with namespace forms key: Unique identifier within the namespace. Together with namespace forms
the complete path to the item. the complete path to the item.
value: Dictionary containing the item's data. Must contain string keys value: Dictionary containing the item's data. Must contain string keys
@@ -1122,10 +1078,10 @@ class BaseStore(ABC):
If you do not initialize the store with indexing capabilities, If you do not initialize the store with indexing capabilities,
the `index` parameter will be ignored the `index` parameter will be ignored
- False: Disable indexing for this item - False: Disable indexing for this item
- `list[str]`: List of field paths to index, supporting: - list[str]: List of field paths to index, supporting:
- Nested fields: `"metadata.title"` - Nested fields: "metadata.title"
- Array access: `"chapters[*].content"` (each indexed separately) - Array access: "chapters[*].content" (each indexed separately)
- Specific indices: `"authors[0].name"` - Specific indices: "authors[0].name"
ttl: Time to live in minutes. Support for this argument depends on your store adapter. ttl: Time to live in minutes. Support for this argument depends on your store adapter.
If specified, the item will expire after this many minutes from when it was last accessed. If specified, the item will expire after this many minutes from when it was last accessed.
None means no expiration. Expired runs will be deleted opportunistically. None means no expiration. Expired runs will be deleted opportunistically.
@@ -1141,22 +1097,18 @@ class BaseStore(ABC):
Some implementations may not support expiration of items. Some implementations may not support expiration of items.
???+ example "Examples" ???+ example "Examples"
Store item. Indexing depends on how you configure the store.
Store item. Indexing depends on how you configure the store:
```python ```python
await store.aput(("docs",), "report", {"memory": "Will likes ai"}) await store.aput(("docs",), "report", {"memory": "Will likes ai"})
``` ```
Do not index item for semantic search. Still accessible through `get()` Do not index item for semantic search. Still accessible through get()
and `search()` operations but won't have a vector representation. and search() operations but won't have a vector representation.
```python ```python
await store.aput(("docs",), "report", {"memory": "Will likes ai"}, index=False) await store.aput(("docs",), "report", {"memory": "Will likes ai"}, index=False)
``` ```
Index specific fields for search (if store configured to index items): Index specific fields for search (if store configured to index items):
```python ```python
await store.aput( await store.aput(
("docs",), ("docs",),
@@ -1215,16 +1167,15 @@ class BaseStore(ABC):
suffix: Filter namespaces that end with this path. suffix: Filter namespaces that end with this path.
max_depth: Return namespaces up to this depth in the hierarchy. max_depth: Return namespaces up to this depth in the hierarchy.
Namespaces deeper than this level will be truncated to this depth. Namespaces deeper than this level will be truncated to this depth.
limit: Maximum number of namespaces to return. limit: Maximum number of namespaces to return (default 100).
offset: Number of namespaces to skip for pagination. offset: Number of namespaces to skip for pagination (default 0).
Returns: Returns:
A list of namespace tuples that match the criteria. Each tuple represents a List[Tuple[str, ...]]: A list of namespace tuples that match the criteria.
full namespace path up to `max_depth`. Each tuple represents a full namespace path up to `max_depth`.
???+ example "Examples" ???+ example "Examples"
Setting max_depth=3 with existing namespaces:
Setting `max_depth=3` with existing namespaces:
```python ```python
# Given the following namespaces: # Given the following namespaces:
# ("a", "b", "c") # ("a", "b", "c")
@@ -5,8 +5,8 @@ from __future__ import annotations
import asyncio import asyncio
import functools import functools
import weakref import weakref
from collections.abc import Callable, Iterable from collections.abc import Iterable
from typing import Any, Literal, TypeVar from typing import Any, Callable, Literal, TypeVar
from langgraph.store.base import ( from langgraph.store.base import (
NOT_PROVIDED, NOT_PROVIDED,
@@ -349,7 +349,7 @@ async def _run(
results = [results[ix] for ix in listen] results = [results[ix] for ix in listen]
# set the results of each operation # set the results of each operation
for fut, result in zip(futs, results, strict=False): for fut, result in zip(futs, results):
# guard against future being done (e.g. cancelled) # guard against future being done (e.g. cancelled)
if not fut.done(): if not fut.done():
fut.set_result(result) fut.set_result(result)
+5 -16
View File
@@ -11,8 +11,8 @@ from __future__ import annotations
import asyncio import asyncio
import functools import functools
import json import json
from collections.abc import Awaitable, Callable, Sequence from collections.abc import Awaitable, Sequence
from typing import Any from typing import Any, Callable
from langchain_core.embeddings import Embeddings from langchain_core.embeddings import Embeddings
@@ -49,9 +49,7 @@ def ensure_embeddings(
An Embeddings instance that wraps the provided function(s). An Embeddings instance that wraps the provided function(s).
??? example "Examples" ??? example "Examples"
Wrap a synchronous embedding function: Wrap a synchronous embedding function:
```python ```python
def my_embed_fn(texts): def my_embed_fn(texts):
return [[0.1, 0.2] for _ in texts] return [[0.1, 0.2] for _ in texts]
@@ -61,7 +59,6 @@ def ensure_embeddings(
``` ```
Wrap an asynchronous embedding function: Wrap an asynchronous embedding function:
```python ```python
async def my_async_fn(texts): async def my_async_fn(texts):
return [[0.1, 0.2] for _ in texts] return [[0.1, 0.2] for _ in texts]
@@ -71,7 +68,6 @@ def ensure_embeddings(
``` ```
Initialize embeddings using a provider string: Initialize embeddings using a provider string:
```python ```python
# Requires langchain>=0.3.9 and langgraph-checkpoint>=2.0.11 # Requires langchain>=0.3.9 and langgraph-checkpoint>=2.0.11
embeddings = ensure_embeddings("openai:text-embedding-3-small") embeddings = ensure_embeddings("openai:text-embedding-3-small")
@@ -123,9 +119,7 @@ class EmbeddingsLambda(Embeddings):
will raise an error. If sync, it will be used for both sync and async operations. will raise an error. If sync, it will be used for both sync and async operations.
??? example "Examples" ??? example "Examples"
With a sync function: With a sync function:
```python ```python
def my_embed_fn(texts): def my_embed_fn(texts):
# Return 2D embeddings for each text # Return 2D embeddings for each text
@@ -137,7 +131,6 @@ class EmbeddingsLambda(Embeddings):
``` ```
With an async function: With an async function:
```python ```python
async def my_async_fn(texts): async def my_async_fn(texts):
return [[0.1, 0.2] for _ in texts] return [[0.1, 0.2] for _ in texts]
@@ -245,7 +238,7 @@ def get_text_at_path(obj: Any, path: str | list[str]) -> list[str]:
- Nested paths in multi-field: "{field1,nested.field2}" - Nested paths in multi-field: "{field1,nested.field2}"
""" """
if not path or path == "$": if not path or path == "$":
return [json.dumps(obj, sort_keys=True, ensure_ascii=False)] return [json.dumps(obj, sort_keys=True)]
tokens = tokenize_path(path) if isinstance(path, str) else path tokens = tokenize_path(path) if isinstance(path, str) else path
@@ -256,7 +249,7 @@ def get_text_at_path(obj: Any, path: str | list[str]) -> list[str]:
elif obj is None: elif obj is None:
return [] return []
elif isinstance(obj, (list, dict)): elif isinstance(obj, (list, dict)):
return [json.dumps(obj, sort_keys=True, ensure_ascii=False)] return [json.dumps(obj, sort_keys=True)]
return [] return []
token = tokens[pos] token = tokens[pos]
@@ -302,11 +295,7 @@ def get_text_at_path(obj: Any, path: str | list[str]) -> list[str]:
if isinstance(current_obj, (str, int, float, bool)): if isinstance(current_obj, (str, int, float, bool)):
results.append(str(current_obj)) results.append(str(current_obj))
elif isinstance(current_obj, (list, dict)): elif isinstance(current_obj, (list, dict)):
results.append( results.append(json.dumps(current_obj, sort_keys=True))
json.dumps(
current_obj, sort_keys=True, ensure_ascii=False
)
)
# Handle wildcard # Handle wildcard
elif token == "*": elif token == "*":
@@ -295,7 +295,7 @@ class InMemoryStore(BaseStore):
if queries: if queries:
coros = [self.embeddings.aembed_query(q) for q in list(queries)] coros = [self.embeddings.aembed_query(q) for q in list(queries)]
results = await asyncio.gather(*coros) results = await asyncio.gather(*coros)
queryinmem_store = dict(zip(queries, results, strict=False)) queryinmem_store = dict(zip(queries, results))
return queryinmem_store return queryinmem_store
@@ -323,9 +323,7 @@ class InMemoryStore(BaseStore):
scores = _cosine_similarity(query_embedding, flat_vectors) scores = _cosine_similarity(query_embedding, flat_vectors)
sorted_results = sorted( sorted_results = sorted(
zip(scores, flat_items, strict=False), zip(scores, flat_items), key=lambda x: x[0], reverse=True
key=lambda x: x[0],
reverse=True,
) )
# max pooling # max pooling
seen: set[tuple[tuple[str, ...], str]] = set() seen: set[tuple[tuple[str, ...], str]] = set()
@@ -454,7 +452,7 @@ class InMemoryStore(BaseStore):
f"Number of embeddings ({len(embeddings)}) does not" f"Number of embeddings ({len(embeddings)}) does not"
f" match number of indices ({len(indices)})" f" match number of indices ({len(indices)})"
) )
for embedding, (ns, key, path) in zip(embeddings, indices, strict=False): for embedding, (ns, key, path) in zip(embeddings, indices):
self._vectors[ns][key][path] = embedding self._vectors[ns][key][path] = embedding
def _handle_list_namespaces(self, op: ListNamespacesOp) -> list[tuple[str, ...]]: def _handle_list_namespaces(self, op: ListNamespacesOp) -> list[tuple[str, ...]]:
@@ -513,7 +511,7 @@ def _cosine_similarity(X: list[float], Y: list[list[float]]) -> list[float]:
similarities = [] similarities = []
for y in Y: for y in Y:
dot_product = sum(a * b for a, b in zip(X, y, strict=False)) dot_product = sum(a * b for a, b in zip(X, y))
norm1 = sum(a * a for a in X) ** 0.5 norm1 = sum(a * a for a in X) ** 0.5
norm2 = sum(a * a for a in y) ** 0.5 norm2 = sum(a * a for a in y) ** 0.5
similarity = dot_product / (norm1 * norm2) if norm1 > 0 and norm2 > 0 else 0.0 similarity = dot_product / (norm1 * norm2) if norm1 > 0 and norm2 > 0 else 0.0
@@ -531,14 +529,14 @@ def _does_match(match_condition: MatchCondition, key: tuple[str, ...]) -> bool:
return False return False
if match_type == "prefix": if match_type == "prefix":
for k_elem, p_elem in zip(key, path, strict=False): for k_elem, p_elem in zip(key, path):
if p_elem == "*": if p_elem == "*":
continue # Wildcard matches any element continue # Wildcard matches any element
if k_elem != p_elem: if k_elem != p_elem:
return False return False
return True return True
elif match_type == "suffix": elif match_type == "suffix":
for k_elem, p_elem in zip(reversed(key), reversed(path), strict=False): for k_elem, p_elem in zip(reversed(key), reversed(path)):
if p_elem == "*": if p_elem == "*":
continue # Wildcard matches any element continue # Wildcard matches any element
if k_elem != p_elem: if k_elem != p_elem:
@@ -565,10 +563,7 @@ def _compare_values(item_value: Any, filter_value: Any) -> bool:
return ( return (
isinstance(item_value, (list, tuple)) isinstance(item_value, (list, tuple))
and len(item_value) == len(filter_value) and len(item_value) == len(filter_value)
and all( and all(_compare_values(iv, fv) for iv, fv in zip(item_value, filter_value))
_compare_values(iv, fv)
for iv, fv in zip(item_value, filter_value, strict=False)
)
) )
else: else:
return item_value == filter_value return item_value == filter_value
+8 -19
View File
@@ -4,45 +4,36 @@ build-backend = "hatchling.build"
[project] [project]
name = "langgraph-checkpoint" name = "langgraph-checkpoint"
version = "4.0.0" version = "2.1.1"
description = "Library with base interfaces for LangGraph checkpoint savers." description = "Library with base interfaces for LangGraph checkpoint savers."
authors = [] authors = []
requires-python = ">=3.10" requires-python = ">=3.9"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
license-files = ['LICENSE'] license-files = ['LICENSE']
dependencies = [ dependencies = [
"langchain-core>=0.2.38", "langchain-core>=0.2.38",
"ormsgpack>=1.12.0", "ormsgpack>=1.10.0",
] ]
[project.urls] [project.urls]
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint" Repository = "https://www.github.com/langchain-ai/langgraph"
Twitter = "https://x.com/LangChainAI"
Slack = "https://www.langchain.com/join-community"
Reddit = "https://www.reddit.com/r/LangChain/"
[dependency-groups] [dependency-groups]
test = [ dev = [
"ruff",
"codespell",
"pytest", "pytest",
"pytest-asyncio", "pytest-asyncio",
"pytest-mock", "pytest-mock",
"pytest-watcher", "pytest-watcher",
"mypy",
"dataclasses-json", "dataclasses-json",
"numpy", "numpy",
"pandas", "pandas",
"pandas-stubs>=2.2.2.240807", "pandas-stubs>=2.2.2.240807",
"redis", "redis",
] ]
lint = [
"ruff",
"codespell",
"mypy",
]
dev = [
{include-group = "test"},
{include-group = "lint"},
]
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
include = ["langgraph"] include = ["langgraph"]
@@ -58,10 +49,8 @@ lint.select = [
"UP", # pyupgrade "UP", # pyupgrade
"B", # flake8-bugbear "B", # flake8-bugbear
"I", # isort "I", # isort
"UP", # pyupgrade
] ]
lint.ignore = ["E501", "B008"] lint.ignore = ["E501", "B008"]
target-version = "py310"
[tool.pytest-watcher] [tool.pytest-watcher]
now = true now = true
+43 -88
View File
@@ -1,5 +1,4 @@
import dataclasses import dataclasses
import json
import pathlib import pathlib
import re import re
import sys import sys
@@ -20,7 +19,6 @@ from pydantic.v1 import BaseModel as BaseModelV1
from pydantic.v1 import SecretStr as SecretStrV1 from pydantic.v1 import SecretStr as SecretStrV1
from langgraph.checkpoint.serde.jsonplus import ( from langgraph.checkpoint.serde.jsonplus import (
InvalidModuleError,
JsonPlusSerializer, JsonPlusSerializer,
_msgpack_ext_hook_to_json, _msgpack_ext_hook_to_json,
) )
@@ -62,15 +60,22 @@ class MyDataclass:
pass pass
@dataclasses.dataclass(slots=True) if sys.version_info < (3, 10):
class MyDataclassWSlots:
foo: str
bar: int
inner: InnerDataclass
def something(self) -> None: class MyDataclassWSlots(MyDataclass):
pass pass
else:
@dataclasses.dataclass(slots=True)
class MyDataclassWSlots:
foo: str
bar: int
inner: InnerDataclass
def something(self) -> None:
pass
class MyEnum(Enum): class MyEnum(Enum):
FOO = "foo" FOO = "foo"
@@ -110,7 +115,11 @@ def test_serde_jsonplus() -> None:
"my_dataclass": MyDataclass("foo", 1, InnerDataclass("hello")), "my_dataclass": MyDataclass("foo", 1, InnerDataclass("hello")),
"my_enum": MyEnum.FOO, "my_enum": MyEnum.FOO,
"my_pydantic": MyPydantic(foo="foo", bar=1, inner=InnerPydantic(hello="hello")), "my_pydantic": MyPydantic(foo="foo", bar=1, inner=InnerPydantic(hello="hello")),
"my_pydantic_v1": MyPydanticV1(
foo="foo", bar=1, inner=InnerPydanticV1(hello="hello")
),
"my_secret_str": SecretStr("meow"), "my_secret_str": SecretStr("meow"),
"my_secret_str_v1": SecretStrV1("meow"),
"person": Person(name="foo"), "person": Person(name="foo"),
"a_bool": True, "a_bool": True,
"a_none": None, "a_none": None,
@@ -132,12 +141,6 @@ def test_serde_jsonplus() -> None:
), ),
} }
if sys.version_info < (3, 14):
to_serialize["my_pydantic_v1"] = MyPydanticV1(
foo="foo", bar=1, inner=InnerPydanticV1(hello="hello")
)
to_serialize["my_secret_str_v1"] = SecretStrV1("meow")
serde = JsonPlusSerializer() serde = JsonPlusSerializer()
dumped = serde.dumps_typed(to_serialize) dumped = serde.dumps_typed(to_serialize)
@@ -149,22 +152,23 @@ def test_serde_jsonplus() -> None:
assert serde.loads_typed(serde.dumps_typed(value)) == value assert serde.loads_typed(serde.dumps_typed(value)) == value
surrogates = [ surrogates = [
"Hello??", "Hello\ud83d\ude00",
"Python??", "Python\ud83d\udc0d",
"Surrogate??", "Surrogate\ud834\udd1e",
"Example??", "Example\ud83c\udf89",
"String??", "String\ud83c\udfa7",
"With??", "With\ud83c\udf08",
"Surrogates??", "Surrogates\ud83d\ude0e",
"Embedded??", "Embedded\ud83d\udcbb",
"In??", "In\ud83c\udf0e",
"The??", "The\ud83d\udcd6",
"Text??", "Text\ud83d\udcac",
"收花🙄·到", "收花🙄·到",
] ]
serde = JsonPlusSerializer(pickle_fallback=False)
assert serde.loads_typed(serde.dumps_typed(surrogates)) == surrogates assert serde.loads_typed(serde.dumps_typed(surrogates)) == [
v.encode("utf-8", "ignore").decode() for v in surrogates
]
def test_serde_jsonplus_json_mode() -> None: def test_serde_jsonplus_json_mode() -> None:
@@ -193,7 +197,11 @@ def test_serde_jsonplus_json_mode() -> None:
"my_dataclass": MyDataclass("foo", 1, InnerDataclass("hello")), "my_dataclass": MyDataclass("foo", 1, InnerDataclass("hello")),
"my_enum": MyEnum.FOO, "my_enum": MyEnum.FOO,
"my_pydantic": MyPydantic(foo="foo", bar=1, inner=InnerPydantic(hello="hello")), "my_pydantic": MyPydantic(foo="foo", bar=1, inner=InnerPydantic(hello="hello")),
"my_pydantic_v1": MyPydanticV1(
foo="foo", bar=1, inner=InnerPydanticV1(hello="hello")
),
"my_secret_str": SecretStr("meow"), "my_secret_str": SecretStr("meow"),
"my_secret_str_v1": SecretStrV1("meow"),
"person": Person(name="foo"), "person": Person(name="foo"),
"a_bool": True, "a_bool": True,
"a_none": None, "a_none": None,
@@ -215,20 +223,13 @@ def test_serde_jsonplus_json_mode() -> None:
), ),
} }
if sys.version_info < (3, 14):
to_serialize["my_pydantic_v1"] = MyPydanticV1(
foo="foo", bar=1, inner=InnerPydanticV1(hello="hello")
)
to_serialize["my_secret_str_v1"] = SecretStrV1("meow")
serde = JsonPlusSerializer(__unpack_ext_hook__=_msgpack_ext_hook_to_json) serde = JsonPlusSerializer(__unpack_ext_hook__=_msgpack_ext_hook_to_json)
dumped = serde.dumps_typed(to_serialize) dumped = serde.dumps_typed(to_serialize)
assert dumped[0] == "msgpack" assert dumped[0] == "msgpack"
result = serde.loads_typed(dumped) result = serde.loads_typed(dumped)
assert result == {
expected_result = {
"path": ["foo", "bar"], "path": ["foo", "bar"],
"re": ["foo", 48], "re": ["foo", 48],
"decimal": "1.10101", "decimal": "1.10101",
@@ -252,7 +253,9 @@ def test_serde_jsonplus_json_mode() -> None:
"my_dataclass": {"foo": "foo", "bar": 1, "inner": {"hello": "hello"}}, "my_dataclass": {"foo": "foo", "bar": 1, "inner": {"hello": "hello"}},
"my_enum": "foo", "my_enum": "foo",
"my_pydantic": {"foo": "foo", "bar": 1, "inner": {"hello": "hello"}}, "my_pydantic": {"foo": "foo", "bar": 1, "inner": {"hello": "hello"}},
"my_pydantic_v1": {"foo": "foo", "bar": 1, "inner": {"hello": "hello"}},
"my_secret_str": "meow", "my_secret_str": "meow",
"my_secret_str_v1": "meow",
"person": {"name": "foo"}, "person": {"name": "foo"},
"a_bool": True, "a_bool": True,
"a_none": None, "a_none": None,
@@ -274,16 +277,6 @@ def test_serde_jsonplus_json_mode() -> None:
}, },
} }
if sys.version_info < (3, 14):
expected_result["my_pydantic_v1"] = {
"foo": "foo",
"bar": 1,
"inner": {"hello": "hello"},
}
expected_result["my_secret_str_v1"] = "meow"
assert result == expected_result
def test_serde_jsonplus_bytes() -> None: def test_serde_jsonplus_bytes() -> None:
serde = JsonPlusSerializer() serde = JsonPlusSerializer()
@@ -295,20 +288,6 @@ def test_serde_jsonplus_bytes() -> None:
assert serde.loads_typed(dumped) == some_bytes assert serde.loads_typed(dumped) == some_bytes
def test_deserde_invalid_module() -> None:
serde = JsonPlusSerializer()
load = {
"lc": 2,
"type": "constructor",
"id": ["pprint", "pprint"],
"kwargs": {"object": "HELLO"},
}
with pytest.raises(InvalidModuleError):
serde._revive_lc2(load)
serde = JsonPlusSerializer(allowed_json_modules=[("pprint", "pprint")])
serde.loads_typed(("json", json.dumps(load).encode("utf-8")))
def test_serde_jsonplus_bytearray() -> None: def test_serde_jsonplus_bytearray() -> None:
serde = JsonPlusSerializer() serde = JsonPlusSerializer()
@@ -385,12 +364,7 @@ def test_serde_jsonplus_numpy_array_json_hook(arr: np.ndarray) -> None:
"str_col": ["a", None, "c"], "str_col": ["a", None, "c"],
} }
), ),
pytest.param( pd.DataFrame({"cat_col": pd.Categorical(["a", "b", "a", "c"])}),
pd.DataFrame({"cat_col": pd.Categorical(["a", "b", "a", "c"])}),
marks=pytest.mark.skipif(
sys.version_info >= (3, 14), reason="NotImplementedError on Python 3.14"
),
),
pd.DataFrame( pd.DataFrame(
{ {
"int8": pd.array([1, 2, 3], dtype="int8"), "int8": pd.array([1, 2, 3], dtype="int8"),
@@ -418,25 +392,11 @@ def test_serde_jsonplus_numpy_array_json_hook(arr: np.ndarray) -> None:
"col3": np.random.rand(1000), "col3": np.random.rand(1000),
} }
), ),
pytest.param( pd.DataFrame(
pd.DataFrame( {"tz_datetime": pd.date_range("2024-01-01", periods=3, freq="D", tz="UTC")}
{
"tz_datetime": pd.date_range(
"2024-01-01", periods=3, freq="D", tz="UTC"
)
}
),
marks=pytest.mark.skipif(
sys.version_info >= (3, 14), reason="NotImplementedError on Python 3.14"
),
), ),
pd.DataFrame({"timedelta": pd.to_timedelta([1, 2, 3], unit="D")}), pd.DataFrame({"timedelta": pd.to_timedelta([1, 2, 3], unit="D")}),
pytest.param( pd.DataFrame({"period": pd.period_range("2024-01", periods=3, freq="M")}),
pd.DataFrame({"period": pd.period_range("2024-01", periods=3, freq="M")}),
marks=pytest.mark.skipif(
sys.version_info >= (3, 14), reason="NotImplementedError on Python 3.14"
),
),
pd.DataFrame({"interval": pd.interval_range(start=0, end=3, periods=3)}), pd.DataFrame({"interval": pd.interval_range(start=0, end=3, periods=3)}),
pd.DataFrame({"unicode": ["Hello 🌍", "Python 🐍", "Data 📊"]}), pd.DataFrame({"unicode": ["Hello 🌍", "Python 🐍", "Data 📊"]}),
pd.DataFrame({"mixed": [1, "string", [1, 2, 3], {"key": "value"}]}), pd.DataFrame({"mixed": [1, "string", [1, 2, 3], {"key": "value"}]}),
@@ -473,12 +433,7 @@ def test_serde_jsonplus_pandas_dataframe(df: pd.DataFrame) -> None:
pd.Series([1, 2, None]), pd.Series([1, 2, None]),
pd.Series([1.1, None, 3.3]), pd.Series([1.1, None, 3.3]),
pd.Series(["a", None, "c"]), pd.Series(["a", None, "c"]),
pytest.param( pd.Series(pd.Categorical(["a", "b", "a", "c"])),
pd.Series(pd.Categorical(["a", "b", "a", "c"])),
marks=pytest.mark.skipif(
sys.version_info >= (3, 14), reason="NotImplementedError on Python 3.14"
),
),
pd.Series([1, 2, 3], dtype="int8"), pd.Series([1, 2, 3], dtype="int8"),
pd.Series([10, 20, 30], dtype="int16"), pd.Series([10, 20, 30], dtype="int16"),
pd.Series([100, 200, 300], dtype="int32"), pd.Series([100, 200, 300], dtype="int32"),
+2 -9
View File
@@ -188,14 +188,7 @@ class TestMemorySaver:
assert len(search_results_4) == 0 assert len(search_results_4) == 0
async def test_memory_saver() -> None: def test_memory_saver() -> None:
from langgraph.checkpoint.memory import InMemorySaver from langgraph.checkpoint.memory import InMemorySaver
memory_saver = InMemorySaver() assert isinstance(InMemorySaver(), InMemorySaver)
assert isinstance(memory_saver, InMemorySaver)
async with memory_saver as async_memory_saver:
assert async_memory_saver is memory_saver
with memory_saver as sync_memory_saver:
assert sync_memory_saver is memory_saver
+42 -49
View File
@@ -5,13 +5,12 @@ import time
import pytest import pytest
import redis import redis
from langgraph.cache.base import FullKey
from langgraph.cache.redis import RedisCache from langgraph.cache.redis import RedisCache
class TestRedisCache: class TestRedisCache:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup(self) -> None: def setup(self):
"""Set up test Redis client and cache.""" """Set up test Redis client and cache."""
self.client = redis.Redis( self.client = redis.Redis(
host="localhost", port=6379, db=0, decode_responses=False host="localhost", port=6379, db=0, decode_responses=False
@@ -21,21 +20,21 @@ class TestRedisCache:
except redis.ConnectionError: except redis.ConnectionError:
pytest.skip("Redis server not available") pytest.skip("Redis server not available")
self.cache: RedisCache = RedisCache(self.client, prefix="test:cache:") self.cache = RedisCache(self.client, prefix="test:cache:")
# Clean up before each test # Clean up before each test
self.client.flushdb() self.client.flushdb()
def teardown_method(self) -> None: def teardown_method(self):
"""Clean up after each test.""" """Clean up after each test."""
try: try:
self.client.flushdb() self.client.flushdb()
except Exception: except Exception:
pass pass
def test_basic_set_and_get(self) -> None: def test_basic_set_and_get(self):
"""Test basic set and get operations.""" """Test basic set and get operations."""
keys: list[FullKey] = [(("graph", "node"), "key1")] keys = [(("graph", "node"), "key1")]
values = {keys[0]: ({"result": 42}, None)} values = {keys[0]: ({"result": 42}, None)}
# Set value # Set value
@@ -46,9 +45,9 @@ class TestRedisCache:
assert len(result) == 1 assert len(result) == 1
assert result[keys[0]] == {"result": 42} assert result[keys[0]] == {"result": 42}
def test_batch_operations(self) -> None: def test_batch_operations(self):
"""Test batch set and get operations.""" """Test batch set and get operations."""
keys: list[FullKey] = [ keys = [
(("graph", "node1"), "key1"), (("graph", "node1"), "key1"),
(("graph", "node2"), "key2"), (("graph", "node2"), "key2"),
(("other", "node"), "key3"), (("other", "node"), "key3"),
@@ -69,9 +68,9 @@ class TestRedisCache:
assert result[keys[1]] == {"result": 2} assert result[keys[1]] == {"result": 2}
assert result[keys[2]] == {"result": 3} assert result[keys[2]] == {"result": 3}
def test_ttl_behavior(self) -> None: def test_ttl_behavior(self):
"""Test TTL (time-to-live) functionality.""" """Test TTL (time-to-live) functionality."""
key: FullKey = (("graph", "node"), "ttl_key") key = (("graph", "node"), "ttl_key")
values = {key: ({"data": "expires_soon"}, 1)} # 1 second TTL values = {key: ({"data": "expires_soon"}, 1)} # 1 second TTL
# Set with TTL # Set with TTL
@@ -89,10 +88,10 @@ class TestRedisCache:
result = self.cache.get([key]) result = self.cache.get([key])
assert len(result) == 0 assert len(result) == 0
def test_namespace_isolation(self) -> None: def test_namespace_isolation(self):
"""Test that different namespaces are isolated.""" """Test that different namespaces are isolated."""
key1: FullKey = (("graph1", "node"), "same_key") key1 = (("graph1", "node"), "same_key")
key2: FullKey = (("graph2", "node"), "same_key") key2 = (("graph2", "node"), "same_key")
values = {key1: ({"graph": 1}, None), key2: ({"graph": 2}, None)} values = {key1: ({"graph": 1}, None), key2: ({"graph": 2}, None)}
@@ -102,12 +101,9 @@ class TestRedisCache:
assert result[key1] == {"graph": 1} assert result[key1] == {"graph": 1}
assert result[key2] == {"graph": 2} assert result[key2] == {"graph": 2}
def test_clear_all(self) -> None: def test_clear_all(self):
"""Test clearing all cached values.""" """Test clearing all cached values."""
keys: list[FullKey] = [ keys = [(("graph", "node1"), "key1"), (("graph", "node2"), "key2")]
(("graph", "node1"), "key1"),
(("graph", "node2"), "key2"),
]
values = {keys[0]: ({"result": 1}, None), keys[1]: ({"result": 2}, None)} values = {keys[0]: ({"result": 1}, None), keys[1]: ({"result": 2}, None)}
self.cache.set(values) self.cache.set(values)
@@ -123,9 +119,9 @@ class TestRedisCache:
result = self.cache.get(keys) result = self.cache.get(keys)
assert len(result) == 0 assert len(result) == 0
def test_clear_by_namespace(self) -> None: def test_clear_by_namespace(self):
"""Test clearing cached values by namespace.""" """Test clearing cached values by namespace."""
keys: list[FullKey] = [ keys = [
(("graph1", "node"), "key1"), (("graph1", "node"), "key1"),
(("graph2", "node"), "key2"), (("graph2", "node"), "key2"),
(("graph1", "other"), "key3"), (("graph1", "other"), "key3"),
@@ -146,7 +142,7 @@ class TestRedisCache:
assert len(result) == 1 assert len(result) == 1
assert result[keys[1]] == {"result": 2} assert result[keys[1]] == {"result": 2}
def test_empty_operations(self) -> None: def test_empty_operations(self):
"""Test behavior with empty keys/values.""" """Test behavior with empty keys/values."""
# Empty get # Empty get
result = self.cache.get([]) result = self.cache.get([])
@@ -155,14 +151,14 @@ class TestRedisCache:
# Empty set # Empty set
self.cache.set({}) # Should not raise error self.cache.set({}) # Should not raise error
def test_nonexistent_keys(self) -> None: def test_nonexistent_keys(self):
"""Test getting keys that don't exist.""" """Test getting keys that don't exist."""
keys: list[FullKey] = [(("graph", "node"), "nonexistent")] keys = [(("graph", "node"), "nonexistent")]
result = self.cache.get(keys) result = self.cache.get(keys)
assert len(result) == 0 assert len(result) == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_async_operations(self) -> None: async def test_async_operations(self):
"""Test async set and get operations with sync Redis client.""" """Test async set and get operations with sync Redis client."""
# Create sync Redis client and cache (like main integration tests) # Create sync Redis client and cache (like main integration tests)
client = redis.Redis(host="localhost", port=6379, db=1, decode_responses=False) client = redis.Redis(host="localhost", port=6379, db=1, decode_responses=False)
@@ -171,9 +167,9 @@ class TestRedisCache:
except Exception: except Exception:
pytest.skip("Redis not available") pytest.skip("Redis not available")
cache: RedisCache = RedisCache(client, prefix="test:async:") cache = RedisCache(client, prefix="test:async:")
keys: list[FullKey] = [(("graph", "node"), "async_key")] keys = [(("graph", "node"), "async_key")]
values = {keys[0]: ({"async": True}, None)} values = {keys[0]: ({"async": True}, None)}
# Async set (delegates to sync) # Async set (delegates to sync)
@@ -188,7 +184,7 @@ class TestRedisCache:
client.flushdb() client.flushdb()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_async_clear(self) -> None: async def test_async_clear(self):
"""Test async clear operations with sync Redis client.""" """Test async clear operations with sync Redis client."""
# Create sync Redis client and cache (like main integration tests) # Create sync Redis client and cache (like main integration tests)
client = redis.Redis(host="localhost", port=6379, db=1, decode_responses=False) client = redis.Redis(host="localhost", port=6379, db=1, decode_responses=False)
@@ -197,9 +193,9 @@ class TestRedisCache:
except Exception: except Exception:
pytest.skip("Redis not available") pytest.skip("Redis not available")
cache: RedisCache = RedisCache(client, prefix="test:async:") cache = RedisCache(client, prefix="test:async:")
keys: list[FullKey] = [(("graph", "node"), "key")] keys = [(("graph", "node"), "key")]
values = {keys[0]: ({"data": "test"}, None)} values = {keys[0]: ({"data": "test"}, None)}
await cache.aset(values) await cache.aset(values)
@@ -218,44 +214,44 @@ class TestRedisCache:
# Cleanup # Cleanup
client.flushdb() client.flushdb()
def test_redis_unavailable_get(self) -> None: def test_redis_unavailable_get(self):
"""Test behavior when Redis is unavailable during get operations.""" """Test behavior when Redis is unavailable during get operations."""
# Create cache with non-existent Redis server # Create cache with non-existent Redis server
bad_client = redis.Redis( bad_client = redis.Redis(
host="nonexistent", port=9999, socket_connect_timeout=0.1 host="nonexistent", port=9999, socket_connect_timeout=0.1
) )
cache: RedisCache = RedisCache(bad_client, prefix="test:cache:") cache = RedisCache(bad_client, prefix="test:cache:")
keys: list[FullKey] = [(("graph", "node"), "key")] keys = [(("graph", "node"), "key")]
result = cache.get(keys) result = cache.get(keys)
# Should return empty dict when Redis unavailable # Should return empty dict when Redis unavailable
assert result == {} assert result == {}
def test_redis_unavailable_set(self) -> None: def test_redis_unavailable_set(self):
"""Test behavior when Redis is unavailable during set operations.""" """Test behavior when Redis is unavailable during set operations."""
# Create cache with non-existent Redis server # Create cache with non-existent Redis server
bad_client = redis.Redis( bad_client = redis.Redis(
host="nonexistent", port=9999, socket_connect_timeout=0.1 host="nonexistent", port=9999, socket_connect_timeout=0.1
) )
cache: RedisCache = RedisCache(bad_client, prefix="test:cache:") cache = RedisCache(bad_client, prefix="test:cache:")
keys: list[FullKey] = [(("graph", "node"), "key")] keys = [(("graph", "node"), "key")]
values = {keys[0]: ({"data": "test"}, None)} values = {keys[0]: ({"data": "test"}, None)}
# Should not raise exception when Redis unavailable # Should not raise exception when Redis unavailable
cache.set(values) # Should silently fail cache.set(values) # Should silently fail
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_redis_unavailable_async(self) -> None: async def test_redis_unavailable_async(self):
"""Test async behavior when Redis is unavailable.""" """Test async behavior when Redis is unavailable."""
# Create sync cache with non-existent Redis server (like main integration tests) # Create sync cache with non-existent Redis server (like main integration tests)
bad_client = redis.Redis( bad_client = redis.Redis(
host="nonexistent", port=9999, socket_connect_timeout=0.1 host="nonexistent", port=9999, socket_connect_timeout=0.1
) )
cache: RedisCache = RedisCache(bad_client, prefix="test:cache:") cache = RedisCache(bad_client, prefix="test:cache:")
keys: list[FullKey] = [(("graph", "node"), "key")] keys = [(("graph", "node"), "key")]
values = {keys[0]: ({"data": "test"}, None)} values = {keys[0]: ({"data": "test"}, None)}
# Should return empty dict for get (delegates to sync) # Should return empty dict for get (delegates to sync)
@@ -265,10 +261,10 @@ class TestRedisCache:
# Should not raise exception for set (delegates to sync) # Should not raise exception for set (delegates to sync)
await cache.aset(values) # Should silently fail await cache.aset(values) # Should silently fail
def test_corrupted_data_handling(self) -> None: def test_corrupted_data_handling(self):
"""Test handling of corrupted data in Redis.""" """Test handling of corrupted data in Redis."""
# Set some valid data first # Set some valid data first
keys: list[FullKey] = [(("graph", "node"), "valid_key")] keys = [(("graph", "node"), "valid_key")]
values = {keys[0]: ({"data": "valid"}, None)} values = {keys[0]: ({"data": "valid"}, None)}
self.cache.set(values) self.cache.set(values)
@@ -277,36 +273,33 @@ class TestRedisCache:
self.client.set(corrupted_key, b"invalid:data:format:too:many:colons") self.client.set(corrupted_key, b"invalid:data:format:too:many:colons")
# Should skip corrupted entry and return only valid ones # Should skip corrupted entry and return only valid ones
all_keys: list[FullKey] = [keys[0], (("graph", "node"), "corrupted_key")] all_keys = [keys[0], (("graph", "node"), "corrupted_key")]
result = self.cache.get(all_keys) result = self.cache.get(all_keys)
assert len(result) == 1 assert len(result) == 1
assert result[keys[0]] == {"data": "valid"} assert result[keys[0]] == {"data": "valid"}
def test_key_parsing_edge_cases(self) -> None: def test_key_parsing_edge_cases(self):
"""Test key parsing with edge cases.""" """Test key parsing with edge cases."""
# Test empty namespace # Test empty namespace
key1: FullKey = ((), "empty_ns") key1 = ((), "empty_ns")
values = {key1: ({"data": "empty_ns"}, None)} values = {key1: ({"data": "empty_ns"}, None)}
self.cache.set(values) self.cache.set(values)
result = self.cache.get([key1]) result = self.cache.get([key1])
assert result[key1] == {"data": "empty_ns"} assert result[key1] == {"data": "empty_ns"}
# Test namespace with special characters # Test namespace with special characters
key2: FullKey = ( key2 = (("graph:with:colons", "node-with-dashes"), "key_with_underscores")
("graph:with:colons", "node-with-dashes"),
"key_with_underscores",
)
values = {key2: ({"data": "special_chars"}, None)} values = {key2: ({"data": "special_chars"}, None)}
self.cache.set(values) self.cache.set(values)
result = self.cache.get([key2]) result = self.cache.get([key2])
assert result[key2] == {"data": "special_chars"} assert result[key2] == {"data": "special_chars"}
def test_large_data_serialization(self) -> None: def test_large_data_serialization(self):
"""Test handling of large data objects.""" """Test handling of large data objects."""
# Create a large data structure # Create a large data structure
large_data = {"large_list": list(range(1000)), "nested": {"data": "x" * 1000}} large_data = {"large_list": list(range(1000)), "nested": {"data": "x" * 1000}}
key: FullKey = (("graph", "node"), "large_key") key = (("graph", "node"), "large_key")
values = {key: (large_data, None)} values = {key: (large_data, None)}
self.cache.set(values) self.cache.set(values)
+2 -26
View File
@@ -845,7 +845,7 @@ async def test_async_batched_vector_search_concurrent(
] ]
) )
for results, (query, filter_) in zip(all_results, search_queries, strict=False): for results, (query, filter_) in zip(all_results, search_queries):
assert len(results) > 0, f"No results for query '{query}' with filter {filter_}" assert len(results) > 0, f"No results for query '{query}' with filter {filter_}"
for result in results: for result in results:
@@ -950,8 +950,8 @@ async def test_embed_with_path(fake_embeddings: CharacterEmbeddings) -> None:
assert results[0].key != results[1].key assert results[0].key != results[1].key
ascore = results[0].score ascore = results[0].score
bscore = results[1].score bscore = results[1].score
assert ascore == bscore
assert ascore is not None and bscore is not None assert ascore is not None and bscore is not None
assert ascore == pytest.approx(bscore, abs=1e-5)
results = await store.asearch(("test",), query="uuu") results = await store.asearch(("test",), query="uuu")
assert len(results) == 2 assert len(results) == 2
@@ -1021,27 +1021,3 @@ async def test_embed_with_path(fake_embeddings: CharacterEmbeddings) -> None:
assert len(results) == 3 assert len(results) == 3
doc5_result = next(r for r in results if r.key == "doc5") doc5_result = next(r for r in results if r.key == "doc5")
assert doc5_result.score is None assert doc5_result.score is None
def test_non_ascii(fake_embeddings: CharacterEmbeddings) -> None:
"""Test support for non-ascii characters"""
store = InMemoryStore(
index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
)
store.put(("user_123", "memories"), "1", {"text": "这是中文"}) # Chinese
store.put(("user_123", "memories"), "2", {"text": "これは日本語です"}) # Japanese
store.put(("user_123", "memories"), "3", {"text": "이건 한국어야"}) # Korean
store.put(("user_123", "memories"), "4", {"text": "Это русский"}) # Russian
store.put(("user_123", "memories"), "5", {"text": "यह रूसी है"}) # Hindi
result1 = store.search(("user_123", "memories"), query="这是中文")
result2 = store.search(("user_123", "memories"), query="これは日本語です")
result3 = store.search(("user_123", "memories"), query="이건 한국어야")
result4 = store.search(("user_123", "memories"), query="Это русский")
result5 = store.search(("user_123", "memories"), query="यह रूसी है")
assert result1[0].key == "1"
assert result2[0].key == "2"
assert result3[0].key == "3"
assert result4[0].key == "4"
assert result5[0].key == "5"

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