fix(langgraph,prebuilt): keep ValidationError and add is_invalid_resume

Replaces InvalidResumeError from the previous commit, which changed the error
`interrupt()` documents. `interrupt()` again raises the pydantic
`ValidationError`, marked so the new `langgraph.errors.is_invalid_resume`
recognizes it. `ToolNode` re-raises those errors instead of reporting them as
invalid tool arguments, and falls back to today's behavior with an older
`langgraph` that doesn't have the helper.
This commit is contained in:
byhow
2026-10-07 12:23:36 -07:00
parent ecf87e026e
commit 329c6af81e
5 changed files with 49 additions and 25 deletions
+18 -13
View File
@@ -18,7 +18,6 @@ __all__ = (
"ErrorCode",
"GraphDrained",
"GraphRecursionError",
"InvalidResumeError",
"InvalidUpdateError",
"GraphBubbleUp",
"GraphInterrupt",
@@ -29,6 +28,7 @@ __all__ = (
"ParentCommand",
"EmptyInputError",
"TaskNotFound",
"is_invalid_resume",
)
@@ -100,18 +100,6 @@ 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."""
@@ -252,3 +240,20 @@ class NodeTimeoutError(Exception):
self.kind = kind
self.idle_timeout = idle_timeout
self.run_timeout = run_timeout
_INVALID_RESUME = "_langgraph_invalid_resume"
def _mark_invalid_resume(error: BaseException) -> None:
setattr(error, _INVALID_RESUME, True)
def is_invalid_resume(error: BaseException) -> bool:
"""Whether `error` was raised because a resume value didn't match `response_schema`.
`interrupt()` raises a `pydantic.ValidationError` in that case. `ToolNode` uses
this to tell it apart from invalid tool arguments when a tool runs a graph, so
the resume fails and the interrupt can be answered again.
"""
return getattr(error, _INVALID_RESUME, False) is True
+5 -4
View File
@@ -883,12 +883,13 @@ class Command(Generic[N], ToolOutputMixin):
def _validate_resume(adapter: TypeAdapter[Any], value: Any) -> Any:
from langgraph.errors import InvalidResumeError
from langgraph.errors import _mark_invalid_resume
try:
return adapter.validate_python(value)
except ValidationError as exc:
raise InvalidResumeError(str(exc)) from exc
_mark_invalid_resume(exc)
raise
@overload
@@ -997,8 +998,8 @@ def interrupt(
Raises:
GraphInterrupt: On the first invocation within the node, halts execution and surfaces the provided value to the client.
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.
pydantic.ValidationError: When a resume value does not match a Pydantic model, `TypedDict`, or dataclass `response_schema`.
Nothing is saved, so the interrupt can be answered again. `is_invalid_resume` identifies it.
"""
from langgraph._internal._constants import (
CONFIG_KEY_CHECKPOINT_NS,
+4 -4
View File
@@ -6,7 +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.errors import is_invalid_resume
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, Durability, Interrupt, interrupt
from tests.any_str import AnyStr
@@ -203,9 +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(InvalidResumeError, match="approved") as exc_info:
with pytest.raises(ValidationError, match="approved") as exc_info:
graph.invoke(resume({"approved": "nope"}), config)
assert isinstance(exc_info.value.__cause__, ValidationError)
assert is_invalid_resume(exc_info.value)
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(InvalidResumeError, match="approved"):
with pytest.raises(ValidationError, match="approved"):
graph.invoke(resume({"approved": "nope"}), config)
assert graph.invoke(resume({"approved": True}), config) == {
@@ -99,6 +99,14 @@ if TYPE_CHECKING:
from langgraph.runtime import Runtime
from pydantic_core import ErrorDetails
try:
from langgraph.errors import is_invalid_resume
except ImportError: # `langgraph` before `is_invalid_resume` never marks resume errors
def is_invalid_resume(error: BaseException) -> bool:
return False
# right now we use a dict as the default, can change this to AgentState, but depends
# on if this lives in LangChain or LangGraph... ideally would have some typed
# messages key
@@ -957,6 +965,11 @@ class ToolNode(RunnableCallable):
try:
response = tool.invoke(call_args, config)
except ValidationError as exc:
if is_invalid_resume(exc):
# A graph this tool ran got a resume value that doesn't match its
# `response_schema`. That's not a bad tool argument: fail the run
# so the interrupt can be answered again.
raise
# Filter out errors for injected arguments
injected = self._injected_args.get(call["name"])
filtered_errors = _filter_validation_errors(exc, injected)
@@ -1104,6 +1117,11 @@ class ToolNode(RunnableCallable):
try:
response = await tool.ainvoke(call_args, config)
except ValidationError as exc:
if is_invalid_resume(exc):
# A graph this tool ran got a resume value that doesn't match its
# `response_schema`. That's not a bad tool argument: fail the run
# so the interrupt can be answered again.
raise
# Filter out errors for injected arguments
injected = self._injected_args.get(call["name"])
filtered_errors = _filter_validation_errors(exc, injected)
+4 -4
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, InvalidResumeError
from langgraph.errors import GraphBubbleUp, GraphInterrupt
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
from pydantic import BaseModel, ValidationError
from pydantic.v1 import BaseModel as BaseModelV1
from typing_extensions import TypedDict
@@ -671,7 +671,7 @@ def test_tool_node_reraises_invalid_resume_from_nested_interrupt(
# 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"):
with pytest.raises(ValidationError, 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(InvalidResumeError, match="approved"):
with pytest.raises(ValidationError, 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]