mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-07 17:05:09 +02:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbc35a30f8 | ||
|
|
b0f14649e0 | ||
|
|
ea20432b9b | ||
|
|
e2efab8061 | ||
|
|
9babffa054 | ||
|
|
73cebea3c2 | ||
|
|
b73b2d19eb | ||
|
|
ca26805b5f | ||
|
|
5ac837d7cd | ||
|
|
c4f5861166 | ||
|
|
172238b2d5 | ||
|
|
095da17833 | ||
|
|
e931c68669 | ||
|
|
666c224c2d | ||
|
|
21a6f41e0a | ||
|
|
acdf85aba6 | ||
|
|
9b18243fa6 | ||
|
|
0225b998af | ||
|
|
bec122d4a2 |
@@ -31,6 +31,8 @@ jobs:
|
||||
workdir: libs/cli/examples/graphs_reqs_b
|
||||
tag: langgraph-test-d
|
||||
name: "CLI integration test"
|
||||
env:
|
||||
HAS_LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY != '' }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: libs/cli
|
||||
@@ -58,7 +60,7 @@ jobs:
|
||||
run: |
|
||||
langgraph build -t ${{ matrix.example.tag }}
|
||||
- name: Test service ${{ matrix.example.name }}
|
||||
if: ${{ steps.changed-files.outputs.all && secrets.LANGSMITH_API_KEY != '' }}
|
||||
if: ${{ steps.changed-files.outputs.all && env.HAS_LANGSMITH_API_KEY == 'true' }}
|
||||
working-directory: ${{ matrix.example.workdir }}
|
||||
env:
|
||||
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||
@@ -89,7 +91,7 @@ jobs:
|
||||
run: |
|
||||
langgraph build -t langgraph-test-g -c apps/agent/langgraph.json
|
||||
- name: Test Python monorepo service
|
||||
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' && secrets.LANGSMITH_API_KEY != '' }}
|
||||
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
|
||||
working-directory: libs/cli/python-monorepo-example
|
||||
env:
|
||||
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||
@@ -104,7 +106,7 @@ jobs:
|
||||
run: |
|
||||
langgraph build -t langgraph-test-h
|
||||
- name: Test prerelease reqs service
|
||||
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' && secrets.LANGSMITH_API_KEY != '' }}
|
||||
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
|
||||
working-directory: libs/cli/examples/graph_prerelease_reqs
|
||||
env:
|
||||
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
---
|
||||
name: CLI Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'cli-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (e.g., cli-v0.2.10)'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write # OIDC trusted publishing for PyPI
|
||||
|
||||
concurrency:
|
||||
group: cli-release
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
# Build native binaries for each platform
|
||||
# ──────────────────────────────────────────────
|
||||
build:
|
||||
name: Build - ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: libs/cli
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-x64
|
||||
runner: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: langgraph
|
||||
npm_pkg: langgraph-cli-linux-x64
|
||||
|
||||
- name: linux-arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
binary: langgraph
|
||||
npm_pkg: langgraph-cli-linux-arm64
|
||||
|
||||
- name: darwin-x64
|
||||
runner: macos-13 # Intel
|
||||
target: x86_64-apple-darwin
|
||||
binary: langgraph
|
||||
npm_pkg: langgraph-cli-darwin-x64
|
||||
|
||||
- name: darwin-arm64
|
||||
runner: macos-latest # Apple Silicon
|
||||
target: aarch64-apple-darwin
|
||||
binary: langgraph
|
||||
npm_pkg: langgraph-cli-darwin-arm64
|
||||
|
||||
- name: win32-x64
|
||||
runner: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: langgraph.exe
|
||||
npm_pkg: langgraph-cli-win32-x64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libs/cli
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release --target ${{ matrix.target }}
|
||||
|
||||
- name: Run tests
|
||||
if: matrix.name != 'linux-arm64' # skip on ARM (slow QEMU)
|
||||
run: cargo test --release --target ${{ matrix.target }}
|
||||
|
||||
# Upload raw binary for GitHub release
|
||||
- name: Prepare binary artifact
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp target/${{ matrix.target }}/release/${{ matrix.binary }} dist/
|
||||
cd dist
|
||||
if [[ "${{ matrix.name }}" == win32-* ]]; then
|
||||
7z a ../langgraph-${{ matrix.name }}.zip ${{ matrix.binary }}
|
||||
else
|
||||
tar czf ../langgraph-${{ matrix.name }}.tar.gz ${{ matrix.binary }}
|
||||
fi
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binary-${{ matrix.name }}
|
||||
path: |
|
||||
libs/cli/langgraph-${{ matrix.name }}.tar.gz
|
||||
libs/cli/langgraph-${{ matrix.name }}.zip
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Prepare npm platform package
|
||||
- name: Prepare npm platform package
|
||||
shell: bash
|
||||
run: |
|
||||
cp target/${{ matrix.target }}/release/${{ matrix.binary }} npm/${{ matrix.npm_pkg }}/bin/
|
||||
|
||||
- name: Upload npm platform package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-${{ matrix.name }}
|
||||
path: libs/cli/npm/${{ matrix.npm_pkg }}/
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Build Linux musl (Alpine-compatible) binary
|
||||
# ──────────────────────────────────────────────
|
||||
build-musl:
|
||||
name: Build - linux-x64-musl
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: libs/cli
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-musl
|
||||
|
||||
- name: Install musl tools
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libs/cli
|
||||
key: x86_64-unknown-linux-musl
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release --target x86_64-unknown-linux-musl
|
||||
|
||||
- name: Prepare binary artifact
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp target/x86_64-unknown-linux-musl/release/langgraph dist/
|
||||
cd dist && tar czf ../langgraph-linux-x64-musl.tar.gz langgraph
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binary-linux-x64-musl
|
||||
path: libs/cli/langgraph-linux-x64-musl.tar.gz
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Build PyPI wheels via maturin
|
||||
# ──────────────────────────────────────────────
|
||||
pypi-wheels:
|
||||
name: PyPI wheel - ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-x64
|
||||
runner: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
manylinux: manylinux_2_17
|
||||
|
||||
- name: linux-arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
manylinux: manylinux_2_17
|
||||
|
||||
- name: linux-x64-musl
|
||||
runner: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
manylinux: musllinux_1_2
|
||||
|
||||
- name: darwin-x64
|
||||
runner: macos-13
|
||||
target: x86_64-apple-darwin
|
||||
manylinux: auto
|
||||
|
||||
- name: darwin-arm64
|
||||
runner: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
manylinux: auto
|
||||
|
||||
- name: win32-x64
|
||||
runner: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
manylinux: auto
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Build wheel
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
manylinux: ${{ matrix.manylinux }}
|
||||
args: --release --manifest-path libs/cli/Cargo.toml --out libs/cli/dist
|
||||
rust-toolchain: stable
|
||||
|
||||
- name: Upload wheel
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wheel-${{ matrix.name }}
|
||||
path: libs/cli/dist/*.whl
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Build sdist
|
||||
# ──────────────────────────────────────────────
|
||||
pypi-sdist:
|
||||
name: PyPI sdist
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build sdist
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
command: sdist
|
||||
args: --manifest-path libs/cli/Cargo.toml --out libs/cli/dist
|
||||
|
||||
- name: Upload sdist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sdist
|
||||
path: libs/cli/dist/*.tar.gz
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Publish to PyPI
|
||||
# ──────────────────────────────────────────────
|
||||
pypi-publish:
|
||||
name: Publish to PyPI
|
||||
needs: [pypi-wheels, pypi-sdist]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/cli-v')
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/project/langgraph-cli/
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: wheel-*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: sdist
|
||||
path: dist
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Publish to npm
|
||||
# ──────────────────────────────────────────────
|
||||
npm-publish:
|
||||
name: Publish to npm
|
||||
needs: [build]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/cli-v')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
# Download all npm platform packages
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: npm-*
|
||||
path: npm-artifacts
|
||||
|
||||
# Publish each platform package
|
||||
- name: Publish platform packages
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
for dir in npm-artifacts/npm-*/; do
|
||||
echo "Publishing $(basename $dir)..."
|
||||
cd "$dir"
|
||||
npm publish --access public
|
||||
cd -
|
||||
done
|
||||
|
||||
# Publish the main package
|
||||
- name: Publish main package
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
cd libs/cli/npm/langgraph-cli
|
||||
npm publish --access public
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Create GitHub Release with standalone binaries
|
||||
# ──────────────────────────────────────────────
|
||||
github-release:
|
||||
name: GitHub Release
|
||||
needs: [build, build-musl]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/cli-v')
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: binary-*
|
||||
path: release-artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release-artifacts/*
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
name: CLI Rust
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'libs/cli/src/**'
|
||||
- 'libs/cli/Cargo.toml'
|
||||
- 'libs/cli/Cargo.lock'
|
||||
- 'libs/cli/build.rs'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'libs/cli/src/**'
|
||||
- 'libs/cli/Cargo.toml'
|
||||
- 'libs/cli/Cargo.lock'
|
||||
- 'libs/cli/build.rs'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check / Lint / Test
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: libs/cli
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libs/cli
|
||||
- name: Format check
|
||||
run: cargo fmt --check
|
||||
- name: Clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
- name: Test
|
||||
run: cargo test
|
||||
- name: Build release
|
||||
run: cargo build --release
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Deploy Redirects to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/deploy-redirects.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Generate redirect files
|
||||
run: python docs/generate_redirects.py
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: 'docs/_site'
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -0,0 +1 @@
|
||||
_site/
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate HTML redirect files from redirects.json.
|
||||
|
||||
Usage:
|
||||
python generate_redirects.py
|
||||
|
||||
This script reads redirects.json and generates individual HTML files
|
||||
for each redirect path. Each HTML file uses meta refresh (0 delay)
|
||||
which is SEO-friendly and treated similarly to 301 redirects by Google.
|
||||
|
||||
To add new redirects, simply edit redirects.json and re-run this script.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Default fallback URL for any path not in the redirect map
|
||||
DEFAULT_REDIRECT = "https://docs.langchain.com/oss/python/langgraph/overview"
|
||||
|
||||
HTML_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Redirecting...</title>
|
||||
<link rel="canonical" href="{url}">
|
||||
<meta name="robots" content="noindex">
|
||||
<script>var anchor=window.location.hash.substr(1);location.href="{url}"+(anchor?"#"+anchor:"")</script>
|
||||
<meta http-equiv="refresh" content="0; url={url}">
|
||||
</head>
|
||||
<body>
|
||||
Redirecting...
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
ROOT_HTML_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Redirecting to LangGraph Documentation</title>
|
||||
<link rel="canonical" href="{url}">
|
||||
<meta name="robots" content="noindex">
|
||||
<script>var anchor=window.location.hash.substr(1);location.href="{url}"+(anchor?"#"+anchor:"")</script>
|
||||
<meta http-equiv="refresh" content="0; url={url}">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Documentation has moved</h1>
|
||||
<p>The LangGraph documentation has moved to <a href="{url}">docs.langchain.com</a>.</p>
|
||||
<p>Redirecting you now...</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
CATCHALL_404_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Redirecting to LangGraph Documentation</title>
|
||||
<link rel="canonical" href="{default_url}">
|
||||
<meta name="robots" content="noindex">
|
||||
<script>
|
||||
// Catchall redirect for any unmapped paths
|
||||
window.location.replace("{default_url}");
|
||||
</script>
|
||||
<meta http-equiv="refresh" content="0; url={default_url}">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Documentation has moved</h1>
|
||||
<p>The LangGraph documentation has moved to <a href="{default_url}">docs.langchain.com</a>.</p>
|
||||
<p>Redirecting you now...</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def generate_redirects():
|
||||
script_dir = Path(__file__).parent
|
||||
output_dir = script_dir / "_site"
|
||||
|
||||
# Load redirects
|
||||
with open(script_dir / "redirects.json") as f:
|
||||
redirects = json.load(f)
|
||||
|
||||
# Clean output directory
|
||||
if output_dir.exists():
|
||||
import shutil
|
||||
shutil.rmtree(output_dir)
|
||||
output_dir.mkdir(parents=True)
|
||||
|
||||
# Generate individual HTML files for each redirect
|
||||
for old_path, new_url in redirects.items():
|
||||
# Remove leading slash and create directory structure
|
||||
path = old_path.lstrip("/")
|
||||
|
||||
# Check if path has a file extension (e.g., .txt, .xml)
|
||||
# If so, create the file directly instead of a directory with index.html
|
||||
path_obj = Path(path)
|
||||
has_extension = path_obj.suffix and len(path_obj.suffix) <= 5
|
||||
|
||||
if not path:
|
||||
html_path = output_dir / "index.html"
|
||||
elif has_extension:
|
||||
# For files with extensions, create the file directly
|
||||
html_path = output_dir / path
|
||||
else:
|
||||
# For directory-style URLs, create index.html inside
|
||||
html_path = output_dir / path / "index.html"
|
||||
|
||||
# Create parent directories
|
||||
html_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write the redirect HTML
|
||||
html_path.write_text(HTML_TEMPLATE.format(url=new_url))
|
||||
print(f"Created: {html_path}")
|
||||
|
||||
# Create root index.html
|
||||
root_index = output_dir / "index.html"
|
||||
if not root_index.exists():
|
||||
root_index.write_text(ROOT_HTML_TEMPLATE.format(url=DEFAULT_REDIRECT))
|
||||
print(f"Created: {root_index}")
|
||||
|
||||
# Create 404.html for catchall
|
||||
catchall_404 = output_dir / "404.html"
|
||||
catchall_404.write_text(CATCHALL_404_TEMPLATE.format(default_url=DEFAULT_REDIRECT))
|
||||
print(f"Created: {catchall_404}")
|
||||
|
||||
# Copy static files (like llms.txt) that can't be redirected via HTML
|
||||
static_files = ["llms.txt"]
|
||||
for static_file in static_files:
|
||||
src = script_dir / static_file
|
||||
if src.exists():
|
||||
dst = output_dir / static_file
|
||||
dst.write_text(src.read_text())
|
||||
print(f"Copied: {dst}")
|
||||
|
||||
print(f"\nGenerated {len(redirects)} redirect files in {output_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_redirects()
|
||||
@@ -0,0 +1,35 @@
|
||||
# LangGraph
|
||||
|
||||
LangGraph documentation has moved to docs.langchain.com.
|
||||
|
||||
## Overview
|
||||
|
||||
- [LangGraph Overview](https://docs.langchain.com/oss/python/langgraph/overview): Introduction to LangGraph, a library for building stateful, multi-actor applications with LLMs.
|
||||
- [Why LangGraph?](https://docs.langchain.com/oss/python/langgraph/why-langgraph): Motivation for LangGraph and its key features.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- [Graph API](https://docs.langchain.com/oss/python/langgraph/graph-api): Learn how to define state, create nodes, and connect them with edges.
|
||||
- [Streaming](https://docs.langchain.com/oss/python/langgraph/streaming): Stream outputs from your graph for better UX.
|
||||
- [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence): Add memory and checkpointing to your graphs.
|
||||
- [Add Memory](https://docs.langchain.com/oss/python/langgraph/add-memory): Implement short-term and long-term memory.
|
||||
- [Workflows & Agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents): Build agents and workflows with LangGraph.
|
||||
|
||||
## How-To Guides
|
||||
|
||||
- [Use Subgraphs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs): Compose graphs using subgraphs.
|
||||
- [Observability](https://docs.langchain.com/oss/python/langgraph/observability): Add tracing and debugging to your graphs.
|
||||
- [Common Errors](https://docs.langchain.com/oss/python/langgraph/common-errors): Troubleshoot common LangGraph errors.
|
||||
|
||||
## Tutorials
|
||||
|
||||
- [Agentic RAG](https://docs.langchain.com/oss/python/langgraph/agentic-rag): Build an agentic RAG system with LangGraph.
|
||||
- [SQL Agent](https://docs.langchain.com/oss/python/langgraph/sql-agent): Create a SQL agent with LangGraph.
|
||||
|
||||
## Reference
|
||||
|
||||
- [API Reference](https://reference.langchain.com/python/langgraph/): Complete API documentation for LangGraph.
|
||||
|
||||
## LangGraph Platform
|
||||
|
||||
For deploying LangGraph applications in production, see the [LangSmith documentation](https://docs.langchain.com/langsmith/agent-server).
|
||||
@@ -0,0 +1,296 @@
|
||||
{
|
||||
"/how-tos/stream-values": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/stream-updates": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming-content": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/stream-multiple": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming-tokens-without-langchain": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming-from-final-node": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming-events-from-within-tools-without-langchain": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/state-reducers": "https://docs.langchain.com/oss/python/langgraph/graph-api#define-and-update-state",
|
||||
"/how-tos/sequence": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-a-sequence-of-steps",
|
||||
"/how-tos/branching": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-branches",
|
||||
"/how-tos/recursion-limit": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-and-control-loops",
|
||||
"/how-tos/visualization": "https://docs.langchain.com/oss/python/langgraph/graph-api#visualize-your-graph",
|
||||
"/how-tos/input_output_schema": "https://docs.langchain.com/oss/python/langgraph/graph-api#define-input-and-output-schemas",
|
||||
"/how-tos/pass_private_state": "https://docs.langchain.com/oss/python/langgraph/graph-api#pass-private-state-between-nodes",
|
||||
"/how-tos/state-model": "https://docs.langchain.com/oss/python/langgraph/graph-api#use-pydantic-models-for-graph-state",
|
||||
"/how-tos/map-reduce": "https://docs.langchain.com/oss/python/langgraph/graph-api#map-reduce-and-the-send-api",
|
||||
"/how-tos/command": "https://docs.langchain.com/oss/python/langgraph/graph-api#combine-control-flow-and-state-updates-with-command",
|
||||
"/how-tos/configuration": "https://docs.langchain.com/oss/python/langgraph/graph-api#add-runtime-configuration",
|
||||
"/how-tos/node-retries": "https://docs.langchain.com/oss/python/langgraph/graph-api#add-retry-policies",
|
||||
"/how-tos/return-when-recursion-limit-hits": "https://docs.langchain.com/oss/python/langgraph/graph-api#impose-a-recursion-limit",
|
||||
"/how-tos/async": "https://docs.langchain.com/oss/python/langgraph/graph-api#async",
|
||||
"/how-tos/memory/manage-conversation-history": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/memory/delete-messages": "https://docs.langchain.com/oss/python/langgraph/add-memory#delete-messages",
|
||||
"/how-tos/memory/add-summary-conversation-history": "https://docs.langchain.com/oss/python/langgraph/add-memory#summarize-messages",
|
||||
"/how-tos/memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/agents/memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/subgraph-transform-state": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs#different-state-schemas",
|
||||
"/how-tos/subgraphs-manage-state": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs#add-persistence",
|
||||
"/how-tos/persistence_postgres": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production",
|
||||
"/how-tos/persistence_mongodb": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production",
|
||||
"/how-tos/persistence_redis": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production",
|
||||
"/how-tos/subgraph-persistence": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-with-subgraphs",
|
||||
"/how-tos/cross-thread-persistence": "https://docs.langchain.com/oss/python/langgraph/add-memory#add-long-term-memory",
|
||||
"/cloud/how-tos/copy_threads": "https://docs.langchain.com/langsmith/use-threads",
|
||||
"/cloud/how-tos/check-thread-status": "https://docs.langchain.com/langsmith/use-threads",
|
||||
"/cloud/concepts/threads": "https://docs.langchain.com/oss/python/langgraph/persistence#threads",
|
||||
"/how-tos/persistence": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/tool-calling-errors": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/pass-config-to-tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/pass-run-time-values-to-tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/update-state-from-tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/agents/tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/agent-handoffs": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/multi-agent-network": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/multi-agent-multi-turn-convo": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/cloud/index": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/cloud/how-tos/index": "https://docs.langchain.com/langsmith/home",
|
||||
"/cloud/concepts/api": "https://docs.langchain.com/langsmith/agent-server",
|
||||
"/cloud/concepts/cloud": "https://docs.langchain.com/langsmith/cloud",
|
||||
"/cloud/faq/studio": "https://docs.langchain.com/langsmith/studio",
|
||||
"/cloud/how-tos/human_in_the_loop_edit_state": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
|
||||
"/cloud/how-tos/human_in_the_loop_user_input": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
|
||||
"/concepts/platform_architecture": "https://docs.langchain.com/langsmith/cloud#architecture",
|
||||
"/cloud/how-tos/stream_values": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_updates": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_messages": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_events": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_debug": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_multiple": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/concepts/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/agents/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/create-react-agent": "https://docs.langchain.com/oss/python/langchain/agents#basic-configuration",
|
||||
"/how-tos/create-react-agent-memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/create-react-agent-system-prompt": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/create-react-agent-structured-output": "https://docs.langchain.com/oss/python/langchain/agents#structured-output",
|
||||
"/prebuilt": "https://docs.langchain.com/oss/python/langchain/agents",
|
||||
"/reference/prebuilt": "https://reference.langchain.com/python/langgraph/agents/",
|
||||
"/concepts/high_level": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/index": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/v0-human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/how-tos/index": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/introduction": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/agents/deployment": "https://docs.langchain.com/oss/python/langgraph/local-server",
|
||||
"/how-tos/deploy-self-hosted": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/concepts/self_hosted": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/tutorials/deployment": "https://docs.langchain.com/langsmith/deployments",
|
||||
"/cloud/how-tos/assistant_versioning": "https://docs.langchain.com/langsmith/configuration-cloud",
|
||||
"/cloud/concepts/runs": "https://docs.langchain.com/langsmith/assistants#execution",
|
||||
"/how-tos/wait-user-input-functional": "https://docs.langchain.com/oss/python/langgraph/functional-api",
|
||||
"/how-tos/review-tool-calls-functional": "https://docs.langchain.com/oss/python/langgraph/functional-api",
|
||||
"/how-tos/create-react-agent-hitl": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/agents/human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/how-tos/human_in_the_loop/dynamic_breakpoints": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/concepts/breakpoints": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/how-tos/human_in_the_loop/breakpoints": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/cloud/how-tos/human_in_the_loop_breakpoint": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
|
||||
"/how-tos/human_in_the_loop/edit-graph-state": "https://docs.langchain.com/oss/python/langgraph/use-time-travel",
|
||||
"/examples/index": "https://docs.langchain.com/oss/python/langgraph/case-studies",
|
||||
"/guides/index": "https://docs.langchain.com/oss/python/langchain/overview",
|
||||
"/tutorials/index": "https://docs.langchain.com/oss/python/learn",
|
||||
"/llms-txt-overview": "https://docs.langchain.com/llms.txt",
|
||||
"/tutorials/rag/langgraph_adaptive_rag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/multi_agent/multi-agent-collaboration": "https://docs.langchain.com/oss/python/langchain/multi-agent",
|
||||
"/how-tos/create-react-agent-manage-message-history": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/many-tools": "https://docs.langchain.com/oss/python/langchain/tools",
|
||||
"/tutorials/customer-support/customer-support": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/how-tos/react-agent-structured-output": "https://docs.langchain.com/oss/python/langchain/agents#structured-output",
|
||||
"/tutorials/code_assistant/langgraph_code_assistant": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/multi_agent/hierarchical_agent_teams": "https://docs.langchain.com/oss/python/langchain/supervisor",
|
||||
"/tutorials/auth/getting_started": "https://docs.langchain.com/langsmith/auth",
|
||||
"/tutorials/auth/resource_auth": "https://docs.langchain.com/langsmith/resource-auth",
|
||||
"/tutorials/auth/add_auth_server": "https://docs.langchain.com/langsmith/add-auth-server",
|
||||
"/how-tos/use-remote-graph": "https://docs.langchain.com/langsmith/use-remote-graph",
|
||||
"/how-tos/autogen-integration": "https://docs.langchain.com/langsmith/autogen-integration",
|
||||
"/how-tos/human_in_the_loop/wait-user-input": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/cloud/how-tos/use_stream_react": "https://docs.langchain.com/langsmith/use-stream-react",
|
||||
"/cloud/how-tos/generative_ui_react": "https://docs.langchain.com/langsmith/generative-ui-react",
|
||||
"/concepts/langgraph_platform": "https://docs.langchain.com/langsmith/home",
|
||||
"/concepts/langgraph_components": "https://docs.langchain.com/langsmith/components",
|
||||
"/concepts/langgraph_server": "https://docs.langchain.com/langsmith/agent-server",
|
||||
"/concepts/langgraph_data_plane": "https://docs.langchain.com/langsmith/data-plane",
|
||||
"/concepts/langgraph_control_plane": "https://docs.langchain.com/langsmith/control-plane",
|
||||
"/concepts/langgraph_cli": "https://docs.langchain.com/langsmith/cli",
|
||||
"/concepts/langgraph_studio": "https://docs.langchain.com/langsmith/studio",
|
||||
"/cloud/how-tos/studio/quick_start": "https://docs.langchain.com/langsmith/quick-start-studio",
|
||||
"/cloud/how-tos/invoke_studio": "https://docs.langchain.com/langsmith/use-studio",
|
||||
"/cloud/how-tos/studio/manage_assistants": "https://docs.langchain.com/langsmith/use-studio",
|
||||
"/cloud/how-tos/threads_studio": "https://docs.langchain.com/langsmith/use-threads",
|
||||
"/cloud/how-tos/iterate_graph_studio": "https://docs.langchain.com/langsmith/use-studio",
|
||||
"/cloud/how-tos/studio/run_evals": "https://docs.langchain.com/langsmith/observability",
|
||||
"/cloud/how-tos/clone_traces_studio": "https://docs.langchain.com/langsmith/observability",
|
||||
"/cloud/how-tos/datasets_studio": "https://docs.langchain.com/langsmith/use-studio",
|
||||
"/concepts/sdk": "https://docs.langchain.com/langsmith/sdk",
|
||||
"/concepts/plans": "https://docs.langchain.com/langsmith/home",
|
||||
"/concepts/application_structure": "https://docs.langchain.com/langsmith/application-structure",
|
||||
"/concepts/scalability_and_resilience": "https://docs.langchain.com/langsmith/scalability-and-resilience",
|
||||
"/concepts/auth": "https://docs.langchain.com/langsmith/auth",
|
||||
"/how-tos/auth/custom_auth": "https://docs.langchain.com/langsmith/custom-auth",
|
||||
"/how-tos/auth/openapi_security": "https://docs.langchain.com/langsmith/openapi-security",
|
||||
"/concepts/assistants": "https://docs.langchain.com/langsmith/assistants",
|
||||
"/cloud/how-tos/configuration_cloud": "https://docs.langchain.com/langsmith/configuration-cloud",
|
||||
"/cloud/how-tos/use_threads": "https://docs.langchain.com/langsmith/use-threads",
|
||||
"/cloud/how-tos/background_run": "https://docs.langchain.com/langsmith/background-run",
|
||||
"/cloud/how-tos/same-thread": "https://docs.langchain.com/langsmith/same-thread",
|
||||
"/cloud/how-tos/stateless_runs": "https://docs.langchain.com/langsmith/stateless-runs",
|
||||
"/cloud/how-tos/configurable_headers": "https://docs.langchain.com/langsmith/configurable-headers",
|
||||
"/concepts/double_texting": "https://docs.langchain.com/langsmith/double-texting",
|
||||
"/cloud/how-tos/interrupt_concurrent": "https://docs.langchain.com/langsmith/interrupt-concurrent",
|
||||
"/cloud/how-tos/rollback_concurrent": "https://docs.langchain.com/langsmith/rollback-concurrent",
|
||||
"/cloud/how-tos/reject_concurrent": "https://docs.langchain.com/langsmith/reject-concurrent",
|
||||
"/cloud/how-tos/enqueue_concurrent": "https://docs.langchain.com/langsmith/enqueue-concurrent",
|
||||
"/cloud/concepts/webhooks": "https://docs.langchain.com/langsmith/use-webhooks",
|
||||
"/cloud/how-tos/webhooks": "https://docs.langchain.com/langsmith/use-webhooks",
|
||||
"/cloud/concepts/cron_jobs": "https://docs.langchain.com/langsmith/cron-jobs",
|
||||
"/cloud/how-tos/cron_jobs": "https://docs.langchain.com/langsmith/cron-jobs",
|
||||
"/how-tos/http/custom_lifespan": "https://docs.langchain.com/langsmith/custom-lifespan",
|
||||
"/how-tos/http/custom_middleware": "https://docs.langchain.com/langsmith/custom-middleware",
|
||||
"/how-tos/http/custom_routes": "https://docs.langchain.com/langsmith/custom-routes",
|
||||
"/cloud/concepts/data_storage_and_privacy": "https://docs.langchain.com/langsmith/data-storage-and-privacy",
|
||||
"/cloud/deployment/semantic_search": "https://docs.langchain.com/langsmith/semantic-search",
|
||||
"/how-tos/ttl/configure_ttl": "https://docs.langchain.com/langsmith/configure-ttl",
|
||||
"/concepts/deployment_options": "https://docs.langchain.com/langsmith/deployments",
|
||||
"/cloud/quick_start": "https://docs.langchain.com/langsmith/deployment-quickstart",
|
||||
"/cloud/deployment/setup": "https://docs.langchain.com/langsmith/setup-app-requirements-txt",
|
||||
"/cloud/deployment/setup_pyproject": "https://docs.langchain.com/langsmith/setup-pyproject",
|
||||
"/cloud/deployment/setup_javascript": "https://docs.langchain.com/langsmith/setup-javascript",
|
||||
"/cloud/deployment/custom_docker": "https://docs.langchain.com/langsmith/custom-docker",
|
||||
"/cloud/deployment/graph_rebuild": "https://docs.langchain.com/langsmith/graph-rebuild",
|
||||
"/concepts/langgraph_cloud": "https://docs.langchain.com/langsmith/cloud",
|
||||
"/concepts/langgraph_self_hosted_data_plane": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/concepts/langgraph_self_hosted_control_plane": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/concepts/langgraph_standalone_container": "https://docs.langchain.com/langsmith/docker",
|
||||
"/cloud/deployment/cloud": "https://docs.langchain.com/langsmith/cloud",
|
||||
"/cloud/deployment/self_hosted_data_plane": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/cloud/deployment/self_hosted_control_plane": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/cloud/deployment/standalone_container": "https://docs.langchain.com/langsmith/docker",
|
||||
"/concepts/server-mcp": "https://docs.langchain.com/langsmith/server-mcp",
|
||||
"/cloud/how-tos/human_in_the_loop_time_travel": "https://docs.langchain.com/langsmith/human-in-the-loop-time-travel",
|
||||
"/cloud/how-tos/add-human-in-the-loop": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
|
||||
"/cloud/deployment/egress": "https://docs.langchain.com/langsmith/env-var",
|
||||
"/cloud/how-tos/streaming": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/reference/api/api_ref": "https://docs.langchain.com/langsmith/server-api-ref",
|
||||
"/cloud/reference/langgraph_server_changelog": "https://docs.langchain.com/langsmith/agent-server-changelog",
|
||||
"/cloud/reference/api/api_ref_control_plane": "https://docs.langchain.com/langsmith/api-ref-control-plane",
|
||||
"/cloud/reference/cli": "https://docs.langchain.com/langsmith/cli",
|
||||
"/cloud/reference/env_var": "https://docs.langchain.com/langsmith/env-var",
|
||||
"/troubleshooting/studio": "https://docs.langchain.com/langsmith/troubleshooting-studio",
|
||||
"/index": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/agents/agents": "https://docs.langchain.com/oss/python/langchain/agents",
|
||||
"/concepts/why-langgraph": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/get-started/1-build-basic-chatbot": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/2-add-tools": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/3-add-memory": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/4-human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/5-customize-state": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/6-time-travel": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/langsmith/local-server": "https://docs.langchain.com/oss/python/langgraph/local-server",
|
||||
"/tutorials/workflows": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/tutorials/plan-and-execute/plan-and-execute": "https://docs.langchain.com/oss/python/langchain/middleware/built-in#to-do-list",
|
||||
"/tutorials/langgraph-platform/local-server/local-server": "https://docs.langchain.com/langsmith/local-server",
|
||||
"/concepts/agentic_concepts": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/agents/overview": "https://docs.langchain.com/oss/python/langchain/agents",
|
||||
"/agents/run_agents": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/concepts/low_level": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/graph-api": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/react-agent-from-scratch": "https://docs.langchain.com/oss/python/langchain/quickstart",
|
||||
"/concepts/functional_api": "https://docs.langchain.com/oss/python/langgraph/functional-api",
|
||||
"/how-tos/use-functional-api": "https://docs.langchain.com/oss/python/langgraph/functional-api",
|
||||
"/concepts/pregel": "https://docs.langchain.com/oss/python/langgraph/pregel",
|
||||
"/concepts/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/concepts/persistence": "https://docs.langchain.com/oss/python/langgraph/persistence",
|
||||
"/concepts/durable_execution": "https://docs.langchain.com/oss/python/langgraph/durable-execution",
|
||||
"/concepts/memory": "https://docs.langchain.com/oss/python/langgraph/memory",
|
||||
"/how-tos/memory/add-memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/agents/context": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/agents/models": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/tool-calling": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/concepts/human_in_the_loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/how-tos/human_in_the_loop/add-human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/concepts/time-travel": "https://docs.langchain.com/oss/python/langgraph/persistence",
|
||||
"/how-tos/human_in_the_loop/time-travel": "https://docs.langchain.com/oss/python/langgraph/use-time-travel",
|
||||
"/concepts/subgraphs": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs",
|
||||
"/how-tos/subgraph": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs",
|
||||
"/concepts/multi_agent": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/agents/multi-agent": "https://docs.langchain.com/oss/python/langchain/multi-agent",
|
||||
"/how-tos/multi_agent": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/concepts/mcp": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/agents/mcp": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/tracing": "https://docs.langchain.com/oss/python/langgraph/observability",
|
||||
"/how-tos/enable-tracing": "https://docs.langchain.com/oss/python/langgraph/observability",
|
||||
"/agents/evals": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/template_applications": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/rag/langgraph_agentic_rag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/multi_agent/agent_supervisor": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/tutorials/sql/sql-agent": "https://docs.langchain.com/oss/python/langgraph/sql-agent",
|
||||
"/agents/ui": "https://docs.langchain.com/oss/python/langgraph/ui",
|
||||
"/how-tos/run-id-langsmith": "https://docs.langchain.com/oss/python/langgraph/observability",
|
||||
"/troubleshooting/errors/index": "https://docs.langchain.com/oss/python/langgraph/common-errors",
|
||||
"/troubleshooting/errors/INVALID_CHAT_HISTORY": "https://docs.langchain.com/oss/python/langgraph/INVALID_CHAT_HISTORY",
|
||||
"/troubleshooting/errors/INVALID_LICENSE": "https://docs.langchain.com/oss/python/langgraph/common-errors",
|
||||
"/adopters": "https://docs.langchain.com/oss/python/langgraph/case-studies",
|
||||
"/concepts/faq": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/agents/prebuilt": "https://docs.langchain.com/oss/python/langchain/agents",
|
||||
"/reference/index": "https://reference.langchain.com/python/langgraph/",
|
||||
"/reference/graphs": "https://reference.langchain.com/python/langgraph/graphs/",
|
||||
"/reference/func": "https://reference.langchain.com/python/langgraph/func/",
|
||||
"/reference/pregel": "https://reference.langchain.com/python/langgraph/pregel/",
|
||||
"/reference/checkpoints": "https://reference.langchain.com/python/langgraph/checkpoints/",
|
||||
"/reference/store": "https://reference.langchain.com/python/langgraph/store/",
|
||||
"/reference/cache": "https://reference.langchain.com/python/langgraph/cache/",
|
||||
"/reference/types": "https://reference.langchain.com/python/langgraph/types/",
|
||||
"/reference/runtime": "https://reference.langchain.com/python/langgraph/runtime/",
|
||||
"/reference/config": "https://reference.langchain.com/python/langgraph/config/",
|
||||
"/reference/errors": "https://reference.langchain.com/python/langgraph/errors/",
|
||||
"/reference/constants": "https://reference.langchain.com/python/langgraph/constants/",
|
||||
"/reference/channels": "https://reference.langchain.com/python/langgraph/channels/",
|
||||
"/reference/agents": "https://reference.langchain.com/python/langgraph/agents/",
|
||||
"/reference/supervisor": "https://reference.langchain.com/python/langgraph/supervisor/",
|
||||
"/reference/swarm": "https://reference.langchain.com/python/langgraph/swarm/",
|
||||
"/reference/mcp": "https://reference.langchain.com/python/langgraph/mcp/",
|
||||
"/cloud/reference/sdk/python_sdk_ref": "https://reference.langchain.com/python/langsmith/deployment/sdk/",
|
||||
"/reference/remote_graph": "https://reference.langchain.com/python/langsmith/deployment/remote_graph/",
|
||||
"/additional-resources/index": "https://docs.langchain.com/oss/python/langchain/overview",
|
||||
"/cloud/reference/sdk/js_ts_sdk_ref": "https://reference.langchain.com/javascript/modules/langsmith.html",
|
||||
"/snippets/chat_model_tabs": "https://docs.langchain.com/oss/python/langchain/overview",
|
||||
"/troubleshooting/errors/GRAPH_RECURSION_LIMIT": "https://docs.langchain.com/oss/python/langgraph/GRAPH_RECURSION_LIMIT",
|
||||
"/troubleshooting/errors/INVALID_CONCURRENT_GRAPH_UPDATE": "https://docs.langchain.com/oss/python/langgraph/INVALID_CONCURRENT_GRAPH_UPDATE",
|
||||
"/troubleshooting/errors/INVALID_GRAPH_NODE_RETURN_VALUE": "https://docs.langchain.com/oss/python/langgraph/INVALID_GRAPH_NODE_RETURN_VALUE",
|
||||
"/troubleshooting/errors/MULTIPLE_SUBGRAPHS": "https://docs.langchain.com/oss/python/langgraph/MULTIPLE_SUBGRAPHS",
|
||||
"/tutorials/rag/langgraph_self_rag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/additional-resources": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/examples": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/guides": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/how-tos/autogen-integration-functional": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/how-tos/cross-thread-persistence-functional": "https://docs.langchain.com/oss/python/langgraph/add-memory#add-long-term-memory",
|
||||
"/how-tos/disable-streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/memory/semantic-search": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/multi-agent-multi-turn-convo-functional": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/multi-agent-network-functional": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/persistence-functional": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/react-agent-from-scratch-functional": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/reference": "https://reference.langchain.com/python/langgraph/",
|
||||
"/troubleshooting/errors": "https://docs.langchain.com/oss/python/langgraph/common-errors",
|
||||
"/tutorials/chatbot-simulation-evaluation/agent-simulation-evaluation": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/chatbot-simulation-evaluation/langsmith-agent-simulation-evaluation": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/chatbots/information-gather-prompting": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/extraction/retries": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/langgraph-platform/local-server": "https://docs.langchain.com/langsmith/agent-server",
|
||||
"/tutorials/lats/lats": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/llm-compiler/LLMCompiler": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/rag/langgraph_adaptive_rag_local": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/rag/langgraph_crag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/rag/langgraph_crag_local": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/rag/langgraph_self_rag_local": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/reflection/reflection": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/reflexion/reflexion": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/rewoo/rewoo": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/self-discover/self-discover": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/tnt-llm/tnt-llm": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/tot/tot": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/usaco/usaco": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/web-navigation/web_voyager": "https://docs.langchain.com/oss/python/langgraph/overview"
|
||||
}
|
||||
@@ -57,3 +57,9 @@ lint.select = [
|
||||
]
|
||||
lint.ignore = ["E501", "B008"]
|
||||
target-version = "py310"
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "testpypi"
|
||||
url = "https://test.pypi.org/simple/"
|
||||
publish-url = "https://test.pypi.org/legacy/"
|
||||
explicit = true
|
||||
|
||||
@@ -13,7 +13,7 @@ license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
dependencies = [
|
||||
"langgraph-checkpoint>=2.1.2,<5.0.0",
|
||||
"orjson>=3.10.1",
|
||||
"orjson>=3.11.5",
|
||||
"psycopg>=3.2.0",
|
||||
"psycopg-pool>=3.2.0",
|
||||
]
|
||||
|
||||
Generated
+1
-1
@@ -346,7 +346,7 @@ test = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "orjson", specifier = ">=3.10.1" },
|
||||
{ name = "orjson", specifier = ">=3.11.5" },
|
||||
{ name = "psycopg", specifier = ">=3.2.0" },
|
||||
{ name = "psycopg-pool", specifier = ">=3.2.0" },
|
||||
]
|
||||
|
||||
Generated
+6
-6
@@ -267,7 +267,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.12"
|
||||
version = "1.2.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -279,9 +279,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/1d/08e935d1532fcc90981f6e5bb6825914c9227ea7a962c62b1e18619b49e7/langchain_core-1.2.12.tar.gz", hash = "sha256:4d7fa6643d7ab06fc1905a9b7dcbe96a6f3c181046b56edf9c0c17ecd412d9e9", size = 831329, upload-time = "2026-02-12T20:53:15.01Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/bb/c501ca60556c11ac80d1454bdcac63cb33583ce4e64fc4535ad5a7d5c6ba/langchain_core-1.2.13.tar.gz", hash = "sha256:d2773d0d0130a356378db9a858cfeef64c3d64bc03722f1d4d6c40eb46fdf01b", size = 831612, upload-time = "2026-02-15T07:45:57.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/a5/678ab0e5cc57794f20ae5ed12c1442506ef1108c9434f950aebc6044e5a3/langchain_core-1.2.12-py3-none-any.whl", hash = "sha256:66ca17a2a9cb007ab29021968e6adfcf4228067151dc2bd6ebfff265ffaf92f5", size = 500132, upload-time = "2026-02-12T20:53:13.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/ab/60fd69e5d55f67d422baefddaaca523c42cd7510ab6aeb17db6ae57fb107/langchain_core-1.2.13-py3-none-any.whl", hash = "sha256:b31823e28d3eff1e237096d0bd3bf80c6f9624eb471a9496dbfbd427779f8d82", size = 500485, upload-time = "2026-02-15T07:45:55.422Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1198,14 +1198,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "7.1.1"
|
||||
version = "7.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/80/2971931d27651affa88a44c0ad7b8c4a19dc29c998abb20b23868d319b59/redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43", size = 4800064, upload-time = "2026-02-09T18:39:40.292Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/29/55/1de1d812ba1481fa4b37fb03b4eec0fcb71b6a0d44c04ea3482eb017600f/redis-7.1.1-py3-none-any.whl", hash = "sha256:f77817f16071c2950492c67d40b771fa493eb3fccc630a424a10976dbb794b7a", size = 356057, upload-time = "2026-02-09T18:39:38.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,10 +1 @@
|
||||
.langgraph_api/
|
||||
npm/
|
||||
|
||||
# Rust build artifacts
|
||||
/target/
|
||||
/dist/
|
||||
|
||||
# npm platform binaries (added at build time, not checked in)
|
||||
npm/*/bin/langgraph
|
||||
npm/*/bin/langgraph.exe
|
||||
|
||||
Generated
-2521
File diff suppressed because it is too large
Load Diff
@@ -1,37 +0,0 @@
|
||||
[package]
|
||||
name = "langgraph-cli"
|
||||
version = "0.2.10"
|
||||
edition = "2021"
|
||||
description = "Native CLI for LangGraph"
|
||||
license = "MIT"
|
||||
|
||||
[[bin]]
|
||||
name = "langgraph"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1" }
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "blocking"], default-features = false }
|
||||
zip = "2"
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
open = "5"
|
||||
indicatif = "0.17"
|
||||
console = "0.15"
|
||||
dotenvy = "0.15"
|
||||
tempfile = "3"
|
||||
thiserror = "2"
|
||||
dialoguer = "0.11"
|
||||
regex = "1"
|
||||
which = "7"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
+5
-52
@@ -1,27 +1,7 @@
|
||||
.PHONY: test lint format test-integration update-schema build build-release clean cargo-test cargo-lint
|
||||
.PHONY: test lint format test-integration update-schema bump-version
|
||||
|
||||
######################
|
||||
# RUST BUILD
|
||||
######################
|
||||
|
||||
build:
|
||||
cargo build
|
||||
|
||||
build-release:
|
||||
cargo build --release
|
||||
|
||||
cargo-test:
|
||||
cargo test
|
||||
|
||||
cargo-lint:
|
||||
cargo fmt --check
|
||||
cargo clippy -- -D warnings
|
||||
|
||||
cargo-fmt:
|
||||
cargo fmt
|
||||
|
||||
######################
|
||||
# PYTHON TESTING AND COVERAGE
|
||||
# TESTING AND COVERAGE
|
||||
######################
|
||||
|
||||
TEST?= "tests/unit_tests"
|
||||
@@ -31,7 +11,7 @@ test-integration:
|
||||
uv run pytest tests/integration_tests
|
||||
|
||||
######################
|
||||
# PYTHON LINTING AND FORMATTING
|
||||
# LINTING AND FORMATTING
|
||||
######################
|
||||
|
||||
# Define a variable for Python and notebook files.
|
||||
@@ -53,35 +33,8 @@ format format_diff:
|
||||
uv run ruff format $(PYTHON_FILES)
|
||||
uv run ruff check --select I --fix $(PYTHON_FILES)
|
||||
|
||||
######################
|
||||
# PACKAGING
|
||||
######################
|
||||
|
||||
# Build maturin wheel for current platform (development)
|
||||
wheel-dev:
|
||||
maturin build --release
|
||||
|
||||
# Build maturin wheel for specific target
|
||||
# Usage: make wheel TARGET=x86_64-unknown-linux-gnu
|
||||
TARGET?=
|
||||
wheel:
|
||||
maturin build --release $(if $(TARGET),--target $(TARGET),)
|
||||
|
||||
# Build sdist
|
||||
sdist:
|
||||
maturin sdist
|
||||
|
||||
######################
|
||||
# SCHEMA
|
||||
######################
|
||||
|
||||
update-schema:
|
||||
uv run python generate_schema.py
|
||||
|
||||
######################
|
||||
# CLEANUP
|
||||
######################
|
||||
|
||||
clean:
|
||||
cargo clean
|
||||
rm -rf dist/ target/wheels/
|
||||
bump-version:
|
||||
uv run hatch version patch
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
// Cargo already sets CARGO_PKG_VERSION, nothing to do
|
||||
}
|
||||
@@ -29,8 +29,8 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tsconfig/recommended": "^1.0.13",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.55.0",
|
||||
"@typescript-eslint/parser": "^8.55.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
||||
"@typescript-eslint/parser": "^8.56.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
|
||||
@@ -1079,101 +1079,101 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@^8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz#086d2ef661507b561f7b17f62d3179d692a0765f"
|
||||
integrity sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==
|
||||
"@typescript-eslint/eslint-plugin@^8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz#5aec3db807a6b8437ea5d5ebf7bd16b4119aba8d"
|
||||
integrity sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.55.0"
|
||||
"@typescript-eslint/type-utils" "8.55.0"
|
||||
"@typescript-eslint/utils" "8.55.0"
|
||||
"@typescript-eslint/visitor-keys" "8.55.0"
|
||||
"@typescript-eslint/scope-manager" "8.56.0"
|
||||
"@typescript-eslint/type-utils" "8.56.0"
|
||||
"@typescript-eslint/utils" "8.56.0"
|
||||
"@typescript-eslint/visitor-keys" "8.56.0"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.4.0"
|
||||
|
||||
"@typescript-eslint/parser@^8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.55.0.tgz#6eace4e9e95f178d3447ed1f17f3d6a5dfdb345c"
|
||||
integrity sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==
|
||||
"@typescript-eslint/parser@^8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.56.0.tgz#8ecff1678b8b1a742d29c446ccf5eeea7f971d72"
|
||||
integrity sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.55.0"
|
||||
"@typescript-eslint/types" "8.55.0"
|
||||
"@typescript-eslint/typescript-estree" "8.55.0"
|
||||
"@typescript-eslint/visitor-keys" "8.55.0"
|
||||
"@typescript-eslint/scope-manager" "8.56.0"
|
||||
"@typescript-eslint/types" "8.56.0"
|
||||
"@typescript-eslint/typescript-estree" "8.56.0"
|
||||
"@typescript-eslint/visitor-keys" "8.56.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.55.0.tgz#b8a71c06a625bdad481c24d5614b68e252f3ae9b"
|
||||
integrity sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==
|
||||
"@typescript-eslint/project-service@8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.56.0.tgz#bb8562fecd8f7922e676fc6a1189c20dd7991d73"
|
||||
integrity sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.55.0"
|
||||
"@typescript-eslint/types" "^8.55.0"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.56.0"
|
||||
"@typescript-eslint/types" "^8.56.0"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz#8a0752c31c788651840dc98f840b0c2ebe143b8c"
|
||||
integrity sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==
|
||||
"@typescript-eslint/scope-manager@8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz#604030a4c6433df3728effdd441d47f45a86edb4"
|
||||
integrity sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.55.0"
|
||||
"@typescript-eslint/visitor-keys" "8.55.0"
|
||||
"@typescript-eslint/types" "8.56.0"
|
||||
"@typescript-eslint/visitor-keys" "8.56.0"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.55.0", "@typescript-eslint/tsconfig-utils@^8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz#62f1d005419985e09d37a040b2f1450e4e805afa"
|
||||
integrity sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==
|
||||
"@typescript-eslint/tsconfig-utils@8.56.0", "@typescript-eslint/tsconfig-utils@^8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz#2538ce83cbc376e685487960cbb24b65fe2abc4e"
|
||||
integrity sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==
|
||||
|
||||
"@typescript-eslint/type-utils@8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz#195d854b3e56308ce475fdea2165313bb1190200"
|
||||
integrity sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==
|
||||
"@typescript-eslint/type-utils@8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz#72b4edc1fc73988998f1632b3ec99c2a66eaac6e"
|
||||
integrity sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.55.0"
|
||||
"@typescript-eslint/typescript-estree" "8.55.0"
|
||||
"@typescript-eslint/utils" "8.55.0"
|
||||
"@typescript-eslint/types" "8.56.0"
|
||||
"@typescript-eslint/typescript-estree" "8.56.0"
|
||||
"@typescript-eslint/utils" "8.56.0"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.4.0"
|
||||
|
||||
"@typescript-eslint/types@8.55.0", "@typescript-eslint/types@^8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.55.0.tgz#8449c5a7adac61184cac92dbf6315733569708c2"
|
||||
integrity sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==
|
||||
"@typescript-eslint/types@8.56.0", "@typescript-eslint/types@^8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.56.0.tgz#a2444011b9a98ca13d70411d2cbfed5443b3526a"
|
||||
integrity sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz#c83ac92c11ce79bedd984937c7780a65e7f7b2e3"
|
||||
integrity sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==
|
||||
"@typescript-eslint/typescript-estree@8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz#fadbc74c14c5bac947db04980ff58bb178701c2e"
|
||||
integrity sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.55.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.55.0"
|
||||
"@typescript-eslint/types" "8.55.0"
|
||||
"@typescript-eslint/visitor-keys" "8.55.0"
|
||||
"@typescript-eslint/project-service" "8.56.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.56.0"
|
||||
"@typescript-eslint/types" "8.56.0"
|
||||
"@typescript-eslint/visitor-keys" "8.56.0"
|
||||
debug "^4.4.3"
|
||||
minimatch "^9.0.5"
|
||||
semver "^7.7.3"
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.4.0"
|
||||
|
||||
"@typescript-eslint/utils@8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.55.0.tgz#c1744d94a3901deb01f58b09d3478d811f96d619"
|
||||
integrity sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==
|
||||
"@typescript-eslint/utils@8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.56.0.tgz#063ce6f702ec603de1b83ee795ed5e877d6f7841"
|
||||
integrity sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.55.0"
|
||||
"@typescript-eslint/types" "8.55.0"
|
||||
"@typescript-eslint/typescript-estree" "8.55.0"
|
||||
"@typescript-eslint/scope-manager" "8.56.0"
|
||||
"@typescript-eslint/types" "8.56.0"
|
||||
"@typescript-eslint/typescript-estree" "8.56.0"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.55.0":
|
||||
version "8.55.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz#3d9a40fd4e3705c63d8fae3af58988add3ed464d"
|
||||
integrity sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==
|
||||
"@typescript-eslint/visitor-keys@8.56.0":
|
||||
version "8.56.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz#7d6592ab001827d3ce052155edf7ecad19688d7d"
|
||||
integrity sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.55.0"
|
||||
eslint-visitor-keys "^4.2.1"
|
||||
"@typescript-eslint/types" "8.56.0"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@ungap/structured-clone@^1.3.0":
|
||||
version "1.3.0"
|
||||
@@ -2241,7 +2241,7 @@ eslint-visitor-keys@^3.4.3:
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
|
||||
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
|
||||
|
||||
eslint-visitor-keys@^4.0.0, eslint-visitor-keys@^4.2.1:
|
||||
eslint-visitor-keys@^4.0.0:
|
||||
version "4.2.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
|
||||
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
|
||||
@@ -3533,9 +3533,9 @@ js-tokens@^4.0.0:
|
||||
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
||||
|
||||
js-yaml@^3.13.1:
|
||||
version "3.14.1"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537"
|
||||
integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==
|
||||
version "3.14.2"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.2.tgz#77485ce1dd7f33c061fd1b16ecea23b55fcb04b0"
|
||||
integrity sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==
|
||||
dependencies:
|
||||
argparse "^1.0.7"
|
||||
esprima "^4.0.0"
|
||||
|
||||
@@ -678,6 +678,51 @@ def _update_encryption_path(
|
||||
)
|
||||
|
||||
|
||||
def _update_checkpointer_path(
|
||||
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
|
||||
) -> None:
|
||||
"""Update checkpointer.path to use Docker container paths."""
|
||||
checkpointer_conf = config.get("checkpointer")
|
||||
if not checkpointer_conf or not isinstance(checkpointer_conf, dict):
|
||||
return
|
||||
if not (path_str := checkpointer_conf.get("path")):
|
||||
return
|
||||
|
||||
module_str, sep, attr_str = path_str.partition(":")
|
||||
if not sep or not module_str.startswith("."):
|
||||
return # Already validated or absolute path
|
||||
|
||||
resolved = config_path.parent / module_str
|
||||
if not resolved.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Checkpointer file not found: {resolved} (from {path_str})"
|
||||
)
|
||||
if not resolved.is_file():
|
||||
raise IsADirectoryError(f"Checkpointer path must be a file: {resolved}")
|
||||
|
||||
# Check faux packages first (higher priority)
|
||||
for faux_path, (_, destpath) in local_deps.faux_pkgs.items():
|
||||
if resolved.is_relative_to(faux_path):
|
||||
new_path = f"{destpath}/{resolved.relative_to(faux_path)}:{attr_str}"
|
||||
checkpointer_conf["path"] = new_path
|
||||
return
|
||||
|
||||
# Check real packages
|
||||
for real_path in local_deps.real_pkgs:
|
||||
if resolved.is_relative_to(real_path):
|
||||
new_path = (
|
||||
f"/deps/{real_path.name}/{resolved.relative_to(real_path)}:{attr_str}"
|
||||
)
|
||||
checkpointer_conf["path"] = new_path
|
||||
return
|
||||
|
||||
raise ValueError(
|
||||
f"Checkpointer file '{resolved}' not covered by dependencies.\n"
|
||||
"Add its parent directory to the 'dependencies' array in your config.\n"
|
||||
f"Current dependencies: {config['dependencies']}"
|
||||
)
|
||||
|
||||
|
||||
def _update_http_app_path(
|
||||
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
|
||||
) -> None:
|
||||
@@ -877,6 +922,8 @@ def python_config_to_docker(
|
||||
_update_auth_path(config_path, config, local_deps)
|
||||
# Rewrite encryption path, so it points to the correct location in the Docker container
|
||||
_update_encryption_path(config_path, config, local_deps)
|
||||
# Rewrite checkpointer path, so it points to the correct location in the Docker container
|
||||
_update_checkpointer_path(config_path, config, local_deps)
|
||||
# Rewrite HTTP app path, so it points to the correct location in the Docker container
|
||||
_update_http_app_path(config_path, config, local_deps)
|
||||
|
||||
|
||||
@@ -167,6 +167,26 @@ class CheckpointerConfig(TypedDict, total=False):
|
||||
If omitted, no checkpointer is set up (the object store will still be present, however).
|
||||
"""
|
||||
|
||||
path: str
|
||||
"""Import path to an async context manager that yields a `BaseCheckpointSaver`
|
||||
instance.
|
||||
|
||||
The referenced object should be an `@asynccontextmanager`-decorated function
|
||||
so that the server can properly manage the checkpointer's lifecycle (e.g.
|
||||
opening and closing connections).
|
||||
|
||||
Examples:
|
||||
- "./my_checkpointer.py:create_checkpointer"
|
||||
- "my_package.checkpointer:create_checkpointer"
|
||||
|
||||
When provided, this replaces the default checkpointer.
|
||||
|
||||
You can use the `langgraph-checkpoint-conformance` package
|
||||
(https://pypi.org/project/langgraph-checkpoint-conformance/) to run simple
|
||||
conformance tests against your custom checkpointer and catch
|
||||
incompatibilities early.
|
||||
"""
|
||||
|
||||
ttl: ThreadTTLConfig | None
|
||||
"""Optional. Defines the TTL (time-to-live) behavior configuration.
|
||||
|
||||
|
||||
+15
-8
@@ -1,6 +1,6 @@
|
||||
[build-system]
|
||||
requires = ["maturin>=1.0,<2.0"]
|
||||
build-backend = "maturin"
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-cli"
|
||||
@@ -11,8 +11,12 @@ requires-python = ">=3.10"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
dependencies = []
|
||||
|
||||
dependencies = [
|
||||
"click>=8.1.7",
|
||||
"langgraph-sdk>=0.1.0 ; python_version >= '3.11'",
|
||||
]
|
||||
[tool.hatch.version]
|
||||
path = "langgraph_cli/__init__.py"
|
||||
[project.optional-dependencies]
|
||||
inmem = [
|
||||
"langgraph-api>=0.5.35,<0.8.0 ; python_version >= '3.11'",
|
||||
@@ -26,10 +30,8 @@ Twitter = "https://x.com/LangChain"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
[tool.maturin]
|
||||
bindings = "bin"
|
||||
manifest-path = "Cargo.toml"
|
||||
strip = true
|
||||
[project.scripts]
|
||||
langgraph = "langgraph_cli.cli:cli"
|
||||
|
||||
[dependency-groups]
|
||||
test = [
|
||||
@@ -47,11 +49,15 @@ lint = [
|
||||
dev = [
|
||||
{include-group = "test"},
|
||||
{include-group = "lint"},
|
||||
"hatch>=1.16.2",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
default-groups = ['dev']
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
include = ["langgraph_cli"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--strict-markers --strict-config --durations=5 -vv"
|
||||
asyncio_mode = "auto"
|
||||
@@ -63,6 +69,7 @@ lint.select = [
|
||||
"UP", # pyupgrade
|
||||
"B", # flake8-bugbear
|
||||
"I", # isort
|
||||
"UP", # pyupgrade
|
||||
]
|
||||
lint.ignore = ["E501", "B008"]
|
||||
target-version = "py310"
|
||||
|
||||
@@ -542,6 +542,10 @@
|
||||
"description": "Configuration for the built-in checkpointer, which handles checkpointing of state.\n\nIf omitted, no checkpointer is set up (the object store will still be present, however).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Import path to an async context manager that yields a `BaseCheckpointSaver`\ninstance.\n\nThe referenced object should be an `@asynccontextmanager`-decorated function\nso that the server can properly manage the checkpointer's lifecycle (e.g.\nopening and closing connections).\n"
|
||||
},
|
||||
"serde": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -542,6 +542,10 @@
|
||||
"description": "Configuration for the built-in checkpointer, which handles checkpointing of state.\n\nIf omitted, no checkpointer is set up (the object store will still be present, however).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Import path to an async context manager that yields a `BaseCheckpointSaver`\ninstance.\n\nThe referenced object should be an `@asynccontextmanager`-decorated function\nso that the server can properly manage the checkpointer's lifecycle (e.g.\nopening and closing connections).\n"
|
||||
},
|
||||
"serde": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bumps the version across Cargo.toml and all npm package.json files.
|
||||
# Usage: ./scripts/bump-version.sh 0.3.0
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -ne 1 ]; then
|
||||
echo "Usage: $0 <version>"
|
||||
echo "Example: $0 0.3.0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$1"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CLI_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
echo "Bumping version to $VERSION"
|
||||
|
||||
# Cargo.toml
|
||||
sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" "$CLI_DIR/Cargo.toml"
|
||||
rm -f "$CLI_DIR/Cargo.toml.bak"
|
||||
echo " Updated Cargo.toml"
|
||||
|
||||
# All npm package.json files
|
||||
for pkg in "$CLI_DIR"/npm/*/package.json; do
|
||||
# Update the package's own version
|
||||
sed -i.bak "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" "$pkg"
|
||||
rm -f "$pkg.bak"
|
||||
|
||||
# Update optionalDependencies versions (main package only)
|
||||
if grep -q "optionalDependencies" "$pkg" 2>/dev/null; then
|
||||
sed -i.bak "s/\"@langchain\/langgraph-cli-\([^\"]*\)\": \"[^\"]*\"/\"@langchain\/langgraph-cli-\1\": \"$VERSION\"/" "$pkg"
|
||||
rm -f "$pkg.bak"
|
||||
fi
|
||||
|
||||
echo " Updated $(basename "$(dirname "$pkg")")/package.json"
|
||||
done
|
||||
|
||||
# Update Cargo.lock
|
||||
cd "$CLI_DIR"
|
||||
cargo update -p langgraph-cli 2>/dev/null || true
|
||||
echo " Updated Cargo.lock"
|
||||
|
||||
echo "Done! Version is now $VERSION"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. git add -A && git commit -m 'chore(cli): bump version to $VERSION'"
|
||||
echo " 2. git tag cli-v$VERSION"
|
||||
echo " 3. git push origin main --tags"
|
||||
@@ -1,50 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::constants::{SUPABASE_PUBLIC_API_KEY, SUPABASE_URL};
|
||||
|
||||
/// Fire-and-forget telemetry: log a CLI command invocation.
|
||||
///
|
||||
/// Spawns a background thread that POSTs anonymized usage data to Supabase.
|
||||
/// Respects the `LANGGRAPH_CLI_NO_ANALYTICS` environment variable -- if set to "1",
|
||||
/// no data is sent.
|
||||
pub fn log_command(command: &str, params: &HashMap<String, String>) {
|
||||
if std::env::var("LANGGRAPH_CLI_NO_ANALYTICS").as_deref() == Ok("1") {
|
||||
return;
|
||||
}
|
||||
|
||||
let os_name = std::env::consts::OS.to_string();
|
||||
let arch = std::env::consts::ARCH.to_string();
|
||||
let cli_version = env!("CARGO_PKG_VERSION").to_string();
|
||||
let command = command.to_string();
|
||||
let params = params.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let data = serde_json::json!({
|
||||
"os": os_name,
|
||||
"os_version": arch,
|
||||
"python_version": "rust",
|
||||
"cli_version": cli_version,
|
||||
"cli_command": command,
|
||||
"params": params,
|
||||
});
|
||||
|
||||
let url = format!("{SUPABASE_URL}/rest/v1/logs");
|
||||
|
||||
// Use a blocking reqwest client in this thread
|
||||
let client = match reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let _ = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("apikey", SUPABASE_PUBLIC_API_KEY)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.body(data.to_string())
|
||||
.send();
|
||||
});
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use console::style;
|
||||
|
||||
use crate::analytics;
|
||||
use crate::config::docker_tag::docker_tag;
|
||||
use crate::config::validate_config_file;
|
||||
use crate::docker::dockerfile::config_to_docker;
|
||||
use crate::exec::{run_command, run_command_streaming};
|
||||
use crate::progress::Progress;
|
||||
use crate::util::warn_non_wolfi_distro;
|
||||
|
||||
/// Build a LangGraph API server Docker image.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run(
|
||||
config: &str,
|
||||
tag: &str,
|
||||
pull: bool,
|
||||
base_image: Option<&str>,
|
||||
api_version: Option<&str>,
|
||||
install_command: Option<&str>,
|
||||
build_command: Option<&str>,
|
||||
docker_build_args: &[String],
|
||||
) -> Result<(), String> {
|
||||
// Fire-and-forget analytics
|
||||
let mut params = HashMap::new();
|
||||
params.insert("pull".to_string(), pull.to_string());
|
||||
analytics::log_command("build", ¶ms);
|
||||
|
||||
// Check docker is available
|
||||
if which::which("docker").is_err() {
|
||||
return Err("Docker not installed".to_string());
|
||||
}
|
||||
|
||||
let progress = Progress::new("Pulling...");
|
||||
|
||||
// Validate config
|
||||
let config_path = Path::new(config);
|
||||
let mut config_json = validate_config_file(config_path)?;
|
||||
let config_value = serde_json::to_value(&config_json).unwrap();
|
||||
warn_non_wolfi_distro(&config_value);
|
||||
|
||||
// Pull latest images if requested
|
||||
if pull {
|
||||
let image_tag = docker_tag(&config_json, base_image, api_version);
|
||||
run_command("docker", &["pull", &image_tag], None, true)?;
|
||||
}
|
||||
|
||||
progress.set_message("Building...");
|
||||
|
||||
// Determine build context
|
||||
let is_js_project =
|
||||
config_json.node_version.is_some() && config_json.python_version.is_none();
|
||||
|
||||
// For JS projects with install/build commands, use CWD; otherwise use config parent
|
||||
let build_context = if is_js_project && (build_command.is_some() || install_command.is_some()) {
|
||||
std::env::current_dir()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| ".".to_string())
|
||||
} else {
|
||||
config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
// Generate Dockerfile
|
||||
let (dockerfile_content, additional_contexts) = config_to_docker(
|
||||
config_path,
|
||||
&mut config_json,
|
||||
base_image,
|
||||
api_version,
|
||||
install_command,
|
||||
build_command,
|
||||
Some(&build_context),
|
||||
false, // no variable escaping for docker build
|
||||
)?;
|
||||
|
||||
// Build docker build args
|
||||
let mut args: Vec<String> = vec![
|
||||
"build".to_string(),
|
||||
"-f".to_string(),
|
||||
"-".to_string(), // Dockerfile from stdin
|
||||
"-t".to_string(),
|
||||
tag.to_string(),
|
||||
];
|
||||
|
||||
// Add additional build contexts
|
||||
for (name, path) in &additional_contexts {
|
||||
args.push("--build-context".to_string());
|
||||
args.push(format!("{name}={path}"));
|
||||
}
|
||||
|
||||
// Add passthrough docker build args
|
||||
for extra in docker_build_args {
|
||||
args.push(extra.clone());
|
||||
}
|
||||
|
||||
// Add build context as last arg
|
||||
args.push(build_context);
|
||||
|
||||
progress.finish();
|
||||
|
||||
// Run docker build with streaming output
|
||||
let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
run_command_streaming("docker", &args_refs, Some(&dockerfile_content), true)?;
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!("Successfully built image: {tag}")).green()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use console::style;
|
||||
|
||||
use crate::analytics;
|
||||
use crate::config::validate_config_file;
|
||||
|
||||
/// Find the Python interpreter, preferring python3 over python.
|
||||
fn find_python() -> Result<String, String> {
|
||||
for candidate in &["python3", "python"] {
|
||||
if which::which(candidate).is_ok() {
|
||||
return Ok(candidate.to_string());
|
||||
}
|
||||
}
|
||||
Err(
|
||||
"Python not found. The `langgraph dev` command requires Python >= 3.11 with \
|
||||
langgraph-cli[inmem] installed.\n\
|
||||
Install with: pip install -U \"langgraph-cli[inmem]\""
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Run the LangGraph API server in development mode (in-memory, via Python subprocess).
|
||||
///
|
||||
/// This passes the config as a JSON object via stdin to a Python bootstrap script,
|
||||
/// avoiding any string interpolation into Python source code.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run(
|
||||
host: &str,
|
||||
port: u16,
|
||||
no_reload: bool,
|
||||
config: &str,
|
||||
n_jobs_per_worker: Option<u32>,
|
||||
no_browser: bool,
|
||||
debug_port: Option<u16>,
|
||||
wait_for_client: bool,
|
||||
studio_url: Option<&str>,
|
||||
allow_blocking: bool,
|
||||
tunnel: bool,
|
||||
server_log_level: &str,
|
||||
) -> Result<(), String> {
|
||||
// Fire-and-forget analytics
|
||||
let mut params = HashMap::new();
|
||||
params.insert("no_reload".to_string(), no_reload.to_string());
|
||||
params.insert("no_browser".to_string(), no_browser.to_string());
|
||||
params.insert("allow_blocking".to_string(), allow_blocking.to_string());
|
||||
params.insert("tunnel".to_string(), tunnel.to_string());
|
||||
analytics::log_command("dev", ¶ms);
|
||||
|
||||
// Validate config
|
||||
let config_path = Path::new(config);
|
||||
let config_json = validate_config_file(config_path)?;
|
||||
|
||||
// Check for node_version -- in-mem server doesn't support JS graphs
|
||||
if config_json.node_version.is_some() {
|
||||
return Err(
|
||||
"In-mem server for JS graphs is not supported in this version of the LangGraph CLI. \
|
||||
Please use `npx @langchain/langgraph-cli` instead."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let python = find_python()?;
|
||||
|
||||
// Pre-check that langgraph_api is importable
|
||||
let check = Command::new(&python)
|
||||
.args(["-c", "from langgraph_api.cli import run_server"])
|
||||
.output();
|
||||
|
||||
match check {
|
||||
Ok(output) if !output.status.success() => {
|
||||
return Err(
|
||||
"Required package 'langgraph-api' is not installed.\n\
|
||||
Please install it with:\n\n\
|
||||
pip install -U \"langgraph-cli[inmem]\"\n\n\
|
||||
Note: The in-mem server requires Python 3.11 or higher."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(format!(
|
||||
"Failed to run {python}. The `langgraph dev` command requires Python >= 3.11 with \
|
||||
langgraph-cli[inmem] installed.\n\
|
||||
Install with: pip install -U \"langgraph-cli[inmem]\""
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Build a JSON config object to pass via stdin.
|
||||
// This avoids interpolating user data into Python source code.
|
||||
let dev_config = serde_json::json!({
|
||||
"host": host,
|
||||
"port": port,
|
||||
"reload": !no_reload,
|
||||
"open_browser": !no_browser,
|
||||
"wait_for_client": wait_for_client,
|
||||
"allow_blocking": allow_blocking,
|
||||
"tunnel": tunnel,
|
||||
"server_log_level": server_log_level,
|
||||
"dependencies": config_json.dependencies,
|
||||
"graphs": config_json.graphs,
|
||||
"n_jobs_per_worker": n_jobs_per_worker,
|
||||
"debug_port": debug_port,
|
||||
"studio_url": studio_url,
|
||||
"env": config_json.env,
|
||||
"store": config_json.store,
|
||||
"auth": config_json.auth,
|
||||
"http": config_json.http,
|
||||
"ui": config_json.ui,
|
||||
"ui_config": config_json.ui_config,
|
||||
"webhooks": config_json.webhooks,
|
||||
});
|
||||
|
||||
// Python bootstrap: reads JSON from stdin, calls run_server
|
||||
let python_code = r#"
|
||||
import sys, os, json, pathlib
|
||||
config = json.loads(sys.stdin.read())
|
||||
cwd = os.getcwd()
|
||||
sys.path.append(cwd)
|
||||
for dep in config.get('dependencies', []):
|
||||
dep_path = pathlib.Path(cwd) / dep
|
||||
if dep_path.is_dir() and dep_path.exists():
|
||||
sys.path.append(str(dep_path))
|
||||
from langgraph_api.cli import run_server
|
||||
run_server(
|
||||
config['host'],
|
||||
config['port'],
|
||||
config['reload'],
|
||||
config['graphs'],
|
||||
n_jobs_per_worker=config.get('n_jobs_per_worker'),
|
||||
open_browser=config['open_browser'],
|
||||
debug_port=config.get('debug_port'),
|
||||
env=config.get('env'),
|
||||
store=config.get('store'),
|
||||
wait_for_client=config['wait_for_client'],
|
||||
auth=config.get('auth'),
|
||||
http=config.get('http'),
|
||||
ui=config.get('ui'),
|
||||
ui_config=config.get('ui_config'),
|
||||
webhooks=config.get('webhooks'),
|
||||
studio_url=config.get('studio_url'),
|
||||
allow_blocking=config['allow_blocking'],
|
||||
tunnel=config['tunnel'],
|
||||
server_level=config['server_log_level'],
|
||||
)
|
||||
"#;
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style("Starting LangGraph API server in development mode...").green()
|
||||
);
|
||||
|
||||
// Spawn Python subprocess with config on stdin
|
||||
let mut child = Command::new(&python)
|
||||
.arg("-c")
|
||||
.arg(python_code)
|
||||
.current_dir(
|
||||
config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(".")),
|
||||
)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::inherit())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start Python: {e}"))?;
|
||||
|
||||
// Write JSON config to stdin
|
||||
if let Some(ref mut stdin) = child.stdin {
|
||||
use std::io::Write;
|
||||
let json_bytes = dev_config.to_string();
|
||||
stdin
|
||||
.write_all(json_bytes.as_bytes())
|
||||
.map_err(|e| format!("Failed to write config to Python stdin: {e}"))?;
|
||||
}
|
||||
// Drop stdin to signal EOF
|
||||
drop(child.stdin.take());
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|e| format!("Failed to wait for Python: {e}"))?;
|
||||
|
||||
if !status.success() {
|
||||
let code = status.code().unwrap_or(1);
|
||||
if code == 130 {
|
||||
// User interrupted with Ctrl-C
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("Development server exited with code {code}"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use console::style;
|
||||
|
||||
use crate::analytics;
|
||||
use crate::config::validate_config_file;
|
||||
use crate::docker::capabilities::check_capabilities;
|
||||
use crate::docker::compose::compose_as_dict;
|
||||
use crate::docker::compose::dict_to_yaml;
|
||||
use crate::docker::dockerfile::config_to_docker;
|
||||
use crate::util::warn_non_wolfi_distro;
|
||||
|
||||
/// Docker ignore file content.
|
||||
fn get_docker_ignore_content() -> &'static str {
|
||||
"\
|
||||
# Ignore node_modules and other dependency directories
|
||||
node_modules
|
||||
bower_components
|
||||
vendor
|
||||
|
||||
# Ignore logs and temporary files
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
# Ignore .env files and other environment files
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
|
||||
# Ignore git-related files
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Ignore Docker-related files and configs
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
|
||||
# Ignore build and cache directories
|
||||
dist
|
||||
build
|
||||
.cache
|
||||
__pycache__
|
||||
|
||||
# Ignore IDE and editor configurations
|
||||
.vscode
|
||||
.idea
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
.DS_Store # macOS-specific
|
||||
|
||||
# Ignore test and coverage files
|
||||
coverage
|
||||
*.coverage
|
||||
*.test.js
|
||||
*.spec.js
|
||||
tests
|
||||
"
|
||||
}
|
||||
|
||||
/// Generate a Dockerfile for the LangGraph API server.
|
||||
pub fn run(
|
||||
save_path: &str,
|
||||
config: &str,
|
||||
add_docker_compose: bool,
|
||||
base_image: Option<&str>,
|
||||
api_version: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
// Fire-and-forget analytics
|
||||
let mut params = HashMap::new();
|
||||
params.insert(
|
||||
"add_docker_compose".to_string(),
|
||||
add_docker_compose.to_string(),
|
||||
);
|
||||
analytics::log_command("dockerfile", ¶ms);
|
||||
|
||||
let save_path = Path::new(save_path);
|
||||
let abs_save_path = if save_path.is_absolute() {
|
||||
save_path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join(save_path)
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!("Validating configuration at path: {config}")).yellow()
|
||||
);
|
||||
let config_path = Path::new(config);
|
||||
let mut config_json = validate_config_file(config_path)?;
|
||||
let config_value = serde_json::to_value(&config_json).unwrap();
|
||||
warn_non_wolfi_distro(&config_value);
|
||||
eprintln!("{}", style("Configuration validated!").green());
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!(
|
||||
"Generating Dockerfile at {}",
|
||||
abs_save_path.display()
|
||||
))
|
||||
.yellow()
|
||||
);
|
||||
|
||||
let (dockerfile_content, additional_contexts) = config_to_docker(
|
||||
config_path,
|
||||
&mut config_json,
|
||||
base_image,
|
||||
api_version,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)?;
|
||||
|
||||
std::fs::write(&abs_save_path, &dockerfile_content)
|
||||
.map_err(|e| format!("Failed to write Dockerfile: {e}"))?;
|
||||
eprintln!("{}", style("Created: Dockerfile").green());
|
||||
|
||||
if !additional_contexts.is_empty() {
|
||||
let ctx_str: Vec<String> = additional_contexts
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect();
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!(
|
||||
"Run docker build with these additional build contexts `--build-context {}`",
|
||||
ctx_str.join(",")
|
||||
))
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
|
||||
if add_docker_compose {
|
||||
let parent = abs_save_path.parent().unwrap_or(Path::new("."));
|
||||
|
||||
// Write .dockerignore
|
||||
let dockerignore_path = parent.join(".dockerignore");
|
||||
std::fs::write(&dockerignore_path, get_docker_ignore_content())
|
||||
.map_err(|e| format!("Failed to write .dockerignore: {e}"))?;
|
||||
eprintln!("{}", style("Created: .dockerignore").green());
|
||||
|
||||
// Generate docker-compose.yml
|
||||
let capabilities = check_capabilities()?;
|
||||
let mut compose_dict = compose_as_dict(
|
||||
&capabilities,
|
||||
8123,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
base_image,
|
||||
api_version,
|
||||
);
|
||||
|
||||
// Add env_file and build context to langgraph-api service
|
||||
if let Some(crate::docker::compose::YamlValue::Dict(ref mut services)) =
|
||||
compose_dict.get_mut("services")
|
||||
{
|
||||
if let Some(crate::docker::compose::YamlValue::Dict(ref mut api_service)) =
|
||||
services.get_mut("langgraph-api")
|
||||
{
|
||||
api_service.insert(
|
||||
"env_file".to_string(),
|
||||
crate::docker::compose::YamlValue::List(vec![".env".to_string()]),
|
||||
);
|
||||
|
||||
let dockerfile_name = abs_save_path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let mut build_config = indexmap::IndexMap::new();
|
||||
build_config.insert(
|
||||
"context".to_string(),
|
||||
crate::docker::compose::YamlValue::String(".".to_string()),
|
||||
);
|
||||
build_config.insert(
|
||||
"dockerfile".to_string(),
|
||||
crate::docker::compose::YamlValue::String(dockerfile_name),
|
||||
);
|
||||
api_service.insert(
|
||||
"build".to_string(),
|
||||
crate::docker::compose::YamlValue::Dict(build_config),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let compose_yaml = dict_to_yaml(&compose_dict, 0);
|
||||
let compose_path = parent.join("docker-compose.yml");
|
||||
std::fs::write(&compose_path, &compose_yaml)
|
||||
.map_err(|e| format!("Failed to write docker-compose.yml: {e}"))?;
|
||||
eprintln!("{}", style("Created: docker-compose.yml").green());
|
||||
|
||||
// Create .env file if it doesn't exist
|
||||
let env_path = parent.join(".env");
|
||||
if !env_path.exists() {
|
||||
let env_content = "\
|
||||
# Uncomment the following line to add your LangSmith API key
|
||||
# LANGSMITH_API_KEY=your-api-key
|
||||
# Or if you have a LangSmith Deployment license key, then uncomment the following line:
|
||||
# LANGGRAPH_CLOUD_LICENSE_KEY=your-license-key
|
||||
# Add any other environment variables go below...
|
||||
";
|
||||
std::fs::write(&env_path, env_content)
|
||||
.map_err(|e| format!("Failed to write .env: {e}"))?;
|
||||
eprintln!("{}", style("Created: .env").green());
|
||||
} else {
|
||||
eprintln!(
|
||||
"{}",
|
||||
style("Skipped: .env. It already exists!").yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!(
|
||||
"Files generated successfully at path {}!",
|
||||
abs_save_path
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
.display()
|
||||
))
|
||||
.cyan()
|
||||
.bold()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
pub mod build_cmd;
|
||||
pub mod dev;
|
||||
pub mod dockerfile;
|
||||
pub mod new;
|
||||
pub mod up;
|
||||
@@ -1,16 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::analytics;
|
||||
use crate::templates;
|
||||
|
||||
/// Create a new LangGraph project from a template.
|
||||
pub fn run(path: Option<&str>, template: Option<&str>) -> Result<(), String> {
|
||||
// Fire-and-forget analytics
|
||||
let mut params = HashMap::new();
|
||||
if let Some(t) = template {
|
||||
params.insert("template".to_string(), t.to_string());
|
||||
}
|
||||
analytics::log_command("new", ¶ms);
|
||||
|
||||
templates::create_new(path, template)
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use console::style;
|
||||
|
||||
use crate::analytics;
|
||||
use crate::config::docker_tag::{default_base_image, docker_tag};
|
||||
use crate::config::validate_config_file;
|
||||
use crate::docker::capabilities::{check_capabilities, ComposeType};
|
||||
use crate::docker::compose::compose;
|
||||
use crate::docker::dockerfile::config_to_compose;
|
||||
use crate::exec::{run_command, run_command_streaming_with_callback};
|
||||
use crate::progress::Progress;
|
||||
use crate::util::warn_non_wolfi_distro;
|
||||
|
||||
/// Launch LangGraph API server with Docker Compose.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run(
|
||||
config: &str,
|
||||
port: u16,
|
||||
docker_compose: Option<&str>,
|
||||
verbose: bool,
|
||||
watch: bool,
|
||||
recreate: bool,
|
||||
pull: bool,
|
||||
wait: bool,
|
||||
debugger_port: Option<u16>,
|
||||
debugger_base_url: Option<&str>,
|
||||
postgres_uri: Option<&str>,
|
||||
api_version: Option<&str>,
|
||||
image: Option<&str>,
|
||||
base_image: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
// Fire-and-forget analytics
|
||||
let mut params = HashMap::new();
|
||||
params.insert("verbose".to_string(), verbose.to_string());
|
||||
params.insert("watch".to_string(), watch.to_string());
|
||||
params.insert("recreate".to_string(), recreate.to_string());
|
||||
params.insert("pull".to_string(), pull.to_string());
|
||||
params.insert("wait".to_string(), wait.to_string());
|
||||
analytics::log_command("up", ¶ms);
|
||||
|
||||
eprintln!("{}", style("Starting LangGraph API server...").green());
|
||||
eprintln!(
|
||||
"For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment.\n\
|
||||
For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KEY."
|
||||
);
|
||||
|
||||
let progress = Progress::new("Pulling...");
|
||||
|
||||
// Validate config
|
||||
let config_path = Path::new(config);
|
||||
let mut config_json = validate_config_file(config_path)?;
|
||||
let config_value = serde_json::to_value(&config_json).unwrap();
|
||||
warn_non_wolfi_distro(&config_value);
|
||||
|
||||
// Check docker capabilities
|
||||
let capabilities = check_capabilities()?;
|
||||
|
||||
// Pull latest images if requested
|
||||
if pull {
|
||||
let tag = docker_tag(&config_json, base_image, api_version);
|
||||
progress.set_message("Pulling...");
|
||||
run_command("docker", &["pull", &tag], None, verbose)?;
|
||||
}
|
||||
|
||||
// Generate compose YAML
|
||||
let debugger_base_url_resolved = debugger_base_url
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("http://127.0.0.1:{port}"));
|
||||
|
||||
let mut compose_stdin = compose(
|
||||
&capabilities,
|
||||
port,
|
||||
debugger_port,
|
||||
Some(&debugger_base_url_resolved),
|
||||
postgres_uri,
|
||||
image,
|
||||
base_image,
|
||||
api_version,
|
||||
);
|
||||
|
||||
// Append config-to-compose output (build instructions, env, watch sections)
|
||||
let base_img = base_image
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| default_base_image(&config_json));
|
||||
let compose_config = config_to_compose(
|
||||
config_path,
|
||||
&mut config_json,
|
||||
Some(&base_img),
|
||||
api_version,
|
||||
image,
|
||||
watch,
|
||||
)?;
|
||||
compose_stdin.push_str(&compose_config);
|
||||
|
||||
// Build docker compose args
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
args.push("--project-directory".to_string());
|
||||
args.push(
|
||||
config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
if let Some(dc) = docker_compose {
|
||||
args.push("-f".to_string());
|
||||
args.push(dc.to_string());
|
||||
}
|
||||
|
||||
// Read compose from stdin
|
||||
args.push("-f".to_string());
|
||||
args.push("-".to_string());
|
||||
|
||||
// Add up + options
|
||||
args.push("up".to_string());
|
||||
args.push("--remove-orphans".to_string());
|
||||
|
||||
if recreate {
|
||||
args.push("--force-recreate".to_string());
|
||||
args.push("--renew-anon-volumes".to_string());
|
||||
// Try to remove the volume, ignore errors
|
||||
let _ = run_command("docker", &["volume", "rm", "langgraph-data"], None, false);
|
||||
}
|
||||
|
||||
if watch {
|
||||
args.push("--watch".to_string());
|
||||
}
|
||||
|
||||
if wait {
|
||||
args.push("--wait".to_string());
|
||||
} else {
|
||||
args.push("--abort-on-container-exit".to_string());
|
||||
}
|
||||
|
||||
progress.set_message("Building...");
|
||||
|
||||
// Determine compose command
|
||||
let compose_cmd = match capabilities.compose_type {
|
||||
ComposeType::Plugin => vec!["docker", "compose"],
|
||||
ComposeType::Standalone => vec!["docker-compose"],
|
||||
};
|
||||
|
||||
// Build final command args
|
||||
let mut cmd_args: Vec<&str> = Vec::new();
|
||||
if compose_cmd.len() > 1 {
|
||||
// "docker compose ..."
|
||||
cmd_args.extend_from_slice(&compose_cmd[1..]);
|
||||
}
|
||||
for a in &args {
|
||||
cmd_args.push(a.as_str());
|
||||
}
|
||||
|
||||
// Run docker compose with streaming output, intercepting stdout
|
||||
// to detect startup and show Ready! URLs
|
||||
let mut ready_printed = false;
|
||||
let debugger_base_url_query = debugger_base_url
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("http://127.0.0.1:{port}"));
|
||||
|
||||
run_command_streaming_with_callback(
|
||||
compose_cmd[0],
|
||||
&cmd_args,
|
||||
Some(&compose_stdin),
|
||||
verbose,
|
||||
|line| {
|
||||
if !ready_printed {
|
||||
if line.contains("unpacking to docker.io") {
|
||||
progress.set_message("Starting...");
|
||||
} else if line.contains("Application startup complete") {
|
||||
progress.finish();
|
||||
ready_printed = true;
|
||||
|
||||
let debugger_origin = if let Some(dp) = debugger_port {
|
||||
format!("http://localhost:{dp}")
|
||||
} else {
|
||||
"https://smith.langchain.com".to_string()
|
||||
};
|
||||
|
||||
println!(
|
||||
"Ready!\n\
|
||||
- API: http://localhost:{port}\n\
|
||||
- Docs: http://localhost:{port}/docs\n\
|
||||
- LangGraph Studio: {debugger_origin}/studio/?baseUrl={debugger_base_url_query}"
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
)?;
|
||||
|
||||
if !ready_printed {
|
||||
progress.finish();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,458 +0,0 @@
|
||||
use super::schema::Config;
|
||||
use crate::constants::DEFAULT_IMAGE_DISTRO;
|
||||
|
||||
/// Get the default base image for a config.
|
||||
pub fn default_base_image(config: &Config) -> String {
|
||||
if let Some(ref base) = config.base_image {
|
||||
return base.clone();
|
||||
}
|
||||
if config.node_version.is_some() && config.python_version.is_none() {
|
||||
"langchain/langgraphjs-api".to_string()
|
||||
} else {
|
||||
"langchain/langgraph-api".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the Docker image tag string.
|
||||
pub fn docker_tag(
|
||||
config: &Config,
|
||||
base_image: Option<&str>,
|
||||
api_version: Option<&str>,
|
||||
) -> String {
|
||||
let api_version = api_version
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| config.api_version.clone());
|
||||
let base_image = base_image
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| default_base_image(config));
|
||||
|
||||
let image_distro = config
|
||||
.image_distro
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_IMAGE_DISTRO);
|
||||
let distro_tag = if image_distro == DEFAULT_IMAGE_DISTRO {
|
||||
String::new()
|
||||
} else {
|
||||
format!("-{image_distro}")
|
||||
};
|
||||
|
||||
if let Some(ref tag) = config.internal_docker_tag {
|
||||
return format!("{base_image}:{tag}");
|
||||
}
|
||||
|
||||
// Build the standard tag format
|
||||
let (language, version) = if config.node_version.is_some() && config.python_version.is_none() {
|
||||
("node", config.node_version.as_deref().unwrap_or("20"))
|
||||
} else {
|
||||
(
|
||||
"py",
|
||||
config
|
||||
.python_version
|
||||
.as_deref()
|
||||
.unwrap_or(crate::constants::DEFAULT_PYTHON_VERSION),
|
||||
)
|
||||
};
|
||||
|
||||
let version_distro_tag = format!("{version}{distro_tag}");
|
||||
|
||||
if let Some(api_ver) = api_version {
|
||||
format!("{base_image}:{api_ver}-{language}{version_distro_tag}")
|
||||
} else if base_image.contains("/langgraph-server") && !base_image.contains(&version_distro_tag)
|
||||
{
|
||||
format!("{base_image}-{language}{version_distro_tag}")
|
||||
} else {
|
||||
format!("{base_image}:{version_distro_tag}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::schema::Config;
|
||||
use crate::config::validate_config;
|
||||
use serde_json::json;
|
||||
|
||||
fn config_from_json(v: serde_json::Value) -> Config {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_base_image_python() {
|
||||
let config = Config {
|
||||
python_version: Some("3.11".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(default_base_image(&config), "langchain/langgraph-api");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_base_image_node() {
|
||||
let config = Config {
|
||||
node_version: Some("20".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(default_base_image(&config), "langchain/langgraphjs-api");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_basic() {
|
||||
let config = Config {
|
||||
python_version: Some("3.11".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
docker_tag(&config, None, None),
|
||||
"langchain/langgraph-api:3.11"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_with_api_version_basic() {
|
||||
let config = Config {
|
||||
python_version: Some("3.11".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
docker_tag(&config, None, Some("0.2.74")),
|
||||
"langchain/langgraph-api:0.2.74-py3.11"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_wolfi() {
|
||||
let config = Config {
|
||||
python_version: Some("3.12".to_string()),
|
||||
image_distro: Some("wolfi".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
docker_tag(&config, None, None),
|
||||
"langchain/langgraph-api:3.12-wolfi"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_internal() {
|
||||
let config = Config {
|
||||
python_version: Some("3.11".to_string()),
|
||||
internal_docker_tag: Some("custom-tag".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
docker_tag(&config, None, None),
|
||||
"langchain/langgraph-api:custom-tag"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Comprehensive tests ported from Python test_config.py
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_image_distro() {
|
||||
// Test 1: Default distro (debian) - no suffix
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(docker_tag(&cfg, None, None), "langchain/langgraph-api:3.11");
|
||||
|
||||
// Test 2: Explicit debian distro - same as default
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "debian"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(docker_tag(&cfg, None, None), "langchain/langgraph-api:3.11");
|
||||
|
||||
// Test 3: Wolfi distro with python
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
"langchain/langgraph-api:3.11-wolfi"
|
||||
);
|
||||
|
||||
// Test 4: Node.js with default distro
|
||||
let cfg = config_from_json(json!({
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"}
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
"langchain/langgraphjs-api:20"
|
||||
);
|
||||
|
||||
// Test 5: Node.js with wolfi distro
|
||||
let cfg = config_from_json(json!({
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
"langchain/langgraphjs-api:20-wolfi"
|
||||
);
|
||||
|
||||
// Test 6: Custom base image with wolfi
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "wolfi",
|
||||
"base_image": "my-registry/custom-image"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, Some("my-registry/custom-image"), None),
|
||||
"my-registry/custom-image:3.12-wolfi"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_multiplatform_with_distro() {
|
||||
// Test 1: Python + Node with wolfi -> defaults to Python
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"node_version": "20",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"python": "./agent.py:graph", "js": "./agent.js:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
"langchain/langgraph-api:3.11-wolfi"
|
||||
);
|
||||
|
||||
// Test 2: Node-only with wolfi
|
||||
let cfg = config_from_json(json!({
|
||||
"node_version": "20",
|
||||
"graphs": {"js": "./agent.js:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
"langchain/langgraphjs-api:20-wolfi"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_different_python_versions_with_distro() {
|
||||
for (ver, expected) in &[
|
||||
("3.11", "langchain/langgraph-api:3.11-wolfi"),
|
||||
("3.12", "langchain/langgraph-api:3.12-wolfi"),
|
||||
("3.13", "langchain/langgraph-api:3.13-wolfi"),
|
||||
] {
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": ver,
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
*expected,
|
||||
"Failed for Python {ver}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_different_node_versions_with_distro() {
|
||||
for (ver, expected) in &[
|
||||
("20", "langchain/langgraphjs-api:20-wolfi"),
|
||||
("21", "langchain/langgraphjs-api:21-wolfi"),
|
||||
("22", "langchain/langgraphjs-api:22-wolfi"),
|
||||
] {
|
||||
let cfg = config_from_json(json!({
|
||||
"node_version": ver,
|
||||
"graphs": {"agent": "./agent.js:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
*expected,
|
||||
"Failed for Node.js {ver}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to run a single api_version test scenario with both in_config and as-argument modes.
|
||||
fn run_api_version_test(
|
||||
base_json: serde_json::Value,
|
||||
api_version: &str,
|
||||
base_image_arg: Option<&str>,
|
||||
expected: &str,
|
||||
) {
|
||||
// Mode 1: api_version passed as function argument (not in config)
|
||||
{
|
||||
let cfg = config_from_json(base_json.clone());
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, base_image_arg, Some(api_version)),
|
||||
expected,
|
||||
"Failed with api_version as argument"
|
||||
);
|
||||
}
|
||||
|
||||
// Mode 2: api_version set in config (not passed as argument)
|
||||
{
|
||||
let mut json_val = base_json.clone();
|
||||
json_val
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("api_version".to_string(), json!(api_version));
|
||||
let cfg = config_from_json(json_val);
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, base_image_arg, None),
|
||||
expected,
|
||||
"Failed with api_version in config"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_with_api_version() {
|
||||
let version = "0.2.74";
|
||||
|
||||
// Test 1: Python config with api_version and default distro
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}),
|
||||
version,
|
||||
None,
|
||||
"langchain/langgraph-api:0.2.74-py3.11",
|
||||
);
|
||||
|
||||
// Test 2: Python config with api_version and wolfi distro
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}),
|
||||
version,
|
||||
None,
|
||||
"langchain/langgraph-api:0.2.74-py3.12-wolfi",
|
||||
);
|
||||
|
||||
// Test 3: Node.js config with api_version and default distro
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"}
|
||||
}),
|
||||
version,
|
||||
None,
|
||||
"langchain/langgraphjs-api:0.2.74-node20",
|
||||
);
|
||||
|
||||
// Test 4: Node.js config with api_version and wolfi distro
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}),
|
||||
version,
|
||||
None,
|
||||
"langchain/langgraphjs-api:0.2.74-node20-wolfi",
|
||||
);
|
||||
|
||||
// Test 5: Custom base image with api_version
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"base_image": "my-registry/custom-image"
|
||||
}),
|
||||
version,
|
||||
Some("my-registry/custom-image"),
|
||||
"my-registry/custom-image:0.2.74-py3.11",
|
||||
);
|
||||
|
||||
// Test 6: Different Python versions with api_version
|
||||
for py_ver in &["3.11", "3.12", "3.13"] {
|
||||
let expected = format!("langchain/langgraph-api:{version}-py{py_ver}");
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"python_version": py_ver,
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}),
|
||||
version,
|
||||
None,
|
||||
&expected,
|
||||
);
|
||||
}
|
||||
|
||||
// Test 7: Without api_version should work as before
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(docker_tag(&cfg, None, None), "langchain/langgraph-api:3.11");
|
||||
|
||||
// Test 8: Multiplatform with api_version (should default to Python)
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"python_version": "3.11",
|
||||
"node_version": "20",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"python": "./agent.py:graph", "js": "./agent.js:graph"}
|
||||
}),
|
||||
version,
|
||||
None,
|
||||
"langchain/langgraph-api:0.2.74-py3.11",
|
||||
);
|
||||
|
||||
// Test 9: _INTERNAL_docker_tag ignores api_version
|
||||
// (can only test with api_version as argument since both in config is invalid)
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"_INTERNAL_docker_tag": "internal-tag"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, Some("0.2.74")),
|
||||
"langchain/langgraph-api:internal-tag"
|
||||
);
|
||||
|
||||
// Test 10: langgraph-server base image with api_version
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}),
|
||||
version,
|
||||
Some("langchain/langgraph-server"),
|
||||
"langchain/langgraph-server:0.2.74-py3.11",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use super::schema::Config;
|
||||
use crate::constants::RESERVED_PACKAGE_NAMES;
|
||||
|
||||
/// Container for referencing and managing local Python dependencies.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalDeps {
|
||||
/// (host_requirements_path, container_requirements_path)
|
||||
pub pip_reqs: Vec<(PathBuf, String)>,
|
||||
/// host_path -> (dependency_string, container_package_name)
|
||||
pub real_pkgs: IndexMap<PathBuf, (String, String)>,
|
||||
/// host_path -> (dependency_string, container_path)
|
||||
pub faux_pkgs: IndexMap<PathBuf, (String, String)>,
|
||||
/// If "." is in dependencies, use it as working_dir
|
||||
pub working_dir: Option<String>,
|
||||
/// Directories outside the config parent that need additional Docker build contexts
|
||||
pub additional_contexts: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for LocalDeps {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pip_reqs: Vec::new(),
|
||||
real_pkgs: IndexMap::new(),
|
||||
faux_pkgs: IndexMap::new(),
|
||||
working_dir: None,
|
||||
additional_contexts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble local dependencies from config.
|
||||
pub fn assemble_local_deps(
|
||||
config_path: &Path,
|
||||
config: &Config,
|
||||
) -> Result<LocalDeps, String> {
|
||||
let config_path = config_path
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Could not resolve config path: {e}"))?;
|
||||
let config_parent = config_path.parent().unwrap();
|
||||
|
||||
let mut reserved: HashSet<String> = RESERVED_PACKAGE_NAMES
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let mut counter: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
let check_reserved = |name: &str, ref_str: &str, reserved: &mut HashSet<String>| -> Result<(), String> {
|
||||
if reserved.contains(name) {
|
||||
return Err(format!(
|
||||
"Package name '{name}' used in local dep '{ref_str}' is reserved. Rename the directory."
|
||||
));
|
||||
}
|
||||
reserved.insert(name.to_string());
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let mut pip_reqs = Vec::new();
|
||||
let mut real_pkgs = IndexMap::new();
|
||||
let mut faux_pkgs = IndexMap::new();
|
||||
let mut working_dir: Option<String> = None;
|
||||
let mut additional_contexts = Vec::new();
|
||||
|
||||
for local_dep in &config.dependencies {
|
||||
if !local_dep.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let resolved = (config_parent.join(local_dep))
|
||||
.canonicalize()
|
||||
.map_err(|_| format!("Could not find local dependency: {}", config_parent.join(local_dep).display()))?;
|
||||
|
||||
if !resolved.exists() {
|
||||
return Err(format!("Could not find local dependency: {}", resolved.display()));
|
||||
}
|
||||
if !resolved.is_dir() {
|
||||
return Err(format!(
|
||||
"Local dependency must be a directory: {}",
|
||||
resolved.display()
|
||||
));
|
||||
}
|
||||
|
||||
if resolved != config_parent && !resolved.starts_with(config_parent) {
|
||||
additional_contexts.push(resolved.clone());
|
||||
}
|
||||
|
||||
let files: Vec<String> = std::fs::read_dir(&resolved)
|
||||
.map_err(|e| format!("Could not read directory {}: {e}", resolved.display()))?
|
||||
.filter_map(|entry| entry.ok().map(|e| e.file_name().to_string_lossy().to_string()))
|
||||
.collect();
|
||||
|
||||
if files.contains(&"pyproject.toml".to_string())
|
||||
|| files.contains(&"setup.py".to_string())
|
||||
{
|
||||
// Real package
|
||||
let dir_name = resolved
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let count = counter.entry(dir_name.clone()).or_insert(0);
|
||||
let container_name = if *count > 0 {
|
||||
format!("{}_{}", dir_name, count)
|
||||
} else {
|
||||
dir_name.clone()
|
||||
};
|
||||
*count += 1;
|
||||
|
||||
real_pkgs.insert(resolved.clone(), (local_dep.clone(), container_name.clone()));
|
||||
|
||||
if local_dep == "." {
|
||||
working_dir = Some(format!("/deps/{container_name}"));
|
||||
}
|
||||
} else {
|
||||
// Faux package
|
||||
let dir_name = resolved
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
if files.contains(&"__init__.py".to_string()) {
|
||||
// Flat layout
|
||||
if dir_name.contains('-') {
|
||||
return Err(format!(
|
||||
"Package name '{dir_name}' contains a hyphen. \
|
||||
Rename the directory to use it as flat-layout package."
|
||||
));
|
||||
}
|
||||
check_reserved(&dir_name, local_dep, &mut reserved)?;
|
||||
let container_path = format!("/deps/outer-{dir_name}/{dir_name}");
|
||||
faux_pkgs.insert(resolved.clone(), (local_dep.clone(), container_path.clone()));
|
||||
if local_dep == "." {
|
||||
working_dir = Some(container_path);
|
||||
}
|
||||
} else {
|
||||
// Src layout
|
||||
let container_path = format!("/deps/outer-{dir_name}/src");
|
||||
|
||||
for file in &files {
|
||||
let rfile = resolved.join(file);
|
||||
if rfile.is_dir() && file != "__pycache__" && !file.starts_with('.') {
|
||||
if let Ok(entries) = std::fs::read_dir(&rfile) {
|
||||
for subentry in entries.flatten() {
|
||||
let subname = subentry.file_name().to_string_lossy().to_string();
|
||||
if subname.ends_with(".py") {
|
||||
check_reserved(file, local_dep, &mut reserved)?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
faux_pkgs.insert(resolved.clone(), (local_dep.clone(), container_path.clone()));
|
||||
if local_dep == "." {
|
||||
working_dir = Some(container_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for requirements.txt
|
||||
if files.contains(&"requirements.txt".to_string()) {
|
||||
let rfile = resolved.join("requirements.txt");
|
||||
let container_req_path = if let Some((_, ref cp)) = faux_pkgs.get(&resolved) {
|
||||
format!("{cp}/requirements.txt")
|
||||
} else {
|
||||
format!("/deps/outer-{dir_name}/requirements.txt")
|
||||
};
|
||||
pip_reqs.push((rfile, container_req_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(LocalDeps {
|
||||
pip_reqs,
|
||||
real_pkgs,
|
||||
faux_pkgs,
|
||||
working_dir,
|
||||
additional_contexts,
|
||||
})
|
||||
}
|
||||
@@ -1,849 +0,0 @@
|
||||
pub mod docker_tag;
|
||||
pub mod local_deps;
|
||||
pub mod path_rewrite;
|
||||
pub mod schema;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use schema::{Config, EnvConfig, GraphSpec, KeepPkgTools};
|
||||
|
||||
use crate::constants::{
|
||||
BUILD_TOOLS, DEFAULT_IMAGE_DISTRO, DEFAULT_NODE_VERSION, DEFAULT_PYTHON_VERSION,
|
||||
MIN_NODE_VERSION, MIN_PYTHON_VERSION, VALID_DISTROS, VALID_PIP_INSTALLERS,
|
||||
};
|
||||
|
||||
/// Check if a graph spec references a Node.js file based on extension.
|
||||
pub fn is_node_graph(spec: &GraphSpec) -> bool {
|
||||
let path_str = match spec {
|
||||
GraphSpec::Path(s) => s.as_str(),
|
||||
GraphSpec::Dict(m) => match m.get("path").and_then(|v| v.as_str()) {
|
||||
Some(p) => p,
|
||||
None => return false,
|
||||
},
|
||||
};
|
||||
|
||||
let file_path = path_str.split(':').next().unwrap_or("");
|
||||
matches!(
|
||||
Path::new(file_path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str()),
|
||||
Some("ts" | "mts" | "cts" | "js" | "mjs" | "cjs")
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse a Python version string "major.minor" into (major, minor).
|
||||
fn parse_version(version_str: &str) -> Result<(u32, u32), String> {
|
||||
let cleaned = version_str.split('-').next().unwrap_or(version_str);
|
||||
let parts: Vec<&str> = cleaned.split('.').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(format!("Invalid version format: {version_str}"));
|
||||
}
|
||||
let major: u32 = parts[0]
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid version format: {version_str}"))?;
|
||||
let minor: u32 = parts[1]
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid version format: {version_str}"))?;
|
||||
Ok((major, minor))
|
||||
}
|
||||
|
||||
/// Parse a Node.js version string (major only) into u32.
|
||||
fn parse_node_version(version_str: &str) -> Result<u32, String> {
|
||||
if version_str.contains('.') {
|
||||
return Err(format!(
|
||||
"Invalid Node.js version format: {version_str}. Use major version only (e.g., '20')."
|
||||
));
|
||||
}
|
||||
version_str
|
||||
.parse::<u32>()
|
||||
.map_err(|_| format!("Invalid Node.js version format: {version_str}. Use major version only (e.g., '20')."))
|
||||
}
|
||||
|
||||
/// Validate a configuration dictionary.
|
||||
pub fn validate_config(mut config: Config) -> Result<Config, String> {
|
||||
let graphs = &config.graphs;
|
||||
|
||||
let some_node = graphs.values().any(|spec| is_node_graph(spec));
|
||||
let some_python = graphs.values().any(|spec| !is_node_graph(spec));
|
||||
|
||||
// Set defaults for node_version and python_version
|
||||
if config.node_version.is_none() && some_node {
|
||||
config.node_version = Some(DEFAULT_NODE_VERSION.to_string());
|
||||
}
|
||||
if config.python_version.is_none() && some_python {
|
||||
config.python_version = Some(DEFAULT_PYTHON_VERSION.to_string());
|
||||
}
|
||||
|
||||
// Default image_distro
|
||||
if config.image_distro.is_none() {
|
||||
config.image_distro = Some(DEFAULT_IMAGE_DISTRO.to_string());
|
||||
}
|
||||
|
||||
// Default pip_installer
|
||||
if config.pip_installer.is_none() {
|
||||
config.pip_installer = Some("auto".to_string());
|
||||
}
|
||||
|
||||
// Default env
|
||||
if config.env.is_none() {
|
||||
config.env = Some(EnvConfig::default());
|
||||
}
|
||||
|
||||
// Validate _INTERNAL_docker_tag vs api_version
|
||||
if config.internal_docker_tag.is_some() && config.api_version.is_some() {
|
||||
return Err("Cannot specify both _INTERNAL_docker_tag and api_version.".to_string());
|
||||
}
|
||||
|
||||
// Validate api_version format
|
||||
if let Some(ref api_version) = config.api_version {
|
||||
let cleaned = api_version.split('-').next().unwrap_or(api_version);
|
||||
let parts: Vec<&str> = cleaned.split('.').collect();
|
||||
if parts.len() > 3 {
|
||||
return Err("Version must be major or major.minor or major.minor.patch.".to_string());
|
||||
}
|
||||
for part in &parts {
|
||||
part.parse::<u32>()
|
||||
.map_err(|_| format!("Invalid version format: {api_version}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate node_version
|
||||
if let Some(ref node_version) = config.node_version {
|
||||
let major = parse_node_version(node_version)?;
|
||||
if major < MIN_NODE_VERSION {
|
||||
return Err(format!(
|
||||
"Node.js version {node_version} is not supported. Minimum required version is {MIN_NODE_VERSION}."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate python_version
|
||||
if let Some(ref pyversion) = config.python_version {
|
||||
let cleaned = pyversion.split('-').next().unwrap_or(pyversion);
|
||||
if cleaned.split('.').count() != 2
|
||||
|| !cleaned.split('.').all(|p| p.chars().all(|c| c.is_ascii_digit()))
|
||||
{
|
||||
return Err(format!(
|
||||
"Invalid Python version format: {pyversion}. \
|
||||
Use 'major.minor' format (e.g., '3.11'). \
|
||||
Patch version cannot be specified."
|
||||
));
|
||||
}
|
||||
if parse_version(pyversion)? < MIN_PYTHON_VERSION {
|
||||
return Err(format!(
|
||||
"Python version {pyversion} is not supported. \
|
||||
Minimum required version is {}.{}.",
|
||||
MIN_PYTHON_VERSION.0, MIN_PYTHON_VERSION.1
|
||||
));
|
||||
}
|
||||
|
||||
if config.dependencies.is_empty() {
|
||||
return Err(
|
||||
"No dependencies found in config. Add at least one dependency to 'dependencies' list."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate graphs
|
||||
if config.graphs.is_empty() {
|
||||
return Err(
|
||||
"No graphs found in config. Add at least one graph to 'graphs' dictionary."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// Validate image_distro
|
||||
if let Some(ref distro) = config.image_distro {
|
||||
if distro == "bullseye" {
|
||||
return Err(
|
||||
"Bullseye images were deprecated in version 0.4.13. \
|
||||
Please use 'bookworm' or 'debian' instead."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if !VALID_DISTROS.contains(&distro.as_str()) {
|
||||
return Err(format!(
|
||||
"Invalid image_distro: '{distro}'. Must be one of 'debian', 'wolfi', or 'bookworm'."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate pip_installer
|
||||
if let Some(ref pip_installer) = config.pip_installer {
|
||||
if !VALID_PIP_INSTALLERS.contains(&pip_installer.as_str()) {
|
||||
return Err(format!(
|
||||
"Invalid pip_installer: '{pip_installer}'. Must be 'auto', 'pip', or 'uv'."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate auth config
|
||||
if let Some(ref auth_conf) = config.auth {
|
||||
if let Some(ref path) = auth_conf.path {
|
||||
if !path.contains(':') {
|
||||
return Err(format!(
|
||||
"Invalid auth.path format: '{path}'. \
|
||||
Must be in format './path/to/file.py:attribute_name'"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate encryption config
|
||||
if let Some(ref encryption_conf) = config.encryption {
|
||||
if let Some(ref path) = encryption_conf.path {
|
||||
if !path.contains(':') {
|
||||
return Err(format!(
|
||||
"Invalid encryption.path format: '{path}'. \
|
||||
Must be in format './path/to/file.py:attribute_name'"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate http config
|
||||
if let Some(ref http_conf) = config.http {
|
||||
if let Some(ref app) = http_conf.app {
|
||||
if !app.contains(':') {
|
||||
return Err(format!(
|
||||
"Invalid http.app format: '{app}'. \
|
||||
Must be in format './path/to/file.py:attribute_name'"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate keep_pkg_tools
|
||||
if let Some(ref keep_pkg_tools) = config.keep_pkg_tools {
|
||||
match keep_pkg_tools {
|
||||
KeepPkgTools::List(tools) => {
|
||||
for tool in tools {
|
||||
if !BUILD_TOOLS.contains(&tool.as_str()) {
|
||||
return Err(format!(
|
||||
"Invalid keep_pkg_tools: '{tool}'. \
|
||||
Must be one of 'pip', 'setuptools', 'wheel'."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
KeepPkgTools::Bool(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Load and validate a configuration file.
|
||||
pub fn validate_config_file(config_path: &Path) -> Result<Config, String> {
|
||||
let content = std::fs::read_to_string(config_path)
|
||||
.map_err(|e| format!("Could not read config file {}: {e}", config_path.display()))?;
|
||||
|
||||
let config: Config = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Invalid JSON in config file {}: {e}", config_path.display()))?;
|
||||
|
||||
let validated = validate_config(config)?;
|
||||
|
||||
// Check package.json engines
|
||||
if validated.node_version.is_some() {
|
||||
let package_json_path = config_path.parent().unwrap().join("package.json");
|
||||
if package_json_path.is_file() {
|
||||
let pkg_content = std::fs::read_to_string(&package_json_path)
|
||||
.map_err(|e| format!("Could not read package.json: {e}"))?;
|
||||
let pkg: serde_json::Value = serde_json::from_str(&pkg_content).map_err(|_| {
|
||||
format!(
|
||||
"Invalid package.json found in langgraph config directory {}: file is not valid JSON",
|
||||
package_json_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(engines) = pkg.get("engines").and_then(|e| e.as_object()) {
|
||||
// Only 'node' engine is supported
|
||||
if engines.keys().any(|k| k != "node") {
|
||||
return Err(format!(
|
||||
"Only 'node' engine is supported in package.json engines. Got engines: {:?}",
|
||||
engines.keys().collect::<Vec<_>>()
|
||||
));
|
||||
}
|
||||
if let Some(node_version) = engines.get("node").and_then(|v| v.as_str()) {
|
||||
let major = parse_node_version(node_version)?;
|
||||
if major < MIN_NODE_VERSION {
|
||||
return Err(format!(
|
||||
"Node.js version in package.json engines must be >= {MIN_NODE_VERSION} \
|
||||
(major version only), got '{node_version}'. Minor/patch versions \
|
||||
(like '20.x.y') are not supported to prevent deployment issues \
|
||||
when new Node.js versions are released."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(validated)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
|
||||
fn config_from_json(val: serde_json::Value) -> Config {
|
||||
serde_json::from_value::<Config>(val).unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// test_validate_config
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_minimal() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
||||
assert_eq!(result.node_version, None);
|
||||
assert_eq!(result.pip_installer.as_deref(), Some("auto"));
|
||||
assert_eq!(result.image_distro.as_deref(), Some("debian"));
|
||||
assert_eq!(result.base_image, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_full() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.12",
|
||||
"dependencies": [".", "langchain"],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"pip_config_file": "/etc/pip.conf",
|
||||
"dockerfile_lines": ["RUN apt-get update"],
|
||||
"env": ".env"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.12"));
|
||||
assert_eq!(result.pip_config_file.as_deref(), Some("/etc/pip.conf"));
|
||||
assert_eq!(result.dockerfile_lines, vec!["RUN apt-get update"]);
|
||||
assert!(matches!(result.env, Some(EnvConfig::File(ref s)) if s == ".env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_313() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.13",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.13"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_39_error() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.9",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Minimum required version"), "Expected 'Minimum required version' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_missing_dependencies() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("dependencies"), "Expected error about dependencies but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_missing_graphs() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."]
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("graphs"), "Expected error about graphs but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_version_with_patch() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.11.0",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid Python version format"), "Expected 'Invalid Python version format' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_version_major_only() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid Python version format"), "Expected 'Invalid Python version format' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_version_non_numeric() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "abc.def",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid Python version format"), "Expected 'Invalid Python version format' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_310_error() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.10",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Minimum required version"), "Expected 'Minimum required version' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_312_slim() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.12-slim",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.12-slim"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_http_app_no_colon() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"http": {"app": "../../examples/my_app.py"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid http.app format"), "Expected 'Invalid http.app format' but got: {err}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// test_validate_config_image_distro
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_image_distro_debian() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "debian"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.image_distro.as_deref(), Some("debian"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_image_distro_wolfi() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.image_distro.as_deref(), Some("wolfi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_image_distro_default() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.image_distro.as_deref(), Some("debian"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_image_distro_bullseye_error() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "bullseye"
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Bullseye images were deprecated"), "Expected 'Bullseye images were deprecated' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_image_distro_ubuntu_error() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "ubuntu"
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid image_distro: 'ubuntu'"), "Expected \"Invalid image_distro: 'ubuntu'\" but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_image_distro_alpine_error() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "alpine"
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid image_distro: 'alpine'"), "Expected \"Invalid image_distro: 'alpine'\" but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_node_with_wolfi() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.image_distro.as_deref(), Some("wolfi"));
|
||||
assert_eq!(result.node_version.as_deref(), Some("20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_node_default_distro() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.image_distro.as_deref(), Some("debian"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// test_validate_config_pip_installer
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_pip_installer_auto() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"pip_installer": "auto"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.pip_installer.as_deref(), Some("auto"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_pip_installer_pip() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"pip_installer": "pip"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.pip_installer.as_deref(), Some("pip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_pip_installer_uv() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"pip_installer": "uv"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.pip_installer.as_deref(), Some("uv"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_pip_installer_default() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.pip_installer.as_deref(), Some("auto"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_pip_installer_conda_error() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"pip_installer": "conda"
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid pip_installer: 'conda'"), "Expected \"Invalid pip_installer: 'conda'\" but got: {err}");
|
||||
assert!(err.contains("Must be 'auto', 'pip', or 'uv'"), "Expected \"Must be 'auto', 'pip', or 'uv'\" but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_pip_installer_invalid_error() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"pip_installer": "invalid"
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid pip_installer: 'invalid'"), "Expected \"Invalid pip_installer: 'invalid'\" but got: {err}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// test_validate_config_multiplatform
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_js_only_no_explicit_versions() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./js.mts:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.node_version.as_deref(), Some("20"));
|
||||
assert_eq!(result.python_version, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_both_versions_explicit() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.12",
|
||||
"node_version": "22",
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent_py": "./agent.py:graph",
|
||||
"agent_js": "./agent.mts:graph"
|
||||
}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.12"));
|
||||
assert_eq!(result.node_version.as_deref(), Some("22"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_mixed_graphs_no_explicit_versions() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent_py": "./agent.py:graph",
|
||||
"agent_js": "./agent.mts:graph"
|
||||
}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.node_version.as_deref(), Some("20"));
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_mixed_graphs_node_version_only() {
|
||||
let config = config_from_json(json!({
|
||||
"node_version": "22",
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent_py": "./agent.py:graph",
|
||||
"agent_js": "./agent.mts:graph"
|
||||
}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.node_version.as_deref(), Some("22"));
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_mixed_graphs_python_version_only() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent_py": "./agent.py:graph",
|
||||
"agent_js": "./agent.mts:graph"
|
||||
}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.node_version.as_deref(), Some("20"));
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_unknown_extension_assumes_python() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "local.workflow:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
||||
assert_eq!(result.node_version, None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// test_validate_config_encryption
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_encryption_valid() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"encryption": {"path": "./encryption.py:encryption"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert!(result.encryption.is_some());
|
||||
assert_eq!(
|
||||
result.encryption.as_ref().unwrap().path.as_deref(),
|
||||
Some("./encryption.py:encryption")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_encryption_no_colon_error() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"encryption": {"path": "./encryption.py"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid encryption.path format"), "Expected 'Invalid encryption.path format' but got: {err}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// test_validate_config_file
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_node_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
});
|
||||
let mut f = std::fs::File::create(&config_path).unwrap();
|
||||
write!(f, "{}", serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
drop(f);
|
||||
|
||||
let result = validate_config_file(&config_path).unwrap();
|
||||
assert_eq!(result.node_version.as_deref(), Some("20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_with_valid_package_json() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
|
||||
let pkg_path = tmp.path().join("package.json");
|
||||
let pkg_json = json!({
|
||||
"engines": {"node": "20"}
|
||||
});
|
||||
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
|
||||
|
||||
let result = validate_config_file(&config_path).unwrap();
|
||||
assert_eq!(result.node_version.as_deref(), Some("20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_package_json_minor_version_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
|
||||
let pkg_path = tmp.path().join("package.json");
|
||||
let pkg_json = json!({
|
||||
"engines": {"node": "20.18"}
|
||||
});
|
||||
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
|
||||
|
||||
let err = validate_config_file(&config_path).unwrap_err();
|
||||
assert!(err.contains("Use major version only"), "Expected 'Use major version only' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_package_json_old_node_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
|
||||
let pkg_path = tmp.path().join("package.json");
|
||||
let pkg_json = json!({
|
||||
"engines": {"node": "18"}
|
||||
});
|
||||
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
|
||||
|
||||
let err = validate_config_file(&config_path).unwrap_err();
|
||||
assert!(err.contains("must be >= 20"), "Expected 'must be >= 20' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_package_json_deno_engine_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
|
||||
let pkg_path = tmp.path().join("package.json");
|
||||
let pkg_json = json!({
|
||||
"engines": {"node": "20", "deno": "1.0"}
|
||||
});
|
||||
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
|
||||
|
||||
let err = validate_config_file(&config_path).unwrap_err();
|
||||
assert!(err.contains("Only 'node' engine is supported"), "Expected 'Only node engine is supported' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_invalid_package_json() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
|
||||
let pkg_path = tmp.path().join("package.json");
|
||||
std::fs::write(&pkg_path, "this is not valid json!!!").unwrap();
|
||||
|
||||
let err = validate_config_file(&config_path).unwrap_err();
|
||||
assert!(err.contains("Invalid package.json"), "Expected 'Invalid package.json' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_python_ignores_bad_package_json() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
|
||||
// Write a bad package.json - should be ignored for Python-only config
|
||||
let pkg_path = tmp.path().join("package.json");
|
||||
std::fs::write(&pkg_path, "this is not valid json!!!").unwrap();
|
||||
|
||||
let result = validate_config_file(&config_path).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
||||
}
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
use std::path::Path;
|
||||
|
||||
use super::local_deps::LocalDeps;
|
||||
use super::schema::{Config, GraphSpec};
|
||||
|
||||
/// Remap each graph's import path to the correct in-container path.
|
||||
pub fn update_graph_paths(
|
||||
config_path: &Path,
|
||||
config: &mut Config,
|
||||
local_deps: &LocalDeps,
|
||||
) -> Result<(), String> {
|
||||
let config_parent = config_path.parent().unwrap();
|
||||
let graph_ids: Vec<String> = config.graphs.keys().cloned().collect();
|
||||
|
||||
for graph_id in graph_ids {
|
||||
let import_str = {
|
||||
let spec = config.graphs.get(&graph_id).unwrap();
|
||||
match spec {
|
||||
GraphSpec::Path(s) => s.clone(),
|
||||
GraphSpec::Dict(m) => {
|
||||
if let Some(path_val) = m.get("path") {
|
||||
path_val
|
||||
.as_str()
|
||||
.ok_or_else(|| {
|
||||
format!("Graph '{graph_id}' path must be a string")
|
||||
})?
|
||||
.to_string()
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Graph '{graph_id}' must contain a 'path' key if it is a dictionary."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (module_str, attr_str) = match import_str.split_once(':') {
|
||||
Some((m, a)) if !m.is_empty() && !a.is_empty() => (m, a),
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Import string \"{import_str}\" must be in format \"<module>:<attribute>\"."
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Check for file path (contains / or \)
|
||||
if module_str.contains('/') || module_str.contains('\\') {
|
||||
let resolved = config_parent
|
||||
.join(module_str)
|
||||
.canonicalize()
|
||||
.map_err(|_| format!("Could not find local module: {}", config_parent.join(module_str).display()))?;
|
||||
|
||||
if !resolved.exists() {
|
||||
return Err(format!("Could not find local module: {}", resolved.display()));
|
||||
}
|
||||
if !resolved.is_file() {
|
||||
return Err(format!("Local module must be a file: {}", resolved.display()));
|
||||
}
|
||||
|
||||
let mut new_module = None;
|
||||
|
||||
// Check real packages
|
||||
for (path, (_, container_name)) in &local_deps.real_pkgs {
|
||||
if resolved.starts_with(path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(path) {
|
||||
let container_path =
|
||||
format!("/deps/{}/{}", container_name, relative.to_string_lossy().replace('\\', "/"));
|
||||
new_module = Some(container_path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check faux packages
|
||||
if new_module.is_none() {
|
||||
for (faux_path, (_, destpath)) in &local_deps.faux_pkgs {
|
||||
if resolved.starts_with(faux_path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(faux_path) {
|
||||
new_module = Some(format!(
|
||||
"{}/{}",
|
||||
destpath,
|
||||
relative.to_string_lossy().replace('\\', "/")
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(new_mod) = new_module {
|
||||
let new_path = format!("{new_mod}:{attr_str}");
|
||||
config.graphs.get_mut(&graph_id).unwrap().set_path(new_path);
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Module '{import_str}' not found in 'dependencies' list. \
|
||||
Add its containing package to 'dependencies' list."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update auth.path to use Docker container paths.
|
||||
pub fn update_auth_path(
|
||||
config_path: &Path,
|
||||
config: &mut Config,
|
||||
local_deps: &LocalDeps,
|
||||
) -> Result<(), String> {
|
||||
let auth_conf = match config.auth.as_mut() {
|
||||
Some(a) => a,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let path_str = match auth_conf.path.as_ref() {
|
||||
Some(p) => p.clone(),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let (module_str, attr_str) = match path_str.split_once(':') {
|
||||
Some((m, a)) => (m, a),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if !module_str.starts_with('.') {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config_parent = config_path.parent().unwrap();
|
||||
let resolved = config_parent
|
||||
.join(module_str)
|
||||
.canonicalize()
|
||||
.map_err(|_| format!("Auth file not found: {} (from {path_str})", config_parent.join(module_str).display()))?;
|
||||
|
||||
if !resolved.is_file() {
|
||||
return Err(format!("Auth path must be a file: {}", resolved.display()));
|
||||
}
|
||||
|
||||
// Check faux packages first
|
||||
for (faux_path, (_, destpath)) in &local_deps.faux_pkgs {
|
||||
if resolved.starts_with(faux_path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(faux_path) {
|
||||
auth_conf.path = Some(format!(
|
||||
"{}/{}:{attr_str}",
|
||||
destpath,
|
||||
relative.to_string_lossy()
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check real packages
|
||||
for (real_path, _) in &local_deps.real_pkgs {
|
||||
if resolved.starts_with(real_path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(real_path) {
|
||||
let dir_name = real_path.file_name().unwrap().to_string_lossy();
|
||||
auth_conf.path = Some(format!(
|
||||
"/deps/{}/{}:{attr_str}",
|
||||
dir_name,
|
||||
relative.to_string_lossy()
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Auth file '{}' not covered by dependencies.\n\
|
||||
Add its parent directory to the 'dependencies' array in your config.",
|
||||
resolved.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// Update encryption.path to use Docker container paths.
|
||||
pub fn update_encryption_path(
|
||||
config_path: &Path,
|
||||
config: &mut Config,
|
||||
local_deps: &LocalDeps,
|
||||
) -> Result<(), String> {
|
||||
let encryption_conf = match config.encryption.as_mut() {
|
||||
Some(e) => e,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let path_str = match encryption_conf.path.as_ref() {
|
||||
Some(p) => p.clone(),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let (module_str, attr_str) = match path_str.split_once(':') {
|
||||
Some((m, a)) => (m, a),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if !module_str.starts_with('.') {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config_parent = config_path.parent().unwrap();
|
||||
let resolved = config_parent
|
||||
.join(module_str)
|
||||
.canonicalize()
|
||||
.map_err(|_| {
|
||||
format!(
|
||||
"Encryption file not found: {} (from {path_str})",
|
||||
config_parent.join(module_str).display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if !resolved.is_file() {
|
||||
return Err(format!(
|
||||
"Encryption path must be a file: {}",
|
||||
resolved.display()
|
||||
));
|
||||
}
|
||||
|
||||
for (faux_path, (_, destpath)) in &local_deps.faux_pkgs {
|
||||
if resolved.starts_with(faux_path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(faux_path) {
|
||||
encryption_conf.path = Some(format!(
|
||||
"{}/{}:{attr_str}",
|
||||
destpath,
|
||||
relative.to_string_lossy()
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (real_path, _) in &local_deps.real_pkgs {
|
||||
if resolved.starts_with(real_path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(real_path) {
|
||||
let dir_name = real_path.file_name().unwrap().to_string_lossy();
|
||||
encryption_conf.path = Some(format!(
|
||||
"/deps/{}/{}:{attr_str}",
|
||||
dir_name,
|
||||
relative.to_string_lossy()
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Encryption file '{}' not covered by dependencies.\n\
|
||||
Add its parent directory to the 'dependencies' array in your config.",
|
||||
resolved.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// Update the HTTP app path to point to the correct location in the Docker container.
|
||||
pub fn update_http_app_path(
|
||||
config_path: &Path,
|
||||
config: &mut Config,
|
||||
local_deps: &LocalDeps,
|
||||
) -> Result<(), String> {
|
||||
let http_config = match config.http.as_mut() {
|
||||
Some(h) => h,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let app_str = match http_config.app.as_ref() {
|
||||
Some(a) => a.clone(),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let (module_str, attr_str) = match app_str.split_once(':') {
|
||||
Some((m, a)) if !m.is_empty() && !a.is_empty() => (m, a),
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Import string \"{app_str}\" must be in format \"<module>:<attribute>\"."
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if !module_str.contains('/') && !module_str.contains('\\') {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config_parent = config_path.parent().unwrap();
|
||||
let resolved = config_parent
|
||||
.join(module_str)
|
||||
.canonicalize()
|
||||
.map_err(|_| format!("Could not find HTTP app module: {}", config_parent.join(module_str).display()))?;
|
||||
|
||||
if !resolved.is_file() {
|
||||
return Err(format!(
|
||||
"HTTP app module must be a file: {}",
|
||||
resolved.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Check real packages
|
||||
for (path, (_, _name)) in &local_deps.real_pkgs {
|
||||
if resolved.starts_with(path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(path) {
|
||||
let dir_name = path.file_name().unwrap().to_string_lossy();
|
||||
http_config.app = Some(format!(
|
||||
"/deps/{}/{}:{attr_str}",
|
||||
dir_name,
|
||||
relative.to_string_lossy().replace('\\', "/")
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check faux packages
|
||||
for (faux_path, (_, destpath)) in &local_deps.faux_pkgs {
|
||||
if resolved.starts_with(faux_path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(faux_path) {
|
||||
http_config.app = Some(format!(
|
||||
"{}/{}:{attr_str}",
|
||||
destpath,
|
||||
relative.to_string_lossy().replace('\\', "/")
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"HTTP app module '{app_str}' not found in 'dependencies' list. \
|
||||
Add its containing package to 'dependencies' list."
|
||||
))
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Top-level config for langgraph-cli deployment.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Config {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub python_version: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_version: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub api_version: Option<String>,
|
||||
|
||||
#[serde(rename = "_INTERNAL_docker_tag", skip_serializing_if = "Option::is_none")]
|
||||
pub internal_docker_tag: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_image: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_distro: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pip_config_file: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pip_installer: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dockerfile_lines: Vec<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dependencies: Vec<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
||||
pub graphs: IndexMap<String, GraphSpec>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub env: Option<EnvConfig>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub store: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth: Option<AuthConfig>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub encryption: Option<EncryptionConfig>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub http: Option<HttpConfig>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub webhooks: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub checkpointer: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ui: Option<IndexMap<String, String>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ui_config: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_pkg_tools: Option<KeepPkgTools>,
|
||||
}
|
||||
|
||||
/// Graph specification: either a string path or a dict with a "path" key.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum GraphSpec {
|
||||
Path(String),
|
||||
Dict(IndexMap<String, Value>),
|
||||
}
|
||||
|
||||
impl GraphSpec {
|
||||
pub fn set_path(&mut self, new_path: String) {
|
||||
match self {
|
||||
GraphSpec::Path(s) => *s = new_path,
|
||||
GraphSpec::Dict(m) => {
|
||||
m.insert("path".to_string(), Value::String(new_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment config: either a dict of key-value pairs or a path to an env file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum EnvConfig {
|
||||
Dict(IndexMap<String, String>),
|
||||
File(String),
|
||||
}
|
||||
|
||||
impl Default for EnvConfig {
|
||||
fn default() -> Self {
|
||||
EnvConfig::Dict(IndexMap::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Auth configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_studio_auth: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub openapi: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache: Option<Value>,
|
||||
}
|
||||
|
||||
/// Encryption configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EncryptionConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// HTTP configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HttpConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub app: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_assistants: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_threads: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_runs: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_store: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_mcp: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_a2a: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_meta: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_ui: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_webhooks: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cors: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub configurable_headers: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub logging_headers: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub middleware_order: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enable_custom_route_auth: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mount_prefix: Option<String>,
|
||||
}
|
||||
|
||||
/// Keep package tools config: either a boolean or a list of tool names.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum KeepPkgTools {
|
||||
Bool(bool),
|
||||
List(Vec<String>),
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
pub const MIN_NODE_VERSION: u32 = 20;
|
||||
pub const DEFAULT_NODE_VERSION: u32 = 20;
|
||||
|
||||
pub const MIN_PYTHON_VERSION: (u32, u32) = (3, 11);
|
||||
pub const DEFAULT_PYTHON_VERSION: &str = "3.11";
|
||||
|
||||
pub const DEFAULT_IMAGE_DISTRO: &str = "debian";
|
||||
|
||||
pub const BUILD_TOOLS: &[&str] = &["pip", "setuptools", "wheel"];
|
||||
|
||||
// Analytics
|
||||
pub const SUPABASE_PUBLIC_API_KEY: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imt6cmxwcG9qaW5wY3l5YWlweG5iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTkyNTc1NzksImV4cCI6MjAzNDgzMzU3OX0.kkVOlLz3BxemA5nP-vat3K4qRtrDuO4SwZSR_htcX9c";
|
||||
pub const SUPABASE_URL: &str = "https://kzrlppojinpcyyaipxnb.supabase.co";
|
||||
|
||||
pub const DEFAULT_POSTGRES_URI: &str =
|
||||
"postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable";
|
||||
|
||||
pub const VALID_DISTROS: &[&str] = &["debian", "wolfi", "bookworm"];
|
||||
pub const VALID_PIP_INSTALLERS: &[&str] = &["auto", "pip", "uv"];
|
||||
|
||||
pub const RESERVED_PACKAGE_NAMES: &[&str] = &[
|
||||
"src",
|
||||
"langgraph-api",
|
||||
"langgraph_api",
|
||||
"langgraph",
|
||||
"langchain-core",
|
||||
"langchain_core",
|
||||
"pydantic",
|
||||
"orjson",
|
||||
"fastapi",
|
||||
"uvicorn",
|
||||
"psycopg",
|
||||
"httpx",
|
||||
"langsmith",
|
||||
];
|
||||
@@ -1,185 +0,0 @@
|
||||
use std::process::Command;
|
||||
|
||||
/// Semantic version tuple.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Version {
|
||||
pub major: u32,
|
||||
pub minor: u32,
|
||||
pub patch: u32,
|
||||
}
|
||||
|
||||
impl Version {
|
||||
pub fn new(major: u32, minor: u32, patch: u32) -> Self {
|
||||
Self {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Type of Docker Compose installation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ComposeType {
|
||||
Plugin,
|
||||
Standalone,
|
||||
}
|
||||
|
||||
/// Docker capabilities detected on the system.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DockerCapabilities {
|
||||
#[allow(dead_code)]
|
||||
pub version_docker: Version,
|
||||
#[allow(dead_code)]
|
||||
pub version_compose: Version,
|
||||
pub healthcheck_start_interval: bool,
|
||||
pub compose_type: ComposeType,
|
||||
}
|
||||
|
||||
/// Parse a version string like "1.2.3", "v1.2.3-alpha", etc.
|
||||
pub fn parse_version(version: &str) -> Version {
|
||||
let cleaned = version.trim();
|
||||
let parts: Vec<&str> = cleaned.split('.').collect();
|
||||
|
||||
let parse_part = |s: &str| -> u32 {
|
||||
let s = s.trim_start_matches('v');
|
||||
let s = s.split('-').next().unwrap_or(s);
|
||||
let s = s.split('+').next().unwrap_or(s);
|
||||
s.parse().unwrap_or(0)
|
||||
};
|
||||
|
||||
match parts.len() {
|
||||
1 => Version::new(parse_part(parts[0]), 0, 0),
|
||||
2 => Version::new(parse_part(parts[0]), parse_part(parts[1]), 0),
|
||||
_ => Version::new(
|
||||
parse_part(parts[0]),
|
||||
parse_part(parts[1]),
|
||||
parse_part(parts[2]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check Docker capabilities on the system.
|
||||
pub fn check_capabilities() -> Result<DockerCapabilities, String> {
|
||||
// Check docker is available
|
||||
if which::which("docker").is_err() {
|
||||
return Err("Docker not installed".to_string());
|
||||
}
|
||||
|
||||
// Get docker info
|
||||
let output = Command::new("docker")
|
||||
.args(["info", "-f", "{{json .}}"])
|
||||
.output()
|
||||
.map_err(|_| "Docker not installed or not running".to_string())?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err("Docker not installed or not running".to_string());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let info: serde_json::Value =
|
||||
serde_json::from_str(&stdout).map_err(|_| "Docker not installed or not running".to_string())?;
|
||||
|
||||
let server_version = info
|
||||
.get("ServerVersion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Docker not running")?;
|
||||
|
||||
if server_version.is_empty() {
|
||||
return Err("Docker not running".to_string());
|
||||
}
|
||||
|
||||
// Try to find compose as plugin
|
||||
let (compose_version_str, compose_type) = if let Some(plugins) = info
|
||||
.get("ClientInfo")
|
||||
.and_then(|ci| ci.get("Plugins"))
|
||||
.and_then(|p| p.as_array())
|
||||
{
|
||||
if let Some(compose) = plugins
|
||||
.iter()
|
||||
.find(|p| p.get("Name").and_then(|n| n.as_str()) == Some("compose"))
|
||||
{
|
||||
let version = compose
|
||||
.get("Version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("0.0.0");
|
||||
(version.to_string(), ComposeType::Plugin)
|
||||
} else {
|
||||
get_standalone_compose_version()?
|
||||
}
|
||||
} else {
|
||||
get_standalone_compose_version()?
|
||||
};
|
||||
|
||||
let docker_version = parse_version(server_version);
|
||||
let compose_version = parse_version(&compose_version_str);
|
||||
|
||||
Ok(DockerCapabilities {
|
||||
version_docker: docker_version,
|
||||
version_compose: compose_version,
|
||||
healthcheck_start_interval: docker_version >= Version::new(25, 0, 0),
|
||||
compose_type,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_standalone_compose_version() -> Result<(String, ComposeType), String> {
|
||||
if which::which("docker-compose").is_err() {
|
||||
return Err("Docker Compose not installed".to_string());
|
||||
}
|
||||
|
||||
let output = Command::new("docker-compose")
|
||||
.args(["--version", "--short"])
|
||||
.output()
|
||||
.map_err(|_| "Docker Compose not installed".to_string())?;
|
||||
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
Ok((version, ComposeType::Standalone))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_basic() {
|
||||
assert_eq!(parse_version("1.2.3"), Version::new(1, 2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_with_v() {
|
||||
assert_eq!(parse_version("v1.2.3"), Version::new(1, 2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_with_prerelease() {
|
||||
assert_eq!(parse_version("1.2.3-alpha"), Version::new(1, 2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_with_build() {
|
||||
assert_eq!(parse_version("1.2.3+1"), Version::new(1, 2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_two_parts() {
|
||||
assert_eq!(parse_version("1.2"), Version::new(1, 2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_one_part() {
|
||||
assert_eq!(parse_version("1"), Version::new(1, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_complex() {
|
||||
assert_eq!(parse_version("v28.1.1+1"), Version::new(28, 1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_beta() {
|
||||
assert_eq!(
|
||||
parse_version("2.0.0-beta.1+exp.sha.5114f85"),
|
||||
Version::new(2, 0, 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,791 +0,0 @@
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use super::capabilities::DockerCapabilities;
|
||||
use crate::constants::DEFAULT_POSTGRES_URI;
|
||||
|
||||
/// Value types for our custom YAML writer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum YamlValue {
|
||||
String(String),
|
||||
Dict(IndexMap<String, YamlValue>),
|
||||
List(Vec<String>),
|
||||
}
|
||||
|
||||
/// Convert a dictionary to a YAML string with custom formatting.
|
||||
/// This matches the Python dict_to_yaml() output exactly.
|
||||
pub fn dict_to_yaml(d: &IndexMap<String, YamlValue>, indent: usize) -> String {
|
||||
let mut yaml_str = String::new();
|
||||
|
||||
for (idx, (key, value)) in d.iter().enumerate() {
|
||||
// Extra newline for top-level keys only (after the first)
|
||||
if idx >= 1 && indent < 2 {
|
||||
yaml_str.push('\n');
|
||||
}
|
||||
let space = " ".repeat(indent);
|
||||
match value {
|
||||
YamlValue::Dict(inner) => {
|
||||
yaml_str.push_str(&format!("{space}{key}:\n"));
|
||||
yaml_str.push_str(&dict_to_yaml(inner, indent + 1));
|
||||
}
|
||||
YamlValue::List(items) => {
|
||||
yaml_str.push_str(&format!("{space}{key}:\n"));
|
||||
for item in items {
|
||||
yaml_str.push_str(&format!("{space} - {item}\n"));
|
||||
}
|
||||
}
|
||||
YamlValue::String(val) => {
|
||||
yaml_str.push_str(&format!("{space}{key}: {val}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
yaml_str
|
||||
}
|
||||
|
||||
/// Create debugger service config.
|
||||
pub fn debugger_compose(port: Option<u16>, base_url: Option<&str>) -> IndexMap<String, YamlValue> {
|
||||
let port = match port {
|
||||
Some(p) => p,
|
||||
None => return IndexMap::new(),
|
||||
};
|
||||
|
||||
let mut debugger = IndexMap::new();
|
||||
debugger.insert(
|
||||
"image".to_string(),
|
||||
YamlValue::String("langchain/langgraph-debugger".to_string()),
|
||||
);
|
||||
debugger.insert(
|
||||
"restart".to_string(),
|
||||
YamlValue::String("on-failure".to_string()),
|
||||
);
|
||||
|
||||
let mut depends = IndexMap::new();
|
||||
let mut pg_condition = IndexMap::new();
|
||||
pg_condition.insert(
|
||||
"condition".to_string(),
|
||||
YamlValue::String("service_healthy".to_string()),
|
||||
);
|
||||
depends.insert("langgraph-postgres".to_string(), YamlValue::Dict(pg_condition));
|
||||
debugger.insert("depends_on".to_string(), YamlValue::Dict(depends));
|
||||
|
||||
debugger.insert(
|
||||
"ports".to_string(),
|
||||
YamlValue::List(vec![format!("\"{port}:3968\"")]),
|
||||
);
|
||||
|
||||
if let Some(url) = base_url {
|
||||
let mut env = IndexMap::new();
|
||||
env.insert(
|
||||
"VITE_STUDIO_LOCAL_GRAPH_URL".to_string(),
|
||||
YamlValue::String(url.to_string()),
|
||||
);
|
||||
debugger.insert("environment".to_string(), YamlValue::Dict(env));
|
||||
}
|
||||
|
||||
let mut result = IndexMap::new();
|
||||
result.insert("langgraph-debugger".to_string(), YamlValue::Dict(debugger));
|
||||
result
|
||||
}
|
||||
|
||||
/// Create a docker compose file as a dictionary.
|
||||
pub fn compose_as_dict(
|
||||
capabilities: &DockerCapabilities,
|
||||
port: u16,
|
||||
debugger_port: Option<u16>,
|
||||
debugger_base_url: Option<&str>,
|
||||
postgres_uri: Option<&str>,
|
||||
image: Option<&str>,
|
||||
_base_image: Option<&str>,
|
||||
_api_version: Option<&str>,
|
||||
) -> IndexMap<String, YamlValue> {
|
||||
let include_db = postgres_uri.is_none();
|
||||
let postgres_uri = postgres_uri.unwrap_or(DEFAULT_POSTGRES_URI);
|
||||
|
||||
let mut services = IndexMap::new();
|
||||
|
||||
// Redis service
|
||||
let mut redis = IndexMap::new();
|
||||
redis.insert(
|
||||
"image".to_string(),
|
||||
YamlValue::String("redis:6".to_string()),
|
||||
);
|
||||
let mut redis_healthcheck = IndexMap::new();
|
||||
redis_healthcheck.insert(
|
||||
"test".to_string(),
|
||||
YamlValue::String("redis-cli ping".to_string()),
|
||||
);
|
||||
redis_healthcheck.insert(
|
||||
"interval".to_string(),
|
||||
YamlValue::String("5s".to_string()),
|
||||
);
|
||||
redis_healthcheck.insert(
|
||||
"timeout".to_string(),
|
||||
YamlValue::String("1s".to_string()),
|
||||
);
|
||||
redis_healthcheck.insert(
|
||||
"retries".to_string(),
|
||||
YamlValue::String("5".to_string()),
|
||||
);
|
||||
redis.insert("healthcheck".to_string(), YamlValue::Dict(redis_healthcheck));
|
||||
services.insert("langgraph-redis".to_string(), YamlValue::Dict(redis));
|
||||
|
||||
// Postgres service (if needed)
|
||||
if include_db {
|
||||
let mut postgres = IndexMap::new();
|
||||
postgres.insert(
|
||||
"image".to_string(),
|
||||
YamlValue::String("pgvector/pgvector:pg16".to_string()),
|
||||
);
|
||||
postgres.insert(
|
||||
"ports".to_string(),
|
||||
YamlValue::List(vec!["\"5433:5432\"".to_string()]),
|
||||
);
|
||||
|
||||
let mut pg_env = IndexMap::new();
|
||||
pg_env.insert(
|
||||
"POSTGRES_DB".to_string(),
|
||||
YamlValue::String("postgres".to_string()),
|
||||
);
|
||||
pg_env.insert(
|
||||
"POSTGRES_USER".to_string(),
|
||||
YamlValue::String("postgres".to_string()),
|
||||
);
|
||||
pg_env.insert(
|
||||
"POSTGRES_PASSWORD".to_string(),
|
||||
YamlValue::String("postgres".to_string()),
|
||||
);
|
||||
postgres.insert("environment".to_string(), YamlValue::Dict(pg_env));
|
||||
|
||||
postgres.insert(
|
||||
"command".to_string(),
|
||||
YamlValue::List(vec![
|
||||
"postgres".to_string(),
|
||||
"-c".to_string(),
|
||||
"shared_preload_libraries=vector".to_string(),
|
||||
]),
|
||||
);
|
||||
|
||||
postgres.insert(
|
||||
"volumes".to_string(),
|
||||
YamlValue::List(vec![
|
||||
"langgraph-data:/var/lib/postgresql/data".to_string(),
|
||||
]),
|
||||
);
|
||||
|
||||
let mut pg_healthcheck = IndexMap::new();
|
||||
pg_healthcheck.insert(
|
||||
"test".to_string(),
|
||||
YamlValue::String("pg_isready -U postgres".to_string()),
|
||||
);
|
||||
pg_healthcheck.insert(
|
||||
"start_period".to_string(),
|
||||
YamlValue::String("10s".to_string()),
|
||||
);
|
||||
pg_healthcheck.insert(
|
||||
"timeout".to_string(),
|
||||
YamlValue::String("1s".to_string()),
|
||||
);
|
||||
pg_healthcheck.insert(
|
||||
"retries".to_string(),
|
||||
YamlValue::String("5".to_string()),
|
||||
);
|
||||
|
||||
if capabilities.healthcheck_start_interval {
|
||||
pg_healthcheck.insert(
|
||||
"interval".to_string(),
|
||||
YamlValue::String("60s".to_string()),
|
||||
);
|
||||
pg_healthcheck.insert(
|
||||
"start_interval".to_string(),
|
||||
YamlValue::String("1s".to_string()),
|
||||
);
|
||||
} else {
|
||||
pg_healthcheck.insert(
|
||||
"interval".to_string(),
|
||||
YamlValue::String("5s".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
postgres.insert("healthcheck".to_string(), YamlValue::Dict(pg_healthcheck));
|
||||
services.insert("langgraph-postgres".to_string(), YamlValue::Dict(postgres));
|
||||
}
|
||||
|
||||
// Debugger service (if port specified)
|
||||
if let Some(dbg_port) = debugger_port {
|
||||
let debugger = debugger_compose(Some(dbg_port), debugger_base_url);
|
||||
for (k, v) in debugger {
|
||||
services.insert(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
// LangGraph API service
|
||||
let mut api = IndexMap::new();
|
||||
api.insert(
|
||||
"ports".to_string(),
|
||||
YamlValue::List(vec![format!("\"{port}:8000\"")]),
|
||||
);
|
||||
|
||||
let mut api_depends = IndexMap::new();
|
||||
let mut redis_condition = IndexMap::new();
|
||||
redis_condition.insert(
|
||||
"condition".to_string(),
|
||||
YamlValue::String("service_healthy".to_string()),
|
||||
);
|
||||
api_depends.insert(
|
||||
"langgraph-redis".to_string(),
|
||||
YamlValue::Dict(redis_condition),
|
||||
);
|
||||
api.insert("depends_on".to_string(), YamlValue::Dict(api_depends.clone()));
|
||||
|
||||
let mut api_env = IndexMap::new();
|
||||
api_env.insert(
|
||||
"REDIS_URI".to_string(),
|
||||
YamlValue::String("redis://langgraph-redis:6379".to_string()),
|
||||
);
|
||||
api_env.insert(
|
||||
"POSTGRES_URI".to_string(),
|
||||
YamlValue::String(postgres_uri.to_string()),
|
||||
);
|
||||
api.insert("environment".to_string(), YamlValue::Dict(api_env));
|
||||
|
||||
if let Some(img) = image {
|
||||
api.insert(
|
||||
"image".to_string(),
|
||||
YamlValue::String(img.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
// Add postgres dependency for API service
|
||||
if include_db {
|
||||
if let YamlValue::Dict(ref mut deps) = api.get_mut("depends_on").unwrap() {
|
||||
let mut pg_condition = IndexMap::new();
|
||||
pg_condition.insert(
|
||||
"condition".to_string(),
|
||||
YamlValue::String("service_healthy".to_string()),
|
||||
);
|
||||
deps.insert(
|
||||
"langgraph-postgres".to_string(),
|
||||
YamlValue::Dict(pg_condition),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Healthcheck for API service
|
||||
if capabilities.healthcheck_start_interval {
|
||||
let mut api_healthcheck = IndexMap::new();
|
||||
api_healthcheck.insert(
|
||||
"test".to_string(),
|
||||
YamlValue::String("python /api/healthcheck.py".to_string()),
|
||||
);
|
||||
api_healthcheck.insert(
|
||||
"interval".to_string(),
|
||||
YamlValue::String("60s".to_string()),
|
||||
);
|
||||
api_healthcheck.insert(
|
||||
"start_interval".to_string(),
|
||||
YamlValue::String("1s".to_string()),
|
||||
);
|
||||
api_healthcheck.insert(
|
||||
"start_period".to_string(),
|
||||
YamlValue::String("10s".to_string()),
|
||||
);
|
||||
api.insert("healthcheck".to_string(), YamlValue::Dict(api_healthcheck));
|
||||
}
|
||||
|
||||
services.insert("langgraph-api".to_string(), YamlValue::Dict(api));
|
||||
|
||||
// Build final compose dict
|
||||
let mut compose_dict = IndexMap::new();
|
||||
if include_db {
|
||||
let mut volumes = IndexMap::new();
|
||||
let mut vol_config = IndexMap::new();
|
||||
vol_config.insert(
|
||||
"driver".to_string(),
|
||||
YamlValue::String("local".to_string()),
|
||||
);
|
||||
volumes.insert("langgraph-data".to_string(), YamlValue::Dict(vol_config));
|
||||
compose_dict.insert("volumes".to_string(), YamlValue::Dict(volumes));
|
||||
}
|
||||
compose_dict.insert("services".to_string(), YamlValue::Dict(services));
|
||||
|
||||
compose_dict
|
||||
}
|
||||
|
||||
/// Create a docker compose file as a string.
|
||||
pub fn compose(
|
||||
capabilities: &DockerCapabilities,
|
||||
port: u16,
|
||||
debugger_port: Option<u16>,
|
||||
debugger_base_url: Option<&str>,
|
||||
postgres_uri: Option<&str>,
|
||||
image: Option<&str>,
|
||||
base_image: Option<&str>,
|
||||
api_version: Option<&str>,
|
||||
) -> String {
|
||||
let compose_dict = compose_as_dict(
|
||||
capabilities,
|
||||
port,
|
||||
debugger_port,
|
||||
debugger_base_url,
|
||||
postgres_uri,
|
||||
image,
|
||||
base_image,
|
||||
api_version,
|
||||
);
|
||||
dict_to_yaml(&compose_dict, 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::docker::capabilities::{ComposeType, Version};
|
||||
use crate::util::clean_empty_lines;
|
||||
|
||||
fn default_capabilities() -> DockerCapabilities {
|
||||
DockerCapabilities {
|
||||
version_docker: Version::new(26, 1, 1),
|
||||
version_compose: Version::new(2, 27, 0),
|
||||
healthcheck_start_interval: false,
|
||||
compose_type: ComposeType::Plugin,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dict_to_yaml_simple() {
|
||||
let mut d = IndexMap::new();
|
||||
d.insert(
|
||||
"key1".to_string(),
|
||||
YamlValue::String("value1".to_string()),
|
||||
);
|
||||
d.insert(
|
||||
"key2".to_string(),
|
||||
YamlValue::String("value2".to_string()),
|
||||
);
|
||||
let result = dict_to_yaml(&d, 0);
|
||||
// Top-level keys get an extra newline separator between them
|
||||
assert_eq!(result, "key1: value1\n\nkey2: value2\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dict_to_yaml_nested() {
|
||||
let mut inner = IndexMap::new();
|
||||
inner.insert(
|
||||
"nested_key".to_string(),
|
||||
YamlValue::String("nested_value".to_string()),
|
||||
);
|
||||
let mut d = IndexMap::new();
|
||||
d.insert("outer".to_string(), YamlValue::Dict(inner));
|
||||
let result = dict_to_yaml(&d, 0);
|
||||
assert_eq!(result, "outer:\n nested_key: nested_value\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dict_to_yaml_list() {
|
||||
let mut d = IndexMap::new();
|
||||
d.insert(
|
||||
"items".to_string(),
|
||||
YamlValue::List(vec!["a".to_string(), "b".to_string()]),
|
||||
);
|
||||
let result = dict_to_yaml(&d, 0);
|
||||
assert_eq!(result, "items:\n - a\n - b\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_no_debugger_and_custom_db() {
|
||||
let port = 8123;
|
||||
let custom_postgres_uri = "custom_postgres_uri";
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
Some(custom_postgres_uri),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let expected = format!(
|
||||
"services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {custom_postgres_uri}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_no_debugger_and_custom_db_with_healthcheck() {
|
||||
let port = 8123;
|
||||
let custom_postgres_uri = "custom_postgres_uri";
|
||||
let mut caps = default_capabilities();
|
||||
caps.healthcheck_start_interval = true;
|
||||
let actual = compose(
|
||||
&caps,
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
Some(custom_postgres_uri),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let expected = format!(
|
||||
"services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {custom_postgres_uri}\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: python /api/healthcheck.py\n\
|
||||
\x20 interval: 60s\n\
|
||||
\x20 start_interval: 1s\n\
|
||||
\x20 start_period: 10s"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_debugger_and_custom_db() {
|
||||
let port = 8123;
|
||||
let custom_postgres_uri = "custom_postgres_uri";
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
Some(custom_postgres_uri),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let expected = format!(
|
||||
"services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {custom_postgres_uri}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_debugger_and_default_db() {
|
||||
let port = 8123;
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let expected = format!(
|
||||
"volumes:\n\
|
||||
\x20 langgraph-data:\n\
|
||||
\x20 driver: local\n\
|
||||
services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 image: pgvector/pgvector:pg16\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"5433:5432\"\n\
|
||||
\x20 environment:\n\
|
||||
\x20 POSTGRES_DB: postgres\n\
|
||||
\x20 POSTGRES_USER: postgres\n\
|
||||
\x20 POSTGRES_PASSWORD: postgres\n\
|
||||
\x20 command:\n\
|
||||
\x20 - postgres\n\
|
||||
\x20 - -c\n\
|
||||
\x20 - shared_preload_libraries=vector\n\
|
||||
\x20 volumes:\n\
|
||||
\x20 - langgraph-data:/var/lib/postgresql/data\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: pg_isready -U postgres\n\
|
||||
\x20 start_period: 10s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {DEFAULT_POSTGRES_URI}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_api_version() {
|
||||
let port = 8123;
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("0.2.74"),
|
||||
);
|
||||
let expected = format!(
|
||||
"volumes:\n\
|
||||
\x20 langgraph-data:\n\
|
||||
\x20 driver: local\n\
|
||||
services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 image: pgvector/pgvector:pg16\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"5433:5432\"\n\
|
||||
\x20 environment:\n\
|
||||
\x20 POSTGRES_DB: postgres\n\
|
||||
\x20 POSTGRES_USER: postgres\n\
|
||||
\x20 POSTGRES_PASSWORD: postgres\n\
|
||||
\x20 command:\n\
|
||||
\x20 - postgres\n\
|
||||
\x20 - -c\n\
|
||||
\x20 - shared_preload_libraries=vector\n\
|
||||
\x20 volumes:\n\
|
||||
\x20 - langgraph-data:/var/lib/postgresql/data\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: pg_isready -U postgres\n\
|
||||
\x20 start_period: 10s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {DEFAULT_POSTGRES_URI}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_api_version_and_base_image() {
|
||||
let port = 8123;
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("my-registry/custom-api"),
|
||||
Some("1.0.0"),
|
||||
);
|
||||
let expected = format!(
|
||||
"volumes:\n\
|
||||
\x20 langgraph-data:\n\
|
||||
\x20 driver: local\n\
|
||||
services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 image: pgvector/pgvector:pg16\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"5433:5432\"\n\
|
||||
\x20 environment:\n\
|
||||
\x20 POSTGRES_DB: postgres\n\
|
||||
\x20 POSTGRES_USER: postgres\n\
|
||||
\x20 POSTGRES_PASSWORD: postgres\n\
|
||||
\x20 command:\n\
|
||||
\x20 - postgres\n\
|
||||
\x20 - -c\n\
|
||||
\x20 - shared_preload_libraries=vector\n\
|
||||
\x20 volumes:\n\
|
||||
\x20 - langgraph-data:/var/lib/postgresql/data\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: pg_isready -U postgres\n\
|
||||
\x20 start_period: 10s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {DEFAULT_POSTGRES_URI}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_api_version_and_custom_postgres() {
|
||||
let port = 8123;
|
||||
let custom_postgres_uri = "postgresql://user:pass@external-db:5432/mydb";
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
Some(custom_postgres_uri),
|
||||
None,
|
||||
None,
|
||||
Some("0.2.74"),
|
||||
);
|
||||
let expected = format!(
|
||||
"services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {custom_postgres_uri}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_api_version_and_debugger() {
|
||||
let port = 8123;
|
||||
let debugger_port = 8001;
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
Some(debugger_port),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("0.2.74"),
|
||||
);
|
||||
let expected = format!(
|
||||
"volumes:\n\
|
||||
\x20 langgraph-data:\n\
|
||||
\x20 driver: local\n\
|
||||
services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 image: pgvector/pgvector:pg16\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"5433:5432\"\n\
|
||||
\x20 environment:\n\
|
||||
\x20 POSTGRES_DB: postgres\n\
|
||||
\x20 POSTGRES_USER: postgres\n\
|
||||
\x20 POSTGRES_PASSWORD: postgres\n\
|
||||
\x20 command:\n\
|
||||
\x20 - postgres\n\
|
||||
\x20 - -c\n\
|
||||
\x20 - shared_preload_libraries=vector\n\
|
||||
\x20 volumes:\n\
|
||||
\x20 - langgraph-data:/var/lib/postgresql/data\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: pg_isready -U postgres\n\
|
||||
\x20 start_period: 10s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 langgraph-debugger:\n\
|
||||
\x20 image: langchain/langgraph-debugger\n\
|
||||
\x20 restart: on-failure\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{debugger_port}:3968\"\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {DEFAULT_POSTGRES_URI}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
pub mod capabilities;
|
||||
pub mod compose;
|
||||
pub mod dockerfile;
|
||||
@@ -1,211 +0,0 @@
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Run a command synchronously, optionally piping stdin, and capturing stdout/stderr.
|
||||
///
|
||||
/// If `verbose` is true, the command is echoed to stdout before execution.
|
||||
///
|
||||
/// Returns `(Option<stdout>, Option<stderr>)` on success, or an error message on failure.
|
||||
pub fn run_command(
|
||||
cmd: &str,
|
||||
args: &[&str],
|
||||
input: Option<&str>,
|
||||
verbose: bool,
|
||||
) -> Result<(Option<String>, Option<String>), String> {
|
||||
if verbose {
|
||||
let cmd_str = format!("+ {} {}", cmd, args.join(" "));
|
||||
if let Some(inp) = input {
|
||||
let filtered: Vec<&str> = inp.lines().filter(|l| !l.is_empty()).collect();
|
||||
println!("{} <\n{}", cmd_str, filtered.join("\n"));
|
||||
} else {
|
||||
println!("{cmd_str}");
|
||||
}
|
||||
}
|
||||
|
||||
let stdin_cfg = if input.is_some() {
|
||||
Stdio::piped()
|
||||
} else {
|
||||
Stdio::null()
|
||||
};
|
||||
|
||||
let mut child = Command::new(cmd)
|
||||
.args(args)
|
||||
.stdin(stdin_cfg)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to execute `{cmd}`: {e}"))?;
|
||||
|
||||
if let Some(input_data) = input {
|
||||
if let Some(ref mut stdin_handle) = child.stdin {
|
||||
stdin_handle
|
||||
.write_all(input_data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write to stdin of `{cmd}`: {e}"))?;
|
||||
}
|
||||
// Drop stdin to signal EOF
|
||||
drop(child.stdin.take());
|
||||
}
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| format!("Failed to wait for `{cmd}`: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
// 130 = SIGINT (Ctrl-C), not an error
|
||||
if code == 130 {
|
||||
return Ok((None, None));
|
||||
}
|
||||
let stdout_str = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr_str = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"Command `{cmd}` exited with code {code}\nstdout: {stdout_str}\nstderr: {stderr_str}"
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = if output.stdout.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
};
|
||||
let stderr = if output.stderr.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
};
|
||||
|
||||
Ok((stdout, stderr))
|
||||
}
|
||||
|
||||
/// Run a command and stream its stdout/stderr to the parent process in real-time.
|
||||
///
|
||||
/// This is useful for long-running commands like `docker compose up` where we
|
||||
/// want to see output as it happens.
|
||||
pub fn run_command_streaming(
|
||||
cmd: &str,
|
||||
args: &[&str],
|
||||
input: Option<&str>,
|
||||
verbose: bool,
|
||||
) -> Result<(), String> {
|
||||
if verbose {
|
||||
let cmd_str = format!("+ {} {}", cmd, args.join(" "));
|
||||
if let Some(inp) = input {
|
||||
let filtered: Vec<&str> = inp.lines().filter(|l| !l.is_empty()).collect();
|
||||
println!("{} <\n{}", cmd_str, filtered.join("\n"));
|
||||
} else {
|
||||
println!("{cmd_str}");
|
||||
}
|
||||
}
|
||||
|
||||
let stdin_cfg = if input.is_some() {
|
||||
Stdio::piped()
|
||||
} else {
|
||||
Stdio::null()
|
||||
};
|
||||
|
||||
let mut child = Command::new(cmd)
|
||||
.args(args)
|
||||
.stdin(stdin_cfg)
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to execute `{cmd}`: {e}"))?;
|
||||
|
||||
if let Some(input_data) = input {
|
||||
if let Some(ref mut stdin_handle) = child.stdin {
|
||||
stdin_handle
|
||||
.write_all(input_data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write to stdin of `{cmd}`: {e}"))?;
|
||||
}
|
||||
drop(child.stdin.take());
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|e| format!("Failed to wait for `{cmd}`: {e}"))?;
|
||||
|
||||
if !status.success() {
|
||||
let code = status.code().unwrap_or(-1);
|
||||
if code == 130 {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("Command `{cmd}` exited with code {code}"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a command, streaming stderr to the parent process, while intercepting
|
||||
/// stdout line-by-line through a callback. Each stdout line is forwarded to
|
||||
/// the parent's stdout after the callback processes it.
|
||||
pub fn run_command_streaming_with_callback<F>(
|
||||
cmd: &str,
|
||||
args: &[&str],
|
||||
input: Option<&str>,
|
||||
verbose: bool,
|
||||
mut on_stdout: F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
if verbose {
|
||||
let cmd_str = format!("+ {} {}", cmd, args.join(" "));
|
||||
if let Some(inp) = input {
|
||||
let filtered: Vec<&str> = inp.lines().filter(|l| !l.is_empty()).collect();
|
||||
println!("{} <\n{}", cmd_str, filtered.join("\n"));
|
||||
} else {
|
||||
println!("{cmd_str}");
|
||||
}
|
||||
}
|
||||
|
||||
let stdin_cfg = if input.is_some() {
|
||||
Stdio::piped()
|
||||
} else {
|
||||
Stdio::null()
|
||||
};
|
||||
|
||||
let mut child = Command::new(cmd)
|
||||
.args(args)
|
||||
.stdin(stdin_cfg)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to execute `{cmd}`: {e}"))?;
|
||||
|
||||
if let Some(input_data) = input {
|
||||
if let Some(ref mut stdin_handle) = child.stdin {
|
||||
stdin_handle
|
||||
.write_all(input_data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write to stdin of `{cmd}`: {e}"))?;
|
||||
}
|
||||
drop(child.stdin.take());
|
||||
}
|
||||
|
||||
// Read stdout line by line, forward to our stdout, and call callback
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines() {
|
||||
match line {
|
||||
Ok(line) => {
|
||||
println!("{line}");
|
||||
on_stdout(&line);
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|e| format!("Failed to wait for `{cmd}`: {e}"))?;
|
||||
|
||||
if !status.success() {
|
||||
let code = status.code().unwrap_or(-1);
|
||||
if code == 130 {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("Command `{cmd}` exited with code {code}"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
mod analytics;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod constants;
|
||||
mod docker;
|
||||
mod exec;
|
||||
mod progress;
|
||||
mod templates;
|
||||
mod util;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "langgraph", version = VERSION, about = "LangGraph CLI")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Launch LangGraph API server with Docker
|
||||
Up {
|
||||
/// Path to configuration file declaring dependencies, graphs and environment variables
|
||||
#[arg(short, long, default_value = "langgraph.json")]
|
||||
config: String,
|
||||
|
||||
/// Port to expose
|
||||
#[arg(short, long, default_value_t = 8123)]
|
||||
port: u16,
|
||||
|
||||
/// Path to docker-compose.yml file with additional services
|
||||
#[arg(short, long)]
|
||||
docker_compose: Option<String>,
|
||||
|
||||
/// Show detailed output
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
|
||||
/// Restart on file changes using docker compose watch
|
||||
#[arg(short, long)]
|
||||
watch: bool,
|
||||
|
||||
/// Recreate containers even if configuration hasn't changed
|
||||
#[arg(long)]
|
||||
recreate: bool,
|
||||
|
||||
/// Skip pulling latest images before running
|
||||
#[arg(long)]
|
||||
no_pull: bool,
|
||||
|
||||
/// Wait for services to be healthy before returning
|
||||
#[arg(long)]
|
||||
wait: bool,
|
||||
|
||||
/// Port to expose the debugger on
|
||||
#[arg(long)]
|
||||
debugger_port: Option<u16>,
|
||||
|
||||
/// Base URL for the debugger
|
||||
#[arg(long)]
|
||||
debugger_base_url: Option<String>,
|
||||
|
||||
/// Postgres connection URI
|
||||
#[arg(long)]
|
||||
postgres_uri: Option<String>,
|
||||
|
||||
/// API version of the LangGraph server
|
||||
#[arg(long)]
|
||||
api_version: Option<String>,
|
||||
|
||||
/// Pre-built image to use instead of building
|
||||
#[arg(long)]
|
||||
image: Option<String>,
|
||||
|
||||
/// Base image for the LangGraph API server
|
||||
#[arg(long)]
|
||||
base_image: Option<String>,
|
||||
},
|
||||
|
||||
/// Build LangGraph API server Docker image
|
||||
Build {
|
||||
/// Path to configuration file
|
||||
#[arg(short, long, default_value = "langgraph.json")]
|
||||
config: String,
|
||||
|
||||
/// Tag for the docker image
|
||||
#[arg(short, long)]
|
||||
tag: String,
|
||||
|
||||
/// Skip pulling latest images before building
|
||||
#[arg(long)]
|
||||
no_pull: bool,
|
||||
|
||||
/// Base image for the LangGraph API server
|
||||
#[arg(long)]
|
||||
base_image: Option<String>,
|
||||
|
||||
/// API version of the LangGraph server
|
||||
#[arg(long)]
|
||||
api_version: Option<String>,
|
||||
|
||||
/// Custom install command
|
||||
#[arg(long)]
|
||||
install_command: Option<String>,
|
||||
|
||||
/// Custom build command
|
||||
#[arg(long)]
|
||||
build_command: Option<String>,
|
||||
|
||||
/// Additional arguments to pass to docker build
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
docker_build_args: Vec<String>,
|
||||
},
|
||||
|
||||
/// Generate a Dockerfile for the LangGraph API server
|
||||
Dockerfile {
|
||||
/// Path to save the generated Dockerfile
|
||||
save_path: String,
|
||||
|
||||
/// Path to configuration file
|
||||
#[arg(short, long, default_value = "langgraph.json")]
|
||||
config: String,
|
||||
|
||||
/// Add docker-compose.yml, .env, and .dockerignore files
|
||||
#[arg(long)]
|
||||
add_docker_compose: bool,
|
||||
|
||||
/// Base image for the LangGraph API server
|
||||
#[arg(long)]
|
||||
base_image: Option<String>,
|
||||
|
||||
/// API version of the LangGraph server
|
||||
#[arg(long)]
|
||||
api_version: Option<String>,
|
||||
},
|
||||
|
||||
/// Run LangGraph API server in development mode
|
||||
Dev {
|
||||
/// Network interface to bind to
|
||||
#[arg(long, default_value = "127.0.0.1")]
|
||||
host: String,
|
||||
|
||||
/// Port number
|
||||
#[arg(long, default_value_t = 2024)]
|
||||
port: u16,
|
||||
|
||||
/// Disable automatic reloading
|
||||
#[arg(long)]
|
||||
no_reload: bool,
|
||||
|
||||
/// Path to configuration file
|
||||
#[arg(short, long, default_value = "langgraph.json")]
|
||||
config: String,
|
||||
|
||||
/// Max concurrent jobs per worker
|
||||
#[arg(long)]
|
||||
n_jobs_per_worker: Option<u32>,
|
||||
|
||||
/// Skip opening browser
|
||||
#[arg(long)]
|
||||
no_browser: bool,
|
||||
|
||||
/// Enable remote debugging on specified port
|
||||
#[arg(long)]
|
||||
debug_port: Option<u16>,
|
||||
|
||||
/// Wait for debugger client to connect
|
||||
#[arg(long)]
|
||||
wait_for_client: bool,
|
||||
|
||||
/// URL of LangGraph Studio
|
||||
#[arg(long)]
|
||||
studio_url: Option<String>,
|
||||
|
||||
/// Allow synchronous I/O blocking operations
|
||||
#[arg(long)]
|
||||
allow_blocking: bool,
|
||||
|
||||
/// Expose via public tunnel
|
||||
#[arg(long)]
|
||||
tunnel: bool,
|
||||
|
||||
/// Log level for the API server
|
||||
#[arg(long, default_value = "WARNING")]
|
||||
server_log_level: String,
|
||||
},
|
||||
|
||||
/// Create a new LangGraph project from a template
|
||||
New {
|
||||
/// Path to create the project
|
||||
path: Option<String>,
|
||||
|
||||
/// Template to use
|
||||
#[arg(long)]
|
||||
template: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let result = match cli.command {
|
||||
Commands::Up {
|
||||
config,
|
||||
port,
|
||||
docker_compose,
|
||||
verbose,
|
||||
watch,
|
||||
recreate,
|
||||
no_pull,
|
||||
wait,
|
||||
debugger_port,
|
||||
debugger_base_url,
|
||||
postgres_uri,
|
||||
api_version,
|
||||
image,
|
||||
base_image,
|
||||
} => commands::up::run(
|
||||
&config,
|
||||
port,
|
||||
docker_compose.as_deref(),
|
||||
verbose,
|
||||
watch,
|
||||
recreate,
|
||||
!no_pull,
|
||||
wait,
|
||||
debugger_port,
|
||||
debugger_base_url.as_deref(),
|
||||
postgres_uri.as_deref(),
|
||||
api_version.as_deref(),
|
||||
image.as_deref(),
|
||||
base_image.as_deref(),
|
||||
),
|
||||
Commands::Build {
|
||||
config,
|
||||
tag,
|
||||
no_pull,
|
||||
base_image,
|
||||
api_version,
|
||||
install_command,
|
||||
build_command,
|
||||
docker_build_args,
|
||||
} => commands::build_cmd::run(
|
||||
&config,
|
||||
&tag,
|
||||
!no_pull,
|
||||
base_image.as_deref(),
|
||||
api_version.as_deref(),
|
||||
install_command.as_deref(),
|
||||
build_command.as_deref(),
|
||||
&docker_build_args,
|
||||
),
|
||||
Commands::Dockerfile {
|
||||
save_path,
|
||||
config,
|
||||
add_docker_compose,
|
||||
base_image,
|
||||
api_version,
|
||||
} => commands::dockerfile::run(
|
||||
&save_path,
|
||||
&config,
|
||||
add_docker_compose,
|
||||
base_image.as_deref(),
|
||||
api_version.as_deref(),
|
||||
),
|
||||
Commands::Dev {
|
||||
host,
|
||||
port,
|
||||
no_reload,
|
||||
config,
|
||||
n_jobs_per_worker,
|
||||
no_browser,
|
||||
debug_port,
|
||||
wait_for_client,
|
||||
studio_url,
|
||||
allow_blocking,
|
||||
tunnel,
|
||||
server_log_level,
|
||||
} => commands::dev::run(
|
||||
&host,
|
||||
port,
|
||||
no_reload,
|
||||
&config,
|
||||
n_jobs_per_worker,
|
||||
no_browser,
|
||||
debug_port,
|
||||
wait_for_client,
|
||||
studio_url.as_deref(),
|
||||
allow_blocking,
|
||||
tunnel,
|
||||
&server_log_level,
|
||||
),
|
||||
Commands::New { path, template } => {
|
||||
commands::new::run(path.as_deref(), template.as_deref())
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
eprintln!("Error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
|
||||
/// Terminal spinner using indicatif.
|
||||
///
|
||||
/// Wraps an indicatif spinner that displays an animated progress indicator
|
||||
/// with a configurable message.
|
||||
pub struct Progress {
|
||||
spinner: ProgressBar,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
/// Create a new spinner with the given initial message.
|
||||
pub fn new(message: &str) -> Self {
|
||||
let spinner = ProgressBar::new_spinner();
|
||||
spinner.set_style(
|
||||
ProgressStyle::default_spinner()
|
||||
.tick_strings(&["|", "/", "-", "\\", ""])
|
||||
.template("{spinner} {msg}")
|
||||
.unwrap_or_else(|_| ProgressStyle::default_spinner()),
|
||||
);
|
||||
spinner.set_message(message.to_string());
|
||||
spinner.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
Self { spinner }
|
||||
}
|
||||
|
||||
/// Update the spinner message.
|
||||
///
|
||||
/// If the message is empty, the spinner is effectively hidden but still running.
|
||||
pub fn set_message(&self, msg: &str) {
|
||||
self.spinner.set_message(msg.to_string());
|
||||
}
|
||||
|
||||
/// Stop the spinner and clear it from the terminal.
|
||||
pub fn finish(&self) {
|
||||
self.spinner.finish_and_clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Progress {
|
||||
fn drop(&mut self) {
|
||||
self.spinner.finish_and_clear();
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Cursor, Read};
|
||||
use std::path::Path;
|
||||
|
||||
use console::style;
|
||||
use dialoguer::{Input, Select};
|
||||
|
||||
/// A project template definition.
|
||||
pub struct Template {
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub python: &'static str,
|
||||
pub js: &'static str,
|
||||
}
|
||||
|
||||
/// All available project templates.
|
||||
pub const TEMPLATES: &[Template] = &[
|
||||
Template {
|
||||
name: "New LangGraph Project",
|
||||
description: "A simple, minimal chatbot with memory.",
|
||||
python: "https://github.com/langchain-ai/new-langgraph-project/archive/refs/heads/main.zip",
|
||||
js: "https://github.com/langchain-ai/new-langgraphjs-project/archive/refs/heads/main.zip",
|
||||
},
|
||||
Template {
|
||||
name: "ReAct Agent",
|
||||
description: "A simple agent that can be flexibly extended to many tools.",
|
||||
python: "https://github.com/langchain-ai/react-agent/archive/refs/heads/main.zip",
|
||||
js: "https://github.com/langchain-ai/react-agent-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
Template {
|
||||
name: "Memory Agent",
|
||||
description: "A ReAct-style agent with an additional tool to store memories for use across conversational threads.",
|
||||
python: "https://github.com/langchain-ai/memory-agent/archive/refs/heads/main.zip",
|
||||
js: "https://github.com/langchain-ai/memory-agent-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
Template {
|
||||
name: "Retrieval Agent",
|
||||
description: "An agent that includes a retrieval-based question-answering system.",
|
||||
python: "https://github.com/langchain-ai/retrieval-agent-template/archive/refs/heads/main.zip",
|
||||
js: "https://github.com/langchain-ai/retrieval-agent-template-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
Template {
|
||||
name: "Data-enrichment Agent",
|
||||
description: "An agent that performs web searches and organizes its findings into a structured format.",
|
||||
python: "https://github.com/langchain-ai/data-enrichment/archive/refs/heads/main.zip",
|
||||
js: "https://github.com/langchain-ai/data-enrichment-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
];
|
||||
|
||||
/// Mapping from template ID (e.g., "react-agent-python") to (template index, language, url).
|
||||
pub fn build_template_id_map() -> HashMap<String, (usize, &'static str, &'static str)> {
|
||||
let mut map = HashMap::new();
|
||||
for (idx, tmpl) in TEMPLATES.iter().enumerate() {
|
||||
let base = tmpl.name.to_lowercase().replace(' ', "-");
|
||||
map.insert(
|
||||
format!("{base}-python"),
|
||||
(idx, "python", tmpl.python),
|
||||
);
|
||||
map.insert(format!("{base}-js"), (idx, "js", tmpl.js));
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Get a sorted list of all valid template IDs.
|
||||
pub fn template_ids() -> Vec<String> {
|
||||
let map = build_template_id_map();
|
||||
let mut ids: Vec<String> = map.keys().cloned().collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
|
||||
/// Interactively choose a template. Returns the download URL.
|
||||
fn choose_template() -> Result<String, String> {
|
||||
eprintln!("{}", style("Please select a template:").bold().yellow());
|
||||
let items: Vec<String> = TEMPLATES
|
||||
.iter()
|
||||
.map(|t| format!("{} - {}", t.name, t.description))
|
||||
.collect();
|
||||
|
||||
let selection = Select::new()
|
||||
.with_prompt("Select a template")
|
||||
.items(&items)
|
||||
.default(0)
|
||||
.interact()
|
||||
.map_err(|e| format!("Template selection failed: {e}"))?;
|
||||
|
||||
let tmpl = &TEMPLATES[selection];
|
||||
eprintln!(
|
||||
"\n{}",
|
||||
style(format!("You selected: {} - {}", tmpl.name, tmpl.description)).green()
|
||||
);
|
||||
|
||||
let lang_items = vec!["Python", "JS/TS"];
|
||||
let lang_choice = Select::new()
|
||||
.with_prompt("Choose language")
|
||||
.items(&lang_items)
|
||||
.default(0)
|
||||
.interact()
|
||||
.map_err(|e| format!("Language selection failed: {e}"))?;
|
||||
|
||||
let url = if lang_choice == 0 {
|
||||
tmpl.python
|
||||
} else {
|
||||
tmpl.js
|
||||
};
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
/// Download a zip archive from `url` and extract to `path`.
|
||||
fn download_repo(url: &str, path: &str) -> Result<(), String> {
|
||||
eprintln!(
|
||||
"{}",
|
||||
style("Downloading repository as a ZIP archive...").yellow()
|
||||
);
|
||||
eprintln!("{}", style(format!("URL: {url}")).yellow());
|
||||
|
||||
// Use blocking reqwest to download
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.map_err(|e| format!("Failed to download repository: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to download repository. HTTP status: {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.map_err(|e| format!("Failed to read response body: {e}"))?;
|
||||
|
||||
let target_path = Path::new(path);
|
||||
if !target_path.exists() {
|
||||
std::fs::create_dir_all(target_path)
|
||||
.map_err(|e| format!("Failed to create directory {path}: {e}"))?;
|
||||
}
|
||||
|
||||
let cursor = Cursor::new(bytes);
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to open ZIP archive: {e}"))?;
|
||||
|
||||
// Extract all files
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("Failed to read ZIP entry: {e}"))?;
|
||||
let name = file.name().to_string();
|
||||
|
||||
// Strip the top-level directory (e.g., "repo-main/")
|
||||
let stripped = match name.split_once('/') {
|
||||
Some((_, rest)) if !rest.is_empty() => rest.to_string(),
|
||||
_ => continue, // Skip the top-level directory entry itself
|
||||
};
|
||||
|
||||
let out_path = target_path.join(&stripped);
|
||||
|
||||
if file.is_dir() {
|
||||
std::fs::create_dir_all(&out_path)
|
||||
.map_err(|e| format!("Failed to create directory {}: {e}", out_path.display()))?;
|
||||
} else {
|
||||
if let Some(parent) = out_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
format!("Failed to create directory {}: {e}", parent.display())
|
||||
})?;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)
|
||||
.map_err(|e| format!("Failed to read file from ZIP: {e}"))?;
|
||||
std::fs::write(&out_path, &buf)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", out_path.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!("Downloaded and extracted repository to {path}")).green()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new LangGraph project at the specified path using the chosen template.
|
||||
///
|
||||
/// If `path` is None, the user is prompted interactively.
|
||||
/// If `template` is None, the user picks from an interactive menu.
|
||||
pub fn create_new(path: Option<&str>, template: Option<&str>) -> Result<(), String> {
|
||||
// Prompt for path if not provided
|
||||
let path = match path {
|
||||
Some(p) => p.to_string(),
|
||||
None => {
|
||||
let input: String = Input::new()
|
||||
.with_prompt("Please specify the path to create the application")
|
||||
.default(".".to_string())
|
||||
.interact_text()
|
||||
.map_err(|e| format!("Input failed: {e}"))?;
|
||||
input
|
||||
}
|
||||
};
|
||||
|
||||
let abs_path = std::path::Path::new(&path)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from(&path));
|
||||
let abs_path_str = abs_path.to_string_lossy().to_string();
|
||||
|
||||
// If the path doesn't exist yet, that's fine. But if it exists and is non-empty, abort.
|
||||
if abs_path.exists() {
|
||||
let entries = std::fs::read_dir(&abs_path)
|
||||
.map_err(|e| format!("Could not read directory {abs_path_str}: {e}"))?;
|
||||
if entries.count() > 0 {
|
||||
return Err(
|
||||
"The specified directory already exists and is not empty. \
|
||||
Aborting to prevent overwriting files."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get template URL either from command-line argument or through interactive selection
|
||||
let template_url = if let Some(tmpl_id) = template {
|
||||
let id_map = build_template_id_map();
|
||||
if let Some((_idx, _lang, url)) = id_map.get(tmpl_id) {
|
||||
url.to_string()
|
||||
} else {
|
||||
let ids = template_ids();
|
||||
let mut options = String::new();
|
||||
for id in &ids {
|
||||
let (_idx, _lang, _url) = id_map.get(id.as_str()).unwrap();
|
||||
let tmpl = &TEMPLATES[*_idx];
|
||||
options.push_str(&format!("- {id}: {}\n", tmpl.description));
|
||||
}
|
||||
return Err(format!(
|
||||
"Template '{tmpl_id}' not found.\n\
|
||||
Please select from the available options:\n{options}"
|
||||
));
|
||||
}
|
||||
} else {
|
||||
choose_template()?
|
||||
};
|
||||
|
||||
// Download and extract the template
|
||||
download_repo(&template_url, &abs_path_str)?;
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!("New project created at {abs_path_str}"))
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
use console::style;
|
||||
|
||||
/// Remove empty lines from a string.
|
||||
pub fn clean_empty_lines(input: &str) -> String {
|
||||
input
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Show warning if image_distro is not set to 'wolfi'.
|
||||
pub fn warn_non_wolfi_distro(config: &serde_json::Value) {
|
||||
let image_distro = config
|
||||
.get("image_distro")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("debian");
|
||||
|
||||
if image_distro != "wolfi" {
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(
|
||||
"Warning: Security Recommendation: Consider switching to Wolfi Linux for enhanced security."
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(
|
||||
" Wolfi is a security-oriented, minimal Linux distribution designed for containers."
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(
|
||||
" To switch, add '\"image_distro\": \"wolfi\"' to your langgraph.json config file."
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_clean_empty_lines() {
|
||||
assert_eq!(clean_empty_lines("line1\n\nline2\n\nline3"), "line1\nline2\nline3");
|
||||
assert_eq!(clean_empty_lines("line1\nline2\nline3"), "line1\nline2\nline3");
|
||||
assert_eq!(clean_empty_lines("\n\n\n"), "");
|
||||
assert_eq!(clean_empty_lines(""), "");
|
||||
}
|
||||
}
|
||||
Generated
+3
-3
@@ -1071,15 +1071,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-sdk"
|
||||
version = "0.3.5"
|
||||
version = "0.3.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "orjson", marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/2b/2dae368ac76e315197f07ab58077aadf20833c226fbfd450d71745850314/langgraph_sdk-0.3.5.tar.gz", hash = "sha256:64669e9885a908578eed921ef9a8e52b8d0cd38db1e3e5d6d299d4e6f8830ac0", size = 177470, upload-time = "2026-02-10T16:56:09.18Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3e/ec/477fa8b408f948b145d90fd935c0a9f37945fa5ec1dfabfc71e7cafba6d8/langgraph_sdk-0.3.6.tar.gz", hash = "sha256:7650f607f89c1586db5bee391b1a8754cbe1fc83b721ff2f1450f8906e790bd7", size = 182666, upload-time = "2026-02-14T19:46:03.752Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/d5/a14d957c515ba7a9713bf0f03f2b9277979c403bc50f829bdfd54ae7dc9e/langgraph_sdk-0.3.5-py3-none-any.whl", hash = "sha256:bcfa1dcbddadb604076ce46f5e08969538735e5ac47fa863d4fac5a512dab5c9", size = 70851, upload-time = "2026-02-10T16:56:07.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/61/12508e12652edd1874327271a5a8834c728a605f53a1a1c945f13ab69664/langgraph_sdk-0.3.6-py3-none-any.whl", hash = "sha256:7df2fd552ad7262d0baf8e1f849dce1d62186e76dcdd36db9dc5bdfa5c3fc20f", size = 88277, upload-time = "2026-02-14T19:46:02.48Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -728,7 +728,6 @@ async def _acall_impl(
|
||||
)
|
||||
else:
|
||||
fut.set_result(None)
|
||||
futures()[fut] = next_task # type: ignore[index]
|
||||
else:
|
||||
# schedule the next task
|
||||
fut = cast(
|
||||
|
||||
@@ -20,10 +20,13 @@ from dataclasses import is_dataclass
|
||||
from functools import partial
|
||||
from inspect import isclass
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Generic,
|
||||
Literal,
|
||||
cast,
|
||||
get_type_hints,
|
||||
overload,
|
||||
)
|
||||
from uuid import UUID, uuid5
|
||||
|
||||
@@ -48,6 +51,9 @@ from langgraph.store.base import BaseStore
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
from typing_extensions import Self, Unpack, deprecated, is_typeddict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.messages import AnyMessage
|
||||
|
||||
from langgraph._internal._config import (
|
||||
ensure_config,
|
||||
merge_configs,
|
||||
@@ -2404,6 +2410,186 @@ class Pregel(
|
||||
durability,
|
||||
)
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["values"],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Iterator[OutputT]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["updates"],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Iterator[dict[str, Any]]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["messages"],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Iterator[tuple[AnyMessage, dict[str, Any]]]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["custom"],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Iterator[Any]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["values"],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[True],
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Iterator[tuple[tuple[str, ...], OutputT]]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["updates"],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[True],
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Iterator[tuple[tuple[str, ...], dict[str, Any]]]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["messages"],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[True],
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Iterator[tuple[tuple[str, ...], tuple[AnyMessage, dict[str, Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["custom"],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[True],
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Iterator[tuple[tuple[str, ...], Any]]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: list[StreamMode],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Iterator[tuple[str, Any]]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: list[StreamMode],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[True],
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> Iterator[tuple[tuple[str, ...], str, Any]]: ...
|
||||
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
@@ -2678,6 +2864,186 @@ class Pregel(
|
||||
run_manager.on_chain_error(e)
|
||||
raise
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["values"],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> AsyncIterator[OutputT]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["updates"],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> AsyncIterator[dict[str, Any]]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["messages"],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> AsyncIterator[tuple[AnyMessage, dict[str, Any]]]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["custom"],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> AsyncIterator[Any]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["values"],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[True],
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> AsyncIterator[tuple[tuple[str, ...], OutputT]]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["updates"],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[True],
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> AsyncIterator[tuple[tuple[str, ...], dict[str, Any]]]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["messages"],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[True],
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> AsyncIterator[tuple[tuple[str, ...], tuple[AnyMessage, dict[str, Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["custom"],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[True],
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> AsyncIterator[tuple[tuple[str, ...], Any]]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: list[StreamMode],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> AsyncIterator[tuple[str, Any]]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: list[StreamMode],
|
||||
print_mode: StreamMode | Sequence[StreamMode] = (),
|
||||
output_keys: str | Sequence[str] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
durability: Durability | None = None,
|
||||
subgraphs: Literal[True],
|
||||
debug: bool | None = None,
|
||||
**kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> AsyncIterator[tuple[tuple[str, ...], str, Any]]: ...
|
||||
|
||||
async def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
@@ -3068,13 +3434,15 @@ class Pregel(
|
||||
chunks: list[dict[str, Any] | Any] = []
|
||||
interrupts: list[Interrupt] = []
|
||||
|
||||
for chunk in self.stream(
|
||||
for chunk in self.stream( # type: ignore[misc]
|
||||
input,
|
||||
config,
|
||||
context=context,
|
||||
stream_mode=["updates", "values"]
|
||||
if stream_mode == "values"
|
||||
else stream_mode,
|
||||
stream_mode=(
|
||||
["updates", "values"] # type: ignore[arg-type]
|
||||
if stream_mode == "values"
|
||||
else stream_mode
|
||||
),
|
||||
print_mode=print_mode,
|
||||
output_keys=output_keys,
|
||||
interrupt_before=interrupt_before,
|
||||
@@ -3158,13 +3526,15 @@ class Pregel(
|
||||
chunks: list[dict[str, Any] | Any] = []
|
||||
interrupts: list[Interrupt] = []
|
||||
|
||||
async for chunk in self.astream(
|
||||
async for chunk in self.astream( # type: ignore[misc]
|
||||
input,
|
||||
config,
|
||||
context=context,
|
||||
stream_mode=["updates", "values"]
|
||||
if stream_mode == "values"
|
||||
else stream_mode,
|
||||
stream_mode=(
|
||||
["updates", "values"] # type: ignore[arg-type]
|
||||
if stream_mode == "values"
|
||||
else stream_mode
|
||||
),
|
||||
print_mode=print_mode,
|
||||
output_keys=output_keys,
|
||||
interrupt_before=interrupt_before,
|
||||
|
||||
@@ -2,13 +2,16 @@ from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
|
||||
from typing import Any, Generic, cast
|
||||
from typing import TYPE_CHECKING, Any, Generic, Literal, cast, overload
|
||||
|
||||
from langchain_core.runnables import Runnable, RunnableConfig
|
||||
from langchain_core.runnables.graph import Graph as DrawableGraph
|
||||
from typing_extensions import Self
|
||||
|
||||
from langgraph.types import All, Command, StateSnapshot, StateUpdate, StreamMode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.messages import AnyMessage
|
||||
from langgraph.typing import ContextT, InputT, OutputT, StateT
|
||||
|
||||
__all__ = ("PregelProtocol", "StreamProtocol")
|
||||
@@ -96,6 +99,146 @@ class PregelProtocol(Runnable[InputT, Any], Generic[StateT, ContextT, InputT, Ou
|
||||
as_node: str | None = None,
|
||||
) -> RunnableConfig: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["values"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
) -> Iterator[OutputT]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["updates"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
) -> Iterator[dict[str, Any]]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["messages"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
) -> Iterator[tuple[AnyMessage, dict[str, Any]]]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["custom"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
) -> Iterator[Any]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["values"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
) -> Iterator[tuple[tuple[str, ...], OutputT]]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["updates"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
) -> Iterator[tuple[tuple[str, ...], dict[str, Any]]]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["messages"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
) -> Iterator[tuple[tuple[str, ...], tuple[AnyMessage, dict[str, Any]]]]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["custom"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
) -> Iterator[tuple[tuple[str, ...], Any]]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: list[StreamMode],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
) -> Iterator[tuple[str, Any]]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def stream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: list[StreamMode],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
) -> Iterator[tuple[tuple[str, ...], str, Any]]: ...
|
||||
|
||||
@abstractmethod
|
||||
def stream(
|
||||
self,
|
||||
@@ -109,6 +252,146 @@ class PregelProtocol(Runnable[InputT, Any], Generic[StateT, ContextT, InputT, Ou
|
||||
subgraphs: bool = False,
|
||||
) -> Iterator[dict[str, Any] | Any]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["values"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
) -> AsyncIterator[OutputT]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["updates"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
) -> AsyncIterator[dict[str, Any]]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["messages"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
) -> AsyncIterator[tuple[AnyMessage, dict[str, Any]]]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["custom"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
) -> AsyncIterator[Any]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["values"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
) -> AsyncIterator[tuple[tuple[str, ...], OutputT]]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["updates"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
) -> AsyncIterator[tuple[tuple[str, ...], dict[str, Any]]]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["messages"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
) -> AsyncIterator[tuple[tuple[str, ...], tuple[AnyMessage, dict[str, Any]]]]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: Literal["custom"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
) -> AsyncIterator[tuple[tuple[str, ...], Any]]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: list[StreamMode],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
) -> AsyncIterator[tuple[str, Any]]: ...
|
||||
|
||||
@overload
|
||||
@abstractmethod
|
||||
def astream(
|
||||
self,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: list[StreamMode],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
) -> AsyncIterator[tuple[tuple[str, ...], str, Any]]: ...
|
||||
|
||||
@abstractmethod
|
||||
def astream(
|
||||
self,
|
||||
|
||||
@@ -4,9 +4,11 @@ import logging
|
||||
from collections.abc import AsyncIterator, Iterator, Sequence
|
||||
from dataclasses import asdict
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Literal,
|
||||
cast,
|
||||
overload,
|
||||
)
|
||||
from uuid import UUID
|
||||
|
||||
@@ -41,6 +43,9 @@ from langgraph_sdk.schema import (
|
||||
)
|
||||
from typing_extensions import Self
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.messages import AnyMessage
|
||||
|
||||
from langgraph._internal._config import merge_configs
|
||||
from langgraph._internal._constants import (
|
||||
CONF,
|
||||
@@ -682,6 +687,156 @@ class RemoteGraph(PregelProtocol):
|
||||
updated_stream_modes.remove("events")
|
||||
return (updated_stream_modes, requested_stream_modes, req_single, stream)
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: Literal["values"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[dict[str, Any]]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: Literal["updates"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[dict[str, Any]]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: Literal["messages"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[tuple[AnyMessage, dict[str, Any]]]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: Literal["custom"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[Any]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: Literal["values"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[tuple[tuple[str, ...], dict[str, Any]]]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: Literal["updates"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[tuple[tuple[str, ...], dict[str, Any]]]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: Literal["messages"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[tuple[tuple[str, ...], tuple[AnyMessage, dict[str, Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: Literal["custom"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[tuple[tuple[str, ...], Any]]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: list[StreamMode],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[tuple[str, Any]]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: list[StreamMode],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[tuple[tuple[str, ...], str, Any]]: ...
|
||||
|
||||
def stream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
@@ -792,6 +947,156 @@ class RemoteGraph(PregelProtocol):
|
||||
else:
|
||||
yield chunk
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: Literal["values"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[dict[str, Any]]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: Literal["updates"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[dict[str, Any]]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: Literal["messages"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[tuple[AnyMessage, dict[str, Any]]]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: Literal["custom"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[Any]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: Literal["values"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[tuple[tuple[str, ...], dict[str, Any]]]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: Literal["updates"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[tuple[tuple[str, ...], dict[str, Any]]]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: Literal["messages"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[tuple[tuple[str, ...], tuple[AnyMessage, dict[str, Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: Literal["custom"],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[tuple[tuple[str, ...], Any]]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: list[StreamMode],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[False] = False,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[tuple[str, Any]]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
stream_mode: list[StreamMode],
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
subgraphs: Literal[True],
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[tuple[tuple[str, ...], str, Any]]: ...
|
||||
|
||||
async def astream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "1.0.8"
|
||||
version = "1.0.9"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -27,7 +27,7 @@ dependencies = [
|
||||
"langchain-core>=0.1",
|
||||
"langgraph-checkpoint>=2.1.0,<5.0.0",
|
||||
"langgraph-sdk>=0.3.0,<0.4.0",
|
||||
"langgraph-prebuilt>=1.0.7,<1.1.0",
|
||||
"langgraph-prebuilt>=1.0.8,<1.1.0",
|
||||
"xxhash>=3.5.0",
|
||||
"pydantic>=2.7.4",
|
||||
]
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5800,6 +5800,284 @@ def test_multiple_interrupts_functional_cache(
|
||||
assert counter == 6
|
||||
|
||||
|
||||
def test_task_before_interrupt_resume(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly when a @task runs
|
||||
before interrupt-producing tasks in an @entrypoint.
|
||||
|
||||
The @task wrapper on both setup and ask is essential to reproduce the bug:
|
||||
- @task on setup triggers a mid-step put_writes (creating a new pending_writes list)
|
||||
- @task on ask means interrupt() runs in a child scratchpad that must
|
||||
delegate to the parent for null resume consumption tracking
|
||||
"""
|
||||
|
||||
@entrypoint(checkpointer=sync_checkpointer)
|
||||
def workflow(number_of_topics: int) -> dict:
|
||||
@task
|
||||
def setup() -> int:
|
||||
return number_of_topics
|
||||
|
||||
@task
|
||||
def ask(question: str) -> str:
|
||||
return interrupt(question)
|
||||
|
||||
n = setup().result()
|
||||
|
||||
answers = []
|
||||
for i in range(n):
|
||||
q = f"Whats the answer for topic {i + 1}?"
|
||||
answers.append(ask(q).result())
|
||||
|
||||
return {"answers": answers}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - should get first interrupt
|
||||
result = workflow.invoke(2, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 1?"
|
||||
|
||||
# Resume with answer for topic 1 - should get second interrupt
|
||||
result = workflow.invoke(Command(resume="answer1"), config=config)
|
||||
assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}"
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 2?"
|
||||
|
||||
# Resume with answer for topic 2 - should get final result
|
||||
result = workflow.invoke(Command(resume="answer2"), config=config)
|
||||
assert result == {"answers": ["answer1", "answer2"]}
|
||||
|
||||
|
||||
def test_multiple_tasks_before_interrupt_resume(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly when multiple @tasks
|
||||
run before an interrupt-producing task in an @entrypoint."""
|
||||
|
||||
@entrypoint(checkpointer=sync_checkpointer)
|
||||
def workflow(inputs: dict) -> dict:
|
||||
@task
|
||||
def step_a(x: int) -> int:
|
||||
return x + 1
|
||||
|
||||
@task
|
||||
def step_b(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
@task
|
||||
def ask(question: str) -> str:
|
||||
return interrupt(question)
|
||||
|
||||
a = step_a(inputs["x"]).result()
|
||||
b = step_b(a).result()
|
||||
|
||||
answer = ask(f"Result so far is {b}. What next?").result()
|
||||
|
||||
return {"computed": b, "answer": answer}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - should get interrupt
|
||||
result = workflow.invoke({"x": 5}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "Result so far is 12. What next?"
|
||||
|
||||
# Resume
|
||||
result = workflow.invoke(Command(resume="continue"), config=config)
|
||||
assert result == {"computed": 12, "answer": "continue"}
|
||||
|
||||
|
||||
def test_no_redundant_put_writes_for_cached_task(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Cached @tasks on resume must not trigger redundant put_writes."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from langgraph.pregel._loop import PregelLoop
|
||||
|
||||
@task
|
||||
def setup(x: int) -> int:
|
||||
return x
|
||||
|
||||
@task
|
||||
def ask(question: str) -> str:
|
||||
return interrupt(question)
|
||||
|
||||
@entrypoint(checkpointer=sync_checkpointer)
|
||||
def workflow(x: int) -> dict:
|
||||
n = setup(x).result()
|
||||
answer = ask(f"q{n}").result()
|
||||
return {"answer": answer}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
result = workflow.invoke(1, config=config)
|
||||
assert "__interrupt__" in result
|
||||
|
||||
put_writes_task_ids: list[str] = []
|
||||
orig = PregelLoop.put_writes
|
||||
|
||||
def spy(self, task_id, writes):
|
||||
put_writes_task_ids.append(task_id)
|
||||
return orig(self, task_id, writes)
|
||||
|
||||
with patch.object(PregelLoop, "put_writes", spy):
|
||||
result = workflow.invoke(Command(resume="ans"), config=config)
|
||||
|
||||
assert result == {"answer": "ans"}
|
||||
# Count unique non-null task IDs that got put_writes.
|
||||
# Should be exactly 2: the ask task and the entrypoint task.
|
||||
# If 3, the cached setup task is being redundantly re-committed.
|
||||
non_null = set(tid for tid in put_writes_task_ids if not tid.startswith("00000000"))
|
||||
assert len(non_null) == 2, (
|
||||
f"Expected 2 task IDs in put_writes (ask + entrypoint), got {len(non_null)}"
|
||||
)
|
||||
|
||||
|
||||
def test_node_before_interrupt_resume_graph_api(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly in a StateGraph when a
|
||||
node runs before a node that calls interrupt(). This is the graph-API
|
||||
analog of test_task_before_interrupt_resume (entrypoint API)."""
|
||||
|
||||
class State(TypedDict):
|
||||
topics: list[str]
|
||||
answers: Annotated[list[str], operator.add]
|
||||
|
||||
def setup(state: State) -> dict:
|
||||
return {"topics": [f"topic {i + 1}" for i in range(len(state["topics"]))]}
|
||||
|
||||
def ask(state: State) -> dict:
|
||||
answers = []
|
||||
for topic in state["topics"]:
|
||||
answer = interrupt(f"Whats the answer for {topic}?")
|
||||
answers.append(answer)
|
||||
return {"answers": answers}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("setup", setup)
|
||||
.add_node("ask", ask)
|
||||
.add_edge(START, "setup")
|
||||
.add_edge("setup", "ask")
|
||||
.add_edge("ask", END)
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - setup runs, then ask interrupts on the first topic
|
||||
result = graph.invoke({"topics": ["a", "b"], "answers": []}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 1?"
|
||||
|
||||
# Resume with answer for topic 1 - should get second interrupt
|
||||
result = graph.invoke(Command(resume="answer1"), config=config)
|
||||
assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}"
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 2?"
|
||||
|
||||
# Resume with answer for topic 2 - should complete
|
||||
result = graph.invoke(Command(resume="answer2"), config=config)
|
||||
assert result == {
|
||||
"topics": ["topic 1", "topic 2"],
|
||||
"answers": ["answer1", "answer2"],
|
||||
}
|
||||
|
||||
|
||||
def test_multiple_nodes_before_interrupt_resume_graph_api(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly in a StateGraph when
|
||||
multiple nodes run before a node that calls interrupt(). This is the
|
||||
graph-API analog of test_multiple_tasks_before_interrupt_resume."""
|
||||
|
||||
class State(TypedDict):
|
||||
value: int
|
||||
answer: str
|
||||
|
||||
def step_a(state: State) -> dict:
|
||||
return {"value": state["value"] + 1}
|
||||
|
||||
def step_b(state: State) -> dict:
|
||||
return {"value": state["value"] * 2}
|
||||
|
||||
def ask(state: State) -> dict:
|
||||
answer = interrupt(f"Result so far is {state['value']}. What next?")
|
||||
return {"answer": answer}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("step_a", step_a)
|
||||
.add_node("step_b", step_b)
|
||||
.add_node("ask", ask)
|
||||
.add_edge(START, "step_a")
|
||||
.add_edge("step_a", "step_b")
|
||||
.add_edge("step_b", "ask")
|
||||
.add_edge("ask", END)
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - step_a and step_b run, then ask interrupts
|
||||
result = graph.invoke({"value": 5, "answer": ""}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "Result so far is 12. What next?"
|
||||
|
||||
# Resume - should complete
|
||||
result = graph.invoke(Command(resume="continue"), config=config)
|
||||
assert result == {"value": 12, "answer": "continue"}
|
||||
|
||||
|
||||
def test_node_before_multiple_interrupt_cycles_graph_api(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that a node running before an interrupt node does not interfere
|
||||
with multiple interrupt/resume cycles in a StateGraph."""
|
||||
|
||||
class State(TypedDict):
|
||||
count: int
|
||||
data: str
|
||||
|
||||
def prepare(state: State) -> dict:
|
||||
return {"count": state["count"] + 10}
|
||||
|
||||
def multi_interrupt(state: State) -> dict:
|
||||
first = interrupt("First question?")
|
||||
second = interrupt("Second question?")
|
||||
return {"data": f"{first},{second}"}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("prepare", prepare)
|
||||
.add_node("multi_interrupt", multi_interrupt)
|
||||
.add_edge(START, "prepare")
|
||||
.add_edge("prepare", "multi_interrupt")
|
||||
.add_edge("multi_interrupt", END)
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - prepare runs, multi_interrupt hits first interrupt
|
||||
result = graph.invoke({"count": 0, "data": ""}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "First question?"
|
||||
|
||||
# Resume first interrupt - hits second interrupt
|
||||
result = graph.invoke(Command(resume="first_answer"), config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "Second question?"
|
||||
|
||||
# Resume second interrupt - completes
|
||||
result = graph.invoke(Command(resume="second_answer"), config=config)
|
||||
assert result == {"count": 10, "data": "first_answer,second_answer"}
|
||||
|
||||
|
||||
def test_double_interrupt_subgraph(sync_checkpointer: BaseCheckpointSaver) -> None:
|
||||
class AgentState(TypedDict):
|
||||
input: str
|
||||
|
||||
@@ -7920,6 +7920,290 @@ async def test_interrupts_in_tasks_surfaced_once(
|
||||
assert result[1] == "Added Will!"
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_task_before_interrupt_resume(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly when a @task runs
|
||||
before interrupt-producing tasks in an @entrypoint.
|
||||
|
||||
The @task wrapper on both setup and ask is essential to reproduce the bug:
|
||||
- @task on setup triggers a mid-step put_writes (creating a new pending_writes list)
|
||||
- @task on ask means interrupt() runs in a child scratchpad that must
|
||||
delegate to the parent for null resume consumption tracking
|
||||
"""
|
||||
|
||||
@entrypoint(checkpointer=async_checkpointer)
|
||||
async def workflow(number_of_topics: int) -> dict:
|
||||
@task
|
||||
async def setup() -> int:
|
||||
return number_of_topics
|
||||
|
||||
@task
|
||||
async def ask(question: str) -> str:
|
||||
return interrupt(question)
|
||||
|
||||
n = await setup()
|
||||
|
||||
answers = []
|
||||
for i in range(n):
|
||||
q = f"Whats the answer for topic {i + 1}?"
|
||||
answers.append(await ask(q))
|
||||
|
||||
return {"answers": answers}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - should get first interrupt
|
||||
result = await workflow.ainvoke(2, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 1?"
|
||||
|
||||
# Resume with answer for topic 1 - should get second interrupt
|
||||
result = await workflow.ainvoke(Command(resume="answer1"), config=config)
|
||||
assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}"
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 2?"
|
||||
|
||||
# Resume with answer for topic 2 - should get final result
|
||||
result = await workflow.ainvoke(Command(resume="answer2"), config=config)
|
||||
assert result == {"answers": ["answer1", "answer2"]}
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_multiple_tasks_before_interrupt_resume(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly when multiple @tasks
|
||||
run before an interrupt-producing task in an @entrypoint."""
|
||||
|
||||
@entrypoint(checkpointer=async_checkpointer)
|
||||
async def workflow(inputs: dict) -> dict:
|
||||
@task
|
||||
async def step_a(x: int) -> int:
|
||||
return x + 1
|
||||
|
||||
@task
|
||||
async def step_b(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
@task
|
||||
async def ask(question: str) -> str:
|
||||
return interrupt(question)
|
||||
|
||||
a = await step_a(inputs["x"])
|
||||
b = await step_b(a)
|
||||
|
||||
answer = await ask(f"Result so far is {b}. What next?")
|
||||
|
||||
return {"computed": b, "answer": answer}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - should get interrupt
|
||||
result = await workflow.ainvoke({"x": 5}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "Result so far is 12. What next?"
|
||||
|
||||
# Resume
|
||||
result = await workflow.ainvoke(Command(resume="continue"), config=config)
|
||||
assert result == {"computed": 12, "answer": "continue"}
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_no_redundant_put_writes_for_cached_task(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Cached @tasks on resume must not trigger redundant put_writes."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from langgraph.pregel._loop import PregelLoop
|
||||
|
||||
@task
|
||||
async def setup(x: int) -> int:
|
||||
return x
|
||||
|
||||
@task
|
||||
async def ask(question: str) -> str:
|
||||
return interrupt(question)
|
||||
|
||||
@entrypoint(checkpointer=async_checkpointer)
|
||||
async def workflow(x: int) -> dict:
|
||||
n = await setup(x)
|
||||
answer = await ask(f"q{n}")
|
||||
return {"answer": answer}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
result = await workflow.ainvoke(1, config=config)
|
||||
assert "__interrupt__" in result
|
||||
|
||||
put_writes_task_ids: list[str] = []
|
||||
orig = PregelLoop.put_writes
|
||||
|
||||
def spy(self, task_id, writes):
|
||||
put_writes_task_ids.append(task_id)
|
||||
return orig(self, task_id, writes)
|
||||
|
||||
with patch.object(PregelLoop, "put_writes", spy):
|
||||
result = await workflow.ainvoke(Command(resume="ans"), config=config)
|
||||
|
||||
assert result == {"answer": "ans"}
|
||||
# Count unique non-null task IDs that got put_writes.
|
||||
# Should be exactly 2: the ask task and the entrypoint task.
|
||||
# If 3, the cached setup task is being redundantly re-committed.
|
||||
non_null = set(tid for tid in put_writes_task_ids if not tid.startswith("00000000"))
|
||||
assert len(non_null) == 2, (
|
||||
f"Expected 2 task IDs in put_writes (ask + entrypoint), got {len(non_null)}"
|
||||
)
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_node_before_interrupt_resume_graph_api(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly in a StateGraph when a
|
||||
node runs before a node that calls interrupt(). This is the graph-API
|
||||
analog of test_task_before_interrupt_resume (entrypoint API)."""
|
||||
|
||||
class State(TypedDict):
|
||||
topics: list[str]
|
||||
answers: Annotated[list[str], operator.add]
|
||||
|
||||
def setup(state: State) -> dict:
|
||||
return {"topics": [f"topic {i + 1}" for i in range(len(state["topics"]))]}
|
||||
|
||||
def ask(state: State) -> dict:
|
||||
answers = []
|
||||
for topic in state["topics"]:
|
||||
answer = interrupt(f"Whats the answer for {topic}?")
|
||||
answers.append(answer)
|
||||
return {"answers": answers}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("setup", setup)
|
||||
.add_node("ask", ask)
|
||||
.add_edge(START, "setup")
|
||||
.add_edge("setup", "ask")
|
||||
.add_edge("ask", END)
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - setup runs, then ask interrupts on the first topic
|
||||
result = await graph.ainvoke({"topics": ["a", "b"], "answers": []}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 1?"
|
||||
|
||||
# Resume with answer for topic 1 - should get second interrupt
|
||||
result = await graph.ainvoke(Command(resume="answer1"), config=config)
|
||||
assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}"
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 2?"
|
||||
|
||||
# Resume with answer for topic 2 - should complete
|
||||
result = await graph.ainvoke(Command(resume="answer2"), config=config)
|
||||
assert result == {
|
||||
"topics": ["topic 1", "topic 2"],
|
||||
"answers": ["answer1", "answer2"],
|
||||
}
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_multiple_nodes_before_interrupt_resume_graph_api(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly in a StateGraph when
|
||||
multiple nodes run before a node that calls interrupt(). This is the
|
||||
graph-API analog of test_multiple_tasks_before_interrupt_resume."""
|
||||
|
||||
class State(TypedDict):
|
||||
value: int
|
||||
answer: str
|
||||
|
||||
def step_a(state: State) -> dict:
|
||||
return {"value": state["value"] + 1}
|
||||
|
||||
def step_b(state: State) -> dict:
|
||||
return {"value": state["value"] * 2}
|
||||
|
||||
def ask(state: State) -> dict:
|
||||
answer = interrupt(f"Result so far is {state['value']}. What next?")
|
||||
return {"answer": answer}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("step_a", step_a)
|
||||
.add_node("step_b", step_b)
|
||||
.add_node("ask", ask)
|
||||
.add_edge(START, "step_a")
|
||||
.add_edge("step_a", "step_b")
|
||||
.add_edge("step_b", "ask")
|
||||
.add_edge("ask", END)
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - step_a and step_b run, then ask interrupts
|
||||
result = await graph.ainvoke({"value": 5, "answer": ""}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "Result so far is 12. What next?"
|
||||
|
||||
# Resume - should complete
|
||||
result = await graph.ainvoke(Command(resume="continue"), config=config)
|
||||
assert result == {"value": 12, "answer": "continue"}
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_node_before_multiple_interrupt_cycles_graph_api(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that a node running before an interrupt node does not interfere
|
||||
with multiple interrupt/resume cycles in a StateGraph."""
|
||||
|
||||
class State(TypedDict):
|
||||
count: int
|
||||
data: str
|
||||
|
||||
def prepare(state: State) -> dict:
|
||||
return {"count": state["count"] + 10}
|
||||
|
||||
def multi_interrupt(state: State) -> dict:
|
||||
first = interrupt("First question?")
|
||||
second = interrupt("Second question?")
|
||||
return {"data": f"{first},{second}"}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("prepare", prepare)
|
||||
.add_node("multi_interrupt", multi_interrupt)
|
||||
.add_edge(START, "prepare")
|
||||
.add_edge("prepare", "multi_interrupt")
|
||||
.add_edge("multi_interrupt", END)
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - prepare runs, multi_interrupt hits first interrupt
|
||||
result = await graph.ainvoke({"count": 0, "data": ""}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "First question?"
|
||||
|
||||
# Resume first interrupt - hits second interrupt
|
||||
result = await graph.ainvoke(Command(resume="first_answer"), config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "Second question?"
|
||||
|
||||
# Resume second interrupt - completes
|
||||
result = await graph.ainvoke(Command(resume="second_answer"), config=config)
|
||||
assert result == {"count": 10, "data": "first_answer,second_answer"}
|
||||
|
||||
|
||||
async def test_pregel_loop_refcount():
|
||||
gc.collect()
|
||||
try:
|
||||
|
||||
Generated
+4
-4
@@ -1367,7 +1367,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.0.8"
|
||||
version = "1.0.9"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -1607,7 +1607,7 @@ dependencies = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "orjson", specifier = ">=3.10.1" },
|
||||
{ name = "orjson", specifier = ">=3.11.5" },
|
||||
{ name = "psycopg", specifier = ">=3.2.0" },
|
||||
{ name = "psycopg-pool", specifier = ">=3.2.0" },
|
||||
]
|
||||
@@ -1735,7 +1735,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
source = { editable = "../prebuilt" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -1812,7 +1812,7 @@ dependencies = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.25.2" },
|
||||
{ name = "orjson", specifier = ">=3.10.1" },
|
||||
{ name = "orjson", specifier = ">=3.11.5" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
|
||||
@@ -922,7 +922,7 @@ class ToolNode(RunnableCallable):
|
||||
raise TypeError(msg)
|
||||
|
||||
# Inject state, store, and runtime right before invocation
|
||||
injected_call = self._inject_tool_args(call, request.runtime)
|
||||
injected_call = self._inject_tool_args(call, request.runtime, tool)
|
||||
call_args = {**injected_call, "type": "tool_call"}
|
||||
|
||||
try:
|
||||
@@ -1075,7 +1075,7 @@ class ToolNode(RunnableCallable):
|
||||
raise TypeError(msg)
|
||||
|
||||
# Inject state, store, and runtime right before invocation
|
||||
injected_call = self._inject_tool_args(call, request.runtime)
|
||||
injected_call = self._inject_tool_args(call, request.runtime, tool)
|
||||
call_args = {**injected_call, "type": "tool_call"}
|
||||
|
||||
try:
|
||||
@@ -1281,6 +1281,7 @@ class ToolNode(RunnableCallable):
|
||||
self,
|
||||
tool_call: ToolCall,
|
||||
tool_runtime: ToolRuntime,
|
||||
tool: BaseTool | None = None,
|
||||
) -> ToolCall:
|
||||
"""Inject graph state, store, and runtime into tool call arguments.
|
||||
|
||||
@@ -1299,6 +1300,9 @@ class ToolNode(RunnableCallable):
|
||||
Must contain 'name', 'args', 'id', and 'type' fields.
|
||||
tool_runtime: The ToolRuntime instance containing all runtime context
|
||||
(state, config, store, context, stream_writer) to inject into tools.
|
||||
tool: Optional tool instance. When provided, allows injection for
|
||||
dynamically registered tools that are not in self.tools_by_name
|
||||
(e.g., tools added via middleware's wrap_tool_call).
|
||||
|
||||
Returns:
|
||||
A new ToolCall dictionary with the same structure as the input but with
|
||||
@@ -1312,10 +1316,12 @@ class ToolNode(RunnableCallable):
|
||||
This method is called automatically during tool execution. It should not
|
||||
be called from outside the `ToolNode`.
|
||||
"""
|
||||
if tool_call["name"] not in self.tools_by_name:
|
||||
return tool_call
|
||||
|
||||
injected = self._injected_args.get(tool_call["name"])
|
||||
if not injected and tool is not None:
|
||||
# For dynamically registered tools (e.g., added via middleware's
|
||||
# wrap_tool_call), compute injected args on-the-fly since they
|
||||
# were not present during ToolNode initialization.
|
||||
injected = _get_all_injected_args(tool)
|
||||
if not injected:
|
||||
return tool_call
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -1902,3 +1902,109 @@ async def test_tool_node_tool_runtime_generic() -> None:
|
||||
assert tool_message.type == "tool"
|
||||
assert tool_message.content == "test_info"
|
||||
assert tool_message.tool_call_id == "call_1"
|
||||
|
||||
|
||||
def test_tool_node_inject_runtime_dynamic_tool_via_wrap_tool_call() -> None:
|
||||
"""Test that ToolRuntime is injected for dynamically registered tools.
|
||||
|
||||
Regression test for https://github.com/langchain-ai/langchain/issues/35305.
|
||||
When a tool is dynamically provided via wrap_tool_call (not registered at
|
||||
ToolNode init time), ToolRuntime should still be injected into the tool.
|
||||
"""
|
||||
|
||||
@dec_tool
|
||||
def static_tool(x: int) -> str:
|
||||
"""A static tool registered at init."""
|
||||
return f"static: {x}"
|
||||
|
||||
@dec_tool
|
||||
def dynamic_tool_with_runtime(x: int, runtime: ToolRuntime) -> str:
|
||||
"""A dynamic tool that needs ToolRuntime injection."""
|
||||
return f"dynamic: x={x}, tool_call_id={runtime.tool_call_id}"
|
||||
|
||||
def wrap_tool_call(request, execute):
|
||||
"""Middleware that swaps in a dynamic tool."""
|
||||
if request.tool_call["name"] == "dynamic_tool_with_runtime":
|
||||
# Override tool to the dynamic one (not registered at init)
|
||||
new_request = request.override(tool=dynamic_tool_with_runtime)
|
||||
return execute(new_request)
|
||||
return execute(request)
|
||||
|
||||
# ToolNode only knows about static_tool at init time
|
||||
tool_node = ToolNode(
|
||||
[static_tool],
|
||||
wrap_tool_call=wrap_tool_call,
|
||||
)
|
||||
|
||||
# Verify the dynamic tool is NOT in the tool node's registered tools
|
||||
assert "dynamic_tool_with_runtime" not in tool_node.tools_by_name
|
||||
|
||||
# Call the dynamic tool
|
||||
tool_call = {
|
||||
"name": "dynamic_tool_with_runtime",
|
||||
"args": {"x": 42},
|
||||
"id": "call_dynamic_1",
|
||||
"type": "tool_call",
|
||||
}
|
||||
msg = AIMessage("", tool_calls=[tool_call])
|
||||
result = tool_node.invoke(
|
||||
{"messages": [msg]},
|
||||
config=_create_config_with_runtime(),
|
||||
)
|
||||
|
||||
# ToolRuntime should be injected and the tool should execute successfully
|
||||
tool_message = result["messages"][-1]
|
||||
assert tool_message.content == "dynamic: x=42, tool_call_id=call_dynamic_1"
|
||||
assert tool_message.tool_call_id == "call_dynamic_1"
|
||||
|
||||
|
||||
async def test_tool_node_inject_runtime_dynamic_tool_via_wrap_tool_call_async() -> None:
|
||||
"""Test that ToolRuntime is injected for dynamically registered tools (async).
|
||||
|
||||
Async version of the regression test for
|
||||
https://github.com/langchain-ai/langchain/issues/35305.
|
||||
"""
|
||||
|
||||
@dec_tool
|
||||
def static_tool(x: int) -> str:
|
||||
"""A static tool registered at init."""
|
||||
return f"static: {x}"
|
||||
|
||||
@dec_tool
|
||||
async def dynamic_tool_with_runtime(x: int, runtime: ToolRuntime) -> str:
|
||||
"""A dynamic async tool that needs ToolRuntime injection."""
|
||||
return f"dynamic: x={x}, tool_call_id={runtime.tool_call_id}"
|
||||
|
||||
async def awrap_tool_call(request, execute):
|
||||
"""Async middleware that swaps in a dynamic tool."""
|
||||
if request.tool_call["name"] == "dynamic_tool_with_runtime":
|
||||
new_request = request.override(tool=dynamic_tool_with_runtime)
|
||||
return await execute(new_request)
|
||||
return await execute(request)
|
||||
|
||||
# ToolNode only knows about static_tool at init time
|
||||
tool_node = ToolNode(
|
||||
[static_tool],
|
||||
awrap_tool_call=awrap_tool_call,
|
||||
)
|
||||
|
||||
# Verify the dynamic tool is NOT in the tool node's registered tools
|
||||
assert "dynamic_tool_with_runtime" not in tool_node.tools_by_name
|
||||
|
||||
# Call the dynamic tool
|
||||
tool_call = {
|
||||
"name": "dynamic_tool_with_runtime",
|
||||
"args": {"x": 42},
|
||||
"id": "call_dynamic_2",
|
||||
"type": "tool_call",
|
||||
}
|
||||
msg = AIMessage("", tool_calls=[tool_call])
|
||||
result = await tool_node.ainvoke(
|
||||
{"messages": [msg]},
|
||||
config=_create_config_with_runtime(),
|
||||
)
|
||||
|
||||
# ToolRuntime should be injected and the tool should execute successfully
|
||||
tool_message = result["messages"][-1]
|
||||
assert tool_message.content == "dynamic: x=42, tool_call_id=call_dynamic_2"
|
||||
assert tool_message.tool_call_id == "call_dynamic_2"
|
||||
|
||||
Generated
+7
-7
@@ -249,7 +249,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.12"
|
||||
version = "1.2.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -261,14 +261,14 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uuid-utils" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/1d/08e935d1532fcc90981f6e5bb6825914c9227ea7a962c62b1e18619b49e7/langchain_core-1.2.12.tar.gz", hash = "sha256:4d7fa6643d7ab06fc1905a9b7dcbe96a6f3c181046b56edf9c0c17ecd412d9e9", size = 831329, upload-time = "2026-02-12T20:53:15.01Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/bb/c501ca60556c11ac80d1454bdcac63cb33583ce4e64fc4535ad5a7d5c6ba/langchain_core-1.2.13.tar.gz", hash = "sha256:d2773d0d0130a356378db9a858cfeef64c3d64bc03722f1d4d6c40eb46fdf01b", size = 831612, upload-time = "2026-02-15T07:45:57.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/a5/678ab0e5cc57794f20ae5ed12c1442506ef1108c9434f950aebc6044e5a3/langchain_core-1.2.12-py3-none-any.whl", hash = "sha256:66ca17a2a9cb007ab29021968e6adfcf4228067151dc2bd6ebfff265ffaf92f5", size = 500132, upload-time = "2026-02-12T20:53:13.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/ab/60fd69e5d55f67d422baefddaaca523c42cd7510ab6aeb17db6ae57fb107/langchain_core-1.2.13-py3-none-any.whl", hash = "sha256:b31823e28d3eff1e237096d0bd3bf80c6f9624eb471a9496dbfbd427779f8d82", size = 500485, upload-time = "2026-02-15T07:45:55.422Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.0.8"
|
||||
version = "1.0.9"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -411,7 +411,7 @@ dependencies = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "orjson", specifier = ">=3.10.1" },
|
||||
{ name = "orjson", specifier = ">=3.11.5" },
|
||||
{ name = "psycopg", specifier = ">=3.2.0" },
|
||||
{ name = "psycopg-pool", specifier = ">=3.2.0" },
|
||||
]
|
||||
@@ -489,7 +489,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -585,7 +585,7 @@ dependencies = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.25.2" },
|
||||
{ name = "orjson", specifier = ">=3.10.1" },
|
||||
{ name = "orjson", specifier = ">=3.11.5" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
|
||||
@@ -3,6 +3,6 @@ from langgraph_sdk.client import get_client, get_sync_client
|
||||
from langgraph_sdk.encryption import Encryption
|
||||
from langgraph_sdk.encryption.types import EncryptionContext
|
||||
|
||||
__version__ = "0.3.6"
|
||||
__version__ = "0.3.8"
|
||||
|
||||
__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"]
|
||||
|
||||
@@ -290,7 +290,7 @@ class AssistantsClient:
|
||||
"""
|
||||
get_params = {"recurse": recurse}
|
||||
if params:
|
||||
get_params = {**get_params, **params}
|
||||
get_params = {**get_params, **dict(params)}
|
||||
if namespace is not None:
|
||||
return await self.http.get(
|
||||
f"/assistants/{assistant_id}/subgraphs/{namespace}",
|
||||
@@ -425,9 +425,9 @@ class AssistantsClient:
|
||||
payload: dict[str, Any] = {}
|
||||
if graph_id:
|
||||
payload["graph_id"] = graph_id
|
||||
if config:
|
||||
if config is not None:
|
||||
payload["config"] = config
|
||||
if context:
|
||||
if context is not None:
|
||||
payload["context"] = context
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
|
||||
@@ -110,7 +110,7 @@ def get_client(
|
||||
if url is None:
|
||||
url = "http://api"
|
||||
if os.environ.get("__LANGGRAPH_DEFER_LOOPBACK_TRANSPORT") == "true":
|
||||
transport = get_asgi_transport()(app=None, root_path="/noauth")
|
||||
transport = get_asgi_transport()(app=None, root_path="/noauth") # type: ignore[invalid-argument-type]
|
||||
_registered_transports.append(transport)
|
||||
else:
|
||||
try:
|
||||
@@ -122,7 +122,7 @@ def get_client(
|
||||
"Failed to connect to in-process LangGraph server. Deferring configuration.",
|
||||
exc_info=True,
|
||||
)
|
||||
transport = get_asgi_transport()(app=None, root_path="/noauth")
|
||||
transport = get_asgi_transport()(app=None, root_path="/noauth") # type: ignore[invalid-argument-type]
|
||||
_registered_transports.append(transport)
|
||||
|
||||
if transport is None:
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import warnings
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -14,11 +15,13 @@ from langgraph_sdk.schema import (
|
||||
Cron,
|
||||
CronSelectField,
|
||||
CronSortBy,
|
||||
Durability,
|
||||
Input,
|
||||
OnCompletionBehavior,
|
||||
QueryParamTypes,
|
||||
Run,
|
||||
SortOrder,
|
||||
StreamMode,
|
||||
)
|
||||
|
||||
|
||||
@@ -60,13 +63,17 @@ class CronClient:
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
config: Config | None = None,
|
||||
context: Context | None = None,
|
||||
checkpoint_during: bool | None = None,
|
||||
checkpoint_during: bool | None = None, # deprecated
|
||||
interrupt_before: All | list[str] | None = None,
|
||||
interrupt_after: All | list[str] | None = None,
|
||||
webhook: str | None = None,
|
||||
multitask_strategy: str | None = None,
|
||||
end_time: datetime | None = None,
|
||||
enabled: bool | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
|
||||
stream_subgraphs: bool | None = None,
|
||||
stream_resumable: bool | None = None,
|
||||
durability: Durability | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> Run:
|
||||
@@ -83,7 +90,7 @@ class CronClient:
|
||||
config: The configuration for the assistant.
|
||||
context: Static context to add to the assistant.
|
||||
!!! version-added "Added in version 0.6.0"
|
||||
checkpoint_during: Whether to checkpoint during the run (or only at the end/interruption).
|
||||
checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption).
|
||||
interrupt_before: Nodes to interrupt immediately before they get executed.
|
||||
|
||||
interrupt_after: Nodes to Nodes to interrupt immediately after they get executed.
|
||||
@@ -93,6 +100,13 @@ class CronClient:
|
||||
Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'.
|
||||
end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely.
|
||||
enabled: Whether the cron job is enabled or not.
|
||||
stream_mode: The stream mode(s) to use.
|
||||
stream_subgraphs: Whether to stream output from subgraphs.
|
||||
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
|
||||
durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'.
|
||||
"async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True
|
||||
"sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False
|
||||
"exit" means checkpoints are only persisted when the run exits, does not save intermediate steps
|
||||
headers: Optional custom headers to include with the request.
|
||||
params: Optional query parameters to include with the request.
|
||||
|
||||
@@ -118,6 +132,13 @@ class CronClient:
|
||||
)
|
||||
```
|
||||
"""
|
||||
if checkpoint_during is not None:
|
||||
warnings.warn(
|
||||
"`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"schedule": schedule,
|
||||
"input": input,
|
||||
@@ -131,6 +152,10 @@ class CronClient:
|
||||
"webhook": webhook,
|
||||
"end_time": end_time.isoformat() if end_time else None,
|
||||
"enabled": enabled,
|
||||
"stream_mode": stream_mode,
|
||||
"stream_subgraphs": stream_subgraphs,
|
||||
"stream_resumable": stream_resumable,
|
||||
"durability": durability,
|
||||
}
|
||||
if multitask_strategy:
|
||||
payload["multitask_strategy"] = multitask_strategy
|
||||
@@ -151,7 +176,7 @@ class CronClient:
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
config: Config | None = None,
|
||||
context: Context | None = None,
|
||||
checkpoint_during: bool | None = None,
|
||||
checkpoint_during: bool | None = None, # deprecated
|
||||
interrupt_before: All | list[str] | None = None,
|
||||
interrupt_after: All | list[str] | None = None,
|
||||
webhook: str | None = None,
|
||||
@@ -159,6 +184,10 @@ class CronClient:
|
||||
multitask_strategy: str | None = None,
|
||||
end_time: datetime | None = None,
|
||||
enabled: bool | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
|
||||
stream_subgraphs: bool | None = None,
|
||||
stream_resumable: bool | None = None,
|
||||
durability: Durability | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> Run:
|
||||
@@ -174,7 +203,7 @@ class CronClient:
|
||||
config: The configuration for the assistant.
|
||||
context: Static context to add to the assistant.
|
||||
!!! version-added "Added in version 0.6.0"
|
||||
checkpoint_during: Whether to checkpoint during the run (or only at the end/interruption).
|
||||
checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption).
|
||||
interrupt_before: Nodes to interrupt immediately before they get executed.
|
||||
interrupt_after: Nodes to Nodes to interrupt immediately after they get executed.
|
||||
webhook: Webhook to call after LangGraph API call is done.
|
||||
@@ -186,6 +215,13 @@ class CronClient:
|
||||
Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'.
|
||||
end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely.
|
||||
enabled: Whether the cron job is enabled or not.
|
||||
stream_mode: The stream mode(s) to use.
|
||||
stream_subgraphs: Whether to stream output from subgraphs.
|
||||
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
|
||||
durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'.
|
||||
"async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True
|
||||
"sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False
|
||||
"exit" means checkpoints are only persisted when the run exits, does not save intermediate steps
|
||||
headers: Optional custom headers to include with the request.
|
||||
params: Optional query parameters to include with the request.
|
||||
|
||||
@@ -211,6 +247,13 @@ class CronClient:
|
||||
```
|
||||
|
||||
"""
|
||||
if checkpoint_during is not None:
|
||||
warnings.warn(
|
||||
"`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"schedule": schedule,
|
||||
"input": input,
|
||||
@@ -225,6 +268,10 @@ class CronClient:
|
||||
"on_run_completed": on_run_completed,
|
||||
"end_time": end_time.isoformat() if end_time else None,
|
||||
"enabled": enabled,
|
||||
"stream_mode": stream_mode,
|
||||
"stream_subgraphs": stream_subgraphs,
|
||||
"stream_resumable": stream_resumable,
|
||||
"durability": durability,
|
||||
}
|
||||
if multitask_strategy:
|
||||
payload["multitask_strategy"] = multitask_strategy
|
||||
@@ -277,6 +324,10 @@ class CronClient:
|
||||
interrupt_after: All | list[str] | None = None,
|
||||
on_run_completed: OnCompletionBehavior | None = None,
|
||||
enabled: bool | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
|
||||
stream_subgraphs: bool | None = None,
|
||||
stream_resumable: bool | None = None,
|
||||
durability: Durability | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> Cron:
|
||||
@@ -299,6 +350,10 @@ class CronClient:
|
||||
after execution. 'keep' creates a new thread for each execution but does not
|
||||
clean them up.
|
||||
enabled: Enable or disable the cron job.
|
||||
stream_mode: The stream mode(s) to use.
|
||||
stream_subgraphs: Whether to stream output from subgraphs.
|
||||
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
|
||||
durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'.
|
||||
headers: Optional custom headers to include with the request.
|
||||
params: Optional query parameters to include with the request.
|
||||
|
||||
@@ -329,6 +384,10 @@ class CronClient:
|
||||
"interrupt_after": interrupt_after,
|
||||
"on_run_completed": on_run_completed,
|
||||
"enabled": enabled,
|
||||
"stream_mode": stream_mode,
|
||||
"stream_subgraphs": stream_subgraphs,
|
||||
"stream_resumable": stream_resumable,
|
||||
"durability": durability,
|
||||
}
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
return await self.http.patch(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import warnings
|
||||
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
||||
from typing import Any, overload
|
||||
@@ -507,11 +508,11 @@ class RunsClient:
|
||||
|
||||
async def create_batch(
|
||||
self,
|
||||
payloads: list[RunCreate],
|
||||
payloads: builtins.list[RunCreate],
|
||||
*,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> list[Run]:
|
||||
) -> builtins.list[Run]:
|
||||
"""Create a batch of stateless background runs."""
|
||||
|
||||
def filter_payload(payload: RunCreate):
|
||||
@@ -547,7 +548,7 @@ class RunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
) -> list[dict] | dict[str, Any]: ...
|
||||
) -> builtins.list[dict] | dict[str, Any]: ...
|
||||
|
||||
@overload
|
||||
async def wait(
|
||||
@@ -572,7 +573,7 @@ class RunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
) -> list[dict] | dict[str, Any]: ...
|
||||
) -> builtins.list[dict] | dict[str, Any]: ...
|
||||
|
||||
async def wait(
|
||||
self,
|
||||
@@ -600,7 +601,7 @@ class RunsClient:
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
) -> list[dict] | dict[str, Any]:
|
||||
) -> builtins.list[dict] | dict[str, Any]:
|
||||
"""Create a run, wait until it finishes and return the final state.
|
||||
|
||||
Args:
|
||||
@@ -751,10 +752,10 @@ class RunsClient:
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
status: RunStatus | None = None,
|
||||
select: list[RunSelectField] | None = None,
|
||||
select: builtins.list[RunSelectField] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> list[Run]:
|
||||
) -> builtins.list[Run]:
|
||||
"""List runs.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -138,7 +138,7 @@ class StoreClient:
|
||||
if refresh_ttl is not None:
|
||||
get_params["refresh_ttl"] = refresh_ttl
|
||||
if params:
|
||||
get_params = {**get_params, **params}
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return await self.http.get("/store/items", params=get_params, headers=headers)
|
||||
|
||||
async def delete_item(
|
||||
|
||||
@@ -543,7 +543,7 @@ class ThreadsClient:
|
||||
elif checkpoint_id:
|
||||
get_params = {"subgraphs": subgraphs}
|
||||
if params:
|
||||
get_params = {**get_params, **params}
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return await self.http.get(
|
||||
f"/threads/{thread_id}/state/{checkpoint_id}",
|
||||
params=get_params,
|
||||
@@ -552,7 +552,7 @@ class ThreadsClient:
|
||||
else:
|
||||
get_params = {"subgraphs": subgraphs}
|
||||
if params:
|
||||
get_params = {**get_params, **params}
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return await self.http.get(
|
||||
f"/threads/{thread_id}/state",
|
||||
params=get_params,
|
||||
|
||||
@@ -294,7 +294,7 @@ class SyncAssistantsClient:
|
||||
"""
|
||||
get_params = {"recurse": recurse}
|
||||
if params:
|
||||
get_params = {**get_params, **params}
|
||||
get_params = {**get_params, **dict(params)}
|
||||
if namespace is not None:
|
||||
return self.http.get(
|
||||
f"/assistants/{assistant_id}/subgraphs/{namespace}",
|
||||
@@ -427,9 +427,9 @@ class SyncAssistantsClient:
|
||||
payload: dict[str, Any] = {}
|
||||
if graph_id:
|
||||
payload["graph_id"] = graph_id
|
||||
if config:
|
||||
if config is not None:
|
||||
payload["config"] = config
|
||||
if context:
|
||||
if context is not None:
|
||||
payload["context"] = context
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import warnings
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -14,11 +15,13 @@ from langgraph_sdk.schema import (
|
||||
Cron,
|
||||
CronSelectField,
|
||||
CronSortBy,
|
||||
Durability,
|
||||
Input,
|
||||
OnCompletionBehavior,
|
||||
QueryParamTypes,
|
||||
Run,
|
||||
SortOrder,
|
||||
StreamMode,
|
||||
)
|
||||
|
||||
|
||||
@@ -54,13 +57,17 @@ class SyncCronClient:
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
config: Config | None = None,
|
||||
context: Context | None = None,
|
||||
checkpoint_during: bool | None = None,
|
||||
checkpoint_during: bool | None = None, # deprecated
|
||||
interrupt_before: All | list[str] | None = None,
|
||||
interrupt_after: All | list[str] | None = None,
|
||||
webhook: str | None = None,
|
||||
multitask_strategy: str | None = None,
|
||||
end_time: datetime | None = None,
|
||||
enabled: bool | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
|
||||
stream_subgraphs: bool | None = None,
|
||||
stream_resumable: bool | None = None,
|
||||
durability: Durability | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> Run:
|
||||
@@ -77,7 +84,7 @@ class SyncCronClient:
|
||||
config: The configuration for the assistant.
|
||||
context: Static context to add to the assistant.
|
||||
!!! version-added "Added in version 0.6.0"
|
||||
checkpoint_during: Whether to checkpoint during the run (or only at the end/interruption).
|
||||
checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption).
|
||||
interrupt_before: Nodes to interrupt immediately before they get executed.
|
||||
interrupt_after: Nodes to Nodes to interrupt immediately after they get executed.
|
||||
webhook: Webhook to call after LangGraph API call is done.
|
||||
@@ -85,6 +92,13 @@ class SyncCronClient:
|
||||
Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'.
|
||||
end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely.
|
||||
enabled: Whether the cron job is enabled. By default, it is considered enabled.
|
||||
stream_mode: The stream mode(s) to use.
|
||||
stream_subgraphs: Whether to stream output from subgraphs.
|
||||
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
|
||||
durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'.
|
||||
"async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True
|
||||
"sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False
|
||||
"exit" means checkpoints are only persisted when the run exits, does not save intermediate steps
|
||||
headers: Optional custom headers to include with the request.
|
||||
|
||||
Returns:
|
||||
@@ -109,6 +123,13 @@ class SyncCronClient:
|
||||
)
|
||||
```
|
||||
"""
|
||||
if checkpoint_during is not None:
|
||||
warnings.warn(
|
||||
"`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"schedule": schedule,
|
||||
"input": input,
|
||||
@@ -123,6 +144,10 @@ class SyncCronClient:
|
||||
"multitask_strategy": multitask_strategy,
|
||||
"end_time": end_time.isoformat() if end_time else None,
|
||||
"enabled": enabled,
|
||||
"stream_mode": stream_mode,
|
||||
"stream_subgraphs": stream_subgraphs,
|
||||
"stream_resumable": stream_resumable,
|
||||
"durability": durability,
|
||||
}
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
return self.http.post(
|
||||
@@ -141,7 +166,7 @@ class SyncCronClient:
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
config: Config | None = None,
|
||||
context: Context | None = None,
|
||||
checkpoint_during: bool | None = None,
|
||||
checkpoint_during: bool | None = None, # deprecated
|
||||
interrupt_before: All | list[str] | None = None,
|
||||
interrupt_after: All | list[str] | None = None,
|
||||
webhook: str | None = None,
|
||||
@@ -149,6 +174,10 @@ class SyncCronClient:
|
||||
multitask_strategy: str | None = None,
|
||||
end_time: datetime | None = None,
|
||||
enabled: bool | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
|
||||
stream_subgraphs: bool | None = None,
|
||||
stream_resumable: bool | None = None,
|
||||
durability: Durability | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> Run:
|
||||
@@ -164,7 +193,7 @@ class SyncCronClient:
|
||||
config: The configuration for the assistant.
|
||||
context: Static context to add to the assistant.
|
||||
!!! version-added "Added in version 0.6.0"
|
||||
checkpoint_during: Whether to checkpoint during the run (or only at the end/interruption).
|
||||
checkpoint_during: (deprecated) Whether to checkpoint during the run (or only at the end/interruption).
|
||||
interrupt_before: Nodes to interrupt immediately before they get executed.
|
||||
interrupt_after: Nodes to Nodes to interrupt immediately after they get executed.
|
||||
webhook: Webhook to call after LangGraph API call is done.
|
||||
@@ -176,6 +205,13 @@ class SyncCronClient:
|
||||
Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'.
|
||||
end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely.
|
||||
enabled: Whether the cron job is enabled. By default, it is considered enabled.
|
||||
stream_mode: The stream mode(s) to use.
|
||||
stream_subgraphs: Whether to stream output from subgraphs.
|
||||
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
|
||||
durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'.
|
||||
"async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True
|
||||
"sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False
|
||||
"exit" means checkpoints are only persisted when the run exits, does not save intermediate steps
|
||||
headers: Optional custom headers to include with the request.
|
||||
|
||||
Returns:
|
||||
@@ -201,6 +237,13 @@ class SyncCronClient:
|
||||
```
|
||||
|
||||
"""
|
||||
if checkpoint_during is not None:
|
||||
warnings.warn(
|
||||
"`checkpoint_during` is deprecated and will be removed in a future version. Use `durability` instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"schedule": schedule,
|
||||
"input": input,
|
||||
@@ -216,6 +259,10 @@ class SyncCronClient:
|
||||
"multitask_strategy": multitask_strategy,
|
||||
"end_time": end_time.isoformat() if end_time else None,
|
||||
"enabled": enabled,
|
||||
"stream_mode": stream_mode,
|
||||
"stream_subgraphs": stream_subgraphs,
|
||||
"stream_resumable": stream_resumable,
|
||||
"durability": durability,
|
||||
}
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
return self.http.post(
|
||||
@@ -266,6 +313,10 @@ class SyncCronClient:
|
||||
interrupt_after: All | list[str] | None = None,
|
||||
on_run_completed: OnCompletionBehavior | None = None,
|
||||
enabled: bool | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
|
||||
stream_subgraphs: bool | None = None,
|
||||
stream_resumable: bool | None = None,
|
||||
durability: Durability | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> Cron:
|
||||
@@ -288,6 +339,10 @@ class SyncCronClient:
|
||||
after execution. 'keep' creates a new thread for each execution but does not
|
||||
clean them up.
|
||||
enabled: Enable or disable the cron job.
|
||||
stream_mode: The stream mode(s) to use.
|
||||
stream_subgraphs: Whether to stream output from subgraphs.
|
||||
stream_resumable: Whether to persist the stream chunks in order to resume the stream later.
|
||||
durability: Durability level for the run. Must be one of 'sync', 'async', or 'exit'.
|
||||
headers: Optional custom headers to include with the request.
|
||||
params: Optional query parameters to include with the request.
|
||||
|
||||
@@ -318,6 +373,10 @@ class SyncCronClient:
|
||||
"interrupt_after": interrupt_after,
|
||||
"on_run_completed": on_run_completed,
|
||||
"enabled": enabled,
|
||||
"stream_mode": stream_mode,
|
||||
"stream_subgraphs": stream_subgraphs,
|
||||
"stream_resumable": stream_resumable,
|
||||
"durability": durability,
|
||||
}
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
return self.http.patch(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import warnings
|
||||
from collections.abc import Callable, Iterator, Mapping, Sequence
|
||||
from typing import Any, overload
|
||||
@@ -503,11 +504,11 @@ class SyncRunsClient:
|
||||
|
||||
def create_batch(
|
||||
self,
|
||||
payloads: list[RunCreate],
|
||||
payloads: builtins.list[RunCreate],
|
||||
*,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> list[Run]:
|
||||
) -> builtins.list[Run]:
|
||||
"""Create a batch of stateless background runs."""
|
||||
|
||||
def filter_payload(payload: RunCreate):
|
||||
@@ -543,7 +544,7 @@ class SyncRunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
) -> list[dict] | dict[str, Any]: ...
|
||||
) -> builtins.list[dict] | dict[str, Any]: ...
|
||||
|
||||
@overload
|
||||
def wait(
|
||||
@@ -568,7 +569,7 @@ class SyncRunsClient:
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
) -> list[dict] | dict[str, Any]: ...
|
||||
) -> builtins.list[dict] | dict[str, Any]: ...
|
||||
|
||||
def wait(
|
||||
self,
|
||||
@@ -596,7 +597,7 @@ class SyncRunsClient:
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
) -> list[dict] | dict[str, Any]:
|
||||
) -> builtins.list[dict] | dict[str, Any]:
|
||||
"""Create a run, wait until it finishes and return the final state.
|
||||
|
||||
Args:
|
||||
@@ -740,10 +741,10 @@ class SyncRunsClient:
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
status: RunStatus | None = None,
|
||||
select: list[RunSelectField] | None = None,
|
||||
select: builtins.list[RunSelectField] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> list[Run]:
|
||||
) -> builtins.list[Run]:
|
||||
"""List runs.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -530,7 +530,7 @@ class SyncThreadsClient:
|
||||
elif checkpoint_id:
|
||||
get_params = {"subgraphs": subgraphs}
|
||||
if params:
|
||||
get_params = {**get_params, **params}
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return self.http.get(
|
||||
f"/threads/{thread_id}/state/{checkpoint_id}",
|
||||
params=get_params,
|
||||
@@ -539,7 +539,7 @@ class SyncThreadsClient:
|
||||
else:
|
||||
get_params = {"subgraphs": subgraphs}
|
||||
if params:
|
||||
get_params = {**get_params, **params}
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return self.http.get(
|
||||
f"/threads/{thread_id}/state",
|
||||
params=get_params,
|
||||
|
||||
@@ -72,8 +72,13 @@ class Auth:
|
||||
assert params.get("metadata", {}).get("owner") == "allowed_user"
|
||||
|
||||
@auth.on.store
|
||||
async def authorize_store(ctx: Auth.types.AuthContext, value: Auth.types.on):
|
||||
assert ctx.user.identity in value["namespace"], "Not authorized"
|
||||
async def authorize_store(ctx: Auth.types.AuthContext, value: Auth.types.on.store.value):
|
||||
# Automatically scope all store operations to the user's namespace.
|
||||
namespace = tuple(value["namespace"]) if value.get("namespace") else ()
|
||||
assert isinstance(namespace, tuple)
|
||||
if not namespace or namespace[0] != ctx.user.identity:
|
||||
namespace = (ctx.user.identity, *namespace)
|
||||
value["namespace"] = namespace
|
||||
```
|
||||
|
||||
???+ note "Request Processing Flow"
|
||||
@@ -170,13 +175,32 @@ class Auth:
|
||||
```
|
||||
|
||||
Auth for the `store` resource is a bit different since its structure is developer defined.
|
||||
You typically want to enforce user creds in the namespace.
|
||||
You typically want to scope store operations by rewriting the namespace to include the user's identity.
|
||||
The `value` dict is mutable — changes to `value["namespace"]` are used by the server for the actual operation.
|
||||
|
||||
```python
|
||||
@auth.on.store
|
||||
async def check_store_access(ctx: AuthContext, value: Auth.types.on) -> bool:
|
||||
# Assuming you structure your store like (store.aput((user_id, application_context), key, value))
|
||||
assert value["namespace"][0] == ctx.user.identity
|
||||
async def authorize_store(ctx: AuthContext, value: Auth.types.on.store.value):
|
||||
# Automatically scope all store operations to the user's namespace.
|
||||
namespace = tuple(value["namespace"]) if value.get("namespace") else ()
|
||||
assert isinstance(namespace, tuple)
|
||||
if not namespace or namespace[0] != ctx.user.identity:
|
||||
namespace = (ctx.user.identity, *namespace)
|
||||
value["namespace"] = namespace
|
||||
```
|
||||
|
||||
You can also register handlers for specific store actions:
|
||||
|
||||
```python
|
||||
@auth.on.store.put
|
||||
async def on_put(ctx: AuthContext, value: Auth.types.on.store.put.value):
|
||||
# value has typed fields: namespace, key, value, index
|
||||
...
|
||||
|
||||
@auth.on.store.get
|
||||
async def on_get(ctx: AuthContext, value: Auth.types.on.store.get.value):
|
||||
# value has typed fields: namespace, key
|
||||
...
|
||||
```
|
||||
"""
|
||||
# These are accessed by the API. Changes to their names or types is
|
||||
@@ -483,9 +507,85 @@ class _CronsOn(
|
||||
Search = types.CronsSearch
|
||||
|
||||
|
||||
class _StoreActionOn(typing.Generic[T]):
|
||||
"""Decorator for registering a handler for a specific store action."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
auth: Auth,
|
||||
action: typing.Literal["put", "get", "search", "delete", "list_namespaces"],
|
||||
value: type[T],
|
||||
) -> None:
|
||||
self.auth = auth
|
||||
self.action = action
|
||||
self.value = value
|
||||
|
||||
def __call__(self, fn: _ActionHandler[T]) -> _ActionHandler[T]:
|
||||
_validate_handler(fn)
|
||||
_register_handler(self.auth, "store", self.action, fn)
|
||||
return fn
|
||||
|
||||
|
||||
class _StoreOn:
|
||||
def __init__(self, auth: Auth) -> None:
|
||||
self._auth = auth
|
||||
self.put = _StoreActionOn(auth, "put", types.StorePut)
|
||||
"""Register a handler for store put operations.
|
||||
|
||||
???+ example "Example"
|
||||
```python
|
||||
@auth.on.store.put
|
||||
async def on_store_put(ctx: Auth.types.AuthContext, value: Auth.types.on.store.put.value):
|
||||
# Scope puts to user's namespace
|
||||
...
|
||||
```
|
||||
"""
|
||||
self.get = _StoreActionOn(auth, "get", types.StoreGet)
|
||||
"""Register a handler for store get operations.
|
||||
|
||||
???+ example "Example"
|
||||
```python
|
||||
@auth.on.store.get
|
||||
async def on_store_get(ctx: Auth.types.AuthContext, value: Auth.types.on.store.get.value):
|
||||
# Scope gets to user's namespace
|
||||
...
|
||||
```
|
||||
"""
|
||||
self.search = _StoreActionOn(auth, "search", types.StoreSearch)
|
||||
"""Register a handler for store search operations.
|
||||
|
||||
???+ example "Example"
|
||||
```python
|
||||
@auth.on.store.search
|
||||
async def on_store_search(ctx: Auth.types.AuthContext, value: Auth.types.on.store.search.value):
|
||||
# Scope searches to user's namespace
|
||||
...
|
||||
```
|
||||
"""
|
||||
self.delete = _StoreActionOn(auth, "delete", types.StoreDelete)
|
||||
"""Register a handler for store delete operations.
|
||||
|
||||
???+ example "Example"
|
||||
```python
|
||||
@auth.on.store.delete
|
||||
async def on_store_delete(ctx: Auth.types.AuthContext, value: Auth.types.on.store.delete.value):
|
||||
# Scope deletes to user's namespace
|
||||
...
|
||||
```
|
||||
"""
|
||||
self.list_namespaces = _StoreActionOn(
|
||||
auth, "list_namespaces", types.StoreListNamespaces
|
||||
)
|
||||
"""Register a handler for store list_namespaces operations.
|
||||
|
||||
???+ example "Example"
|
||||
```python
|
||||
@auth.on.store.list_namespaces
|
||||
async def on_list_ns(ctx: Auth.types.AuthContext, value: Auth.types.on.store.list_namespaces.value):
|
||||
# Scope namespace listing to user's prefix
|
||||
...
|
||||
```
|
||||
"""
|
||||
|
||||
@typing.overload
|
||||
def __call__(
|
||||
|
||||
@@ -402,7 +402,7 @@ class AuthContext(BaseAuthContext):
|
||||
"list_namespaces",
|
||||
]
|
||||
"""The action being performed on the resource.
|
||||
|
||||
|
||||
Most resources support the following actions:
|
||||
- create: Create a new resource
|
||||
- read: Read information about a resource
|
||||
@@ -411,8 +411,10 @@ class AuthContext(BaseAuthContext):
|
||||
- search: Search for resources
|
||||
|
||||
The store supports the following actions:
|
||||
- put: Add or update a document in the store
|
||||
- get: Get a document from the store
|
||||
- put: Add or update an item in the store
|
||||
- get: Get an item from the store
|
||||
- search: Search for items within a namespace prefix
|
||||
- delete: Delete an item from the store
|
||||
- list_namespaces: List the namespaces in the store
|
||||
"""
|
||||
|
||||
@@ -851,20 +853,34 @@ class CronsSearch(typing.TypedDict, total=False):
|
||||
|
||||
|
||||
class StoreGet(typing.TypedDict):
|
||||
"""Operation to retrieve a specific item by its namespace and key."""
|
||||
"""Operation to retrieve a specific item by its namespace and key.
|
||||
|
||||
This dict is mutable — auth handlers can modify `namespace` to enforce
|
||||
access scoping (e.g., prepending the user's identity).
|
||||
"""
|
||||
|
||||
namespace: tuple[str, ...]
|
||||
"""Hierarchical path that uniquely identifies the item's location."""
|
||||
"""Hierarchical path that uniquely identifies the item's location.
|
||||
|
||||
Auth handlers can modify this to enforce per-user scoping.
|
||||
"""
|
||||
|
||||
key: str
|
||||
"""Unique identifier for the item within its specific namespace."""
|
||||
|
||||
|
||||
class StoreSearch(typing.TypedDict):
|
||||
"""Operation to search for items within a specified namespace hierarchy."""
|
||||
"""Operation to search for items within a specified namespace hierarchy.
|
||||
|
||||
This dict is mutable — auth handlers can modify `namespace` to enforce
|
||||
access scoping (e.g., prepending the user's identity).
|
||||
"""
|
||||
|
||||
namespace: tuple[str, ...]
|
||||
"""Prefix filter for defining the search scope."""
|
||||
"""Prefix filter for defining the search scope.
|
||||
|
||||
Auth handlers can modify this to enforce per-user scoping.
|
||||
"""
|
||||
|
||||
filter: dict[str, typing.Any] | None
|
||||
"""Key-value pairs for filtering results based on exact matches or comparison operators."""
|
||||
@@ -876,14 +892,22 @@ class StoreSearch(typing.TypedDict):
|
||||
"""Number of matching items to skip for pagination."""
|
||||
|
||||
query: str | None
|
||||
"""Naturalj language search query for semantic search capabilities."""
|
||||
"""Natural language search query for semantic search capabilities."""
|
||||
|
||||
|
||||
class StoreListNamespaces(typing.TypedDict):
|
||||
"""Operation to list and filter namespaces in the store."""
|
||||
"""Operation to list and filter namespaces in the store.
|
||||
|
||||
This dict is mutable — auth handlers can modify `namespace` (the prefix)
|
||||
to enforce access scoping (e.g., prepending the user's identity).
|
||||
"""
|
||||
|
||||
namespace: tuple[str, ...] | None
|
||||
"""Prefix filter namespaces."""
|
||||
"""Prefix filter for namespaces. Can be `None` if no prefix was provided.
|
||||
|
||||
Auth handlers can modify this to enforce per-user scoping. When `None`,
|
||||
handlers should set it to `(user_id,)` to scope listing to the user's namespaces.
|
||||
"""
|
||||
|
||||
suffix: tuple[str, ...] | None
|
||||
"""Optional conditions for filtering namespaces."""
|
||||
@@ -903,10 +927,17 @@ class StoreListNamespaces(typing.TypedDict):
|
||||
|
||||
|
||||
class StorePut(typing.TypedDict):
|
||||
"""Operation to store, update, or delete an item in the store."""
|
||||
"""Operation to store, update, or delete an item in the store.
|
||||
|
||||
This dict is mutable — auth handlers can modify `namespace` to enforce
|
||||
access scoping (e.g., prepending the user's identity).
|
||||
"""
|
||||
|
||||
namespace: tuple[str, ...]
|
||||
"""Hierarchical path that identifies the location of the item."""
|
||||
"""Hierarchical path that identifies the location of the item.
|
||||
|
||||
Auth handlers can modify this to enforce per-user scoping.
|
||||
"""
|
||||
|
||||
key: str
|
||||
"""Unique identifier for the item within its namespace."""
|
||||
@@ -919,10 +950,17 @@ class StorePut(typing.TypedDict):
|
||||
|
||||
|
||||
class StoreDelete(typing.TypedDict):
|
||||
"""Operation to delete an item from the store."""
|
||||
"""Operation to delete an item from the store.
|
||||
|
||||
This dict is mutable — auth handlers can modify `namespace` to enforce
|
||||
access scoping (e.g., prepending the user's identity).
|
||||
"""
|
||||
|
||||
namespace: tuple[str, ...]
|
||||
"""Hierarchical path that uniquely identifies the item's location."""
|
||||
"""Hierarchical path that uniquely identifies the item's location.
|
||||
|
||||
Auth handlers can modify this to enforce per-user scoping.
|
||||
"""
|
||||
|
||||
key: str
|
||||
"""Unique identifier for the item within its specific namespace."""
|
||||
|
||||
@@ -146,7 +146,9 @@ AssistantSortBy = Literal[
|
||||
The field to sort by.
|
||||
"""
|
||||
|
||||
ThreadSortBy = Literal["thread_id", "status", "created_at", "updated_at"]
|
||||
ThreadSortBy = Literal[
|
||||
"thread_id", "status", "created_at", "updated_at", "state_updated_at"
|
||||
]
|
||||
"""
|
||||
The field to sort by.
|
||||
"""
|
||||
@@ -422,6 +424,14 @@ class CronUpdate(TypedDict, total=False):
|
||||
"""What to do with the thread after the run completes."""
|
||||
enabled: bool
|
||||
"""Enable or disable the cron job."""
|
||||
stream_mode: StreamMode | list[StreamMode]
|
||||
"""The stream mode(s) to use."""
|
||||
stream_subgraphs: bool
|
||||
"""Whether to stream output from subgraphs."""
|
||||
stream_resumable: bool
|
||||
"""Whether to persist the stream chunks in order to resume the stream later."""
|
||||
durability: Durability
|
||||
"""Durability level for the run. Must be one of 'sync', 'async', or 'exit'."""
|
||||
|
||||
|
||||
# Select field aliases for client-side typing of `select` parameters.
|
||||
|
||||
@@ -11,7 +11,7 @@ requires-python = ">=3.10"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
dependencies = ["httpx>=0.25.2", "orjson>=3.10.1"]
|
||||
dependencies = ["httpx>=0.25.2", "orjson>=3.11.5"]
|
||||
|
||||
[tool.hatch.version]
|
||||
path = "langgraph_sdk/__init__.py"
|
||||
|
||||
@@ -19,7 +19,7 @@ class AsyncListByteStream(httpx.AsyncByteStream):
|
||||
self._chunks = list(chunks)
|
||||
self._exc = exc
|
||||
|
||||
async def __aiter__(self): # type: ignore[override]
|
||||
async def __aiter__(self):
|
||||
for chunk in self._chunks:
|
||||
yield chunk
|
||||
if self._exc is not None:
|
||||
@@ -34,7 +34,7 @@ class ListByteStream(httpx.ByteStream):
|
||||
self._chunks = list(chunks)
|
||||
self._exc = exc
|
||||
|
||||
def __iter__(self): # type: ignore[override]
|
||||
def __iter__(self):
|
||||
yield from self._chunks
|
||||
if self._exc is not None:
|
||||
raise self._exc
|
||||
|
||||
@@ -65,7 +65,7 @@ def test_raise_for_status_typed_maps_exceptions_and_sets_status_code(
|
||||
with pytest.raises(exc_type) as ei:
|
||||
_raise_for_status_typed(r)
|
||||
|
||||
err = cast("APIStatusError", ei.value)
|
||||
err = ei.value
|
||||
assert err.status_code == status
|
||||
# response attribute should be present and match
|
||||
assert err.response.status_code == status
|
||||
@@ -113,7 +113,7 @@ def test_error_message_in_str_and_args() -> None:
|
||||
r = make_response(422, json_body={"message": "Validation failed"})
|
||||
with pytest.raises(UnprocessableEntityError) as ei:
|
||||
_raise_for_status_typed(r)
|
||||
err = cast("UnprocessableEntityError", ei.value)
|
||||
err = ei.value
|
||||
assert str(err) == "Validation failed"
|
||||
assert err.args == ("Validation failed",)
|
||||
assert err.message == "Validation failed"
|
||||
|
||||
Generated
+3
-3
@@ -265,7 +265,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.0.8"
|
||||
version = "1.0.9"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -396,7 +396,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
source = { editable = "../prebuilt" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -484,7 +484,7 @@ test = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.25.2" },
|
||||
{ name = "orjson", specifier = ">=3.10.1" },
|
||||
{ name = "orjson", specifier = ">=3.11.5" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
|
||||
Reference in New Issue
Block a user