Compare commits

..
Author SHA1 Message Date
Vadym BardaandGitHub 4a45f6c99a langgraph, sqlite, postgres: update to use langgraph-checkpoint==2.0.0 (#1946) 2024-10-01 15:28:06 -04:00
Vadym BardaandGitHub 00662ab557 ci: update the check for changed notebooks (#1945) 2024-10-01 18:14:32 +00:00
Brace SproulandGitHub 11d636ff83 Merge pull request #1910 from langchain-ai/wfh/js_store_client
Add JS SDK Store Client
2024-10-01 10:44:06 -07:00
Vadym BardaandGitHub 78c9c15b14 langgraph, checkpoint: update versions (#1942) 2024-10-01 13:43:07 -04:00
Vadym BardaandGitHub 46b52b2426 ci: run notebook checks for changed notebooks (#1941) 2024-10-01 16:50:24 +00:00
Eugene YurtsevandGitHub 674180ad0c docs: Add linting check as part of docs deploy (#1939)
We'll need to add the same thing as part of CI testing, but a second check
during deployment wont hurt in case CI is by passed for whatever reason.
2024-10-01 10:50:39 -04:00
Vadym BardaandGitHub 71efff7cee ci: add a script to check sync and async methods in SDK (#1938) 2024-10-01 09:50:26 -04:00
William FHandGitHub dd88ac6224 Bump SDK Py (#1935) 2024-10-01 08:17:51 +00:00
Nuno CamposandGitHub 53c2e4d8c2 Merge pull request #1932 from langchain-ai/eugene/format_docs
docs: format with ruff
2024-09-30 19:13:47 -07:00
Eugene Yurtsev 70b0032191 format docs 2024-09-30 22:08:27 -04:00
William FHandGitHub 445b795037 [Docs] update min bounds from API (#1929) 2024-09-30 17:06:34 -07:00
William FHandGitHub 29f58fd9e5 Custom loads support in postgres checkpointer (#1930) 2024-09-30 16:42:23 -07:00
Vadym BardaandGitHub 716d23e269 langgraph: release 0.2.31 (#1928) 2024-09-30 18:33:34 -04:00
Vadym BardaandGitHub a9df3a3515 langgraph: use correct logic for binding tools in prebuilt agent (#1927) 2024-09-30 22:22:48 +00:00
Vadym BardaandGitHub da935e7805 langgraph: fix edge case with string enums as node names (#1926) 2024-09-30 18:00:33 -04:00
Andrew NguonlyandGitHub 6d1d705b84 Update API docs. (#1925) 2024-09-30 14:23:32 -07:00
William FHandGitHub 97f79fc66b Add Postgres Store Implementation (#1906) 2024-09-30 21:18:58 +00:00
Isaac FranciscoandGitHub 17dc1108a3 docs: __start__/__end__ consistency (#1874)
* changes

* skip mermaid

* retries fix

* viz error

* changes
2024-09-30 13:55:53 -07:00
Eugene YurtsevandGitHub d29747906d docs: Add ruff commands to Makefile and pyproject (#1921) 2024-09-30 16:19:17 -04:00
Vadym BardaandGitHub cb8db211c2 langgraph: bump checkpoint (#1922) 2024-09-30 15:56:46 -04:00
Isaac FranciscoandGitHub 865436529e simple -> simply (#1919) 2024-09-30 19:30:29 +00:00
William Fu-Hinthorn 4fca814d3a Merge branch 'main' into wfh/js_store_client 2024-09-30 10:49:09 -07:00
William Fu-Hinthorn d309c36388 Review 2024-09-30 10:48:49 -07:00
William Fu-Hinthorn f064a5969d Add JS SDK Store Client 2024-09-30 09:08:37 -07:00
94 changed files with 4180 additions and 1702 deletions
+64
View File
@@ -0,0 +1,64 @@
import ast
import os
from itertools import filterfalse
from typing import List, Tuple
ROOT_PATH = os.path.abspath(os.path.join(__file__, "..", "..", ".."))
CLIENT_PATH = os.path.join(ROOT_PATH, "libs", "sdk-py", "langgraph_sdk", "client.py")
def get_class_methods(node: ast.ClassDef) -> List[str]:
return [n.name for n in node.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
def find_classes(tree: ast.AST) -> List[Tuple[str, List[str]]]:
classes = []
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
methods = get_class_methods(node)
classes.append((node.name, methods))
return classes
def compare_sync_async_methods(sync_methods: List[str], async_methods: List[str]) -> List[str]:
sync_set = set(sync_methods)
async_set = set(async_methods)
missing_in_sync = list(async_set - sync_set)
missing_in_async = list(sync_set - async_set)
return missing_in_sync + missing_in_async
def main():
with open(CLIENT_PATH, "r") as file:
tree = ast.parse(file.read())
classes = find_classes(tree)
def is_sync(class_spec: Tuple[str, List[str]]) -> bool:
return class_spec[0].startswith("Sync")
sync_class_name_to_methods = {class_name: class_methods for class_name, class_methods in filter(is_sync, classes)}
async_class_name_to_methods = {class_name: class_methods for class_name, class_methods in filterfalse(is_sync, classes)}
mismatches = []
for async_class_name, async_class_methods in async_class_name_to_methods.items():
sync_class_name = "Sync" + async_class_name
sync_class_methods = sync_class_name_to_methods.get(sync_class_name, [])
diff = compare_sync_async_methods(sync_class_methods, async_class_methods)
if diff:
mismatches.append((sync_class_name, async_class_name, diff))
if mismatches:
error_message = "Mismatches found between sync and async client methods:\n"
for sync_class_name, async_class_name, diff in mismatches:
error_message += f"{sync_class_name} vs {async_class_name}:\n"
for method in diff:
error_message += f" - {method}\n"
raise ValueError(error_message)
print("All sync and async client methods match.")
if __name__ == "__main__":
main()
+12
View File
@@ -66,6 +66,18 @@ jobs:
uses: ./.github/workflows/_test_scheduler_kafka.yml
secrets: inherit
check-sdk-methods:
name: "Check SDK methods matching"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Run check_sdk_methods script
run: python .github/scripts/check_sdk_methods.py
integration-test:
name: CLI integration test
uses: ./.github/workflows/_integration_test.yml
+24
View File
@@ -22,7 +22,27 @@ concurrency:
cancel-in-progress: false
jobs:
get-changed-files:
runs-on: ubuntu-latest
outputs:
changed-files: ${{ steps.changed-files.outputs.all }}
steps:
- uses: actions/checkout@v4
- name: Get changed files
id: changed-files
uses: Ana06/get-changed-files@v2.3.0
with:
filter: "docs/docs/**"
run-changed-notebooks:
needs: get-changed-files
uses: ./.github/workflows/run_notebooks.yml
secrets: inherit
with:
changed-files: ${{ needs.get-changed-files.outputs.changed-files }}
deploy:
# needs: run-changed-notebooks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -41,6 +61,10 @@ jobs:
poetry install --with docs
poetry run pip install -U pytest pytest-check-links langsmith langchain GitPython
- name: Lint Docs
# This step lints the docs using the existing linting set up.
# It should be very fast and should not require any external services.
run: make lint-docs
- name: Build site
run: make build-docs
env:
+18 -2
View File
@@ -2,6 +2,12 @@ name: Run notebooks
on:
workflow_dispatch:
workflow_call:
inputs:
changed-files:
required: false
type: string
description: "JSON string of changed files"
schedule:
- cron: '0 13 * * *'
@@ -14,7 +20,6 @@ jobs:
- "development"
- "latest"
name: "test (langgraph: ${{ matrix.lib-version }})"
steps:
- uses: actions/checkout@v4
- name: Set up Python + Poetry
@@ -56,7 +61,18 @@ jobs:
COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
run: |
./docs/_scripts/execute_notebooks.sh
if [ "${{ github.event_name }}" = "workflow_dispatch" ] || [ "${{ github.event_name }}" = "schedule" ]; then
echo "Running all notebooks"
./docs/_scripts/execute_notebooks.sh
else
CHANGED_FILES=$(echo '${{ inputs.changed-files }}' | tr ' ' '\n' | grep '\.ipynb$' || true)
if [ -n "$CHANGED_FILES" ]; then
echo "Running changed notebooks: $CHANGED_FILES"
./docs/_scripts/execute_notebooks.sh $CHANGED_FILES
else
echo "No notebook files changed, skipping execution"
fi
fi
- name: Stop services
run: make stop-services
+11 -2
View File
@@ -1,4 +1,4 @@
.PHONY: build-docs serve-docs serve-clean-docs clean-docs codespell build-typedoc
.PHONY: lint-docs format-docs build-docs serve-docs serve-clean-docs clean-docs codespell build-typedoc
build-typedoc:
cd libs/sdk-js && yarn install --include-dev && yarn typedoc
@@ -19,6 +19,15 @@ clean-docs:
find ./docs/docs -name "*.ipynb" -type f -delete
rm -rf docs/site
## Run format against the project documentation.
format-docs:
poetry run ruff format docs/docs
poetry run ruff check --fix docs/docs
# Check the docs for linting violations
lint-docs:
poetry run ruff check docs/docs
codespell:
./docs/codespell_notebooks.sh .
@@ -26,4 +35,4 @@ start-services:
docker compose -f docs/test-compose.yml up -V --force-recreate --wait --remove-orphans
stop-services:
docker compose -f docs/test-compose.yml down
docker compose -f docs/test-compose.yml down
+7 -2
View File
@@ -22,8 +22,13 @@ execute_notebook() {
export -f execute_notebook
# Find all notebooks and filter out those in the skip list
notebooks=$(find docs/docs/tutorials docs/docs/how-tos -name "*.ipynb" | grep -v ".ipynb_checkpoints" | grep -vFf <(echo "$SKIP_NOTEBOOKS"))
# Check if custom notebook paths are provided
if [ $# -gt 0 ]; then
notebooks="$@"
else
# Find all notebooks and filter out those in the skip list
notebooks=$(find docs/docs/tutorials docs/docs/how-tos -name "*.ipynb" | grep -v ".ipynb_checkpoints" | grep -vFf <(echo "$SKIP_NOTEBOOKS"))
fi
# Execute notebooks sequentially
for file in $notebooks; do
+16 -2
View File
@@ -11,6 +11,13 @@ NOTEBOOK_DIRS = ("docs/docs/how-tos","docs/docs/tutorials")
DOCS_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CASSETTES_PATH = os.path.join(DOCS_PATH, "cassettes")
BLOCKLIST_COMMANDS = (
# skip if has WebBaseLoader to avoid caching web pages
"WebBaseLoader",
# skip if has draw_mermaid_png to avoid generating mermaid images via API
"draw_mermaid_png",
)
NOTEBOOKS_NO_CASSETTES = (
"docs/docs/how-tos/visualization.ipynb",
"docs/docs/how-tos/many-tools.ipynb"
@@ -63,6 +70,14 @@ def is_comment(code: str) -> bool:
return code.strip().startswith("#")
def has_blocklisted_command(code: str) -> bool:
code = code.strip()
for blocklisted_command in BLOCKLIST_COMMANDS:
if blocklisted_command in code:
return True
return False
def add_vcr_to_notebook(
notebook: nbformat.NotebookNode, cassette_prefix: str
) -> nbformat.NotebookNode:
@@ -93,8 +108,7 @@ def add_vcr_to_notebook(
if all(is_comment(line) or not line.strip() for line in lines):
continue
# skip if has WebBaseLoader to avoid caching web pages
if "WebBaseLoader" in cell.source:
if has_blocklisted_command(cell.source):
continue
cell_id = cell.get("id", idx)
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
eNrtV3tcVFUeRy3TtJUKSazsNtbap7zDvfNk4EMGAwIqzwEBBccz955hLnPn3su9d2AGItMeVi6bgyltrqWAgBOglpXPtY+7tYaG24NcHxnGp08vn5SmPXTPvQwKaru1H/fT7mebP2bmnPM739/7d36/Bc3lUJQYnhvSynAyFAElo4VUu6BZhGVeKMmPNnmg7OLpxqxMW26DV2T23euSZUGKjY4GAqPlBcgBRkvxnuhyMppyATka/RdYqMI0Onjav3/ojiqNB0oSKIGSJhabXaWheMSLk9FCU4GuTJIw2QUxFsiIIcbBCgkDDt4rY1N5PhGImsmYRuRZqJB7JShqqovRjoenIatslQgybuAVIg4tSfQrySIEHrRwAlaCaEOGHgHpJntFBYTQEsoez7MhcWS/oII7vZyqvoJ14X8sVqXhgEclKIGyXZXdJys0NJQokRFCZJoUKGOhU4znVJVUE6JjrUIuABHhICNLKqggIuOJMgP7lv2k6uIS5FwEpWh+AU+BY5BWfVf75Ud6M1yJplrRLrQFRBH4NdXKluJQRoS0ovNFbsUDiHlHKaRkRI3If4ZVKCRJv1nsEu8VKaTS5eaxIjKswsVQLqyPCAMYxQLGg1UACXMACdLYT7KUeunHzNSHKKOoUmE9gIYq5KU2uswifahXNkdxdbMLIiRRerrRxUtyoH1w7K8FFAVRGEKO4mkEH2grqWSEyRgNnUpQB5FxOKgaLhB0QyjggGXKYVPfrcA6IAgsQwHlPLpU4rnWUH7giiSXHweVUMBRNnFyYEMmEiIhLTrLj5KUw0itIUZLrPPhkgwYjkVJh7MAydMkqOdbBh4IgHIjEDxUAAJNfZfbB9LwUmB1OqAybYMggUi5AquB6DEZXh64L3o5mfHAQLM163J2ocOL7PRaktRa1g8ClvwcFVitpu1rgy5DWfTjFI8wAquI9n77sJArkV2BBjNhahGhJKCSAx9pQtdkr7SgEfkC7t7ZHCo99ZnT+514KCyyMQn5JbAt1+WdjOlMmA0KmI7QGTBSH6u3xOpisJT03FZriE3uFd2wPlcEnORErkjud3sz5fJybkgHrVd0+DbF4UgbRXxU0HDoE3gJ4iGpAq0FeE5f0cXTkl7uiy6cF0sAx1SqbANrFGeiIstwG0LHKDUUSMQc90iBBl2MsT100m/nINKLwEkCJ8hNSvBTKKwUwQVelHEJUqiky/7Avske4FNiKl5PGvUmgiDiMIajWC8NbV5HEu9BPKU4TBAhywN6sw9H1RSyjIdBTlC/Q88FihcSXSY2Xk4h827ISYEWPdH3+dNAEhEqHBQ1LgA1WtBn65WJ+rF0Co3FrN88mEyCAwRqMHmkjZefhyDqCanV10+MM3Rg311oYachsACd2WCEOmAyk9AAgEFnJHSUjo6xGPRgrXUqbgWUC+I2NdoCzUmFGQnpadagDWFbed7NwNr9Q4bZ7ZTT7vDECwUGPwXc00oTKqDDVlnqN2YI00jSYDVIeallORY7mDVrhlVvTUlw46RZZ9YbzRZTDE5qCS2pJXG9lFfOlWcVpBEmD29ylVmpVGNekk5HG0ijy8FUlhQ4BFIwAntqqolOSJrJVOaWawtd1gJbYimYzvtSqTJfvseUk+DUpTC6jBQGZCWWZyN/AtkVHx2HoUhEhVCKD+UDjvIBV7LBEEv0Z0McRqtREK8dXPvisFTUD2RyrD8OpREKJ4h+UfW2oXIfn8FzcN8zyAbecoaOL01MyjKnZOsyHW5bqjknN8vEa3V5ZIbNkJqeTdhyKu15XqYAzJR9A40QQxpwImQHE2GIUYPnouj/plSvFuAD0xvPVF8R5EeOlzjG6WyyQRGlUCBIsbyXRmVchE3I5zkJhYENMZRFDyyEHtCUgQR6A56cn7OuH+1CMWhU3gC1A5rf1PfqvDFk1x2LRoSpn2FyTjrfQIRv++q+VsvCsWexDQdNW9Jndqw6uuYd01hxLsbVpB9NOGDomGcHN618R3vdruf+WvXV987fT6Aw3RNRjVUZTGHa+e8+NhYsXyicY0wvdn2sfW4Ku2z7hK++OXHu0/FTb7Aen0OuHvXl7OCuaZqRbbNijDtH3tpVZniyCchx28seWKN/5oa2lp0vMPNumuF8KPzbns//sHGvJnkUWXV6HHXks8436ztODHNE/k679Ka2T65NPPjDX8aNw5dfL7gfu7t9xNT4Z0uTW8bvDS4TwxZ/oEvb9Ex1z+m43xyrzijqoOELzzftOpLNfjDl68PnMnoOHM/7oXDMt6+8u7AjqsO9tPzs6BPtE+xGanRE9O2vPTDtj9+M2VyvH34wwvvkU+O/FA7uqKp59caDid9vvfatkTj52LzJPXHFN88u3dbtHBU96YzG23vOOy+qkSbW4nWfnuk8ZTOPWbOo47MV2566RVz16IeThm/y7rmt6Wjx5k33Z82Luid3at3Yuxs/yjK+ZM6+7/ofNp/qPnN6dO1vtdedmntuyszaO8sOP37oaKHb1lKfPyK27tj6uWOWrZshrrresPRGrgFbWyvsaHgw8siC232Tjkcjr50/PyzscFj6llnDwsKuZot8Te3VbpGVDu4iBudl2QEkAL0Y6JXk1C5WaYTtFGD/WTfM0Gqjh4jsKVJ+vn4qnc0UTLfy/sxkHrpnpXgrvT+1aQZiideDpFK4aaqKLnSiRQr3Is2PaVikKa7WKJ3YYMU0faeYYlHA+bFSL7osAkZpJkkskWFZBI1qBIsSWLqziCviLrnASBjHV6DGk/VjLkbJUiwhbZJ0BUqlnXSiDoRG0AyH2gXScgUqD3BDRCcykKMRpMg7eFka6B7F3IPMbv8pxv115Pl15PmFRp5DYeG/Dj2//NDTRKlNZWBf7395T/kf6PYuHfgaSdJM/LyJb+y/mPgs/6MTn9FE/h9OfHrdVZ/49JQOmPVAp3NaSL0jhjKZYyw6vc5M6RxmM03BH534rsIkYTE6nMafN0n87eIkofalck4Cd4AI3/rd2NlzKtePLnzyy511O4a/a0qOjh83JOeWoXgbvT8iv7t9ccz883OyZrZGJGiviflz1O7OXnjNyhmrIlf2rli2lWM3FB3tsYtx9o49x0/WnK/2+Xyz1h7fu6diz+dt46+71vPNs7teLK21fVgQYT0249MJr8wVVnQuOdvFPv9QWlx901DP4ryapL0Rd5eJt7/xsquya1QFLdKvPDxlYtjDdcdG31g8P7N0yMkzY9cECvNbIutSwjqW3PDEu7sfT47L7pm/+62Rm7Wnxnw9Yv9EomtE7oIj4a2R9JCFT4+unN72url+6elFNQc2ft64PWHczC0Tj8yOPLGfiCQeW7r+g8WPLGjten6Kp6g3A4y7LX6operRFSffzFu39bvdS7rnZbbh3al4gfRAy1sTI+7ZXzC89/X3v70talpveGtnIGoPUzP3bftHnV+sZwsXBovdnqdf0H5SGByfOfvY2VFzxpf46vzvz9BGvJFY88Xf3xvRzb/WeHr7obyeNdxdN8euHPX4e4Xb7zI+uGj+tsWJ9UcfnnRyYbD1+OG3g12WVWVd3eF2R7l07/0vzV2x9M28z27lO5dHL5+ML/EUuqqXBN9hvh7TN06s5LTYUOTDfwCLrf0K
eNrtVwtYVFUeB9+1Vla2RineJs3nhXuZF4+Q5Q0iDDAgYBDeuffMzIX7GO69AzOQq9GGWz5HyUp6KCCvEDHRSGBd9dPS3DItFTAts7Z0BRS0TUv23GFQUNu1/dyv3W+b74OZc87//P7v//n/CytzgSDSPOdeS3MSEAhSggtxVWGlAHKsQJT+UMECycxT5fE6fVKZVaBbp5slySL6e3sTFtqLtwCOoL1InvXOxb1JMyF5w98WBjhhyg08ZW8bsqtAwQJRJExAVPgjTxUoSB7y4iS4UOTBK1NERDIDhCEkyBDhQJ6IEAbeKiERPB9CCIqZiELgGSCTW0UgKBZkwB2WpwAjb5ksEqriZSIOLnH4LUoCIFi4MBKMCOCGBFgL1E2yCjII5oXJezzPuMSR7BYnuNHKOdWXsa799kcKFBzBOglMQMp0ym6TZBoKiKRAW1xkikggIa5ThOecKjlNCI+9ZHILIUAcaGTRCWoRoPEEiQZ9y35S5+IG5CQIJWt+DU+Go6FWfVf75Yd605xJsUDWzrVFCAJhVyyQt2SH0gKgZJ2vc8sYQMwbsgApQWpI/jOsQkJJ+s2SKfJWgYQq3WyeUEiG5Jlp0oz0ESEEQjIEzSJ5hIgYCBFQyG1Zynnpp8zUhyjBqHLCsgQFnJA32ugmi/Sh3tocGQsqzQAiCeKKcjMvSo66wbG/iSBJAMMQcCRPQXjHRlM+bZmJUMAoB3UNNA4HnIZz1GQDYEEJhs4FFX23HPWExcLQJCGfe2eJPFfryg9UluTm4xo5FFCYTZzkaNBBIYKjvePtMEk5BPdSY154vQ0VJYLmGJh0KENAeSoszvOmgQcWgsyGIKirADgq+i7XDaThRceGWILU6QdBEgJpdmwgBFaj2jJwX7ByEs0CR2Vo/M3sXIfX2Sm9cNzLb/MgYNHOkY4NzrR9Z9BlIAl2lOQhhmM9VtdvHwZwJsnsKNNimioBiBZYcsBzFfCaZBULy6EvwIH3K12lp1QX0+/EE26/LQ+DfnG0JFlhecBwREdKiA/mo0Jwjb9K5Y9rkMjYpNpQF5ukW7phc5JAcKIRuiK83+2VpNnKZQOqJvSWDm+RHQ61kcWHBQ0FNgsvAtQllaM2FU3sK7podNiWvuhCecFEcHS+k62jWnYmLLI01+A6hqkhQ0LmKCs6ypQqdZ3rpN/ONVAvDMUxFMPflYOfhGElC27hBQkVAQlLumR3tM5kCZscU4FKXK3UYBgWgNAcyVgpoLcawngW8hQDEIsAGJ6gtttQWE0BQ7M0dILzv+u5gPGCw8tY480UEp8NONFRpcT6Pn8aSCIAmYOsxjWgcj/4ab41UT+Wj0zjp1VuH0wmggEClWlYsfHmcxdEKSbW2vqJUZpytE6Ci0wtATDc6KuCGUwBjDL4AcwIDD7Al8AwP42Pz6bQCDSUIM0A1TujzVEZlhYXHBsdWqOH2KE8n02DVW3uQzMzSWOmgQ3MtSbr4qXE6DhzZravKic+LZyyhKmNjDkpd17onJwoyidXm2ulrWSSCcW1PlpfDMfUGhT3ghnphaP5SspE0fMSvVQmHR0Zghn0lswUK2ZTqbRUiJLSJaTRvhaLnojPpaKyWL9QWHXyBLMUEacSYq20WYiyhsQaYjTq5CSjRmfM9GFTzRFEHvQnIZkDvQMQGImwEIqBrnxAYT6gcjZo/fH+bAhAKGcUBHoNrn0BSBTsB3QcYw9A9HI4AfgNq7celvvAOJ4DrcXQBtZcmgqM9E2wzaZjYJk2x8eI4ZbwvKSo6Dx7cjJhSktV6phwdf7sZIukwg3ZA4yA+/qhmMsOGkzl6wye66L/m1JtS0UHpjeqc74i0I8cL3K00VihBwJMIUcNyfBWCpZxAVRAnycGpzkafEkDBQg/JeWnAWqgodDwlMT6frRrxaBcfgOcHdCzFX2vzh73oxOXjHJzfobCv95eKXEn9xo2pqX7oSPpQtr0dTFRjRuyHp3mx5bMTX77/gtr3Y8/UvJ5MNXUtnBTV8eQNPX4mdmenX/pLmm1L332wGMAmR5XyhUv7H715KHPdPmpGz1Xo7PG5lxUXAEN33ruiUwdPXrL98sK42rv2/XJspBVERPnf6yYMj5p96TZdYXYH8mRnzwRqT64v+iHDefLkHmXg/+2ODIjwWPawZD9bzy59+H2d8X6KvGZ54uHL9FviMi2v7ByeRA+YmNC6PDGrYceODpsyV1jIx48s3NrR/Fad2rp/mEpWVnP/D13y5XiB1Omz0jJ7Hri8tWzT+1o3oRVd+5qv3r1vZPPHOlEWtjX1pceNhwwFa242FPrQa6zn2ucnPh1845DHq/jn7Otn1bR9fc2qZsl88id6nJd9jcz4satSXh+Tk5IzByS9zj5yvJjKeSs87n3fFRgK9v+3dW8hW8MP2L4OK1uebTn2Ss5qv3FsdmaSzPpqeVrtu2fd/pIi82zOe/M15d/951x28GGCm7q490jLyIFL40PI9qa71pa+nhUUePaM4TlR88ZHzgape6JKR3z9WeExFERL3dsnv/OmvoP766OYUrmbv+qsHTfqdOXVy35YNJdO9sLep3eG+p2oebUMQBdeSfb5WGv3+l2We7mrmNwVoYZQELA1wO+mJyzo5Wb4kySYP5ZZ0xTzqYPEmWaEsJBsI8+NTFPFWMGWnY2HpkXk5xP3W4DTQgmKwulkrkpCtKvdaXpMvf021E2XZGxQCE3aIN1VPSdIrJxCc6OZFnhZYGg5R4TR0JohoFcYOlgYF6Lj6Vz6dwNF2gR4fg82I8ydsRMy8mLBEdPEW9BKXeZRtiYUBCa5mAXgfvdgoolsgGkE2jAURBS4A28JA70lGz5QR7IvB07/zoJ/ToJ/UKT0Am3Mb/OQr/8LFRBOntNR+uF//JW8z/QBN44B5bjuC/+8wbBh/7FIKj93xwEoSW0yv/DSVDpc+cnQa2PksTgjIkZDIRRCzQqoCahnwzymiRUPzkJ3oEJw09LUKqfNWEMuff6hCGtan+xHRtT1Nk99rlvkqMKz00q2R3bepDLm1bcY6BpfaFhBcue9ihzRB+e3Pn06de2rTwxqqf3coSYf85t6upqv6qGT2ZtzGrf0bsuSF2yoqxVbPjBM2hyUG7HVx90dBuSfp8x662nEVt3YXWQ/rnit9pKtXMiA6xVgUVV5ye9OqfM95GTWyP2eLQtemhr54rGN8P2VU1cduV817A1TOJY9eI3J7gtOnpp9LTEoh2fji89kVYdHzau6uuXI932b77nxUOKiOYnNuWnKMGFKPbIim/GNIR2TS781HPP0C/i1lVvsQxt+WLv7qHdIU3fvzh37H3r2heYpyqTpKbu1cPXC7EFU7cu2j12X3ugLztMaRu3e29q9isjZswe0ms+zh4fNaZz0b6O9czEnT+uHj1XNfpYSGX7rpzQ1nukyuEXPidGFCEvnGqdH172Z3SCKfbMVyMmqysinuoeOTn+pRNFQaJp5dvx7vyxxb0pPcseLdn5xqP1xiXxm0PfP5hNDn8FGfblkz0JXcdbvuObOjA+bPF6PXkuOih9r/ndqgn86dOZPcdXOoRz8b/RP/yqrS75rKe/2442K3211S05oC5rx6Xm8Lvdvzzf9bAuY1zPmbj3phzecvRSzZTd3/ZWXazu0sZULvd4ZHrd/aUxa9nXZ186tXneX4vulrYq35tw4OxFm0/T4W8/ox5468P13eP6BpOPAg7/uBQOJv8A96FD/A==
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -36,8 +36,8 @@ Dependencies can optionally be specified in one of the following files: `pyproje
The dependencies below will be included in the image, you can also use them in your code, as long as with a compatible version range:
```
langgraph>=0.2.18,<0.3.0
langgraph-checkpoint>=1.0.9
langgraph>=0.2.30,<0.3.0
langgraph-checkpoint>=1.0.14
langchain-core>=0.2.38,<0.4.0
langsmith>=0.1.63
orjson>=3.9.7
@@ -36,8 +36,8 @@ Dependencies can optionally be specified in one of the following files: `pyproje
The dependencies below will be included in the image, you can also use them in your code, as long as with a compatible version range:
```
langgraph>=0.2.18,<0.3.0
langgraph-checkpoint>=1.0.9
langgraph>=0.2.30,<0.3.0
langgraph-checkpoint>=1.0.14
langchain-core>=0.2.38,<0.4.0
langsmith>=0.1.63
orjson>=3.9.7
+67 -45
View File
@@ -255,6 +255,18 @@
},
"name": "assistant_id",
"in": "path"
},
{
"description": "Include graph representation of subgraphs. If an integer value is provided, only subgraphs with a depth less than or equal to the value will be included.",
"required": false,
"schema": {
"oneOf": [{ "type": "boolean" }, { "type": "integer" }],
"title": "Xray",
"default": false,
"description": "Include graph representation of subgraphs. If an integer value is provided, only subgraphs with a depth less than or equal to the value will be included."
},
"name": "xray",
"in": "query"
}
],
"responses": {
@@ -629,37 +641,24 @@
}
}
},
"/threads/{thread_id}/state/{checkpoint_id}": {
"get": {
"/threads/{thread_id}/state/checkpoint": {
"post": {
"tags": [
"threads/state"
],
"summary": "Get Thread State At Checkpoint",
"description": "Get state for a thread.",
"operationId": "get_thread_state_at_checkpoint_threads__thread_id__state__checkpoint_id__get",
"parameters": [
{
"description": "The ID of the thread.",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Thread Id",
"description": "The ID of the thread."
},
"name": "thread_id",
"in": "path"
"description": "Get state for a thread at a specific checkpoint.",
"operationId": "post_thread_state_at_checkpoint_threads__thread_id__state__checkpoint_id__get",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ThreadStateCheckpointRequest"
}
}
},
{
"required": true,
"schema": {
"type": "string",
"title": "Checkpoint Id"
},
"name": "checkpoint_id",
"in": "path"
}
],
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
@@ -2847,6 +2846,26 @@
"title": "ThreadPatch",
"description": "Payload for creating a thread."
},
"ThreadStateCheckpointRequest": {
"properties": {
"checkpoint": {
"type": "object",
"title": "Checkpoint",
"description": "The checkpoint to get the state for."
},
"subgraphs": {
"type": "boolean",
"title": "Subgraphs",
"description": "Include subgraph states."
}
},
"required": [
"checkpoint"
],
"type": "object",
"title": "ThreadStateCheckpointRequest",
"description": "Payload for getting the state of a thread at a checkpoint."
},
"ThreadState": {
"properties": {
"values": {
@@ -2889,6 +2908,13 @@
"interrupts": {
"type": "array",
"items": {}
},
"checkpoint": {
"type": "object",
"title": "Checkpoint"
},
"state": {
"$ref": "#/components/schemas/ThreadState"
}
},
"required": [
@@ -2899,9 +2925,9 @@
"type": "array",
"title": "Tasks"
},
"checkpoint_id": {
"type": "string",
"title": "Checkpoint Id"
"checkpoint": {
"type": "object",
"title": "Checkpoint"
},
"metadata": {
"type": "object",
@@ -2911,16 +2937,16 @@
"type": "string",
"title": "Created At"
},
"parent_checkpoint_id": {
"type": "string",
"title": "Parent Checkpoint Id"
"parent_checkpoint": {
"type": "object",
"title": "Parent Checkpoint"
}
},
"type": "object",
"required": [
"values",
"next",
"checkpoint_id",
"checkpoint",
"metadata",
"created_at"
],
@@ -2969,9 +2995,9 @@
],
"title": "Values"
},
"checkpoint_id": {
"type": "string",
"title": "Checkpoint Id"
"checkpoint": {
"type": "object",
"title": "Checkpoint"
},
"as_node": {
"type": "string",
@@ -2984,18 +3010,14 @@
},
"ThreadStateUpdateResponse": {
"properties": {
"checkpoint_id": {
"type": "string",
"title": "Checkpoint Id"
},
"as_node": {
"type": "string",
"title": "As Node"
"checkpoint": {
"type": "object",
"title": "Checkpoint"
}
},
"type": "object",
"title": "ThreadStateUpdate",
"description": "Payload for adding state to a thread."
"title": "ThreadStateUpdateResponse",
"description": "Response for adding state to a thread."
},
"ValidationError": {
"properties": {
+5 -5
View File
@@ -533,12 +533,12 @@
" # because we chose to only include LLMs, these are LLM tokens\n",
" try:\n",
" content = op[\"value\"].content[0]\n",
" if 'partial_json' in content:\n",
" print(content['partial_json'], end=\"|\")\n",
" elif 'text' in content:\n",
" print(content['text'], end='|')\n",
" if \"partial_json\" in content:\n",
" print(content[\"partial_json\"], end=\"|\")\n",
" elif \"text\" in content:\n",
" print(content[\"text\"], end=\"|\")\n",
" else:\n",
" print(content,end=\"|\")\n",
" print(content, end=\"|\")\n",
" except:\n",
" pass"
]
+3
View File
@@ -158,6 +158,7 @@
" \"openai\": openai_model,\n",
"}\n",
"\n",
"\n",
"def _call_model(state: AgentState, config: RunnableConfig):\n",
" # Access the config through the configurable key\n",
" model_name = config[\"configurable\"].get(\"model\", \"anthropic\")\n",
@@ -253,12 +254,14 @@
"source": [
"from langchain_core.messages import SystemMessage\n",
"\n",
"\n",
"# We can define a config schema to specify the configuration options for the graph\n",
"# A config schema is useful for indicating which fields are available in the configurable dict inside the config\n",
"class ConfigSchema(TypedDict):\n",
" model: Optional[str]\n",
" system_message: Optional[str]\n",
"\n",
"\n",
"def _call_model(state: AgentState, config: RunnableConfig):\n",
" # Access the config through the configurable key\n",
" model_name = config[\"configurable\"].get(\"model\", \"anthropic\")\n",
@@ -176,6 +176,7 @@
],
"source": [
"from langchain_core.messages import HumanMessage\n",
"\n",
"config = {\"configurable\": {\"thread_id\": \"42\"}}\n",
"inputs = {\"messages\": [(\"user\", \"what is the weather in SF, CA?\")]}\n",
"\n",
@@ -285,10 +286,10 @@
"source": [
"state = graph.get_state(config)\n",
"\n",
"last_message = state.values['messages'][-1]\n",
"last_message.tool_calls[0]['args'] = {\"location\": \"San Francisco\"}\n",
"last_message = state.values[\"messages\"][-1]\n",
"last_message.tool_calls[0][\"args\"] = {\"location\": \"San Francisco\"}\n",
"\n",
"graph.update_state(config, {\"messages\": [ last_message]})"
"graph.update_state(config, {\"messages\": [last_message]})"
]
},
{
+8 -4
View File
@@ -21,13 +21,15 @@
"from langgraph.graph import MessagesState\n",
"from langgraph.graph import StateGraph, START, END\n",
"\n",
"llm = ChatOpenAI(model=\"o1-preview\",temperature=1)\n",
"llm = ChatOpenAI(model=\"o1-preview\", temperature=1)\n",
"\n",
"graph_builder = StateGraph(MessagesState)\n",
"\n",
"\n",
"def chatbot(state: MessagesState):\n",
" return {\"messages\": [llm.invoke(state[\"messages\"])]}\n",
"\n",
"\n",
"graph_builder.add_node(\"chatbot\", chatbot)\n",
"graph_builder.add_edge(START, \"chatbot\")\n",
"graph_builder.add_edge(\"chatbot\", END)\n",
@@ -112,7 +114,7 @@
}
],
"source": [
"input = {\"messages\": {\"role\":\"user\", \"content\":\"how many r's are in strawberry?\"}}\n",
"input = {\"messages\": {\"role\": \"user\", \"content\": \"how many r's are in strawberry?\"}}\n",
"try:\n",
" async for event in graph.astream_events(input, version=\"v2\"):\n",
" if event[\"event\"] == \"on_chat_model_end\":\n",
@@ -138,13 +140,15 @@
"metadata": {},
"outputs": [],
"source": [
"llm = ChatOpenAI(model=\"o1-preview\",temperature=1,disable_streaming=True)\n",
"llm = ChatOpenAI(model=\"o1-preview\", temperature=1, disable_streaming=True)\n",
"\n",
"graph_builder = StateGraph(MessagesState)\n",
"\n",
"\n",
"def chatbot(state: MessagesState):\n",
" return {\"messages\": [llm.invoke(state[\"messages\"])]}\n",
"\n",
"\n",
"graph_builder.add_node(\"chatbot\", chatbot)\n",
"graph_builder.add_edge(START, \"chatbot\")\n",
"graph_builder.add_edge(\"chatbot\", END)\n",
@@ -187,7 +191,7 @@
}
],
"source": [
"input = {\"messages\": {\"role\":\"user\", \"content\":\"how many r's are in strawberry?\"}}\n",
"input = {\"messages\": {\"role\": \"user\", \"content\": \"how many r's are in strawberry?\"}}\n",
"async for event in graph.astream_events(input, version=\"v2\"):\n",
" if event[\"event\"] == \"on_chat_model_end\":\n",
" print(event[\"data\"][\"output\"].content, end=\"\", flush=True)"
@@ -94,12 +94,15 @@
"def step_2(state: State) -> State:\n",
" # Let's optionally raise a NodeInterrupt\n",
" # if the length of the input is longer than 5 characters\n",
" if len(state['input']) > 5:\n",
" raise NodeInterrupt(f\"Received input that is longer than 5 characters: {state['input']}\")\n",
" \n",
" if len(state[\"input\"]) > 5:\n",
" raise NodeInterrupt(\n",
" f\"Received input that is longer than 5 characters: {state['input']}\"\n",
" )\n",
"\n",
" print(\"---Step 2---\")\n",
" return state\n",
"\n",
"\n",
"def step_3(state: State) -> State:\n",
" print(\"---Step 3---\")\n",
" return state\n",
File diff suppressed because it is too large Load Diff
@@ -382,6 +382,7 @@
"\n",
"from pydantic import BaseModel\n",
"\n",
"\n",
"# We are going \"bind\" all tools to the model\n",
"# We have the ACTUAL tools from above, but we also need a mock tool to ask a human\n",
"# Since `bind_tools` takes in tools but also just tool definitions,\n",
@@ -396,6 +397,7 @@
"\n",
"# Define nodes and conditional edges\n",
"\n",
"\n",
"# Define the function that determines whether to continue or not\n",
"def should_continue(state):\n",
" messages = state[\"messages\"]\n",
@@ -79,9 +79,11 @@
"class OutputState(TypedDict):\n",
" answer: str\n",
"\n",
"\n",
"class OverallState(InputState, OutputState):\n",
" pass\n",
"\n",
"\n",
"def answer_node(state: InputState):\n",
" return {\"answer\": \"bye\"}\n",
"\n",
@@ -107,7 +107,7 @@
"from langchain_core.tools import tool\n",
"\n",
"from langgraph.checkpoint.memory import MemorySaver\n",
"from langgraph.graph import MessagesState, StateGraph, START\n",
"from langgraph.graph import MessagesState, StateGraph, START, END\n",
"from langgraph.prebuilt import ToolNode\n",
"\n",
"memory = MemorySaver()\n",
@@ -127,12 +127,12 @@
"bound_model = model.bind_tools(tools)\n",
"\n",
"\n",
"def should_continue(state: MessagesState) -> Literal[\"action\", \"__end__\"]:\n",
"def should_continue(state: MessagesState):\n",
" \"\"\"Return the next node to execute.\"\"\"\n",
" last_message = state[\"messages\"][-1]\n",
" # If there is no function call, then we finish\n",
" if not last_message.tool_calls:\n",
" return \"__end__\"\n",
" return END\n",
" # Otherwise if there is, we continue\n",
" return \"action\"\n",
"\n",
@@ -162,6 +162,8 @@
" \"agent\",\n",
" # Next, we pass in the function that will determine which node is called next.\n",
" should_continue,\n",
" # Next, we pass in the path map - all the possible nodes this edge could go to\n",
" [\"action\", END],\n",
")\n",
"\n",
"# We now add a normal edge from `tools` to `agent`.\n",
@@ -98,7 +98,7 @@
"from langchain_core.tools import tool\n",
"\n",
"from langgraph.checkpoint.memory import MemorySaver\n",
"from langgraph.graph import MessagesState, StateGraph, START\n",
"from langgraph.graph import MessagesState, StateGraph, START, END\n",
"from langgraph.prebuilt import ToolNode\n",
"\n",
"memory = MemorySaver()\n",
@@ -118,12 +118,12 @@
"bound_model = model.bind_tools(tools)\n",
"\n",
"\n",
"def should_continue(state: MessagesState) -> Literal[\"action\", \"__end__\"]:\n",
"def should_continue(state: MessagesState):\n",
" \"\"\"Return the next node to execute.\"\"\"\n",
" last_message = state[\"messages\"][-1]\n",
" # If there is no function call, then we finish\n",
" if not last_message.tool_calls:\n",
" return \"__end__\"\n",
" return END\n",
" # Otherwise if there is, we continue\n",
" return \"action\"\n",
"\n",
@@ -153,6 +153,8 @@
" \"agent\",\n",
" # Next, we pass in the function that will determine which node is called next.\n",
" should_continue,\n",
" # Next, we pass in the path map - all the possible nodes this edge could go to\n",
" [\"action\", END],\n",
")\n",
"\n",
"# We now add a normal edge from `tools` to `agent`.\n",
@@ -247,12 +249,12 @@
"bound_model = model.bind_tools(tools)\n",
"\n",
"\n",
"def should_continue(state: MessagesState) -> Literal[\"action\", \"__end__\"]:\n",
"def should_continue(state: MessagesState):\n",
" \"\"\"Return the next node to execute.\"\"\"\n",
" last_message = state[\"messages\"][-1]\n",
" # If there is no function call, then we finish\n",
" if not last_message.tool_calls:\n",
" return \"__end__\"\n",
" return END\n",
" # Otherwise if there is, we continue\n",
" return \"action\"\n",
"\n",
@@ -288,6 +290,8 @@
" \"agent\",\n",
" # Next, we pass in the function that will determine which node is called next.\n",
" should_continue,\n",
" # Next, we pass in the pathmap - all the possible nodes this edge could go to\n",
" [\"action\", END],\n",
")\n",
"\n",
"# We now add a normal edge from `tools` to `agent`.\n",
+5 -8
View File
@@ -180,15 +180,15 @@
"source": [
"from typing import Literal\n",
"\n",
"from langgraph.graph import StateGraph, MessagesState\n",
"from langgraph.graph import StateGraph, MessagesState, START, END\n",
"\n",
"\n",
"def should_continue(state: MessagesState) -> Literal[\"tools\", \"__end__\"]:\n",
"def should_continue(state: MessagesState):\n",
" messages = state[\"messages\"]\n",
" last_message = messages[-1]\n",
" if last_message.tool_calls:\n",
" return \"tools\"\n",
" return \"__end__\"\n",
" return END\n",
"\n",
"\n",
"def call_model(state: MessagesState):\n",
@@ -203,11 +203,8 @@
"workflow.add_node(\"agent\", call_model)\n",
"workflow.add_node(\"tools\", tool_node)\n",
"\n",
"workflow.add_edge(\"__start__\", \"agent\")\n",
"workflow.add_conditional_edges(\n",
" \"agent\",\n",
" should_continue,\n",
")\n",
"workflow.add_edge(START, \"agent\")\n",
"workflow.add_conditional_edges(\"agent\", should_continue, [\"tools\", END])\n",
"workflow.add_edge(\"tools\", \"agent\")\n",
"\n",
"app = workflow.compile()"
@@ -102,7 +102,7 @@
},
{
"cell_type": "code",
"execution_count": 2,
"execution_count": 1,
"id": "1d36e782-80f4-4334-b7d7-ee4c79864480",
"metadata": {},
"outputs": [],
@@ -111,6 +111,7 @@
"from typing_extensions import Annotated\n",
"\n",
"from langchain_core.documents import Document\n",
"from langchain_core.messages import ToolMessage\n",
"from langchain_core.tools import tool\n",
"from langgraph.prebuilt import InjectedState\n",
"\n",
@@ -316,7 +317,6 @@
"outputs": [],
"source": [
"from langchain_openai import ChatOpenAI\n",
"\n",
"from langgraph.prebuilt import ToolNode\n",
"\n",
"model = ChatOpenAI(model=\"gpt-4o\", temperature=0)\n",
File diff suppressed because one or more lines are too long
+1 -3
View File
@@ -256,9 +256,7 @@
" }\n",
" }\n",
"\n",
" checkpoint = serde.loads_typed(\n",
" (data[b\"type\"].decode(), data[b\"checkpoint\"])\n",
" )\n",
" checkpoint = serde.loads_typed((data[b\"type\"].decode(), data[b\"checkpoint\"]))\n",
" metadata = serde.loads(data[b\"metadata\"].decode())\n",
" parent_checkpoint_id = data.get(b\"parent_checkpoint_id\", b\"\").decode()\n",
" parent_config = (\n",
@@ -82,6 +82,7 @@
"from langchain_core.messages import BaseMessage\n",
"from langgraph.graph.message import add_messages\n",
"\n",
"\n",
"class AgentState(TypedDict):\n",
" \"\"\"The state of the agent.\"\"\"\n",
"\n",
@@ -108,16 +109,18 @@
"\n",
"model = ChatOpenAI(model=\"gpt-4o-mini\")\n",
"\n",
"\n",
"@tool\n",
"def get_weather(location: str):\n",
" \"\"\"Call to get the weather from a specific location.\"\"\"\n",
" # This is a placeholder for the actual implementation\n",
" # Don't let the LLM know this though 😊\n",
" if any([city in location.lower() for city in ['sf','san francisco']]):\n",
" if any([city in location.lower() for city in [\"sf\", \"san francisco\"]]):\n",
" return \"It's sunny in San Francisco, but you better look out if you're a Gemini 😈.\"\n",
" else:\n",
" return f\"I am not sure what the weather is in {location}\"\n",
"\n",
"\n",
"tools = [get_weather]\n",
"\n",
"model = model.bind_tools(tools)"
@@ -145,13 +148,13 @@
"from langchain_core.runnables import RunnableConfig\n",
"\n",
"tools_by_name = {tool.name: tool for tool in tools}\n",
"\n",
"\n",
"# Define our tool node\n",
"def tool_node(state: AgentState):\n",
" outputs = []\n",
" for tool_call in state['messages'][-1].tool_calls:\n",
" tool_result = tools_by_name[tool_call[\"name\"]].invoke(\n",
" tool_call[\"args\"]\n",
" )\n",
" for tool_call in state[\"messages\"][-1].tool_calls:\n",
" tool_result = tools_by_name[tool_call[\"name\"]].invoke(tool_call[\"args\"])\n",
" outputs.append(\n",
" ToolMessage(\n",
" content=json.dumps(tool_result),\n",
@@ -161,17 +164,21 @@
" )\n",
" return {\"messages\": outputs}\n",
"\n",
"\n",
"# Define the node that calls the model\n",
"def call_model(\n",
" state: AgentState,\n",
" config: RunnableConfig,\n",
"):\n",
" # this is similar to customizing the create_react_agent with state_modifier, but is a lot more flexible\n",
" system_prompt = SystemMessage(\"You are a helpful AI assistant, please respond to the users query to the best of your ability!\")\n",
" response = model.invoke([system_prompt] + state['messages'], config)\n",
" system_prompt = SystemMessage(\n",
" \"You are a helpful AI assistant, please respond to the users query to the best of your ability!\"\n",
" )\n",
" response = model.invoke([system_prompt] + state[\"messages\"], config)\n",
" # We return a list, because this will get added to the existing list\n",
" return {\"messages\": [response]}\n",
"\n",
"\n",
"# Define the conditional edge that determines whether to continue or not\n",
"def should_continue(state: AgentState):\n",
" messages = state[\"messages\"]\n",
@@ -308,6 +315,7 @@
" else:\n",
" message.pretty_print()\n",
"\n",
"\n",
"inputs = {\"messages\": [(\"user\", \"what is the weather in sf\")]}\n",
"print_stream(graph.stream(inputs, stream_mode=\"values\"))"
]
@@ -112,22 +112,28 @@
"outputs": [],
"source": [
"from pydantic import BaseModel, Field\n",
"from typing import Literal\n",
"from typing import Literal\n",
"from langchain_core.tools import tool\n",
"from langchain_anthropic import ChatAnthropic\n",
"from langgraph.graph import MessagesState\n",
"\n",
"\n",
"class WeatherResponse(BaseModel):\n",
" \"\"\"Respond to the user with this\"\"\"\n",
"\n",
" temperature: float = Field(description=\"The temperature in fahrenheit\")\n",
" wind_directon: str = Field(description=\"The direction of the wind in abbreviated form\")\n",
" wind_directon: str = Field(\n",
" description=\"The direction of the wind in abbreviated form\"\n",
" )\n",
" wind_speed: float = Field(description=\"The speed of the wind in km/h\")\n",
"\n",
"# Inherit 'messages' key from MessagesState, which is a list of chat messages \n",
"\n",
"# Inherit 'messages' key from MessagesState, which is a list of chat messages\n",
"class AgentState(MessagesState):\n",
" # Final structured response from the agent\n",
" final_response: WeatherResponse\n",
"\n",
"\n",
"@tool\n",
"def get_weather(city: Literal[\"nyc\", \"sf\"]):\n",
" \"\"\"Use this to get weather information.\"\"\"\n",
@@ -137,11 +143,12 @@
" return \"It is 75 degrees and sunny in SF, with 3 mph winds in the South-East direction\"\n",
" else:\n",
" raise AssertionError(\"Unknown city\")\n",
" \n",
"\n",
"\n",
"tools = [get_weather]\n",
" \n",
"\n",
"model = ChatAnthropic(model=\"claude-3-opus-20240229\")\n",
" \n",
"\n",
"model_with_tools = model.bind_tools(tools)\n",
"model_with_structured_output = model.with_structured_output(WeatherResponse)"
]
@@ -170,33 +177,40 @@
"\n",
"tools = [get_weather, WeatherResponse]\n",
"\n",
"# Force the model to use tools by passing tool_choice=\"any\" \n",
"model_with_response_tool = model.bind_tools(tools,tool_choice=\"any\")\n",
"# Force the model to use tools by passing tool_choice=\"any\"\n",
"model_with_response_tool = model.bind_tools(tools, tool_choice=\"any\")\n",
"\n",
"\n",
"# Define the function that calls the model\n",
"def call_model(state: AgentState):\n",
" response = model_with_response_tool.invoke(state['messages'])\n",
" response = model_with_response_tool.invoke(state[\"messages\"])\n",
" # We return a list, because this will get added to the existing list\n",
" return {\"messages\": [response]}\n",
"\n",
"\n",
"# Define the function that responds to the user\n",
"def respond(state: AgentState):\n",
" # Construct the final answer from the arguments of the last tool call\n",
" response = WeatherResponse(**state['messages'][-1].tool_calls[0]['args'])\n",
" response = WeatherResponse(**state[\"messages\"][-1].tool_calls[0][\"args\"])\n",
" # We return the final answer\n",
" return {\"final_response\": response}\n",
"\n",
"\n",
"# Define the function that determines whether to continue or not\n",
"def should_continue(state: AgentState):\n",
" messages = state[\"messages\"]\n",
" last_message = messages[-1]\n",
" # If there is only one tool call and it is the response tool call we respond to the user\n",
" if len(last_message.tool_calls) == 1 and last_message.tool_calls[0]['name'] == \"WeatherResponse\":\n",
" if (\n",
" len(last_message.tool_calls) == 1\n",
" and last_message.tool_calls[0][\"name\"] == \"WeatherResponse\"\n",
" ):\n",
" return \"respond\"\n",
" # Otherwise we will use the tool node again\n",
" else:\n",
" return \"continue\"\n",
"\n",
"\n",
"# Define a new graph\n",
"workflow = StateGraph(AgentState)\n",
"\n",
@@ -239,7 +253,9 @@
"metadata": {},
"outputs": [],
"source": [
"answer = graph.invoke(input={\"messages\": [(\"human\", \"what's the weather in SF?\")]})['final_response']"
"answer = graph.invoke(input={\"messages\": [(\"human\", \"what's the weather in SF?\")]})[\n",
" \"final_response\"\n",
"]"
]
},
{
@@ -292,21 +308,26 @@
"from langgraph.prebuilt import ToolNode\n",
"from langchain_core.messages import HumanMessage\n",
"\n",
"\n",
"# Define the function that calls the model\n",
"def call_model(state: AgentState):\n",
" response = model_with_tools.invoke(state['messages'])\n",
" response = model_with_tools.invoke(state[\"messages\"])\n",
" # We return a list, because this will get added to the existing list\n",
" return {\"messages\": [response]}\n",
"\n",
"\n",
"# Define the function that responds to the user\n",
"def respond(state: AgentState):\n",
" # We call the model with structured output in order to return the same format to the user every time\n",
" # state['messages'][-2] is the last ToolMessage in the convo, which we convert to a HumanMessage for the model to use\n",
" # We could also pass the entire chat history, but this saves tokens since all we care to structure is the output of the tool\n",
" response = model_with_structured_output.invoke([HumanMessage(content=state['messages'][-2].content)])\n",
" response = model_with_structured_output.invoke(\n",
" [HumanMessage(content=state[\"messages\"][-2].content)]\n",
" )\n",
" # We return the final answer\n",
" return {\"final_response\": response}\n",
"\n",
"\n",
"# Define the function that determines whether to continue or not\n",
"def should_continue(state: AgentState):\n",
" messages = state[\"messages\"]\n",
@@ -318,6 +339,7 @@
" else:\n",
" return \"continue\"\n",
"\n",
"\n",
"# Define a new graph\n",
"workflow = StateGraph(AgentState)\n",
"\n",
@@ -361,7 +383,9 @@
"metadata": {},
"outputs": [],
"source": [
"answer = graph.invoke(input={\"messages\": [(\"human\", \"what's the weather in SF?\")]})['final_response']"
"answer = graph.invoke(input={\"messages\": [(\"human\", \"what's the weather in SF?\")]})[\n",
" \"final_response\"\n",
"]"
]
},
{
+2 -2
View File
@@ -139,7 +139,7 @@
"from langgraph.errors import GraphRecursionError\n",
"\n",
"try:\n",
" graph.invoke({\"aggregate\": []},{\"recursion_limit\":3})\n",
" graph.invoke({\"aggregate\": []}, {\"recursion_limit\": 3})\n",
"except GraphRecursionError:\n",
" print(\"Recursion Error\")"
]
@@ -169,7 +169,7 @@
],
"source": [
"try:\n",
" graph.invoke({\"aggregate\": []},{\"recursion_limit\":4})\n",
" graph.invoke({\"aggregate\": []}, {\"recursion_limit\": 4})\n",
"except GraphRecursionError:\n",
" print(\"Recursion Error\")"
]
@@ -59,29 +59,34 @@
"from langgraph.graph import StateGraph\n",
"from langgraph.graph import START, END\n",
"\n",
"\n",
"class State(TypedDict):\n",
" value: str\n",
" action_result: str\n",
"\n",
"\n",
"def router(state: State):\n",
" if state['value'] == \"end\":\n",
" return \"__end__\"\n",
" if state[\"value\"] == \"end\":\n",
" return END\n",
" else:\n",
" return \"action\"\n",
"\n",
"\n",
"def decision_node(state):\n",
" return {'value':'keep going!'}\n",
" return {\"value\": \"keep going!\"}\n",
"\n",
"\n",
"def action_node(state: State):\n",
" # Do your action here ...\n",
" return {'action_result':'what a great result!'}\n",
" return {\"action_result\": \"what a great result!\"}\n",
"\n",
"\n",
"workflow = StateGraph(State)\n",
"workflow.add_node('decision',decision_node)\n",
"workflow.add_node('action',action_node)\n",
"workflow.add_edge(START,'decision')\n",
"workflow.add_conditional_edges('decision',router)\n",
"workflow.add_edge('action','decision')\n",
"workflow.add_node(\"decision\", decision_node)\n",
"workflow.add_node(\"action\", action_node)\n",
"workflow.add_edge(START, \"decision\")\n",
"workflow.add_conditional_edges(\"decision\", router, [\"action\", END])\n",
"workflow.add_edge(\"action\", \"decision\")\n",
"app = workflow.compile()"
]
},
@@ -131,7 +136,7 @@
"from langgraph.errors import GraphRecursionError\n",
"\n",
"try:\n",
" app.invoke({\"value\":\"hi!\"})\n",
" app.invoke({\"value\": \"hi!\"})\n",
"except GraphRecursionError:\n",
" print(\"Recursion Error\")"
]
@@ -168,34 +173,39 @@
" def __call__(self, step: int) -> bool:\n",
" limit = self.config.get(\"recursion_limit\", 0)\n",
" return step >= limit - 2\n",
" \n",
"\n",
"\n",
"class State(TypedDict):\n",
" value: str\n",
" action_result: str\n",
" is_last_step: Annotated[bool, IsLastOrSecondToLastStepManager]\n",
"\n",
"\n",
"def router(state: State):\n",
" # Force the agent to end if it is on the last step\n",
" if state['is_last_step']:\n",
" return \"__end__\"\n",
" if state['value'] == \"end\":\n",
" return \"__end__\"\n",
" if state[\"is_last_step\"]:\n",
" return END\n",
" if state[\"value\"] == \"end\":\n",
" return END\n",
" else:\n",
" return \"action\"\n",
"\n",
"\n",
"def decision_node(state):\n",
" return {'value':'keep going!'}\n",
" return {\"value\": \"keep going!\"}\n",
"\n",
"\n",
"def action_node(state: State):\n",
" # Do your action here ...\n",
" return {'action_result':'what a great result!'}\n",
" return {\"action_result\": \"what a great result!\"}\n",
"\n",
"\n",
"workflow = StateGraph(State)\n",
"workflow.add_node('decision',decision_node)\n",
"workflow.add_node('action',action_node)\n",
"workflow.add_edge(START,'decision')\n",
"workflow.add_conditional_edges('decision',router)\n",
"workflow.add_edge('action','decision')\n",
"workflow.add_node(\"decision\", decision_node)\n",
"workflow.add_node(\"action\", action_node)\n",
"workflow.add_edge(START, \"decision\")\n",
"workflow.add_conditional_edges(\"decision\", router, [\"action\", END])\n",
"workflow.add_edge(\"action\", \"decision\")\n",
"app = workflow.compile()"
]
},
@@ -216,7 +226,7 @@
}
],
"source": [
"app.invoke({\"value\":\"hi!\"})"
"app.invoke({\"value\": \"hi!\"})"
]
},
{
+3 -1
View File
@@ -136,8 +136,10 @@
" print(message)\n",
" else:\n",
" message.pretty_print()\n",
"\n",
"\n",
"inputs = {\"messages\": [(\"user\", \"what is the weather in sf\")]}\n",
"config = {\"configurable\": {\"run_id\":\"12345\"}}\n",
"config = {\"configurable\": {\"run_id\": \"12345\"}}\n",
"\n",
"print_stream(graph.stream(inputs, config, stream_mode=\"values\"))"
]
+1
View File
@@ -231,6 +231,7 @@
"\n",
"from pydantic import BaseModel\n",
"\n",
"\n",
"class AgentState(BaseModel):\n",
" messages: Annotated[Sequence[BaseMessage], operator.add]"
]
+8 -4
View File
@@ -71,9 +71,10 @@
"from langgraph.graph import START, StateGraph, MessagesState, END\n",
"from langgraph.types import StreamWriter\n",
"\n",
"\n",
"async def my_node(\n",
" state: MessagesState, \n",
" writer: StreamWriter # <-- provide StreamWriter to write chunks to be streamed\n",
" state: MessagesState,\n",
" writer: StreamWriter, # <-- provide StreamWriter to write chunks to be streamed\n",
"):\n",
" chunks = [\n",
" \"Four\",\n",
@@ -87,11 +88,12 @@
" \"...\",\n",
" ]\n",
" for chunk in chunks:\n",
" # write the chunk to be streamed using stream_mode=custom \n",
" # write the chunk to be streamed using stream_mode=custom\n",
" writer(chunk)\n",
"\n",
" return {\"messages\": [AIMessage(content=\" \".join(chunks))]}\n",
"\n",
"\n",
"# Define a new graph\n",
"workflow = StateGraph(MessagesState)\n",
"\n",
@@ -184,6 +186,7 @@
"from langchain_core.runnables import RunnableConfig, RunnableLambda\n",
"from langchain_core.callbacks.manager import adispatch_custom_event\n",
"\n",
"\n",
"async def my_node(state: MessagesState, config: RunnableConfig):\n",
" chunks = [\n",
" \"Four\",\n",
@@ -200,11 +203,12 @@
" await adispatch_custom_event(\n",
" \"my_custom_event\",\n",
" {\"chunk\": chunk},\n",
" config=config # <-- propagate config\n",
" config=config, # <-- propagate config\n",
" )\n",
"\n",
" return {\"messages\": [AIMessage(content=\" \".join(chunks))]}\n",
"\n",
"\n",
"# Define a new graph\n",
"workflow = StateGraph(MessagesState)\n",
"\n",
@@ -196,12 +196,19 @@
"\n",
"inputs = [HumanMessage(content=\"what is the weather in sf\")]\n",
"final_message = \"\"\n",
"async for msg, metadata in agent.astream({\"messages\": [(\"human\", \"what items are on the shelf?\")]}, stream_mode=\"messages\"):\n",
"async for msg, metadata in agent.astream(\n",
" {\"messages\": [(\"human\", \"what items are on the shelf?\")]}, stream_mode=\"messages\"\n",
"):\n",
" # Stream all messages from the tool node\n",
" if msg.content and not isinstance(msg,HumanMessage) and metadata['langgraph_node'] == 'tools' and not msg.name:\n",
" if (\n",
" msg.content\n",
" and not isinstance(msg, HumanMessage)\n",
" and metadata[\"langgraph_node\"] == \"tools\"\n",
" and not msg.name\n",
" ):\n",
" print(msg.content, end=\"|\", flush=True)\n",
" # Final message should come from our agent\n",
" if msg.content and metadata['langgraph_node'] == \"agent\":\n",
" if msg.content and metadata[\"langgraph_node\"] == \"agent\":\n",
" final_message += msg.content"
]
},
@@ -163,6 +163,7 @@
" response.id = last_ai_message.id\n",
" return {\"messages\": [response]}\n",
"\n",
"\n",
"workflow = StateGraph(MessagesState)\n",
"\n",
"workflow.add_node(\"agent\", call_model)\n",
@@ -222,7 +223,8 @@
"source": [
"import warnings\n",
"from langchain_core._api import LangChainBetaWarning\n",
"warnings.filterwarnings('ignore', category=LangChainBetaWarning)"
"\n",
"warnings.filterwarnings(\"ignore\", category=LangChainBetaWarning)"
]
},
{
@@ -260,7 +262,11 @@
"\n",
"inputs = [HumanMessage(content=\"what is the weather in sf\")]\n",
"async for msg, metadata in app.astream({\"messages\": inputs}, stream_mode=\"messages\"):\n",
" if msg.content and not isinstance(msg,HumanMessage) and metadata['langgraph_node'] == 'final':\n",
" if (\n",
" msg.content\n",
" and not isinstance(msg, HumanMessage)\n",
" and metadata[\"langgraph_node\"] == \"final\"\n",
" ):\n",
" print(msg.content, end=\"|\", flush=True)"
]
},
+12 -4
View File
@@ -87,7 +87,7 @@
"def add_logs(left: list[Logs], right: list[Logs]) -> list[Logs]:\n",
" if not left:\n",
" left = []\n",
" \n",
"\n",
" if not right:\n",
" right = []\n",
"\n",
@@ -141,6 +141,7 @@
" # subgraph keys\n",
" summary: str\n",
"\n",
"\n",
"def generate_summary(state: QuestionSummarizationState):\n",
" docs = state[\"logs\"]\n",
" # NOTE: you can implement custom summarization logic here\n",
@@ -255,7 +256,7 @@
" id=\"3\",\n",
" question=\"How do I create react agent in langgraph?\",\n",
" answer=\"from langgraph.prebuilt import create_react_agent\",\n",
" )\n",
" ),\n",
"]\n",
"\n",
"input = {\"raw_logs\": dummy_logs}"
@@ -335,11 +336,18 @@
"source": [
"# Format the namespace slightly nicer\n",
"def format_namespace(namespace):\n",
" return namespace[-1].split(':')[0]+' subgraph' if len(namespace) > 0 else 'parent graph'\n",
" return (\n",
" namespace[-1].split(\":\")[0] + \" subgraph\"\n",
" if len(namespace) > 0\n",
" else \"parent graph\"\n",
" )\n",
"\n",
"\n",
"for namespace, chunk in graph.stream(input, stream_mode=\"updates\", subgraphs=True):\n",
" node_name = list(chunk.keys())[0]\n",
" print(f\"---------- Update from node {node_name} in {format_namespace(namespace)} ---------\")\n",
" print(\n",
" f\"---------- Update from node {node_name} in {format_namespace(namespace)} ---------\"\n",
" )\n",
" print(chunk[node_name])"
]
},
@@ -99,6 +99,7 @@
" ensure_config,\n",
" get_callback_manager_for_config,\n",
")\n",
"\n",
"openai_client = AsyncOpenAI()\n",
"# define tool schema for openai tool calling\n",
"\n",
@@ -311,7 +312,10 @@
"from langchain_core.messages import AIMessageChunk\n",
"\n",
"first = True\n",
"async for msg, metadata in graph.astream({\"messages\": [{\"role\": \"user\", \"content\": \"what's in the bedroom\"}]}, stream_mode=\"messages\"):\n",
"async for msg, metadata in graph.astream(\n",
" {\"messages\": [{\"role\": \"user\", \"content\": \"what's in the bedroom\"}]},\n",
" stream_mode=\"messages\",\n",
"):\n",
" if msg.content:\n",
" print(msg.content, end=\"|\", flush=True)\n",
"\n",
+5 -3
View File
@@ -282,7 +282,7 @@
"\n",
"\n",
"# Define the function that determines whether to continue or not\n",
"def should_continue(state: State) -> Literal[\"__end__\", \"tools\"]:\n",
"def should_continue(state: State):\n",
" messages = state[\"messages\"]\n",
" last_message = messages[-1]\n",
" # If there is no function call, then we finish\n",
@@ -338,6 +338,8 @@
" \"agent\",\n",
" # Next, we pass in the function that will determine which node is called next.\n",
" should_continue,\n",
" # Next we pass in the path map - all the nodes this edge could go to\n",
" [\"tools\", END],\n",
")\n",
"\n",
"workflow.add_edge(\"tools\", \"agent\")\n",
@@ -412,7 +414,7 @@
"inputs = [HumanMessage(content=\"what is the weather in sf\")]\n",
"first = True\n",
"async for msg, metadata in app.astream({\"messages\": inputs}, stream_mode=\"messages\"):\n",
" if msg.content and not isinstance(msg,HumanMessage):\n",
" if msg.content and not isinstance(msg, HumanMessage):\n",
" print(msg.content, end=\"|\", flush=True)\n",
"\n",
" if isinstance(msg, AIMessageChunk):\n",
@@ -443,7 +445,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.9"
"version": "3.9.6"
}
},
"nbformat": 4,
@@ -71,10 +71,12 @@
"class GrandChildState(TypedDict):\n",
" my_grandchild_key: str\n",
"\n",
"\n",
"def grandchild_1(state: GrandChildState) -> GrandChildState:\n",
" # NOTE: child or parent keys will not be accessible here\n",
" return {\"my_grandchild_key\": state[\"my_grandchild_key\"] + \", how are you\"}\n",
"\n",
"\n",
"grandchild = StateGraph(GrandChildState)\n",
"grandchild.add_node(\"grandchild_1\", grandchild_1)\n",
"\n",
@@ -194,11 +196,13 @@
"source": [
"class ParentState(TypedDict):\n",
" my_key: str\n",
" \n",
"\n",
"\n",
"def parent_1(state: ParentState) -> ParentState:\n",
" # NOTE: child or grandchild keys won't be accessible here\n",
" return {\"my_key\": \"hi \" + state[\"my_key\"]}\n",
"\n",
"\n",
"def parent_2(state: ParentState) -> ParentState:\n",
" return {\"my_key\": state[\"my_key\"] + \" bye!\"}\n",
"\n",
+9 -6
View File
@@ -97,7 +97,7 @@
"def add_logs(left: list[Logs], right: list[Logs]) -> list[Logs]:\n",
" if not left:\n",
" left = []\n",
" \n",
"\n",
" if not right:\n",
" right = []\n",
"\n",
@@ -225,7 +225,7 @@
" id=\"3\",\n",
" question=\"How do I create react agent in langgraph?\",\n",
" answer=\"from langgraph.prebuilt import create_react_agent\",\n",
" )\n",
" ),\n",
"]\n",
"\n",
"\n",
@@ -323,6 +323,7 @@
"\n",
"from typing_extensions import TypedDict\n",
"\n",
"\n",
"# define a simple reducer\n",
"def reduce_list(left: list, right: list) -> list:\n",
" if not left:\n",
@@ -331,6 +332,7 @@
" right = []\n",
" return left + right\n",
"\n",
"\n",
"# define parent and child state\n",
"class ChildState(TypedDict):\n",
" name: str\n",
@@ -345,7 +347,7 @@
"# define a helper to build the graph\n",
"def make_graph(parent_schema, child_schema):\n",
" child_builder = StateGraph(child_schema)\n",
" \n",
"\n",
" child_builder.add_node(\"child_start\", lambda state: {\"path\": [\"child_start\"]})\n",
" child_builder.add_edge(START, \"child_start\")\n",
" child_builder.add_node(\"child_middle\", lambda state: {\"path\": [\"child_middle\"]})\n",
@@ -353,16 +355,16 @@
" child_builder.add_edge(\"child_start\", \"child_middle\")\n",
" child_builder.add_edge(\"child_middle\", \"child_end\")\n",
" child_builder.add_edge(\"child_end\", END)\n",
" \n",
"\n",
" builder = StateGraph(parent_schema)\n",
" \n",
"\n",
" builder.add_node(\"grandparent\", lambda state: {\"path\": [\"grandparent\"]})\n",
" builder.add_edge(START, \"grandparent\")\n",
" builder.add_node(\"parent\", lambda state: {\"path\": [\"parent\"]})\n",
" builder.add_node(\"child\", child_builder.compile())\n",
" builder.add_node(\"sibling\", lambda state: {\"path\": [\"sibling\"]})\n",
" builder.add_node(\"fin\", lambda state: {\"path\": [\"fin\"]})\n",
" \n",
"\n",
" # Add connections\n",
" builder.add_edge(\"grandparent\", \"parent\")\n",
" builder.add_edge(\"parent\", \"child\")\n",
@@ -508,6 +510,7 @@
"source": [
"import uuid\n",
"\n",
"\n",
"def reduce_list(left: list | None, right: list | None) -> list:\n",
" \"\"\"Append the right-hand list, replacing any elements with the same id in the left-hand list.\"\"\"\n",
" if not left:\n",
+80 -33
View File
@@ -89,6 +89,7 @@
" \"\"\"Get the weather for a specific city\"\"\"\n",
" return f\"It's sunny in {city}!\"\n",
"\n",
"\n",
"raw_model = ChatOpenAI()\n",
"model = raw_model.with_structured_output(get_weather)\n",
"\n",
@@ -98,11 +99,12 @@
"\n",
"\n",
"def model_node(state: SubGraphState):\n",
" result = model.invoke(state['messages'])\n",
" result = model.invoke(state[\"messages\"])\n",
" return {\"city\": result[\"city\"]}\n",
"\n",
"\n",
"def weather_node(state: SubGraphState):\n",
" result = get_weather.invoke({\"city\": state['city']})\n",
" result = get_weather.invoke({\"city\": state[\"city\"]})\n",
" return {\"messages\": [{\"role\": \"assistant\", \"content\": result}]}\n",
"\n",
"\n",
@@ -145,22 +147,26 @@
"class Router(TypedDict):\n",
" route: Literal[\"weather\", \"other\"]\n",
"\n",
"\n",
"router_model = raw_model.with_structured_output(Router)\n",
" \n",
"\n",
"\n",
"def router_node(state: RouterState):\n",
" system_message = \"Classify the incoming query as either about weather or not.\"\n",
" messages = [{\"role\": \"system\", \"content\": system_message}] + state['messages']\n",
" messages = [{\"role\": \"system\", \"content\": system_message}] + state[\"messages\"]\n",
" route = router_model.invoke(messages)\n",
" return {\"route\": route['route']}\n",
" return {\"route\": route[\"route\"]}\n",
"\n",
"\n",
"def normal_llm_node(state: RouterState):\n",
" response = raw_model.invoke(state['messages'])\n",
" response = raw_model.invoke(state[\"messages\"])\n",
" return {\"messages\": [response]}\n",
"\n",
"\n",
"def route_after_prediction(state: RouterState) -> Literal[\"weather_graph\", \"normal_llm_node\"]:\n",
" if state['route'] == \"weather\":\n",
"def route_after_prediction(\n",
" state: RouterState,\n",
") -> Literal[\"weather_graph\", \"normal_llm_node\"]:\n",
" if state[\"route\"] == \"weather\":\n",
" return \"weather_graph\"\n",
" else:\n",
" return \"normal_llm_node\"\n",
@@ -421,7 +427,9 @@
"metadata": {},
"outputs": [],
"source": [
"parent_graph_state_before_subgraph = next(h for h in graph.get_state_history(config) if h.next == ('weather_graph',))"
"parent_graph_state_before_subgraph = next(\n",
" h for h in graph.get_state_history(config) if h.next == (\"weather_graph\",)\n",
")"
]
},
{
@@ -430,7 +438,11 @@
"metadata": {},
"outputs": [],
"source": [
"subgraph_state_before_model_node = next(h for h in graph.get_state_history(parent_graph_state_before_subgraph.tasks[0].state) if h.next == ('model_node',))\n",
"subgraph_state_before_model_node = next(\n",
" h\n",
" for h in graph.get_state_history(parent_graph_state_before_subgraph.tasks[0].state)\n",
" if h.next == (\"model_node\",)\n",
")\n",
"\n",
"# This pattern can be extended no matter how many levels deep - image model node was another subgraph in this case\n",
"# subsubgraph_stat_history = next(h for h in graph.get_state_history(subgraph_state_before_model_node.tasks[0].state) if h.next == ('my_subsubgraph_node',))"
@@ -486,7 +498,12 @@
}
],
"source": [
"for value in graph.stream(None, config=subgraph_state_before_model_node.config, stream_mode=\"values\", subgraphs=True):\n",
"for value in graph.stream(\n",
" None,\n",
" config=subgraph_state_before_model_node.config,\n",
" stream_mode=\"values\",\n",
" subgraphs=True,\n",
"):\n",
" print(value)"
]
},
@@ -546,7 +563,7 @@
],
"source": [
"state = graph.get_state(config, subgraphs=True)\n",
"state.values['messages']"
"state.values[\"messages\"]"
]
},
{
@@ -637,16 +654,22 @@
"source": [
"config = {\"configurable\": {\"thread_id\": \"14\"}}\n",
"inputs = {\"messages\": [{\"role\": \"user\", \"content\": \"what's the weather in sf\"}]}\n",
"for update in graph.stream(inputs, config=config, stream_mode=\"updates\", subgraphs=True):\n",
"for update in graph.stream(\n",
" inputs, config=config, stream_mode=\"updates\", subgraphs=True\n",
"):\n",
" print(update)\n",
"# Graph execution should stop before the weather node\n",
"print(\"interrupted!\")\n",
"state = graph.get_state(config, subgraphs=True)\n",
"# We update the state by passing in the message we want returned from the weather node, and make sure to use as_node\n",
"graph.update_state(state.tasks[0].state.config, {\"messages\": [{\"role\": \"assistant\", \"content\": \"rainy\"}]}, as_node=\"weather_node\")\n",
"graph.update_state(\n",
" state.tasks[0].state.config,\n",
" {\"messages\": [{\"role\": \"assistant\", \"content\": \"rainy\"}]},\n",
" as_node=\"weather_node\",\n",
")\n",
"for update in graph.stream(None, config=config, stream_mode=\"updates\", subgraphs=True):\n",
" print(update)\n",
"print(graph.get_state(config).values['messages'])"
"print(graph.get_state(config).values[\"messages\"])"
]
},
{
@@ -679,16 +702,22 @@
"source": [
"config = {\"configurable\": {\"thread_id\": \"8\"}}\n",
"inputs = {\"messages\": [{\"role\": \"user\", \"content\": \"what's the weather in sf\"}]}\n",
"for update in graph.stream(inputs, config=config, stream_mode=\"updates\", subgraphs=True):\n",
"for update in graph.stream(\n",
" inputs, config=config, stream_mode=\"updates\", subgraphs=True\n",
"):\n",
" print(update)\n",
"# Graph execution should stop before the weather node\n",
"print(\"interrupted!\")\n",
"# We update the state by passing in the message we want returned from the weather graph, making sure to use as_node\n",
"# Note that we don't need to pass in the subgraph config, since we aren't updating the state inside the subgraph\n",
"graph.update_state(config, {\"messages\": [{\"role\": \"assistant\", \"content\": \"rainy\"}]}, as_node=\"weather_graph\")\n",
"graph.update_state(\n",
" config,\n",
" {\"messages\": [{\"role\": \"assistant\", \"content\": \"rainy\"}]},\n",
" as_node=\"weather_graph\",\n",
")\n",
"for update in graph.stream(None, config=config, stream_mode=\"updates\"):\n",
" print(update)\n",
"print(graph.get_state(config).values['messages'])"
"print(graph.get_state(config).values[\"messages\"])"
]
},
{
@@ -723,22 +752,26 @@
"class Router(TypedDict):\n",
" route: Literal[\"weather\", \"other\"]\n",
"\n",
"\n",
"router_model = raw_model.with_structured_output(Router)\n",
" \n",
"\n",
"\n",
"def router_node(state: RouterState):\n",
" system_message = \"Classify the incoming query as either about weather or not.\"\n",
" messages = [{\"role\": \"system\", \"content\": system_message}] + state['messages']\n",
" messages = [{\"role\": \"system\", \"content\": system_message}] + state[\"messages\"]\n",
" route = router_model.invoke(messages)\n",
" return {\"route\": route['route']}\n",
" return {\"route\": route[\"route\"]}\n",
"\n",
"\n",
"def normal_llm_node(state: RouterState):\n",
" response = raw_model.invoke(state['messages'])\n",
" response = raw_model.invoke(state[\"messages\"])\n",
" return {\"messages\": [response]}\n",
"\n",
"\n",
"def route_after_prediction(state: RouterState) -> Literal[\"weather_graph\", \"normal_llm_node\"]:\n",
" if state['route'] == \"weather\":\n",
"def route_after_prediction(\n",
" state: RouterState,\n",
") -> Literal[\"weather_graph\", \"normal_llm_node\"]:\n",
" if state[\"route\"] == \"weather\":\n",
" return \"weather_graph\"\n",
" else:\n",
" return \"normal_llm_node\"\n",
@@ -765,24 +798,30 @@
"\n",
"memory = MemorySaver()\n",
"\n",
"\n",
"class GrandfatherState(MessagesState):\n",
" to_continue: bool\n",
" \n",
"\n",
"\n",
"def router_node(state: GrandfatherState):\n",
" # Dummy logic that will always continue\n",
" return {\"to_continue\": True}\n",
"\n",
"def route_after_prediction(state: GrandfatherState) -> Literal[\"graph\", \"__end__\"]:\n",
" if state['to_continue']:\n",
"\n",
"def route_after_prediction(state: GrandfatherState):\n",
" if state[\"to_continue\"]:\n",
" return \"graph\"\n",
" else:\n",
" return \"__end__\"\n",
" return END\n",
"\n",
"\n",
"grandparent_graph = StateGraph(GrandfatherState)\n",
"grandparent_graph.add_node(router_node)\n",
"grandparent_graph.add_node(\"graph\", graph)\n",
"grandparent_graph.add_edge(START, \"router_node\")\n",
"grandparent_graph.add_conditional_edges(\"router_node\", route_after_prediction)\n",
"grandparent_graph.add_conditional_edges(\n",
" \"router_node\", route_after_prediction, [\"graph\", END]\n",
")\n",
"grandparent_graph.add_edge(\"graph\", END)\n",
"grandparent_graph = grandparent_graph.compile(checkpointer=MemorySaver())"
]
@@ -835,7 +874,9 @@
"source": [
"config = {\"configurable\": {\"thread_id\": \"2\"}}\n",
"inputs = {\"messages\": [{\"role\": \"user\", \"content\": \"what's the weather in sf\"}]}\n",
"for update in grandparent_graph.stream(inputs, config=config, stream_mode=\"updates\", subgraphs=True):\n",
"for update in grandparent_graph.stream(\n",
" inputs, config=config, stream_mode=\"updates\", subgraphs=True\n",
"):\n",
" print(update)"
]
},
@@ -897,10 +938,16 @@
"grandparent_graph_state = state\n",
"parent_graph_state = grandparent_graph_state.tasks[0].state\n",
"subgraph_state = parent_graph_state.tasks[0].state\n",
"grandparent_graph.update_state(subgraph_state.config, {\"messages\": [{\"role\": \"assistant\", \"content\": \"rainy\"}]}, as_node=\"weather_node\")\n",
"for update in grandparent_graph.stream(None, config=config, stream_mode=\"updates\", subgraphs=True):\n",
"grandparent_graph.update_state(\n",
" subgraph_state.config,\n",
" {\"messages\": [{\"role\": \"assistant\", \"content\": \"rainy\"}]},\n",
" as_node=\"weather_node\",\n",
")\n",
"for update in grandparent_graph.stream(\n",
" None, config=config, stream_mode=\"updates\", subgraphs=True\n",
"):\n",
" print(update)\n",
"print(grandparent_graph.get_state(config).values['messages'])"
"print(grandparent_graph.get_state(config).values[\"messages\"])"
]
},
{
+14 -23
View File
@@ -124,7 +124,7 @@
"from typing import Literal\n",
"\n",
"from langchain_anthropic import ChatAnthropic\n",
"from langgraph.graph import StateGraph, MessagesState\n",
"from langgraph.graph import StateGraph, MessagesState, START, END\n",
"from langgraph.prebuilt import ToolNode\n",
"\n",
"tool_node = ToolNode([get_weather])\n",
@@ -134,12 +134,12 @@
").bind_tools([get_weather])\n",
"\n",
"\n",
"def should_continue(state: MessagesState) -> Literal[\"tools\", \"__end__\"]:\n",
"def should_continue(state: MessagesState):\n",
" messages = state[\"messages\"]\n",
" last_message = messages[-1]\n",
" if last_message.tool_calls:\n",
" return \"tools\"\n",
" return \"__end__\"\n",
" return END\n",
"\n",
"\n",
"def call_model(state: MessagesState):\n",
@@ -154,11 +154,8 @@
"workflow.add_node(\"agent\", call_model)\n",
"workflow.add_node(\"tools\", tool_node)\n",
"\n",
"workflow.add_edge(\"__start__\", \"agent\")\n",
"workflow.add_conditional_edges(\n",
" \"agent\",\n",
" should_continue,\n",
")\n",
"workflow.add_edge(START, \"agent\")\n",
"workflow.add_conditional_edges(\"agent\", should_continue, [\"tools\", END])\n",
"workflow.add_edge(\"tools\", \"agent\")\n",
"\n",
"app = workflow.compile()"
@@ -295,6 +292,7 @@
"from langchain_core.output_parsers import StrOutputParser\n",
"from pydantic import BaseModel, Field\n",
"\n",
"\n",
"class HaikuRequest(BaseModel):\n",
" topic: list[str] = Field(\n",
" max_length=3,\n",
@@ -302,7 +300,6 @@
" )\n",
"\n",
"\n",
"\n",
"@tool\n",
"def master_haiku_generator(request: HaikuRequest):\n",
" \"\"\"Generates a haiku based on the provided topics.\"\"\"\n",
@@ -319,12 +316,12 @@
"model_with_tools = model.bind_tools([master_haiku_generator])\n",
"\n",
"\n",
"def should_continue(state: MessagesState) -> Literal[\"tools\", \"__end__\"]:\n",
"def should_continue(state: MessagesState):\n",
" messages = state[\"messages\"]\n",
" last_message = messages[-1]\n",
" if last_message.tool_calls:\n",
" return \"tools\"\n",
" return \"__end__\"\n",
" return END\n",
"\n",
"\n",
"def call_model(state: MessagesState):\n",
@@ -339,11 +336,8 @@
"workflow.add_node(\"agent\", call_model)\n",
"workflow.add_node(\"tools\", tool_node)\n",
"\n",
"workflow.add_edge(\"__start__\", \"agent\")\n",
"workflow.add_conditional_edges(\n",
" \"agent\",\n",
" should_continue,\n",
")\n",
"workflow.add_edge(START, \"agent\")\n",
"workflow.add_conditional_edges(\"agent\", should_continue, [\"tools\", END])\n",
"workflow.add_edge(\"tools\", \"agent\")\n",
"\n",
"app = workflow.compile()\n",
@@ -424,12 +418,12 @@
"better_model_with_tools = better_model.bind_tools([master_haiku_generator])\n",
"\n",
"\n",
"def should_continue(state: MessagesState) -> Literal[\"tools\", \"__end__\"]:\n",
"def should_continue(state: MessagesState):\n",
" messages = state[\"messages\"]\n",
" last_message = messages[-1]\n",
" if last_message.tool_calls:\n",
" return \"tools\"\n",
" return \"__end__\"\n",
" return END\n",
"\n",
"\n",
"def should_fallback(\n",
@@ -480,11 +474,8 @@
"workflow.add_node(\"remove_failed_tool_call_attempt\", remove_failed_tool_call_attempt)\n",
"workflow.add_node(\"fallback_agent\", call_fallback_model)\n",
"\n",
"workflow.add_edge(\"__start__\", \"agent\")\n",
"workflow.add_conditional_edges(\n",
" \"agent\",\n",
" should_continue,\n",
")\n",
"workflow.add_edge(START, \"agent\")\n",
"workflow.add_conditional_edges(\"agent\", should_continue, [\"tools\", END])\n",
"workflow.add_conditional_edges(\"tools\", should_fallback)\n",
"workflow.add_edge(\"remove_failed_tool_call_attempt\", \"fallback_agent\")\n",
"workflow.add_edge(\"fallback_agent\", \"tools\")\n",
+5 -8
View File
@@ -311,15 +311,15 @@
"source": [
"from typing import Literal\n",
"\n",
"from langgraph.graph import StateGraph, MessagesState\n",
"from langgraph.graph import StateGraph, MessagesState, START, END\n",
"\n",
"\n",
"def should_continue(state: MessagesState) -> Literal[\"tools\", \"__end__\"]:\n",
"def should_continue(state: MessagesState):\n",
" messages = state[\"messages\"]\n",
" last_message = messages[-1]\n",
" if last_message.tool_calls:\n",
" return \"tools\"\n",
" return \"__end__\"\n",
" return END\n",
"\n",
"\n",
"def call_model(state: MessagesState):\n",
@@ -334,11 +334,8 @@
"workflow.add_node(\"agent\", call_model)\n",
"workflow.add_node(\"tools\", tool_node)\n",
"\n",
"workflow.add_edge(\"__start__\", \"agent\")\n",
"workflow.add_conditional_edges(\n",
" \"agent\",\n",
" should_continue,\n",
")\n",
"workflow.add_edge(START, \"agent\")\n",
"workflow.add_conditional_edges(\"agent\", should_continue, [\"tools\", END])\n",
"workflow.add_edge(\"tools\", \"agent\")\n",
"\n",
"app = workflow.compile()"
+3 -1
View File
@@ -425,7 +425,9 @@
"try:\n",
" display(Image(app.get_graph().draw_png()))\n",
"except ImportError:\n",
" print(\"You likely need to install dependencies for pygraphviz, see more here https://github.com/pygraphviz/pygraphviz/blob/main/INSTALL.txt\")"
" print(\n",
" \"You likely need to install dependencies for pygraphviz, see more here https://github.com/pygraphviz/pygraphviz/blob/main/INSTALL.txt\"\n",
" )"
]
}
],
+1 -1
View File
@@ -6,4 +6,4 @@ title: Home
---
{!README.md!}
{!README.md!}
@@ -407,7 +407,7 @@
}
],
"source": [
"for chunk in simulation.stream({\"messages\":[]}):\n",
"for chunk in simulation.stream({\"messages\": []}):\n",
" # Print out all events aside from the final end chunk\n",
" if END not in chunk:\n",
" print(chunk)\n",
@@ -229,7 +229,7 @@
"from langgraph.graph import END\n",
"\n",
"\n",
"def get_state(state) -> Literal[\"add_tool_message\", \"info\", \"__end__\"]:\n",
"def get_state(state):\n",
" messages = state[\"messages\"]\n",
" if isinstance(messages[-1], AIMessage) and messages[-1].tool_calls:\n",
" return \"add_tool_message\"\n",
@@ -285,7 +285,7 @@
" }\n",
"\n",
"\n",
"workflow.add_conditional_edges(\"info\", get_state)\n",
"workflow.add_conditional_edges(\"info\", get_state, [\"add_tool_message\", \"info\", END])\n",
"workflow.add_edge(\"add_tool_message\", \"prompt\")\n",
"workflow.add_edge(\"prompt\", END)\n",
"workflow.add_edge(START, \"info\")\n",
@@ -378,8 +378,8 @@
],
"source": [
"import uuid\n",
" \n",
"cached_human_responses = ['hi!','rag prompt','1 rag, 2 none, 3 no, 4 no','red','q']\n",
"\n",
"cached_human_responses = [\"hi!\", \"rag prompt\", \"1 rag, 2 none, 3 no, 4 no\", \"red\", \"q\"]\n",
"cached_response_index = 0\n",
"config = {\"configurable\": {\"thread_id\": str(uuid.uuid4())}}\n",
"while True:\n",
@@ -196,7 +196,9 @@
"llm = ChatOpenAI(temperature=0, model=expt_llm)\n",
"code_gen_chain_oai = code_gen_prompt | llm.with_structured_output(code)\n",
"question = \"How do I build a RAG chain in LCEL?\"\n",
"solution = code_gen_chain_oai.invoke({\"context\":concatenated_content,\"messages\":[(\"user\",question)]})\n",
"solution = code_gen_chain_oai.invoke(\n",
" {\"context\": concatenated_content, \"messages\": [(\"user\", question)]}\n",
")\n",
"solution"
]
},
@@ -618,7 +620,7 @@
],
"source": [
"question = \"How can I directly pass a string to a runnable and use it to construct the input needed for my prompt?\"\n",
"solution = app.invoke({\"messages\": [(\"user\", question)], \"iterations\": 0, \"error\":\"\"})"
"solution = app.invoke({\"messages\": [(\"user\", question)], \"iterations\": 0, \"error\": \"\"})"
]
},
{
@@ -639,7 +641,7 @@
}
],
"source": [
"solution['generation']"
"solution[\"generation\"]"
]
},
{
@@ -764,7 +766,9 @@
"\n",
"def predict_langgraph(example: dict):\n",
" \"\"\"LangGraph\"\"\"\n",
" graph = app.invoke({\"messages\": [(\"user\", example[\"question\"])], \"iterations\": 0, \"error\": \"\"})\n",
" graph = app.invoke(\n",
" {\"messages\": [(\"user\", example[\"question\"])], \"iterations\": 0, \"error\": \"\"}\n",
" )\n",
" solution = graph[\"generation\"]\n",
" return {\"imports\": solution.imports, \"code\": solution.code}"
]
@@ -107,6 +107,8 @@
" f.write(response.content)\n",
" # Backup - we will use this to \"reset\" our DB in each section\n",
" shutil.copy(local_file, backup_file)\n",
"\n",
"\n",
"# Convert the flights to present time for our tutorial\n",
"def update_dates(file):\n",
" shutil.copy(backup_file, file)\n",
@@ -151,6 +153,7 @@
"\n",
" return file\n",
"\n",
"\n",
"db = update_dates(local_file)"
]
},
@@ -2545,7 +2548,7 @@
"builder.add_edge(\"fetch_user_info\", \"assistant\")\n",
"\n",
"\n",
"def route_tools(state: State) -> Literal[\"safe_tools\", \"sensitive_tools\", \"__end__\"]:\n",
"def route_tools(state: State):\n",
" next_node = tools_condition(state)\n",
" # If no tools are invoked, return to the user\n",
" if next_node == END:\n",
@@ -2560,8 +2563,7 @@
"\n",
"\n",
"builder.add_conditional_edges(\n",
" \"assistant\",\n",
" route_tools,\n",
" \"assistant\", route_tools, [\"safe_tools\", \"sensitive_tools\", END]\n",
")\n",
"builder.add_edge(\"safe_tools\", \"assistant\")\n",
"builder.add_edge(\"sensitive_tools\", \"assistant\")\n",
@@ -3523,12 +3525,7 @@
"\n",
"def route_update_flight(\n",
" state: State,\n",
") -> Literal[\n",
" \"update_flight_sensitive_tools\",\n",
" \"update_flight_safe_tools\",\n",
" \"leave_skill\",\n",
" \"__end__\",\n",
"]:\n",
"):\n",
" route = tools_condition(state)\n",
" if route == END:\n",
" return END\n",
@@ -3544,7 +3541,11 @@
"\n",
"builder.add_edge(\"update_flight_sensitive_tools\", \"update_flight\")\n",
"builder.add_edge(\"update_flight_safe_tools\", \"update_flight\")\n",
"builder.add_conditional_edges(\"update_flight\", route_update_flight)\n",
"builder.add_conditional_edges(\n",
" \"update_flight\",\n",
" route_update_flight,\n",
" [\"update_flight_sensitive_tools\", \"update_flight_safe_tools\", \"leave_skill\", END],\n",
")\n",
"\n",
"\n",
"# This node will be shared for exiting all specialized assistants\n",
@@ -3608,12 +3609,7 @@
"\n",
"def route_book_car_rental(\n",
" state: State,\n",
") -> Literal[\n",
" \"book_car_rental_safe_tools\",\n",
" \"book_car_rental_sensitive_tools\",\n",
" \"leave_skill\",\n",
" \"__end__\",\n",
"]:\n",
"):\n",
" route = tools_condition(state)\n",
" if route == END:\n",
" return END\n",
@@ -3629,7 +3625,16 @@
"\n",
"builder.add_edge(\"book_car_rental_sensitive_tools\", \"book_car_rental\")\n",
"builder.add_edge(\"book_car_rental_safe_tools\", \"book_car_rental\")\n",
"builder.add_conditional_edges(\"book_car_rental\", route_book_car_rental)"
"builder.add_conditional_edges(\n",
" \"book_car_rental\",\n",
" route_book_car_rental,\n",
" [\n",
" \"book_car_rental_safe_tools\",\n",
" \"book_car_rental_sensitive_tools\",\n",
" \"leave_skill\",\n",
" END,\n",
" ],\n",
")"
]
},
{
@@ -3665,9 +3670,7 @@
"\n",
"def route_book_hotel(\n",
" state: State,\n",
") -> Literal[\n",
" \"leave_skill\", \"book_hotel_safe_tools\", \"book_hotel_sensitive_tools\", \"__end__\"\n",
"]:\n",
"):\n",
" route = tools_condition(state)\n",
" if route == END:\n",
" return END\n",
@@ -3683,7 +3686,11 @@
"\n",
"builder.add_edge(\"book_hotel_sensitive_tools\", \"book_hotel\")\n",
"builder.add_edge(\"book_hotel_safe_tools\", \"book_hotel\")\n",
"builder.add_conditional_edges(\"book_hotel\", route_book_hotel)"
"builder.add_conditional_edges(\n",
" \"book_hotel\",\n",
" route_book_hotel,\n",
" [\"leave_skill\", \"book_hotel_safe_tools\", \"book_hotel_sensitive_tools\", END],\n",
")"
]
},
{
@@ -3720,12 +3727,7 @@
"\n",
"def route_book_excursion(\n",
" state: State,\n",
") -> Literal[\n",
" \"book_excursion_safe_tools\",\n",
" \"book_excursion_sensitive_tools\",\n",
" \"leave_skill\",\n",
" \"__end__\",\n",
"]:\n",
"):\n",
" route = tools_condition(state)\n",
" if route == END:\n",
" return END\n",
@@ -3741,7 +3743,11 @@
"\n",
"builder.add_edge(\"book_excursion_sensitive_tools\", \"book_excursion\")\n",
"builder.add_edge(\"book_excursion_safe_tools\", \"book_excursion\")\n",
"builder.add_conditional_edges(\"book_excursion\", route_book_excursion)"
"builder.add_conditional_edges(\n",
" \"book_excursion\",\n",
" route_book_excursion,\n",
" [\"book_excursion_safe_tools\", \"book_excursion_sensitive_tools\", \"leave_skill\", END],\n",
")"
]
},
{
@@ -3768,13 +3774,7 @@
"\n",
"def route_primary_assistant(\n",
" state: State,\n",
") -> Literal[\n",
" \"primary_assistant_tools\",\n",
" \"enter_update_flight\",\n",
" \"enter_book_hotel\",\n",
" \"enter_book_excursion\",\n",
" \"__end__\",\n",
"]:\n",
"):\n",
" route = tools_condition(state)\n",
" if route == END:\n",
" return END\n",
@@ -3797,14 +3797,14 @@
"builder.add_conditional_edges(\n",
" \"primary_assistant\",\n",
" route_primary_assistant,\n",
" {\n",
" \"enter_update_flight\": \"enter_update_flight\",\n",
" \"enter_book_car_rental\": \"enter_book_car_rental\",\n",
" \"enter_book_hotel\": \"enter_book_hotel\",\n",
" \"enter_book_excursion\": \"enter_book_excursion\",\n",
" \"primary_assistant_tools\": \"primary_assistant_tools\",\n",
" END: END,\n",
" },\n",
" [\n",
" \"enter_update_flight\",\n",
" \"enter_book_car_rental\",\n",
" \"enter_book_hotel\",\n",
" \"enter_book_excursion\",\n",
" \"primary_assistant_tools\",\n",
" END,\n",
" ],\n",
")\n",
"builder.add_edge(\"primary_assistant_tools\", \"primary_assistant\")\n",
"\n",
+7 -5
View File
@@ -286,16 +286,16 @@
" builder.add_edge(START, \"count_messages\")\n",
" builder.add_edge(\"count_messages\", \"llm\")\n",
"\n",
" def route_validator(state: State) -> Literal[\"validator\", \"__end__\"]:\n",
" def route_validator(state: State):\n",
" if state[\"messages\"][-1].tool_calls or tool_choice is not None:\n",
" return \"validator\"\n",
" return \"__end__\"\n",
" return END\n",
"\n",
" builder.add_conditional_edges(\"llm\", route_validator)\n",
" builder.add_conditional_edges(\"llm\", route_validator, [\"validator\", END])\n",
" builder.add_edge(\"fallback\", \"validator\")\n",
" max_attempts = retry_strategy.get(\"max_attempts\", 3)\n",
"\n",
" def route_validation(state: State) -> Literal[\"finalizer\", \"fallback\"]:\n",
" def route_validation(state: State):\n",
" if state[\"attempt_number\"] > max_attempts:\n",
" raise ValueError(\n",
" f\"Could not extract a valid value in {max_attempts} attempts.\"\n",
@@ -307,7 +307,9 @@
" return \"fallback\"\n",
" return \"finalizer\"\n",
"\n",
" builder.add_conditional_edges(\"validator\", route_validation)\n",
" builder.add_conditional_edges(\n",
" \"validator\", route_validation, [\"finalizer\", \"fallback\"]\n",
" )\n",
"\n",
" builder.add_edge(\"finalizer\", END)\n",
"\n",
+13 -11
View File
@@ -464,7 +464,7 @@
"from langchain_anthropic import ChatAnthropic\n",
"from typing_extensions import TypedDict\n",
"\n",
"from langgraph.graph import StateGraph, START\n",
"from langgraph.graph import StateGraph, START, END\n",
"from langgraph.graph.message import add_messages\n",
"\n",
"\n",
@@ -552,7 +552,7 @@
"\n",
"Below, call define a router function called `route_tools`, that checks for tool_calls in the chatbot's output. Provide this function to the graph by calling `add_conditional_edges`, which tells the graph that whenever the `chatbot` node completes to check this function to see where to go next. \n",
"\n",
"The condition will route to `tools` if tool calls are present and \"`__end__`\" if not.\n",
"The condition will route to `tools` if tool calls are present and `END` if not.\n",
"\n",
"Later, we will replace this with the prebuilt [tools_condition](https://langchain-ai.github.io/langgraph/reference/prebuilt/#tools_condition) to be more concise, but implementing it ourselves first makes things more clear. "
]
@@ -569,7 +569,7 @@
"\n",
"def route_tools(\n",
" state: State,\n",
") -> Literal[\"tools\", \"__end__\"]:\n",
"):\n",
" \"\"\"\n",
" Use in the conditional_edge to route to the ToolNode if the last message\n",
" has tool calls. Otherwise, route to the end.\n",
@@ -582,10 +582,10 @@
" raise ValueError(f\"No messages found in input state to tool_edge: {state}\")\n",
" if hasattr(ai_message, \"tool_calls\") and len(ai_message.tool_calls) > 0:\n",
" return \"tools\"\n",
" return \"__end__\"\n",
" return END\n",
"\n",
"\n",
"# The `tools_condition` function returns \"tools\" if the chatbot asks to use a tool, and \"__end__\" if\n",
"# The `tools_condition` function returns \"tools\" if the chatbot asks to use a tool, and \"END\" if\n",
"# it is fine directly responding. This conditional routing defines the main agent loop.\n",
"graph_builder.add_conditional_edges(\n",
" \"chatbot\",\n",
@@ -595,7 +595,7 @@
" # want to use a node named something else apart from \"tools\",\n",
" # You can update the value of the dictionary to something else\n",
" # e.g., \"tools\": \"my_tools\"\n",
" {\"tools\": \"tools\", \"__end__\": \"__end__\"},\n",
" {\"tools\": \"tools\", END: END},\n",
")\n",
"# Any time a tool is called, we return to the chatbot to decide the next step\n",
"graph_builder.add_edge(\"tools\", \"chatbot\")\n",
@@ -610,7 +610,7 @@
"source": [
"**Notice** that conditional edges start from a single node. This tells the graph \"any time the '`chatbot`' node runs, either go to 'tools' if it calls a tool, or end the loop if it responds directly. \n",
"\n",
"Like the prebuilt `tools_condition`, our function returns the \"`__end__`\" string if no tool calls are made. When the graph transitions to `__end__`, it has no more tasks to complete and ceases execution. Because the condition can return `__end__`, we don't need to explicitly set a `finish_point` this time. Our graph already has a way to finish!\n",
"Like the prebuilt `tools_condition`, our function returns the `END` string if no tool calls are made. When the graph transitions to `END`, it has no more tasks to complete and ceases execution. Because the condition can return `END`, we don't need to explicitly set a `finish_point` this time. Our graph already has a way to finish!\n",
"\n",
"Let's visualize the graph we've built. The following function has some additional dependencies to run that are unimportant for this tutorial."
]
@@ -1115,7 +1115,7 @@
"id": "627f4998-6780-4cce-8f3c-9a5580888e3a",
"metadata": {},
"source": [
"The snapshot above contains the current state values, corresponding config, and the `next` node to process. In our case, the graph has reached an `__end__` state, so `next` is empty.\n",
"The snapshot above contains the current state values, corresponding config, and the `next` node to process. In our case, the graph has reached an `END` state, so `next` is empty.\n",
"\n",
"**Congratulations!** Your chatbot can now maintain conversation state across sessions thanks to LangGraph's checkpointing system. This opens up exciting possibilities for more natural, contextual interactions. LangGraph's checkpointing even handles **arbitrarily complex graph states**, which is much more expressive and powerful than simple chat memory.\n",
"\n",
@@ -2108,6 +2108,7 @@
"source": [
"from pydantic import BaseModel\n",
"\n",
"\n",
"class RequestAssistance(BaseModel):\n",
" \"\"\"Escalate the conversation to an expert. Use this if you are unable to assist directly or if the user requires support beyond your permissions.\n",
"\n",
@@ -2243,7 +2244,7 @@
"graph_builder.add_conditional_edges(\n",
" \"chatbot\",\n",
" select_next_node,\n",
" {\"human\": \"human\", \"tools\": \"tools\", \"__end__\": \"__end__\"},\n",
" {\"human\": \"human\", \"tools\": \"tools\", END: END},\n",
")"
]
},
@@ -2668,6 +2669,7 @@
"from langchain_anthropic import ChatAnthropic\n",
"from langchain_community.tools.tavily_search import TavilySearchResults\n",
"from langchain_core.messages import AIMessage, ToolMessage\n",
"\n",
"# NOTE: you must use langchain-core >= 0.3 with Pydantic v2\n",
"from pydantic import BaseModel\n",
"from typing_extensions import TypedDict\n",
@@ -2744,7 +2746,7 @@
"graph_builder.add_node(\"human\", human_node)\n",
"\n",
"\n",
"def select_next_node(state: State) -> Literal[\"human\", \"tools\", \"__end__\"]:\n",
"def select_next_node(state: State):\n",
" if state[\"ask_human\"]:\n",
" return \"human\"\n",
" # Otherwise, we can route as before\n",
@@ -2754,7 +2756,7 @@
"graph_builder.add_conditional_edges(\n",
" \"chatbot\",\n",
" select_next_node,\n",
" {\"human\": \"human\", \"tools\": \"tools\", \"__end__\": \"__end__\"},\n",
" {\"human\": \"human\", \"tools\": \"tools\", END: END},\n",
")\n",
"graph_builder.add_edge(\"tools\", \"chatbot\")\n",
"graph_builder.add_edge(\"human\", \"chatbot\")\n",
+46 -16
View File
@@ -142,6 +142,7 @@
" def normalized_score(self) -> float:\n",
" return self.score / 10.0\n",
"\n",
"\n",
"class Node:\n",
" def __init__(\n",
" self,\n",
@@ -476,12 +477,22 @@
" \"\"\"Generate the initial candidate response.\"\"\"\n",
" res = initial_answer_chain.invoke({\"input\": state[\"input\"]})\n",
" parsed = parser.invoke(res)\n",
" tool_responses = [tool_node.invoke(\n",
" {\"messages\": [\n",
" AIMessage(content=\"\",tool_calls=[{\"name\":r[\"type\"], \"args\":r[\"args\"], 'id':r['id']}]) \n",
" ]}\n",
" ) for r in parsed]\n",
" output_messages = [res] + [tr['messages'][0] for tr in tool_responses]\n",
" tool_responses = [\n",
" tool_node.invoke(\n",
" {\n",
" \"messages\": [\n",
" AIMessage(\n",
" content=\"\",\n",
" tool_calls=[\n",
" {\"name\": r[\"type\"], \"args\": r[\"args\"], \"id\": r[\"id\"]}\n",
" ],\n",
" )\n",
" ]\n",
" }\n",
" )\n",
" for r in parsed\n",
" ]\n",
" output_messages = [res] + [tr[\"messages\"][0] for tr in tool_responses]\n",
" reflection = reflection_chain.invoke(\n",
" {\"input\": state[\"input\"], \"candidate\": output_messages}\n",
" )\n",
@@ -575,12 +586,13 @@
"source": [
"from collections import defaultdict\n",
"\n",
"\n",
"def select(root: Node) -> dict:\n",
" \"\"\"Starting from the root node a child node is selected at each tree level until a leaf node is reached.\"\"\"\n",
"\n",
" if not root.children:\n",
" return root\n",
" \n",
"\n",
" node = root\n",
" while node.children:\n",
" max_child = max(node.children, key=lambda child: child.upper_confidence_bound())\n",
@@ -588,6 +600,7 @@
"\n",
" return node\n",
"\n",
"\n",
"def expand(state: TreeState, config: RunnableConfig) -> dict:\n",
" \"\"\"Starting from the \"best\" node in the tree, generate N candidates for the next step.\"\"\"\n",
" root = state[\"root\"]\n",
@@ -603,16 +616,31 @@
" for i, tool_calls in enumerate(parsed)\n",
" for tool_call in tool_calls\n",
" ]\n",
" tool_responses = [(i,tool_node.invoke(\n",
" {\"messages\":\n",
" [AIMessage(content=\"\",tool_calls=[{\"name\":tool_call[\"type\"], \"args\":tool_call[\"args\"], 'id':tool_call['id']}])]\n",
" }\n",
" )) for i, tool_call in flattened]\n",
" tool_responses = [\n",
" (\n",
" i,\n",
" tool_node.invoke(\n",
" {\n",
" \"messages\": [\n",
" AIMessage(\n",
" content=\"\",\n",
" tool_calls=[\n",
" {\n",
" \"name\": tool_call[\"type\"],\n",
" \"args\": tool_call[\"args\"],\n",
" \"id\": tool_call[\"id\"],\n",
" }\n",
" ],\n",
" )\n",
" ]\n",
" }\n",
" ),\n",
" )\n",
" for i, tool_call in flattened\n",
" ]\n",
" collected_responses = defaultdict(list)\n",
" for i, resp in tool_responses:\n",
" collected_responses[i].append(\n",
" resp['messages'][0]\n",
" )\n",
" collected_responses[i].append(resp[\"messages\"][0])\n",
" output_messages = []\n",
" for i, candidate in enumerate(new_candidates):\n",
" output_messages.append([candidate] + collected_responses[i])\n",
@@ -655,7 +683,7 @@
"from langgraph.graph import END, StateGraph, START\n",
"\n",
"\n",
"def should_loop(state: TreeState) -> Literal[\"expand\", \"__end__\"]:\n",
"def should_loop(state: TreeState):\n",
" \"\"\"Determine whether to continue the tree search.\"\"\"\n",
" root = state[\"root\"]\n",
" if root.is_solved:\n",
@@ -675,11 +703,13 @@
" \"start\",\n",
" # Either expand/rollout or finish\n",
" should_loop,\n",
" [\"expand\", END],\n",
")\n",
"builder.add_conditional_edges(\n",
" \"expand\",\n",
" # Either continue to rollout or finish\n",
" should_loop,\n",
" [\"expand\", END],\n",
")\n",
"\n",
"graph = builder.compile()"
@@ -859,8 +859,7 @@
" args_for_tasks[task[\"idx\"]] = task[\"args\"]\n",
" if (\n",
" # Depends on other tasks\n",
" deps\n",
" and (any([dep not in observations for dep in deps]))\n",
" deps and (any([dep not in observations for dep in deps]))\n",
" ):\n",
" futures.append(\n",
" executor.submit(\n",
@@ -883,7 +882,10 @@
" }\n",
" tool_messages = [\n",
" FunctionMessage(\n",
" name=name, content=str(obs), additional_kwargs={\"idx\": k, \"args\": task_args}, tool_call_id = k\n",
" name=name,\n",
" content=str(obs),\n",
" additional_kwargs={\"idx\": k, \"args\": task_args},\n",
" tool_call_id=k,\n",
" )\n",
" for k, (name, task_args, obs) in new_observations.items()\n",
" ]\n",
@@ -936,7 +938,9 @@
"metadata": {},
"outputs": [],
"source": [
"tool_messages = plan_and_schedule.invoke({\"messages\":[HumanMessage(content=example_question)]})['messages']"
"tool_messages = plan_and_schedule.invoke(\n",
" {\"messages\": [HumanMessage(content=example_question)]}\n",
")[\"messages\"]"
]
},
{
@@ -1050,11 +1054,13 @@
"def _parse_joiner_output(decision: JoinOutputs) -> List[BaseMessage]:\n",
" response = [AIMessage(content=f\"Thought: {decision.thought}\")]\n",
" if isinstance(decision.action, Replan):\n",
" return {\"messages\": response + [\n",
" SystemMessage(\n",
" content=f\"Context from last attempt: {decision.action.feedback}\"\n",
" )\n",
" ]\n",
" return {\n",
" \"messages\": response\n",
" + [\n",
" SystemMessage(\n",
" content=f\"Context from last attempt: {decision.action.feedback}\"\n",
" )\n",
" ]\n",
" }\n",
" else:\n",
" return {\"messages\": response + [AIMessage(content=decision.action.response)]}\n",
@@ -1102,7 +1108,7 @@
}
],
"source": [
"joiner.invoke({\"messages\":input_messages})"
"joiner.invoke({\"messages\": input_messages})"
]
},
{
@@ -1217,7 +1223,7 @@
],
"source": [
"# Final answer\n",
"print(step['join']['messages'][-1].content)"
"print(step[\"join\"][\"messages\"][-1].content)"
]
},
{
@@ -1248,12 +1254,13 @@
}
],
"source": [
"steps = chain.stream({\"messages\":\n",
" [\n",
" HumanMessage(\n",
" content=\"What's the oldest parrot alive, and how much longer is that than the average?\"\n",
" )\n",
" ]\n",
"steps = chain.stream(\n",
" {\n",
" \"messages\": [\n",
" HumanMessage(\n",
" content=\"What's the oldest parrot alive, and how much longer is that than the average?\"\n",
" )\n",
" ]\n",
" },\n",
" {\n",
" \"recursion_limit\": 100,\n",
@@ -1280,7 +1287,7 @@
],
"source": [
"# Final answer\n",
"print(step['join']['messages'][-1].content)"
"print(step[\"join\"][\"messages\"][-1].content)"
]
},
{
@@ -1307,12 +1314,14 @@
}
],
"source": [
"for step in chain.stream({\"messages\":\n",
" [\n",
" HumanMessage(\n",
" content=\"What's ((3*(4+5)/0.5)+3245) + 8? What's 32/4.23? What's the sum of those two values?\"\n",
" )\n",
" ]}\n",
"for step in chain.stream(\n",
" {\n",
" \"messages\": [\n",
" HumanMessage(\n",
" content=\"What's ((3*(4+5)/0.5)+3245) + 8? What's 32/4.23? What's the sum of those two values?\"\n",
" )\n",
" ]\n",
" }\n",
"):\n",
" print(step)"
]
@@ -1335,7 +1344,7 @@
],
"source": [
"# Final answer\n",
"print(step['join']['messages'][-1].content)"
"print(step[\"join\"][\"messages\"][-1].content)"
]
},
{
@@ -1364,12 +1373,14 @@
}
],
"source": [
"for step in chain.stream({\"messages\":\n",
" [\n",
" HumanMessage(\n",
" content=\"Find the current temperature in Tokyo, then, respond with a flashcard summarizing this information\"\n",
" )\n",
" ]}\n",
"for step in chain.stream(\n",
" {\n",
" \"messages\": [\n",
" HumanMessage(\n",
" content=\"Find the current temperature in Tokyo, then, respond with a flashcard summarizing this information\"\n",
" )\n",
" ]\n",
" }\n",
"):\n",
" print(step)"
]
@@ -158,7 +158,7 @@
" user_id = config[\"configurable\"].get(\"user_id\")\n",
" if user_id is None:\n",
" raise ValueError(\"User ID needs to be provided to save a memory.\")\n",
" \n",
"\n",
" return user_id\n",
"\n",
"\n",
@@ -166,7 +166,9 @@
"def save_recall_memory(memory: str, config: RunnableConfig) -> str:\n",
" \"\"\"Save memory to vectorstore for later semantic retrieval.\"\"\"\n",
" user_id = get_user_id(config)\n",
" document = Document(page_content=memory, id=str(uuid.uuid4()), metadata={\"user_id\": user_id})\n",
" document = Document(\n",
" page_content=memory, id=str(uuid.uuid4()), metadata={\"user_id\": user_id}\n",
" )\n",
" recall_vector_store.add_documents([document])\n",
" return memory\n",
"\n",
@@ -175,10 +177,13 @@
"def search_recall_memories(query: str, config: RunnableConfig) -> List[str]:\n",
" \"\"\"Search for relevant memories.\"\"\"\n",
" user_id = get_user_id(config)\n",
"\n",
" def _filter_function(doc: Document) -> bool:\n",
" return doc.metadata.get(\"user_id\") == user_id\n",
"\n",
" documents = recall_vector_store.similarity_search(query, k=3, filter=_filter_function)\n",
" documents = recall_vector_store.similarity_search(\n",
" query, k=3, filter=_filter_function\n",
" )\n",
" return [document.page_content for document in documents]"
]
},
@@ -281,7 +286,7 @@
" \" information you want to retain in the next conversation. If you\"\n",
" \" do call tools, all text preceding the tool call is an internal\"\n",
" \" message. Respond AFTER calling the tool, once you have\"\n",
" \" confirmation that the tool completed successfully.\\n\\n\"\n",
" \" confirmation that the tool completed successfully.\\n\\n\",\n",
" ),\n",
" (\"placeholder\", \"{messages}\"),\n",
" ]\n",
@@ -300,6 +305,7 @@
"\n",
"tokenizer = tiktoken.encoding_for_model(\"gpt-4o\")\n",
"\n",
"\n",
"def agent(state: State) -> State:\n",
" \"\"\"Process the current state and generate a response using the LLM.\n",
"\n",
@@ -354,7 +360,7 @@
" msg = state[\"messages\"][-1]\n",
" if msg.tool_calls:\n",
" return \"tools\"\n",
" \n",
"\n",
" return END"
]
},
@@ -594,7 +600,10 @@
}
],
"source": [
"for chunk in graph.stream({\"messages\": [(\"user\", \"yes -- pepperoni!\")]}, config={\"configurable\": {\"user_id\": \"1\", \"thread_id\": \"1\"}}):\n",
"for chunk in graph.stream(\n",
" {\"messages\": [(\"user\", \"yes -- pepperoni!\")]},\n",
" config={\"configurable\": {\"user_id\": \"1\", \"thread_id\": \"1\"}},\n",
"):\n",
" pretty_print_stream_chunk(chunk)"
]
},
@@ -638,7 +647,10 @@
}
],
"source": [
"for chunk in graph.stream({\"messages\": [(\"user\", \"i also just moved to new york\")]}, config={\"configurable\": {\"user_id\": \"1\", \"thread_id\": \"1\"}}):\n",
"for chunk in graph.stream(\n",
" {\"messages\": [(\"user\", \"i also just moved to new york\")]},\n",
" config={\"configurable\": {\"user_id\": \"1\", \"thread_id\": \"1\"}},\n",
"):\n",
" pretty_print_stream_chunk(chunk)"
]
},
@@ -682,7 +694,9 @@
"source": [
"config = {\"configurable\": {\"user_id\": \"1\", \"thread_id\": \"2\"}}\n",
"\n",
"for chunk in graph.stream({\"messages\": [(\"user\", \"where should i go for dinner?\")]}, config=config):\n",
"for chunk in graph.stream(\n",
" {\"messages\": [(\"user\", \"where should i go for dinner?\")]}, config=config\n",
"):\n",
" pretty_print_stream_chunk(chunk)"
]
},
@@ -740,7 +754,10 @@
}
],
"source": [
"for chunk in graph.stream({\"messages\": [(\"user\", \"what's the address for joe's in greenwich village?\")]}, config=config):\n",
"for chunk in graph.stream(\n",
" {\"messages\": [(\"user\", \"what's the address for joe's in greenwich village?\")]},\n",
" config=config,\n",
"):\n",
" pretty_print_stream_chunk(chunk)"
]
},
@@ -924,7 +941,9 @@
}
],
"source": [
"for chunk in graph.stream({\"messages\": [(\"user\", \"My friend John likes Pizza.\")]}, config=config):\n",
"for chunk in graph.stream(\n",
" {\"messages\": [(\"user\", \"My friend John likes Pizza.\")]}, config=config\n",
"):\n",
" pretty_print_stream_chunk(chunk)"
]
},
@@ -962,7 +981,9 @@
"source": [
"config = {\"configurable\": {\"user_id\": \"3\", \"thread_id\": \"2\"}}\n",
"\n",
"for chunk in graph.stream({\"messages\": [(\"user\", \"What food should I bring to John's party?\")]}, config=config):\n",
"for chunk in graph.stream(\n",
" {\"messages\": [(\"user\", \"What food should I bring to John's party?\")]}, config=config\n",
"):\n",
" pretty_print_stream_chunk(chunk)"
]
},
@@ -1007,7 +1028,9 @@
"\n",
"\n",
"# Fetch records\n",
"records = recall_vector_store.similarity_search(\"Alice\", k=2, filter=lambda doc: doc.metadata[\"user_id\"] == \"3\")\n",
"records = recall_vector_store.similarity_search(\n",
" \"Alice\", k=2, filter=lambda doc: doc.metadata[\"user_id\"] == \"3\"\n",
")\n",
"\n",
"\n",
"# Plot graph\n",
@@ -124,9 +124,12 @@
"source": [
"from langchain_core.messages import HumanMessage\n",
"\n",
"\n",
"def agent_node(state, agent, name):\n",
" result = agent.invoke(state)\n",
" return {\"messages\": [HumanMessage(content=result[\"messages\"][-1].content, name=name)]}"
" return {\n",
" \"messages\": [HumanMessage(content=result[\"messages\"][-1].content, name=name)]\n",
" }"
]
},
{
@@ -163,9 +166,11 @@
"# and decides when the work is completed\n",
"options = [\"FINISH\"] + members\n",
"\n",
"\n",
"class routeResponse(BaseModel):\n",
" next: Literal[*options]\n",
"\n",
"\n",
"prompt = ChatPromptTemplate.from_messages(\n",
" [\n",
" (\"system\", system_prompt),\n",
@@ -181,11 +186,9 @@
"\n",
"llm = ChatOpenAI(model=\"gpt-4o\")\n",
"\n",
"\n",
"def supervisor_agent(state):\n",
" supervisor_chain = (\n",
" prompt\n",
" | llm.with_structured_output(routeResponse)\n",
" )\n",
" supervisor_chain = prompt | llm.with_structured_output(routeResponse)\n",
" return supervisor_chain.invoke(state)"
]
},
@@ -216,6 +219,7 @@
"from langgraph.graph import END, StateGraph, START\n",
"from langgraph.prebuilt import create_react_agent\n",
"\n",
"\n",
"# The agent state is the input to each node in the graph\n",
"class AgentState(TypedDict):\n",
" # The annotation tells the graph that new messages will always\n",
@@ -305,7 +305,9 @@
"\n",
"def agent_node(state, agent, name):\n",
" result = agent.invoke(state)\n",
" return {\"messages\": [HumanMessage(content=result[\"messages\"][-1].content, name=name)]}\n",
" return {\n",
" \"messages\": [HumanMessage(content=result[\"messages\"][-1].content, name=name)]\n",
" }\n",
"\n",
"\n",
"def create_team_supervisor(llm: ChatOpenAI, system_prompt, members) -> str:\n",
@@ -340,7 +342,8 @@
" ]\n",
" ).partial(options=str(options), team_members=\", \".join(members))\n",
" return (\n",
" prompt | trimmer\n",
" prompt\n",
" | trimmer\n",
" | llm.bind_functions(functions=[function_def], function_call=\"route\")\n",
" | JsonOutputFunctionsParser()\n",
" )"
@@ -379,6 +382,7 @@
"from langchain_openai.chat_models import ChatOpenAI\n",
"from langgraph.prebuilt import create_react_agent\n",
"\n",
"\n",
"# ResearchTeam graph state\n",
"class ResearchTeamState(TypedDict):\n",
" # A message is added after each team member finishes\n",
@@ -595,14 +599,16 @@
"\n",
"llm = ChatOpenAI(model=\"gpt-4o\")\n",
"\n",
"doc_writer_agent = create_react_agent(llm, tools=[write_document, edit_document, read_document])\n",
"doc_writer_agent = create_react_agent(\n",
" llm, tools=[write_document, edit_document, read_document]\n",
")\n",
"# Injects current directory working state before each call\n",
"context_aware_doc_writer_agent = prelude | doc_writer_agent\n",
"doc_writing_node = functools.partial(\n",
" agent_node, agent=context_aware_doc_writer_agent, name=\"DocWriter\"\n",
")\n",
"\n",
"note_taking_agent = create_react_agent(llm,tools=[create_outline, read_document])\n",
"note_taking_agent = create_react_agent(llm, tools=[create_outline, read_document])\n",
"context_aware_note_taking_agent = prelude | note_taking_agent\n",
"note_taking_node = functools.partial(\n",
" agent_node, agent=context_aware_note_taking_agent, name=\"NoteTaker\"\n",
@@ -312,7 +312,7 @@
"from typing import Literal\n",
"\n",
"\n",
"def router(state) -> Literal[\"call_tool\", \"__end__\", \"continue\"]:\n",
"def router(state):\n",
" # This is the router\n",
" messages = state[\"messages\"]\n",
" last_message = messages[-1]\n",
@@ -321,7 +321,7 @@
" return \"call_tool\"\n",
" if \"FINAL ANSWER\" in last_message.content:\n",
" # Any agent decided the work is done\n",
" return \"__end__\"\n",
" return END\n",
" return \"continue\""
]
},
@@ -351,12 +351,12 @@
"workflow.add_conditional_edges(\n",
" \"Researcher\",\n",
" router,\n",
" {\"continue\": \"chart_generator\", \"call_tool\": \"call_tool\", \"__end__\": END},\n",
" {\"continue\": \"chart_generator\", \"call_tool\": \"call_tool\", END: END},\n",
")\n",
"workflow.add_conditional_edges(\n",
" \"chart_generator\",\n",
" router,\n",
" {\"continue\": \"Researcher\", \"call_tool\": \"call_tool\", \"__end__\": END},\n",
" {\"continue\": \"Researcher\", \"call_tool\": \"call_tool\", END: END},\n",
")\n",
"\n",
"workflow.add_conditional_edges(\n",
@@ -391,6 +391,7 @@
"outputs": [],
"source": [
"from typing import Literal\n",
"from langgraph.graph import END\n",
"\n",
"\n",
"async def execute_step(state: PlanExecute):\n",
@@ -420,9 +421,9 @@
" return {\"plan\": output.action.steps}\n",
"\n",
"\n",
"def should_end(state: PlanExecute) -> Literal[\"agent\", \"__end__\"]:\n",
"def should_end(state: PlanExecute):\n",
" if \"response\" in state and state[\"response\"]:\n",
" return \"__end__\"\n",
" return END\n",
" else:\n",
" return \"agent\""
]
@@ -459,6 +460,7 @@
" \"replan\",\n",
" # Next, we pass in the function that will determine which node is called next.\n",
" should_end,\n",
" [\"agent\", END],\n",
")\n",
"\n",
"# Finally, we compile it!\n",
@@ -185,7 +185,6 @@
"from pydantic import BaseModel, Field\n",
"\n",
"\n",
"\n",
"# Data model\n",
"class RouteQuery(BaseModel):\n",
" \"\"\"Route a user query to the most relevant datasource.\"\"\"\n",
@@ -59,9 +59,10 @@
"source": [
"### LLM\n",
"from langchain_ollama import ChatOllama\n",
"local_llm = 'llama3.2:3b-instruct-fp16'\n",
"\n",
"local_llm = \"llama3.2:3b-instruct-fp16\"\n",
"llm = ChatOllama(model=local_llm, temperature=0)\n",
"llm_json_mode = ChatOllama(model=local_llm, temperature=0, format='json')"
"llm_json_mode = ChatOllama(model=local_llm, temperature=0, format=\"json\")"
]
},
{
@@ -76,19 +77,22 @@
},
{
"cell_type": "code",
"execution_count": 4,
"execution_count": null,
"id": "8a8792f5",
"metadata": {},
"outputs": [],
"source": [
"import os, getpass\n",
"import os\n",
"import getpass\n",
"\n",
"\n",
"def _set_env(var: str):\n",
" if not os.environ.get(var):\n",
" os.environ[var] = getpass.getpass(f\"{var}: \")\n",
"\n",
"\n",
"_set_env(\"TAVILY_API_KEY\")\n",
"os.environ['TOKENIZERS_PARALLELISM'] = 'true'"
"os.environ[\"TOKENIZERS_PARALLELISM\"] = \"true\""
]
},
{
@@ -194,7 +198,7 @@
"import json\n",
"from langchain_core.messages import HumanMessage, SystemMessage\n",
"\n",
"# Prompt \n",
"# Prompt\n",
"router_instructions = \"\"\"You are an expert at routing a user question to a vectorstore or web search.\n",
"\n",
"The vectorstore contains documents related to agents, prompt engineering, and adversarial attacks.\n",
@@ -204,10 +208,27 @@
"Return JSON with single key, datasource, that is 'websearch' or 'vectorstore' depending on the question.\"\"\"\n",
"\n",
"# Test router\n",
"test_web_search = llm_json_mode.invoke([SystemMessage(content=router_instructions)] + [HumanMessage(content=\"Who is favored to win the NFC Championship game in the 2024 season?\")])\n",
"test_web_search_2 = llm_json_mode.invoke([SystemMessage(content=router_instructions)] + [HumanMessage(content=\"What are the models released today for llama3.2?\")])\n",
"test_vector_store = llm_json_mode.invoke([SystemMessage(content=router_instructions)] + [HumanMessage(content=\"What are the types of agent memory?\")])\n",
"print(json.loads(test_web_search.content), json.loads(test_web_search_2.content), json.loads(test_vector_store.content))"
"test_web_search = llm_json_mode.invoke(\n",
" [SystemMessage(content=router_instructions)]\n",
" + [\n",
" HumanMessage(\n",
" content=\"Who is favored to win the NFC Championship game in the 2024 season?\"\n",
" )\n",
" ]\n",
")\n",
"test_web_search_2 = llm_json_mode.invoke(\n",
" [SystemMessage(content=router_instructions)]\n",
" + [HumanMessage(content=\"What are the models released today for llama3.2?\")]\n",
")\n",
"test_vector_store = llm_json_mode.invoke(\n",
" [SystemMessage(content=router_instructions)]\n",
" + [HumanMessage(content=\"What are the types of agent memory?\")]\n",
")\n",
"print(\n",
" json.loads(test_web_search.content),\n",
" json.loads(test_web_search_2.content),\n",
" json.loads(test_vector_store.content),\n",
")"
]
},
{
@@ -228,9 +249,9 @@
}
],
"source": [
"### Retrieval Grader \n",
"### Retrieval Grader\n",
"\n",
"# Doc grader instructions \n",
"# Doc grader instructions\n",
"doc_grader_instructions = \"\"\"You are a grader assessing relevance of a retrieved document to a user question.\n",
"\n",
"If the document contains keyword(s) or semantic meaning related to the question, grade it as relevant.\"\"\"\n",
@@ -246,8 +267,13 @@
"question = \"What is Chain of thought prompting?\"\n",
"docs = retriever.invoke(question)\n",
"doc_txt = docs[1].page_content\n",
"doc_grader_prompt_formatted = doc_grader_prompt.format(document=doc_txt, question=question)\n",
"result = llm_json_mode.invoke([SystemMessage(content=doc_grader_instructions)] + [HumanMessage(content=doc_grader_prompt_formatted)])\n",
"doc_grader_prompt_formatted = doc_grader_prompt.format(\n",
" document=doc_txt, question=question\n",
")\n",
"result = llm_json_mode.invoke(\n",
" [SystemMessage(content=doc_grader_instructions)]\n",
" + [HumanMessage(content=doc_grader_prompt_formatted)]\n",
")\n",
"json.loads(result.content)"
]
},
@@ -287,10 +313,12 @@
"\n",
"Answer:\"\"\"\n",
"\n",
"\n",
"# Post-processing\n",
"def format_docs(docs):\n",
" return \"\\n\\n\".join(doc.page_content for doc in docs)\n",
"\n",
"\n",
"# Test\n",
"docs = retriever.invoke(question)\n",
"docs_txt = format_docs(docs)\n",
@@ -318,9 +346,9 @@
}
],
"source": [
"### Hallucination Grader \n",
"### Hallucination Grader\n",
"\n",
"# Hallucination grader instructions \n",
"# Hallucination grader instructions\n",
"hallucination_grader_instructions = \"\"\"\n",
"\n",
"You are a teacher grading a quiz. \n",
@@ -348,9 +376,14 @@
"\n",
"Return JSON with two two keys, binary_score is 'yes' or 'no' score to indicate whether the STUDENT ANSWER is grounded in the FACTS. And a key, explanation, that contains an explanation of the score.\"\"\"\n",
"\n",
"# Test using documents and generation from above \n",
"hallucination_grader_prompt_formatted = hallucination_grader_prompt.format(documents=docs_txt, generation=generation.content)\n",
"result = llm_json_mode.invoke([SystemMessage(content=hallucination_grader_instructions)] + [HumanMessage(content=hallucination_grader_prompt_formatted)])\n",
"# Test using documents and generation from above\n",
"hallucination_grader_prompt_formatted = hallucination_grader_prompt.format(\n",
" documents=docs_txt, generation=generation.content\n",
")\n",
"result = llm_json_mode.invoke(\n",
" [SystemMessage(content=hallucination_grader_instructions)]\n",
" + [HumanMessage(content=hallucination_grader_prompt_formatted)]\n",
")\n",
"json.loads(result.content)"
]
},
@@ -373,9 +406,9 @@
}
],
"source": [
"### Answer Grader \n",
"### Answer Grader\n",
"\n",
"# Answer grader instructions \n",
"# Answer grader instructions\n",
"answer_grader_instructions = \"\"\"You are a teacher grading a quiz. \n",
"\n",
"You will be given a QUESTION and a STUDENT ANSWER. \n",
@@ -401,13 +434,18 @@
"\n",
"Return JSON with two two keys, binary_score is 'yes' or 'no' score to indicate whether the STUDENT ANSWER meets the criteria. And a key, explanation, that contains an explanation of the score.\"\"\"\n",
"\n",
"# Test \n",
"# Test\n",
"question = \"What are the vision models released today as part of Llama 3.2?\"\n",
"answer = \"The Llama 3.2 models released today include two vision models: Llama 3.2 11B Vision Instruct and Llama 3.2 90B Vision Instruct, which are available on Azure AI Model Catalog via managed compute. These models are part of Meta's first foray into multimodal AI and rival closed models like Anthropic's Claude 3 Haiku and OpenAI's GPT-4o mini in visual reasoning. They replace the older text-only Llama 3.1 models.\"\n",
"\n",
"# Test using question and generation from above \n",
"answer_grader_prompt_formatted = answer_grader_prompt.format(question=question, generation=answer)\n",
"result = llm_json_mode.invoke([SystemMessage(content=answer_grader_instructions)] + [HumanMessage(content=answer_grader_prompt_formatted)])\n",
"# Test using question and generation from above\n",
"answer_grader_prompt_formatted = answer_grader_prompt.format(\n",
" question=question, generation=answer\n",
")\n",
"result = llm_json_mode.invoke(\n",
" [SystemMessage(content=answer_grader_instructions)]\n",
" + [HumanMessage(content=answer_grader_prompt_formatted)]\n",
")\n",
"json.loads(result.content)"
]
},
@@ -428,6 +466,7 @@
"source": [
"### Search\n",
"from langchain_community.tools.tavily_search import TavilySearchResults\n",
"\n",
"web_search_tool = TavilySearchResults(k=3)"
]
},
@@ -461,17 +500,19 @@
"from typing_extensions import TypedDict\n",
"from typing import List, Annotated\n",
"\n",
"\n",
"class GraphState(TypedDict):\n",
" \"\"\"\n",
" Graph state is a dictionary that contains information we want to propagate to, and modify in, each graph node.\n",
" \"\"\"\n",
" question : str # User question\n",
" generation : str # LLM generation\n",
" web_search : str # Binary decision to run web search\n",
" max_retries : int # Max number of retries for answer generation \n",
" answers : int # Number of answers generated\n",
" loop_step: Annotated[int, operator.add] \n",
" documents : List[str] # List of retrieved documents"
"\n",
" question: str # User question\n",
" generation: str # LLM generation\n",
" web_search: str # Binary decision to run web search\n",
" max_retries: int # Max number of retries for answer generation\n",
" answers: int # Number of answers generated\n",
" loop_step: Annotated[int, operator.add]\n",
" documents: List[str] # List of retrieved documents"
]
},
{
@@ -504,6 +545,7 @@
"from langchain.schema import Document\n",
"from langgraph.graph import END\n",
"\n",
"\n",
"### Nodes\n",
"def retrieve(state):\n",
" \"\"\"\n",
@@ -522,6 +564,7 @@
" documents = retriever.invoke(question)\n",
" return {\"documents\": documents}\n",
"\n",
"\n",
"def generate(state):\n",
" \"\"\"\n",
" Generate answer using RAG on retrieved documents\n",
@@ -536,12 +579,13 @@
" question = state[\"question\"]\n",
" documents = state[\"documents\"]\n",
" loop_step = state.get(\"loop_step\", 0)\n",
" \n",
"\n",
" # RAG generation\n",
" docs_txt = format_docs(documents)\n",
" rag_prompt_formatted = rag_prompt.format(context=docs_txt, question=question)\n",
" generation = llm.invoke([HumanMessage(content=rag_prompt_formatted)])\n",
" return {\"generation\": generation, \"loop_step\": loop_step+1}\n",
" return {\"generation\": generation, \"loop_step\": loop_step + 1}\n",
"\n",
"\n",
"def grade_documents(state):\n",
" \"\"\"\n",
@@ -558,14 +602,19 @@
" print(\"---CHECK DOCUMENT RELEVANCE TO QUESTION---\")\n",
" question = state[\"question\"]\n",
" documents = state[\"documents\"]\n",
" \n",
"\n",
" # Score each doc\n",
" filtered_docs = []\n",
" web_search = \"No\" \n",
" web_search = \"No\"\n",
" for d in documents:\n",
" doc_grader_prompt_formatted = doc_grader_prompt.format(document=d.page_content, question=question)\n",
" result = llm_json_mode.invoke([SystemMessage(content=doc_grader_instructions)] + [HumanMessage(content=doc_grader_prompt_formatted)])\n",
" grade = json.loads(result.content)['binary_score']\n",
" doc_grader_prompt_formatted = doc_grader_prompt.format(\n",
" document=d.page_content, question=question\n",
" )\n",
" result = llm_json_mode.invoke(\n",
" [SystemMessage(content=doc_grader_instructions)]\n",
" + [HumanMessage(content=doc_grader_prompt_formatted)]\n",
" )\n",
" grade = json.loads(result.content)[\"binary_score\"]\n",
" # Document relevant\n",
" if grade.lower() == \"yes\":\n",
" print(\"---GRADE: DOCUMENT RELEVANT---\")\n",
@@ -578,7 +627,8 @@
" web_search = \"Yes\"\n",
" continue\n",
" return {\"documents\": filtered_docs, \"web_search\": web_search}\n",
" \n",
"\n",
"\n",
"def web_search(state):\n",
" \"\"\"\n",
" Web search based based on the question\n",
@@ -601,11 +651,13 @@
" documents.append(web_results)\n",
" return {\"documents\": documents}\n",
"\n",
"\n",
"### Edges\n",
"\n",
"\n",
"def route_question(state):\n",
" \"\"\"\n",
" Route question to web search or RAG \n",
" Route question to web search or RAG\n",
"\n",
" Args:\n",
" state (dict): The current graph state\n",
@@ -615,15 +667,19 @@
" \"\"\"\n",
"\n",
" print(\"---ROUTE QUESTION---\")\n",
" route_question = llm_json_mode.invoke([SystemMessage(content=router_instructions)] + [HumanMessage(content=state[\"question\"])])\n",
" source = json.loads(route_question.content)['datasource']\n",
" if source == 'websearch':\n",
" route_question = llm_json_mode.invoke(\n",
" [SystemMessage(content=router_instructions)]\n",
" + [HumanMessage(content=state[\"question\"])]\n",
" )\n",
" source = json.loads(route_question.content)[\"datasource\"]\n",
" if source == \"websearch\":\n",
" print(\"---ROUTE QUESTION TO WEB SEARCH---\")\n",
" return \"websearch\"\n",
" elif source == 'vectorstore':\n",
" elif source == \"vectorstore\":\n",
" print(\"---ROUTE QUESTION TO RAG---\")\n",
" return \"vectorstore\"\n",
"\n",
"\n",
"def decide_to_generate(state):\n",
" \"\"\"\n",
" Determines whether to generate an answer, or add web search\n",
@@ -643,13 +699,16 @@
" if web_search == \"Yes\":\n",
" # All documents have been filtered check_relevance\n",
" # We will re-generate a new query\n",
" print(\"---DECISION: NOT ALL DOCUMENTS ARE RELEVANT TO QUESTION, INCLUDE WEB SEARCH---\")\n",
" print(\n",
" \"---DECISION: NOT ALL DOCUMENTS ARE RELEVANT TO QUESTION, INCLUDE WEB SEARCH---\"\n",
" )\n",
" return \"websearch\"\n",
" else:\n",
" # We have relevant documents, so generate answer\n",
" print(\"---DECISION: GENERATE---\")\n",
" return \"generate\"\n",
"\n",
"\n",
"def grade_generation_v_documents_and_question(state):\n",
" \"\"\"\n",
" Determines whether the generation is grounded in the document and answers question\n",
@@ -665,21 +724,31 @@
" question = state[\"question\"]\n",
" documents = state[\"documents\"]\n",
" generation = state[\"generation\"]\n",
" max_retries = state.get(\"max_retries\", 3) # Default to 3 if not provided\n",
" max_retries = state.get(\"max_retries\", 3) # Default to 3 if not provided\n",
"\n",
" hallucination_grader_prompt_formatted = hallucination_grader_prompt.format(documents=format_docs(documents), generation=generation.content)\n",
" result = llm_json_mode.invoke([SystemMessage(content=hallucination_grader_instructions)] + [HumanMessage(content=hallucination_grader_prompt_formatted)])\n",
" grade = json.loads(result.content)['binary_score']\n",
" hallucination_grader_prompt_formatted = hallucination_grader_prompt.format(\n",
" documents=format_docs(documents), generation=generation.content\n",
" )\n",
" result = llm_json_mode.invoke(\n",
" [SystemMessage(content=hallucination_grader_instructions)]\n",
" + [HumanMessage(content=hallucination_grader_prompt_formatted)]\n",
" )\n",
" grade = json.loads(result.content)[\"binary_score\"]\n",
"\n",
" # Check hallucination\n",
" if grade == \"yes\":\n",
" print(\"---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---\")\n",
" # Check question-answering\n",
" print(\"---GRADE GENERATION vs QUESTION---\")\n",
" # Test using question and generation from above \n",
" answer_grader_prompt_formatted = answer_grader_prompt.format(question=question, generation=generation.content)\n",
" result = llm_json_mode.invoke([SystemMessage(content=answer_grader_instructions)] + [HumanMessage(content=answer_grader_prompt_formatted)])\n",
" grade = json.loads(result.content)['binary_score']\n",
" # Test using question and generation from above\n",
" answer_grader_prompt_formatted = answer_grader_prompt.format(\n",
" question=question, generation=generation.content\n",
" )\n",
" result = llm_json_mode.invoke(\n",
" [SystemMessage(content=answer_grader_instructions)]\n",
" + [HumanMessage(content=answer_grader_prompt_formatted)]\n",
" )\n",
" grade = json.loads(result.content)[\"binary_score\"]\n",
" if grade == \"yes\":\n",
" print(\"---DECISION: GENERATION ADDRESSES QUESTION---\")\n",
" return \"useful\"\n",
@@ -688,7 +757,7 @@
" return \"not useful\"\n",
" else:\n",
" print(\"---DECISION: MAX RETRIES REACHED---\")\n",
" return \"max retries\" \n",
" return \"max retries\"\n",
" elif state[\"loop_step\"] <= max_retries:\n",
" print(\"---DECISION: GENERATION IS NOT GROUNDED IN DOCUMENTS, RE-TRY---\")\n",
" return \"not supported\"\n",
@@ -729,10 +798,10 @@
"workflow = StateGraph(GraphState)\n",
"\n",
"# Define the nodes\n",
"workflow.add_node(\"websearch\", web_search) # web search\n",
"workflow.add_node(\"retrieve\", retrieve) # retrieve\n",
"workflow.add_node(\"grade_documents\", grade_documents) # grade documents\n",
"workflow.add_node(\"generate\", generate) # generate\n",
"workflow.add_node(\"websearch\", web_search) # web search\n",
"workflow.add_node(\"retrieve\", retrieve) # retrieve\n",
"workflow.add_node(\"grade_documents\", grade_documents) # grade documents\n",
"workflow.add_node(\"generate\", generate) # generate\n",
"\n",
"# Build graph\n",
"workflow.set_conditional_entry_point(\n",
@@ -798,7 +867,10 @@
"outputs": [],
"source": [
"# Test on current events\n",
"inputs = {\"question\": \"What are the models released today for llama3.2?\", \"max_retries\": 3}\n",
"inputs = {\n",
" \"question\": \"What are the models released today for llama3.2?\",\n",
" \"max_retries\": 3,\n",
"}\n",
"for event in graph.stream(inputs, stream_mode=\"values\"):\n",
" print(event)"
]
@@ -836,7 +908,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.6"
"version": "3.11.4"
}
},
"nbformat": 4,
@@ -9,7 +9,7 @@
"\n",
"[Retrieval Agents](https://python.langchain.com/docs/tutorials/qa_chat_history/#agents) are useful when we want to make decisions about whether to retrieve from an index.\n",
"\n",
"To implement a retrieval agent, we simple need to give an LLM access to a retriever tool.\n",
"To implement a retrieval agent, we simply need to give an LLM access to a retriever tool.\n",
"\n",
"We can incorporate this into [LangGraph](https://langchain-ai.github.io/langgraph/).\n",
"\n",
@@ -699,7 +699,9 @@
" \"\"\"\n",
" Find all tool calls in the messages returned\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",
"\n",
"\n",
+15 -13
View File
@@ -58,6 +58,7 @@
" return\n",
" os.environ[var] = getpass.getpass(var)\n",
"\n",
"\n",
"_set_if_undefined(\"TAVILY_API_KEY\")\n",
"_set_if_undefined(\"FIREWORKS_API_KEY\")"
]
@@ -108,8 +109,7 @@
" ]\n",
")\n",
"llm = ChatFireworks(\n",
" model=\"accounts/fireworks/models/mixtral-8x7b-instruct\",\n",
" max_tokens=32768\n",
" model=\"accounts/fireworks/models/mixtral-8x7b-instruct\", max_tokens=32768\n",
")\n",
"generate = prompt | llm"
]
@@ -331,15 +331,15 @@
"\n",
"\n",
"async def generation_node(state: State) -> State:\n",
" return {\"messages\": [await generate.ainvoke(state['messages'])]}\n",
" return {\"messages\": [await generate.ainvoke(state[\"messages\"])]}\n",
"\n",
"\n",
"async def reflection_node(state: State) -> State:\n",
" # Other messages we need to adjust\n",
" cls_map = {\"ai\": HumanMessage, \"human\": AIMessage}\n",
" # First message is the original user request. We hold it the same for all nodes\n",
" translated = [state['messages'][0]] + [\n",
" cls_map[msg.type](content=msg.content) for msg in state['messages'][1:]\n",
" translated = [state[\"messages\"][0]] + [\n",
" cls_map[msg.type](content=msg.content) for msg in state[\"messages\"][1:]\n",
" ]\n",
" res = await reflect.ainvoke(translated)\n",
" # We treat the output of this as human feedback for the generator\n",
@@ -359,7 +359,6 @@
" return \"reflect\"\n",
"\n",
"\n",
"\n",
"builder.add_conditional_edges(\"generate\", should_continue)\n",
"builder.add_edge(\"reflect\", \"generate\")\n",
"memory = MemorySaver()\n",
@@ -406,13 +405,16 @@
}
],
"source": [
"async for event in graph.astream({\n",
" \"messages\": [\n",
" HumanMessage(\n",
" content=\"Generate an essay on the topicality of The Little Prince and its message in modern life\"\n",
" )\n",
" ],\n",
"}, config):\n",
"async for event in graph.astream(\n",
" {\n",
" \"messages\": [\n",
" HumanMessage(\n",
" content=\"Generate an essay on the topicality of The Little Prince and its message in modern life\"\n",
" )\n",
" ],\n",
" },\n",
" config,\n",
"):\n",
" print(event)\n",
" print(\"---\")"
]
+27 -18
View File
@@ -66,6 +66,7 @@
" return\n",
" os.environ[var] = getpass.getpass(var)\n",
"\n",
"\n",
"_set_if_undefined(\"ANTHROPIC_API_KEY\")\n",
"_set_if_undefined(\"TAVILY_API_KEY\")"
]
@@ -192,7 +193,7 @@
" response = []\n",
" for attempt in range(3):\n",
" response = self.runnable.invoke(\n",
" {\"messages\": state['messages']}, {\"tags\": [f\"attempt:{attempt}\"]}\n",
" {\"messages\": state[\"messages\"]}, {\"tags\": [f\"attempt:{attempt}\"]}\n",
" )\n",
" try:\n",
" self.validator.invoke(response)\n",
@@ -259,7 +260,9 @@
"outputs": [],
"source": [
"example_question = \"Why is reflection useful in AI?\"\n",
"initial = first_responder.respond({\"messages\":[HumanMessage(content=example_question)]})"
"initial = first_responder.respond(\n",
" {\"messages\": [HumanMessage(content=example_question)]}\n",
")"
]
},
{
@@ -332,20 +335,26 @@
"import json\n",
"\n",
"revised = revisor.respond(\n",
" {\"messages\": [\n",
" HumanMessage(content=example_question),\n",
" initial['messages'],\n",
" ToolMessage(\n",
" tool_call_id=initial['messages'].tool_calls[0][\"id\"],\n",
" content=json.dumps(\n",
" tavily_tool.invoke(\n",
" {\"query\": initial['messages'].tool_calls[0][\"args\"][\"search_queries\"][0]}\n",
" )\n",
" {\n",
" \"messages\": [\n",
" HumanMessage(content=example_question),\n",
" initial[\"messages\"],\n",
" ToolMessage(\n",
" tool_call_id=initial[\"messages\"].tool_calls[0][\"id\"],\n",
" content=json.dumps(\n",
" tavily_tool.invoke(\n",
" {\n",
" \"query\": initial[\"messages\"].tool_calls[0][\"args\"][\n",
" \"search_queries\"\n",
" ][0]\n",
" }\n",
" )\n",
" ),\n",
" ),\n",
" ),\n",
" ]}\n",
" ]\n",
" }\n",
")\n",
"revised['messages']"
"revised[\"messages\"]"
]
},
{
@@ -437,16 +446,16 @@
" return i\n",
"\n",
"\n",
"def event_loop(state: list) -> Literal[\"execute_tools\", \"__end__\"]:\n",
"def event_loop(state: list):\n",
" # in our case, we'll just stop after N plans\n",
" num_iterations = _get_num_iterations(state['messages'])\n",
" num_iterations = _get_num_iterations(state[\"messages\"])\n",
" if num_iterations > MAX_ITERATIONS:\n",
" return END\n",
" return \"execute_tools\"\n",
"\n",
"\n",
"# revise -> execute_tools OR end\n",
"builder.add_conditional_edges(\"revise\", event_loop)\n",
"builder.add_conditional_edges(\"revise\", event_loop, [\"execute_tools\", END])\n",
"builder.add_edge(START, \"draft\")\n",
"graph = builder.compile()"
]
@@ -598,7 +607,7 @@
")\n",
"for i, step in enumerate(events):\n",
" print(f\"Step {i}\")\n",
" step['messages'][-1].pretty_print()"
" step[\"messages\"][-1].pretty_print()"
]
},
{
+3 -3
View File
@@ -317,7 +317,7 @@
" \"\"\"Worker node that executes the tools of a given plan.\"\"\"\n",
" _step = _get_current_task(state)\n",
" _, step_name, tool, tool_input = state[\"steps\"][_step - 1]\n",
" _results = (state[\"results\"] or {}) if \"results\" in state else {}\n",
" _results = (state[\"results\"] or {}) if \"results\" in state else {}\n",
" for k, v in _results.items():\n",
" tool_input = tool_input.replace(k, v)\n",
" if tool == \"Google\":\n",
@@ -363,7 +363,7 @@
"def solve(state: ReWOO):\n",
" plan = \"\"\n",
" for _plan, step_name, tool, tool_input in state[\"steps\"]:\n",
" _results = (state[\"results\"] or {}) if \"results\" in state else {}\n",
" _results = (state[\"results\"] or {}) if \"results\" in state else {}\n",
" for k, v in _results.items():\n",
" tool_input = tool_input.replace(k, v)\n",
" step_name = step_name.replace(k, v)\n",
@@ -464,7 +464,7 @@
],
"source": [
"# Print out the final result\n",
"print(s['solve']['result'])"
"print(s[\"solve\"][\"result\"])"
]
},
{
+3 -1
View File
@@ -847,7 +847,9 @@
"builder.add_edge(\"ask_question\", \"answer_question\")\n",
"\n",
"builder.add_edge(START, \"ask_question\")\n",
"interview_graph = builder.compile(checkpointer=False).with_config(run_name=\"Conduct Interviews\")"
"interview_graph = builder.compile(checkpointer=False).with_config(\n",
" run_name=\"Conduct Interviews\"\n",
")"
]
},
{
+1 -3
View File
@@ -172,9 +172,7 @@
"\n",
"\n",
"summary_llm_chain = (\n",
" summary_prompt\n",
" | ChatAnthropic(model=\"claude-3-haiku-20240307\")\n",
" | StrOutputParser()\n",
" summary_prompt | ChatAnthropic(model=\"claude-3-haiku-20240307\") | StrOutputParser()\n",
" # Customize the tracing name for easier organization\n",
").with_config(run_name=\"GenerateSummary\")\n",
"summary_chain = summary_llm_chain | parse_summary\n",
@@ -17,9 +17,7 @@ from langgraph.checkpoint.base import (
CheckpointTuple,
get_checkpoint_id,
)
from langgraph.checkpoint.postgres.base import (
BasePostgresSaver,
)
from langgraph.checkpoint.postgres.base import BasePostgresSaver
from langgraph.checkpoint.serde.base import SerializerProtocol
Conn = Union[Connection[DictRow], ConnectionPool[Connection[DictRow]]]
@@ -167,15 +165,17 @@ class PostgresSaver(BasePostgresSaver):
value["pending_sends"],
),
self._load_metadata(value["metadata"]),
{
"configurable": {
"thread_id": value["thread_id"],
"checkpoint_ns": value["checkpoint_ns"],
"checkpoint_id": value["parent_checkpoint_id"],
(
{
"configurable": {
"thread_id": value["thread_id"],
"checkpoint_ns": value["checkpoint_ns"],
"checkpoint_id": value["parent_checkpoint_id"],
}
}
}
if value["parent_checkpoint_id"]
else None,
if value["parent_checkpoint_id"]
else None
),
self._load_writes(value["pending_writes"]),
)
@@ -246,15 +246,17 @@ class PostgresSaver(BasePostgresSaver):
value["pending_sends"],
),
self._load_metadata(value["metadata"]),
{
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": checkpoint_ns,
"checkpoint_id": value["parent_checkpoint_id"],
(
{
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": checkpoint_ns,
"checkpoint_id": value["parent_checkpoint_id"],
}
}
}
if value["parent_checkpoint_id"]
else None,
if value["parent_checkpoint_id"]
else None
),
self._load_writes(value["pending_writes"]),
)
@@ -384,3 +386,6 @@ class PostgresSaver(BasePostgresSaver):
else:
with self.lock, conn.cursor(binary=True, row_factory=dict_row) as cur:
yield cur
__all__ = ["PostgresSaver", "Conn"]
@@ -0,0 +1,4 @@
from langgraph.store.postgres.aio import AsyncPostgresStore
from langgraph.store.postgres.base import PostgresStore
__all__ = ["AsyncPostgresStore", "PostgresStore"]
@@ -0,0 +1,215 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from typing import (
Any,
AsyncIterator,
Callable,
Iterable,
Optional,
Sequence,
Union,
cast,
)
import orjson
from psycopg import AsyncConnection, AsyncCursor
from psycopg.errors import UndefinedTable
from psycopg.rows import dict_row
from langgraph.store.base import GetOp, ListNamespacesOp, Op, PutOp, Result, SearchOp
from langgraph.store.postgres.base import (
BasePostgresStore,
Row,
_decode_ns_bytes,
_group_ops,
_row_to_item,
)
logger = logging.getLogger(__name__)
class AsyncPostgresStore(BasePostgresStore[AsyncConnection]):
def __init__(
self,
conn: AsyncConnection[Any],
*,
deserializer: Optional[
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
] = None,
) -> None:
super().__init__(deserializer=deserializer)
self.conn = conn
self.conn = conn
self.loop = asyncio.get_running_loop()
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
grouped_ops, num_ops = _group_ops(ops)
results: list[Result] = [None] * num_ops
async with self.conn.pipeline():
tasks = []
if GetOp in grouped_ops:
tasks.append(
self._batch_get_ops(
cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results
)
)
if PutOp in grouped_ops:
tasks.append(
self._batch_put_ops(
cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp])
)
)
if SearchOp in grouped_ops:
tasks.append(
self._batch_search_ops(
cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
results,
)
)
if ListNamespacesOp in grouped_ops:
tasks.append(
self._batch_list_namespaces_ops(
cast(
Sequence[tuple[int, ListNamespacesOp]],
grouped_ops[ListNamespacesOp],
),
results,
)
)
await asyncio.gather(*tasks)
return results
def batch(self, ops: Iterable[Op]) -> list[Result]:
return asyncio.run_coroutine_threadsafe(self.abatch(ops), self.loop).result()
async def _batch_get_ops(
self,
get_ops: Sequence[tuple[int, GetOp]],
results: list[Result],
) -> None:
cursors = []
for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops):
cur = self.conn.cursor(binary=True)
await cur.execute(query, params)
cursors.append((cur, namespace, items))
for cur, namespace, items in cursors:
rows = cast(list[Row], await cur.fetchall())
key_to_row = {row["key"]: row for row in rows}
for idx, key in items:
row = key_to_row.get(key)
if row:
results[idx] = _row_to_item(
namespace, row, loader=self._deserializer
)
else:
results[idx] = None
async def _batch_put_ops(
self,
put_ops: Sequence[tuple[int, PutOp]],
) -> None:
queries = self._get_batch_PUT_queries(put_ops)
for query, params in queries:
cur = self.conn.cursor(binary=True)
await cur.execute(query, params)
async def _batch_search_ops(
self,
search_ops: Sequence[tuple[int, SearchOp]],
results: list[Result],
) -> None:
queries = self._get_batch_search_queries(search_ops)
cursors: list[tuple[AsyncCursor[Any], int]] = []
for (query, params), (idx, _) in zip(queries, search_ops):
cur = self.conn.cursor(binary=True)
await cur.execute(query, params)
cursors.append((cur, idx))
for cur, idx in cursors:
rows = cast(list[Row], await cur.fetchall())
items = [
_row_to_item(
_decode_ns_bytes(row["prefix"]), row, loader=self._deserializer
)
for row in rows
]
results[idx] = items
async def _batch_list_namespaces_ops(
self,
list_ops: Sequence[tuple[int, ListNamespacesOp]],
results: list[Result],
) -> None:
queries = self._get_batch_list_namespaces_queries(list_ops)
cursors: list[tuple[AsyncCursor[Any], int]] = []
for (query, params), (idx, _) in zip(queries, list_ops):
cur = self.conn.cursor(binary=True)
await cur.execute(query, params)
cursors.append((cur, idx))
for cur, idx in cursors:
rows = cast(list[dict], await cur.fetchall())
namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows]
results[idx] = namespaces
@classmethod
@asynccontextmanager
async def from_conn_string(
cls,
conn_string: str,
) -> AsyncIterator["AsyncPostgresStore"]:
"""Create a new AsyncPostgresStore instance from a connection string.
Args:
conn_string (str): The Postgres connection info string.
Returns:
AsyncPostgresStore: A new AsyncPostgresStore instance.
"""
async with await AsyncConnection.connect(
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
) as conn:
yield cls(conn=conn)
async def setup(self) -> None:
"""Set up the store database asynchronously.
This method creates the necessary tables in the Postgres database if they don't
already exist and runs database migrations. It MUST be called directly by the user
the first time the store is used.
"""
async with self.conn.cursor() as cur:
try:
await cur.execute(
"SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1"
)
row = cast(dict, await cur.fetchone())
if row is None:
version = -1
else:
version = row["v"]
except UndefinedTable:
version = -1
# Create store_migrations table if it doesn't exist
await cur.execute(
"""
CREATE TABLE IF NOT EXISTS store_migrations (
v INTEGER PRIMARY KEY
)
"""
)
for v, migration in enumerate(
self.MIGRATIONS[version + 1 :], start=version + 1
):
await cur.execute(migration)
await cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,))
@@ -0,0 +1,441 @@
import asyncio
import json
import logging
from collections import defaultdict
from contextlib import contextmanager
from datetime import datetime
from typing import (
Any,
Callable,
Generic,
Iterable,
Iterator,
Optional,
Sequence,
TypeVar,
Union,
cast,
)
import orjson
from psycopg import BaseConnection, Connection, Cursor
from psycopg.errors import UndefinedTable
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from typing_extensions import TypedDict
from langgraph.store.base import (
BaseStore,
GetOp,
Item,
ListNamespacesOp,
Op,
PutOp,
Result,
SearchOp,
)
logger = logging.getLogger(__name__)
MIGRATIONS = [
"""
CREATE EXTENSION IF NOT EXISTS ltree;
""",
"""
CREATE TABLE IF NOT EXISTS store (
-- 'prefix' represents the doc's 'namespace'
prefix ltree NOT NULL,
key text NOT NULL,
value jsonb NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (prefix, key)
);
""",
"""
-- For faster listing of namespaces & lookups by namespace with prefix/suffix matching
CREATE INDEX IF NOT EXISTS store_prefix_idx ON store USING gist (prefix);
""",
]
C = TypeVar("C", bound=BaseConnection)
class BasePostgresStore(BaseStore, Generic[C]):
MIGRATIONS = MIGRATIONS
conn: C
__slots__ = ("_deserializer",)
def __init__(
self,
*,
deserializer: Optional[
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
] = None,
) -> None:
super().__init__()
self._deserializer = deserializer
def _get_batch_GET_ops_queries(
self,
get_ops: Sequence[tuple[int, GetOp]],
) -> list[tuple[str, tuple, tuple[str, ...], list]]:
namespace_groups = defaultdict(list)
for idx, op in get_ops:
namespace_groups[op.namespace].append((idx, op.key))
results = []
for namespace, items in namespace_groups.items():
_, keys = zip(*items)
keys_to_query = ",".join(["%s"] * len(keys))
query = f"""
SELECT key, value, created_at, updated_at
FROM store
WHERE prefix = %s AND key IN ({keys_to_query})
"""
params = (_namespace_to_ltree(namespace), *keys)
results.append((query, params, namespace, items))
return results
def _get_batch_PUT_queries(
self,
put_ops: Sequence[tuple[int, PutOp]],
) -> list[tuple[str, Sequence]]:
inserts: list[PutOp] = []
deletes: list[PutOp] = []
for _, op in put_ops:
if op.value is None:
deletes.append(op)
else:
inserts.append(op)
queries: list[tuple[str, Sequence]] = []
if deletes:
namespace_groups: dict[tuple[str, ...], list[str]] = defaultdict(list)
for op in deletes:
namespace_groups[op.namespace].append(op.key)
for namespace, keys in namespace_groups.items():
placeholders = ",".join(["%s"] * len(keys))
query = (
f"DELETE FROM store WHERE prefix = %s AND key IN ({placeholders})"
)
params = (_namespace_to_ltree(namespace), *keys)
queries.append((query, params))
if inserts:
values = []
insertion_params = []
for op in inserts:
values.append("(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")
insertion_params.extend(
[
_namespace_to_ltree(op.namespace),
op.key,
Jsonb(op.value),
]
)
values_str = ",".join(values)
query = f"""
INSERT INTO store (prefix, key, value, created_at, updated_at)
VALUES {values_str}
ON CONFLICT (prefix, key) DO UPDATE
SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
"""
queries.append((query, insertion_params))
return queries
def _get_batch_search_queries(
self,
search_ops: Sequence[tuple[int, SearchOp]],
) -> list[tuple[str, Sequence]]:
queries: list[tuple[str, Sequence]] = []
for _, op in search_ops:
query = """
SELECT prefix, key, value, created_at, updated_at, prefix
FROM store
WHERE prefix <@ %s
"""
params: list = [_namespace_to_ltree(op.namespace_prefix)]
if op.filter:
filter_conditions = []
for key, value in op.filter.items():
if isinstance(value, list):
filter_conditions.append("value->%s @> %s::jsonb")
params.extend([key, json.dumps(value)])
else:
filter_conditions.append("value->%s = %s::jsonb")
params.extend([key, json.dumps(value)])
query += " AND " + " AND ".join(filter_conditions)
query += " LIMIT %s OFFSET %s"
params.extend([op.limit, op.offset])
queries.append((query, params))
return queries
def _get_batch_list_namespaces_queries(
self,
list_ops: Sequence[tuple[int, ListNamespacesOp]],
) -> list[tuple[str, Sequence]]:
queries: list[tuple[str, Sequence]] = []
for _, op in list_ops:
query = "SELECT DISTINCT subltree(prefix, 0, LEAST(nlevel(prefix), %s)) AS truncated_prefix FROM store"
# https://www.postgresql.org/docs/current/ltree.html
# The length of a label path cannot exceed 65535 labels.
params: list[Any] = [op.max_depth if op.max_depth is not None else 65536]
conditions = []
if op.match_conditions:
for condition in op.match_conditions:
if condition.match_type == "prefix":
conditions.append("prefix ~ %s::lquery")
lquery_pattern = f"{_namespace_to_ltree(condition.path)}.*"
params.append(lquery_pattern)
elif condition.match_type == "suffix":
conditions.append("prefix ~ %s::lquery")
lquery_pattern = f"*.{_namespace_to_ltree(condition.path)}"
params.append(lquery_pattern)
else:
logger.warning(
f"Unknown match_type in list_namespaces: {condition.match_type}"
)
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY truncated_prefix LIMIT %s OFFSET %s"
params.extend([op.limit, op.offset])
queries.append((query, params))
return queries
class PostgresStore(BasePostgresStore[Connection]):
def __init__(
self,
conn: Connection[Any],
*,
deserializer: Optional[
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
] = None,
) -> None:
super().__init__(deserializer=deserializer)
self.conn = conn
def batch(self, ops: Iterable[Op]) -> list[Result]:
grouped_ops, num_ops = _group_ops(ops)
results: list[Result] = [None] * num_ops
with self.conn.pipeline():
if GetOp in grouped_ops:
self._batch_get_ops(
cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results
)
if PutOp in grouped_ops:
self._batch_put_ops(
cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp])
)
if SearchOp in grouped_ops:
self._batch_search_ops(
cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
results,
)
if ListNamespacesOp in grouped_ops:
self._batch_list_namespaces_ops(
cast(
Sequence[tuple[int, ListNamespacesOp]],
grouped_ops[ListNamespacesOp],
),
results,
)
return results
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
return await asyncio.get_running_loop().run_in_executor(None, self.batch, ops)
def _batch_get_ops(
self,
get_ops: Sequence[tuple[int, GetOp]],
results: list[Result],
) -> None:
cursors = []
for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops):
cur = self.conn.cursor(binary=True)
cur.execute(query, params)
cursors.append((cur, namespace, items))
for cur, namespace, items in cursors:
rows = cast(list[Row], cur.fetchall())
key_to_row = {row["key"]: row for row in rows}
for idx, key in items:
row = key_to_row.get(key)
if row:
results[idx] = _row_to_item(
namespace, row, loader=self._deserializer
)
else:
results[idx] = None
def _batch_put_ops(
self,
put_ops: Sequence[tuple[int, PutOp]],
) -> None:
queries = self._get_batch_PUT_queries(put_ops)
for query, params in queries:
cur = self.conn.cursor(binary=True)
cur.execute(query, params)
def _batch_search_ops(
self,
search_ops: Sequence[tuple[int, SearchOp]],
results: list[Result],
) -> None:
queries = self._get_batch_search_queries(search_ops)
cursors: list[tuple[Cursor[Any], int]] = []
for (query, params), (idx, _) in zip(queries, search_ops):
cur = self.conn.cursor(binary=True)
cur.execute(query, params)
cursors.append((cur, idx))
for cur, idx in cursors:
rows = cast(list[Row], cur.fetchall())
items = [
_row_to_item(
_decode_ns_bytes(row["prefix"]), row, loader=self._deserializer
)
for row in rows
]
results[idx] = items
def _batch_list_namespaces_ops(
self,
list_ops: Sequence[tuple[int, ListNamespacesOp]],
results: list[Result],
) -> None:
queries = self._get_batch_list_namespaces_queries(list_ops)
cursors: list[tuple[Cursor[Any], int]] = []
for (query, params), (idx, _) in zip(queries, list_ops):
cur = self.conn.cursor(binary=True)
cur.execute(query, params)
cursors.append((cur, idx))
for cur, idx in cursors:
rows = cast(list[dict], cur.fetchall())
namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows]
results[idx] = namespaces
@classmethod
@contextmanager
def from_conn_string(
cls,
conn_string: str,
) -> Iterator["PostgresStore"]:
"""Create a new BasePostgresStore instance from a connection string.
Args:
conn_string (str): The Postgres connection info string.
Returns:
BasePostgresStore: A new BasePostgresStore instance.
"""
with Connection.connect(
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
) as conn:
yield cls(conn=conn)
def setup(self) -> None:
"""Set up the store database.
This method creates the necessary tables in the Postgres database if they don't
already exist and runs database migrations. It MUST be called directly by the user
the first time the store is used.
"""
with self.conn.cursor(binary=True) as cur:
try:
cur.execute("SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1")
row = cast(dict, cur.fetchone())
if row is None:
version = -1
else:
version = row["v"]
except UndefinedTable:
self.conn.rollback()
version = -1
# Create store_migrations table if it doesn't exist
cur.execute(
"""
CREATE TABLE IF NOT EXISTS store_migrations (
v INTEGER PRIMARY KEY
)
"""
)
for v, migration in enumerate(
self.MIGRATIONS[version + 1 :], start=version + 1
):
cur.execute(migration)
cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,))
class Row(TypedDict):
key: str
value: Any
prefix: bytes
created_at: datetime
updated_at: datetime
def _namespace_to_ltree(namespace: tuple[str, ...]) -> str:
"""Convert namespace tuple to ltree-compatible string."""
return ".".join(namespace)
def _row_to_item(
namespace: tuple[str, ...],
row: Row,
*,
loader: Optional[Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]] = None,
) -> Item:
"""Convert a row from the database into an Item."""
loader = loader or _json_loads
val = row["value"]
return Item(
value=val if isinstance(val, dict) else loader(val),
key=row["key"],
namespace=namespace,
created_at=row["created_at"],
updated_at=row["updated_at"],
)
def _group_ops(ops: Iterable[Op]) -> tuple[dict[type, list[tuple[int, Op]]], int]:
grouped_ops: dict[type, list[tuple[int, Op]]] = defaultdict(list)
tot = 0
for idx, op in enumerate(ops):
grouped_ops[type(op)].append((idx, op))
tot += 1
return grouped_ops, tot
def _json_loads(content: Union[bytes, orjson.Fragment]) -> Any:
if isinstance(content, orjson.Fragment):
if hasattr(content, "buf"):
content = content.buf
else:
if isinstance(content.contents, bytes):
content = content.contents
else:
content = content.contents.encode()
return orjson.loads(cast(bytes, content))
def _decode_ns_bytes(namespace: Union[str, bytes]) -> tuple[str, ...]:
if isinstance(namespace, bytes):
namespace = namespace.decode()[1:]
return tuple(namespace.split("."))
+2 -2
View File
@@ -324,7 +324,7 @@ typing-extensions = ">=4.7"
[[package]]
name = "langgraph-checkpoint"
version = "1.0.11"
version = "2.0.0"
description = "Library with base interfaces for LangGraph checkpoint savers."
optional = false
python-versions = "^3.9.0,<4.0"
@@ -1105,4 +1105,4 @@ watchmedo = ["PyYAML (>=3.10)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.9.0,<4.0"
content-hash = "4b174f142964238b985de1a2209680bc83e9a310e6797be14dc26f61fe1352e2"
content-hash = "8f763cd1727287f8c8b5ad2b4d8df00fb446e68d0cd4e88c278e4007969b83fd"
+2 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-checkpoint-postgres"
version = "1.0.9"
version = "2.0.0"
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
authors = []
license = "MIT"
@@ -10,7 +10,7 @@ packages = [{ include = "langgraph" }]
[tool.poetry.dependencies]
python = "^3.9.0,<4.0"
langgraph-checkpoint = "^1.0.11"
langgraph-checkpoint = "^2.0.0"
orjson = ">=3.10.1"
psycopg = "^3.0.0"
psycopg-pool = "^3.0.0"
@@ -23,5 +23,7 @@ async def clear_test_db(conn: AsyncConnection[DictRow]) -> None:
await conn.execute("DELETE FROM checkpoints")
await conn.execute("DELETE FROM checkpoint_blobs")
await conn.execute("DELETE FROM checkpoint_writes")
await conn.execute("DELETE FROM checkpoint_migrations")
await conn.execute("DELETE FROM store_migrations")
except UndefinedTable:
pass
+1 -1
View File
@@ -107,7 +107,7 @@ class TestAsyncPostgresSaver:
config = await saver.aput(
self.config_1, self.chkpnt_1, {"my_key": "\x00abc"}, {}
)
assert (await saver.aget_tuple(config)).metadata["my_key"] == "abc"
assert (await saver.aget_tuple(config)).metadata["my_key"] == "abc" # type: ignore
assert [c async for c in saver.alist(None, filter={"my_key": "abc"})][
0
].metadata["my_key"] == "abc"
@@ -0,0 +1,530 @@
# type: ignore
import uuid
from datetime import datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from conftest import DEFAULT_URI # type: ignore
from langgraph.store.base import GetOp, Item, ListNamespacesOp, PutOp, SearchOp
from langgraph.store.postgres import AsyncPostgresStore
class MockAsyncCursor:
def __init__(self, fetch_result: Any) -> None:
self.fetch_result = fetch_result
self.execute = AsyncMock()
self.fetchall = AsyncMock(return_value=self.fetch_result)
class MockAsyncConnection:
def __init__(self) -> None:
self.cursor = MagicMock()
self.pipeline = MagicMock(
return_value=AsyncMock(__aenter__=AsyncMock(), __aexit__=AsyncMock())
)
@pytest.fixture
def mock_connection() -> MockAsyncConnection:
return MockAsyncConnection()
@pytest.fixture
async def store(mock_connection: MockAsyncConnection) -> AsyncPostgresStore:
return AsyncPostgresStore(mock_connection)
async def test_abatch_order(store: AsyncPostgresStore) -> None:
mock_connection = store.conn
mock_get_cursor = MockAsyncCursor(
[
{
"key": "key1",
"value": '{"data": "value1"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.foo",
},
{
"key": "key2",
"value": '{"data": "value2"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.bar",
},
]
)
mock_search_cursor = MockAsyncCursor(
[
{
"key": "key1",
"value": '{"data": "value1"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.foo",
},
]
)
mock_list_namespaces_cursor = MockAsyncCursor(
[
{"truncated_prefix": b"\x01test"},
]
)
failures = []
def cursor_side_effect(binary: bool = False) -> Any:
cursor = MagicMock()
async def execute_side_effect(query: str, *params: Any) -> None:
# My super sophisticated database.
if "WHERE prefix <@" in query:
cursor.fetchall = mock_search_cursor.fetchall
elif "SELECT DISTINCT subltree" in query:
cursor.fetchall = mock_list_namespaces_cursor.fetchall
elif "WHERE prefix = %s AND key" in query:
cursor.fetchall = mock_get_cursor.fetchall
elif "INSERT INTO " in query:
pass
else:
e = ValueError(f"Unmatched query: {query}")
failures.append(e)
raise e
cursor.execute = AsyncMock(side_effect=execute_side_effect)
return cursor
mock_connection.cursor.side_effect = cursor_side_effect # type: ignore
ops = [
GetOp(namespace=("test",), key="key1"),
PutOp(namespace=("test",), key="key2", value={"data": "value2"}),
SearchOp(
namespace_prefix=("test",), filter={"data": "value1"}, limit=10, offset=0
),
ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0),
GetOp(namespace=("test",), key="key3"),
]
results = await store.abatch(ops)
assert not failures
assert len(results) == 5
assert isinstance(results[0], Item)
assert isinstance(results[0].value, dict)
assert results[0].value == {"data": "value1"}
assert results[0].key == "key1"
assert results[1] is None
assert isinstance(results[2], list)
assert len(results[2]) == 1
assert isinstance(results[3], list)
assert results[3] == [("test",)]
assert results[4] is None
ops_reordered = [
SearchOp(namespace_prefix=("test",), filter=None, limit=5, offset=0),
GetOp(namespace=("test",), key="key2"),
ListNamespacesOp(match_conditions=None, max_depth=None, limit=5, offset=0),
PutOp(namespace=("test",), key="key3", value={"data": "value3"}),
GetOp(namespace=("test",), key="key1"),
]
results_reordered = await store.abatch(ops_reordered)
assert not failures
assert len(results_reordered) == 5
assert isinstance(results_reordered[0], list)
assert len(results_reordered[0]) == 1
assert isinstance(results_reordered[1], Item)
assert results_reordered[1].value == {"data": "value2"}
assert results_reordered[1].key == "key2"
assert isinstance(results_reordered[2], list)
assert results_reordered[2] == [("test",)]
assert results_reordered[3] is None
assert isinstance(results_reordered[4], Item)
assert results_reordered[4].value == {"data": "value1"}
assert results_reordered[4].key == "key1"
async def test_batch_get_ops(store: AsyncPostgresStore) -> None:
mock_connection = store.conn
mock_cursor = MockAsyncCursor(
[
{
"key": "key1",
"value": '{"data": "value1"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.foo",
},
{
"key": "key2",
"value": '{"data": "value2"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.bar",
},
]
)
mock_connection.cursor.return_value = mock_cursor
ops = [
GetOp(namespace=("test",), key="key1"),
GetOp(namespace=("test",), key="key2"),
GetOp(namespace=("test",), key="key3"),
]
results = await store.abatch(ops)
assert len(results) == 3
assert results[0] is not None
assert results[1] is not None
assert results[2] is None
assert results[0].key == "key1"
assert results[1].key == "key2"
async def test_batch_put_ops(store: AsyncPostgresStore) -> None:
mock_connection = store.conn
mock_cursor = MockAsyncCursor([])
mock_connection.cursor.return_value = mock_cursor
ops = [
PutOp(namespace=("test",), key="key1", value={"data": "value1"}),
PutOp(namespace=("test",), key="key2", value={"data": "value2"}),
PutOp(namespace=("test",), key="key3", value=None),
]
results = await store.abatch(ops)
assert len(results) == 3
assert all(result is None for result in results)
assert mock_cursor.execute.call_count == 2
async def test_batch_search_ops(store: AsyncPostgresStore) -> None:
mock_connection = store.conn
mock_cursor = MockAsyncCursor(
[
{
"key": "key1",
"value": '{"data": "value1"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.foo",
},
{
"key": "key2",
"value": '{"data": "value2"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.bar",
},
]
)
mock_connection.cursor.return_value = mock_cursor
ops = [
SearchOp(
namespace_prefix=("test",), filter={"data": "value1"}, limit=10, offset=0
),
SearchOp(namespace_prefix=("test",), filter=None, limit=5, offset=0),
]
results = await store.abatch(ops)
assert len(results) == 2
assert len(results[0]) == 2
assert len(results[1]) == 2
async def test_batch_list_namespaces_ops(store: AsyncPostgresStore) -> None:
mock_connection = store.conn
mock_cursor = MockAsyncCursor(
[
{"truncated_prefix": b"\x01test.namespace1"},
{"truncated_prefix": b"\x01test.namespace2"},
]
)
mock_connection.cursor.return_value = mock_cursor
ops = [ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0)]
results = await store.abatch(ops)
assert len(results) == 1
assert results[0] == [("test", "namespace1"), ("test", "namespace2")]
# The following use the actual DB connection
class TestAsyncPostgresStore:
@pytest.fixture(autouse=True)
async def setup(self) -> None:
async with AsyncPostgresStore.from_conn_string(DEFAULT_URI) as store:
await store.setup()
async def test_basic_store_ops(self) -> None:
async with AsyncPostgresStore.from_conn_string(DEFAULT_URI) as store:
namespace = ("test", "documents")
item_id = "doc1"
item_value = {"title": "Test Document", "content": "Hello, World!"}
await store.aput(namespace, item_id, item_value)
item = await store.aget(namespace, item_id)
assert item
assert item.namespace == namespace
assert item.key == item_id
assert item.value == item_value
updated_value = {
"title": "Updated Test Document",
"content": "Hello, LangGraph!",
}
await store.aput(namespace, item_id, updated_value)
updated_item = await store.aget(namespace, item_id)
assert updated_item.value == updated_value
assert updated_item.updated_at > item.updated_at
different_namespace = ("test", "other_documents")
item_in_different_namespace = await store.aget(different_namespace, item_id)
assert item_in_different_namespace is None
new_item_id = "doc2"
new_item_value = {"title": "Another Document", "content": "Greetings!"}
await store.aput(namespace, new_item_id, new_item_value)
search_results = await store.asearch(["test"], limit=10)
items = search_results
assert len(items) == 2
assert any(item.key == item_id for item in items)
assert any(item.key == new_item_id for item in items)
namespaces = await store.alist_namespaces(prefix=["test"])
assert ("test", "documents") in namespaces
await store.adelete(namespace, item_id)
await store.adelete(namespace, new_item_id)
deleted_item = await store.aget(namespace, item_id)
assert deleted_item is None
deleted_item = await store.aget(namespace, new_item_id)
assert deleted_item is None
empty_search_results = await store.asearch(["test"], limit=10)
assert len(empty_search_results) == 0
async def test_list_namespaces(self) -> None:
async with AsyncPostgresStore.from_conn_string(DEFAULT_URI) as store:
test_pref = str(uuid.uuid4())
test_namespaces = [
(test_pref, "test", "documents", "public", test_pref),
(test_pref, "test", "documents", "private", test_pref),
(test_pref, "test", "images", "public", test_pref),
(test_pref, "test", "images", "private", test_pref),
(test_pref, "prod", "documents", "public", test_pref),
(
test_pref,
"prod",
"documents",
"some",
"nesting",
"public",
test_pref,
),
(test_pref, "prod", "documents", "private", test_pref),
]
for namespace in test_namespaces:
await store.aput(namespace, "dummy", {"content": "dummy"})
prefix_result = await store.alist_namespaces(prefix=[test_pref, "test"])
assert len(prefix_result) == 4
assert all([ns[1] == "test" for ns in prefix_result])
specific_prefix_result = await store.alist_namespaces(
prefix=[test_pref, "test", "documents"]
)
assert len(specific_prefix_result) == 2
assert all(
[ns[1:3] == ("test", "documents") for ns in specific_prefix_result]
)
suffix_result = await store.alist_namespaces(suffix=["public", test_pref])
assert len(suffix_result) == 4
assert all(ns[-2] == "public" for ns in suffix_result)
prefix_suffix_result = await store.alist_namespaces(
prefix=[test_pref, "test"], suffix=["public", test_pref]
)
assert len(prefix_suffix_result) == 2
assert all(
ns[1] == "test" and ns[-2] == "public" for ns in prefix_suffix_result
)
wildcard_prefix_result = await store.alist_namespaces(
prefix=[test_pref, "*", "documents"]
)
assert len(wildcard_prefix_result) == 5
assert all(ns[2] == "documents" for ns in wildcard_prefix_result)
wildcard_suffix_result = await store.alist_namespaces(
suffix=["*", "public", test_pref]
)
assert len(wildcard_suffix_result) == 4
assert all(ns[-2] == "public" for ns in wildcard_suffix_result)
wildcard_single = await store.alist_namespaces(
suffix=["some", "*", "public", test_pref]
)
assert len(wildcard_single) == 1
assert wildcard_single[0] == (
test_pref,
"prod",
"documents",
"some",
"nesting",
"public",
test_pref,
)
max_depth_result = await store.alist_namespaces(max_depth=3)
assert all([len(ns) <= 3 for ns in max_depth_result])
max_depth_result = await store.alist_namespaces(
max_depth=4, prefix=[test_pref, "*", "documents"]
)
assert (
len(set(tuple(res) for res in max_depth_result))
== len(max_depth_result)
== 5
)
limit_result = await store.alist_namespaces(prefix=[test_pref], limit=3)
assert len(limit_result) == 3
offset_result = await store.alist_namespaces(prefix=[test_pref], offset=3)
assert len(offset_result) == len(test_namespaces) - 3
empty_prefix_result = await store.alist_namespaces(prefix=[test_pref])
assert len(empty_prefix_result) == len(test_namespaces)
assert set(tuple(ns) for ns in empty_prefix_result) == set(
tuple(ns) for ns in test_namespaces
)
for namespace in test_namespaces:
await store.adelete(namespace, "dummy")
async def test_search(self):
async with AsyncPostgresStore.from_conn_string(DEFAULT_URI) as store:
test_namespaces = [
("test_search", "documents", "user1"),
("test_search", "documents", "user2"),
("test_search", "reports", "department1"),
("test_search", "reports", "department2"),
]
test_items = [
{"title": "Doc 1", "author": "John Doe", "tags": ["important"]},
{"title": "Doc 2", "author": "Jane Smith", "tags": ["draft"]},
{"title": "Report A", "author": "John Doe", "tags": ["final"]},
{"title": "Report B", "author": "Alice Johnson", "tags": ["draft"]},
]
empty = await store.asearch(
(
"scoped",
"assistant_id",
"shared",
"6c5356f6-63ab-4158-868d-cd9fd14c736e",
),
limit=10,
offset=0,
)
assert len(empty) == 0
for namespace, item in zip(test_namespaces, test_items):
await store.aput(namespace, f"item_{namespace[-1]}", item)
docs_result = await store.asearch(["test_search", "documents"])
assert len(docs_result) == 2
assert all([item.namespace[1] == "documents" for item in docs_result]), [
item.namespace for item in docs_result
]
reports_result = await store.asearch(["test_search", "reports"])
assert len(reports_result) == 2
assert all(item.namespace[1] == "reports" for item in reports_result)
limited_result = await store.asearch(["test_search"], limit=2)
assert len(limited_result) == 2
offset_result = await store.asearch(["test_search"])
assert len(offset_result) == 4
offset_result = await store.asearch(["test_search"], offset=2)
assert len(offset_result) == 2
assert all(item not in limited_result for item in offset_result)
john_doe_result = await store.asearch(
["test_search"], filter={"author": "John Doe"}
)
assert len(john_doe_result) == 2
assert all(item.value["author"] == "John Doe" for item in john_doe_result)
draft_result = await store.asearch(
["test_search"], filter={"tags": ["draft"]}
)
assert len(draft_result) == 2
assert all("draft" in item.value["tags"] for item in draft_result)
page1 = await store.asearch(["test_search"], limit=2, offset=0)
page2 = await store.asearch(["test_search"], limit=2, offset=2)
all_items = page1 + page2
assert len(all_items) == 4
assert len(set(item.key for item in all_items)) == 4
empty = await store.asearch(
(
"scoped",
"assistant_id",
"shared",
"again",
"maybe",
"some-long",
"6be5cb0e-2eb4-42e6-bb6b-fba3c269db25",
),
limit=10,
offset=0,
)
assert len(empty) == 0
# Test with a namespace beginning with a number (like a UUID)
uuid_namespace = (str(uuid.uuid4()), "documents")
uuid_item_id = "uuid_doc"
uuid_item_value = {
"title": "UUID Document",
"content": "This document has a UUID namespace.",
}
# Insert the item with the UUID namespace
await store.aput(uuid_namespace, uuid_item_id, uuid_item_value)
# Retrieve the item to verify it was stored correctly
retrieved_item = await store.aget(uuid_namespace, uuid_item_id)
assert retrieved_item is not None
assert retrieved_item.namespace == uuid_namespace
assert retrieved_item.key == uuid_item_id
assert retrieved_item.value == uuid_item_value
# Search for the item using the UUID namespace
search_result = await store.asearch([uuid_namespace[0]])
assert len(search_result) == 1
assert search_result[0].key == uuid_item_id
assert search_result[0].value == uuid_item_value
# Clean up: delete the item with the UUID namespace
await store.adelete(uuid_namespace, uuid_item_id)
# Verify the item was deleted
deleted_item = await store.aget(uuid_namespace, uuid_item_id)
assert deleted_item is None
for namespace in test_namespaces:
await store.adelete(namespace, f"item_{namespace[-1]}")
@@ -0,0 +1,467 @@
# type: ignore
import uuid
from datetime import datetime
from typing import Any
from unittest.mock import MagicMock
import pytest
from conftest import DEFAULT_URI # type: ignore
from langgraph.store.base import GetOp, Item, ListNamespacesOp, PutOp, SearchOp
from langgraph.store.postgres import PostgresStore
class MockCursor:
def __init__(self, fetch_result: Any) -> None:
self.fetch_result = fetch_result
self.execute = MagicMock()
self.fetchall = MagicMock(return_value=self.fetch_result)
class MockConnection:
def __init__(self) -> None:
self.cursor = MagicMock()
self.pipeline = MagicMock()
@pytest.fixture
def mock_connection() -> MockConnection:
return MockConnection()
@pytest.fixture
def store(mock_connection: MockConnection) -> PostgresStore:
return PostgresStore(mock_connection)
def test_batch_order(store: PostgresStore) -> None:
mock_connection = store.conn
mock_get_cursor = MockCursor(
[
{
"key": "key1",
"value": '{"data": "value1"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.foo",
},
{
"key": "key2",
"value": '{"data": "value2"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.bar",
},
]
)
mock_search_cursor = MockCursor(
[
{
"key": "key1",
"value": '{"data": "value1"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.foo",
},
]
)
mock_list_namespaces_cursor = MockCursor(
[
{"truncated_prefix": b"\x01test"},
]
)
failures = []
def cursor_side_effect(binary: bool = False) -> Any:
cursor = MagicMock()
def execute_side_effect(query: str, *params: Any) -> None:
# My super sophisticated database.
if "WHERE prefix <@" in query:
cursor.fetchall = mock_search_cursor.fetchall
elif "SELECT DISTINCT subltree" in query:
cursor.fetchall = mock_list_namespaces_cursor.fetchall
elif "WHERE prefix = %s AND key" in query:
cursor.fetchall = mock_get_cursor.fetchall
elif "INSERT INTO " in query:
pass
else:
e = ValueError(f"Unmatched query: {query}")
failures.append(e)
raise e
cursor.execute = MagicMock(side_effect=execute_side_effect)
return cursor
mock_connection.cursor.side_effect = cursor_side_effect
ops = [
GetOp(namespace=("test",), key="key1"),
PutOp(namespace=("test",), key="key2", value={"data": "value2"}),
SearchOp(
namespace_prefix=("test",), filter={"data": "value1"}, limit=10, offset=0
),
ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0),
GetOp(namespace=("test",), key="key3"),
]
results = store.batch(ops)
assert not failures
assert len(results) == 5
assert isinstance(results[0], Item)
assert isinstance(results[0].value, dict)
assert results[0].value == {"data": "value1"}
assert results[0].key == "key1"
assert results[1] is None
assert isinstance(results[2], list)
assert len(results[2]) == 1
assert isinstance(results[3], list)
assert results[3] == [("test",)]
assert results[4] is None
ops_reordered = [
SearchOp(namespace_prefix=("test",), filter=None, limit=5, offset=0),
GetOp(namespace=("test",), key="key2"),
ListNamespacesOp(match_conditions=None, max_depth=None, limit=5, offset=0),
PutOp(namespace=("test",), key="key3", value={"data": "value3"}),
GetOp(namespace=("test",), key="key1"),
]
results_reordered = store.batch(ops_reordered)
assert not failures
assert len(results_reordered) == 5
assert isinstance(results_reordered[0], list)
assert len(results_reordered[0]) == 1
assert isinstance(results_reordered[1], Item)
assert results_reordered[1].value == {"data": "value2"}
assert results_reordered[1].key == "key2"
assert isinstance(results_reordered[2], list)
assert results_reordered[2] == [("test",)]
assert results_reordered[3] is None
assert isinstance(results_reordered[4], Item)
assert results_reordered[4].value == {"data": "value1"}
assert results_reordered[4].key == "key1"
def test_batch_get_ops(store: PostgresStore) -> None:
mock_connection = store.conn
mock_cursor = MockCursor(
[
{
"key": "key1",
"value": '{"data": "value1"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.foo",
},
{
"key": "key2",
"value": '{"data": "value2"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.bar",
},
]
)
mock_connection.cursor.return_value = mock_cursor
ops = [
GetOp(namespace=("test",), key="key1"),
GetOp(namespace=("test",), key="key2"),
GetOp(namespace=("test",), key="key3"),
]
results = store.batch(ops)
assert len(results) == 3
assert results[0] is not None
assert results[1] is not None
assert results[2] is None
assert results[0].key == "key1"
assert results[1].key == "key2"
def test_batch_put_ops(store: PostgresStore) -> None:
mock_connection = store.conn
mock_cursor = MockCursor([])
mock_connection.cursor.return_value = mock_cursor
ops = [
PutOp(namespace=("test",), key="key1", value={"data": "value1"}),
PutOp(namespace=("test",), key="key2", value={"data": "value2"}),
PutOp(namespace=("test",), key="key3", value=None),
]
results = store.batch(ops)
assert len(results) == 3
assert all(result is None for result in results)
assert mock_cursor.execute.call_count == 2
def test_batch_search_ops(store: PostgresStore) -> None:
mock_connection = store.conn
mock_cursor = MockCursor(
[
{
"key": "key1",
"value": '{"data": "value1"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.foo",
},
{
"key": "key2",
"value": '{"data": "value2"}',
"created_at": datetime.now(),
"updated_at": datetime.now(),
"prefix": "test.bar",
},
]
)
mock_connection.cursor.return_value = mock_cursor
ops = [
SearchOp(
namespace_prefix=("test",), filter={"data": "value1"}, limit=10, offset=0
),
SearchOp(namespace_prefix=("test",), filter=None, limit=5, offset=0),
]
results = store.batch(ops)
assert len(results) == 2
assert len(results[0]) == 2
assert len(results[1]) == 2
def test_batch_list_namespaces_ops(store: PostgresStore) -> None:
mock_connection = store.conn
mock_cursor = MockCursor(
[
{"truncated_prefix": b"\x01test.namespace1"},
{"truncated_prefix": b"\x01test.namespace2"},
]
)
mock_connection.cursor.return_value = mock_cursor
ops = [ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0)]
results = store.batch(ops)
assert len(results) == 1
assert results[0] == [("test", "namespace1"), ("test", "namespace2")]
class TestPostgresStore:
@pytest.fixture(autouse=True)
def setup(self) -> None:
with PostgresStore.from_conn_string(DEFAULT_URI) as store:
store.setup()
def test_basic_store_ops(self) -> None:
with PostgresStore.from_conn_string(DEFAULT_URI) as store:
namespace = ("test", "documents")
item_id = "doc1"
item_value = {"title": "Test Document", "content": "Hello, World!"}
store.put(namespace, item_id, item_value)
item = store.get(namespace, item_id)
assert item
assert item.namespace == namespace
assert item.key == item_id
assert item.value == item_value
updated_value = {
"title": "Updated Test Document",
"content": "Hello, LangGraph!",
}
store.put(namespace, item_id, updated_value)
updated_item = store.get(namespace, item_id)
assert updated_item.value == updated_value
assert updated_item.updated_at > item.updated_at
different_namespace = ("test", "other_documents")
item_in_different_namespace = store.get(different_namespace, item_id)
assert item_in_different_namespace is None
new_item_id = "doc2"
new_item_value = {"title": "Another Document", "content": "Greetings!"}
store.put(namespace, new_item_id, new_item_value)
search_results = store.search(["test"], limit=10)
items = search_results
assert len(items) == 2
assert any(item.key == item_id for item in items)
assert any(item.key == new_item_id for item in items)
namespaces = store.list_namespaces(prefix=["test"])
assert ("test", "documents") in namespaces
store.delete(namespace, item_id)
store.delete(namespace, new_item_id)
deleted_item = store.get(namespace, item_id)
assert deleted_item is None
deleted_item = store.get(namespace, new_item_id)
assert deleted_item is None
empty_search_results = store.search(["test"], limit=10)
assert len(empty_search_results) == 0
def test_list_namespaces(self) -> None:
with PostgresStore.from_conn_string(DEFAULT_URI) as store:
test_pref = str(uuid.uuid4())
test_namespaces = [
(test_pref, "test", "documents", "public", test_pref),
(test_pref, "test", "documents", "private", test_pref),
(test_pref, "test", "images", "public", test_pref),
(test_pref, "test", "images", "private", test_pref),
(test_pref, "prod", "documents", "public", test_pref),
(
test_pref,
"prod",
"documents",
"some",
"nesting",
"public",
test_pref,
),
(test_pref, "prod", "documents", "private", test_pref),
]
for namespace in test_namespaces:
store.put(namespace, "dummy", {"content": "dummy"})
prefix_result = store.list_namespaces(prefix=[test_pref, "test"])
assert len(prefix_result) == 4
assert all([ns[1] == "test" for ns in prefix_result])
specific_prefix_result = store.list_namespaces(
prefix=[test_pref, "test", "documents"]
)
assert len(specific_prefix_result) == 2
assert all(
[ns[1:3] == ("test", "documents") for ns in specific_prefix_result]
)
suffix_result = store.list_namespaces(suffix=["public", test_pref])
assert len(suffix_result) == 4
assert all(ns[-2] == "public" for ns in suffix_result)
prefix_suffix_result = store.list_namespaces(
prefix=[test_pref, "test"], suffix=["public", test_pref]
)
assert len(prefix_suffix_result) == 2
assert all(
ns[1] == "test" and ns[-2] == "public" for ns in prefix_suffix_result
)
wildcard_prefix_result = store.list_namespaces(
prefix=[test_pref, "*", "documents"]
)
assert len(wildcard_prefix_result) == 5
assert all(ns[2] == "documents" for ns in wildcard_prefix_result)
wildcard_suffix_result = store.list_namespaces(
suffix=["*", "public", test_pref]
)
assert len(wildcard_suffix_result) == 4
assert all(ns[-2] == "public" for ns in wildcard_suffix_result)
wildcard_single = store.list_namespaces(
suffix=["some", "*", "public", test_pref]
)
assert len(wildcard_single) == 1
assert wildcard_single[0] == (
test_pref,
"prod",
"documents",
"some",
"nesting",
"public",
test_pref,
)
max_depth_result = store.list_namespaces(max_depth=3)
assert all([len(ns) <= 3 for ns in max_depth_result])
max_depth_result = store.list_namespaces(
max_depth=4, prefix=[test_pref, "*", "documents"]
)
assert (
len(set(tuple(res) for res in max_depth_result))
== len(max_depth_result)
== 5
)
limit_result = store.list_namespaces(prefix=[test_pref], limit=3)
assert len(limit_result) == 3
offset_result = store.list_namespaces(prefix=[test_pref], offset=3)
assert len(offset_result) == len(test_namespaces) - 3
empty_prefix_result = store.list_namespaces(prefix=[test_pref])
assert len(empty_prefix_result) == len(test_namespaces)
assert set(tuple(ns) for ns in empty_prefix_result) == set(
tuple(ns) for ns in test_namespaces
)
for namespace in test_namespaces:
store.delete(namespace, "dummy")
def test_search(self):
with PostgresStore.from_conn_string(DEFAULT_URI) as store:
test_namespaces = [
("test_search", "documents", "user1"),
("test_search", "documents", "user2"),
("test_search", "reports", "department1"),
("test_search", "reports", "department2"),
]
test_items = [
{"title": "Doc 1", "author": "John Doe", "tags": ["important"]},
{"title": "Doc 2", "author": "Jane Smith", "tags": ["draft"]},
{"title": "Report A", "author": "John Doe", "tags": ["final"]},
{"title": "Report B", "author": "Alice Johnson", "tags": ["draft"]},
]
for namespace, item in zip(test_namespaces, test_items):
store.put(namespace, f"item_{namespace[-1]}", item)
docs_result = store.search(["test_search", "documents"])
assert len(docs_result) == 2
assert all(
[item.namespace[1] == "documents" for item in docs_result]
), docs_result
reports_result = store.search(["test_search", "reports"])
assert len(reports_result) == 2
assert all(item.namespace[1] == "reports" for item in reports_result)
limited_result = store.search(["test_search"], limit=2)
assert len(limited_result) == 2
offset_result = store.search(["test_search"])
assert len(offset_result) == 4
offset_result = store.search(["test_search"], offset=2)
assert len(offset_result) == 2
assert all(item not in limited_result for item in offset_result)
john_doe_result = store.search(
["test_search"], filter={"author": "John Doe"}
)
assert len(john_doe_result) == 2
assert all(item.value["author"] == "John Doe" for item in john_doe_result)
draft_result = store.search(["test_search"], filter={"tags": ["draft"]})
assert len(draft_result) == 2
assert all("draft" in item.value["tags"] for item in draft_result)
page1 = store.search(["test_search"], limit=2, offset=0)
page2 = store.search(["test_search"], limit=2, offset=2)
all_items = page1 + page2
assert len(all_items) == 4
assert len(set(item.key for item in all_items)) == 4
for namespace in test_namespaces:
store.delete(namespace, f"item_{namespace[-1]}")
+2 -2
View File
@@ -105,8 +105,8 @@ class TestPostgresSaver:
def test_null_chars(self) -> None:
with PostgresSaver.from_conn_string(DEFAULT_URI) as saver:
config = saver.put(self.config_1, self.chkpnt_1, {"my_key": "\x00abc"}, {})
assert saver.get_tuple(config).metadata["my_key"] == "abc"
assert saver.get_tuple(config).metadata["my_key"] == "abc" # type: ignore
assert (
list(saver.list(None, filter={"my_key": "abc"}))[0].metadata["my_key"]
list(saver.list(None, filter={"my_key": "abc"}))[0].metadata["my_key"] # type: ignore
== "abc"
)
+2 -2
View File
@@ -332,7 +332,7 @@ typing-extensions = ">=4.7"
[[package]]
name = "langgraph-checkpoint"
version = "1.0.11"
version = "2.0.0"
description = "Library with base interfaces for LangGraph checkpoint savers."
optional = false
python-versions = "^3.9.0,<4.0"
@@ -1001,4 +1001,4 @@ watchmedo = ["PyYAML (>=3.10)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.9.0"
content-hash = "b38677f65f382dfb391bc663c876a6ced7772c9a894ab4502e36c97fd663575a"
content-hash = "e0091cc2deab4de99a6bc4eb262b0040b771a9659dd3638ac1c4a225a1f11dc2"
+2 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-checkpoint-sqlite"
version = "1.0.4"
version = "2.0.0"
description = "Library with a SQLite implementation of LangGraph checkpoint saver."
authors = []
license = "MIT"
@@ -10,7 +10,7 @@ packages = [{ include = "langgraph" }]
[tool.poetry.dependencies]
python = "^3.9.0"
langgraph-checkpoint = "^1.0.11"
langgraph-checkpoint = "^2.0.0"
aiosqlite = "^0.20.0"
[tool.poetry.group.dev.dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-checkpoint"
version = "1.0.14"
version = "2.0.0"
description = "Library with base interfaces for LangGraph checkpoint savers."
authors = []
license = "MIT"
@@ -122,10 +122,10 @@ def _get_model_preprocessing_runnable(
def _should_bind_tools(model: LanguageModelLike, tools: Sequence[BaseTool]) -> bool:
if not isinstance(model, RunnableBinding):
return False
return True
if "tools" not in model.kwargs:
return False
return True
bound_tools = model.kwargs["tools"]
if len(tools) != len(bound_tools):
@@ -151,7 +151,7 @@ def _should_bind_tools(model: LanguageModelLike, tools: Sequence[BaseTool]) -> b
if missing_tools := tool_names - bound_tool_names:
raise ValueError(f"Missing tools '{missing_tools}' in the model.bind_tools()")
return True
return False
@deprecated_parameter("messages_modifier", "0.1.9", "state_modifier", removal="0.3.0")
+2 -1
View File
@@ -13,6 +13,7 @@ from typing import (
Protocol,
Sequence,
Union,
cast,
overload,
)
from uuid import UUID
@@ -471,7 +472,7 @@ def prepare_single_task(
else:
return PregelTask(task_id, packet.node, task_path)
elif task_path[0] == PULL:
name = str(task_path[1])
name = cast(str, task_path[1])
if name not in processes:
return
proc = processes[name]
+17 -6
View File
@@ -1238,7 +1238,7 @@ typing-extensions = ">=4.7"
[[package]]
name = "langgraph-checkpoint"
version = "1.0.13"
version = "2.0.0"
description = "Library with base interfaces for LangGraph checkpoint savers."
optional = false
python-versions = "^3.9.0,<4.0"
@@ -1255,7 +1255,7 @@ url = "../checkpoint"
[[package]]
name = "langgraph-checkpoint-postgres"
version = "1.0.9"
version = "2.0.0"
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
optional = false
python-versions = "^3.9.0,<4.0"
@@ -1263,7 +1263,7 @@ files = []
develop = true
[package.dependencies]
langgraph-checkpoint = "^1.0.11"
langgraph-checkpoint = "^2.0.0"
orjson = ">=3.10.1"
psycopg = "^3.0.0"
psycopg-pool = "^3.0.0"
@@ -1274,7 +1274,7 @@ url = "../checkpoint-postgres"
[[package]]
name = "langgraph-checkpoint-sqlite"
version = "1.0.4"
version = "2.0.0"
description = "Library with a SQLite implementation of LangGraph checkpoint saver."
optional = false
python-versions = "^3.9.0"
@@ -1283,7 +1283,7 @@ develop = true
[package.dependencies]
aiosqlite = "^0.20.0"
langgraph-checkpoint = "^1.0.11"
langgraph-checkpoint = "^2.0.0"
[package.source]
type = "directory"
@@ -2385,6 +2385,7 @@ files = [
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
{file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
{file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
{file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
{file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
@@ -2392,8 +2393,16 @@ files = [
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
{file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
{file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
{file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
{file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
@@ -2410,6 +2419,7 @@ files = [
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
{file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
{file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
{file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
{file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
@@ -2417,6 +2427,7 @@ files = [
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
{file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
{file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
{file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
@@ -3205,4 +3216,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools",
[metadata]
lock-version = "2.0"
python-versions = ">=3.9.0,<4.0"
content-hash = "272370fa28665231d6b09e9225330a224fc604cb7ec2cd5178b52e7f5c7ab15b"
content-hash = "734cf2b68846e513403f580fb7eeb00f9c26cd46919d9b9529e43d97e7c43216"
+2 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph"
version = "0.2.29"
version = "0.2.32"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
license = "MIT"
@@ -10,7 +10,7 @@ repository = "https://www.github.com/langchain-ai/langgraph"
[tool.poetry.dependencies]
python = ">=3.9.0,<4.0"
langchain-core = ">=0.2.39,<0.4"
langgraph-checkpoint = "^1.0.13"
langgraph-checkpoint = "^2.0.0"
[tool.poetry.group.dev.dependencies]
pytest = "^8.3.2"
+62
View File
@@ -15,6 +15,9 @@ from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
from langgraph.store.base import BaseStore
from langgraph.store.memory import InMemoryStore
from langgraph.store.postgres import AsyncPostgresStore, PostgresStore
from tests.memory_assert import MemorySaverAssertImmutable
DEFAULT_POSTGRES_URI = "postgres://postgres:postgres@localhost:5442/"
@@ -222,6 +225,63 @@ async def awith_checkpointer(
raise NotImplementedError(f"Unknown checkpointer: {checkpointer_name}")
@asynccontextmanager
async def _store_postgres_aio():
if sys.version_info < (3, 10):
pytest.skip("Async Postgres tests require Python 3.10+")
database = f"test_{uuid4().hex[:16]}"
async with await AsyncConnection.connect(
DEFAULT_POSTGRES_URI, autocommit=True
) as conn:
await conn.execute(f"CREATE DATABASE {database}")
try:
async with AsyncPostgresStore.from_conn_string(
DEFAULT_POSTGRES_URI + database
) as store:
await store.setup()
yield store
finally:
async with await AsyncConnection.connect(
DEFAULT_POSTGRES_URI, autocommit=True
) as conn:
await conn.execute(f"DROP DATABASE {database}")
@pytest.fixture(scope="function")
def store_postgres():
database = f"test_{uuid4().hex[:16]}"
# create unique db
with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn:
conn.execute(f"CREATE DATABASE {database}")
try:
# yield store
with PostgresStore.from_conn_string(DEFAULT_POSTGRES_URI + database) as store:
store.setup()
yield store
finally:
# drop unique db
with Connection.connect(DEFAULT_POSTGRES_URI, autocommit=True) as conn:
conn.execute(f"DROP DATABASE {database}")
@pytest.fixture(scope="function")
def store_in_memory():
yield InMemoryStore()
@asynccontextmanager
async def awith_store(store_name: Optional[str]) -> AsyncIterator[BaseStore]:
if store_name is None:
yield None
elif store_name == "in_memory":
yield InMemoryStore()
elif store_name == "postgres_aio":
async with _store_postgres_aio() as store:
yield store
else:
raise NotImplementedError(f"Unknown store {store_name}")
ALL_CHECKPOINTERS_SYNC = [
"memory",
"sqlite",
@@ -240,3 +300,5 @@ ALL_CHECKPOINTERS_ASYNC_PLUS_NONE = [
*ALL_CHECKPOINTERS_ASYNC,
None,
]
ALL_STORES_SYNC = ["in_memory", "postgres"]
ALL_STORES_ASYNC = ["in_memory", "postgres_aio"]
+31 -4
View File
@@ -1,3 +1,4 @@
import enum
import json
import operator
import re
@@ -75,7 +76,11 @@ from langgraph.store.base import BaseStore
from langgraph.store.memory import InMemoryStore
from langgraph.types import Interrupt, PregelTask, Send, StreamWriter
from tests.any_str import AnyDict, AnyStr, AnyVersion, FloatBetween, UnsortedSequence
from tests.conftest import ALL_CHECKPOINTERS_SYNC, SHOULD_CHECK_SNAPSHOTS
from tests.conftest import (
ALL_CHECKPOINTERS_SYNC,
ALL_STORES_SYNC,
SHOULD_CHECK_SNAPSHOTS,
)
from tests.fake_chat import FakeChatModel
from tests.fake_tracer import FakeTracer
from tests.memory_assert import MemorySaverAssertCheckpointMetadata
@@ -11457,8 +11462,12 @@ def test_subgraph_retries():
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_store_injected(request: pytest.FixtureRequest, checkpointer_name: str) -> None:
@pytest.mark.parametrize("store_name", ALL_STORES_SYNC)
def test_store_injected(
request: pytest.FixtureRequest, checkpointer_name: str, store_name: str
) -> None:
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
the_store = request.getfixturevalue(f"store_{store_name}")
class State(TypedDict):
count: Annotated[int, operator.add]
@@ -11468,7 +11477,6 @@ def test_store_injected(request: pytest.FixtureRequest, checkpointer_name: str)
def node(input: State, config: RunnableConfig, store: BaseStore):
assert isinstance(store, BaseStore)
assert isinstance(store, InMemoryStore)
store.put(
("foo", "bar"),
doc_id,
@@ -11483,7 +11491,6 @@ def test_store_injected(request: pytest.FixtureRequest, checkpointer_name: str)
builder = StateGraph(State)
builder.add_node("node", node)
builder.add_edge("__start__", "node")
the_store = InMemoryStore()
graph = builder.compile(store=the_store, checkpointer=checkpointer)
thread_1 = str(uuid.uuid4())
@@ -11511,3 +11518,23 @@ def test_store_injected(request: pytest.FixtureRequest, checkpointer_name: str)
"some_val": 0,
} # Overwrites the whole doc
assert len(the_store.search(("foo", "bar"))) == 1 # still overwriting the same one
def test_enum_node_names():
class NodeName(str, enum.Enum):
BAZ = "baz"
class State(TypedDict):
foo: str
bar: str
def baz(state: State):
return {"bar": state["foo"] + "!"}
graph = StateGraph(State)
graph.add_node(NodeName.BAZ, baz)
graph.add_edge(START, NodeName.BAZ)
graph.add_edge(NodeName.BAZ, END)
graph = graph.compile()
assert graph.invoke({"foo": "hello"}) == {"foo": "hello", "bar": "hello!"}
+7 -4
View File
@@ -67,8 +67,10 @@ from tests.any_str import AnyDict, AnyStr, AnyVersion, FloatBetween, UnsortedSeq
from tests.conftest import (
ALL_CHECKPOINTERS_ASYNC,
ALL_CHECKPOINTERS_ASYNC_PLUS_NONE,
ALL_STORES_ASYNC,
SHOULD_CHECK_SNAPSHOTS,
awith_checkpointer,
awith_store,
)
from tests.fake_chat import FakeChatModel
from tests.fake_tracer import FakeTracer
@@ -9709,7 +9711,8 @@ async def test_checkpointer_null_pending_writes() -> None:
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_store_injected_async(checkpointer_name: str) -> None:
@pytest.mark.parametrize("store_name", ALL_STORES_ASYNC)
async def test_store_injected_async(checkpointer_name: str, store_name: str) -> None:
class State(TypedDict):
count: Annotated[int, operator.add]
@@ -9718,7 +9721,6 @@ async def test_store_injected_async(checkpointer_name: str) -> None:
async def node(input: State, config: RunnableConfig, store: BaseStore):
assert isinstance(store, BaseStore)
assert isinstance(store, InMemoryStore)
await store.aput(
("foo", "bar"),
doc_id,
@@ -9733,8 +9735,9 @@ async def test_store_injected_async(checkpointer_name: str) -> None:
builder = StateGraph(State)
builder.add_node("node", node)
builder.add_edge("__start__", "node")
the_store = InMemoryStore()
async with awith_checkpointer(checkpointer_name) as checkpointer:
async with awith_checkpointer(checkpointer_name) as checkpointer, awith_store(
store_name
) as the_store:
graph = builder.compile(store=the_store, checkpointer=checkpointer)
thread_1 = str(uuid.uuid4())
+174
View File
@@ -12,6 +12,9 @@ import {
AssistantVersion,
Subgraphs,
Checkpoint,
SearchItemsResponse,
ListNamespaceResponse,
Item,
} from "./schema.js";
import { AsyncCaller, AsyncCallerParams } from "./utils/async_caller.js";
import {
@@ -983,6 +986,171 @@ export class RunsClient extends BaseClient {
}
}
interface APIItem {
namespace: string[];
key: string;
value: Record<string, any>;
created_at: string;
updated_at: string;
}
interface APISearchItemsResponse {
items: APIItem[];
}
export class StoreClient extends BaseClient {
/**
* Store or update an item.
*
* @param namespace A list of strings representing the namespace path.
* @param key The unique identifier for the item within the namespace.
* @param value A dictionary containing the item's data.
* @returns Promise<void>
*/
async putItem(
namespace: string[],
key: string,
value: Record<string, any>,
): Promise<void> {
namespace.forEach((label) => {
if (label.includes(".")) {
throw new Error(
`Invalid namespace label '${label}'. Namespace labels cannot contain periods ('.')`,
);
}
});
const payload = {
namespace,
key,
value,
};
return this.fetch<void>("/store/items", {
method: "PUT",
json: payload,
});
}
/**
* Retrieve a single item.
*
* @param namespace A list of strings representing the namespace path.
* @param key The unique identifier for the item.
* @returns Promise<Item>
*/
async getItem(namespace: string[], key: string): Promise<Item | null> {
namespace.forEach((label) => {
if (label.includes(".")) {
throw new Error(
`Invalid namespace label '${label}'. Namespace labels cannot contain periods ('.')`,
);
}
});
const response = await this.fetch<APIItem>("/store/items", {
params: { namespace: namespace.join("."), key },
});
return {
...response,
createdAt: response.created_at,
updatedAt: response.updated_at,
};
}
/**
* Delete an item.
*
* @param namespace A list of strings representing the namespace path.
* @param key The unique identifier for the item.
* @returns Promise<void>
*/
async deleteItem(namespace: string[], key: string): Promise<void> {
namespace.forEach((label) => {
if (label.includes(".")) {
throw new Error(
`Invalid namespace label '${label}'. Namespace labels cannot contain periods ('.')`,
);
}
});
return this.fetch<void>("/store/items", {
method: "DELETE",
json: { namespace, key },
});
}
/**
* Search for items within a namespace prefix.
*
* @param namespacePrefix List of strings representing the namespace prefix.
* @param options.filter Optional dictionary of key-value pairs to filter results.
* @param options.limit Maximum number of items to return (default is 10).
* @param options.offset Number of items to skip before returning results (default is 0).
* @returns Promise<SearchItemsResponse>
*/
async searchItems(
namespacePrefix: string[],
options?: {
filter?: Record<string, any>;
limit?: number;
offset?: number;
},
): Promise<SearchItemsResponse> {
const payload = {
namespace_prefix: namespacePrefix,
filter: options?.filter,
limit: options?.limit ?? 10,
offset: options?.offset ?? 0,
};
const response = await this.fetch<APISearchItemsResponse>(
"/store/items/search",
{
method: "POST",
json: payload,
},
);
return {
items: response.items.map((item) => ({
...item,
createdAt: item.created_at,
updatedAt: item.updated_at,
})),
};
}
/**
* List namespaces with optional match conditions.
*
* @param options.prefix Optional list of strings representing the prefix to filter namespaces.
* @param options.suffix Optional list of strings representing the suffix to filter namespaces.
* @param options.maxDepth Optional integer specifying the maximum depth of namespaces to return.
* @param options.limit Maximum number of namespaces to return (default is 100).
* @param options.offset Number of namespaces to skip before returning results (default is 0).
* @returns Promise<ListNamespaceResponse>
*/
async listNamespaces(options?: {
prefix?: string[];
suffix?: string[];
maxDepth?: number;
limit?: number;
offset?: number;
}): Promise<ListNamespaceResponse> {
const payload = {
prefix: options?.prefix,
suffix: options?.suffix,
max_depth: options?.maxDepth,
limit: options?.limit ?? 100,
offset: options?.offset ?? 0,
};
return this.fetch<ListNamespaceResponse>("/store/namespaces", {
method: "POST",
json: payload,
});
}
}
export class Client {
/**
* The client for interacting with assistants.
@@ -1004,10 +1172,16 @@ export class Client {
*/
public crons: CronsClient;
/**
* The client for interacting with the KV store.
*/
public store: StoreClient;
constructor(config?: ClientConfig) {
this.assistants = new AssistantsClient(config);
this.threads = new ThreadsClient(config);
this.runs = new RunsClient(config);
this.crons = new CronsClient(config);
this.store = new StoreClient(config);
}
}
+16
View File
@@ -219,3 +219,19 @@ export interface Checkpoint {
checkpoint_id: Optional<string>;
checkpoint_map: Optional<Record<string, unknown>>;
}
export interface ListNamespaceResponse {
namespaces: string[][];
}
export interface SearchItemsResponse {
items: Item[];
}
export interface Item {
namespace: string[];
key: string;
value: Record<string, any>;
createdAt: string;
updatedAt: string;
}
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-sdk"
version = "0.1.31"
version = "0.1.32"
description = "SDK for interacting with LangGraph API"
authors = []
license = "MIT"
Generated
+31 -3
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
[[package]]
name = "aiohappyeyeballs"
@@ -4625,6 +4625,7 @@ description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs
optional = false
python-versions = ">=3.8"
files = [
{file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
{file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
]
@@ -4635,6 +4636,7 @@ description = "A collection of ASN.1-based protocols modules"
optional = false
python-versions = ">=3.8"
files = [
{file = "pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd"},
{file = "pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c"},
]
@@ -5590,6 +5592,33 @@ files = [
[package.dependencies]
pyasn1 = ">=0.1.3"
[[package]]
name = "ruff"
version = "0.6.8"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.6.8-py3-none-linux_armv6l.whl", hash = "sha256:77944bca110ff0a43b768f05a529fecd0706aac7bcce36d7f1eeb4cbfca5f0f2"},
{file = "ruff-0.6.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27b87e1801e786cd6ede4ada3faa5e254ce774de835e6723fd94551464c56b8c"},
{file = "ruff-0.6.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd48f945da2a6334f1793d7f701725a76ba93bf3d73c36f6b21fb04d5338dcf5"},
{file = "ruff-0.6.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:677e03c00f37c66cea033274295a983c7c546edea5043d0c798833adf4cf4c6f"},
{file = "ruff-0.6.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f1476236b3eacfacfc0f66aa9e6cd39f2a624cb73ea99189556015f27c0bdeb"},
{file = "ruff-0.6.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f5a2f17c7d32991169195d52a04c95b256378bbf0de8cb98478351eb70d526f"},
{file = "ruff-0.6.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5fd0d4b7b1457c49e435ee1e437900ced9b35cb8dc5178921dfb7d98d65a08d0"},
{file = "ruff-0.6.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8034b19b993e9601f2ddf2c517451e17a6ab5cdb1c13fdff50c1442a7171d87"},
{file = "ruff-0.6.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cfb227b932ba8ef6e56c9f875d987973cd5e35bc5d05f5abf045af78ad8e098"},
{file = "ruff-0.6.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef0411eccfc3909269fed47c61ffebdcb84a04504bafa6b6df9b85c27e813b0"},
{file = "ruff-0.6.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:007dee844738c3d2e6c24ab5bc7d43c99ba3e1943bd2d95d598582e9c1b27750"},
{file = "ruff-0.6.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ce60058d3cdd8490e5e5471ef086b3f1e90ab872b548814e35930e21d848c9ce"},
{file = "ruff-0.6.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1085c455d1b3fdb8021ad534379c60353b81ba079712bce7a900e834859182fa"},
{file = "ruff-0.6.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:70edf6a93b19481affd287d696d9e311388d808671bc209fb8907b46a8c3af44"},
{file = "ruff-0.6.8-py3-none-win32.whl", hash = "sha256:792213f7be25316f9b46b854df80a77e0da87ec66691e8f012f887b4a671ab5a"},
{file = "ruff-0.6.8-py3-none-win_amd64.whl", hash = "sha256:ec0517dc0f37cad14a5319ba7bba6e7e339d03fbf967a6d69b0907d61be7a263"},
{file = "ruff-0.6.8-py3-none-win_arm64.whl", hash = "sha256:8d3bb2e3fbb9875172119021a13eed38849e762499e3cfde9588e4b4d70968dc"},
{file = "ruff-0.6.8.tar.gz", hash = "sha256:a5bf44b1aa0adaf6d9d20f86162b34f7c593bfedabc51239953e446aefc8ce18"},
]
[[package]]
name = "scikit-learn"
version = "1.5.2"
@@ -6326,7 +6355,6 @@ description = "Automatically mock your HTTP interactions to simplify and speed u
optional = false
python-versions = ">=3.8"
files = [
{file = "vcrpy-6.0.1-py2.py3-none-any.whl", hash = "sha256:621c3fb2d6bd8aa9f87532c688e4575bcbbde0c0afeb5ebdb7e14cac409edfdd"},
{file = "vcrpy-6.0.1.tar.gz", hash = "sha256:9e023fee7f892baa0bbda2f7da7c8ac51165c1c6e38ff8688683a12a4bde9278"},
]
@@ -6812,4 +6840,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "231fced6f4a45e886918b0cc71292e792646564da1200a31da12ade24c35b599"
content-hash = "dace09c0bfa54606acbd49f84076d4cdc512d3415b68ae8b4b11b742da3839b6"
+16
View File
@@ -30,6 +30,7 @@ markdown-callouts = "^0.4.0"
mkdocs-exclude = "^1.0.2"
vcrpy = "^6.0.1"
click = "^8.1.7"
ruff = "^0.6.8"
[tool.poetry.group.test.dependencies]
langchain = "^0.3.1"
@@ -59,3 +60,18 @@ optional = true
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.ruff]
extend-include = ["*.ipynb"]
[tool.ruff.lint.per-file-ignores]
"docs/*" = [
"E402", # allow imports to appear anywhere in docs
"F401", # allow "imported but unused" example code
"F811", # allow re-importing the same module, so that cells can stay independent
"F841", # allow assignments to variables that are never read -- it's example code
# The issues below should be cleaned up when there's time
"E722", # allow base imports in notebooks
]