Compare commits

..
Author SHA1 Message Date
syachamaneni-lc 598254ddc4 feat(cli): expose MCP task settings 2026-09-13 12:34:14 -07:00
47 changed files with 1278 additions and 5069 deletions
-4
View File
@@ -7,10 +7,6 @@ on:
paths:
- 'docs/**'
- '.github/workflows/deploy-redirects.yml'
# llms.txt is fetched from docs.langchain.com at build time, so redeploy on a
# schedule to pick up docs changes that never touch this repo.
schedule:
- cron: '17 6 * * 1'
workflow_dispatch:
permissions:
+65
View File
@@ -0,0 +1,65 @@
# AGENTS Instructions
This repository is a monorepo. Each library lives in a subdirectory under `libs/`.
<corridor>
## Corridor security analysis
Before generating or modifying code, create a plan and use Corridor's `analyzePlan` tool to analyze it. Apply the resulting security guidance before writing code.
</corridor>
When you modify code in any library, run the following commands in that library's directory before creating a pull request:
- `make format` – run code formatters
- `make lint` – run the linter
- `make test` – execute the test suite
To run a particular test file or to pass additional pytest options you can specify the `TEST` variable:
```
TEST=path/to/test.py make test
```
Other pytest arguments can also be supplied inside the `TEST` variable.
## Libraries
The repository contains several Python and JavaScript/TypeScript libraries.
Below is a high-level overview:
- **checkpoint** – base interfaces for LangGraph checkpointers.
- **checkpoint-postgres** – Postgres implementation of the checkpoint saver.
- **checkpoint-sqlite** – SQLite implementation of the checkpoint saver.
- **cli** – official command-line interface for LangGraph.
- **langgraph** – core framework for building stateful, multi-actor agents.
- **prebuilt** – high-level APIs for creating and running agents and tools.
- **sdk-js** – JS/TS SDK for interacting with the LangGraph REST API.
- **sdk-py** – Python SDK for the LangGraph Server API.
### Dependency map
The diagram below lists downstream libraries for each production dependency as
declared in that library's `pyproject.toml` (or `package.json`).
```text
checkpoint
├── checkpoint-postgres
├── checkpoint-sqlite
├── prebuilt
└── langgraph
prebuilt
└── langgraph
sdk-py
├── langgraph
└── cli
sdk-js (standalone)
```
Changes to a library may impact all of its dependents shown above.
- Do NOT use Sphinx-style double backtick formatting (` ``code`` `). Use single backticks (`` `code` ``) for inline code references in docstrings and comments.
+7 -83
View File
@@ -12,26 +12,13 @@ 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 http.client
import json
import os
import urllib.error
import urllib.parse
import urllib.request
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"
# The docs site regenerates this index on every deploy, so fetching it here
# keeps the published llms.txt from drifting. The URL is a hardcoded constant,
# never built from input, and both it and the post-redirect URL are checked
# against ALLOWED_LLMS_HOST before anything is read.
CANONICAL_LLMS_URL = "https://docs.langchain.com/oss/python/langgraph/llms.txt"
ALLOWED_LLMS_HOST = "docs.langchain.com"
LLMS_FETCH_TIMEOUT = 30
LLMS_MAX_BYTES = 1_000_000
HTML_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
@@ -88,64 +75,6 @@ CATCHALL_404_TEMPLATE = """<!doctype html>
"""
def is_allowed_llms_url(url):
"""Return True if url is HTTPS on the one host we accept content from."""
parsed = urllib.parse.urlsplit(url)
return parsed.scheme == "https" and parsed.hostname == ALLOWED_LLMS_HOST
def fetch_canonical_llms_txt():
"""Return the published LangGraph index, or None if it cannot be used.
Returning None leaves the caller on the committed docs/llms.txt, so a
docs.langchain.com outage degrades to a stale file rather than a broken
deploy or a published error page.
"""
if not is_allowed_llms_url(CANONICAL_LLMS_URL):
print(f"Refusing to fetch {CANONICAL_LLMS_URL}: host not allowed")
return None
try:
with urllib.request.urlopen( # noqa: S310 - constant, allowlisted URL
CANONICAL_LLMS_URL, timeout=LLMS_FETCH_TIMEOUT
) as response:
# urlopen follows redirects, so re-check where it actually landed.
if not is_allowed_llms_url(response.url):
print(f"Refusing {CANONICAL_LLMS_URL}: redirected to {response.url}")
return None
body = response.read(LLMS_MAX_BYTES + 1)
# A connection dropped mid-body raises http.client.IncompleteRead, which
# descends from HTTPException rather than OSError, so catching only the
# urllib and OS errors would let it escape and fail the whole deploy.
except (
urllib.error.URLError,
http.client.HTTPException,
TimeoutError,
OSError,
) as exc:
print(f"Could not fetch {CANONICAL_LLMS_URL}: {type(exc).__name__}: {exc}")
return None
if len(body) > LLMS_MAX_BYTES:
print(f"Refusing {CANONICAL_LLMS_URL}: larger than {LLMS_MAX_BYTES} bytes")
return None
try:
text = body.decode("utf-8")
except UnicodeDecodeError as exc:
print(f"Refusing {CANONICAL_LLMS_URL}: not valid UTF-8: {exc}")
return None
# An index opens with a markdown heading and links to the docs site. A
# body that does not is an error page or a truncated response, not content
# worth publishing.
if not text.startswith("# ") or f"https://{ALLOWED_LLMS_HOST}/" not in text:
print(f"Refusing {CANONICAL_LLMS_URL}: does not look like an llms.txt index")
return None
return text
def generate_redirects():
script_dir = Path(__file__).parent
output_dir = script_dir / "_site"
@@ -197,19 +126,14 @@ def generate_redirects():
catchall_404.write_text(CATCHALL_404_TEMPLATE.format(default_url=DEFAULT_REDIRECT))
print(f"Created: {catchall_404}")
# llms.txt can't be redirected via HTML, so publish the docs site's own
# generated index. The committed copy is only a fallback.
llms_txt = fetch_canonical_llms_txt()
if llms_txt is not None:
(output_dir / "llms.txt").write_text(llms_txt)
print(f"Fetched: {output_dir / 'llms.txt'} (from {CANONICAL_LLMS_URL})")
else:
src = script_dir / "llms.txt"
# 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():
(output_dir / "llms.txt").write_text(src.read_text())
print(f"Copied: {output_dir / 'llms.txt'} (fallback, may be stale)")
else:
print("No llms.txt fetched and no committed fallback; skipping")
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}")
+32 -46
View File
@@ -1,49 +1,35 @@
# Docs by LangChain: LangGraph (Python)
# LangGraph
> Markdown index of the LangGraph (Python) documentation.
LangGraph documentation has moved to docs.langchain.com.
## LangGraph (Python)
## Overview
- [Memory](https://docs.langchain.com/oss/python/langgraph/add-memory.md)
- [Build a custom RAG agent with LangGraph](https://docs.langchain.com/oss/python/langgraph/agentic-rag.md)
- [Application structure](https://docs.langchain.com/oss/python/langgraph/application-structure.md)
- [Backward compatibility](https://docs.langchain.com/oss/python/langgraph/backward-compatibility.md)
- [Case studies](https://docs.langchain.com/oss/python/langgraph/case-studies.md)
- [Changelog](https://docs.langchain.com/oss/python/langgraph/changelog-js.md)
- [Changelog](https://docs.langchain.com/oss/python/langgraph/changelog-py.md)
- [Checkpointers](https://docs.langchain.com/oss/python/langgraph/checkpointers.md)
- [Choosing between Graph and Functional APIs](https://docs.langchain.com/oss/python/langgraph/choosing-apis.md)
- [Deployment](https://docs.langchain.com/oss/python/langgraph/deploy.md)
- [GRAPH_RECURSION_LIMIT](https://docs.langchain.com/oss/python/langgraph/errors/GRAPH_RECURSION_LIMIT.md)
- [INVALID_CHAT_HISTORY](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_CHAT_HISTORY.md)
- [INVALID_CONCURRENT_GRAPH_UPDATE](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_CONCURRENT_GRAPH_UPDATE.md)
- [INVALID_GRAPH_NODE_RETURN_VALUE](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_GRAPH_NODE_RETURN_VALUE.md)
- [MISSING_CHECKPOINTER](https://docs.langchain.com/oss/python/langgraph/errors/MISSING_CHECKPOINTER.md)
- [MULTIPLE_SUBGRAPHS](https://docs.langchain.com/oss/python/langgraph/errors/MULTIPLE_SUBGRAPHS.md)
- [Event streaming](https://docs.langchain.com/oss/python/langgraph/event-streaming.md)
- [Fault tolerance](https://docs.langchain.com/oss/python/langgraph/fault-tolerance.md)
- [Custom stream channels](https://docs.langchain.com/oss/python/langgraph/frontend/custom-stream-channels.md)
- [Graph execution](https://docs.langchain.com/oss/python/langgraph/frontend/graph-execution.md)
- [Overview](https://docs.langchain.com/oss/python/langgraph/frontend/overview.md)
- [Functional API overview](https://docs.langchain.com/oss/python/langgraph/functional-api.md)
- [Graph API overview](https://docs.langchain.com/oss/python/langgraph/graph-api.md)
- [Install LangGraph](https://docs.langchain.com/oss/python/langgraph/install.md)
- [Interrupts](https://docs.langchain.com/oss/python/langgraph/interrupts.md)
- [Run a local server](https://docs.langchain.com/oss/python/langgraph/local-server.md)
- [LangSmith Observability](https://docs.langchain.com/oss/python/langgraph/observability.md)
- [LangGraph overview](https://docs.langchain.com/oss/python/langgraph/overview.md)
- [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence.md)
- [LangGraph runtime](https://docs.langchain.com/oss/python/langgraph/pregel.md)
- [Quickstart](https://docs.langchain.com/oss/python/langgraph/quickstart.md)
- [Build a custom SQL agent](https://docs.langchain.com/oss/python/langgraph/sql-agent.md)
- [Stores](https://docs.langchain.com/oss/python/langgraph/stores.md)
- [Streaming](https://docs.langchain.com/oss/python/langgraph/streaming.md)
- [LangSmith Studio](https://docs.langchain.com/oss/python/langgraph/studio.md)
- [Test](https://docs.langchain.com/oss/python/langgraph/test.md)
- [Thinking in LangGraph](https://docs.langchain.com/oss/python/langgraph/thinking-in-langgraph.md)
- [Agent Chat UI](https://docs.langchain.com/oss/python/langgraph/ui.md)
- [Use the functional API](https://docs.langchain.com/oss/python/langgraph/use-functional-api.md)
- [Use the graph API](https://docs.langchain.com/oss/python/langgraph/use-graph-api.md)
- [Subgraphs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs.md)
- [Use time-travel](https://docs.langchain.com/oss/python/langgraph/use-time-travel.md)
- [Workflows and agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents.md)
- [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).
+3 -3
View File
@@ -13,16 +13,16 @@ wheels = [
[[package]]
name = "anyio"
version = "4.14.2"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
+3 -3
View File
@@ -26,16 +26,16 @@ wheels = [
[[package]]
name = "anyio"
version = "4.14.2"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
+3 -3
View File
@@ -19,16 +19,16 @@ wheels = [
[[package]]
name = "anyio"
version = "4.14.2"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
-2
View File
@@ -103,8 +103,6 @@ The CLI uses a `langgraph.json` configuration file with these key settings:
}
```
Git dependencies should use credential-free URLs. The CLI conservatively scans direct `langgraph.json` dependencies, common Python package files, uv project and lock files, and common Node.js package and lock files for HTTP Git URLs with userinfo. This check is not exhaustive: generated Docker builds can copy other files, including nested requirement or constraint files, into image layers without scanning them. For private dependencies, provide short-lived credentials through your build environment's secret-backed Git credential helper. Do not store credentials in copied files such as `langgraph.json` or `pip_config_file`.
See the [full documentation](https://reference.langchain.com/python/langgraph-cli) for detailed configuration options.
## Development
+285
View File
@@ -0,0 +1,285 @@
# This file is automatically @generated by Poetry 2.0.0 and should not be changed by hand.
[[package]]
name = "anyio"
version = "4.4.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"},
{file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"},
]
[package.dependencies]
exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""}
idna = ">=2.8"
sniffio = ">=1.1"
typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""}
[package.extras]
doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"]
trio = ["trio (>=0.23)"]
[[package]]
name = "certifi"
version = "2024.7.4"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
{file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
]
[[package]]
name = "click"
version = "8.1.7"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main"]
markers = "platform_system == \"Windows\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "exceptiongroup"
version = "1.2.1"
description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
groups = ["main"]
markers = "python_version < \"3.11\""
files = [
{file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"},
{file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"},
]
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "h11"
version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
]
[[package]]
name = "httpcore"
version = "1.0.9"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.16"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<1.0)"]
[[package]]
name = "httpx"
version = "0.28.1"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
]
[package.dependencies]
anyio = "*"
certifi = "*"
httpcore = "==1.*"
idna = "*"
[package.extras]
brotli = ["brotli", "brotlicffi"]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "httpx-sse"
version = "0.4.0"
description = "Consume Server-Sent Event (SSE) messages with HTTPX."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"},
{file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"},
]
[[package]]
name = "idna"
version = "3.7"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.5"
groups = ["main"]
files = [
{file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
]
[[package]]
name = "langgraph-cli"
version = "0.1.52"
description = "CLI for interacting with LangGraph API"
optional = false
python-versions = "^3.9.0,<4.0"
groups = ["main"]
files = []
develop = true
[package.dependencies]
click = "^8.1.7"
[package.source]
type = "directory"
url = ".."
[[package]]
name = "langgraph-sdk"
version = "0.1.29"
description = "SDK for interacting with LangGraph API"
optional = false
python-versions = "^3.9.0,<4.0"
groups = ["main"]
files = []
develop = true
[package.dependencies]
httpx = ">=0.25.2"
httpx-sse = ">=0.4.0"
orjson = ">=3.10.1"
[package.source]
type = "directory"
url = "../../sdk-py"
[[package]]
name = "orjson"
version = "3.10.5"
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "orjson-3.10.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:545d493c1f560d5ccfc134803ceb8955a14c3fcb47bbb4b2fee0232646d0b932"},
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4324929c2dd917598212bfd554757feca3e5e0fa60da08be11b4aa8b90013c1"},
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c13ca5e2ddded0ce6a927ea5a9f27cae77eee4c75547b4297252cb20c4d30e6"},
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6c8e30adfa52c025f042a87f450a6b9ea29649d828e0fec4858ed5e6caecf63"},
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:338fd4f071b242f26e9ca802f443edc588fa4ab60bfa81f38beaedf42eda226c"},
{file = "orjson-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6970ed7a3126cfed873c5d21ece1cd5d6f83ca6c9afb71bbae21a0b034588d96"},
{file = "orjson-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:235dadefb793ad12f7fa11e98a480db1f7c6469ff9e3da5e73c7809c700d746b"},
{file = "orjson-3.10.5-cp310-none-win32.whl", hash = "sha256:be79e2393679eda6a590638abda16d167754393f5d0850dcbca2d0c3735cebe2"},
{file = "orjson-3.10.5-cp310-none-win_amd64.whl", hash = "sha256:c4a65310ccb5c9910c47b078ba78e2787cb3878cdded1702ac3d0da71ddc5228"},
{file = "orjson-3.10.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:cdf7365063e80899ae3a697def1277c17a7df7ccfc979990a403dfe77bb54d40"},
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b68742c469745d0e6ca5724506858f75e2f1e5b59a4315861f9e2b1df77775a"},
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d10cc1b594951522e35a3463da19e899abe6ca95f3c84c69e9e901e0bd93d38"},
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcbe82b35d1ac43b0d84072408330fd3295c2896973112d495e7234f7e3da2e1"},
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c0eb7e0c75e1e486c7563fe231b40fdd658a035ae125c6ba651ca3b07936f5"},
{file = "orjson-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:53ed1c879b10de56f35daf06dbc4a0d9a5db98f6ee853c2dbd3ee9d13e6f302f"},
{file = "orjson-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:099e81a5975237fda3100f918839af95f42f981447ba8f47adb7b6a3cdb078fa"},
{file = "orjson-3.10.5-cp311-none-win32.whl", hash = "sha256:1146bf85ea37ac421594107195db8bc77104f74bc83e8ee21a2e58596bfb2f04"},
{file = "orjson-3.10.5-cp311-none-win_amd64.whl", hash = "sha256:36a10f43c5f3a55c2f680efe07aa93ef4a342d2960dd2b1b7ea2dd764fe4a37c"},
{file = "orjson-3.10.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:68f85ecae7af14a585a563ac741b0547a3f291de81cd1e20903e79f25170458f"},
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28afa96f496474ce60d3340fe8d9a263aa93ea01201cd2bad844c45cd21f5268"},
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cd684927af3e11b6e754df80b9ffafd9fb6adcaa9d3e8fdd5891be5a5cad51e"},
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d21b9983da032505f7050795e98b5d9eee0df903258951566ecc358f6696969"},
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ad1de7fef79736dde8c3554e75361ec351158a906d747bd901a52a5c9c8d24b"},
{file = "orjson-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d97531cdfe9bdd76d492e69800afd97e5930cb0da6a825646667b2c6c6c0211"},
{file = "orjson-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d69858c32f09c3e1ce44b617b3ebba1aba030e777000ebdf72b0d8e365d0b2b3"},
{file = "orjson-3.10.5-cp312-none-win32.whl", hash = "sha256:64c9cc089f127e5875901ac05e5c25aa13cfa5dbbbd9602bda51e5c611d6e3e2"},
{file = "orjson-3.10.5-cp312-none-win_amd64.whl", hash = "sha256:b2efbd67feff8c1f7728937c0d7f6ca8c25ec81373dc8db4ef394c1d93d13dc5"},
{file = "orjson-3.10.5-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:03b565c3b93f5d6e001db48b747d31ea3819b89abf041ee10ac6988886d18e01"},
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:584c902ec19ab7928fd5add1783c909094cc53f31ac7acfada817b0847975f26"},
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a35455cc0b0b3a1eaf67224035f5388591ec72b9b6136d66b49a553ce9eb1e6"},
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1670fe88b116c2745a3a30b0f099b699a02bb3482c2591514baf5433819e4f4d"},
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:185c394ef45b18b9a7d8e8f333606e2e8194a50c6e3c664215aae8cf42c5385e"},
{file = "orjson-3.10.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ca0b3a94ac8d3886c9581b9f9de3ce858263865fdaa383fbc31c310b9eac07c9"},
{file = "orjson-3.10.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dfc91d4720d48e2a709e9c368d5125b4b5899dced34b5400c3837dadc7d6271b"},
{file = "orjson-3.10.5-cp38-none-win32.whl", hash = "sha256:c05f16701ab2a4ca146d0bca950af254cb7c02f3c01fca8efbbad82d23b3d9d4"},
{file = "orjson-3.10.5-cp38-none-win_amd64.whl", hash = "sha256:8a11d459338f96a9aa7f232ba95679fc0c7cedbd1b990d736467894210205c09"},
{file = "orjson-3.10.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:85c89131d7b3218db1b24c4abecea92fd6c7f9fab87441cfc342d3acc725d807"},
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb66215277a230c456f9038d5e2d84778141643207f85336ef8d2a9da26bd7ca"},
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51bbcdea96cdefa4a9b4461e690c75ad4e33796530d182bdd5c38980202c134a"},
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbead71dbe65f959b7bd8cf91e0e11d5338033eba34c114f69078d59827ee139"},
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df58d206e78c40da118a8c14fc189207fffdcb1f21b3b4c9c0c18e839b5a214"},
{file = "orjson-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c4057c3b511bb8aef605616bd3f1f002a697c7e4da6adf095ca5b84c0fd43595"},
{file = "orjson-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b39e006b00c57125ab974362e740c14a0c6a66ff695bff44615dcf4a70ce2b86"},
{file = "orjson-3.10.5-cp39-none-win32.whl", hash = "sha256:eded5138cc565a9d618e111c6d5c2547bbdd951114eb822f7f6309e04db0fb47"},
{file = "orjson-3.10.5-cp39-none-win_amd64.whl", hash = "sha256:cc28e90a7cae7fcba2493953cff61da5a52950e78dc2dacfe931a317ee3d8de7"},
{file = "orjson-3.10.5.tar.gz", hash = "sha256:7a5baef8a4284405d96c90c7c62b755e9ef1ada84c2406c24a9ebec86b89f46d"},
]
[[package]]
name = "sniffio"
version = "1.3.1"
description = "Sniff out which async library your code is running under"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
]
[[package]]
name = "typing-extensions"
version = "4.12.2"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "python_version < \"3.11\""
files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
]
[metadata]
lock-version = "2.1"
python-versions = "^3.9.0,<4.0"
content-hash = "ec5109729f30d2033a10a10e8f8d3ed94c7d96d5d31025b4815b0123664bb063"
+4
View File
@@ -23,6 +23,8 @@ from langgraph_cli.schemas import (
GraphDef,
HttpConfig,
IndexConfig,
McpConfig,
McpTasksConfig,
SecurityConfig,
SerdeConfig,
StoreConfig,
@@ -115,6 +117,8 @@ def add_descriptions_to_schema(schema, cls):
AuthConfig,
SecurityConfig,
HttpConfig,
McpConfig,
McpTasksConfig,
CorsConfig,
CacheConfig,
ThreadTTLConfig,
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.4.32"
__version__ = "0.4.31"
+3 -87
View File
@@ -6,7 +6,6 @@ import re
import shlex
import textwrap
from collections import Counter
from collections.abc import Iterable
from typing import Literal, NamedTuple
import click
@@ -37,10 +36,6 @@ DISALLOWED_BUILD_COMMAND_CHARS = [
# This blocks background execution (cmd &) while allowing command
# chaining (cmd1 && cmd2) which is common in build commands.
_SINGLE_AMPERSAND_RE = re.compile(r"(?<!&)&(?:&&)*(?!&)")
_GIT_HTTP_AUTHORITY_RES = (
re.compile(r"git\+https?://(?P<authority>[^/\s\"']+)", re.I),
re.compile(r"\bgit\s*=\s*[\"']https?://(?P<authority>[^/\s\"']+)", re.I),
)
_API_VERSION_PATTERN = re.compile(
r"^(?P<major>\d+)"
r"(?:\.(?P<minor>\d+))?"
@@ -83,62 +78,6 @@ def has_disallowed_build_command_content(command: str) -> bool:
return False
def _has_git_http_url_userinfo(dependency: str) -> bool:
"""Check whether a Git HTTP URL contains userinfo."""
return any(
"@" in match.group("authority")
for pattern in _GIT_HTTP_AUTHORITY_RES
for match in pattern.finditer(dependency)
)
def _validate_git_http_url_userinfo(
values: Iterable[str], *, source: pathlib.Path | None = None
) -> None:
"""Reject credential-bearing Git HTTP URLs without echoing their values."""
if not any(_has_git_http_url_userinfo(value) for value in values):
return
message = (
"Git dependency URLs must not contain credentials or other URL "
"userinfo because generated Dockerfiles and image layers can retain "
"them. Use a credential-free Git URL and provide short-lived "
"credentials through your build environment's secret-backed Git "
"credential helper."
)
if source is not None:
message += f" Found in: {source}"
raise click.UsageError(message)
def _validate_git_http_url_userinfo_files(paths: Iterable[pathlib.Path]) -> None:
"""Reject credential-bearing Git HTTP URLs in dependency files."""
for path in paths:
path = path.resolve()
if not path.is_file():
continue
try:
contents = path.read_text(encoding="utf-8", errors="replace")
except OSError:
raise click.UsageError(
f"Could not inspect dependency file for embedded credentials: {path}"
) from None
_validate_git_http_url_userinfo([contents], source=path)
def _validate_local_dependency_files(config_path: pathlib.Path, config: Config) -> None:
"""Validate dependency files copied into a non-uv Python image."""
paths: list[pathlib.Path] = []
for dependency in config["dependencies"]:
if not isinstance(dependency, str) or not dependency.startswith("."):
continue
root = (config_path.parent / dependency).resolve()
paths.extend(
root / name
for name in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg")
)
_validate_git_http_url_userinfo_files(paths)
MIN_PYTHON_VERSION = "3.11"
DEFAULT_PYTHON_VERSION = "3.11"
@@ -381,9 +320,7 @@ def _get_source_kind(config: Config) -> str | None:
return kind if isinstance(kind, str) else None
def validate_config(
config: Config, *, source_path: pathlib.Path | None = None
) -> Config:
def validate_config(config: Config) -> Config:
"""Validate a configuration dictionary."""
graphs = config.get("graphs", {})
@@ -478,15 +415,6 @@ def validate_config(
' "source": {"kind": "uv", "root": ".."}'
)
_validate_git_http_url_userinfo(
(
dependency
for dependency in config["dependencies"]
if isinstance(dependency, str)
),
source=source_path,
)
source = config.get("source")
source_kind = _get_source_kind(config)
if source is not None and not isinstance(source, dict):
@@ -681,7 +609,7 @@ def validate_config_file(config_path: pathlib.Path) -> Config:
"""Load and validate a configuration file."""
with open(config_path) as f:
config = json.load(f)
validated = validate_config(config, source_path=config_path.resolve())
validated = validate_config(config)
# Enforce the package.json doesn't enforce an
# incompatible Node.js version
if validated.get("node_version"):
@@ -1352,7 +1280,6 @@ def python_config_to_docker(
api_version=api_version,
build_tools_to_uninstall=build_tools_to_uninstall,
)
_validate_local_dependency_files(config_path, config)
if pip_installer == "auto":
if _image_supports_uv(base_image):
pip_installer = "uv"
@@ -1563,18 +1490,7 @@ def node_config_to_docker(
) -> tuple[str, dict[str, str]]:
# Calculate paths for monorepo support
install_root = (
pathlib.Path(build_context).resolve()
if build_context
else config_path.parent.resolve()
)
config_root = config_path.parent.resolve()
dependency_roots = (
(install_root, config_root) if install_root != config_root else (install_root,)
)
_validate_git_http_url_userinfo_files(
root / name
for root in dependency_roots
for name in ("package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml")
pathlib.Path(build_context).resolve() if build_context else config_path.parent
)
install_cmd = install_command or _get_node_pm_install_cmd(install_root)
if build_context:
File diff suppressed because it is too large Load Diff
+2 -8
View File
@@ -1,18 +1,12 @@
import asyncio
import signal
import sys
from collections.abc import Callable, Coroutine
from collections.abc import Callable
from contextlib import contextmanager
from typing import Any, Protocol, TypeVar, cast
from typing import cast
import click.exceptions
_T = TypeVar("_T")
class CommandRunner(Protocol):
def run(self, coro: Coroutine[Any, Any, _T]) -> _T: ...
@contextmanager
def Runner():
+32 -180
View File
@@ -2,125 +2,18 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal
from urllib.parse import urlparse
from typing import Any
import click
import httpx
CLOUD_CONTROL_PLANE_URL = "https://api.host.langchain.com"
CLOUD_DASHBOARD_URL = "https://smith.langchain.com"
CLOUD_DOMAIN = "langchain.com"
CLOUD_API_HOST = "api.smith.langchain.com"
CLOUD_CONTROL_PLANE_HOST = "api.host.langchain.com"
CLOUD_DASHBOARD_HOST = "smith.langchain.com"
CONTROL_PLANE_PATH = "/api-host"
LANGSMITH_API_PATHS = ("/api/v1", "/api")
LOCAL_HOSTNAMES = ("localhost", "127.0.0.1")
MAX_PAGE_SIZE = 100
SourceName = Literal["internal_docker", "internal_source", "external_docker"]
@dataclass(frozen=True, slots=True)
class ControlPlaneEndpoints:
control_plane_url: str
dashboard_url: str
@classmethod
def resolve(
cls, host_url: str | None, langsmith_endpoint: str | None
) -> ControlPlaneEndpoints:
if host_url:
return cls.from_control_plane_url(host_url)
if langsmith_endpoint:
return cls.from_langsmith_endpoint(langsmith_endpoint)
return cls(CLOUD_CONTROL_PLANE_URL, CLOUD_DASHBOARD_URL)
@property
def is_cloud(self) -> bool:
hostname = urlparse(self.control_plane_url).hostname or ""
return hostname == CLOUD_CONTROL_PLANE_HOST or hostname.endswith(
f".{CLOUD_CONTROL_PLANE_HOST}"
)
@classmethod
def from_control_plane_url(cls, url: str) -> ControlPlaneEndpoints:
control_plane_url = url.rstrip("/")
hostname = urlparse(control_plane_url).hostname or ""
if control_plane_url.endswith(CONTROL_PLANE_PATH):
return cls(control_plane_url, control_plane_url[: -len(CONTROL_PLANE_PATH)])
if hostname in LOCAL_HOSTNAMES:
return cls(control_plane_url, control_plane_url)
return cls(control_plane_url, _cloud_dashboard_for(hostname))
@classmethod
def from_langsmith_endpoint(cls, endpoint: str) -> ControlPlaneEndpoints:
parsed = urlparse(endpoint.rstrip("/"))
hostname = parsed.hostname or ""
if _is_cloud_host(hostname):
return cls.from_control_plane_url(
f"https://{_cloud_control_plane_host_for(hostname)}"
)
root = f"{parsed.scheme}://{parsed.netloc}{_without_api_path(parsed.path)}"
return cls(f"{root}{CONTROL_PLANE_PATH}", root)
def _is_cloud_host(hostname: str) -> bool:
return hostname == CLOUD_DOMAIN or hostname.endswith(f".{CLOUD_DOMAIN}")
def _cloud_control_plane_host_for(langsmith_api_host: str) -> str:
if langsmith_api_host.endswith(f".{CLOUD_API_HOST}"):
region = langsmith_api_host[: -len(CLOUD_API_HOST)]
return f"{region}{CLOUD_CONTROL_PLANE_HOST}"
return CLOUD_CONTROL_PLANE_HOST
def _cloud_dashboard_for(control_plane_host: str) -> str:
if control_plane_host.endswith(f".{CLOUD_CONTROL_PLANE_HOST}"):
region = control_plane_host[: -len(CLOUD_CONTROL_PLANE_HOST) - 1]
return f"https://{region}.{CLOUD_DASHBOARD_HOST}"
return CLOUD_DASHBOARD_URL
def _without_api_path(path: str) -> str:
for api_path in LANGSMITH_API_PATHS:
if path.endswith(api_path):
return path[: -len(api_path)]
return path
def _resources(payload: object) -> list[dict[str, Any]]:
if not isinstance(payload, dict):
return []
resources = payload.get("resources")
if not isinstance(resources, list):
return []
return [item for item in resources if isinstance(item, dict)]
class HostBackendError(click.ClickException):
"""Raised when the host backend returns an error response."""
def __init__(
self,
message: str,
status_code: int | None = None,
detail: str | None = None,
):
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
self.detail = detail
def _error_detail(response: httpx.Response) -> str | None:
try:
body = response.json()
except ValueError:
return None
detail = body.get("detail") if isinstance(body, dict) else None
return detail if isinstance(detail, str) else None
class HostBackendClient:
@@ -131,37 +24,24 @@ class HostBackendClient:
base_url: str,
api_key: str,
tenant_id: str | None = None,
*,
transport: httpx.BaseTransport | None = None,
):
if not base_url:
raise click.UsageError("Host backend URL is required")
transport = httpx.HTTPTransport(retries=3)
headers: dict[str, str] = {
"X-Api-Key": api_key,
"Accept": "application/json",
}
if tenant_id:
headers["X-Tenant-ID"] = tenant_id
self._endpoints = ControlPlaneEndpoints.from_control_plane_url(base_url)
self._base_url = self._endpoints.control_plane_url
self._base_url = base_url.rstrip("/")
self._client = httpx.Client(
base_url=self._base_url,
headers=headers,
transport=transport or httpx.HTTPTransport(retries=3),
transport=transport,
timeout=30,
)
@property
def base_url(self) -> str:
return self._base_url
@property
def endpoints(self) -> ControlPlaneEndpoints:
return self._endpoints
def set_tenant(self, tenant_id: str) -> None:
self._client.headers["X-Tenant-ID"] = tenant_id
def _request(
self,
method: str,
@@ -173,12 +53,10 @@ class HostBackendClient:
resp = self._client.request(method, path, json=payload, params=params)
resp.raise_for_status()
except httpx.HTTPStatusError as err:
detail = _error_detail(err.response)
reason = detail or err.response.text or str(err.response.status_code)
detail = err.response.text or str(err.response.status_code)
raise HostBackendError(
f"{method} {path} failed with status {err.response.status_code}: {reason}",
f"{method} {path} failed with status {err.response.status_code}: {detail}",
status_code=err.response.status_code,
detail=detail,
) from None
except httpx.TransportError as err:
raise HostBackendError(str(err)) from None
@@ -194,52 +72,30 @@ class HostBackendClient:
def create_deployment(
self,
*,
name: str | None,
source: SourceName,
source_config: dict[str, object],
source_revision_config: dict[str, object],
name: str,
deployment_type: str,
source: str,
config_path: str | None = None,
secrets: list[dict[str, str]] | None = None,
agent: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Create a deployment."""
payload: dict[str, Any] = {
"name": name,
"source": source,
"source_config": source_config,
"source_revision_config": source_revision_config,
"source_config": {"deployment_type": deployment_type},
"source_revision_config": {},
}
if agent is not None:
payload["agent"] = agent
else:
payload["name"] = name
if source == "internal_source" and config_path:
payload["source_revision_config"]["langgraph_config_path"] = config_path
if secrets is not None:
payload["secrets"] = secrets
return self._request("POST", "/v2/deployments", payload)
def list_deployments(
self,
*,
name: str | None = None,
name_contains: str | None = None,
limit: int | None = None,
agent_id: str | None = None,
agent_environment: str | None = None,
) -> list[dict[str, Any]]:
given = (
("name", name),
("name_contains", name_contains),
("limit", limit),
("agent_id", agent_id),
("agent_environment", agent_environment),
)
params = {key: value for key, value in given if value is not None}
return _resources(self._request("GET", "/v2/deployments", params=params))
def get_listener(self, listener_id: str) -> dict[str, Any]:
return self._request("GET", f"/v2/listeners/{listener_id}")
def list_listeners(self) -> list[dict[str, Any]]:
return _resources(
self._request("GET", "/v2/listeners", params={"limit": MAX_PAGE_SIZE})
def list_deployments(self, name_contains: str = "") -> dict[str, Any]:
return self._request(
"GET",
"/v2/deployments",
params={"name_contains": name_contains},
)
def get_deployment(self, deployment_id: str) -> dict[str, Any]:
@@ -265,21 +121,22 @@ class HostBackendClient:
self,
deployment_id: str,
image_uri: str,
*,
revision_source: SourceName | None,
secrets: list[dict[str, str]] | None = None,
tracked_packages: list[str] | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"revision_source": "internal_docker",
"source_revision_config": {"image_uri": image_uri},
}
if revision_source is not None:
payload["revision_source"] = revision_source
if tracked_packages:
payload["tracked_packages"] = tracked_packages
if secrets is not None:
payload["secrets"] = secrets
return self._request("PATCH", f"/v2/deployments/{deployment_id}", payload)
return self._request(
"PATCH",
f"/v2/deployments/{deployment_id}",
payload,
)
def update_deployment_internal_source(
self,
@@ -314,15 +171,10 @@ class HostBackendClient:
payload["secrets"] = secrets
return self._request("PATCH", f"/v2/deployments/{deployment_id}", payload)
def list_revisions(
self, deployment_id: str, limit: int = 1
) -> list[dict[str, Any]]:
return _resources(
self._request(
"GET",
f"/v2/deployments/{deployment_id}/revisions",
params={"limit": limit},
)
def list_revisions(self, deployment_id: str, limit: int = 1) -> dict[str, Any]:
return self._request(
"GET",
f"/v2/deployments/{deployment_id}/revisions?limit={limit}",
)
def get_revision(self, deployment_id: str, revision_id: str) -> dict[str, Any]:
-35
View File
@@ -1,35 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, replace
DIGEST_SEPARATOR = "@sha256:"
DIGEST_MARKER = "@"
TAG_SEPARATOR = ":"
PATH_SEPARATOR = "/"
@dataclass(frozen=True, slots=True)
class ImageReference:
repository: str
tag: str | None = None
@classmethod
def parse(cls, reference: str) -> ImageReference:
if DIGEST_MARKER in reference:
raise ValueError(f"{reference!r} carries a digest and cannot be tagged")
path_start = reference.rfind(PATH_SEPARATOR) + 1
name, separator, tag = reference[path_start:].partition(TAG_SEPARATOR)
if not separator:
return cls(reference)
return cls(reference[:path_start] + name, tag)
def with_tag(self, tag: str) -> ImageReference:
return replace(self, tag=tag)
def matches_digest(self, repo_digest: str) -> bool:
return repo_digest.startswith(f"{self.repository}{DIGEST_SEPARATOR}")
def __str__(self) -> str:
if self.tag is None:
return self.repository
return f"{self.repository}{TAG_SEPARATOR}{self.tag}"
+21 -5
View File
@@ -439,6 +439,24 @@ class ConfigurableHeaderConfig(TypedDict, total=False):
"""
class McpTasksConfig(TypedDict, total=False):
"""Settings for authenticated MCP elicitation tasks."""
poll_interval_ms: int
"""Suggested client polling interval, in milliseconds. Default 1000; range 100–60000."""
ttl_minutes: int
"""Maximum task lifetime, in minutes. Default 1440; range 1–10080."""
completed_grace_seconds: int
"""Result retention after completion, in seconds. Default 300; range 1–86400."""
allow_insecure_urls: bool
"""Allow HTTP elicitation URLs for development. Defaults to False; production URLs require HTTPS."""
class McpConfig(TypedDict, total=False):
tasks: McpTasksConfig
"""Polling, retention and URL settings for the MCP Tasks extension."""
class HttpConfig(TypedDict, total=False):
"""Configuration for the built-in HTTP server that powers your deployment's routes and endpoints."""
@@ -473,6 +491,8 @@ class HttpConfig(TypedDict, total=False):
Default is False.
"""
mcp: McpConfig
"""Optional. Configure the MCP Tasks extension for elicitation over protocol 2026-07-28."""
disable_a2a: bool
"""Optional. If `True`, /a2a routes are removed, disabling default support to expose the deployment as an agent-to-agent (A2A) server.
@@ -650,8 +670,7 @@ class Config(TypedDict, total=False):
pip_config_file: str | None
"""Optional. Path to a pip config file (e.g., "/etc/pip.conf" or "pip.ini") for controlling
package installation (custom indices, timeouts, etc.). The file is copied into the
generated image, so it must not contain credentials or other secrets.
package installation (custom indices, credentials, etc.).
Only relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.
"""
@@ -690,9 +709,6 @@ class Config(TypedDict, total=False):
- "." or "./src" if you have a local Python package
- str (aka "anthropic") for a PyPI package
- "git+https://github.com/org/repo.git@main" for a Git-based package
Git HTTP URLs must not contain userinfo such as a username or token. For private
dependencies, provide short-lived credentials through the build environment's
secret-backed Git credential helper.
Defaults to an empty list, meaning no additional packages installed beyond your base environment.
This field is not supported when `source.kind` is `uv`.
-10
View File
@@ -880,7 +880,6 @@ def python_config_to_docker_uv_lock(
_get_node_pm_install_cmd,
_get_pip_cleanup_lines,
_image_supports_uv,
_validate_git_http_url_userinfo_files,
docker_tag,
)
@@ -891,20 +890,11 @@ def python_config_to_docker_uv_lock(
)
config_root = config_path.parent.resolve()
source_root = config["source"].get("root", ".")
project_root = (config_root / source_root).resolve()
_validate_git_http_url_userinfo_files(
[project_root / "pyproject.toml", project_root / "uv.lock"]
)
install_cmd = "uv pip install --system"
_, global_reqs_pip_install, pip_config_file_str = _build_python_install_commands(
config, install_cmd
)
plan = _plan_uv_lock_workspace(config_path, config)
_validate_git_http_url_userinfo_files(
package.pyproject_path for package in plan.install_order
)
_update_uv_lock_graph_paths(config_path, config, plan)
for section, key in [
+42 -2
View File
@@ -28,7 +28,7 @@
"type": "null"
}
],
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
},
"_INTERNAL_docker_tag": {
"anyOf": [
@@ -270,7 +270,7 @@
"type": "null"
}
],
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
},
"_INTERNAL_docker_tag": {
"anyOf": [
@@ -1065,6 +1065,10 @@
],
"description": "Optional. Defines which headers are excluded from logging."
},
"mcp": {
"$ref": "#/$defs/McpConfig",
"description": "Optional. Configure the MCP Tasks extension for elicitation over protocol 2026-07-28."
},
"middleware_order": {
"anyOf": [
{
@@ -1170,6 +1174,42 @@
},
"required": []
},
"McpConfig": {
"title": "McpConfig",
"type": "object",
"properties": {
"tasks": {
"$ref": "#/$defs/McpTasksConfig",
"description": "Polling, retention and URL settings for the MCP Tasks extension."
}
},
"required": [],
"description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n d = {}\n for k, v in iterable:\n d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n in the keyword argument list. For example: dict(one=1, two=2)"
},
"McpTasksConfig": {
"title": "McpTasksConfig",
"description": "Settings for authenticated MCP elicitation tasks.",
"type": "object",
"properties": {
"allow_insecure_urls": {
"type": "boolean",
"description": "Allow HTTP elicitation URLs for development. Defaults to False; production URLs require HTTPS."
},
"completed_grace_seconds": {
"type": "integer",
"description": "Result retention after completion, in seconds. Default 300; range 1\u201386400."
},
"poll_interval_ms": {
"type": "integer",
"description": "Suggested client polling interval, in milliseconds. Default 1000; range 100\u201360000."
},
"ttl_minutes": {
"type": "integer",
"description": "Maximum task lifetime, in minutes. Default 1440; range 1\u201310080."
}
},
"required": []
},
"UvSource": {
"title": "UvSource",
"description": "Deployment source rooted at a uv project or workspace.",
+42 -2
View File
@@ -28,7 +28,7 @@
"type": "null"
}
],
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
},
"_INTERNAL_docker_tag": {
"anyOf": [
@@ -270,7 +270,7 @@
"type": "null"
}
],
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
},
"_INTERNAL_docker_tag": {
"anyOf": [
@@ -1065,6 +1065,10 @@
],
"description": "Optional. Defines which headers are excluded from logging."
},
"mcp": {
"$ref": "#/$defs/McpConfig",
"description": "Optional. Configure the MCP Tasks extension for elicitation over protocol 2026-07-28."
},
"middleware_order": {
"anyOf": [
{
@@ -1170,6 +1174,42 @@
},
"required": []
},
"McpConfig": {
"title": "McpConfig",
"type": "object",
"properties": {
"tasks": {
"$ref": "#/$defs/McpTasksConfig",
"description": "Polling, retention and URL settings for the MCP Tasks extension."
}
},
"required": [],
"description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n d = {}\n for k, v in iterable:\n d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n in the keyword argument list. For example: dict(one=1, two=2)"
},
"McpTasksConfig": {
"title": "McpTasksConfig",
"description": "Settings for authenticated MCP elicitation tasks.",
"type": "object",
"properties": {
"allow_insecure_urls": {
"type": "boolean",
"description": "Allow HTTP elicitation URLs for development. Defaults to False; production URLs require HTTPS."
},
"completed_grace_seconds": {
"type": "integer",
"description": "Result retention after completion, in seconds. Default 300; range 1\u201386400."
},
"poll_interval_ms": {
"type": "integer",
"description": "Suggested client polling interval, in milliseconds. Default 1000; range 100\u201360000."
},
"ttl_minutes": {
"type": "integer",
"description": "Maximum task lifetime, in minutes. Default 1440; range 1\u201310080."
}
},
"required": []
},
"UvSource": {
"title": "UvSource",
"description": "Deployment source rooted at a uv project or workspace.",
+31 -27
View File
@@ -382,18 +382,20 @@ def test_deploy_list_command(monkeypatch) -> None:
def list_deployments(self, name_contains: str = ""):
captured["name_contains"] = name_contains
return [
{
"id": "dep-123",
"name": "alpha",
"source_config": {"custom_url": "https://alpha.example.com"},
},
{
"id": "dep-456",
"name": "beta",
"source_config": {"custom_url": "https://beta.example.com"},
},
]
return {
"resources": [
{
"id": "dep-123",
"name": "alpha",
"source_config": {"custom_url": "https://alpha.example.com"},
},
{
"id": "dep-456",
"name": "beta",
"source_config": {"custom_url": "https://beta.example.com"},
},
]
}
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
@@ -433,7 +435,7 @@ def test_deploy_list_command_no_results(monkeypatch) -> None:
pass
def list_deployments(self, name_contains: str = ""):
return []
return {"resources": []}
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
@@ -466,18 +468,20 @@ def test_deploy_revisions_list_command(monkeypatch) -> None:
def list_revisions(self, deployment_id: str, limit: int = 1):
captured["deployment_id"] = deployment_id
captured["limit"] = str(limit)
return [
{
"id": "rev-123",
"status": "CREATING",
"created_at": "2023-11-07T05:31:56Z",
},
{
"id": "rev-456",
"status": "DEPLOYED",
"created_at": "2023-11-08T10:00:00Z",
},
]
return {
"resources": [
{
"id": "rev-123",
"status": "CREATING",
"created_at": "2023-11-07T05:31:56Z",
},
{
"id": "rev-456",
"status": "DEPLOYED",
"created_at": "2023-11-08T10:00:00Z",
},
]
}
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
@@ -518,7 +522,7 @@ def test_deploy_revisions_list_command_no_results(monkeypatch) -> None:
pass
def list_revisions(self, deployment_id: str, limit: int = 1):
return []
return {"resources": []}
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
@@ -551,7 +555,7 @@ def test_deploy_revisions_list_command_with_explicit_limit(monkeypatch) -> None:
def list_revisions(self, deployment_id: str, limit: int = 1):
captured["deployment_id"] = deployment_id
captured["limit"] = str(limit)
return []
return {"resources": []}
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -12,5 +12,5 @@ def disable_analytics_env() -> None:
if "LANGGRAPH_CLI_NO_ANALYTICS" in os.environ:
print("⚠️ LANGGRAPH_CLI_NO_ANALYTICS is set. Overriding it for the test.")
with patch.dict(os.environ, {"LANGGRAPH_CLI_NO_ANALYTICS": "1"}):
with patch.dict(os.environ, {"LANGGRAPH_CLI_NO_ANALYTICS": "0"}):
yield
-237
View File
@@ -255,243 +255,6 @@ def test_validate_config():
)
@pytest.mark.parametrize(
"dependency",
[
"git+https://user:secret-token@github.com/org/private.git@main",
"private-package @ git+http://token@github.com/org/private.git",
"git+HTTPS://user%40example.com:secret%2Ftoken@github.com/org/private.git",
"git+https://${GIT_TOKEN}@github.com/org/private.git",
],
)
def test_validate_config_rejects_git_http_url_userinfo(dependency: str):
with pytest.raises(click.UsageError) as exc_info:
validate_config(
{
"python_version": "3.11",
"dependencies": [dependency],
"graphs": {"agent": "./agent.py:graph"},
}
)
message = str(exc_info.value)
assert "must not contain credentials or other URL userinfo" in message
assert "secret-token" not in message
assert "secret%2Ftoken" not in message
def test_validate_config_file_reports_source_for_git_http_url_userinfo(
tmp_path: pathlib.Path,
):
config_path = tmp_path / "langgraph.json"
config_path.write_text(
json.dumps(
{
"python_version": "3.11",
"dependencies": ["git+https://secret-token@github.com/org/private.git"],
"graphs": {"agent": "./agent.py:graph"},
}
)
)
with pytest.raises(click.UsageError) as exc_info:
validate_config_file(config_path)
message = str(exc_info.value)
assert "secret-token" not in message
assert f"Found in: {config_path.resolve()}" in message
@pytest.mark.parametrize(
"manifest", ["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"]
)
def test_config_to_docker_rejects_git_http_url_userinfo_in_node_files(
tmp_path: pathlib.Path, manifest: str
):
config_path = tmp_path / "langgraph.json"
config_path.write_text("{}\n")
(tmp_path / "agent.js").write_text("export const graph = {};\n")
(tmp_path / "package.json").write_text('{"name":"agent"}\n')
(tmp_path / manifest).write_text(
'"priv": "git+https://user:secret-token@github.com/org/private.git"\n'
)
config = validate_config(
{
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
}
)
with pytest.raises(click.UsageError) as exc_info:
config_to_docker(
config_path,
config,
base_image="langchain/langgraphjs-api",
)
message = str(exc_info.value)
assert "must not contain credentials or other URL userinfo" in message
assert "secret-token" not in message
assert f"Found in: {(tmp_path / manifest).resolve()}" in message
def test_config_to_docker_allows_node_git_urls_without_http_userinfo(
tmp_path: pathlib.Path,
):
config_path = tmp_path / "langgraph.json"
config_path.write_text("{}\n")
(tmp_path / "agent.js").write_text("export const graph = {};\n")
(tmp_path / "package.json").write_text(
'{"dependencies":{"public":"git+https://github.com/org/public.git"}}\n'
)
config = validate_config(
{
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
}
)
docker, _ = config_to_docker(
config_path,
config,
base_image="langchain/langgraphjs-api",
)
assert f"ADD . /deps/{tmp_path.name}" in docker
def test_config_to_docker_rejects_git_http_url_userinfo_in_node_workspace(
tmp_path: pathlib.Path,
):
config_root = tmp_path / "apps" / "agent"
config_root.mkdir(parents=True)
config_path = config_root / "langgraph.json"
config_path.write_text("{}\n")
(config_root / "agent.js").write_text("export const graph = {};\n")
(config_root / "package.json").write_text(
'{"dependencies":{"priv":"git+https://secret-token@github.com/org/private.git"}}\n'
)
(tmp_path / "package.json").write_text('{"name":"workspace"}\n')
config = validate_config(
{
"node_version": "20",
"graphs": {"agent": "./agent.js:graph"},
}
)
with pytest.raises(click.UsageError) as exc_info:
config_to_docker(
config_path,
config,
base_image="langchain/langgraphjs-api",
build_context=str(tmp_path),
)
message = str(exc_info.value)
assert "secret-token" not in message
assert f"Found in: {(config_root / 'package.json').resolve()}" in message
@pytest.mark.parametrize(
"dependency",
[
"git+https://github.com/org/public.git@main",
"private-package @ git+https://github.com/org/private.git@main",
"git+ssh://git@github.com/org/private.git@main",
],
)
def test_validate_config_allows_git_urls_without_http_userinfo(dependency: str):
config = validate_config(
{
"python_version": "3.11",
"dependencies": [dependency],
"graphs": {"agent": "./agent.py:graph"},
}
)
assert config["dependencies"] == [dependency]
def test_config_to_docker_rejects_git_http_url_userinfo_in_requirements(
tmp_path: pathlib.Path,
):
config_path = tmp_path / "langgraph.json"
config_path.write_text("{}\n")
(tmp_path / "agent.py").write_text("graph = object()\n")
(tmp_path / "requirements.txt").write_text(
"private @ git+https://secret-token@github.com/org/private.git\n"
)
config = validate_config(
{
"python_version": "3.11",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
}
)
with pytest.raises(click.UsageError) as exc_info:
config_to_docker(
config_path,
config,
base_image="langchain/langgraph-api:0.2.47",
)
message = str(exc_info.value)
assert "must not contain credentials or other URL userinfo" in message
assert "secret-token" not in message
assert f"Found in: {(tmp_path / 'requirements.txt').resolve()}" in message
@pytest.mark.parametrize("manifest", ["pyproject.toml", "uv.lock"])
def test_config_to_docker_rejects_git_http_url_userinfo_in_uv_files(
tmp_path: pathlib.Path, manifest: str
):
config_path = tmp_path / "langgraph.json"
config_path.write_text("{}\n")
(tmp_path / "src").mkdir()
(tmp_path / "src" / "agent.py").write_text("graph = object()\n")
pyproject = textwrap.dedent(
"""
[project]
name = "agent"
version = "0.1.0"
dependencies = ["private"]
[tool.uv.sources]
private = { git = "https://github.com/org/private.git" }
"""
).strip()
uv_lock = "# uv lock file\n"
if manifest == "pyproject.toml":
pyproject = pyproject.replace(
"https://github.com", "https://secret-token@github.com"
)
else:
uv_lock += (
'source = { git = "https://secret-token@github.com/org/private.git" }\n'
)
(tmp_path / "pyproject.toml").write_text(pyproject + "\n")
(tmp_path / "uv.lock").write_text(uv_lock)
config = validate_config(
{
"python_version": "3.11",
"graphs": {"agent": "./src/agent.py:graph"},
"source": {"kind": "uv"},
}
)
with pytest.raises(click.UsageError) as exc_info:
config_to_docker(
config_path,
config,
base_image="langchain/langgraph-api:0.2.47",
)
message = str(exc_info.value)
assert "must not contain credentials or other URL userinfo" in message
assert "secret-token" not in message
def test_validate_config_image_distro():
"""Test validation of image_distro field."""
# Valid image_distro values should work
@@ -1,119 +0,0 @@
import json
from unittest.mock import Mock
import httpx
import pytest
from click.testing import CliRunner
import langgraph_cli.deploy as deploy
from langgraph_cli.cli import cli
from langgraph_cli.host_backend import HostBackendClient
@pytest.fixture
def deployment_api(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("LANGSMITH_DEPLOYMENT_NAME", raising=False)
monkeypatch.setattr(deploy, "_emitter", None)
monkeypatch.setattr(deploy, "_no_input", False)
(tmp_path / "langgraph.json").write_text(
json.dumps({"dependencies": ["."], "graphs": {"agent": "./agent.py:graph"}})
)
(tmp_path / ".env").write_text("LANGSMITH_DEPLOYMENT_NAME=legacy\n")
requests = []
state = {"enabled": True, "resources": []}
def handler(request):
requests.append(request)
assert request.url.path == "/v2/deployments"
if request.method == "GET":
if not state["enabled"] and (
"agent_id" in request.url.params
or "agent_environment" in request.url.params
):
return httpx.Response(
400, text="Agent filters are not available for this tenant."
)
return httpx.Response(200, json={"resources": state["resources"]})
assert request.method == "POST"
return httpx.Response(200, json={"id": "runtime-id", "name": "server-name"})
client = HostBackendClient("https://api.example.com", "test-key")
client._client.close()
client._client = httpx.Client(
base_url="https://api.example.com",
transport=httpx.MockTransport(handler),
headers={"X-Api-Key": "test-key"},
)
monkeypatch.setattr(deploy, "_create_host_backend_client", lambda *a, **kw: client)
monkeypatch.setattr(deploy, "find_tracked_packages", lambda *a: [])
remote_build = Mock(return_value=deploy.BuildResult())
monkeypatch.setattr(deploy, "_run_remote_build", remote_build)
monkeypatch.setattr(deploy, "_resolve_build_mode", lambda flag, **kw: (flag, None))
yield state, requests, remote_build
client._client.close()
AGENT_ARGS = [
"deploy",
"--agent-id",
"customer-support",
"--agent-environment",
"staging",
"--remote",
"--no-wait",
"--no-input",
]
def test_agent_create(deployment_api, tmp_path, monkeypatch):
monkeypatch.setenv("LANGSMITH_DEPLOYMENT_NAME", "legacy")
_, requests, build = deployment_api
result = CliRunner().invoke(cli, AGENT_ARGS)
assert result.exit_code == 0, result.output
assert dict(requests[0].url.params) == {
"agent_id": "customer-support",
"agent_environment": "staging",
"limit": "100",
}
payload = json.loads(requests[1].content)
assert payload["agent"] == {
"agent_id": "customer-support",
"environment": "staging",
}
assert "name" not in payload
assert build.call_args.kwargs["deployment_id"] == "runtime-id"
assert "server-name" in result.output
assert (tmp_path / ".env").read_text() == "LANGSMITH_DEPLOYMENT_NAME=legacy\n"
def test_agent_update(deployment_api):
state, requests, build = deployment_api
state["resources"] = [{"id": "existing-id", "is_preview": False}]
result = CliRunner().invoke(cli, AGENT_ARGS)
assert result.exit_code == 0, result.output
assert len(requests) == 1
assert build.call_args.kwargs["deployment_id"] == "existing-id"
def test_agent_rejects_explicit_name(deployment_api, monkeypatch):
monkeypatch.setenv("LANGSMITH_DEPLOYMENT_NAME", "legacy")
_, requests, _ = deployment_api
result = CliRunner().invoke(cli, [*AGENT_ARGS, "--name", "legacy"])
assert result.exit_code == 2
assert "cannot be combined" in result.output
assert not requests
def test_agent_lookup_refuses_a_control_plane_that_ignores_the_filter(deployment_api):
state, requests, _ = deployment_api
state["resources"] = [
{"id": "someone-elses", "is_preview": False},
{"id": "another", "is_preview": False},
]
result = CliRunner().invoke(cli, AGENT_ARGS)
assert result.exit_code != 0
assert "does not filter deployments by agent" in result.output
assert len(requests) == 1
+57 -529
View File
@@ -13,17 +13,6 @@ import pytest
import langgraph_cli.deploy as deploy_mod
from langgraph_cli.deploy import (
ById,
ByName,
CustomerRegistrySource,
DockerBuildCommand,
ExistingDeployment,
Listener,
ManagedRegistrySource,
OnListener,
RemoteBuildSource,
RequestedPlacement,
Unplaced,
_call_host_backend_with_optional_tenant,
_create_host_backend_client,
_docker_config_for_token,
@@ -32,14 +21,12 @@ from langgraph_cli.deploy import (
_parse_env_from_config,
_resolve_env_path,
_resolve_pushed_image_digest,
_select_source,
_smith_dashboard_base_url,
_validate_prebuilt_image,
find_deployment_by_name,
normalize_image_tag,
normalize_name,
)
from langgraph_cli.host_backend import HostBackendClient, HostBackendError
from langgraph_cli.image_reference import ImageReference
class TestDockerConfigForToken:
@@ -272,29 +259,31 @@ class TestEnvWithoutDeploymentName:
class TestCallHostBackendWithOptionalTenant:
def _make_client(self, handler):
c = HostBackendClient(
"https://api.example.com",
"test-key",
c = HostBackendClient("https://api.example.com", "test-key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=httpx.MockTransport(handler),
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
timeout=30,
)
return c
def _make_eu_client(self, handler):
c = HostBackendClient(
"https://eu.api.host.langchain.com",
"test-key",
c = HostBackendClient("https://eu.api.host.langchain.com", "test-key")
c._client = httpx.Client(
base_url="https://eu.api.host.langchain.com",
transport=httpx.MockTransport(handler),
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
timeout=30,
)
return c
def test_success_passes_through(self):
client = self._make_client(
lambda req: httpx.Response(200, json={"resources": [{"id": "dep-1"}]})
)
client = self._make_client(lambda req: httpx.Response(200, json={"ok": True}))
result = _call_host_backend_with_optional_tenant(
client, lambda c: c.list_deployments()
)
assert result == [{"id": "dep-1"}]
assert result == {"ok": True}
def test_403_not_enabled_gives_actionable_error(self):
detail = (
@@ -345,6 +334,7 @@ class TestCallHostBackendWithOptionalTenant:
assert exc_info.value.status_code == 403
assert "smith.langchain.com" in exc_info.value.message
assert seen_tenant_ids == [None, "workspace-123"]
assert client._client.headers["X-Tenant-ID"] == "workspace-123"
def test_other_403_re_raises_original(self):
client = self._make_client(
@@ -550,226 +540,60 @@ class TestCreateHostBackendClientNoInput:
assert client is not None
class TestCreateHostBackendClientEndpoint:
def test_langsmith_endpoint_from_project_env_selects_self_hosted_control_plane(
self, monkeypatch
):
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
monkeypatch.delenv("LANGSMITH_ENDPOINT", raising=False)
class TestSmithDashboardBaseUrl:
def test_none_returns_default(self):
assert _smith_dashboard_base_url(None) == "https://smith.langchain.com"
client = _create_host_backend_client(
host_url=None,
api_key=None,
env_vars={"LANGSMITH_ENDPOINT": "https://smith.example.com/api/v1"},
def test_empty_returns_default(self):
assert _smith_dashboard_base_url("") == "https://smith.langchain.com"
def test_prod_host_url(self):
assert (
_smith_dashboard_base_url("https://api.host.langchain.com")
== "https://smith.langchain.com"
)
assert client.base_url == "https://smith.example.com/api-host"
def test_explicit_host_url_wins_over_langsmith_endpoint(self, monkeypatch):
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://smith.example.com/api/v1")
client = _create_host_backend_client(
host_url="https://custom.host.com", api_key=None, env_vars={}
def test_dev_host_url(self):
assert (
_smith_dashboard_base_url("https://dev.api.host.langchain.com")
== "https://dev.smith.langchain.com"
)
assert client.base_url == "https://custom.host.com"
class TestDockerBuildCommand:
@pytest.mark.parametrize(
("machine", "verbose", "expected"),
[
pytest.param(
"x86_64",
False,
DockerBuildCommand(("docker", "build"), ()),
id="amd64_host_builds_natively",
),
pytest.param(
"arm64",
False,
DockerBuildCommand(
("docker", "buildx", "build"),
("--platform", "linux/amd64", "--load", "--progress=quiet"),
),
id="other_hosts_cross_build_quietly",
),
pytest.param(
"arm64",
True,
DockerBuildCommand(
("docker", "buildx", "build"),
("--platform", "linux/amd64", "--load"),
),
id="verbose_cross_build_keeps_progress_output",
),
],
)
def test_for_host_targets_the_deployment_platform(self, machine, verbose, expected):
assert DockerBuildCommand.for_host(machine, verbose=verbose) == expected
class TestSelectSource:
OPTIONS = {
"push_to": None,
"image": None,
"image_name": None,
"tag": None,
"remote_build_flag": None,
"placement": RequestedPlacement(),
"selector": ByName("my-app"),
}
REPOSITORY = "registry.example.com/app"
@pytest.mark.parametrize(
("flags", "docker_available", "expected"),
[
pytest.param(
{"push_to": REPOSITORY},
True,
CustomerRegistrySource(
reference=ImageReference(REPOSITORY, "latest"),
prebuilt_image=None,
requested_placement=RequestedPlacement(),
),
id="push_to_selects_the_external_source_with_the_default_tag",
),
pytest.param(
{"push_to": f"{REPOSITORY}:v2"},
True,
CustomerRegistrySource(
reference=ImageReference(REPOSITORY, "v2"),
prebuilt_image=None,
requested_placement=RequestedPlacement(),
),
id="push_to_keeps_a_tag_given_in_the_reference",
),
pytest.param(
{"push_to": REPOSITORY, "tag": "v3"},
True,
CustomerRegistrySource(
reference=ImageReference(REPOSITORY, "v3"),
prebuilt_image=None,
requested_placement=RequestedPlacement(),
),
id="tag_flag_composes_with_push_to",
),
pytest.param(
{"push_to": REPOSITORY, "image": "app:dev"},
False,
CustomerRegistrySource(
reference=ImageReference(REPOSITORY, "latest"),
prebuilt_image="app:dev",
requested_placement=RequestedPlacement(),
),
id="prebuilt_image_is_retagged_for_push_to_without_docker_checks",
),
pytest.param(
{
"push_to": REPOSITORY,
"placement": RequestedPlacement("listener-1", "agents"),
},
True,
CustomerRegistrySource(
reference=ImageReference(REPOSITORY, "latest"),
prebuilt_image=None,
requested_placement=RequestedPlacement("listener-1", "agents"),
),
id="push_to_carries_the_requested_placement",
),
pytest.param(
{"remote_build_flag": True},
True,
RemoteBuildSource(),
id="remote_flag_selects_the_source_upload",
),
pytest.param(
{},
False,
RemoteBuildSource(),
id="no_local_docker_falls_back_to_the_source_upload",
),
pytest.param(
{},
True,
ManagedRegistrySource(
prebuilt_image=None, image_name=None, tag="latest"
),
id="local_docker_selects_the_internal_docker_source",
),
pytest.param(
{"image": "app:dev", "tag": "v1"},
False,
ManagedRegistrySource(
prebuilt_image="app:dev", image_name=None, tag="v1"
),
id="prebuilt_image_forces_the_internal_docker_source",
),
],
)
def test_flags_select_one_source(
self, monkeypatch, mocker, flags, docker_available, expected
):
mocker.patch(
"langgraph_cli.deploy._get_emitter", return_value=mocker.MagicMock()
)
monkeypatch.setattr(
deploy_mod,
"can_build_locally",
lambda: (True, None) if docker_available else (False, "Docker is required"),
def test_eu_host_url(self):
assert (
_smith_dashboard_base_url("https://eu.api.host.langchain.com")
== "https://eu.smith.langchain.com"
)
assert _select_source(**{**self.OPTIONS, **flags}) == expected
def test_push_to_build_requires_local_docker(self, monkeypatch):
monkeypatch.setattr(
deploy_mod, "can_build_locally", lambda: (False, "Docker is required")
def test_staging_host_url(self):
assert (
_smith_dashboard_base_url("https://staging.api.host.langchain.com")
== "https://staging.smith.langchain.com"
)
with pytest.raises(click.UsageError, match="Docker is required"):
_select_source(**{**self.OPTIONS, "push_to": self.REPOSITORY})
def test_localhost(self):
assert (
_smith_dashboard_base_url("http://localhost:8080")
== "http://localhost:8080"
)
@pytest.mark.parametrize(
("flags", "message"),
[
pytest.param(
{"push_to": REPOSITORY, "remote_build_flag": True},
"--push-to cannot be combined with --remote.",
id="push_to_with_remote",
),
pytest.param(
{"push_to": f"{REPOSITORY}:v1", "tag": "v2"},
"already includes a tag",
id="push_to_with_a_tag_and_the_tag_flag",
),
pytest.param(
{"push_to": f"{REPOSITORY}@sha256:abc"},
"not a digest",
id="push_to_with_a_digest",
),
pytest.param(
{"image": "app:dev", "remote_build_flag": True},
"--image cannot be combined with --remote builds.",
id="image_with_remote",
),
pytest.param(
{"placement": RequestedPlacement(listener_id="listener-1")},
"only apply when creating a deployment with --push-to",
id="listener_without_push_to",
),
pytest.param(
{"placement": RequestedPlacement(k8s_namespace="agents")},
"only apply when creating a deployment with --push-to",
id="namespace_without_push_to",
),
],
)
def test_conflicting_flags_are_rejected(self, monkeypatch, flags, message):
monkeypatch.setattr(deploy_mod, "can_build_locally", lambda: (True, None))
def test_localhost_trailing_slash(self):
assert (
_smith_dashboard_base_url("http://localhost:8080/")
== "http://localhost:8080"
)
with pytest.raises(click.UsageError, match=message):
_select_source(**{**self.OPTIONS, **flags})
def test_127_0_0_1(self):
assert (
_smith_dashboard_base_url("http://127.0.0.1:3000")
== "http://127.0.0.1:3000"
)
def test_unknown_domain_returns_default(self):
assert (
_smith_dashboard_base_url("https://custom.example.com")
== "https://smith.langchain.com"
)
class TestResolvePushedImageDigest:
@@ -820,16 +644,6 @@ class TestResolvePushedImageDigest:
)
assert out == "us-central1-docker.pkg.dev/proj/repo@sha256:abc123"
def test_registry_port_without_tag_still_resolves_the_digest(self):
runner = self._runner('["localhost:5000/repo@sha256:abc123"]')
out = _resolve_pushed_image_digest(
runner,
remote_image="localhost:5000/repo",
docker_config_dir=None,
verbose=False,
)
assert out == "localhost:5000/repo@sha256:abc123"
def test_empty_repodigests_falls_back_with_warning(self, mocker):
emitter = mocker.MagicMock()
mocker.patch("langgraph_cli.deploy._get_emitter", return_value=emitter)
@@ -933,289 +747,3 @@ class TestResolvePushedImageDigest:
frame_locals = captured["coro"].cr_frame.f_locals
assert "--config" not in frame_locals["args"]
captured["coro"].close()
class TestListener:
@pytest.mark.parametrize(
("resource", "expected"),
[
pytest.param(
{
"id": "listener-1",
"compute_id": "prod-cluster",
"compute_config": {"k8s_namespaces": ["agents", "agents-staging"]},
},
Listener("listener-1", "prod-cluster", ("agents", "agents-staging")),
id="reads_id_cluster_and_namespaces",
),
pytest.param(
{"id": "listener-1", "compute_id": "c", "compute_config": {}},
Listener("listener-1", "c", ()),
id="missing_namespaces",
),
pytest.param(
{"id": "listener-1", "compute_id": "c", "compute_config": None},
Listener("listener-1", "c", ()),
id="null_compute_config",
),
pytest.param(
{"id": "listener-1"},
Listener("listener-1", "", ()),
id="only_an_id",
),
],
)
def test_from_resource_reads_the_control_plane_shape(self, resource, expected):
assert Listener.from_resource(resource) == expected
ONE_NAMESPACE = Listener("listener-1", "prod-cluster", ("agents",))
TWO_NAMESPACES = Listener("listener-2", "multi-cluster", ("agents", "agents-staging"))
NO_NAMESPACE = Listener("listener-3", "broken-cluster", ())
class TestRequestedPlacement:
@pytest.mark.parametrize(
("request_", "listeners", "expected"),
[
pytest.param(
RequestedPlacement(), (), Unplaced(), id="no_listeners_no_request"
),
pytest.param(
RequestedPlacement(),
(ONE_NAMESPACE,),
OnListener("listener-1", "agents"),
id="uses_the_only_possible_answer",
),
pytest.param(
RequestedPlacement(k8s_namespace="agents-staging"),
(TWO_NAMESPACES,),
OnListener("listener-2", "agents-staging"),
id="namespace_alone_picks_the_only_listener",
),
],
)
def test_resolves_to_a_placement(self, request_, listeners, expected):
assert request_.among(listeners) == expected
@pytest.mark.parametrize(
("request_", "listeners", "message"),
[
pytest.param(
RequestedPlacement(listener_id="listener-1"),
(),
"no listeners",
id="workspace_has_no_listeners",
),
pytest.param(
RequestedPlacement(),
(ONE_NAMESPACE, TWO_NAMESPACES),
"--listener-id",
id="several_listeners_need_a_choice",
),
pytest.param(
RequestedPlacement(k8s_namespace="agents"),
(ONE_NAMESPACE, TWO_NAMESPACES),
"--listener-id",
id="namespace_alone_is_ambiguous_with_several_listeners",
),
pytest.param(
RequestedPlacement(k8s_namespace="agents"),
(),
"no listeners",
id="namespace_without_any_listener",
),
pytest.param(
RequestedPlacement(),
(TWO_NAMESPACES,),
"--k8s-namespace",
id="several_namespaces_need_a_choice",
),
],
)
def test_refuses_and_names_the_choices(self, request_, listeners, message):
with pytest.raises(click.UsageError, match=message):
request_.among(listeners)
def test_the_error_lists_every_listener_with_its_cluster_and_namespaces(self):
with pytest.raises(click.UsageError) as error:
RequestedPlacement().among((ONE_NAMESPACE, TWO_NAMESPACES))
assert "listener-1" in error.value.message
assert "prod-cluster" in error.value.message
assert "agents-staging" in error.value.message
@pytest.mark.parametrize(
("placement", "expected"),
[
pytest.param(Unplaced(), {}, id="unplaced_adds_nothing"),
pytest.param(
OnListener("listener-1", "agents"),
{
"listener_id": "listener-1",
"listener_config": {"k8s_namespace": "agents"},
},
id="placed_carries_listener_and_namespace",
),
],
)
def test_source_config_matches_the_control_plane_shape(self, placement, expected):
assert placement.source_config() == expected
def test_finding_a_deployment_by_name_narrows_the_search_for_every_server_version():
seen: dict = {}
def handler(req: httpx.Request) -> httpx.Response:
seen["params"] = dict(req.url.params)
return httpx.Response(
200,
json={"resources": [{"id": "dep-1", "name": "agent", "source": "github"}]},
)
client = HostBackendClient(
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
)
found = find_deployment_by_name(client, "agent")
assert seen["params"] == {
"name": "agent",
"name_contains": "agent",
"limit": "100",
}
assert found == ExistingDeployment("dep-1", "github")
def test_a_server_that_ignores_the_exact_name_filter_never_matches_another_deployment():
client = HostBackendClient(
"https://api.example.com",
"key",
transport=httpx.MockTransport(
lambda req: httpx.Response(
200,
json={
"resources": [
{
"id": "dep-other",
"name": "another-teams-agent",
"source": "external_docker",
}
]
},
)
),
)
assert find_deployment_by_name(client, "brand-new-agent") is None
def test_a_full_page_without_a_match_refuses_to_claim_the_name_is_free():
page = [
{"id": f"dep-{index}", "name": f"other-agent-{index}"} for index in range(100)
]
client = HostBackendClient(
"https://api.example.com",
"key",
transport=httpx.MockTransport(
lambda req: httpx.Response(200, json={"resources": page})
),
)
with pytest.raises(click.ClickException, match="--deployment-id"):
find_deployment_by_name(client, "brand-new-agent")
def test_a_partial_page_without_a_match_means_the_name_is_free():
client = HostBackendClient(
"https://api.example.com",
"key",
transport=httpx.MockTransport(
lambda req: httpx.Response(
200, json={"resources": [{"id": "dep-1", "name": "other"}]}
)
),
)
assert find_deployment_by_name(client, "brand-new-agent") is None
@pytest.mark.parametrize(
"resource",
[
pytest.param({"compute_id": "c"}, id="no_id"),
pytest.param({"id": ""}, id="empty_id"),
],
)
def test_a_listener_without_an_id_is_refused(resource):
with pytest.raises(HostBackendError, match="without an id"):
Listener.from_resource(resource)
def test_a_deployment_id_with_listener_flags_is_refused_without_probing_docker(
monkeypatch,
):
def explode() -> tuple[bool, str | None]:
raise AssertionError("docker must not be probed for an argv-only conflict")
monkeypatch.setattr(deploy_mod, "can_build_locally", explode)
with pytest.raises(click.UsageError, match="--deployment-id"):
_select_source(
push_to="registry.example.com/app",
image=None,
image_name=None,
tag=None,
remote_build_flag=None,
placement=RequestedPlacement(listener_id="listener-1"),
selector=ById("dep-1"),
)
class TestPlacementOnAKnownListener:
@pytest.mark.parametrize(
("request_", "listener", "expected"),
[
pytest.param(
RequestedPlacement(listener_id="listener-1"),
ONE_NAMESPACE,
OnListener("listener-1", "agents"),
id="the_only_namespace_is_used",
),
pytest.param(
RequestedPlacement(listener_id="listener-2", k8s_namespace="agents"),
TWO_NAMESPACES,
OnListener("listener-2", "agents"),
id="the_chosen_namespace_is_used",
),
],
)
def test_places_on_the_listener(self, request_, listener, expected):
assert request_.on(listener) == expected
@pytest.mark.parametrize(
("request_", "listener", "message"),
[
pytest.param(
RequestedPlacement(listener_id="listener-2"),
TWO_NAMESPACES,
"--k8s-namespace",
id="several_namespaces_need_a_choice",
),
pytest.param(
RequestedPlacement(listener_id="listener-2", k8s_namespace="nope"),
TWO_NAMESPACES,
"does not serve namespace",
id="unknown_namespace",
),
pytest.param(
RequestedPlacement(listener_id="listener-3"),
NO_NAMESPACE,
"serves no namespaces",
id="listener_without_namespaces",
),
],
)
def test_refuses_and_names_the_namespaces(self, request_, listener, message):
with pytest.raises(click.UsageError, match=message):
request_.on(listener)
+148 -535
View File
@@ -3,16 +3,29 @@ import json
import httpx
import pytest
from langgraph_cli.host_backend import (
ControlPlaneEndpoints,
HostBackendClient,
HostBackendError,
)
from langgraph_cli.host_backend import HostBackendClient, HostBackendError
@pytest.fixture
def mock_transport():
return httpx.MockTransport(lambda req: httpx.Response(200, json={"ok": True}))
@pytest.fixture
def client(mock_transport):
c = HostBackendClient("https://api.example.com", "test-key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=mock_transport,
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
timeout=30,
)
return c
def test_constructor_strips_trailing_slash():
c = HostBackendClient("https://api.example.com/", "key")
assert c.base_url == "https://api.example.com"
assert str(c._client.base_url) == "https://api.example.com"
def test_constructor_empty_url_raises():
@@ -26,8 +39,12 @@ def test_request_sends_headers():
assert req.headers["accept"] == "application/json"
return httpx.Response(200, json={"ok": True})
c = HostBackendClient(
"https://api.example.com", "test-key", transport=httpx.MockTransport(handler)
c = HostBackendClient("https://api.example.com", "test-key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=httpx.MockTransport(handler),
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
timeout=30,
)
result = c._request("GET", "/test")
assert result == {"ok": True}
@@ -39,8 +56,12 @@ def test_request_sends_json_payload():
assert req.content == b'{"key":"value"}'
return httpx.Response(200, json={"created": True})
c = HostBackendClient(
"https://api.example.com", "test-key", transport=httpx.MockTransport(handler)
c = HostBackendClient("https://api.example.com", "test-key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=httpx.MockTransport(handler),
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
timeout=30,
)
result = c._request("POST", "/test", {"key": "value"})
assert result == {"created": True}
@@ -48,13 +69,25 @@ def test_request_sends_json_payload():
def test_request_empty_body_returns_none():
transport = httpx.MockTransport(lambda req: httpx.Response(200, content=b""))
c = HostBackendClient("https://api.example.com", "test-key", transport=transport)
c = HostBackendClient("https://api.example.com", "test-key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=transport,
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
timeout=30,
)
assert c._request("DELETE", "/test") is None
def test_request_http_error_raises():
transport = httpx.MockTransport(lambda req: httpx.Response(404, text="not found"))
c = HostBackendClient("https://api.example.com", "test-key", transport=transport)
c = HostBackendClient("https://api.example.com", "test-key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=transport,
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
timeout=30,
)
with pytest.raises(HostBackendError, match="404"):
c._request("GET", "/missing")
@@ -63,7 +96,13 @@ def test_request_invalid_json_raises():
transport = httpx.MockTransport(
lambda req: httpx.Response(200, content=b"not json")
)
c = HostBackendClient("https://api.example.com", "test-key", transport=transport)
c = HostBackendClient("https://api.example.com", "test-key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=transport,
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
timeout=30,
)
with pytest.raises(HostBackendError, match="Failed to decode"):
c._request("GET", "/bad-json")
@@ -72,20 +111,84 @@ def test_request_transport_error_raises():
def handler(req: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("connection refused")
c = HostBackendClient(
"https://api.example.com", "test-key", transport=httpx.MockTransport(handler)
c = HostBackendClient("https://api.example.com", "test-key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=httpx.MockTransport(handler),
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
timeout=30,
)
with pytest.raises(HostBackendError, match="connection refused"):
c._request("GET", "/test")
def test_create_deployment(client):
result = client.create_deployment(
name="my-deploy", deployment_type="dev", source="internal_docker"
)
assert result == {"ok": True}
def test_get_deployment(client):
result = client.get_deployment("dep-123")
assert result == {"ok": True}
def test_list_deployments(client):
result = client.list_deployments("my-app")
assert result == {"ok": True}
def test_list_deployments_sends_query_params():
def handler(req: httpx.Request) -> httpx.Response:
assert req.url.path == "/v2/deployments"
assert req.url.params["name_contains"] == "my app"
return httpx.Response(200, json={"ok": True})
c = HostBackendClient("https://api.example.com", "test-key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=httpx.MockTransport(handler),
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
timeout=30,
)
result = c.list_deployments("my app")
assert result == {"ok": True}
def test_delete_deployment(client):
result = client.delete_deployment("dep-123")
assert result == {"ok": True}
def test_request_push_token(client):
result = client.request_push_token("dep-123")
assert result == {"ok": True}
def test_update_deployment(client):
result = client.update_deployment(
"dep-123", "image:latest", secrets=[{"name": "KEY", "value": "val"}]
)
assert result == {"ok": True}
def test_update_deployment_no_secrets(client):
result = client.update_deployment("dep-123", "image:latest")
assert result == {"ok": True}
def _capturing_client(captured: dict) -> HostBackendClient:
def handler(req: httpx.Request) -> httpx.Response:
captured["body"] = req.read()
return httpx.Response(200, json={"ok": True})
c = HostBackendClient(
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
c = HostBackendClient("https://api.example.com", "key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=httpx.MockTransport(handler),
headers={"X-Api-Key": "key", "Accept": "application/json"},
timeout=30,
)
return c
@@ -96,7 +199,6 @@ def test_update_deployment_forwards_tracked_packages():
c.update_deployment(
"dep-123",
"image:latest",
revision_source="internal_docker",
tracked_packages=["google-adk:1.0.0"],
)
body = json.loads(captured["body"])
@@ -107,7 +209,7 @@ def test_update_deployment_forwards_tracked_packages():
def test_update_deployment_omits_tracked_packages_when_absent():
captured: dict = {}
c = _capturing_client(captured)
c.update_deployment("dep-123", "image:latest", revision_source="internal_docker")
c.update_deployment("dep-123", "image:latest")
body = json.loads(captured["body"])
assert "tracked_packages" not in body
@@ -139,14 +241,33 @@ def test_update_deployment_internal_source_omits_tracked_packages_when_absent():
assert "tracked_packages" not in body
def test_list_revisions(client):
result = client.list_revisions("dep-123", limit=5)
assert result == {"ok": True}
def test_get_revision(client):
result = client.get_revision("dep-123", "rev-456")
assert result == {"ok": True}
def test_get_build_logs(client):
result = client.get_build_logs("proj-1", "rev-1", {"limit": 10})
assert result == {"ok": True}
def test_get_deploy_logs_all_revisions():
def handler(req: httpx.Request) -> httpx.Response:
assert "/v1/projects/proj-1/deploy_logs" in str(req.url)
assert "/revisions/" not in str(req.url)
return httpx.Response(200, json={"logs": [{"message": "running"}]})
c = HostBackendClient(
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
c = HostBackendClient("https://api.example.com", "key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=httpx.MockTransport(handler),
headers={"X-Api-Key": "key", "Accept": "application/json"},
timeout=30,
)
result = c.get_deploy_logs("proj-1", {"limit": 10})
assert result == {"logs": [{"message": "running"}]}
@@ -157,520 +278,12 @@ def test_get_deploy_logs_specific_revision():
assert "/v1/projects/proj-1/revisions/rev-2/deploy_logs" in str(req.url)
return httpx.Response(200, json={"logs": []})
c = HostBackendClient(
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
c = HostBackendClient("https://api.example.com", "key")
c._client = httpx.Client(
base_url="https://api.example.com",
transport=httpx.MockTransport(handler),
headers={"X-Api-Key": "key", "Accept": "application/json"},
timeout=30,
)
result = c.get_deploy_logs("proj-1", {"limit": 10}, revision_id="rev-2")
assert result == {"logs": []}
def _routing_client(seen: dict) -> HostBackendClient:
def handler(req: httpx.Request) -> httpx.Response:
seen["method"] = req.method
seen["url"] = str(req.url)
return httpx.Response(200, json={"ok": True})
c = HostBackendClient(
"https://api.example.com/prefix", "key", transport=httpx.MockTransport(handler)
)
return c
@pytest.mark.parametrize(
("call", "expected_body"),
[
pytest.param(
lambda c: c.create_deployment(
name="my-deploy",
source="internal_docker",
source_config={"deployment_type": "dev"},
source_revision_config={},
),
{
"name": "my-deploy",
"source": "internal_docker",
"source_config": {"deployment_type": "dev"},
"source_revision_config": {},
},
id="internal_docker_create_omits_secrets_key_when_not_given",
),
pytest.param(
lambda c: c.create_deployment(
name="my-deploy",
source="internal_docker",
source_config={"deployment_type": "prod"},
source_revision_config={},
secrets=[{"name": "KEY", "value": "val"}],
),
{
"name": "my-deploy",
"source": "internal_docker",
"source_config": {"deployment_type": "prod"},
"source_revision_config": {},
"secrets": [{"name": "KEY", "value": "val"}],
},
id="internal_docker_create_forwards_secrets",
),
pytest.param(
lambda c: c.update_deployment(
"dep-123",
"registry.example.com/app@sha256:abc",
revision_source="internal_docker",
secrets=[{"name": "KEY", "value": "val"}],
),
{
"revision_source": "internal_docker",
"source_revision_config": {
"image_uri": "registry.example.com/app@sha256:abc"
},
"secrets": [{"name": "KEY", "value": "val"}],
},
id="internal_docker_revision_names_its_source",
),
pytest.param(
lambda c: c.update_deployment_internal_source(
"dep-123",
source_tarball_path="tarballs/src.tgz",
config_path="langgraph.json",
secrets=[],
install_command="yarn install",
build_command="yarn build",
),
{
"revision_source": "internal_source",
"source_revision_config": {
"source_tarball_path": "tarballs/src.tgz",
"langgraph_config_path": "langgraph.json",
},
"source_config": {
"install_command": "yarn install",
"build_command": "yarn build",
},
"secrets": [],
},
id="internal_source_revision_sends_js_build_commands",
),
pytest.param(
lambda c: c.update_deployment_internal_source(
"dep-123",
source_tarball_path="tarballs/src.tgz",
config_path="langgraph.json",
),
{
"revision_source": "internal_source",
"source_revision_config": {
"source_tarball_path": "tarballs/src.tgz",
"langgraph_config_path": "langgraph.json",
},
},
id="internal_source_revision_omits_source_config_without_commands",
),
pytest.param(
lambda c: c.create_deployment(
name="agent",
source="external_docker",
source_config={"resource_spec": {}},
source_revision_config={
"image_uri": "registry.example.com/agent@sha256:1"
},
secrets=[],
),
{
"name": "agent",
"source": "external_docker",
"source_config": {"resource_spec": {}},
"source_revision_config": {
"image_uri": "registry.example.com/agent@sha256:1"
},
"secrets": [],
},
id="create_sends_the_source_configs_as_given",
),
pytest.param(
lambda c: c.update_deployment(
"dep-1", "registry.example.com/agent@sha256:2", revision_source=None
),
{
"source_revision_config": {
"image_uri": "registry.example.com/agent@sha256:2"
}
},
id="revision_without_source_override_omits_revision_source",
),
pytest.param(
lambda c: c.update_deployment(
"dep-1",
"registry.example.com/agent@sha256:2",
revision_source="internal_docker",
tracked_packages=["langgraph:1.0.0"],
),
{
"revision_source": "internal_docker",
"source_revision_config": {
"image_uri": "registry.example.com/agent@sha256:2"
},
"tracked_packages": ["langgraph:1.0.0"],
},
id="revision_with_source_override_names_it",
),
],
)
def test_request_body_matches_control_plane_contract(call, expected_body):
captured: dict = {}
call(_capturing_client(captured))
assert json.loads(captured["body"]) == expected_body
@pytest.mark.parametrize(
("call", "method", "route"),
[
pytest.param(
lambda c: c.create_deployment(
name="n",
source="internal_docker",
source_config={"deployment_type": "dev"},
source_revision_config={},
),
"POST",
"/v2/deployments",
id="create_deployment",
),
pytest.param(
lambda c: c.get_deployment("dep-1"),
"GET",
"/v2/deployments/dep-1",
id="get_deployment",
),
pytest.param(
lambda c: c.delete_deployment("dep-1"),
"DELETE",
"/v2/deployments/dep-1",
id="delete_deployment",
),
pytest.param(
lambda c: c.update_deployment("dep-1", "img", revision_source=None),
"PATCH",
"/v2/deployments/dep-1",
id="patch_deployment",
),
pytest.param(
lambda c: c.request_push_token("dep-1"),
"POST",
"/v2/deployments/dep-1/push-token",
id="push_token",
),
pytest.param(
lambda c: c.request_upload_url("dep-1"),
"POST",
"/v2/deployments/dep-1/upload-url",
id="upload_url",
),
pytest.param(
lambda c: c.list_revisions("dep-1", limit=5),
"GET",
"/v2/deployments/dep-1/revisions?limit=5",
id="list_revisions_puts_limit_in_query",
),
pytest.param(
lambda c: c.get_revision("dep-1", "rev-2"),
"GET",
"/v2/deployments/dep-1/revisions/rev-2",
id="get_revision",
),
pytest.param(
lambda c: c.get_build_logs("dep-1", "rev-2", {"limit": 10}),
"POST",
"/v1/projects/dep-1/revisions/rev-2/build_logs",
id="build_logs",
),
],
)
def test_request_targets_control_plane_route_under_base_url(call, method, route):
seen: dict = {}
call(_routing_client(seen))
assert (seen["method"], seen["url"]) == (
method,
f"https://api.example.com/prefix{route}",
)
def test_injected_transport_receives_requests_under_the_prefixed_base_url():
seen: dict = {}
def handler(req: httpx.Request) -> httpx.Response:
seen["url"] = str(req.url)
seen["api_key"] = req.headers["x-api-key"]
return httpx.Response(200, json={"ok": True})
c = HostBackendClient(
"https://smith.example.com/api-host",
"key",
transport=httpx.MockTransport(handler),
)
assert c.list_revisions("dep-1", limit=2) == []
assert seen == {
"url": "https://smith.example.com/api-host/v2/deployments/dep-1/revisions?limit=2",
"api_key": "key",
}
CLOUD = ("https://api.host.langchain.com", "https://smith.langchain.com")
@pytest.mark.parametrize(
("host_url", "langsmith_endpoint", "expected"),
[
pytest.param(None, None, CLOUD, id="nothing_configured_targets_cloud"),
pytest.param(
None, "https://api.smith.langchain.com", CLOUD, id="cloud_langsmith_api"
),
pytest.param(
None,
"https://api.smith.langchain.com/api/v1",
CLOUD,
id="cloud_langsmith_api_with_versioned_path",
),
pytest.param(
None, "https://api.langchain.com", CLOUD, id="cloud_langchain_api_alias"
),
pytest.param(
None,
"https://xapi.smith.langchain.com",
CLOUD,
id="lookalike_cloud_host_is_not_rewritten_into_a_control_plane",
),
pytest.param(
None,
"https://eu.api.smith.langchain.com",
("https://eu.api.host.langchain.com", "https://eu.smith.langchain.com"),
id="eu_cloud_maps_to_eu_control_plane",
),
pytest.param(
None,
"https://dev.api.smith.langchain.com",
("https://dev.api.host.langchain.com", "https://dev.smith.langchain.com"),
id="dev_cloud_maps_to_dev_control_plane",
),
pytest.param(
None,
"https://aks.smith.langchain.dev/api",
(
"https://aks.smith.langchain.dev/api-host",
"https://aks.smith.langchain.dev",
),
id="self_hosted_api_path_becomes_api_host",
),
pytest.param(
None,
"https://smith.example.com/api/v1",
("https://smith.example.com/api-host", "https://smith.example.com"),
id="self_hosted_versioned_api_path_becomes_api_host",
),
pytest.param(
None,
"https://smith.example.com",
("https://smith.example.com/api-host", "https://smith.example.com"),
id="self_hosted_origin_gets_api_host_appended",
),
pytest.param(
None,
"https://corp.example.com/langsmith/api/v1",
(
"https://corp.example.com/langsmith/api-host",
"https://corp.example.com/langsmith",
),
id="self_hosted_path_prefix_is_kept",
),
pytest.param(
"https://custom.host.example",
"https://aks.smith.langchain.dev/api",
("https://custom.host.example", "https://smith.langchain.com"),
id="explicit_host_url_beats_langsmith_endpoint",
),
pytest.param(
"https://api.host.langchain.com",
"https://aks.smith.langchain.dev/api",
CLOUD,
id="explicit_cloud_host_url_beats_self_hosted_endpoint",
),
pytest.param(
"https://smith.example.com/api-host/",
None,
("https://smith.example.com/api-host", "https://smith.example.com"),
id="explicit_api_host_url_derives_dashboard_root",
),
pytest.param(
"https://corp.example.com/langsmith/api-host",
None,
(
"https://corp.example.com/langsmith/api-host",
"https://corp.example.com/langsmith",
),
id="explicit_api_host_url_keeps_path_prefix_in_dashboard",
),
pytest.param(
"http://localhost:8080",
None,
("http://localhost:8080", "http://localhost:8080"),
id="localhost_dashboard_is_the_same_origin",
),
pytest.param(
"http://localhost:8080/api-host",
None,
("http://localhost:8080/api-host", "http://localhost:8080"),
id="localhost_api_host_dashboard_is_the_origin",
),
pytest.param(
"https://eu.api.host.langchain.com",
None,
("https://eu.api.host.langchain.com", "https://eu.smith.langchain.com"),
id="regional_control_plane_maps_to_regional_dashboard",
),
],
)
def test_control_plane_endpoints_resolve(host_url, langsmith_endpoint, expected):
endpoints = ControlPlaneEndpoints.resolve(host_url, langsmith_endpoint)
assert (endpoints.control_plane_url, endpoints.dashboard_url) == expected
@pytest.mark.parametrize(
("payload", "expected"),
[
pytest.param(
{"resources": [{"id": "a"}, {"id": "b"}]},
[{"id": "a"}, {"id": "b"}],
id="list_returns_the_resources",
),
pytest.param({"resources": []}, [], id="empty_list"),
pytest.param({}, [], id="missing_key"),
pytest.param({"resources": None}, [], id="null_resources"),
pytest.param(
{"resources": ["nope", {"id": "a"}]}, [{"id": "a"}], id="skips_non_objects"
),
pytest.param([], [], id="unexpected_envelope"),
],
)
def test_list_endpoints_return_resource_objects(payload, expected):
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
c = HostBackendClient(
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
)
assert c.list_deployments() == expected
def test_list_listeners_asks_for_a_full_page():
seen: dict = {}
def handler(req: httpx.Request) -> httpx.Response:
seen["url"] = str(req.url)
return httpx.Response(200, json={"resources": [{"id": "listener-1"}]})
c = HostBackendClient(
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
)
assert c.list_listeners() == [{"id": "listener-1"}]
assert seen["url"] == "https://api.example.com/v2/listeners?limit=100"
@pytest.mark.parametrize(
("control_plane_url", "expected"),
[
pytest.param("https://api.host.langchain.com", True, id="cloud"),
pytest.param("https://eu.api.host.langchain.com", True, id="cloud_region"),
pytest.param("https://dev.api.host.langchain.com", True, id="cloud_dev"),
pytest.param("https://smith.example.com/api-host", False, id="self_hosted"),
pytest.param(
"https://corp.example.com/langsmith/api-host",
False,
id="self_hosted_prefix",
),
pytest.param("http://localhost:8080/api-host", False, id="local"),
pytest.param(
"https://evil-api.host.langchain.com", False, id="lookalike_needs_a_dot"
),
],
)
def test_is_cloud_recognises_the_managed_control_plane(control_plane_url, expected):
endpoints = ControlPlaneEndpoints.from_control_plane_url(control_plane_url)
assert endpoints.is_cloud is expected
@pytest.mark.parametrize(
("call", "expected_params"),
[
pytest.param(
lambda c: c.list_deployments(name="agent"),
{"name": "agent"},
id="exact_name_filters_server_side",
),
pytest.param(
lambda c: c.list_deployments(name_contains="age"),
{"name_contains": "age"},
id="substring_search_keeps_its_own_parameter",
),
pytest.param(
lambda c: c.list_deployments(),
{},
id="no_filter_sends_no_parameters",
),
pytest.param(
lambda c: c.list_deployments(
name="agent", name_contains="agent", limit=100
),
{"name": "agent", "name_contains": "agent", "limit": "100"},
id="both_filters_travel_together_for_older_servers",
),
],
)
def test_list_deployments_sends_one_name_filter(call, expected_params):
seen: dict = {}
def handler(req: httpx.Request) -> httpx.Response:
seen.update(dict(req.url.params))
return httpx.Response(200, json={"resources": []})
call(
HostBackendClient(
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
)
)
assert seen == expected_params
@pytest.mark.parametrize(
("body", "expected"),
[
pytest.param(
{"detail": "Source configuration error: bad listener"},
"Source configuration error: bad listener",
id="fastapi_detail_is_unwrapped",
),
pytest.param(
{"detail": {"loc": ["body"], "msg": "nope"}},
None,
id="a_structured_detail_is_left_alone",
),
pytest.param({"other": "shape"}, None, id="an_unknown_shape_is_left_alone"),
],
)
def test_error_detail_is_readable(body, expected):
c = HostBackendClient(
"https://api.example.com",
"key",
transport=httpx.MockTransport(lambda req: httpx.Response(400, json=body)),
)
with pytest.raises(HostBackendError) as error:
c.get_deployment("dep-1")
assert error.value.detail == expected
if expected is not None:
assert error.value.message.endswith(expected)
@@ -1,71 +0,0 @@
import pytest
from langgraph_cli.image_reference import ImageReference
@pytest.mark.parametrize(
("reference", "repository", "tag"),
[
pytest.param(
"registry.example.com/team/app:v1",
"registry.example.com/team/app",
"v1",
id="tag_after_last_slash",
),
pytest.param(
"registry.example.com/team/app",
"registry.example.com/team/app",
None,
id="no_tag",
),
pytest.param(
"localhost:5000/app",
"localhost:5000/app",
None,
id="registry_port_is_not_a_tag",
),
pytest.param(
"localhost:5000/app:latest",
"localhost:5000/app",
"latest",
id="registry_port_with_tag",
),
pytest.param("app:dev", "app", "dev", id="bare_name_with_tag"),
],
)
def test_parse_splits_repository_and_tag(reference, repository, tag):
assert ImageReference.parse(reference) == ImageReference(repository, tag)
def test_with_tag_replaces_the_tag():
assert ImageReference("r/app", "v1").with_tag("v2") == ImageReference("r/app", "v2")
@pytest.mark.parametrize(
("reference", "expected"),
[
pytest.param(ImageReference("r/app", "v1"), "r/app:v1", id="tagged"),
pytest.param(ImageReference("r/app"), "r/app", id="untagged"),
],
)
def test_str_renders_the_docker_reference(reference, expected):
assert str(reference) == expected
@pytest.mark.parametrize(
("repo_digest", "expected"),
[
pytest.param("localhost:5000/app@sha256:abc", True, id="same_repository"),
pytest.param("localhost:5000/app-2@sha256:abc", False, id="other_repository"),
pytest.param("mirror.example.com/app@sha256:abc", False, id="other_registry"),
],
)
def test_matches_digest_only_for_the_same_repository(repo_digest, expected):
assert ImageReference("localhost:5000/app", "v1").matches_digest(repo_digest) is (
expected
)
def test_parse_rejects_a_digest_reference():
with pytest.raises(ValueError, match="digest"):
ImageReference.parse("registry.example.com/app@sha256:abc")
+3 -3
View File
@@ -39,15 +39,15 @@ wheels = [
[[package]]
name = "anyio"
version = "4.14.2"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
+3 -3
View File
@@ -13,15 +13,15 @@ wheels = [
[[package]]
name = "anyio"
version = "4.14.2"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
+3 -3
View File
@@ -19,16 +19,16 @@ wheels = [
[[package]]
name = "anyio"
version = "4.14.2"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
+35 -11
View File
@@ -119,7 +119,6 @@ from langgraph.pregel._io import (
)
from langgraph.pregel._messages import ensure_message_ids
from langgraph.pregel._read import PregelNode
from langgraph.pregel._task_status import read_task_statuses
from langgraph.pregel._utils import get_new_channel_versions, is_xxh3_128_hexdigest
from langgraph.pregel.debug import (
map_debug_checkpoint,
@@ -737,14 +736,17 @@ class PregelLoop:
def _reapply_writes_to_succeeded_nodes(
self, tasks: Mapping[str, PregelExecutableTask]
) -> None:
"""Restore the output of finished tasks from checkpoint to in-memory tasks.
"""Restore successful channel writes from checkpoint to in-memory tasks.
Unfinished (failed or interrupted) tasks keep empty writes, so the
runner re-executes them or routes them to error handlers.
Skips control signals (ERROR, ERROR_SOURCE_NODE, INTERRUPT, RESUME)
so that failed/interrupted tasks remain with empty writes and will be
re-executed (or routed to error handlers) by the runner.
"""
for tid, status in read_task_statuses(self.checkpoint_pending_writes).items():
for tid, k, v in self.checkpoint_pending_writes:
if k in (ERROR, ERROR_SOURCE_NODE, INTERRUPT, RESUME):
continue
if task := tasks.get(tid):
task.writes.extend(status.output)
task.writes.append((k, v))
def _resume_error_handlers_if_applicable(self) -> None:
"""On resume, schedule error handlers for tasks that failed in a prior run.
@@ -814,13 +816,35 @@ class PregelLoop:
self.tasks[handler_task.id] = handler_task
def _pending_interrupts(self) -> set[str]:
"""Return the ids of interrupts that are still waiting for an answer."""
return {
interrupt.id
for status in read_task_statuses(self.checkpoint_pending_writes).values()
for interrupt in status.pending_interrupts
"""Return the set of interrupt ids that are pending without corresponding resume values."""
# mapping of task ids to interrupt ids
pending_interrupts: dict[str, str] = {}
# set of resume task ids
pending_resumes: set[str] = set()
for task_id, write_type, value in self.checkpoint_pending_writes:
if write_type == INTERRUPT:
# interrupts is always a list, but there should only be one element
pending_interrupts[task_id] = value[0].id
elif write_type == RESUME:
pending_resumes.add(task_id)
resumed_interrupt_ids = {
pending_interrupts[task_id]
for task_id in pending_resumes
if task_id in pending_interrupts
}
# Keep only interrupts whose interrupt_id is not resumed
hanging_interrupts: set[str] = {
interrupt_id
for interrupt_id in pending_interrupts.values()
if interrupt_id not in resumed_interrupt_ids
}
return hanging_interrupts
def _first(
self, *, input_keys: str | Sequence[str], updated_channels: set[str] | None
) -> set[str] | None:
+2 -4
View File
@@ -45,7 +45,6 @@ from langgraph.errors import GraphBubbleUp, GraphInterrupt
from langgraph.pregel._algo import Call
from langgraph.pregel._executor import Submit
from langgraph.pregel._retry import arun_with_retry, run_with_retry
from langgraph.pregel._task_status import CONTROL_WRITES
from langgraph.types import (
CachePolicy,
PregelExecutableTask,
@@ -607,9 +606,8 @@ class PregelRunner:
task.config is None or TAG_HIDDEN not in task.config.get("tags", [])
):
self.node_finished(task.name)
if all(chan in CONTROL_WRITES for chan, _ in task.writes):
# record that the task finished, even if it produced no output
# (see `langgraph.pregel._task_status`)
if not task.writes:
# add no writes marker
task.writes.append((NO_WRITES, None))
# save task writes to checkpointer
self.put_writes()(task.id, task.writes) # type: ignore[misc]
@@ -1,127 +0,0 @@
"""Read the status of each task from the writes recorded for a superstep.
While a superstep is open, the checkpointer keeps a log of writes for each
task in that step. Entries are added as tasks run and are only discarded when
the whole superstep finishes and a new checkpoint is saved. When a task runs
again, for example after being resumed, its earlier entries stay in the log.
This module is the single place that turns that log into task status. Code that
needs to know whether a task finished, which interrupts it raised, which of them
are still waiting for an answer, or which output it produced must use
`read_task_statuses` instead of inspecting the writes directly.
The log uses two kinds of writes:
- Control writes describe what happened to a task: `INTERRUPT` (the task asked
a question), `RESUME` (answers the task has received), `ERROR`, and
`ERROR_SOURCE_NODE`. `INTERRUPT`, `RESUME` and `ERROR` each have a fixed slot
per task (`WRITES_IDX_MAP`), so a newer write of the same kind can replace an
older one.
- Every other write is output: channel writes, `RETURN` for functional tasks,
and the `NO_WRITES` marker.
The rules are:
1. When a task that ran finishes successfully, `PregelRunner.commit` records at
least one output write, adding `NO_WRITES` if the task produced no other
output.
2. A task that pauses at an interrupt records only control writes.
3. A task is therefore treated as finished if and only if it has an output
write.
4. Because `INTERRUPT` is stored in a fixed slot, its recorded value is the most
recent question the task asked. That question is waiting for an answer only
while the task is unfinished.
A `RESUME` write never means a task is finished: it can hold the answer to an
earlier question while the task waits on a later one.
What these rules cannot see:
- A task whose result came from the cache does not go through
`PregelRunner.commit`, so nothing is recorded for it. It reads as not
finished.
- A task that fails can record partial output writes along with its error. It
reads as finished, which is how the executor has always treated it.
- Writes recorded before rule 1 existed may describe a finished task with no
output using only control writes. Those tasks read as unfinished, which
matches how they were treated before.
"""
from __future__ import annotations
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from typing import Any
from langgraph.checkpoint.base import PendingWrite
from langgraph._internal._constants import (
ERROR,
ERROR_SOURCE_NODE,
INTERRUPT,
NULL_TASK_ID,
RESUME,
)
from langgraph.types import Interrupt
__all__ = ("CONTROL_WRITES", "TaskStatus", "read_task_statuses")
CONTROL_WRITES = frozenset((ERROR, ERROR_SOURCE_NODE, INTERRUPT, RESUME))
"""Channels that describe what happened to a task rather than what it produced."""
@dataclass(frozen=True, slots=True)
class TaskStatus:
"""The status of one task, read from the writes recorded for its superstep."""
output: tuple[tuple[str, Any], ...] = ()
"""Output writes in recorded order. Empty if the task has not finished."""
interrupts: tuple[Interrupt, ...] = ()
"""The most recent interrupts the task raised, whether or not they were answered."""
error: BaseException | None = None
"""The recorded error, if any."""
@property
def finished(self) -> bool:
"""Whether the task ran to completion."""
return bool(self.output)
@property
def pending_interrupts(self) -> tuple[Interrupt, ...]:
"""Interrupts waiting for an answer. Always empty for a finished task."""
return () if self.finished else self.interrupts
def read_task_statuses(
pending_writes: Iterable[PendingWrite],
) -> dict[str, TaskStatus]:
"""Return the status of every task that has recorded writes, keyed by task id.
Writes from `NULL_TASK_ID` are input to the superstep, not task activity, so
they are not included.
"""
output: dict[str, list[tuple[str, Any]]] = {}
interrupts: dict[str, list[Interrupt]] = {}
errors: dict[str, BaseException] = {}
for task_id, channel, value in pending_writes:
if task_id == NULL_TASK_ID:
continue
output.setdefault(task_id, [])
if channel == INTERRUPT:
interrupts.setdefault(task_id, []).extend(
value if isinstance(value, Sequence) else [value]
)
elif channel == ERROR:
errors.setdefault(task_id, value)
elif channel not in CONTROL_WRITES:
output[task_id].append((channel, value))
return {
task_id: TaskStatus(
output=tuple(task_output),
interrupts=tuple(interrupts.get(task_id, ())),
error=errors.get(task_id),
)
for task_id, task_output in output.items()
}
+33 -18
View File
@@ -26,7 +26,6 @@ from langgraph._internal._typing import MISSING
from langgraph.channels.base import BaseChannel
from langgraph.constants import TAG_HIDDEN
from langgraph.pregel._io import read_channels
from langgraph.pregel._task_status import TaskStatus, read_task_statuses
from langgraph.types import (
CheckpointPayload,
PregelExecutableTask,
@@ -38,8 +37,6 @@ from langgraph.types import (
TASK_NAMESPACE = UUID("6ba7b831-9dad-11d1-80b4-00c04fd430c8")
_NOT_STARTED = TaskStatus()
def map_debug_tasks(tasks: Iterable[PregelExecutableTask]) -> Iterator[TaskPayload]:
"""Produce "task" events for stream_mode=debug."""
@@ -214,21 +211,35 @@ def tasks_w_writes(
pending_writes: list[PendingWrite] | None,
states: dict[str, RunnableConfig | StateSnapshot] | None,
output_keys: str | Sequence[str],
*,
live: bool = False,
) -> tuple[PregelTask, ...]:
"""Apply writes / subgraph states to tasks to be returned in a StateSnapshot.
With `live=True`, tasks report only the interrupts still waiting for an
answer, as of the most recent writes. Otherwise tasks report the interrupts
they raised in the step, including answered ones, as a record of the step.
"""
statuses = read_task_statuses(pending_writes or [])
"""Apply writes / subgraph states to tasks to be returned in a StateSnapshot."""
pending_writes = pending_writes or []
out: list[PregelTask] = []
for task in tasks:
status = statuses.get(task.id, _NOT_STARTED)
rtn = next((val for chan, val in status.output if chan == RETURN), MISSING)
task_writes = [(chan, val) for chan, val in status.output if chan != RETURN]
rtn = next(
(
val
for tid, chan, val in pending_writes
if tid == task.id and chan == RETURN
),
MISSING,
)
task_error = next(
(exc for tid, n, exc in pending_writes if tid == task.id and n == ERROR),
None,
)
task_interrupts = tuple(
v
for tid, n, vv in pending_writes
if tid == task.id and n == INTERRUPT
for v in (vv if isinstance(vv, Sequence) else [vv])
)
task_writes = [
(chan, val)
for tid, chan, val in pending_writes
if tid == task.id and chan not in (ERROR, INTERRUPT, RETURN)
]
if rtn is not MISSING:
task_result = rtn
@@ -250,15 +261,19 @@ def tasks_w_writes(
mapped_writes = map_task_result_writes(filtered_writes)
task_result = mapped_writes if filtered_writes else {}
has_writes = rtn is not MISSING or any(
w[0] == task.id and w[1] not in (ERROR, INTERRUPT) for w in pending_writes
)
out.append(
PregelTask(
task.id,
task.name,
task.path,
status.error,
status.pending_interrupts if live else status.interrupts,
task_error,
task_interrupts,
states.get(task.id) if states else None,
task_result if status.finished else None,
task_result if has_writes else None,
)
)
return tuple(out)
+33 -43
View File
@@ -79,6 +79,7 @@ from langgraph._internal._constants import (
CONFIG_KEY_STREAM_MESSAGES_V2,
CONFIG_KEY_TASK_ID,
CONFIG_KEY_THREAD_ID,
ERROR,
INPUT,
INTERRUPT,
NS_END,
@@ -148,7 +149,6 @@ from langgraph.pregel._messages import (
from langgraph.pregel._read import DEFAULT_BOUND, PregelNode
from langgraph.pregel._retry import RetryPolicy
from langgraph.pregel._runner import PregelRunner
from langgraph.pregel._task_status import read_task_statuses
from langgraph.pregel._tools import StreamToolCallHandler
from langgraph.pregel._utils import (
get_new_channel_versions,
@@ -1147,16 +1147,8 @@ class Pregel(
config: RunnableConfig,
saved: CheckpointTuple | None,
recurse: BaseCheckpointSaver | None = None,
live: bool = False,
apply_pending_writes: bool = False,
) -> StateSnapshot:
"""Build a `StateSnapshot` from a saved checkpoint and its pending writes.
With `live=True` the snapshot shows current status: values include the
output of tasks that already finished, `next` lists only tasks that still
need to run, and `interrupts` lists only questions still waiting for an
answer. Otherwise the snapshot is a record of the step: values as of the
start of the step, every task in the step, and the interrupts they raised.
"""
if not saved:
return StateSnapshot(
values={},
@@ -1244,10 +1236,13 @@ class Pregel(
None,
self.trigger_to_nodes,
)
if live and saved.pending_writes:
for tid, status in read_task_statuses(saved.pending_writes).items():
if tid in next_tasks:
next_tasks[tid].writes.extend(status.output)
if apply_pending_writes and saved.pending_writes:
for tid, k, v in saved.pending_writes:
if k in (ERROR, INTERRUPT):
continue
if tid not in next_tasks:
continue
next_tasks[tid].writes.append((k, v))
if tasks := [t for t in next_tasks.values() if t.writes]:
apply_writes(
saved.checkpoint, channels, tasks, None, self.trigger_to_nodes
@@ -1257,7 +1252,6 @@ class Pregel(
saved.pending_writes,
task_states,
self.stream_channels_asis,
live=live,
)
# assemble the state snapshot
return StateSnapshot(
@@ -1276,16 +1270,8 @@ class Pregel(
config: RunnableConfig,
saved: CheckpointTuple | None,
recurse: BaseCheckpointSaver | None = None,
live: bool = False,
apply_pending_writes: bool = False,
) -> StateSnapshot:
"""Build a `StateSnapshot` from a saved checkpoint and its pending writes.
With `live=True` the snapshot shows current status: values include the
output of tasks that already finished, `next` lists only tasks that still
need to run, and `interrupts` lists only questions still waiting for an
answer. Otherwise the snapshot is a record of the step: values as of the
start of the step, every task in the step, and the interrupts they raised.
"""
if not saved:
return StateSnapshot(
values={},
@@ -1373,10 +1359,13 @@ class Pregel(
None,
self.trigger_to_nodes,
)
if live and saved.pending_writes:
for tid, status in read_task_statuses(saved.pending_writes).items():
if tid in next_tasks:
next_tasks[tid].writes.extend(status.output)
if apply_pending_writes and saved.pending_writes:
for tid, k, v in saved.pending_writes:
if k in (ERROR, INTERRUPT):
continue
if tid not in next_tasks:
continue
next_tasks[tid].writes.append((k, v))
if tasks := [t for t in next_tasks.values() if t.writes]:
apply_writes(
saved.checkpoint, channels, tasks, None, self.trigger_to_nodes
@@ -1387,7 +1376,6 @@ class Pregel(
saved.pending_writes,
task_states,
self.stream_channels_asis,
live=live,
)
# assemble the state snapshot
return StateSnapshot(
@@ -1442,7 +1430,7 @@ class Pregel(
config,
saved,
recurse=checkpointer if subgraphs else None,
live=CONFIG_KEY_CHECKPOINT_ID not in config[CONF],
apply_pending_writes=CONFIG_KEY_CHECKPOINT_ID not in config[CONF],
)
async def aget_state(
@@ -1486,7 +1474,7 @@ class Pregel(
config,
saved,
recurse=checkpointer if subgraphs else None,
live=CONFIG_KEY_CHECKPOINT_ID not in config[CONF],
apply_pending_writes=CONFIG_KEY_CHECKPOINT_ID not in config[CONF],
)
def get_state_history(
@@ -1722,12 +1710,13 @@ class Pregel(
checkpointer.get_next_version,
self.trigger_to_nodes,
)
# apply writes from tasks that already finished
for tid, status in read_task_statuses(
saved.pending_writes or []
).items():
if tid in next_tasks:
next_tasks[tid].writes.extend(status.output)
# apply writes from tasks that already ran
for tid, k, v in saved.pending_writes or []:
if k in (ERROR, INTERRUPT):
continue
if tid not in next_tasks:
continue
next_tasks[tid].writes.append((k, v))
# clear all current tasks
apply_writes(
checkpoint,
@@ -2185,12 +2174,13 @@ class Pregel(
checkpointer.get_next_version,
self.trigger_to_nodes,
)
# apply writes from tasks that already finished
for tid, status in read_task_statuses(
saved.pending_writes or []
).items():
if tid in next_tasks:
next_tasks[tid].writes.extend(status.output)
# apply writes from tasks that already ran
for tid, k, v in saved.pending_writes or []:
if k in (ERROR, INTERRUPT):
continue
if tid not in next_tasks:
continue
next_tasks[tid].writes.append((k, v))
# clear all current tasks
apply_writes(
checkpoint,
+12 -73
View File
@@ -12,23 +12,15 @@ from typing import (
Generic,
Literal,
NamedTuple,
TypeVar,
final,
overload,
)
from warnings import warn
from langchain_core.messages import AnyMessage
from langchain_core.runnables import Runnable, RunnableConfig
from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointMetadata
from pydantic import TypeAdapter
from typing_extensions import (
NotRequired,
TypeAliasType,
TypedDict,
TypeVar,
Unpack,
deprecated,
)
from typing_extensions import NotRequired, TypeAliasType, TypedDict, Unpack, deprecated
from xxhash import xxh3_128_hexdigest
from langgraph._internal._cache import default_cache_key
@@ -44,7 +36,6 @@ from langgraph.warnings import LangGraphDeprecatedSinceV10, LangGraphDeprecatedS
# when used in standalone type aliases.
StateT = TypeVar("StateT")
OutputT = TypeVar("OutputT")
ResponseT = TypeVar("ResponseT", default=Any)
if TYPE_CHECKING:
from langgraph.pregel.protocol import PregelProtocol
@@ -581,7 +572,7 @@ _DEFAULT_INTERRUPT_ID = "placeholder-id"
@final
@dataclass(init=False, slots=True)
class Interrupt(Generic[ResponseT]):
class Interrupt:
"""Information about an interrupt that occurred in a node.
!!! version-added "Added in version 0.2.24"
@@ -605,22 +596,13 @@ class Interrupt(Generic[ResponseT]):
id: str
"""The ID of the interrupt. Can be used to resume the interrupt directly."""
response_schema: type[ResponseT] | dict[str, Any] | None = None
"""Schema for the value expected when resuming this interrupt, if the graph provided one.
A surfaced interrupt carries JSON Schema (a `dict`); `type[ResponseT]` records the
Python type at construction so `Interrupt[Decision]` is meaningful to type checkers."""
def __init__(
self,
value: Any,
id: str = _DEFAULT_INTERRUPT_ID,
*,
response_schema: type[ResponseT] | dict[str, Any] | None = None,
**deprecated_kwargs: Unpack[DeprecatedKwargs],
) -> None:
self.value = value
self.response_schema = response_schema
if (
(ns := deprecated_kwargs.get("ns", MISSING)) is not MISSING
@@ -632,18 +614,8 @@ class Interrupt(Generic[ResponseT]):
self.id = id
@classmethod
def from_ns(
cls,
value: Any,
ns: str,
*,
response_schema: type[ResponseT] | dict[str, Any] | None = None,
) -> Interrupt[ResponseT]:
return cls(
value=value,
id=xxh3_128_hexdigest(ns.encode()),
response_schema=response_schema,
)
def from_ns(cls, value: Any, ns: str) -> Interrupt:
return cls(value=value, id=xxh3_128_hexdigest(ns.encode()))
@property
@deprecated("`interrupt_id` is deprecated. Use `id` instead.", category=None)
@@ -726,13 +698,7 @@ class StateSnapshot(NamedTuple):
tasks: tuple[PregelTask, ...]
"""Tasks to execute in this step. If already attempted, may contain an error."""
interrupts: tuple[Interrupt, ...]
"""Interrupts that occurred in this step.
When reading the latest state (`get_state` without a `checkpoint_id`), this
contains only interrupts still waiting for an answer. When reading a specific
checkpoint or state history, it contains the most recent interrupt each task
raised in that step, including ones answered later in the same step.
"""
"""Interrupts that occurred in this step that are pending resolution."""
class Send:
@@ -882,17 +848,7 @@ class Command(Generic[N], ToolOutputMixin):
PARENT: ClassVar[Literal["__parent__"]] = "__parent__"
@overload
def interrupt(value: Any, *, response_schema: type[ResponseT]) -> ResponseT: ...
@overload
def interrupt(value: Any, *, response_schema: dict[str, Any] | None = None) -> Any: ...
def interrupt(
value: Any, *, response_schema: dict[str, Any] | type | None = None
) -> Any:
def interrupt(value: Any) -> Any:
"""Interrupt the graph with a resumable exception from within a node.
The `interrupt` function enables human-in-the-loop workflows by pausing graph
@@ -962,7 +918,7 @@ def interrupt(
for chunk in graph.stream({\"foo\": \"abc\"}, config):
print(chunk)
# > {'__interrupt__': (Interrupt(value='what is your age?', id='45fda8478b2ef754419799e10992af06', response_schema=None),)}
# > {'__interrupt__': (Interrupt(value='what is your age?', id='45fda8478b2ef754419799e10992af06'),)}
command = Command(resume=\"some input from a human!!!\")
@@ -975,20 +931,12 @@ def interrupt(
Args:
value: The value to surface to the client when the graph is interrupted.
response_schema: Optional schema for the value expected on resume, surfaced
to clients so they can render a typed input form. Accepts a JSON Schema
`dict` (used as-is, resume values are not validated), or a Pydantic model
class, `TypedDict`, or dataclass, which are converted to JSON Schema for
clients and used to validate the resume value; the validated object is
what `interrupt` returns.
Returns:
Any: On subsequent invocations within the same node (same task to be precise), returns the value provided during the first invocation,
validated against `response_schema` when one that supports validation was given.
Any: On subsequent invocations within the same node (same task to be precise), returns the value provided during the first invocation
Raises:
GraphInterrupt: On the first invocation within the node, halts execution and surfaces the provided value to the client.
pydantic.ValidationError: When a resume value does not match a Pydantic model, `TypedDict`, or dataclass `response_schema`.
"""
from langgraph._internal._constants import (
CONFIG_KEY_CHECKPOINT_NS,
@@ -1000,36 +948,27 @@ def interrupt(
from langgraph.errors import GraphInterrupt
conf = get_config()["configurable"]
adapter = (
None
if response_schema is None or isinstance(response_schema, dict)
else TypeAdapter(response_schema)
)
# track interrupt index
scratchpad = conf[CONFIG_KEY_SCRATCHPAD]
idx = scratchpad.interrupt_counter()
# find previous resume values
if scratchpad.resume:
if idx < len(scratchpad.resume):
v = scratchpad.resume[idx]
validated = adapter.validate_python(v) if adapter else v
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume[: idx + 1])])
return validated
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
return scratchpad.resume[idx]
# find current resume value
v = scratchpad.get_null_resume(True)
if v is not None:
assert len(scratchpad.resume) == idx, (scratchpad.resume, idx)
validated = adapter.validate_python(v) if adapter else v
scratchpad.resume.append(v)
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
return validated
return v
# no resume value found
raise GraphInterrupt(
(
Interrupt.from_ns(
value=value,
ns=conf[CONFIG_KEY_CHECKPOINT_NS],
response_schema=adapter.json_schema() if adapter else response_schema,
),
)
)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "1.2.12"
version = "1.2.11"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
@@ -1,534 +0,0 @@
"""State reads while some tasks of a superstep are finished and others are paused.
When parallel tasks each call `interrupt()` and only some of them are resumed,
the superstep stays open. Its recorded writes then contain the old interrupt of
each finished task next to that task's output. These tests check that state
reads, which are rebuilt from the checkpointer, report only the interrupts that
still need an answer.
"""
import operator
import sys
import uuid
from collections import Counter
from typing import Annotated, Any
import pytest
from langgraph.checkpoint.base import BaseCheckpointSaver
from typing_extensions import TypedDict
from langgraph._internal._constants import (
ERROR,
INTERRUPT,
NO_WRITES,
NULL_TASK_ID,
RESUME,
RETURN,
)
from langgraph.func import entrypoint, task
from langgraph.graph import END, START, StateGraph
from langgraph.pregel._task_status import read_task_statuses
from langgraph.types import Command, Durability, Interrupt, Send, interrupt
pytestmark = pytest.mark.anyio
NEEDS_CONTEXTVARS = pytest.mark.skipif(
sys.version_info < (3, 11),
reason="Python 3.11+ is required for async contextvars support",
)
class State(TypedDict, total=False):
log: Annotated[list[str], operator.add]
count: int
def _config() -> dict[str, Any]:
return {"configurable": {"thread_id": str(uuid.uuid4())}}
def _build_parallel(
checkpointer: BaseCheckpointSaver,
calls: Counter[str],
*,
a_questions: int = 1,
a_returns: Any = "log",
):
"""Build a graph where nodes `a` and `b` start in parallel and both ask questions.
`a` asks `a_questions` questions in a row. `a_returns` controls what `a`
returns after its last answer. The default `"log"` returns the answers in
`log`. Any other value is returned as-is.
"""
def a(state: State) -> Any:
calls["a"] += 1
answers = [interrupt(f"A{i + 1}") for i in range(a_questions)]
if a_returns == "log":
return {"log": [f"a:{answer}" for answer in answers]}
return a_returns
def b(state: State) -> State:
calls["b"] += 1
return {"log": [f"b:{interrupt('B')}"]}
builder = StateGraph(State)
builder.add_node("a", a)
builder.add_node("b", b)
builder.add_edge(START, "a")
builder.add_edge(START, "b")
builder.add_edge("a", END)
builder.add_edge("b", END)
return builder.compile(checkpointer=checkpointer)
def _interrupt_by_value(snapshot: Any, value: str) -> Interrupt:
return next(i for i in snapshot.interrupts if i.value == value)
def _task(snapshot: Any, name: str) -> Any:
return next(t for t in snapshot.tasks if t.name == name)
def _interrupt_values(interrupts: Any) -> list[str]:
return sorted(i.value for i in interrupts)
# --- Task A answered and finished, task B still paused ---
def test_finished_task_does_not_report_answered_interrupt(
sync_checkpointer: BaseCheckpointSaver, durability: Durability
) -> None:
calls: Counter[str] = Counter()
graph = _build_parallel(sync_checkpointer, calls)
config = _config()
graph.invoke({"log": []}, config, durability=durability)
snapshot = graph.get_state(config)
assert _interrupt_values(snapshot.interrupts) == ["A1", "B"]
graph.invoke(
Command(resume={_interrupt_by_value(snapshot, "A1").id: "yes"}),
config,
durability=durability,
)
snapshot = graph.get_state(config)
assert _interrupt_values(snapshot.interrupts) == ["B"]
assert snapshot.next == ("b",)
assert _task(snapshot, "a").interrupts == ()
assert _task(snapshot, "a").result == {"log": ["a:yes"]}
assert _interrupt_values(_task(snapshot, "b").interrupts) == ["B"]
assert _task(snapshot, "b").result is None
# Reading the same checkpoint by id gives the record of the step: every task
# in it, and every question asked, including the one A already answered.
record = graph.get_state(snapshot.config)
assert sorted(record.next) == ["a", "b"]
assert _interrupt_values(record.interrupts) == ["A1", "B"]
assert _interrupt_values(_task(record, "a").interrupts) == ["A1"]
assert _task(record, "a").result == {"log": ["a:yes"]}
# B can still be answered, and the graph finishes normally.
result = graph.invoke(
Command(resume={_interrupt_by_value(snapshot, "B").id: "ok"}),
config,
durability=durability,
)
assert sorted(result["log"]) == ["a:yes", "b:ok"]
assert calls == {"a": 2, "b": 3}
snapshot = graph.get_state(config)
assert snapshot.next == ()
assert snapshot.interrupts == ()
# History still shows where each question was asked.
asked = [
_interrupt_values(s.interrupts)
for s in graph.get_state_history(config)
if s.interrupts
]
if durability != "exit":
assert asked == [["A1", "B"]]
@NEEDS_CONTEXTVARS
async def test_finished_task_does_not_report_answered_interrupt_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
calls: Counter[str] = Counter()
graph = _build_parallel(async_checkpointer, calls)
config = _config()
await graph.ainvoke({"log": []}, config)
snapshot = await graph.aget_state(config)
await graph.ainvoke(
Command(resume={_interrupt_by_value(snapshot, "A1").id: "yes"}), config
)
snapshot = await graph.aget_state(config)
assert _interrupt_values(snapshot.interrupts) == ["B"]
assert snapshot.next == ("b",)
assert _task(snapshot, "a").interrupts == ()
assert _task(snapshot, "a").result == {"log": ["a:yes"]}
assert _interrupt_values(_task(snapshot, "b").interrupts) == ["B"]
record = await graph.aget_state(snapshot.config)
assert _interrupt_values(record.interrupts) == ["A1", "B"]
assert _interrupt_values(_task(record, "a").interrupts) == ["A1"]
result = await graph.ainvoke(
Command(resume={_interrupt_by_value(snapshot, "B").id: "ok"}), config
)
assert sorted(result["log"]) == ["a:yes", "b:ok"]
assert calls == {"a": 2, "b": 3}
# --- Task A answered its first question and asked a second one ---
def test_task_paused_at_second_question_stays_pending(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
calls: Counter[str] = Counter()
graph = _build_parallel(sync_checkpointer, calls, a_questions=2)
config = _config()
graph.invoke({"log": []}, config)
snapshot = graph.get_state(config)
graph.invoke(
Command(resume={_interrupt_by_value(snapshot, "A1").id: "one"}), config
)
snapshot = graph.get_state(config)
assert _interrupt_values(snapshot.interrupts) == ["A2", "B"]
# A is not finished: it has a saved answer, but no output.
assert sorted(snapshot.next) == ["a", "b"]
assert _interrupt_values(_task(snapshot, "a").interrupts) == ["A2"]
assert _task(snapshot, "a").result is None
assert _interrupt_values(_task(snapshot, "b").interrupts) == ["B"]
# Both remaining questions can be answered together.
result = graph.invoke(
Command(
resume={
_interrupt_by_value(snapshot, "A2").id: "two",
_interrupt_by_value(snapshot, "B").id: "ok",
}
),
config,
)
assert sorted(result["log"]) == ["a:one", "a:two", "b:ok"]
snapshot = graph.get_state(config)
assert snapshot.next == ()
assert snapshot.interrupts == ()
@NEEDS_CONTEXTVARS
async def test_task_paused_at_second_question_stays_pending_async(
async_checkpointer: BaseCheckpointSaver,
) -> None:
calls: Counter[str] = Counter()
graph = _build_parallel(async_checkpointer, calls, a_questions=2)
config = _config()
await graph.ainvoke({"log": []}, config)
snapshot = await graph.aget_state(config)
await graph.ainvoke(
Command(resume={_interrupt_by_value(snapshot, "A1").id: "one"}), config
)
snapshot = await graph.aget_state(config)
assert _interrupt_values(snapshot.interrupts) == ["A2", "B"]
assert sorted(snapshot.next) == ["a", "b"]
assert _interrupt_values(_task(snapshot, "a").interrupts) == ["A2"]
assert _task(snapshot, "a").result is None
def test_task_paused_at_second_question_then_other_task_finishes(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
calls: Counter[str] = Counter()
graph = _build_parallel(sync_checkpointer, calls, a_questions=2)
config = _config()
graph.invoke({"log": []}, config)
snapshot = graph.get_state(config)
graph.invoke(
Command(resume={_interrupt_by_value(snapshot, "A1").id: "one"}), config
)
snapshot = graph.get_state(config)
graph.invoke(Command(resume={_interrupt_by_value(snapshot, "B").id: "ok"}), config)
snapshot = graph.get_state(config)
assert _interrupt_values(snapshot.interrupts) == ["A2"]
assert snapshot.next == ("a",)
assert _task(snapshot, "b").interrupts == ()
assert _task(snapshot, "b").result == {"log": ["b:ok"]}
result = graph.invoke(
Command(resume={_interrupt_by_value(snapshot, "A2").id: "two"}), config
)
assert sorted(result["log"]) == ["a:one", "a:two", "b:ok"]
def test_resume_without_id_rejected_when_second_question_and_other_task_pending(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
calls: Counter[str] = Counter()
graph = _build_parallel(sync_checkpointer, calls, a_questions=2)
config = _config()
graph.invoke({"log": []}, config)
snapshot = graph.get_state(config)
graph.invoke(
Command(resume={_interrupt_by_value(snapshot, "A1").id: "one"}), config
)
# A2 and B are both waiting, so a resume value without an id is ambiguous.
with pytest.raises(RuntimeError, match="multiple pending interrupts"):
graph.invoke(Command(resume="ambiguous"), config)
def test_resume_without_id_rejected_when_subgraph_has_parallel_interrupts(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
# A subgraph node whose child graph pauses in two parallel nodes records
# both interrupts under one parent task. Both count as pending, so a resume
# value without an id is ambiguous. (Before, only the first was counted and
# the value went to whichever interrupt consumed it first.)
child_builder = StateGraph(State)
child_builder.add_node("a", lambda s: {"log": [f"a:{interrupt('A')}"]})
child_builder.add_node("b", lambda s: {"log": [f"b:{interrupt('B')}"]})
child_builder.add_edge(START, "a")
child_builder.add_edge(START, "b")
builder = StateGraph(State)
builder.add_node("child", child_builder.compile())
builder.add_edge(START, "child")
graph = builder.compile(checkpointer=sync_checkpointer)
config = _config()
graph.invoke({"log": []}, config)
snapshot = graph.get_state(config)
assert _interrupt_values(snapshot.interrupts) == ["A", "B"]
with pytest.raises(RuntimeError, match="multiple pending interrupts"):
graph.invoke(Command(resume="ambiguous"), config)
result = graph.invoke(
Command(
resume={
_interrupt_by_value(snapshot, "A").id: "x",
_interrupt_by_value(snapshot, "B").id: "y",
}
),
config,
)
assert sorted(result["log"]) == ["a:x", "b:y"]
# --- Task A finished with an empty or falsy result ---
@pytest.mark.parametrize(
"a_returns",
[None, {}, {"count": 0}, {"log": []}],
ids=["none", "empty_dict", "zero", "empty_list"],
)
def test_task_finished_with_falsy_result(
sync_checkpointer: BaseCheckpointSaver, a_returns: Any
) -> None:
calls: Counter[str] = Counter()
graph = _build_parallel(sync_checkpointer, calls, a_returns=a_returns)
config = _config()
graph.invoke({"log": []}, config)
snapshot = graph.get_state(config)
graph.invoke(
Command(resume={_interrupt_by_value(snapshot, "A1").id: "yes"}), config
)
snapshot = graph.get_state(config)
assert _interrupt_values(snapshot.interrupts) == ["B"]
assert snapshot.next == ("b",)
assert _task(snapshot, "a").interrupts == ()
graph.invoke(Command(resume={_interrupt_by_value(snapshot, "B").id: "ok"}), config)
# A already finished, so resuming B must not run A again.
assert calls == {"a": 2, "b": 3}
snapshot = graph.get_state(config)
assert snapshot.next == ()
assert snapshot.interrupts == ()
@pytest.mark.parametrize("a_returns", [None, {"count": 0}], ids=["none", "zero"])
@NEEDS_CONTEXTVARS
async def test_task_finished_with_falsy_result_async(
async_checkpointer: BaseCheckpointSaver, a_returns: Any
) -> None:
calls: Counter[str] = Counter()
graph = _build_parallel(async_checkpointer, calls, a_returns=a_returns)
config = _config()
await graph.ainvoke({"log": []}, config)
snapshot = await graph.aget_state(config)
await graph.ainvoke(
Command(resume={_interrupt_by_value(snapshot, "A1").id: "yes"}), config
)
snapshot = await graph.aget_state(config)
assert _interrupt_values(snapshot.interrupts) == ["B"]
assert snapshot.next == ("b",)
assert _task(snapshot, "a").interrupts == ()
await graph.ainvoke(
Command(resume={_interrupt_by_value(snapshot, "B").id: "ok"}), config
)
assert calls == {"a": 2, "b": 3}
# --- Subgraphs and the functional API ---
def test_parallel_subgraphs_report_only_pending_interrupts(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
class ChildState(TypedDict):
prompt: str
answers: Annotated[list[str], operator.add]
def ask(state: ChildState) -> dict[str, Any]:
return {"answers": [interrupt(state["prompt"])]}
child_builder = StateGraph(ChildState)
child_builder.add_node("ask", ask)
child_builder.add_edge(START, "ask")
child = child_builder.compile()
class ParentState(TypedDict):
answers: Annotated[list[str], operator.add]
builder = StateGraph(ParentState)
builder.add_node("child", child)
builder.add_conditional_edges(
START,
lambda _: [Send("child", {"prompt": p, "answers": []}) for p in ("a", "b")],
["child"],
)
graph = builder.compile(checkpointer=sync_checkpointer)
config = _config()
graph.invoke({"answers": []}, config)
snapshot = graph.get_state(config)
assert _interrupt_values(snapshot.interrupts) == ["a", "b"]
graph.invoke(Command(resume={_interrupt_by_value(snapshot, "a").id: "x"}), config)
snapshot = graph.get_state(config)
assert _interrupt_values(snapshot.interrupts) == ["b"]
assert snapshot.next == ("child",)
finished = next(t for t in snapshot.tasks if t.result is not None)
assert finished.interrupts == ()
assert finished.result == {"answers": ["x"]}
result = graph.invoke(
Command(resume={_interrupt_by_value(snapshot, "b").id: "y"}), config
)
assert sorted(result["answers"]) == ["x", "y"]
def test_functional_task_finished_with_none_is_not_rerun(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
calls: Counter[str] = Counter()
@task
def ask_a() -> None:
calls["a"] += 1
interrupt("A")
@task
def ask_b() -> str:
calls["b"] += 1
return interrupt("B")
@entrypoint(checkpointer=sync_checkpointer)
def workflow(_: Any) -> list[Any]:
a, b = ask_a(), ask_b()
return [a.result(), b.result()]
config = _config()
workflow.invoke(1, config)
snapshot = workflow.get_state(config)
workflow.invoke(
Command(resume={_interrupt_by_value(snapshot, "A").id: "x"}), config
)
snapshot = workflow.get_state(config)
assert _interrupt_values(snapshot.interrupts) == ["B"]
result = workflow.invoke(
Command(resume={_interrupt_by_value(snapshot, "B").id: "y"}), config
)
assert result == [None, "y"]
assert calls == {"a": 2, "b": 3}
# --- Reading task status from recorded writes ---
def test_read_task_statuses() -> None:
a1 = Interrupt(value="A1", id="a")
a2 = Interrupt(value="A2", id="a")
b = Interrupt(value="B", id="b")
error = ValueError("boom")
statuses = read_task_statuses(
[
# answered and finished: old interrupt stays recorded
("finished", INTERRUPT, (a1,)),
("finished", RESUME, ["yes"]),
("finished", "log", ["a:yes"]),
# answered once, then paused at a second question
("paused", INTERRUPT, (a2,)),
("paused", RESUME, ["one"]),
# finished with no output
("no_output", INTERRUPT, (b,)),
("no_output", RESUME, ["ok"]),
("no_output", NO_WRITES, None),
# functional task that returned None
("returned_none", RETURN, None),
# failed
("failed", ERROR, error),
# not a task
(NULL_TASK_ID, RESUME, "global"),
]
)
assert set(statuses) == {
"finished",
"paused",
"no_output",
"returned_none",
"failed",
}
assert statuses["finished"].finished
assert statuses["finished"].interrupts == (a1,)
assert statuses["finished"].pending_interrupts == ()
assert statuses["finished"].output == (("log", ["a:yes"]),)
assert not statuses["paused"].finished
assert statuses["paused"].interrupts == (a2,)
assert statuses["paused"].pending_interrupts == (a2,)
assert statuses["paused"].output == ()
assert statuses["no_output"].finished
assert statuses["no_output"].interrupts == (b,)
assert statuses["no_output"].pending_interrupts == ()
assert statuses["returned_none"].finished
assert statuses["returned_none"].output == ((RETURN, None),)
assert not statuses["failed"].finished
assert statuses["failed"].error is error
+1 -153
View File
@@ -1,14 +1,9 @@
from dataclasses import dataclass
from typing import Any
import pytest
from langgraph.checkpoint.base import BaseCheckpointSaver
from pydantic import BaseModel, ValidationError
from typing_extensions import TypedDict
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, Durability, Interrupt, interrupt
from tests.any_str import AnyStr
from langgraph.types import Durability
pytestmark = pytest.mark.anyio
@@ -95,150 +90,3 @@ async def test_interruption_without_state_updates_async(
assert (await graph.aget_state(thread)).next == ()
n_checkpoints = len([c async for c in graph.aget_state_history(thread)])
assert n_checkpoints == (5 if durability != "exit" else 3)
class Decision(BaseModel):
approved: bool
note: str | None = None
class DecisionDict(TypedDict):
approved: bool
@dataclass
class DecisionData:
approved: bool
RAW_SCHEMA = {"type": "object", "properties": {"approved": {"type": "boolean"}}}
@pytest.mark.parametrize(
("response_schema", "expected_schema", "expected_answer"),
[
(None, None, {"approved": True, "extra": 1}),
(RAW_SCHEMA, RAW_SCHEMA, {"approved": True, "extra": 1}),
(Decision, Decision.model_json_schema(), Decision(approved=True)),
(
DecisionDict,
{
"properties": {"approved": {"title": "Approved", "type": "boolean"}},
"required": ["approved"],
"title": "DecisionDict",
"type": "object",
},
{"approved": True},
),
(
DecisionData,
{
"properties": {"approved": {"title": "Approved", "type": "boolean"}},
"required": ["approved"],
"title": "DecisionData",
"type": "object",
},
DecisionData(approved=True),
),
],
ids=["none", "raw_dict", "pydantic", "typeddict", "dataclass"],
)
def test_interrupt_response_schema(
sync_checkpointer: BaseCheckpointSaver,
response_schema: Any,
expected_schema: dict[str, Any] | None,
expected_answer: Any,
) -> None:
class State(TypedDict):
answer: Any
def node(state: State) -> State:
return {
"answer": interrupt(
{"question": "approve?"}, response_schema=response_schema
)
}
graph = (
StateGraph(State)
.add_node("node", node)
.add_edge(START, "node")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
expected = Interrupt(
value={"question": "approve?"}, id=AnyStr(), response_schema=expected_schema
)
assert list(graph.stream({"answer": None}, config)) == [
{"__interrupt__": (expected,)}
]
assert graph.get_state(config).tasks[0].interrupts == (expected,)
assert graph.invoke(Command(resume={"approved": True, "extra": 1}), config) == {
"answer": expected_answer
}
@pytest.mark.parametrize("resume_style", ["null", "map"])
def test_interrupt_response_schema_rejects_invalid_resume(
sync_checkpointer: BaseCheckpointSaver, resume_style: str
) -> None:
class State(TypedDict):
answer: Any
def node(state: State) -> State:
return {"answer": interrupt("approve?", response_schema=Decision)}
graph = (
StateGraph(State)
.add_node("node", node)
.add_edge(START, "node")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
graph.invoke({"answer": None}, config)
[pending] = graph.get_state(config).tasks[0].interrupts
def resume(value: dict[str, Any]) -> Command:
return Command(resume=value if resume_style == "null" else {pending.id: value})
with pytest.raises(ValidationError, match="approved"):
graph.invoke(resume({"approved": "nope"}), config)
assert graph.invoke(resume({"approved": False}), config) == {
"answer": Decision(approved=False)
}
@pytest.mark.parametrize("resume_style", ["null", "id_map"])
def test_interrupt_response_schema_invalid_resume_after_earlier_interrupt(
sync_checkpointer: BaseCheckpointSaver, resume_style: str
) -> None:
class State(TypedDict):
answer: Any
def node(state: State) -> State:
first = interrupt("first")
second = interrupt("approve?", response_schema=Decision)
return {"answer": [first, second]}
graph = (
StateGraph(State)
.add_node("node", node)
.add_edge(START, "node")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
graph.invoke({"answer": None}, config)
graph.invoke(Command(resume="ok"), config)
[pending] = graph.get_state(config).tasks[0].interrupts
def resume(value: dict[str, Any]) -> Command:
return Command(resume=value if resume_style == "null" else {pending.id: value})
with pytest.raises(ValidationError, match="approved"):
graph.invoke(resume({"approved": "nope"}), config)
assert graph.invoke(resume({"approved": True}), config) == {
"answer": ["ok", Decision(approved=True)]
}
-2
View File
@@ -5583,7 +5583,6 @@ def test_falsy_return_from_task(sync_checkpointer: BaseCheckpointSaver):
"interrupts": [
{
"id": AnyStr(),
"response_schema": None,
"value": "test",
},
],
@@ -5628,7 +5627,6 @@ def test_falsy_return_from_task(sync_checkpointer: BaseCheckpointSaver):
"interrupts": (
{
"id": AnyStr(),
"response_schema": None,
"value": "test",
},
),
+4 -4
View File
@@ -1437,7 +1437,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.12"
version = "1.2.11"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -3404,11 +3404,11 @@ wheels = [
[[package]]
name = "soupsieve"
version = "2.9"
version = "2.8.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/80/f1/93422647dd7e461f23d254e6b2bfa687a85b53aeb4903fcdbb74474d4584/soupsieve-2.9.tar.gz", hash = "sha256:acee8417325c5653e1377dc31eccad59eb82cbc65942afe6174c53b3aaad63fc", size = 122122, upload-time = "2026-07-19T01:35:18.425Z" }
sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/d6/3185ab5ad1280319b31986898f3206dd7227cd75e293d4dba2a5e6bf27a0/soupsieve-2.9-py3-none-any.whl", hash = "sha256:a2b2c76d67df2382d245409fd71e321a571717e58463efa32ace87dcadac2c12", size = 37387, upload-time = "2026-07-19T01:35:17.106Z" },
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
]
[[package]]
+1 -1
View File
@@ -286,7 +286,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.12"
version = "1.2.11"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
+1 -1
View File
@@ -3,7 +3,7 @@ from langgraph_sdk.client import get_client, get_sync_client
from langgraph_sdk.encryption import Encryption
from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext
__version__ = "0.4.5"
__version__ = "0.4.4"
__all__ = [
"Auth",
-2
View File
@@ -295,8 +295,6 @@ class Interrupt(TypedDict):
"""The value associated with the interrupt."""
id: str
"""The ID of the interrupt. Can be used to resume the interrupt."""
response_schema: NotRequired[dict[str, Any]]
"""JSON Schema for the value expected when resuming this interrupt, if the graph provided one."""
class Thread(TypedDict):
@@ -29,10 +29,7 @@ def test_sync_extension_projection_yields_matching_custom_payloads():
{"name": "progress", "step": 1},
{"name": "progress", "step": 2},
]
assert any(
"custom:progress" in body.get("channels", [])
for body in fake.stream_request_bodies
)
assert "custom:progress" in fake.stream_request_bodies[-1]["channels"]
def test_sync_extension_projection_supports_namespace_scope_on_subgraph_handle():
+13 -33
View File
@@ -17,16 +17,16 @@ wheels = [
[[package]]
name = "anyio"
version = "4.15.1"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
@@ -181,7 +181,7 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" } },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
@@ -277,8 +277,7 @@ dependencies = [
{ name = "pydantic" },
{ name = "pyyaml" },
{ name = "tenacity" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/12/aff76ca89c219ebe6f9dd3c5dbc4e3b1cf5450e9fc7037dccad23d45cd7a/langchain_core-1.6.1.tar.gz", hash = "sha256:1b156cb395aac4f009a8a1b38a574c7d948fe2d5f74c96e0d8a5017b4149e04f", size = 1003359, upload-time = "2026-08-27T19:31:14.956Z" }
@@ -291,8 +290,7 @@ name = "langchain-protocol"
version = "0.0.19"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/14/56/913599f2f9cec8524868929f12d72b2ede377a6056ca8a40a32bdadfa535/langchain_protocol-0.0.19.tar.gz", hash = "sha256:79d90a1425122ac87e8052e2ec054fbd09c3edbf341bdfb6397112a495c7bf8c", size = 6265, upload-time = "2026-08-26T21:12:00.703Z" }
wheels = [
@@ -301,7 +299,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.12"
version = "1.2.11"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -728,8 +726,7 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
@@ -742,8 +739,7 @@ name = "pydantic-core"
version = "2.46.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
wheels = [
@@ -888,7 +884,7 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
{ name = "pytest" },
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
@@ -1041,7 +1037,7 @@ version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
wheels = [
@@ -1140,33 +1136,17 @@ wheels = [
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.15'",
]
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.15'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [