From 329c6af81ec63a67b1f34f3397df8b847834bb40 Mon Sep 17 00:00:00 2001 From: byhow <25713361+byhow@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:23:36 -0700 Subject: [PATCH] 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. --- libs/langgraph/langgraph/errors.py | 31 +++++++++++-------- libs/langgraph/langgraph/types.py | 9 +++--- libs/langgraph/tests/test_interruption.py | 8 ++--- libs/prebuilt/langgraph/prebuilt/tool_node.py | 18 +++++++++++ libs/prebuilt/tests/test_tool_node.py | 8 ++--- 5 files changed, 49 insertions(+), 25 deletions(-) diff --git a/libs/langgraph/langgraph/errors.py b/libs/langgraph/langgraph/errors.py index e8a50f210..825b495e9 100644 --- a/libs/langgraph/langgraph/errors.py +++ b/libs/langgraph/langgraph/errors.py @@ -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 diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index aaf33000e..8fba752a1 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -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, diff --git a/libs/langgraph/tests/test_interruption.py b/libs/langgraph/tests/test_interruption.py index c9d54412d..983c172dc 100644 --- a/libs/langgraph/tests/test_interruption.py +++ b/libs/langgraph/tests/test_interruption.py @@ -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) == { diff --git a/libs/prebuilt/langgraph/prebuilt/tool_node.py b/libs/prebuilt/langgraph/prebuilt/tool_node.py index 95e161b90..8c079135e 100644 --- a/libs/prebuilt/langgraph/prebuilt/tool_node.py +++ b/libs/prebuilt/langgraph/prebuilt/tool_node.py @@ -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) diff --git a/libs/prebuilt/tests/test_tool_node.py b/libs/prebuilt/tests/test_tool_node.py index 2cd1bc86e..0557c3b62 100644 --- a/libs/prebuilt/tests/test_tool_node.py +++ b/libs/prebuilt/tests/test_tool_node.py @@ -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]