mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-21 15:31:43 +02:00
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:
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user