mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-08 02:37:52 +02:00
Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef15bc7d67 | ||
|
|
dddcb8ad8f | ||
|
|
5ea018e7d6 | ||
|
|
ac39fd2400 | ||
|
|
ce8c512642 | ||
|
|
81efce7fe7 | ||
|
|
b456b82ee4 | ||
|
|
11596dcd10 | ||
|
|
c2279cbe6f | ||
|
|
3a024cff6d | ||
|
|
4a0b2fa0ef | ||
|
|
36179ab1d2 | ||
|
|
20ddb2b8b4 | ||
|
|
b0a25f2794 | ||
|
|
ea0aebaa2e | ||
|
|
26c68aa528 | ||
|
|
4101aebeea | ||
|
|
90ac06deb6 | ||
|
|
32d66d48eb | ||
|
|
d933d455ec | ||
|
|
c421afba65 | ||
|
|
6139dacef9 | ||
|
|
affaa90d2a | ||
|
|
9f969f5fe1 | ||
|
|
fb531b2473 | ||
|
|
fe4029b3b8 | ||
|
|
7cd9a8e5dd | ||
|
|
5ba02d5b46 | ||
|
|
11834512db | ||
|
|
eeb731c07e | ||
|
|
f0fced262a | ||
|
|
8dc4465d05 | ||
|
|
d0a3eaf601 | ||
|
|
6f45f13952 | ||
|
|
328129e5bd | ||
|
|
2d05a17dfb | ||
|
|
5a36229e38 | ||
|
|
eeadeb282e | ||
|
|
3a22aa0af3 | ||
|
|
9467a0e2bb | ||
|
|
8b55dff7a5 | ||
|
|
a19b74154a | ||
|
|
0607dc4611 | ||
|
|
a3ee814539 | ||
|
|
b65140a892 | ||
|
|
77a63608d1 | ||
|
|
c6179ca9d5 | ||
|
|
f087567853 | ||
|
|
bdef6b3f5d | ||
|
|
677d941bb6 | ||
|
|
a43acc33bd | ||
|
|
326fd55e4f | ||
|
|
6037f0210f | ||
|
|
d9328027f9 | ||
|
|
7170e04aa6 | ||
|
|
20581e61c0 | ||
|
|
4af07942ed | ||
|
|
682f39e0d3 | ||
|
|
7bbe8d8628 | ||
|
|
4dfd1c368c | ||
|
|
b75daf093e | ||
|
|
faacbc1570 | ||
|
|
f5f536ba78 | ||
|
|
7284326160 | ||
|
|
62f7548532 | ||
|
|
e6a9e1d1c1 | ||
|
|
94fa329100 | ||
|
|
264caa0684 | ||
|
|
cd33de2ad1 | ||
|
|
692f177a10 | ||
|
|
14d4be6c4a | ||
|
|
2e133d6189 | ||
|
|
89de950307 | ||
|
|
788b62c0fb | ||
|
|
1539a55d2c | ||
|
|
f3055178f3 | ||
|
|
c0067cd304 | ||
|
|
b90d7c4e58 | ||
|
|
2c14b1d658 | ||
|
|
50ce6badee | ||
|
|
6fc5b3aeda | ||
|
|
b543752878 | ||
|
|
8f6ad0b25a | ||
|
|
ada5d2ecb1 | ||
|
|
eaeafe54ab | ||
|
|
f761116de7 | ||
|
|
36cf353d19 | ||
|
|
d503c0bf33 | ||
|
|
25ba4c3bda | ||
|
|
dfc1c59ebf | ||
|
|
6f4c5fefee | ||
|
|
7cf230defa | ||
|
|
5db65e0281 | ||
|
|
b08c2e092f | ||
|
|
120ae38c12 | ||
|
|
22942d4eec | ||
|
|
1756ce1dd2 | ||
|
|
0b4638269b | ||
|
|
1ebdb1ba31 |
@@ -1,6 +1,9 @@
|
|||||||
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, support, and feature requests
|
about: General community discussions and support
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -14,12 +14,25 @@ jobs:
|
|||||||
python-version:
|
python-version:
|
||||||
- "3.10"
|
- "3.10"
|
||||||
- "3.11"
|
- "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@v4
|
- uses: actions/checkout@v5
|
||||||
- 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
|
||||||
@@ -33,57 +46,65 @@ jobs:
|
|||||||
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 A
|
- name: Build and test service ${{ matrix.example.name }}
|
||||||
if: steps.changed-files.outputs.all
|
if: steps.changed-files.outputs.all
|
||||||
working-directory: libs/cli/examples
|
working-directory: ${{ matrix.example.workdir }}
|
||||||
env:
|
env:
|
||||||
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||||
run: |
|
run: |
|
||||||
# The build-arg isn't used; just testing that we accept other args
|
# Build the image for this example
|
||||||
langgraph build -t langgraph-test-a
|
langgraph build -t ${{ matrix.example.tag }}
|
||||||
cp .env.example .env
|
# Prepare environment file from local or parent example directory
|
||||||
if [ -n "${{ secrets.LANGSMITH_API_KEY }}" ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env; fi
|
if [ -f .env.example ]; then cp .env.example .env; elif [ -f ../.env.example ]; then cp ../.env.example .env && cp ../.env.example ../.env; fi
|
||||||
timeout 60 python ../../../.github/scripts/run_langgraph_cli_test.py -c langgraph.json -t langgraph-test-a
|
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
|
||||||
- name: Build and test service B
|
# Run the integration test using the built tag
|
||||||
if: steps.changed-files.outputs.all
|
# Compute repo root to reference the shared script robustly
|
||||||
working-directory: libs/cli/examples/graphs
|
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||||
env:
|
timeout 60 python "$REPO_ROOT/.github/scripts/run_langgraph_cli_test.py" -t ${{ matrix.example.tag }}
|
||||||
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
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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.0a2" ]; then
|
||||||
|
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" != "0.3.0" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build and test prerelease reqs fail service
|
||||||
|
if: steps.changed-files.outputs.all
|
||||||
|
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@v4
|
- uses: actions/checkout@v5
|
||||||
- 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
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
|
|
||||||
name: "test #${{ matrix.python-version }}"
|
name: "test #${{ matrix.python-version }}"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
working-directory: libs/langgraph
|
working-directory: libs/langgraph
|
||||||
name: "test #${{ matrix.python-version }}"
|
name: "test #${{ matrix.python-version }}"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
version: ${{ steps.check-version.outputs.version }}
|
version: ${{ steps.check-version.outputs.version }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Python $${ env.PYTHON_VERSION }}
|
- name: Set up Python $${ env.PYTHON_VERSION }}
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v6
|
||||||
@@ -75,9 +75,9 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: test-dist
|
name: test-dist
|
||||||
path: ${{ inputs.working-directory }}/dist/
|
path: ${{ inputs.working-directory }}/dist/
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
run:
|
run:
|
||||||
working-directory: libs/langgraph
|
working-directory: libs/langgraph
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- 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@v6
|
uses: astral-sh/setup-uv@v6
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
run:
|
run:
|
||||||
working-directory: libs/langgraph
|
working-directory: libs/langgraph
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- 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
|
||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
echo EOF
|
echo EOF
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
- name: Annotation
|
- name: Annotation
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v8
|
||||||
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@v4
|
- uses: actions/checkout@v5
|
||||||
- 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@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
- name: Run check_sdk_methods script
|
- name: Run check_sdk_methods script
|
||||||
@@ -118,7 +118,7 @@ jobs:
|
|||||||
python-version:
|
python-version:
|
||||||
- "3.11"
|
- "3.11"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
changed-files: ${{ steps.changed-files.outputs.added_modified }}
|
changed-files: ${{ steps.changed-files.outputs.added_modified }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- 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
|
||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.MKDOCS_GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.MKDOCS_GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload Pages Artifact
|
- name: Upload Pages Artifact
|
||||||
# if: github.ref == 'refs/heads/main'
|
# if: github.ref == 'refs/heads/main'
|
||||||
uses: actions/upload-pages-artifact@v3
|
uses: actions/upload-pages-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: ./docs/site/
|
path: ./docs/site/
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
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@v4
|
uses: actions/checkout@v5
|
||||||
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@v5
|
uses: amannn/action-semantic-pull-request@v6
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
@@ -40,6 +40,7 @@ jobs:
|
|||||||
sdk-py
|
sdk-py
|
||||||
docs
|
docs
|
||||||
ci
|
ci
|
||||||
|
deps
|
||||||
requireScope: false
|
requireScope: false
|
||||||
ignoreLabels: |
|
ignoreLabels: |
|
||||||
ignore-lint-pr-title
|
ignore-lint-pr-title
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
tag: ${{ steps.check-version.outputs.tag }}
|
tag: ${{ steps.check-version.outputs.tag }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v6
|
||||||
@@ -87,7 +87,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@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: langchain-ai/langgraph
|
repository: langchain-ai/langgraph
|
||||||
path: langgraph
|
path: langgraph
|
||||||
@@ -158,7 +158,7 @@ jobs:
|
|||||||
- test-pypi-publish
|
- test-pypi-publish
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
# 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.
|
||||||
@@ -261,7 +261,7 @@ jobs:
|
|||||||
working-directory: ${{ inputs.working-directory }}
|
working-directory: ${{ inputs.working-directory }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v6
|
||||||
@@ -270,7 +270,7 @@ jobs:
|
|||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-suffix: "release"
|
cache-suffix: "release"
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: ${{ inputs.working-directory }}/dist/
|
path: ${{ inputs.working-directory }}/dist/
|
||||||
@@ -302,7 +302,7 @@ jobs:
|
|||||||
working-directory: ${{ inputs.working-directory }}
|
working-directory: ${{ inputs.working-directory }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v6
|
||||||
@@ -311,7 +311,7 @@ jobs:
|
|||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-suffix: "release"
|
cache-suffix: "release"
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: ${{ inputs.working-directory }}/dist/
|
path: ${{ inputs.working-directory }}/dist/
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
- "latest"
|
- "latest"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Set up Python + Poetry
|
- name: Set up Python + Poetry
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v6
|
||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ While LangGraph can be used standalone, it also integrates seamlessly with any L
|
|||||||
|
|
||||||
## Additional resources
|
## Additional resources
|
||||||
|
|
||||||
- [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.).
|
- [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.).
|
||||||
- [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.
|
||||||
|
|||||||
+113
-10
@@ -1,24 +1,126 @@
|
|||||||
# Setup
|
# LangGraph Documentation
|
||||||
|
|
||||||
To setup requirements for building docs you can run:
|
For more information on contributing to our documentation, see the [Contributing Guide](../CONTRIBUTING.md).
|
||||||
|
|
||||||
```bash
|
## Structure
|
||||||
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
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
- **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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Serving documentation locally
|
## Build Process
|
||||||
|
|
||||||
To run the documentation server locally you can run:
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
This will start the documentation server on [http://127.0.0.1:8000/langgraph/](http://127.0.0.1:8000/langgraph/).
|
The `serve-docs` command:
|
||||||
|
|
||||||
|
- 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" GHA, you can run:
|
If you would like to automatically execute all of the notebooks, to mimic the "Run notebooks" GitHub action, you can run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python _scripts/prepare_notebooks_for_ci.py
|
python _scripts/prepare_notebooks_for_ci.py
|
||||||
@@ -33,8 +135,9 @@ 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 subsequently, the cells with network requests will be replayed from the cassettes
|
- 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
|
||||||
|
|
||||||
## Adding new notebooks
|
## Adding new notebooks
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""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
|
||||||
@@ -70,8 +72,18 @@ 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.handoff", "create_handoff_tool", "supervisor"),
|
["langgraph_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"),
|
||||||
|
|||||||
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.8.9
|
version: 4.9.7
|
||||||
resolution: "hono@npm:4.8.9"
|
resolution: "hono@npm:4.9.7"
|
||||||
checksum: 10c0/385539d1787fdc747bc869ef0e5ccc9f39cbe40289b94f23eecfc82c6ca440f059704647cd6381a5066d2cf7baa43ab25184c78d44af4c5c98a5c5b07670059e
|
checksum: 10c0/089184660a9211ea216ab95bafa45260e371651cb019db49828064b7982b0ae61cc3c4715324bfeb9037aa2460c39ffa2c91d84ad0c8d500fa77cbcc7fc07a8f
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""Convert Jupyter notebooks to markdown with custom processing."""
|
||||||
|
|
||||||
import ast
|
import ast
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|||||||
@@ -144,9 +144,9 @@ REDIRECT_MAP = {
|
|||||||
"concepts/langgraph_cli.md": "https://docs.langchain.com/langgraph-platform/langgraph-cli",
|
"concepts/langgraph_cli.md": "https://docs.langchain.com/langgraph-platform/langgraph-cli",
|
||||||
"concepts/langgraph_studio.md": "https://docs.langchain.com/langgraph-platform/langgraph-studio",
|
"concepts/langgraph_studio.md": "https://docs.langchain.com/langgraph-platform/langgraph-studio",
|
||||||
"cloud/how-tos/studio/quick_start.md": "https://docs.langchain.com/langgraph-platform/quick-start-studio",
|
"cloud/how-tos/studio/quick_start.md": "https://docs.langchain.com/langgraph-platform/quick-start-studio",
|
||||||
"cloud/how-tos/invoke_studio.md": "https://docs.langchain.com/langgraph-platform/invoke-studio",
|
"cloud/how-tos/invoke_studio.md": "https://docs.langchain.com/langgraph-platform/use-studio#run-application",
|
||||||
"cloud/how-tos/studio/manage_assistants.md": "https://docs.langchain.com/langgraph-platform/manage-assistants-studio",
|
"cloud/how-tos/studio/manage_assistants.md": "https://docs.langchain.com/langgraph-platform/use-studio#manage-assistants",
|
||||||
"cloud/how-tos/threads_studio.md": "https://docs.langchain.com/langgraph-platform/threads-studio",
|
"cloud/how-tos/threads_studio.md": "https://docs.langchain.com/langgraph-platform/use-studio#manage-threads",
|
||||||
"cloud/how-tos/iterate_graph_studio.md": "https://docs.langchain.com/langgraph-platform/iterate-graph-studio",
|
"cloud/how-tos/iterate_graph_studio.md": "https://docs.langchain.com/langgraph-platform/iterate-graph-studio",
|
||||||
"cloud/how-tos/studio/run_evals.md": "https://docs.langchain.com/langgraph-platform/run-evals-studio",
|
"cloud/how-tos/studio/run_evals.md": "https://docs.langchain.com/langgraph-platform/run-evals-studio",
|
||||||
"cloud/how-tos/clone_traces_studio.md": "https://docs.langchain.com/langgraph-platform/clone-traces-studio",
|
"cloud/how-tos/clone_traces_studio.md": "https://docs.langchain.com/langgraph-platform/clone-traces-studio",
|
||||||
|
|||||||
@@ -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, config: Runtime[ContextSchema]):
|
def node(state: State, runtime: Runtime[ContextSchema]):
|
||||||
user_name = runtime.context.user_name
|
user_name = runtime.context.user_name
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
"name": "Store",
|
"name": "Store",
|
||||||
"description": "Store is an API for managing persistent key-value store (long-term memory) that is available from any thread."
|
"description": "Store is an API for managing persistent key-value store (long-term memory) that is available from any thread."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "A2A",
|
||||||
|
"description": "Agent-to-Agent Protocol related endpoints for exposing assistants as A2A-compliant agents."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "MCP",
|
"name": "MCP",
|
||||||
"description": "Model Context Protocol related endpoints for exposing an agent as an MCP server."
|
"description": "Model Context Protocol related endpoints for exposing an agent as an MCP server."
|
||||||
@@ -1550,6 +1554,29 @@
|
|||||||
},
|
},
|
||||||
"name": "Last-Event-ID",
|
"name": "Last-Event-ID",
|
||||||
"in": "header"
|
"in": "header"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["lifecycle", "run_modes", "state_update"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["lifecycle", "run_modes", "state_update"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": ["run_modes"],
|
||||||
|
"title": "Stream Modes",
|
||||||
|
"description": "Stream modes to control which events are returned. 'lifecycle' returns only run start/end events, 'run_modes' returns all run events (default behavior), 'state_update' returns only state update events."
|
||||||
|
},
|
||||||
|
"name": "stream_modes",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -3159,6 +3186,195 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/a2a/{assistant_id}": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "post_a2a",
|
||||||
|
"summary": "A2A Post",
|
||||||
|
"description": "Communicate with an assistant using the Agent-to-Agent Protocol.\nSends a JSON-RPC 2.0 message to the assistant.\n\n- **Request**: Provide an object with `jsonrpc`, `id`, `method`, and optional `params`.\n- **Response**: Returns a JSON-RPC response with task information or error.\n\n**Supported Methods:**\n- `message/send`: Send a message to the assistant\n- `tasks/get`: Get the status and result of a task\n\n**Notes:**\n- Supports threaded conversations via thread context\n- Messages can contain text and data parts\n- Tasks run asynchronously and return completion status\n",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "assistant_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"description": "The ID of the assistant to communicate with"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accept",
|
||||||
|
"in": "header",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["application/json"]
|
||||||
|
},
|
||||||
|
"description": "Must be application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"jsonrpc": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["2.0"],
|
||||||
|
"description": "JSON-RPC version"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Request identifier"
|
||||||
|
},
|
||||||
|
"method": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["message/send", "tasks/get"],
|
||||||
|
"description": "The method to invoke"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Method parameters",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"title": "Message Send Parameters",
|
||||||
|
"properties": {
|
||||||
|
"message": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"role": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["user", "assistant"],
|
||||||
|
"description": "Message role"
|
||||||
|
},
|
||||||
|
"parts": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"title": "Text Part",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["text"]
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["kind", "text"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Data Part",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["data"]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["kind", "data"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Message parts"
|
||||||
|
},
|
||||||
|
"messageId": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unique message identifier"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["role", "parts", "messageId"]
|
||||||
|
},
|
||||||
|
"thread": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"threadId": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Thread identifier for conversation context"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Optional thread context"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["message"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Task Get Parameters",
|
||||||
|
"properties": {
|
||||||
|
"taskId": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Task identifier to retrieve"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["taskId"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["jsonrpc", "id", "method"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "JSON-RPC response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"jsonrpc": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["2.0"]
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Success result containing task information or task details"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error information if request failed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["jsonrpc", "id"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad request - invalid JSON-RPC or missing Accept header"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Assistant not found"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal server error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"A2A"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/mcp/": {
|
"/mcp/": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "post_mcp",
|
"operationId": "post_mcp",
|
||||||
@@ -4413,6 +4629,17 @@
|
|||||||
"title": "Checkpoint During",
|
"title": "Checkpoint During",
|
||||||
"description": "Whether to checkpoint during the run.",
|
"description": "Whether to checkpoint during the run.",
|
||||||
"default": false
|
"default": false
|
||||||
|
},
|
||||||
|
"durability": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"sync",
|
||||||
|
"async",
|
||||||
|
"exit"
|
||||||
|
],
|
||||||
|
"title": "Durability",
|
||||||
|
"description": "Durability level for the run. Must be one of 'sync', 'async', or 'exit'.",
|
||||||
|
"default": "async"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -4649,6 +4876,17 @@
|
|||||||
"title": "Checkpoint During",
|
"title": "Checkpoint During",
|
||||||
"description": "Whether to checkpoint during the run.",
|
"description": "Whether to checkpoint during the run.",
|
||||||
"default": false
|
"default": false
|
||||||
|
},
|
||||||
|
"durability": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"sync",
|
||||||
|
"async",
|
||||||
|
"exit"
|
||||||
|
],
|
||||||
|
"title": "Durability",
|
||||||
|
"description": "Durability level for the run. Must be one of 'sync', 'async', or 'exit'.",
|
||||||
|
"default": "async"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -4777,6 +5015,12 @@
|
|||||||
},
|
},
|
||||||
"ThreadSearchRequest": {
|
"ThreadSearchRequest": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string", "format": "uuid"},
|
||||||
|
"title": "Ids",
|
||||||
|
"description": "List of thread IDs to include. Others are excluded."
|
||||||
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "Metadata",
|
"title": "Metadata",
|
||||||
@@ -5017,11 +5261,30 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "Metadata",
|
"title": "Metadata",
|
||||||
"description": "Metadata to merge with existing thread metadata."
|
"description": "Metadata to merge with existing thread metadata."
|
||||||
|
},
|
||||||
|
"ttl": {
|
||||||
|
"type": "object",
|
||||||
|
"title": "TTL",
|
||||||
|
"description": "The time-to-live for the thread.",
|
||||||
|
"properties": {
|
||||||
|
"strategy": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"delete"
|
||||||
|
],
|
||||||
|
"description": "The TTL strategy. 'delete' removes the entire thread.",
|
||||||
|
"default": "delete"
|
||||||
|
},
|
||||||
|
"ttl": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The time-to-live in minutes from now until thread should be swept."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "ThreadPatch",
|
"title": "ThreadPatch",
|
||||||
"description": "Payload for creating a thread."
|
"description": "Payload for updating a thread."
|
||||||
},
|
},
|
||||||
"ThreadStateCheckpointRequest": {
|
"ThreadStateCheckpointRequest": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1040,7 +1040,7 @@ def node_a(state: State, runtime: Runtime[ContextSchema]):
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
See [this guide](../how-tos/graph-api.ipynb#add-runtime-configuration) for a full breakdown on configuration.
|
See [this guide](../how-tos/graph-api.md#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=instructions.value["instructions"], conversation=state["messages"])
|
prompt = prompt_template.format(instructions=current_instructions.value["instructions"], conversation=state["messages"])
|
||||||
output = llm.invoke(prompt)
|
output = llm.invoke(prompt)
|
||||||
new_instructions = output['new_instructions']
|
new_instructions = output['new_instructions']
|
||||||
store.put(("agent_instructions",), "agent_a", {"instructions": new_instructions})
|
store.put(("agent_instructions",), "agent_a", {"instructions": new_instructions})
|
||||||
@@ -278,4 +278,4 @@ const items = await store.search(
|
|||||||
```
|
```
|
||||||
:::
|
:::
|
||||||
|
|
||||||
For more information about the memory store, see the [Persistence](persistence.md#memory-store) guide.
|
For more information about the memory store, see the [Persistence](persistence.md#memory-store) guide.
|
||||||
|
|||||||
@@ -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.ipynb#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.md#different-state-schemas) so that the parent graph knows how to communicate with the subgraphs.
|
||||||
- Define agent node functions with a [private input state schema](../how-tos/graph-api.ipynb#pass-private-state-between-nodes) that is distinct from the overall graph state schema. This allows passing information that is only needed for executing that particular agent.
|
- Define agent node functions with a [private input state schema](../how-tos/graph-api.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.
|
||||||
|
|||||||
@@ -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]})["messages"][-1]
|
response_1 = graph.invoke({"messages": [input_message]}, context=ContextSchema())["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.pregel import RetryPolicy
|
from langgraph.types 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.pregel import RetryPolicy
|
from langgraph.types 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,7 +2110,6 @@ 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()
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -2333,7 +2332,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
|
||||||
@@ -3272,7 +3271,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**
|
||||||
|
|
||||||
@@ -3320,4 +3319,4 @@ const imageBuffer = new Uint8Array(await image.arrayBuffer());
|
|||||||
|
|
||||||
await fs.writeFile("graph.png", imageBuffer);
|
await fs.writeFile("graph.png", imageBuffer);
|
||||||
```
|
```
|
||||||
:::
|
:::
|
||||||
|
|||||||
@@ -366,8 +366,8 @@ result = graph.invoke(
|
|||||||
|
|
||||||
# Resume with mapping of interrupt IDs to values
|
# Resume with mapping of interrupt IDs to values
|
||||||
resume_map = {
|
resume_map = {
|
||||||
i.interrupt_id: f"human input for prompt {i.value}"
|
i.id: f"edited text for {i.value['text_to_revise']}"
|
||||||
for i in parent.get_state(thread_config).interrupts
|
for i in graph.get_state(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'}
|
||||||
|
|||||||
@@ -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["environment", END]:
|
def should_continue(state: MessagesState) -> Literal["Action", 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"]
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
|||||||
}
|
}
|
||||||
|
|
||||||
.md-banner {
|
.md-banner {
|
||||||
background-color: #CFC9FA;
|
background-color: #FFAE42;
|
||||||
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 %}
|
||||||
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>.
|
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>
|
||||||
{% 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"
|
requires-python = ">=3.11.0,<4.0.0"
|
||||||
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",
|
"hub>=3.0.1,<4.0.0",
|
||||||
"xxhash>=3.5.0,<4",
|
"xxhash>=3.5.0,<4.0.0",
|
||||||
"black>=25.1.0,<26",
|
"black>=25.1.0,<26.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
Generated
+5
-4
@@ -1,5 +1,5 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 2
|
revision = 3
|
||||||
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.2"
|
version = "0.6.7"
|
||||||
source = { editable = "../libs/langgraph" }
|
source = { editable = "../libs/langgraph" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
@@ -2380,6 +2380,7 @@ 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" },
|
||||||
@@ -2413,6 +2414,7 @@ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2643,7 +2645,7 @@ test = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph-prebuilt"
|
name = "langgraph-prebuilt"
|
||||||
version = "0.6.2"
|
version = "0.6.4"
|
||||||
source = { editable = "../libs/prebuilt" }
|
source = { editable = "../libs/prebuilt" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
@@ -2674,7 +2676,6 @@ 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/persistence_postgres.ipynb"
|
"This file has been moved to https://github.com/langchain-ai/langgraph/blob/main/docs/docs/how-tos/memory/add-memory.md"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -707,7 +707,9 @@
|
|||||||
" \"\"\"\n",
|
" \"\"\"\n",
|
||||||
" Find all tool calls in the messages returned\n",
|
" Find all tool calls in the messages returned\n",
|
||||||
" \"\"\"\n",
|
" \"\"\"\n",
|
||||||
" tool_calls = [tc['name'] for m in messages['messages'] for tc in getattr(m, 'tool_calls', [])]\n",
|
" 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",
|
||||||
|
|||||||
@@ -7,11 +7,6 @@ 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,
|
||||||
@@ -21,10 +16,15 @@ from langgraph.checkpoint.base import (
|
|||||||
get_checkpoint_id,
|
get_checkpoint_id,
|
||||||
get_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
|
||||||
|
|
||||||
@@ -450,7 +450,7 @@ class PostgresSaver(BasePostgresSaver):
|
|||||||
{
|
{
|
||||||
**value["checkpoint"],
|
**value["checkpoint"],
|
||||||
"channel_values": {
|
"channel_values": {
|
||||||
**value["checkpoint"].get("channel_values"),
|
**(value["checkpoint"].get("channel_values") or {}),
|
||||||
**self._load_blobs(value["channel_values"]),
|
**self._load_blobs(value["channel_values"]),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,11 +7,6 @@ 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,
|
||||||
@@ -21,10 +16,15 @@ from langgraph.checkpoint.base import (
|
|||||||
get_checkpoint_id,
|
get_checkpoint_id,
|
||||||
get_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
|
||||||
|
|
||||||
@@ -409,7 +409,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
|||||||
{
|
{
|
||||||
**value["checkpoint"],
|
**value["checkpoint"],
|
||||||
"channel_values": {
|
"channel_values": {
|
||||||
**value["checkpoint"].get("channel_values"),
|
**(value["checkpoint"].get("channel_values") or {}),
|
||||||
**self._load_blobs(value["channel_values"]),
|
**self._load_blobs(value["channel_values"]),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ from collections.abc import Sequence
|
|||||||
from typing import Any, Optional, cast
|
from typing import Any, Optional, 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,6 +12,7 @@ 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 = Optional[dict[str, Any]]
|
MetadataInput = Optional[dict[str, Any]]
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ from contextlib import asynccontextmanager, contextmanager
|
|||||||
from typing import Any, Optional
|
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_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,
|
||||||
@@ -19,18 +29,8 @@ 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.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from langgraph.store.postgres.aio import AsyncPostgresStore
|
from langgraph.store.postgres.aio import AsyncPostgresStore
|
||||||
from langgraph.store.postgres.base import PostgresStore
|
from langgraph.store.postgres.base import PoolConfig, PostgresStore
|
||||||
|
|
||||||
__all__ = ["AsyncPostgresStore", "PostgresStore"]
|
__all__ = ["AsyncPostgresStore", "PoolConfig", "PostgresStore"]
|
||||||
|
|||||||
@@ -8,11 +8,6 @@ from types import TracebackType
|
|||||||
from typing import Any, Callable, 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,
|
||||||
@@ -22,6 +17,11 @@ 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,
|
||||||
|
|||||||
@@ -22,14 +22,6 @@ from typing import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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,
|
||||||
@@ -46,6 +38,14 @@ 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
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ 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,
|
||||||
@@ -17,11 +13,15 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -344,3 +344,34 @@ 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"] == {}
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ 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,
|
||||||
@@ -21,6 +19,8 @@ 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,
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ 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,
|
||||||
@@ -19,6 +17,8 @@ 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,
|
||||||
@@ -861,3 +861,41 @@ 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,10 +7,6 @@ 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,
|
||||||
@@ -18,8 +14,12 @@ from langgraph.checkpoint.base import (
|
|||||||
create_checkpoint,
|
create_checkpoint,
|
||||||
empty_checkpoint,
|
empty_checkpoint,
|
||||||
)
|
)
|
||||||
from langgraph.checkpoint.postgres import PostgresSaver, ShallowPostgresSaver
|
|
||||||
from langgraph.checkpoint.serde.types import TASKS
|
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 tests.conftest import DEFAULT_POSTGRES_URI
|
from tests.conftest import DEFAULT_POSTGRES_URI
|
||||||
|
|
||||||
|
|
||||||
@@ -332,3 +332,33 @@ 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
+482
-497
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@ 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,6 +20,7 @@ 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 = (
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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,6 +20,7 @@ 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)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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,
|
||||||
@@ -22,6 +21,7 @@ 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,
|
||||||
@@ -507,7 +507,9 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
|
|||||||
results: List to store results in.
|
results: List to store results in.
|
||||||
cur: Database cursor.
|
cur: Database cursor.
|
||||||
"""
|
"""
|
||||||
queries, embedding_requests = self._prepare_batch_search_queries(search_ops)
|
prepared_queries, embedding_requests = self._prepare_batch_search_queries(
|
||||||
|
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:
|
||||||
@@ -515,23 +517,60 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
|
|||||||
[query for _, query in embedding_requests]
|
[query for _, query in embedding_requests]
|
||||||
)
|
)
|
||||||
|
|
||||||
for (idx, _), embedding in zip(embedding_requests, vectors):
|
for (embed_req_idx, _), embedding in zip(embedding_requests, vectors):
|
||||||
_params_list: list = queries[idx][1]
|
# Find the corresponding query in prepared_queries
|
||||||
for i, param in enumerate(_params_list):
|
# The embed_req_idx is the original index in search_ops, which should map to prepared_queries
|
||||||
if param is _PLACEHOLDER:
|
if embed_req_idx < len(prepared_queries):
|
||||||
_params_list[i] = sqlite_vec.serialize_float32(embedding)
|
_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 (idx, _), (query, params) in zip(search_ops, queries):
|
for (original_op_idx, _), (query, params, needs_refresh) in zip(
|
||||||
|
search_ops, prepared_queries
|
||||||
|
):
|
||||||
await cur.execute(query, params)
|
await cur.execute(query, params)
|
||||||
rows = await cur.fetchall()
|
rows = await cur.fetchall()
|
||||||
|
|
||||||
if "score" in query:
|
if needs_refresh and rows and self.ttl_config:
|
||||||
|
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]),
|
_decode_ns_text(row[0]), # prefix
|
||||||
{
|
{
|
||||||
"key": row[1],
|
"key": row[1], # key
|
||||||
"value": row[2],
|
"value": row[2], # value
|
||||||
"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,
|
||||||
@@ -545,10 +584,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]),
|
_decode_ns_text(row[0]), # prefix
|
||||||
{
|
{
|
||||||
"key": row[1],
|
"key": row[1], # key
|
||||||
"value": row[2],
|
"value": row[2], # value
|
||||||
"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,
|
||||||
@@ -559,7 +598,7 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
|
|||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
results[idx] = items
|
results[original_op_idx] = items
|
||||||
|
|
||||||
async def _batch_list_namespaces_ops(
|
async def _batch_list_namespaces_ops(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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,
|
||||||
@@ -372,13 +371,15 @@ 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[tuple[str, list[None | str | list[float]]]], # queries, params
|
list[
|
||||||
|
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) plus embedding requests.
|
Build per-SearchOp SQL queries (with optional TTL refresh flag) plus embedding requests.
|
||||||
Returns:
|
Returns:
|
||||||
- queries: list of (SQL, param_list)
|
- queries: list of (SQL, param_list, needs_ttl_refresh_flag)
|
||||||
- embedding_requests: list of (original_index_in_search_ops, text_query)
|
- embedding_requests: list of (original_index_in_search_ops, text_query)
|
||||||
"""
|
"""
|
||||||
queries = []
|
queries = []
|
||||||
@@ -519,30 +520,18 @@ 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}")
|
||||||
|
|
||||||
# Handle TTL refresh if requested
|
# Determine if TTL refresh is needed
|
||||||
if (
|
needs_ttl_refresh = bool(
|
||||||
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
|
|
||||||
|
|
||||||
queries.append((final_sql, final_params))
|
# The base_query is now the final_sql, and we pass the refresh flag
|
||||||
|
final_sql = base_query
|
||||||
|
final_params = params
|
||||||
|
|
||||||
|
queries.append((final_sql, final_params, needs_ttl_refresh))
|
||||||
|
|
||||||
return queries, embedding_requests
|
return queries, embedding_requests
|
||||||
|
|
||||||
@@ -1331,7 +1320,9 @@ class SqliteStore(BaseSqliteStore, BaseStore):
|
|||||||
results: list[Result],
|
results: list[Result],
|
||||||
cur: sqlite3.Cursor,
|
cur: sqlite3.Cursor,
|
||||||
) -> None:
|
) -> None:
|
||||||
queries, embedding_requests = self._prepare_batch_search_queries(search_ops)
|
prepared_queries, embedding_requests = self._prepare_batch_search_queries(
|
||||||
|
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:
|
||||||
@@ -1341,16 +1332,48 @@ class SqliteStore(BaseSqliteStore, BaseStore):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Replace placeholders with actual embeddings
|
# Replace placeholders with actual embeddings
|
||||||
for (idx, _), embedding in zip(embedding_requests, embeddings):
|
for (embed_req_idx, _), embedding in zip(embedding_requests, embeddings):
|
||||||
_params_list: list = queries[idx][1]
|
if embed_req_idx < len(prepared_queries):
|
||||||
for i, param in enumerate(_params_list):
|
_params_list: list = prepared_queries[embed_req_idx][1]
|
||||||
if param is _PLACEHOLDER:
|
for i, param in enumerate(_params_list):
|
||||||
_params_list[i] = sqlite_vec.serialize_float32(embedding)
|
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 (idx, _), (query, params) in zip(search_ops, queries):
|
for (original_op_idx, _), (query, params, needs_refresh) in zip(
|
||||||
|
search_ops, prepared_queries
|
||||||
|
):
|
||||||
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(
|
||||||
@@ -1385,7 +1408,7 @@ class SqliteStore(BaseSqliteStore, BaseStore):
|
|||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
results[idx] = items
|
results[original_op_idx] = items
|
||||||
|
|
||||||
def _batch_list_namespaces_ops(
|
def _batch_list_namespaces_ops(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from contextlib import asynccontextmanager
|
|||||||
from typing import Optional, Union, 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,
|
||||||
@@ -16,6 +15,7 @@ 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
|
||||||
|
|||||||
@@ -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,7 +116,17 @@ class TestSqliteSaver:
|
|||||||
search_results_5[1].config["configurable"]["checkpoint_ns"],
|
search_results_5[1].config["configurable"]["checkpoint_ns"],
|
||||||
} == {"", "inner"}
|
} == {"", "inner"}
|
||||||
|
|
||||||
# TODO: test before and limit params
|
# search with before param
|
||||||
|
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
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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,
|
||||||
@@ -18,6 +17,7 @@ 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
|
||||||
|
|
||||||
@@ -1067,3 +1067,31 @@ 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"})
|
||||||
|
|
||||||
|
|
||||||
|
@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,6 +7,7 @@ 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
|
||||||
@@ -93,9 +94,13 @@ 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={"default_ttl": ttl_minutes, "sweep_interval_minutes": ttl_minutes / 2},
|
ttl=ttl_config,
|
||||||
) as store:
|
) as store:
|
||||||
store.setup()
|
store.setup()
|
||||||
|
|
||||||
@@ -298,9 +303,14 @@ 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={"default_ttl": ttl_minutes, "sweep_interval_minutes": ttl_minutes / 2},
|
ttl=ttl_config,
|
||||||
) as store:
|
) as store:
|
||||||
await store.setup()
|
await store.setup()
|
||||||
|
|
||||||
@@ -353,3 +363,67 @@ 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
+424
-438
File diff suppressed because it is too large
Load Diff
@@ -238,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)]
|
return [json.dumps(obj, sort_keys=True, ensure_ascii=False)]
|
||||||
|
|
||||||
tokens = tokenize_path(path) if isinstance(path, str) else path
|
tokens = tokenize_path(path) if isinstance(path, str) else path
|
||||||
|
|
||||||
@@ -249,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)]
|
return [json.dumps(obj, sort_keys=True, ensure_ascii=False)]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
token = tokens[pos]
|
token = tokens[pos]
|
||||||
@@ -295,7 +295,11 @@ 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(json.dumps(current_obj, sort_keys=True))
|
results.append(
|
||||||
|
json.dumps(
|
||||||
|
current_obj, sort_keys=True, ensure_ascii=False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Handle wildcard
|
# Handle wildcard
|
||||||
elif token == "*":
|
elif token == "*":
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ 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):
|
def setup(self) -> None:
|
||||||
"""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
|
||||||
@@ -20,21 +21,21 @@ class TestRedisCache:
|
|||||||
except redis.ConnectionError:
|
except redis.ConnectionError:
|
||||||
pytest.skip("Redis server not available")
|
pytest.skip("Redis server not available")
|
||||||
|
|
||||||
self.cache = RedisCache(self.client, prefix="test:cache:")
|
self.cache: RedisCache = 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):
|
def teardown_method(self) -> None:
|
||||||
"""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):
|
def test_basic_set_and_get(self) -> None:
|
||||||
"""Test basic set and get operations."""
|
"""Test basic set and get operations."""
|
||||||
keys = [(("graph", "node"), "key1")]
|
keys: list[FullKey] = [(("graph", "node"), "key1")]
|
||||||
values = {keys[0]: ({"result": 42}, None)}
|
values = {keys[0]: ({"result": 42}, None)}
|
||||||
|
|
||||||
# Set value
|
# Set value
|
||||||
@@ -45,9 +46,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):
|
def test_batch_operations(self) -> None:
|
||||||
"""Test batch set and get operations."""
|
"""Test batch set and get operations."""
|
||||||
keys = [
|
keys: list[FullKey] = [
|
||||||
(("graph", "node1"), "key1"),
|
(("graph", "node1"), "key1"),
|
||||||
(("graph", "node2"), "key2"),
|
(("graph", "node2"), "key2"),
|
||||||
(("other", "node"), "key3"),
|
(("other", "node"), "key3"),
|
||||||
@@ -68,9 +69,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):
|
def test_ttl_behavior(self) -> None:
|
||||||
"""Test TTL (time-to-live) functionality."""
|
"""Test TTL (time-to-live) functionality."""
|
||||||
key = (("graph", "node"), "ttl_key")
|
key: FullKey = (("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
|
||||||
@@ -88,10 +89,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):
|
def test_namespace_isolation(self) -> None:
|
||||||
"""Test that different namespaces are isolated."""
|
"""Test that different namespaces are isolated."""
|
||||||
key1 = (("graph1", "node"), "same_key")
|
key1: FullKey = (("graph1", "node"), "same_key")
|
||||||
key2 = (("graph2", "node"), "same_key")
|
key2: FullKey = (("graph2", "node"), "same_key")
|
||||||
|
|
||||||
values = {key1: ({"graph": 1}, None), key2: ({"graph": 2}, None)}
|
values = {key1: ({"graph": 1}, None), key2: ({"graph": 2}, None)}
|
||||||
|
|
||||||
@@ -101,9 +102,12 @@ 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):
|
def test_clear_all(self) -> None:
|
||||||
"""Test clearing all cached values."""
|
"""Test clearing all cached values."""
|
||||||
keys = [(("graph", "node1"), "key1"), (("graph", "node2"), "key2")]
|
keys: list[FullKey] = [
|
||||||
|
(("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)
|
||||||
@@ -119,9 +123,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):
|
def test_clear_by_namespace(self) -> None:
|
||||||
"""Test clearing cached values by namespace."""
|
"""Test clearing cached values by namespace."""
|
||||||
keys = [
|
keys: list[FullKey] = [
|
||||||
(("graph1", "node"), "key1"),
|
(("graph1", "node"), "key1"),
|
||||||
(("graph2", "node"), "key2"),
|
(("graph2", "node"), "key2"),
|
||||||
(("graph1", "other"), "key3"),
|
(("graph1", "other"), "key3"),
|
||||||
@@ -142,7 +146,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):
|
def test_empty_operations(self) -> None:
|
||||||
"""Test behavior with empty keys/values."""
|
"""Test behavior with empty keys/values."""
|
||||||
# Empty get
|
# Empty get
|
||||||
result = self.cache.get([])
|
result = self.cache.get([])
|
||||||
@@ -151,14 +155,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):
|
def test_nonexistent_keys(self) -> None:
|
||||||
"""Test getting keys that don't exist."""
|
"""Test getting keys that don't exist."""
|
||||||
keys = [(("graph", "node"), "nonexistent")]
|
keys: list[FullKey] = [(("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):
|
async def test_async_operations(self) -> None:
|
||||||
"""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)
|
||||||
@@ -167,9 +171,9 @@ class TestRedisCache:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pytest.skip("Redis not available")
|
pytest.skip("Redis not available")
|
||||||
|
|
||||||
cache = RedisCache(client, prefix="test:async:")
|
cache: RedisCache = RedisCache(client, prefix="test:async:")
|
||||||
|
|
||||||
keys = [(("graph", "node"), "async_key")]
|
keys: list[FullKey] = [(("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)
|
||||||
@@ -184,7 +188,7 @@ class TestRedisCache:
|
|||||||
client.flushdb()
|
client.flushdb()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_async_clear(self):
|
async def test_async_clear(self) -> None:
|
||||||
"""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)
|
||||||
@@ -193,9 +197,9 @@ class TestRedisCache:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pytest.skip("Redis not available")
|
pytest.skip("Redis not available")
|
||||||
|
|
||||||
cache = RedisCache(client, prefix="test:async:")
|
cache: RedisCache = RedisCache(client, prefix="test:async:")
|
||||||
|
|
||||||
keys = [(("graph", "node"), "key")]
|
keys: list[FullKey] = [(("graph", "node"), "key")]
|
||||||
values = {keys[0]: ({"data": "test"}, None)}
|
values = {keys[0]: ({"data": "test"}, None)}
|
||||||
|
|
||||||
await cache.aset(values)
|
await cache.aset(values)
|
||||||
@@ -214,44 +218,44 @@ class TestRedisCache:
|
|||||||
# Cleanup
|
# Cleanup
|
||||||
client.flushdb()
|
client.flushdb()
|
||||||
|
|
||||||
def test_redis_unavailable_get(self):
|
def test_redis_unavailable_get(self) -> None:
|
||||||
"""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(bad_client, prefix="test:cache:")
|
cache: RedisCache = RedisCache(bad_client, prefix="test:cache:")
|
||||||
|
|
||||||
keys = [(("graph", "node"), "key")]
|
keys: list[FullKey] = [(("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):
|
def test_redis_unavailable_set(self) -> None:
|
||||||
"""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(bad_client, prefix="test:cache:")
|
cache: RedisCache = RedisCache(bad_client, prefix="test:cache:")
|
||||||
|
|
||||||
keys = [(("graph", "node"), "key")]
|
keys: list[FullKey] = [(("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):
|
async def test_redis_unavailable_async(self) -> None:
|
||||||
"""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(bad_client, prefix="test:cache:")
|
cache: RedisCache = RedisCache(bad_client, prefix="test:cache:")
|
||||||
|
|
||||||
keys = [(("graph", "node"), "key")]
|
keys: list[FullKey] = [(("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)
|
||||||
@@ -261,10 +265,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):
|
def test_corrupted_data_handling(self) -> None:
|
||||||
"""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 = [(("graph", "node"), "valid_key")]
|
keys: list[FullKey] = [(("graph", "node"), "valid_key")]
|
||||||
values = {keys[0]: ({"data": "valid"}, None)}
|
values = {keys[0]: ({"data": "valid"}, None)}
|
||||||
self.cache.set(values)
|
self.cache.set(values)
|
||||||
|
|
||||||
@@ -273,33 +277,36 @@ 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 = [keys[0], (("graph", "node"), "corrupted_key")]
|
all_keys: list[FullKey] = [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):
|
def test_key_parsing_edge_cases(self) -> None:
|
||||||
"""Test key parsing with edge cases."""
|
"""Test key parsing with edge cases."""
|
||||||
# Test empty namespace
|
# Test empty namespace
|
||||||
key1 = ((), "empty_ns")
|
key1: FullKey = ((), "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 = (("graph:with:colons", "node-with-dashes"), "key_with_underscores")
|
key2: FullKey = (
|
||||||
|
("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):
|
def test_large_data_serialization(self) -> None:
|
||||||
"""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 = (("graph", "node"), "large_key")
|
key: FullKey = (("graph", "node"), "large_key")
|
||||||
values = {key: (large_data, None)}
|
values = {key: (large_data, None)}
|
||||||
|
|
||||||
self.cache.set(values)
|
self.cache.set(values)
|
||||||
|
|||||||
@@ -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,3 +1021,27 @@ 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
+551
-542
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
|||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Annotated, Literal, TypedDict
|
||||||
|
|
||||||
|
from langchain_community.tools.tavily_search import TavilySearchResults
|
||||||
|
from langchain_core.messages import BaseMessage
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
from langgraph.graph import END, StateGraph, add_messages
|
||||||
|
from langgraph.prebuilt import ToolNode
|
||||||
|
|
||||||
|
tools = [TavilySearchResults(max_results=1)]
|
||||||
|
|
||||||
|
model_oai = ChatOpenAI(temperature=0)
|
||||||
|
|
||||||
|
model_oai = model_oai.bind_tools(tools)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentState(TypedDict):
|
||||||
|
messages: Annotated[Sequence[BaseMessage], add_messages]
|
||||||
|
|
||||||
|
|
||||||
|
# Define the function that determines whether to continue or not
|
||||||
|
def should_continue(state):
|
||||||
|
messages = state["messages"]
|
||||||
|
last_message = messages[-1]
|
||||||
|
# If there are no tool calls, then we finish
|
||||||
|
if not last_message.tool_calls:
|
||||||
|
return "end"
|
||||||
|
# Otherwise if there is, we continue
|
||||||
|
else:
|
||||||
|
return "continue"
|
||||||
|
|
||||||
|
|
||||||
|
# Define the function that calls the model
|
||||||
|
def call_model(state, config):
|
||||||
|
model = model_oai
|
||||||
|
messages = state["messages"]
|
||||||
|
response = model.invoke(messages)
|
||||||
|
# We return a list, because this will get added to the existing list
|
||||||
|
return {"messages": [response]}
|
||||||
|
|
||||||
|
|
||||||
|
# Define the function to execute tools
|
||||||
|
tool_node = ToolNode(tools)
|
||||||
|
|
||||||
|
|
||||||
|
class ContextSchema(TypedDict):
|
||||||
|
model: Literal["anthropic", "openai"]
|
||||||
|
|
||||||
|
|
||||||
|
# Define a new graph
|
||||||
|
workflow = StateGraph(AgentState, context_schema=ContextSchema)
|
||||||
|
|
||||||
|
# Define the two nodes we will cycle between
|
||||||
|
workflow.add_node("agent", call_model)
|
||||||
|
workflow.add_node("action", tool_node)
|
||||||
|
|
||||||
|
# Set the entrypoint as `agent`
|
||||||
|
# This means that this node is the first one called
|
||||||
|
workflow.set_entry_point("agent")
|
||||||
|
|
||||||
|
# We now add a conditional edge
|
||||||
|
workflow.add_conditional_edges(
|
||||||
|
# First, we define the start node. We use `agent`.
|
||||||
|
# This means these are the edges taken after the `agent` node is called.
|
||||||
|
"agent",
|
||||||
|
# Next, we pass in the function that will determine which node is called next.
|
||||||
|
should_continue,
|
||||||
|
# Finally we pass in a mapping.
|
||||||
|
# The keys are strings, and the values are other nodes.
|
||||||
|
# END is a special node marking that the graph should finish.
|
||||||
|
# What will happen is we will call `should_continue`, and then the output of that
|
||||||
|
# will be matched against the keys in this mapping.
|
||||||
|
# Based on which one it matches, that node will then be called.
|
||||||
|
{
|
||||||
|
# If `tools`, then we call the tool node.
|
||||||
|
"continue": "action",
|
||||||
|
# Otherwise we finish.
|
||||||
|
"end": END,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# We now add a normal edge from `tools` to `agent`.
|
||||||
|
# This means that after `tools` is called, `agent` node is called next.
|
||||||
|
workflow.add_edge("action", "agent")
|
||||||
|
|
||||||
|
# Finally, we compile it!
|
||||||
|
# This compiles it into a LangChain Runnable,
|
||||||
|
# meaning you can use it as you would any other runnable
|
||||||
|
graph = workflow.compile()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[project]
|
||||||
|
name = "graph-prerelease-reqs-additional-deps"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Test for prerelease stuff"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"langgraph==0.6.0"
|
||||||
|
]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[project]
|
||||||
|
name = "graph-prerelease-reqs-zuper-deps"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Test for prerelease stuff"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"langchain-openai==0.3.0"
|
||||||
|
]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"python_version": "3.12",
|
||||||
|
"dependencies": [
|
||||||
|
".",
|
||||||
|
"./deps/additional_deps",
|
||||||
|
"./deps/zuper_deps"
|
||||||
|
],
|
||||||
|
"graphs": {
|
||||||
|
"agent": "./agent.py:graph"
|
||||||
|
},
|
||||||
|
"env": "../.env"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[project]
|
||||||
|
name = "graph-prerelease-reqs"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Test for prerelease stuff"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"langchain-openai==1.0.0a2",
|
||||||
|
"langgraph==1.0.0a2",
|
||||||
|
"langchain_community>=0.3.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
prerelease = "allow"
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Annotated, Literal, TypedDict
|
||||||
|
|
||||||
|
from langchain_community.tools.tavily_search import TavilySearchResults
|
||||||
|
from langchain_core.messages import BaseMessage
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
from langgraph.graph import END, StateGraph, add_messages
|
||||||
|
from langgraph.prebuilt import ToolNode
|
||||||
|
|
||||||
|
tools = [TavilySearchResults(max_results=1)]
|
||||||
|
|
||||||
|
model_oai = ChatOpenAI(temperature=0)
|
||||||
|
|
||||||
|
model_oai = model_oai.bind_tools(tools)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentState(TypedDict):
|
||||||
|
messages: Annotated[Sequence[BaseMessage], add_messages]
|
||||||
|
|
||||||
|
|
||||||
|
# Define the function that determines whether to continue or not
|
||||||
|
def should_continue(state):
|
||||||
|
messages = state["messages"]
|
||||||
|
last_message = messages[-1]
|
||||||
|
# If there are no tool calls, then we finish
|
||||||
|
if not last_message.tool_calls:
|
||||||
|
return "end"
|
||||||
|
# Otherwise if there is, we continue
|
||||||
|
else:
|
||||||
|
return "continue"
|
||||||
|
|
||||||
|
|
||||||
|
# Define the function that calls the model
|
||||||
|
def call_model(state, config):
|
||||||
|
model = model_oai
|
||||||
|
messages = state["messages"]
|
||||||
|
response = model.invoke(messages)
|
||||||
|
# We return a list, because this will get added to the existing list
|
||||||
|
return {"messages": [response]}
|
||||||
|
|
||||||
|
|
||||||
|
# Define the function to execute tools
|
||||||
|
tool_node = ToolNode(tools)
|
||||||
|
|
||||||
|
|
||||||
|
class ContextSchema(TypedDict):
|
||||||
|
model: Literal["anthropic", "openai"]
|
||||||
|
|
||||||
|
|
||||||
|
# Define a new graph
|
||||||
|
workflow = StateGraph(AgentState, context_schema=ContextSchema)
|
||||||
|
|
||||||
|
# Define the two nodes we will cycle between
|
||||||
|
workflow.add_node("agent", call_model)
|
||||||
|
workflow.add_node("action", tool_node)
|
||||||
|
|
||||||
|
# Set the entrypoint as `agent`
|
||||||
|
# This means that this node is the first one called
|
||||||
|
workflow.set_entry_point("agent")
|
||||||
|
|
||||||
|
# We now add a conditional edge
|
||||||
|
workflow.add_conditional_edges(
|
||||||
|
# First, we define the start node. We use `agent`.
|
||||||
|
# This means these are the edges taken after the `agent` node is called.
|
||||||
|
"agent",
|
||||||
|
# Next, we pass in the function that will determine which node is called next.
|
||||||
|
should_continue,
|
||||||
|
# Finally we pass in a mapping.
|
||||||
|
# The keys are strings, and the values are other nodes.
|
||||||
|
# END is a special node marking that the graph should finish.
|
||||||
|
# What will happen is we will call `should_continue`, and then the output of that
|
||||||
|
# will be matched against the keys in this mapping.
|
||||||
|
# Based on which one it matches, that node will then be called.
|
||||||
|
{
|
||||||
|
# If `tools`, then we call the tool node.
|
||||||
|
"continue": "action",
|
||||||
|
# Otherwise we finish.
|
||||||
|
"end": END,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# We now add a normal edge from `tools` to `agent`.
|
||||||
|
# This means that after `tools` is called, `agent` node is called next.
|
||||||
|
workflow.add_edge("action", "agent")
|
||||||
|
|
||||||
|
# Finally, we compile it!
|
||||||
|
# This compiles it into a LangChain Runnable,
|
||||||
|
# meaning you can use it as you would any other runnable
|
||||||
|
graph = workflow.compile()
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"python_version": "3.12",
|
||||||
|
"dependencies": [
|
||||||
|
"."
|
||||||
|
],
|
||||||
|
"graphs": {
|
||||||
|
"agent": "./agent.py:graph"
|
||||||
|
},
|
||||||
|
"env": "../.env"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[project]
|
||||||
|
name = "graph-prerelease-reqs"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Test for prerelease stuff"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"langchain-openai==1.0.0a2",
|
||||||
|
"langgraph==1.0.0a2",
|
||||||
|
"langchain_community>=0.3.0",
|
||||||
|
]
|
||||||
@@ -7,6 +7,7 @@ from langchain_core.messages import BaseMessage
|
|||||||
from langchain_openai import ChatOpenAI
|
from langchain_openai import ChatOpenAI
|
||||||
from langgraph.graph import END, StateGraph, add_messages
|
from langgraph.graph import END, StateGraph, add_messages
|
||||||
from langgraph.prebuilt import ToolNode
|
from langgraph.prebuilt import ToolNode
|
||||||
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
tools = [TavilySearchResults(max_results=1)]
|
tools = [TavilySearchResults(max_results=1)]
|
||||||
|
|
||||||
@@ -17,6 +18,10 @@ model_anth = model_anth.bind_tools(tools)
|
|||||||
model_oai = model_oai.bind_tools(tools)
|
model_oai = model_oai.bind_tools(tools)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentContext(TypedDict):
|
||||||
|
model: Literal["anthropic", "openai"]
|
||||||
|
|
||||||
|
|
||||||
class AgentState(TypedDict):
|
class AgentState(TypedDict):
|
||||||
messages: Annotated[Sequence[BaseMessage], add_messages]
|
messages: Annotated[Sequence[BaseMessage], add_messages]
|
||||||
|
|
||||||
@@ -34,8 +39,8 @@ def should_continue(state):
|
|||||||
|
|
||||||
|
|
||||||
# Define the function that calls the model
|
# Define the function that calls the model
|
||||||
def call_model(state, config):
|
def call_model(state, runtime: Runtime[AgentContext]):
|
||||||
if config["configurable"].get("model", "anthropic") == "anthropic":
|
if runtime.context.get("model", "anthropic") == "anthropic":
|
||||||
model = model_anth
|
model = model_anth
|
||||||
else:
|
else:
|
||||||
model = model_oai
|
model = model_oai
|
||||||
@@ -49,12 +54,8 @@ def call_model(state, config):
|
|||||||
tool_node = ToolNode(tools)
|
tool_node = ToolNode(tools)
|
||||||
|
|
||||||
|
|
||||||
class ContextSchema(TypedDict):
|
|
||||||
model: Literal["anthropic", "openai"]
|
|
||||||
|
|
||||||
|
|
||||||
# Define a new graph
|
# Define a new graph
|
||||||
workflow = StateGraph(AgentState, context_schema=ContextSchema)
|
workflow = StateGraph(AgentState, context_schema=AgentContext)
|
||||||
|
|
||||||
# Define the two nodes we will cycle between
|
# Define the two nodes we will cycle between
|
||||||
workflow.add_node("agent", call_model)
|
workflow.add_node("agent", call_model)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"$schema": "https://langgra.ph/schema.json",
|
||||||
"python_version": "3.12",
|
"python_version": "3.12",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"langchain_community",
|
"langchain_community",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, TypedDict
|
from typing import Annotated, Literal, TypedDict
|
||||||
|
|
||||||
from langchain_anthropic import ChatAnthropic
|
from langchain_anthropic import ChatAnthropic
|
||||||
from langchain_community.tools.tavily_search import TavilySearchResults
|
from langchain_community.tools.tavily_search import TavilySearchResults
|
||||||
@@ -8,6 +8,7 @@ from langchain_core.messages import BaseMessage
|
|||||||
from langchain_openai import ChatOpenAI
|
from langchain_openai import ChatOpenAI
|
||||||
from langgraph.graph import END, StateGraph, add_messages
|
from langgraph.graph import END, StateGraph, add_messages
|
||||||
from langgraph.prebuilt import ToolNode
|
from langgraph.prebuilt import ToolNode
|
||||||
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
tools = [TavilySearchResults(max_results=1)]
|
tools = [TavilySearchResults(max_results=1)]
|
||||||
|
|
||||||
@@ -21,6 +22,10 @@ prompt = open(Path(__file__).parent.parent / "prompt.txt").read()
|
|||||||
subprompt = open(Path(__file__).parent / "subprompt.txt").read()
|
subprompt = open(Path(__file__).parent / "subprompt.txt").read()
|
||||||
|
|
||||||
|
|
||||||
|
class AgentContext(TypedDict):
|
||||||
|
model: Literal["anthropic", "openai"]
|
||||||
|
|
||||||
|
|
||||||
class AgentState(TypedDict):
|
class AgentState(TypedDict):
|
||||||
messages: Annotated[Sequence[BaseMessage], add_messages]
|
messages: Annotated[Sequence[BaseMessage], add_messages]
|
||||||
|
|
||||||
@@ -38,8 +43,8 @@ def should_continue(state):
|
|||||||
|
|
||||||
|
|
||||||
# Define the function that calls the model
|
# Define the function that calls the model
|
||||||
def call_model(state, config):
|
def call_model(state, runtime: Runtime[AgentContext]):
|
||||||
if config["configurable"].get("model", "anthropic") == "anthropic":
|
if runtime.context.get("model", "anthropic") == "anthropic":
|
||||||
model = model_anth
|
model = model_anth
|
||||||
else:
|
else:
|
||||||
model = model_oai
|
model = model_oai
|
||||||
@@ -52,9 +57,8 @@ def call_model(state, config):
|
|||||||
# Define the function to execute tools
|
# Define the function to execute tools
|
||||||
tool_node = ToolNode(tools)
|
tool_node = ToolNode(tools)
|
||||||
|
|
||||||
|
|
||||||
# Define a new graph
|
# Define a new graph
|
||||||
workflow = StateGraph(AgentState)
|
workflow = StateGraph(AgentState, context_schema=AgentContext)
|
||||||
|
|
||||||
# Define the two nodes we will cycle between
|
# Define the two nodes we will cycle between
|
||||||
workflow.add_node("agent", call_model)
|
workflow.add_node("agent", call_model)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"$schema": "https://langgra.ph/schema.json",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"."
|
"."
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, TypedDict
|
from typing import Annotated, Literal, TypedDict
|
||||||
|
|
||||||
from langchain_anthropic import ChatAnthropic
|
from langchain_anthropic import ChatAnthropic
|
||||||
from langchain_community.tools.tavily_search import TavilySearchResults
|
from langchain_community.tools.tavily_search import TavilySearchResults
|
||||||
@@ -8,6 +8,7 @@ from langchain_core.messages import BaseMessage
|
|||||||
from langchain_openai import ChatOpenAI
|
from langchain_openai import ChatOpenAI
|
||||||
from langgraph.graph import END, StateGraph, add_messages
|
from langgraph.graph import END, StateGraph, add_messages
|
||||||
from langgraph.prebuilt import ToolNode
|
from langgraph.prebuilt import ToolNode
|
||||||
|
from langgraph.runtime import Runtime
|
||||||
|
|
||||||
tools = [TavilySearchResults(max_results=1)]
|
tools = [TavilySearchResults(max_results=1)]
|
||||||
|
|
||||||
@@ -21,6 +22,10 @@ prompt = open(Path(__file__).parent.parent / "prompt.txt").read()
|
|||||||
subprompt = open(Path(__file__).parent / "subprompt.txt").read()
|
subprompt = open(Path(__file__).parent / "subprompt.txt").read()
|
||||||
|
|
||||||
|
|
||||||
|
class AgentContext(TypedDict):
|
||||||
|
model: Literal["anthropic", "openai"]
|
||||||
|
|
||||||
|
|
||||||
class AgentState(TypedDict):
|
class AgentState(TypedDict):
|
||||||
messages: Annotated[Sequence[BaseMessage], add_messages]
|
messages: Annotated[Sequence[BaseMessage], add_messages]
|
||||||
|
|
||||||
@@ -38,8 +43,8 @@ def should_continue(state):
|
|||||||
|
|
||||||
|
|
||||||
# Define the function that calls the model
|
# Define the function that calls the model
|
||||||
def call_model(state, config):
|
def call_model(state, runtime: Runtime[AgentContext]):
|
||||||
if config["configurable"].get("model", "anthropic") == "anthropic":
|
if runtime.context.get("model", "anthropic") == "anthropic":
|
||||||
model = model_anth
|
model = model_anth
|
||||||
else:
|
else:
|
||||||
model = model_oai
|
model = model_oai
|
||||||
@@ -54,7 +59,7 @@ tool_node = ToolNode(tools)
|
|||||||
|
|
||||||
|
|
||||||
# Define a new graph
|
# Define a new graph
|
||||||
workflow = StateGraph(AgentState)
|
workflow = StateGraph(AgentState, context_schema=AgentContext)
|
||||||
|
|
||||||
# Define the two nodes we will cycle between
|
# Define the two nodes we will cycle between
|
||||||
workflow.add_node("agent", call_model)
|
workflow.add_node("agent", call_model)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"$schema": "https://langgra.ph/schema.json",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"."
|
"."
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"$schema": "https://langgra.ph/schema.json",
|
||||||
"node_version": "20",
|
"node_version": "20",
|
||||||
"graphs": {
|
"graphs": {
|
||||||
"agent": "./src/agent/graph.ts:graph"
|
"agent": "./src/agent/graph.ts:graph"
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: [
|
||||||
|
"eslint:recommended",
|
||||||
|
"prettier",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
],
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 12,
|
||||||
|
parser: "@typescript-eslint/parser",
|
||||||
|
project: "./tsconfig.json",
|
||||||
|
sourceType: "module",
|
||||||
|
},
|
||||||
|
plugins: ["import", "@typescript-eslint", "no-instanceof"],
|
||||||
|
ignorePatterns: [
|
||||||
|
".eslintrc.cjs",
|
||||||
|
"scripts",
|
||||||
|
"src/utils/lodash/*",
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"dist-cjs",
|
||||||
|
"*.js",
|
||||||
|
"*.cjs",
|
||||||
|
"*.d.ts",
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
"no-process-env": 2,
|
||||||
|
"no-instanceof/no-instanceof": 2,
|
||||||
|
"@typescript-eslint/explicit-module-boundary-types": 0,
|
||||||
|
"@typescript-eslint/no-empty-function": 0,
|
||||||
|
"@typescript-eslint/no-shadow": 0,
|
||||||
|
"@typescript-eslint/no-empty-interface": 0,
|
||||||
|
"@typescript-eslint/no-use-before-define": ["error", "nofunc"],
|
||||||
|
"@typescript-eslint/no-unused-vars": ["warn", { args: "none" }],
|
||||||
|
"@typescript-eslint/no-floating-promises": "error",
|
||||||
|
"@typescript-eslint/no-misused-promises": "error",
|
||||||
|
camelcase: 0,
|
||||||
|
"class-methods-use-this": 0,
|
||||||
|
"import/extensions": [2, "ignorePackages"],
|
||||||
|
"import/no-extraneous-dependencies": [
|
||||||
|
"error",
|
||||||
|
{ devDependencies: ["**/*.test.ts"] },
|
||||||
|
],
|
||||||
|
"import/no-unresolved": 0,
|
||||||
|
"import/prefer-default-export": 0,
|
||||||
|
"keyword-spacing": "error",
|
||||||
|
"max-classes-per-file": 0,
|
||||||
|
"max-len": 0,
|
||||||
|
"no-await-in-loop": 0,
|
||||||
|
"no-bitwise": 0,
|
||||||
|
"no-console": 0,
|
||||||
|
"no-restricted-syntax": 0,
|
||||||
|
"no-shadow": 0,
|
||||||
|
"no-continue": 0,
|
||||||
|
"no-underscore-dangle": 0,
|
||||||
|
"no-use-before-define": 0,
|
||||||
|
"no-useless-constructor": 0,
|
||||||
|
"no-return-await": 0,
|
||||||
|
"consistent-return": 0,
|
||||||
|
"no-else-return": 0,
|
||||||
|
"new-cap": ["error", { properties: false, capIsNew: false }],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"node_version": "20",
|
||||||
|
"graphs": {
|
||||||
|
"agent": "./src/graph.ts:graph"
|
||||||
|
},
|
||||||
|
"env": "../../.env"
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@js-monorepo-example/agent",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/graph.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@js-monorepo-example/shared": "*",
|
||||||
|
"@langchain/core": "^0.3.2",
|
||||||
|
"@langchain/langgraph": "^0.2.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Simple LangGraph.js example for monorepo testing
|
||||||
|
*/
|
||||||
|
import { StateGraph } from "@langchain/langgraph";
|
||||||
|
import { RunnableConfig } from "@langchain/core/runnables";
|
||||||
|
import { StateAnnotation } from "./state.js";
|
||||||
|
import { getGreeting } from "@js-monorepo-example/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple node that uses the shared library
|
||||||
|
*/
|
||||||
|
const callModel = async (
|
||||||
|
state: typeof StateAnnotation.State,
|
||||||
|
_config: RunnableConfig,
|
||||||
|
): Promise<typeof StateAnnotation.Update> => {
|
||||||
|
// Use functions from the shared library
|
||||||
|
const greeting = getGreeting();
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: `${greeting}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple routing function
|
||||||
|
*/
|
||||||
|
export const route = (
|
||||||
|
state: typeof StateAnnotation.State,
|
||||||
|
): "__end__" | "callModel" => {
|
||||||
|
if (state.messages.length > 0) {
|
||||||
|
return "__end__";
|
||||||
|
}
|
||||||
|
return "callModel";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the graph
|
||||||
|
const builder = new StateGraph(StateAnnotation)
|
||||||
|
.addNode("callModel", callModel)
|
||||||
|
.addEdge("__start__", "callModel")
|
||||||
|
.addConditionalEdges("callModel", route);
|
||||||
|
|
||||||
|
export const graph = builder.compile();
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { BaseMessage, BaseMessageLike } from "@langchain/core/messages";
|
||||||
|
import { Annotation, messagesStateReducer } from "@langchain/langgraph";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple state annotation for the agent
|
||||||
|
*/
|
||||||
|
export const StateAnnotation = Annotation.Root({
|
||||||
|
/**
|
||||||
|
* Messages track the primary execution state of the agent.
|
||||||
|
*/
|
||||||
|
messages: Annotation<BaseMessage[], BaseMessageLike[]>({
|
||||||
|
reducer: messagesStateReducer,
|
||||||
|
default: () => [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@js-monorepo-example/shared",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Simple utility functions for monorepo testing
|
||||||
|
*/
|
||||||
|
export function getGreeting(): string {
|
||||||
|
return "Hello from shared library!";
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "js-monorepo-example",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"packageManager": "yarn@1.22.22",
|
||||||
|
"description": "A simple monorepo example for LangGraph integration testing.",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"libs/*",
|
||||||
|
"apps/*"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "turbo build",
|
||||||
|
"clean": "turbo clean",
|
||||||
|
"test": "turbo test",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"lint": "eslint 'apps/**/*.ts' 'libs/**/*.ts'"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"turbo": "^2.5.0",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"@tsconfig/recommended": "^1.0.7",
|
||||||
|
"@eslint/eslintrc": "^3.1.0",
|
||||||
|
"@eslint/js": "^9.9.1",
|
||||||
|
"eslint": "^8.41.0",
|
||||||
|
"eslint-config-prettier": "^8.8.0",
|
||||||
|
"eslint-plugin-import": "^2.27.5",
|
||||||
|
"eslint-plugin-no-instanceof": "^1.0.1",
|
||||||
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.59.8",
|
||||||
|
"@typescript-eslint/parser": "^5.59.8",
|
||||||
|
"prettier": "^3.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/recommended",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"include": ["apps/**/*", "libs/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**"]
|
||||||
|
},
|
||||||
|
"clean": {
|
||||||
|
"dependsOn": ["^clean"]
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"dependsOn": ["^test"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
|||||||
__version__ = "0.4.0"
|
__version__ = "0.4.2"
|
||||||
|
|||||||
@@ -303,6 +303,8 @@ def _build(
|
|||||||
pull: bool,
|
pull: bool,
|
||||||
tag: str,
|
tag: str,
|
||||||
passthrough: Sequence[str] = (),
|
passthrough: Sequence[str] = (),
|
||||||
|
install_command: Optional[str] = None,
|
||||||
|
build_command: Optional[str] = None,
|
||||||
):
|
):
|
||||||
# pull latest images
|
# pull latest images
|
||||||
if pull:
|
if pull:
|
||||||
@@ -322,22 +324,38 @@ def _build(
|
|||||||
"-t",
|
"-t",
|
||||||
tag,
|
tag,
|
||||||
]
|
]
|
||||||
|
# determine build context: use current directory for JS projects, config parent for Python
|
||||||
|
is_js_project = config_json.get("node_version") and not config_json.get(
|
||||||
|
"python_version"
|
||||||
|
)
|
||||||
|
# build/install commands only apply to JS projects for now
|
||||||
|
# without install/build command, JS projects will follow the old behavior
|
||||||
|
if is_js_project and (build_command or install_command):
|
||||||
|
build_context = str(pathlib.Path.cwd())
|
||||||
|
else:
|
||||||
|
build_context = str(config.parent)
|
||||||
|
|
||||||
# apply config
|
# apply config
|
||||||
stdin, additional_contexts = langgraph_cli.config.config_to_docker(
|
stdin, additional_contexts = langgraph_cli.config.config_to_docker(
|
||||||
config, config_json, base_image, api_version
|
config,
|
||||||
|
config_json,
|
||||||
|
base_image,
|
||||||
|
api_version,
|
||||||
|
install_command,
|
||||||
|
build_command,
|
||||||
|
build_context,
|
||||||
)
|
)
|
||||||
# add additional_contexts
|
# add additional_contexts
|
||||||
if additional_contexts:
|
if additional_contexts:
|
||||||
for k, v in additional_contexts.items():
|
for k, v in additional_contexts.items():
|
||||||
args.extend(["--build-context", f"{k}={v}"])
|
args.extend(["--build-context", f"{k}={v}"])
|
||||||
# run docker build
|
|
||||||
runner.run(
|
runner.run(
|
||||||
subp_exec(
|
subp_exec(
|
||||||
"docker",
|
"docker",
|
||||||
"build",
|
"build",
|
||||||
*args,
|
*args,
|
||||||
*passthrough,
|
*passthrough,
|
||||||
str(config.parent),
|
build_context,
|
||||||
input=stdin,
|
input=stdin,
|
||||||
verbose=True,
|
verbose=True,
|
||||||
)
|
)
|
||||||
@@ -366,6 +384,14 @@ def _build(
|
|||||||
"\n --base-image langchain/langgraph-server:0.2 # Pin to a minor version (Python)",
|
"\n --base-image langchain/langgraph-server:0.2 # Pin to a minor version (Python)",
|
||||||
)
|
)
|
||||||
@OPT_API_VERSION
|
@OPT_API_VERSION
|
||||||
|
@click.option(
|
||||||
|
"--install-command",
|
||||||
|
help="Custom install command to run from the build context root. If not provided, auto-detects based on package manager files.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--build-command",
|
||||||
|
help="Custom build command to run from the langgraph.json directory. If not provided, uses default build process.",
|
||||||
|
)
|
||||||
@click.argument("docker_build_args", nargs=-1, type=click.UNPROCESSED)
|
@click.argument("docker_build_args", nargs=-1, type=click.UNPROCESSED)
|
||||||
@cli.command(
|
@cli.command(
|
||||||
help="📦 Build LangGraph API server Docker image.",
|
help="📦 Build LangGraph API server Docker image.",
|
||||||
@@ -381,6 +407,8 @@ def build(
|
|||||||
api_version: Optional[str],
|
api_version: Optional[str],
|
||||||
pull: bool,
|
pull: bool,
|
||||||
tag: str,
|
tag: str,
|
||||||
|
install_command: Optional[str],
|
||||||
|
build_command: Optional[str],
|
||||||
):
|
):
|
||||||
with Runner() as runner, Progress(message="Pulling...") as set:
|
with Runner() as runner, Progress(message="Pulling...") as set:
|
||||||
if shutil.which("docker") is None:
|
if shutil.which("docker") is None:
|
||||||
@@ -397,6 +425,8 @@ def build(
|
|||||||
pull,
|
pull,
|
||||||
tag,
|
tag,
|
||||||
docker_build_args,
|
docker_build_args,
|
||||||
|
install_command,
|
||||||
|
build_command,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ DEFAULT_PYTHON_VERSION = "3.11"
|
|||||||
|
|
||||||
DEFAULT_IMAGE_DISTRO = "debian"
|
DEFAULT_IMAGE_DISTRO = "debian"
|
||||||
|
|
||||||
|
CONSTRAINTS_PATH = "/api/constraints.txt"
|
||||||
|
|
||||||
Distros = Literal["debian", "wolfi", "bullseye", "bookworm"]
|
Distros = Literal["debian", "wolfi", "bullseye", "bookworm"]
|
||||||
|
MiddlewareOrders = Literal["auth_first", "middleware_first"]
|
||||||
|
|
||||||
|
|
||||||
class TTLConfig(TypedDict, total=False):
|
class TTLConfig(TypedDict, total=False):
|
||||||
@@ -357,6 +359,27 @@ class HttpConfig(TypedDict, total=False):
|
|||||||
|
|
||||||
You can include or exclude headers as configurable values to condition your
|
You can include or exclude headers as configurable values to condition your
|
||||||
agent's behavior or permissions on a request's headers."""
|
agent's behavior or permissions on a request's headers."""
|
||||||
|
logging_headers: Optional[ConfigurableHeaderConfig]
|
||||||
|
"""Optional. Defines which headers are excluded from logging."""
|
||||||
|
middleware_order: Optional[MiddlewareOrders]
|
||||||
|
"""Optional. Defines the order in which to apply server customizations.
|
||||||
|
|
||||||
|
Choices:
|
||||||
|
- "auth_first": Authentication hooks (custom or default) are evaluated
|
||||||
|
before custom middleware.
|
||||||
|
- "middleware_first": Custom middleware is evaluated
|
||||||
|
before authentication hooks (custom or default).
|
||||||
|
|
||||||
|
Default is `middleware_first`.
|
||||||
|
"""
|
||||||
|
enable_custom_route_auth: bool
|
||||||
|
"""Optional. If True, authentication is enabled for custom routes,
|
||||||
|
not just the routes that are protected by default.
|
||||||
|
(Routes protected by default include /assistants, /threads, and /runs).
|
||||||
|
|
||||||
|
Default is False. This flag only affects authentication behavior
|
||||||
|
if `app` is provided and contains custom routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class Config(TypedDict, total=False):
|
class Config(TypedDict, total=False):
|
||||||
@@ -771,6 +794,15 @@ def validate_config_file(config_path: pathlib.Path) -> Config:
|
|||||||
return validated
|
return validated
|
||||||
|
|
||||||
|
|
||||||
|
class ReqGenSpec(NamedTuple):
|
||||||
|
host_pkg_path: pathlib.Path
|
||||||
|
container_pkg_path: str
|
||||||
|
container_req_path: str
|
||||||
|
package_type: Literal["pyproject", "setup"]
|
||||||
|
has_uv_lock: bool
|
||||||
|
stage_name: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
class LocalDeps(NamedTuple):
|
class LocalDeps(NamedTuple):
|
||||||
"""A container for referencing and managing local Python dependencies.
|
"""A container for referencing and managing local Python dependencies.
|
||||||
|
|
||||||
@@ -818,6 +850,15 @@ class LocalDeps(NamedTuple):
|
|||||||
additional_contexts: A list of paths to directories that contain local
|
additional_contexts: A list of paths to directories that contain local
|
||||||
dependencies in parent directories. These directories are added to the
|
dependencies in parent directories. These directories are added to the
|
||||||
Docker build context to ensure that the Dockerfile can access them.
|
Docker build context to ensure that the Dockerfile can access them.
|
||||||
|
|
||||||
|
pkgs_missing_reqs: A list of packages that need requirements.txt generated.
|
||||||
|
Each entry is a ReqGenSpec containing:
|
||||||
|
- host_pkg_path: Absolute host path of the package directory
|
||||||
|
- container_pkg_path: Target path in the container for metadata/requirements
|
||||||
|
- container_req_path: Full path to requirements.txt inside the container
|
||||||
|
- package_type: "pyproject" or "setup"
|
||||||
|
- has_uv_lock: True if the package includes uv.lock
|
||||||
|
- stage_name: BuildKit context name if outside the build context, else None
|
||||||
"""
|
"""
|
||||||
|
|
||||||
pip_reqs: list[tuple[pathlib.Path, str]]
|
pip_reqs: list[tuple[pathlib.Path, str]]
|
||||||
@@ -826,7 +867,9 @@ class LocalDeps(NamedTuple):
|
|||||||
# if . is in dependencies, use it as working_dir
|
# if . is in dependencies, use it as working_dir
|
||||||
working_dir: Optional[str] = None
|
working_dir: Optional[str] = None
|
||||||
# if there are local dependencies in parent directories, use additional_contexts
|
# if there are local dependencies in parent directories, use additional_contexts
|
||||||
additional_contexts: list[pathlib.Path] = None
|
additional_contexts: Optional[list[pathlib.Path]] = None
|
||||||
|
# Packages that need requirements.txt generation
|
||||||
|
pkgs_missing_reqs: Optional[list[ReqGenSpec]] = None
|
||||||
|
|
||||||
|
|
||||||
def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps:
|
def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps:
|
||||||
@@ -862,6 +905,7 @@ def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps
|
|||||||
faux_pkgs = {}
|
faux_pkgs = {}
|
||||||
working_dir: Optional[str] = None
|
working_dir: Optional[str] = None
|
||||||
additional_contexts: list[pathlib.Path] = []
|
additional_contexts: list[pathlib.Path] = []
|
||||||
|
pkgs_missing_reqs: list[ReqGenSpec] = []
|
||||||
|
|
||||||
for local_dep in config["dependencies"]:
|
for local_dep in config["dependencies"]:
|
||||||
if not local_dep.startswith("."):
|
if not local_dep.startswith("."):
|
||||||
@@ -901,6 +945,35 @@ def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps
|
|||||||
# set working_dir
|
# set working_dir
|
||||||
if local_dep == ".":
|
if local_dep == ".":
|
||||||
working_dir = f"/deps/{container_name}"
|
working_dir = f"/deps/{container_name}"
|
||||||
|
requirement_path = f"/deps/{container_name}/requirements.txt"
|
||||||
|
|
||||||
|
# Track packages needing requirements.txt generation (real packages only)
|
||||||
|
if "requirements.txt" not in files:
|
||||||
|
has_pyproject = "pyproject.toml" in files
|
||||||
|
has_uv_lock = "uv.lock" in files
|
||||||
|
|
||||||
|
if has_pyproject:
|
||||||
|
pkg_type = "pyproject"
|
||||||
|
has_lock = has_uv_lock
|
||||||
|
else: # setup.py only
|
||||||
|
pkg_type = "setup"
|
||||||
|
has_lock = False
|
||||||
|
|
||||||
|
container_pkg_path = f"/deps/{container_name}"
|
||||||
|
stage_name = None
|
||||||
|
if config_path.parent not in resolved.parents:
|
||||||
|
stage_name = container_name
|
||||||
|
|
||||||
|
pkgs_missing_reqs.append(
|
||||||
|
ReqGenSpec(
|
||||||
|
host_pkg_path=resolved,
|
||||||
|
container_pkg_path=container_pkg_path,
|
||||||
|
container_req_path=requirement_path,
|
||||||
|
package_type=pkg_type,
|
||||||
|
has_uv_lock=has_lock,
|
||||||
|
stage_name=stage_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# We could not find a pyproject.toml or setup.py, so treat as a faux package
|
# We could not find a pyproject.toml or setup.py, so treat as a faux package
|
||||||
if any(file == "__init__.py" for file in files):
|
if any(file == "__init__.py" for file in files):
|
||||||
@@ -911,10 +984,10 @@ def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps
|
|||||||
"Rename the directory to use it as flat-layout package."
|
"Rename the directory to use it as flat-layout package."
|
||||||
)
|
)
|
||||||
check_reserved(resolved.name, local_dep)
|
check_reserved(resolved.name, local_dep)
|
||||||
container_path = f"/deps/__outer_{resolved.name}/{resolved.name}"
|
container_path = f"/deps/outer-{resolved.name}/{resolved.name}"
|
||||||
else:
|
else:
|
||||||
# src layout
|
# src layout
|
||||||
container_path = f"/deps/__outer_{resolved.name}/src"
|
container_path = f"/deps/outer-{resolved.name}/src"
|
||||||
for file in files:
|
for file in files:
|
||||||
rfile = resolved / file
|
rfile = resolved / file
|
||||||
if (
|
if (
|
||||||
@@ -932,19 +1005,27 @@ def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps
|
|||||||
faux_pkgs[resolved] = (local_dep, container_path)
|
faux_pkgs[resolved] = (local_dep, container_path)
|
||||||
if local_dep == ".":
|
if local_dep == ".":
|
||||||
working_dir = container_path
|
working_dir = container_path
|
||||||
|
requirement_path = f"{container_path}/requirements.txt"
|
||||||
|
|
||||||
# If the faux package has a requirements.txt, we'll add
|
# If the package has a requirements.txt, we'll add
|
||||||
# the path to the list of requirements to install.
|
# the path to the list of requirements to install.
|
||||||
if "requirements.txt" in files:
|
if "requirements.txt" in files:
|
||||||
rfile = resolved / "requirements.txt"
|
rfile = resolved / "requirements.txt"
|
||||||
pip_reqs.append(
|
pip_reqs.append(
|
||||||
(
|
(
|
||||||
rfile,
|
rfile,
|
||||||
f"{container_path}/requirements.txt",
|
requirement_path,
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return LocalDeps(pip_reqs, real_pkgs, faux_pkgs, working_dir, additional_contexts)
|
return LocalDeps(
|
||||||
|
pip_reqs,
|
||||||
|
real_pkgs,
|
||||||
|
faux_pkgs,
|
||||||
|
working_dir,
|
||||||
|
additional_contexts,
|
||||||
|
pkgs_missing_reqs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _update_graph_paths(
|
def _update_graph_paths(
|
||||||
@@ -1239,6 +1320,110 @@ def get_build_tools_to_uninstall(config: Config) -> tuple[str]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_files(spec: ReqGenSpec) -> list[str]:
|
||||||
|
files = ["pyproject.toml"] if spec.package_type == "pyproject" else ["setup.py"]
|
||||||
|
if spec.package_type == "pyproject" and spec.has_uv_lock:
|
||||||
|
files.append("uv.lock")
|
||||||
|
if spec.package_type == "setup" and (spec.host_pkg_path / "setup.cfg").exists():
|
||||||
|
files.append("setup.cfg")
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def _get_reqs_gen_cmd(spec: ReqGenSpec) -> str:
|
||||||
|
if spec.package_type == "pyproject" and spec.has_uv_lock:
|
||||||
|
return "uv export --no-hashes --no-dev --no-emit-local -o 'requirements.txt'"
|
||||||
|
if spec.package_type == "pyproject":
|
||||||
|
return f"uv pip compile pyproject.toml -o 'requirements.txt' --constraint {CONSTRAINTS_PATH}"
|
||||||
|
return (
|
||||||
|
f"uv pip compile setup.py -o 'requirements.txt' --constraint {CONSTRAINTS_PATH}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_requirements_from_metadata(
|
||||||
|
config_path: pathlib.Path,
|
||||||
|
local_deps: LocalDeps,
|
||||||
|
pip_installer: Literal["uv", "pip"],
|
||||||
|
) -> str:
|
||||||
|
"""Generate requirements.txt from uv.lock, pyproject.toml, or setup.py.
|
||||||
|
|
||||||
|
This function creates Docker layers that:
|
||||||
|
1. Copy packaging metadata files (pyproject.toml, setup.py, uv.lock, etc.)
|
||||||
|
2. Generate requirements.txt using appropriate uv commands:
|
||||||
|
- `uv export` if uv.lock exists (exact locked versions)
|
||||||
|
- `uv pip compile` for pyproject.toml or setup.py (fresh resolution)
|
||||||
|
|
||||||
|
The generated requirements.txt files are then handled by the existing
|
||||||
|
pip_reqs installation logic, maintaining a single code path for all packages.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- pyproject.toml with uv.lock → uv export (preserves lock file versions)
|
||||||
|
- pyproject.toml without uv.lock → uv pip compile (resolve dependencies)
|
||||||
|
- setup.py (with optional setup.cfg) → uv pip compile (resolve dependencies)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path: Path to the langgraph.json config file
|
||||||
|
local_deps: LocalDeps object containing package information
|
||||||
|
pip_installer: Either "uv" or "pip"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Docker instruction string for requirements.txt generation,
|
||||||
|
or empty string if not applicable (pip installer or no packages to generate)
|
||||||
|
"""
|
||||||
|
if pip_installer != "uv" or not local_deps.pkgs_missing_reqs:
|
||||||
|
# if installer is pip, we need pip-tools to generate requirements.txt
|
||||||
|
# this doesn't come automatically with pip, so we need to install it
|
||||||
|
# in base images, but ci uses uv, which is where we need the caching
|
||||||
|
# so limit to uv.
|
||||||
|
return ""
|
||||||
|
|
||||||
|
docker_lines = ["# -- Generate requirements.txt for packages without one --"]
|
||||||
|
|
||||||
|
# Layer 1: Copy packaging metadata files needed for requirements generation
|
||||||
|
docker_lines.append("# Copy packaging metadata files")
|
||||||
|
for spec in sorted(
|
||||||
|
local_deps.pkgs_missing_reqs, key=lambda s: s.container_pkg_path
|
||||||
|
):
|
||||||
|
for file_name in _metadata_files(spec):
|
||||||
|
if (
|
||||||
|
local_deps.additional_contexts
|
||||||
|
and spec.host_pkg_path in local_deps.additional_contexts
|
||||||
|
):
|
||||||
|
if not spec.stage_name:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Package {spec.host_pkg_path} in additional_contexts but has no stage_name"
|
||||||
|
)
|
||||||
|
docker_lines.append(
|
||||||
|
f"COPY --from={spec.stage_name} {file_name} {spec.container_pkg_path}/{file_name}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
file_relpath = (spec.host_pkg_path / file_name).relative_to(
|
||||||
|
config_path.parent
|
||||||
|
)
|
||||||
|
docker_lines.append(
|
||||||
|
f"ADD {file_relpath} {spec.container_pkg_path}/{file_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Layer 2: Generate requirements.txt files using appropriate uv commands
|
||||||
|
docker_lines.append("")
|
||||||
|
docker_lines.append("# Generate requirements.txt from packaging metadata")
|
||||||
|
for spec in sorted(
|
||||||
|
local_deps.pkgs_missing_reqs, key=lambda s: s.container_pkg_path
|
||||||
|
):
|
||||||
|
pkg_name = spec.host_pkg_path.name
|
||||||
|
if spec.package_type == "pyproject" and spec.has_uv_lock:
|
||||||
|
docker_lines.append(f"# Generate from uv.lock for {pkg_name}")
|
||||||
|
elif spec.package_type == "pyproject":
|
||||||
|
docker_lines.append(f"# Compile from pyproject.toml for {pkg_name}")
|
||||||
|
else:
|
||||||
|
docker_lines.append(f"# Compile from setup.py for {pkg_name}")
|
||||||
|
docker_lines.append(
|
||||||
|
f"RUN cd '{spec.container_pkg_path}' && {_get_reqs_gen_cmd(spec)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
docker_lines.append("# -- End of requirements.txt generation --")
|
||||||
|
return os.linesep.join(docker_lines)
|
||||||
|
|
||||||
|
|
||||||
def python_config_to_docker(
|
def python_config_to_docker(
|
||||||
config_path: pathlib.Path,
|
config_path: pathlib.Path,
|
||||||
config: Config,
|
config: Config,
|
||||||
@@ -1261,9 +1446,19 @@ def python_config_to_docker(
|
|||||||
raise ValueError(f"Invalid pip_installer: {pip_installer}")
|
raise ValueError(f"Invalid pip_installer: {pip_installer}")
|
||||||
|
|
||||||
# configure pip
|
# configure pip
|
||||||
pip_install = f"PYTHONDONTWRITEBYTECODE=1 {install_cmd} --no-cache-dir -c /api/constraints.txt"
|
local_reqs_pip_install = (
|
||||||
|
f"PYTHONDONTWRITEBYTECODE=1 {install_cmd} --no-cache-dir -c {CONSTRAINTS_PATH}"
|
||||||
|
)
|
||||||
|
global_reqs_pip_install = (
|
||||||
|
f"PYTHONDONTWRITEBYTECODE=1 {install_cmd} --no-cache-dir -c {CONSTRAINTS_PATH}"
|
||||||
|
)
|
||||||
if config.get("pip_config_file"):
|
if config.get("pip_config_file"):
|
||||||
pip_install = f"PIP_CONFIG_FILE=/pipconfig.txt {pip_install}"
|
local_reqs_pip_install = (
|
||||||
|
f"PIP_CONFIG_FILE=/pipconfig.txt {local_reqs_pip_install}"
|
||||||
|
)
|
||||||
|
global_reqs_pip_install = (
|
||||||
|
f"PIP_CONFIG_FILE=/pipconfig.txt {global_reqs_pip_install}"
|
||||||
|
)
|
||||||
pip_config_file_str = (
|
pip_config_file_str = (
|
||||||
f"ADD {config['pip_config_file']} /pipconfig.txt"
|
f"ADD {config['pip_config_file']} /pipconfig.txt"
|
||||||
if config.get("pip_config_file")
|
if config.get("pip_config_file")
|
||||||
@@ -1280,30 +1475,69 @@ def python_config_to_docker(
|
|||||||
# Rewrite HTTP app path, so it points to the correct location in the Docker container
|
# Rewrite HTTP app path, so it points to the correct location in the Docker container
|
||||||
_update_http_app_path(config_path, config, local_deps)
|
_update_http_app_path(config_path, config, local_deps)
|
||||||
|
|
||||||
pip_pkgs_str = f"RUN {pip_install} {' '.join(pypi_deps)}" if pypi_deps else ""
|
pip_pkgs_str = (
|
||||||
if local_deps.pip_reqs:
|
f"RUN {local_reqs_pip_install} {' '.join(pypi_deps)}" if pypi_deps else ""
|
||||||
pip_reqs_str = os.linesep.join(
|
)
|
||||||
(
|
|
||||||
f"COPY --from=__outer_{reqpath.name} requirements.txt {destpath}"
|
|
||||||
if reqpath.parent in local_deps.additional_contexts
|
|
||||||
else f"ADD {reqpath.relative_to(config_path.parent)} {destpath}"
|
|
||||||
)
|
|
||||||
for reqpath, destpath in local_deps.pip_reqs
|
|
||||||
)
|
|
||||||
pip_reqs_str += f"{os.linesep}RUN {pip_install} {' '.join('-r ' + r for _, r in local_deps.pip_reqs)}"
|
|
||||||
pip_reqs_str = f"""# -- Installing local requirements --
|
|
||||||
{pip_reqs_str}
|
|
||||||
# -- End of local requirements install --"""
|
|
||||||
|
|
||||||
else:
|
# Generate requirements.txt layer for packages that need it
|
||||||
pip_reqs_str = ""
|
# This happens BEFORE copying existing requirements.txt files
|
||||||
|
generated_reqs_str = _generate_requirements_from_metadata(
|
||||||
|
config_path, local_deps, pip_installer
|
||||||
|
)
|
||||||
|
# Combine existing requirements.txt with generated ones in a single deterministic layer
|
||||||
|
all_req_paths: list[str] = []
|
||||||
|
copy_lines: list[str] = []
|
||||||
|
|
||||||
|
# Map additional_contexts path -> stage name
|
||||||
|
additional_ctx_stage: dict[pathlib.Path, str] = {}
|
||||||
|
for p in local_deps.additional_contexts or []:
|
||||||
|
if p in local_deps.real_pkgs:
|
||||||
|
additional_ctx_stage[p] = local_deps.real_pkgs[p][1]
|
||||||
|
elif p in local_deps.faux_pkgs:
|
||||||
|
additional_ctx_stage[p] = f"outer-{p.name}"
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Package {p} in additional_contexts but not in real_pkgs or faux_pkgs"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Existing reqs
|
||||||
|
for reqpath, destpath in local_deps.pip_reqs or []:
|
||||||
|
if local_deps.additional_contexts and reqpath.parent in additional_ctx_stage:
|
||||||
|
copy_lines.append(
|
||||||
|
f"COPY --from={additional_ctx_stage[reqpath.parent]} requirements.txt {destpath}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
copy_lines.append(
|
||||||
|
f"ADD {reqpath.relative_to(config_path.parent)} {destpath}"
|
||||||
|
)
|
||||||
|
all_req_paths.append(destpath)
|
||||||
|
|
||||||
|
# Generated reqs
|
||||||
|
if (
|
||||||
|
pip_installer == "uv"
|
||||||
|
): # we are only generate a requirements.txt if installer is uv (for now)
|
||||||
|
for spec in local_deps.pkgs_missing_reqs or []:
|
||||||
|
all_req_paths.append(spec.container_req_path)
|
||||||
|
|
||||||
|
pip_reqs_str = ""
|
||||||
|
if all_req_paths:
|
||||||
|
# Stabilize order
|
||||||
|
all_req_paths = sorted(set(all_req_paths))
|
||||||
|
# Install each requirements.txt sequentially. This mimics the previous sequential solver behavior
|
||||||
|
# which allows to adjust/downgrade packages between installs, so no conflict is raised.
|
||||||
|
pip_reqs_str = f"""# -- Installing from requirements.txt files --
|
||||||
|
{os.linesep.join(copy_lines)}
|
||||||
|
{os.linesep.join(f"RUN {local_reqs_pip_install} -r '{p}'" for p in all_req_paths)}
|
||||||
|
# -- End of requirements.txt install --"""
|
||||||
|
|
||||||
|
# generate lock file if real package and lock file missing
|
||||||
|
|
||||||
# https://setuptools.pypa.io/en/latest/userguide/datafiles.html#package-data
|
# https://setuptools.pypa.io/en/latest/userguide/datafiles.html#package-data
|
||||||
# https://til.simonwillison.net/python/pyproject
|
# https://til.simonwillison.net/python/pyproject
|
||||||
faux_pkgs_str = f"{os.linesep}{os.linesep}".join(
|
faux_pkgs_str = f"{os.linesep}{os.linesep}".join(
|
||||||
(
|
(
|
||||||
f"""# -- Adding non-package dependency {fullpath.name} --
|
f"""# -- Adding non-package dependency {fullpath.name} --
|
||||||
COPY --from=__outer_{fullpath.name} . {destpath}"""
|
COPY --from=outer-{fullpath.name} . {destpath}"""
|
||||||
if fullpath in local_deps.additional_contexts
|
if fullpath in local_deps.additional_contexts
|
||||||
else f"""# -- Adding non-package dependency {fullpath.name} --
|
else f"""# -- Adding non-package dependency {fullpath.name} --
|
||||||
ADD {relpath} {destpath}"""
|
ADD {relpath} {destpath}"""
|
||||||
@@ -1318,7 +1552,7 @@ RUN set -ex && \\
|
|||||||
'[build-system]' \\
|
'[build-system]' \\
|
||||||
'requires = ["setuptools>=61"]' \\
|
'requires = ["setuptools>=61"]' \\
|
||||||
'build-backend = "setuptools.build_meta"'; do \\
|
'build-backend = "setuptools.build_meta"'; do \\
|
||||||
echo "$line" >> /deps/__outer_{fullpath.name}/pyproject.toml; \\
|
echo "$line" >> /deps/outer-{fullpath.name}/pyproject.toml; \\
|
||||||
done
|
done
|
||||||
# -- End of non-package dependency {fullpath.name} --"""
|
# -- End of non-package dependency {fullpath.name} --"""
|
||||||
for fullpath, (relpath, destpath) in local_deps.faux_pkgs.items()
|
for fullpath, (relpath, destpath) in local_deps.faux_pkgs.items()
|
||||||
@@ -1350,6 +1584,7 @@ ADD {relpath} /deps/{name}
|
|||||||
install_node_str,
|
install_node_str,
|
||||||
pip_config_file_str,
|
pip_config_file_str,
|
||||||
pip_pkgs_str,
|
pip_pkgs_str,
|
||||||
|
generated_reqs_str,
|
||||||
pip_reqs_str,
|
pip_reqs_str,
|
||||||
local_pkgs_str,
|
local_pkgs_str,
|
||||||
faux_pkgs_str,
|
faux_pkgs_str,
|
||||||
@@ -1387,7 +1622,7 @@ ADD {relpath} /deps/{name}
|
|||||||
[
|
[
|
||||||
"# -- Installing JS dependencies --",
|
"# -- Installing JS dependencies --",
|
||||||
f"ENV NODE_VERSION={config.get('node_version') or DEFAULT_NODE_VERSION}",
|
f"ENV NODE_VERSION={config.get('node_version') or DEFAULT_NODE_VERSION}",
|
||||||
f"RUN cd {local_deps.working_dir} && {_get_node_pm_install_cmd(config_path, config)} && tsx /api/langgraph_api/js/build.mts",
|
f"RUN cd '{local_deps.working_dir}' && {_get_node_pm_install_cmd(config_path, config)} && tsx /api/langgraph_api/js/build.mts",
|
||||||
"# -- End of JS dependencies install --",
|
"# -- End of JS dependencies install --",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -1400,7 +1635,13 @@ ADD {relpath} /deps/{name}
|
|||||||
installs,
|
installs,
|
||||||
"",
|
"",
|
||||||
"# -- Installing all local dependencies --",
|
"# -- Installing all local dependencies --",
|
||||||
f"RUN {pip_install} -e /deps/*",
|
f"""RUN for dep in /deps/*; do \
|
||||||
|
echo "Installing $dep"; \
|
||||||
|
if [ -d "$dep" ]; then \
|
||||||
|
echo "Installing $dep"; \
|
||||||
|
(cd "$dep" && {global_reqs_pip_install} .); \
|
||||||
|
fi; \
|
||||||
|
done""",
|
||||||
"# -- End of local dependencies install --",
|
"# -- End of local dependencies install --",
|
||||||
os.linesep.join(env_vars),
|
os.linesep.join(env_vars),
|
||||||
"",
|
"",
|
||||||
@@ -1421,7 +1662,7 @@ ADD {relpath} /deps/{name}
|
|||||||
if p in local_deps.real_pkgs:
|
if p in local_deps.real_pkgs:
|
||||||
name = local_deps.real_pkgs[p][1]
|
name = local_deps.real_pkgs[p][1]
|
||||||
elif p in local_deps.faux_pkgs:
|
elif p in local_deps.faux_pkgs:
|
||||||
name = f"__outer_{p.name}"
|
name = f"outer-{p.name}"
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f"Unknown additional context: {p}")
|
raise RuntimeError(f"Unknown additional context: {p}")
|
||||||
additional_contexts[name] = str(p)
|
additional_contexts[name] = str(p)
|
||||||
@@ -1434,9 +1675,28 @@ def node_config_to_docker(
|
|||||||
config: Config,
|
config: Config,
|
||||||
base_image: str,
|
base_image: str,
|
||||||
api_version: Optional[str] = None,
|
api_version: Optional[str] = None,
|
||||||
|
install_command: Optional[str] = None,
|
||||||
|
build_command: Optional[str] = None,
|
||||||
|
build_context: Optional[str] = None,
|
||||||
) -> tuple[str, dict[str, str]]:
|
) -> tuple[str, dict[str, str]]:
|
||||||
faux_path = f"/deps/{config_path.parent.name}"
|
# Calculate paths for monorepo support
|
||||||
install_cmd = _get_node_pm_install_cmd(config_path, config)
|
if build_context:
|
||||||
|
relative_workdir = _calculate_relative_workdir(config_path, build_context)
|
||||||
|
container_name = pathlib.Path(build_context).name
|
||||||
|
if relative_workdir:
|
||||||
|
faux_path = f"/deps/{container_name}/{relative_workdir}"
|
||||||
|
else:
|
||||||
|
faux_path = f"/deps/{container_name}"
|
||||||
|
else:
|
||||||
|
# Backward compatibility: use the original behavior
|
||||||
|
faux_path = f"/deps/{config_path.parent.name}"
|
||||||
|
|
||||||
|
# Use custom install command or auto-detect
|
||||||
|
if install_command:
|
||||||
|
install_cmd = install_command
|
||||||
|
else:
|
||||||
|
install_cmd = _get_node_pm_install_cmd(config_path, config)
|
||||||
|
|
||||||
image_str = docker_tag(config, base_image, api_version)
|
image_str = docker_tag(config, base_image, api_version)
|
||||||
|
|
||||||
env_vars: list[str] = []
|
env_vars: list[str] = []
|
||||||
@@ -1463,20 +1723,35 @@ def node_config_to_docker(
|
|||||||
|
|
||||||
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'")
|
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'")
|
||||||
|
|
||||||
|
# For monorepo support, we need to handle install and build commands differently
|
||||||
|
if build_context:
|
||||||
|
# Monorepo case: install from root, build from config directory
|
||||||
|
container_root = f"/deps/{pathlib.Path(build_context).name}"
|
||||||
|
install_step = f"RUN cd {container_root} && {install_cmd}"
|
||||||
|
|
||||||
|
if build_command:
|
||||||
|
build_step = f"RUN cd {faux_path} && {build_command}"
|
||||||
|
else:
|
||||||
|
build_step = 'RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts'
|
||||||
|
else:
|
||||||
|
# Original behavior: everything happens in the same directory
|
||||||
|
install_step = f"RUN cd {faux_path} && {install_cmd}"
|
||||||
|
build_step = 'RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts'
|
||||||
|
|
||||||
docker_file_contents = [
|
docker_file_contents = [
|
||||||
f"FROM {image_str}",
|
f"FROM {image_str}",
|
||||||
"",
|
"",
|
||||||
os.linesep.join(config["dockerfile_lines"]),
|
os.linesep.join(config["dockerfile_lines"]),
|
||||||
"",
|
"",
|
||||||
f"ADD . {faux_path}",
|
f"ADD . {faux_path if not build_context else container_root}",
|
||||||
"",
|
"",
|
||||||
f"RUN cd {faux_path} && {install_cmd}",
|
install_step,
|
||||||
"",
|
"",
|
||||||
os.linesep.join(env_vars),
|
os.linesep.join(env_vars),
|
||||||
"",
|
"",
|
||||||
f"WORKDIR {faux_path}",
|
f"WORKDIR {faux_path}",
|
||||||
"",
|
"",
|
||||||
'RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts',
|
build_step,
|
||||||
]
|
]
|
||||||
|
|
||||||
return os.linesep.join(docker_file_contents), {}
|
return os.linesep.join(docker_file_contents), {}
|
||||||
@@ -1524,16 +1799,42 @@ def docker_tag(
|
|||||||
return f"{base_image}:{full_tag}"
|
return f"{base_image}:{full_tag}"
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_relative_workdir(config_path: pathlib.Path, build_context: str) -> str:
|
||||||
|
"""Calculate the relative path from build context to langgraph.json directory."""
|
||||||
|
config_dir = config_path.parent.resolve()
|
||||||
|
build_context_path = pathlib.Path(build_context).resolve()
|
||||||
|
|
||||||
|
try:
|
||||||
|
relative_path = config_dir.relative_to(build_context_path)
|
||||||
|
return str(relative_path) if str(relative_path) != "." else ""
|
||||||
|
except ValueError as _:
|
||||||
|
raise ValueError(
|
||||||
|
f"Configuration file {config_path} is not under the build context {build_context}. "
|
||||||
|
f"Please run the command from a directory that contains your langgraph.json file, "
|
||||||
|
) from None
|
||||||
|
|
||||||
|
|
||||||
def config_to_docker(
|
def config_to_docker(
|
||||||
config_path: pathlib.Path,
|
config_path: pathlib.Path,
|
||||||
config: Config,
|
config: Config,
|
||||||
base_image: Optional[str] = None,
|
base_image: Optional[str] = None,
|
||||||
api_version: Optional[str] = None,
|
api_version: Optional[str] = None,
|
||||||
|
install_command: Optional[str] = None,
|
||||||
|
build_command: Optional[str] = None,
|
||||||
|
build_context: Optional[str] = None,
|
||||||
) -> tuple[str, dict[str, str]]:
|
) -> tuple[str, dict[str, str]]:
|
||||||
base_image = base_image or default_base_image(config)
|
base_image = base_image or default_base_image(config)
|
||||||
|
|
||||||
if config.get("node_version") and not config.get("python_version"):
|
if config.get("node_version") and not config.get("python_version"):
|
||||||
return node_config_to_docker(config_path, config, base_image, api_version)
|
return node_config_to_docker(
|
||||||
|
config_path,
|
||||||
|
config,
|
||||||
|
base_image,
|
||||||
|
api_version,
|
||||||
|
install_command,
|
||||||
|
build_command,
|
||||||
|
build_context,
|
||||||
|
)
|
||||||
|
|
||||||
return python_config_to_docker(config_path, config, base_image, api_version)
|
return python_config_to_docker(config_path, config, base_image, api_version)
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ def _parse_version(version: str) -> Version:
|
|||||||
patch = "0"
|
patch = "0"
|
||||||
else:
|
else:
|
||||||
major, minor, patch = parts
|
major, minor, patch = parts
|
||||||
return Version(int(major.lstrip("v")), int(minor), int(patch.split("-")[0]))
|
return Version(
|
||||||
|
int(major.lstrip("v")), int(minor), int(patch.split("-")[0].split("+")[0])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def check_capabilities(runner) -> DockerCapabilities:
|
def check_capabilities(runner) -> DockerCapabilities:
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://langgra.ph/schema.json",
|
||||||
|
"dependencies": [".", "../../libs/shared", "../../libs/common"],
|
||||||
|
"graphs": {
|
||||||
|
"agent": "./src/agent/graph.py:graph"
|
||||||
|
},
|
||||||
|
"env": ".env"
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[project]
|
||||||
|
name = "agent"
|
||||||
|
version = "0.0.1"
|
||||||
|
description = "Agent for the Python monorepo"
|
||||||
|
authors = [
|
||||||
|
{ name = "Developer", email = "dev@example.com" },
|
||||||
|
]
|
||||||
|
license = { text = "MIT" }
|
||||||
|
requires-python = ">=3.11,<4.0"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=73.0.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = ["agent"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-dir]
|
||||||
|
"agent" = "src/agent"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user