Compare commits

...
Author SHA1 Message Date
Elior Nataf Lackritz ff87b5f654 release(langgraph): 1.2.12 2026-09-18 15:06:57 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c81c13533e chore(deps): bump soupsieve from 2.8.4 to 2.9 in /libs/langgraph (#8958)
Bumps [soupsieve](https://github.com/facelessuser/soupsieve) from 2.8.4
to 2.9.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/facelessuser/soupsieve/releases">soupsieve's
releases</a>.</em></p>
<blockquote>
<h2>2.9</h2>
<ul>
<li><strong>NEW</strong>: Drop Python 3.9 support.</li>
<li><strong>NEW</strong>: Lazy compile selector patterns to improve
initial import speed.</li>
<li><strong>FIX</strong>: Correct
<code>:nth-child</code>/<code>:nth-of-type</code> (and
<code>-last-</code> variants) for <code>An+B</code> values whose
sequence steps onto
index 0 or onto the last child (e.g. <code>:nth-child(2n-2)</code>,
<code>:nth-child(n-1)</code>, <code>:nth-child(n+5)</code>), which
previously
matched the wrong elements or nothing at all (<a
href="https://github.com/gaoflow"><code>@​gaoflow</code></a>).</li>
<li><strong>FIX</strong>: More efficient CSS ID matching (<a
href="https://github.com/kaimandalic"><code>@​kaimandalic</code></a>).</li>
<li><strong>FIX</strong>: Fix inefficient trimming of comments and white
space (<a
href="https://github.com/kaimandalic"><code>@​kaimandalic</code></a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/facelessuser/soupsieve/commit/8763f914472fc83652babda708bed5c8ef287004"><code>8763f91</code></a>
Format changelog message</li>
<li><a
href="https://github.com/facelessuser/soupsieve/commit/cf198fcddc9230f06ed39f974eba0ce076b85cda"><code>cf198fc</code></a>
Fix inefficient trimming of comments and white space</li>
<li><a
href="https://github.com/facelessuser/soupsieve/commit/ce44e4996e6632871c18cdd7a7fb641be8ef34ef"><code>ce44e49</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/facelessuser/soupsieve/commit/751c57b2c7e978e206b94b7dba17f8e2af392e19"><code>751c57b</code></a>
Fix :nth-child/:nth-of-type matching for An+B index boundaries (<a
href="https://redirect.github.com/facelessuser/soupsieve/issues/297">#297</a>)</li>
<li><a
href="https://github.com/facelessuser/soupsieve/commit/08e9ede4dcfafef860155319ef5eb9708e75d10b"><code>08e9ede</code></a>
Drop Python 3.9</li>
<li><a
href="https://github.com/facelessuser/soupsieve/commit/d6e68303a6c3e0e410530939b92955ba24a07a81"><code>d6e6830</code></a>
Rework selector mapping</li>
<li><a
href="https://github.com/facelessuser/soupsieve/commit/d2d1581fe275f89cb2e792589fed770aeb9e99b3"><code>d2d1581</code></a>
Utilize property for accessing lazy regular expression pattern</li>
<li><a
href="https://github.com/facelessuser/soupsieve/commit/b8701dec25c84a3910fd9a03222a3804fa119a1d"><code>b8701de</code></a>
Build patterns and regexes lazily in css_parser (<a
href="https://redirect.github.com/facelessuser/soupsieve/issues/296">#296</a>)</li>
<li>See full diff in <a
href="https://github.com/facelessuser/soupsieve/compare/2.8.4...2.9">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=soupsieve&package-manager=uv&previous-version=2.8.4&new-version=2.9)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/langchain-ai/langgraph/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-17 20:18:46 -07:00
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
8 changed files with 230 additions and 19 deletions
+66 -11
View File
@@ -12,15 +12,23 @@ 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 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 langgraph._internal._cache import default_cache_key
@@ -36,6 +44,7 @@ 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
@@ -572,7 +581,7 @@ _DEFAULT_INTERRUPT_ID = "placeholder-id"
@final
@dataclass(init=False, slots=True)
class Interrupt:
class Interrupt(Generic[ResponseT]):
"""Information about an interrupt that occurred in a node.
!!! version-added "Added in version 0.2.24"
@@ -596,13 +605,22 @@ class Interrupt:
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
@@ -614,8 +632,18 @@ class Interrupt:
self.id = id
@classmethod
def from_ns(cls, value: Any, ns: str) -> Interrupt:
return cls(value=value, id=xxh3_128_hexdigest(ns.encode()))
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,
)
@property
@deprecated("`interrupt_id` is deprecated. Use `id` instead.", category=None)
@@ -848,7 +876,17 @@ class Command(Generic[N], ToolOutputMixin):
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.
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):
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!!!\")
@@ -931,12 +969,20 @@ def interrupt(value: Any) -> Any:
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
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:
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,
@@ -948,27 +994,36 @@ def interrupt(value: Any) -> Any:
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):
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
return scratchpad.resume[idx]
v = 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
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 v
return validated
# 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.11"
version = "1.2.12"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
+153 -1
View File
@@ -1,9 +1,14 @@
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 Durability
from langgraph.types import Command, Durability, Interrupt, interrupt
from tests.any_str import AnyStr
pytestmark = pytest.mark.anyio
@@ -90,3 +95,150 @@ 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,6 +5583,7 @@ def test_falsy_return_from_task(sync_checkpointer: BaseCheckpointSaver):
"interrupts": [
{
"id": AnyStr(),
"response_schema": None,
"value": "test",
},
],
@@ -5627,6 +5628,7 @@ 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.11"
version = "1.2.12"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -3404,11 +3404,11 @@ wheels = [
[[package]]
name = "soupsieve"
version = "2.8.4"
version = "2.9"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
+1 -1
View File
@@ -286,7 +286,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.11"
version = "1.2.12"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
+2
View File
@@ -295,6 +295,8 @@ 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):
+1 -1
View File
@@ -299,7 +299,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.11"
version = "1.2.12"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },