mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-10 03:37:51 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c0e729e68 | ||
|
|
adcfa19391 | ||
|
|
1335f1eb20 |
@@ -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).
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
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:
|
||||||
@@ -15,7 +15,7 @@ body:
|
|||||||
* [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,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
|
||||||
|
|||||||
@@ -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,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")
|
|
||||||
|
|||||||
@@ -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 ]
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 user’s *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 it’s 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 user’s 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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
|
|||||||
+14
-117
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"""Convert Jupyter notebooks to markdown with custom processing."""
|
|
||||||
|
|
||||||
import ast
|
import ast
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|||||||
+166
-490
@@ -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,23 +560,6 @@ 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")
|
||||||
@@ -840,12 +578,9 @@ def on_post_build(config):
|
|||||||
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")
|
||||||
@@ -873,63 +608,4 @@ def on_post_build(config):
|
|||||||
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."""
|
||||||
|
|
||||||
@@ -76,7 +73,6 @@ def _get_pypi_downloads(package: Package) -> int:
|
|||||||
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] = []
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -22,15 +22,11 @@ To leverage durable execution in LangGraph, you need to:
|
|||||||
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.
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()))
|
||||||
```
|
```
|
||||||
|
|
||||||

|

|
||||||
:::
|
:::
|
||||||
|
|
||||||
:::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()))
|
||||||
```
|
```
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
**Using Mermaid + Pyppeteer**
|
**Using Mermaid + Pyppeteer**
|
||||||
|
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
:::
|
:::
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
+97
-61
@@ -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:
|
||||||
@@ -272,6 +280,34 @@ nav:
|
|||||||
- 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
|
||||||
- admonition
|
- admonition
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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" },
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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("/")
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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"] == {}
|
|
||||||
|
|||||||
Generated
+710
-830
File diff suppressed because it is too large
Load Diff
@@ -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()]
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
)
|
|
||||||
|
|||||||
Generated
+593
-573
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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: ...
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
Generated
+838
-896
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
.langgraph_api/
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user