fix(langgraph): raise InvalidResumeError for resume values that don't match response_schema

Replaces the attribute tag from the previous commit. `interrupt()` now raises
`InvalidResumeError` (a `ValueError` chained from the pydantic
`ValidationError`), which `ToolNode` doesn't read as invalid tool arguments,
so a graph run by a tool fails the resume and keeps the interrupt. `ToolNode`
is unchanged.
This commit is contained in:
byhow
2026-10-07 12:19:10 -07:00
parent b480be5e6e
commit ecf87e026e
5 changed files with 28 additions and 33 deletions
+13
View File
@@ -18,6 +18,7 @@ __all__ = (
"ErrorCode",
"GraphDrained",
"GraphRecursionError",
"InvalidResumeError",
"InvalidUpdateError",
"GraphBubbleUp",
"GraphInterrupt",
@@ -99,6 +100,18 @@ class InvalidUpdateError(Exception):
pass
class InvalidResumeError(ValueError):
"""Raised when a resume value doesn't match the interrupt's `response_schema`.
Nothing from the run is saved, so the interrupt can be answered again. The
`pydantic.ValidationError` describing the problem is the exception's cause.
It isn't a `pydantic.ValidationError` itself because `ToolNode` reads those as
invalid tool arguments, which would hide this error when the interrupt runs in
a graph that a tool calls.
"""
class GraphInterrupt(GraphBubbleUp):
"""Raised when a subgraph is interrupted, suppressed by the root graph.
Never raised directly, or surfaced to the user."""
+5 -13
View File
@@ -882,20 +882,13 @@ class Command(Generic[N], ToolOutputMixin):
PARENT: ClassVar[Literal["__parent__"]] = "__parent__"
_INVALID_RESUME_ATTR = "langgraph_invalid_resume"
"""Set on the `ValidationError` for a resume value that doesn't match `response_schema`.
`ToolNode` reads it by name, so a graph run by a tool re-raises the error instead of
reporting it as invalid tool arguments.
"""
def _validate_resume(adapter: TypeAdapter[Any], value: Any) -> Any:
from langgraph.errors import InvalidResumeError
try:
return adapter.validate_python(value)
except ValidationError as exc:
setattr(exc, _INVALID_RESUME_ATTR, True)
raise
raise InvalidResumeError(str(exc)) from exc
@overload
@@ -1004,9 +997,8 @@ def interrupt(
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`.
Inside a graph run by a tool, `ToolNode` re-raises it rather than reporting it
as invalid tool arguments, so the interrupt can be answered again.
InvalidResumeError: When a resume value does not match a Pydantic model, `TypedDict`, or dataclass `response_schema`.
Its cause is the `pydantic.ValidationError`. Nothing is saved, so the interrupt can be answered again.
"""
from langgraph._internal._constants import (
CONFIG_KEY_CHECKPOINT_NS,
+4 -4
View File
@@ -6,6 +6,7 @@ from langgraph.checkpoint.base import BaseCheckpointSaver
from pydantic import BaseModel, ValidationError
from typing_extensions import TypedDict
from langgraph.errors import InvalidResumeError
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, Durability, Interrupt, interrupt
from tests.any_str import AnyStr
@@ -202,10 +203,9 @@ def test_interrupt_response_schema_rejects_invalid_resume(
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") as exc_info:
with pytest.raises(InvalidResumeError, match="approved") as exc_info:
graph.invoke(resume({"approved": "nope"}), config)
# `ToolNode` reads this by name to re-raise the error from a graph run by a tool.
assert getattr(exc_info.value, "langgraph_invalid_resume", False)
assert isinstance(exc_info.value.__cause__, ValidationError)
assert graph.invoke(resume({"approved": False}), config) == {
"answer": Decision(approved=False)
@@ -238,7 +238,7 @@ def test_interrupt_response_schema_invalid_resume_after_earlier_interrupt(
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"):
with pytest.raises(InvalidResumeError, match="approved"):
graph.invoke(resume({"approved": "nope"}), config)
assert graph.invoke(resume({"approved": True}), config) == {
@@ -957,11 +957,6 @@ class ToolNode(RunnableCallable):
try:
response = tool.invoke(call_args, config)
except ValidationError as exc:
if getattr(exc, "langgraph_invalid_resume", False):
# Set by `interrupt()`: a graph this tool ran got a resume value
# that doesn't match its `response_schema`. The tool's own
# arguments were fine, so fail the run and keep the interrupt.
raise
# Filter out errors for injected arguments
injected = self._injected_args.get(call["name"])
filtered_errors = _filter_validation_errors(exc, injected)
@@ -1109,11 +1104,6 @@ class ToolNode(RunnableCallable):
try:
response = await tool.ainvoke(call_args, config)
except ValidationError as exc:
if getattr(exc, "langgraph_invalid_resume", False):
# Set by `interrupt()`: a graph this tool ran got a resume value
# that doesn't match its `response_schema`. The tool's own
# arguments were fine, so fail the run and keep the interrupt.
raise
# Filter out errors for injected arguments
injected = self._injected_args.get(call["name"])
filtered_errors = _filter_validation_errors(exc, injected)
+6 -6
View File
@@ -27,14 +27,14 @@ from langchain_core.tools import tool as dec_tool
from langchain_core.tools.base import InjectedToolCallId
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.config import get_stream_writer
from langgraph.errors import GraphBubbleUp, GraphInterrupt
from langgraph.errors import GraphBubbleUp, GraphInterrupt, InvalidResumeError
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.graph.message import REMOVE_ALL_MESSAGES, add_messages
from langgraph.runtime import ExecutionInfo, ServerInfo
from langgraph.store.base import BaseStore
from langgraph.store.memory import InMemoryStore
from langgraph.types import Command, Send, interrupt
from pydantic import BaseModel, ValidationError
from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
from typing_extensions import TypedDict
@@ -669,9 +669,9 @@ def test_tool_node_reraises_invalid_resume_from_nested_interrupt(
config: RunnableConfig = {"configurable": {"thread_id": "1"}}
[pending] = graph.invoke(_ask_human_call(), config)["__interrupt__"]
# A bad answer isn't a bad tool argument: the run fails without committing,
# so the same interrupt can be answered again.
with pytest.raises(ValidationError, match="approved"):
# A bad answer isn't a bad tool argument: the run fails without saving, so
# the same interrupt can be answered again.
with pytest.raises(InvalidResumeError, match="approved"):
graph.invoke(Command(resume={pending.id: {"approved": "maybe"}}), config)
assert [i.id for i in graph.get_state(config).interrupts] == [pending.id]
@@ -698,7 +698,7 @@ async def test_tool_node_reraises_invalid_resume_from_nested_interrupt_async(
config: RunnableConfig = {"configurable": {"thread_id": "1"}}
[pending] = (await graph.ainvoke(_ask_human_call(), config))["__interrupt__"]
with pytest.raises(ValidationError, match="approved"):
with pytest.raises(InvalidResumeError, match="approved"):
await graph.ainvoke(Command(resume={pending.id: {"approved": "maybe"}}), config)
state = await graph.aget_state(config)
assert [i.id for i in state.interrupts] == [pending.id]