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