Compare commits

..
Author SHA1 Message Date
Elior Nataf LackritzandGitHub 448a763779 feat(langgraph): add response_schema to interrupt() (#8886)
Adds an optional keyword-only `response_schema` to `interrupt()`,
carried on `Interrupt.response_schema` as JSON Schema so clients
(LangGraph Studio first) can render a typed form for the resume value
instead of a free-form JSON box. Default `None`, so nothing changes for
existing graphs.

- Accepts a JSON Schema dict (passed through, not validated) or a
Pydantic model / `TypedDict` / dataclass, converted with
`TypeAdapter.json_schema()`. For those, the resume value is validated
and the validated object is what `interrupt()` returns; an invalid value
raises from the node before it is committed, so the next resume works.
- Additive on the `stream_mode="debug"` payload: interrupt dicts gain
`response_schema` (it is a dataclass field, serialized with `asdict`).
- `libs/sdk-py`: mirrors the field on the `Interrupt` TypedDict as
`NotRequired`; the server omits the key when no schema was given.

JS counterpart: langchain-ai/langgraphjs#2824.

Verified: new tests parametrized across every checkpointer backend
(schema kinds, coercion, invalid resume) plus the full `libs/langgraph`
suite and downstream `prebuilt`, `cli`, `sdk-py`; round-tripped end to
end through the agent server with this branch installed.
2026-09-17 11:33:37 -04:00
Lauren Hirata SinghandGitHub 230927fb3a docs: publish the generated llms.txt instead of a hand-written copy (#8922)
## Why

`docs/llms.txt` is written by hand and was last touched in February, so
the index served at <https://langchain-ai.github.io/langgraph/llms.txt>
had drifted badly:

- 12 pages listed, against the 43 the docs site publishes.
- One entry, `/oss/python/langgraph/why-langgraph`, now 404s.

The deploy workflow only fires on `docs/**` pushes, so nothing brought
the file back in line as the docs site changed. It is served verbatim:
`generate_redirects.py` copies it into `docs/_site` via a `static_files`
list, and `deploy-redirects.yml` uploads that directory to Pages.

The docs site already generates exactly this index, and regenerates it
on every deploy:

```
https://docs.langchain.com/oss/python/langgraph/llms.txt
```

## What changed

`generate_redirects.py` now fetches that index at build time and writes
it into `_site`, so the published file cannot drift from the docs site.
The committed `docs/llms.txt` stays as a fallback only, refreshed here
to current content.

`deploy-redirects.yml` gains a weekly `schedule:` trigger, so docs
changes that never touch this repo still reach the deployed file. The
`permissions:` block is unchanged.

## Fetching remote content in CI

The fetched body is published on a public Pages site, so it is validated
before it is written:

- The URL is a hardcoded module constant, never built from input.
- Both that URL and the post-redirect `response.url` are checked against
an HTTPS-plus-single-host allowlist. `urlopen` follows redirects, so
checking only the request URL would not be enough.
- 30 second timeout, response capped at 1MB, decoded as UTF-8.
- The body must open with a markdown heading and contain a
docs.langchain.com link, which rejects an error page or a truncated
response.

Any failure returns `None` and falls back to the committed copy, so a
docs.langchain.com outage degrades to a stale file rather than a broken
deploy or a published error page.

## Verification

Ran `python docs/generate_redirects.py`: 294 redirect files, `llms.txt`
fetched, 43 entries.

Each rejection path was exercised against a stubbed `urlopen` and all
fall back to the committed file:

| Case | Result |
|---|---|
| Network error | falls back |
| Timeout | falls back |
| Redirect to another host | falls back |
| Body over 1MB | falls back |
| Invalid UTF-8 | falls back |
| HTML error page | falls back |
| Empty body | falls back |
| Valid index | published |

Allowlist rejects `http://docs.langchain.com/...`,
`https://evil.com/...`, and `https://docs.langchain.com.evil.com/...`.

## Note on ordering

The fallback committed here is labelled "LangGraph (Python)", which is
what the docs site will serve once langchain-ai/docs#6032 deploys. Until
then the fetched file reads "Open source (Python)". No action needed; it
self-corrects.

---

Written with Claude Code; I reviewed the diff and ran the verification
above.
2026-09-14 15:45:19 -04:00
9 changed files with 359 additions and 129 deletions
+4
View File
@@ -7,6 +7,10 @@ on:
paths: paths:
- 'docs/**' - 'docs/**'
- '.github/workflows/deploy-redirects.yml' - '.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: workflow_dispatch:
permissions: permissions:
+83 -7
View File
@@ -12,13 +12,26 @@ 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. To add new redirects, simply edit redirects.json and re-run this script.
""" """
import http.client
import json import json
import os import os
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path from pathlib import Path
# Default fallback URL for any path not in the redirect map # Default fallback URL for any path not in the redirect map
DEFAULT_REDIRECT = "https://docs.langchain.com/oss/python/langgraph/overview" 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_TEMPLATE = """<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -75,6 +88,64 @@ 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(): def generate_redirects():
script_dir = Path(__file__).parent script_dir = Path(__file__).parent
output_dir = script_dir / "_site" output_dir = script_dir / "_site"
@@ -126,14 +197,19 @@ def generate_redirects():
catchall_404.write_text(CATCHALL_404_TEMPLATE.format(default_url=DEFAULT_REDIRECT)) catchall_404.write_text(CATCHALL_404_TEMPLATE.format(default_url=DEFAULT_REDIRECT))
print(f"Created: {catchall_404}") print(f"Created: {catchall_404}")
# Copy static files (like llms.txt) that can't be redirected via HTML # llms.txt can't be redirected via HTML, so publish the docs site's own
static_files = ["llms.txt"] # generated index. The committed copy is only a fallback.
for static_file in static_files: llms_txt = fetch_canonical_llms_txt()
src = script_dir / static_file 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"
if src.exists(): if src.exists():
dst = output_dir / static_file (output_dir / "llms.txt").write_text(src.read_text())
dst.write_text(src.read_text()) print(f"Copied: {output_dir / 'llms.txt'} (fallback, may be stale)")
print(f"Copied: {dst}") else:
print("No llms.txt fetched and no committed fallback; skipping")
print(f"\nGenerated {len(redirects)} redirect files in {output_dir}") print(f"\nGenerated {len(redirects)} redirect files in {output_dir}")
+46 -32
View File
@@ -1,35 +1,49 @@
# LangGraph # Docs by LangChain: LangGraph (Python)
LangGraph documentation has moved to docs.langchain.com. > Markdown index of the LangGraph (Python) documentation.
## Overview ## LangGraph (Python)
- [LangGraph Overview](https://docs.langchain.com/oss/python/langgraph/overview): Introduction to LangGraph, a library for building stateful, multi-actor applications with LLMs. - [Memory](https://docs.langchain.com/oss/python/langgraph/add-memory.md)
- [Why LangGraph?](https://docs.langchain.com/oss/python/langgraph/why-langgraph): Motivation for LangGraph and its key features. - [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)
## Core Concepts - [Backward compatibility](https://docs.langchain.com/oss/python/langgraph/backward-compatibility.md)
- [Case studies](https://docs.langchain.com/oss/python/langgraph/case-studies.md)
- [Graph API](https://docs.langchain.com/oss/python/langgraph/graph-api): Learn how to define state, create nodes, and connect them with edges. - [Changelog](https://docs.langchain.com/oss/python/langgraph/changelog-js.md)
- [Streaming](https://docs.langchain.com/oss/python/langgraph/streaming): Stream outputs from your graph for better UX. - [Changelog](https://docs.langchain.com/oss/python/langgraph/changelog-py.md)
- [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence): Add memory and checkpointing to your graphs. - [Checkpointers](https://docs.langchain.com/oss/python/langgraph/checkpointers.md)
- [Add Memory](https://docs.langchain.com/oss/python/langgraph/add-memory): Implement short-term and long-term memory. - [Choosing between Graph and Functional APIs](https://docs.langchain.com/oss/python/langgraph/choosing-apis.md)
- [Workflows & Agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents): Build agents and workflows with LangGraph. - [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)
## How-To Guides - [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)
- [Use Subgraphs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs): Compose graphs using subgraphs. - [INVALID_GRAPH_NODE_RETURN_VALUE](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_GRAPH_NODE_RETURN_VALUE.md)
- [Observability](https://docs.langchain.com/oss/python/langgraph/observability): Add tracing and debugging to your graphs. - [MISSING_CHECKPOINTER](https://docs.langchain.com/oss/python/langgraph/errors/MISSING_CHECKPOINTER.md)
- [Common Errors](https://docs.langchain.com/oss/python/langgraph/common-errors): Troubleshoot common LangGraph errors. - [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)
## Tutorials - [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)
- [Agentic RAG](https://docs.langchain.com/oss/python/langgraph/agentic-rag): Build an agentic RAG system with LangGraph. - [Graph execution](https://docs.langchain.com/oss/python/langgraph/frontend/graph-execution.md)
- [SQL Agent](https://docs.langchain.com/oss/python/langgraph/sql-agent): Create a SQL agent with LangGraph. - [Overview](https://docs.langchain.com/oss/python/langgraph/frontend/overview.md)
- [Functional API overview](https://docs.langchain.com/oss/python/langgraph/functional-api.md)
## Reference - [Graph API overview](https://docs.langchain.com/oss/python/langgraph/graph-api.md)
- [Install LangGraph](https://docs.langchain.com/oss/python/langgraph/install.md)
- [API Reference](https://reference.langchain.com/python/langgraph/): Complete API documentation for LangGraph. - [Interrupts](https://docs.langchain.com/oss/python/langgraph/interrupts.md)
- [Run a local server](https://docs.langchain.com/oss/python/langgraph/local-server.md)
## LangGraph Platform - [LangSmith Observability](https://docs.langchain.com/oss/python/langgraph/observability.md)
- [LangGraph overview](https://docs.langchain.com/oss/python/langgraph/overview.md)
For deploying LangGraph applications in production, see the [LangSmith documentation](https://docs.langchain.com/langsmith/agent-server). - [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)
+3 -20
View File
@@ -51,7 +51,6 @@ RESERVED_ENV_VARS = frozenset(
"LANGGRAPH_AUTH_TYPE", "LANGGRAPH_AUTH_TYPE",
"LANGSMITH_AUTH_ENDPOINT", "LANGSMITH_AUTH_ENDPOINT",
"LANGSMITH_TENANT_ID", "LANGSMITH_TENANT_ID",
"LANGSMITH_WORKSPACE_ID",
"LANGSMITH_AUTH_VERIFY_TENANT_ID", "LANGSMITH_AUTH_VERIFY_TENANT_ID",
"LANGSMITH_HOST_PROJECT_ID", "LANGSMITH_HOST_PROJECT_ID",
"LANGSMITH_HOST_PROJECT_NAME", "LANGSMITH_HOST_PROJECT_NAME",
@@ -1230,24 +1229,6 @@ def _run_remote_build(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _get_tenant_id(env_vars: dict[str, str]) -> str | None:
"""Get the tenant ID from LANGSMITH_TENANT_ID or LANGSMITH_WORKSPACE_ID."""
tenant_id = env_vars.get("LANGSMITH_TENANT_ID") or os.environ.get(
"LANGSMITH_TENANT_ID"
)
fallback_tenant_id = env_vars.get("LANGSMITH_WORKSPACE_ID") or os.environ.get(
"LANGSMITH_WORKSPACE_ID"
)
if tenant_id and fallback_tenant_id:
raise click.UsageError(
"LANGSMITH_TENANT_ID and LANGSMITH_WORKSPACE_ID cannot both be set. "
"Set only one."
)
if tenant_id:
return tenant_id
return fallback_tenant_id or None
def _create_host_backend_client( def _create_host_backend_client(
host_url: str | None, host_url: str | None,
api_key: str | None, api_key: str | None,
@@ -1255,7 +1236,6 @@ def _create_host_backend_client(
) -> HostBackendClient: ) -> HostBackendClient:
if env_vars is None: if env_vars is None:
env_vars = _parse_env_from_config({}, pathlib.Path.cwd() / DEFAULT_CONFIG) env_vars = _parse_env_from_config({}, pathlib.Path.cwd() / DEFAULT_CONFIG)
tenant_id = _get_tenant_id(env_vars)
resolved_api_key = api_key resolved_api_key = api_key
if not resolved_api_key: if not resolved_api_key:
for key_name in _API_KEY_ENV_NAMES: for key_name in _API_KEY_ENV_NAMES:
@@ -1278,6 +1258,9 @@ def _create_host_backend_client(
fg="yellow", fg="yellow",
) )
resolved_api_key = click.prompt("Enter LangSmith API key", hide_input=True) resolved_api_key = click.prompt("Enter LangSmith API key", hide_input=True)
tenant_id = env_vars.get("LANGSMITH_TENANT_ID") or os.environ.get(
"LANGSMITH_TENANT_ID"
)
return HostBackendClient(host_url, resolved_api_key, tenant_id=tenant_id) return HostBackendClient(host_url, resolved_api_key, tenant_id=tenant_id)
@@ -21,7 +21,6 @@ from langgraph_cli.deploy import (
_parse_env_from_config, _parse_env_from_config,
_resolve_env_path, _resolve_env_path,
_resolve_pushed_image_digest, _resolve_pushed_image_digest,
_secrets_from_env,
_smith_dashboard_base_url, _smith_dashboard_base_url,
_validate_prebuilt_image, _validate_prebuilt_image,
normalize_image_tag, normalize_image_tag,
@@ -541,63 +540,6 @@ class TestCreateHostBackendClientNoInput:
assert client is not None assert client is not None
@pytest.mark.parametrize("source", ["config", "shell"])
@pytest.mark.parametrize("name", ["LANGSMITH_TENANT_ID", "LANGSMITH_WORKSPACE_ID"])
def test_workspace_id_alias(monkeypatch, source, name):
monkeypatch.delenv("LANGSMITH_WORKSPACE_ID", raising=False)
monkeypatch.delenv("LANGSMITH_TENANT_ID", raising=False)
env_vars = {}
if source == "config":
env_vars[name] = "workspace"
else:
monkeypatch.setenv(name, "workspace")
def handler(request):
assert request.headers["X-Tenant-ID"] == "workspace"
return httpx.Response(200, json={"resources": []})
monkeypatch.setattr(
httpx, "HTTPTransport", lambda **kwargs: httpx.MockTransport(handler)
)
client = _create_host_backend_client(
"https://api.example.com", "test-key", env_vars
)
try:
client.list_deployments()
finally:
client._client.close()
@pytest.mark.parametrize("tenant_source", ["config", "shell"])
@pytest.mark.parametrize("workspace_source", ["config", "shell"])
@pytest.mark.parametrize("workspace_id", ["tenant-id", "workspace-id"])
def test_rejects_both_workspace_names(
monkeypatch, tenant_source, workspace_source, workspace_id
):
env_vars = {}
for name, source, value in [
("LANGSMITH_TENANT_ID", tenant_source, "tenant-id"),
("LANGSMITH_WORKSPACE_ID", workspace_source, workspace_id),
]:
monkeypatch.delenv(name, raising=False)
if source == "config":
env_vars[name] = value
else:
monkeypatch.setenv(name, value)
with pytest.raises(
click.UsageError,
match="LANGSMITH_TENANT_ID and LANGSMITH_WORKSPACE_ID cannot both be set",
):
_create_host_backend_client("https://api.example.com", "test-key", env_vars)
def test_workspace_id_is_not_uploaded_as_secret():
assert _secrets_from_env(
{"LANGSMITH_WORKSPACE_ID": "workspace", "APP_SETTING": "value"}
) == [{"name": "APP_SETTING", "value": "value"}]
class TestSmithDashboardBaseUrl: class TestSmithDashboardBaseUrl:
def test_none_returns_default(self): def test_none_returns_default(self):
assert _smith_dashboard_base_url(None) == "https://smith.langchain.com" assert _smith_dashboard_base_url(None) == "https://smith.langchain.com"
+66 -11
View File
@@ -12,15 +12,23 @@ from typing import (
Generic, Generic,
Literal, Literal,
NamedTuple, NamedTuple,
TypeVar,
final, final,
overload,
) )
from warnings import warn from warnings import warn
from langchain_core.messages import AnyMessage from langchain_core.messages import AnyMessage
from langchain_core.runnables import Runnable, RunnableConfig from langchain_core.runnables import Runnable, RunnableConfig
from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointMetadata from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointMetadata
from typing_extensions import NotRequired, TypeAliasType, TypedDict, Unpack, deprecated from pydantic import TypeAdapter
from typing_extensions import (
NotRequired,
TypeAliasType,
TypedDict,
TypeVar,
Unpack,
deprecated,
)
from xxhash import xxh3_128_hexdigest from xxhash import xxh3_128_hexdigest
from langgraph._internal._cache import default_cache_key from langgraph._internal._cache import default_cache_key
@@ -36,6 +44,7 @@ from langgraph.warnings import LangGraphDeprecatedSinceV10, LangGraphDeprecatedS
# when used in standalone type aliases. # when used in standalone type aliases.
StateT = TypeVar("StateT") StateT = TypeVar("StateT")
OutputT = TypeVar("OutputT") OutputT = TypeVar("OutputT")
ResponseT = TypeVar("ResponseT", default=Any)
if TYPE_CHECKING: if TYPE_CHECKING:
from langgraph.pregel.protocol import PregelProtocol from langgraph.pregel.protocol import PregelProtocol
@@ -572,7 +581,7 @@ _DEFAULT_INTERRUPT_ID = "placeholder-id"
@final @final
@dataclass(init=False, slots=True) @dataclass(init=False, slots=True)
class Interrupt: class Interrupt(Generic[ResponseT]):
"""Information about an interrupt that occurred in a node. """Information about an interrupt that occurred in a node.
!!! version-added "Added in version 0.2.24" !!! version-added "Added in version 0.2.24"
@@ -596,13 +605,22 @@ class Interrupt:
id: str id: str
"""The ID of the interrupt. Can be used to resume the interrupt directly.""" """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__( def __init__(
self, self,
value: Any, value: Any,
id: str = _DEFAULT_INTERRUPT_ID, id: str = _DEFAULT_INTERRUPT_ID,
*,
response_schema: type[ResponseT] | dict[str, Any] | None = None,
**deprecated_kwargs: Unpack[DeprecatedKwargs], **deprecated_kwargs: Unpack[DeprecatedKwargs],
) -> None: ) -> None:
self.value = value self.value = value
self.response_schema = response_schema
if ( if (
(ns := deprecated_kwargs.get("ns", MISSING)) is not MISSING (ns := deprecated_kwargs.get("ns", MISSING)) is not MISSING
@@ -614,8 +632,18 @@ class Interrupt:
self.id = id self.id = id
@classmethod @classmethod
def from_ns(cls, value: Any, ns: str) -> Interrupt: def from_ns(
return cls(value=value, id=xxh3_128_hexdigest(ns.encode())) 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,
)
@property @property
@deprecated("`interrupt_id` is deprecated. Use `id` instead.", category=None) @deprecated("`interrupt_id` is deprecated. Use `id` instead.", category=None)
@@ -848,7 +876,17 @@ class Command(Generic[N], ToolOutputMixin):
PARENT: ClassVar[Literal["__parent__"]] = "__parent__" PARENT: ClassVar[Literal["__parent__"]] = "__parent__"
def interrupt(value: Any) -> Any: @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:
"""Interrupt the graph with a resumable exception from within a node. """Interrupt the graph with a resumable exception from within a node.
The `interrupt` function enables human-in-the-loop workflows by pausing graph The `interrupt` function enables human-in-the-loop workflows by pausing graph
@@ -918,7 +956,7 @@ def interrupt(value: Any) -> Any:
for chunk in graph.stream({\"foo\": \"abc\"}, config): for chunk in graph.stream({\"foo\": \"abc\"}, config):
print(chunk) print(chunk)
# > {'__interrupt__': (Interrupt(value='what is your age?', id='45fda8478b2ef754419799e10992af06'),)} # > {'__interrupt__': (Interrupt(value='what is your age?', id='45fda8478b2ef754419799e10992af06', response_schema=None),)}
command = Command(resume=\"some input from a human!!!\") command = Command(resume=\"some input from a human!!!\")
@@ -931,12 +969,20 @@ def interrupt(value: Any) -> Any:
Args: Args:
value: The value to surface to the client when the graph is interrupted. 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: Returns:
Any: On subsequent invocations within the same node (same task to be precise), returns the value provided during the first invocation 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.
Raises: Raises:
GraphInterrupt: On the first invocation within the node, halts execution and surfaces the provided value to the client. 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 ( from langgraph._internal._constants import (
CONFIG_KEY_CHECKPOINT_NS, CONFIG_KEY_CHECKPOINT_NS,
@@ -948,27 +994,36 @@ def interrupt(value: Any) -> Any:
from langgraph.errors import GraphInterrupt from langgraph.errors import GraphInterrupt
conf = get_config()["configurable"] conf = get_config()["configurable"]
adapter = (
None
if response_schema is None or isinstance(response_schema, dict)
else TypeAdapter(response_schema)
)
# track interrupt index # track interrupt index
scratchpad = conf[CONFIG_KEY_SCRATCHPAD] scratchpad = conf[CONFIG_KEY_SCRATCHPAD]
idx = scratchpad.interrupt_counter() idx = scratchpad.interrupt_counter()
# find previous resume values # find previous resume values
if scratchpad.resume: if scratchpad.resume:
if idx < len(scratchpad.resume): if idx < len(scratchpad.resume):
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)]) v = scratchpad.resume[idx]
return scratchpad.resume[idx] validated = adapter.validate_python(v) if adapter else v
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume[: idx + 1])])
return validated
# find current resume value # find current resume value
v = scratchpad.get_null_resume(True) v = scratchpad.get_null_resume(True)
if v is not None: if v is not None:
assert len(scratchpad.resume) == idx, (scratchpad.resume, idx) assert len(scratchpad.resume) == idx, (scratchpad.resume, idx)
validated = adapter.validate_python(v) if adapter else v
scratchpad.resume.append(v) scratchpad.resume.append(v)
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)]) conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
return v return validated
# no resume value found # no resume value found
raise GraphInterrupt( raise GraphInterrupt(
( (
Interrupt.from_ns( Interrupt.from_ns(
value=value, value=value,
ns=conf[CONFIG_KEY_CHECKPOINT_NS], ns=conf[CONFIG_KEY_CHECKPOINT_NS],
response_schema=adapter.json_schema() if adapter else response_schema,
), ),
) )
) )
+153 -1
View File
@@ -1,9 +1,14 @@
from dataclasses import dataclass
from typing import Any
import pytest import pytest
from langgraph.checkpoint.base import BaseCheckpointSaver from langgraph.checkpoint.base import BaseCheckpointSaver
from pydantic import BaseModel, ValidationError
from typing_extensions import TypedDict from typing_extensions import TypedDict
from langgraph.graph import END, START, StateGraph from langgraph.graph import END, START, StateGraph
from langgraph.types import Durability from langgraph.types import Command, Durability, Interrupt, interrupt
from tests.any_str import AnyStr
pytestmark = pytest.mark.anyio pytestmark = pytest.mark.anyio
@@ -90,3 +95,150 @@ async def test_interruption_without_state_updates_async(
assert (await graph.aget_state(thread)).next == () assert (await graph.aget_state(thread)).next == ()
n_checkpoints = len([c async for c in graph.aget_state_history(thread)]) n_checkpoints = len([c async for c in graph.aget_state_history(thread)])
assert n_checkpoints == (5 if durability != "exit" else 3) 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,6 +5583,7 @@ def test_falsy_return_from_task(sync_checkpointer: BaseCheckpointSaver):
"interrupts": [ "interrupts": [
{ {
"id": AnyStr(), "id": AnyStr(),
"response_schema": None,
"value": "test", "value": "test",
}, },
], ],
@@ -5627,6 +5628,7 @@ def test_falsy_return_from_task(sync_checkpointer: BaseCheckpointSaver):
"interrupts": ( "interrupts": (
{ {
"id": AnyStr(), "id": AnyStr(),
"response_schema": None,
"value": "test", "value": "test",
}, },
), ),
+2
View File
@@ -295,6 +295,8 @@ class Interrupt(TypedDict):
"""The value associated with the interrupt.""" """The value associated with the interrupt."""
id: str id: str
"""The ID of the interrupt. Can be used to resume the interrupt.""" """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): class Thread(TypedDict):