diff --git a/libs/langgraph/tests/test_time_travel.py b/libs/langgraph/tests/test_time_travel.py index caf51f5a5..fe544274f 100644 --- a/libs/langgraph/tests/test_time_travel.py +++ b/libs/langgraph/tests/test_time_travel.py @@ -1161,9 +1161,7 @@ def test_subgraph_interrupt_resume_with_explicit_head_checkpoint_id( assert called == ["step_a", "ask_human"] # Resume with explicit head checkpoint_id in config - head_checkpoint_id = graph.get_state(config).config["configurable"][ - "checkpoint_id" - ] + head_checkpoint_id = graph.get_state(config).config["configurable"]["checkpoint_id"] called.clear() resume_config = { "configurable": { diff --git a/libs/langgraph/uv.lock b/libs/langgraph/uv.lock index fe967dce4..5c6463c46 100644 --- a/libs/langgraph/uv.lock +++ b/libs/langgraph/uv.lock @@ -1348,7 +1348,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.3.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -1360,9 +1360,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/fe/20190232d9b513242899dbb0c2bb77e31b4d61e343743adbe90ebc2603d2/langchain_core-1.3.0.tar.gz", hash = "sha256:14a39f528bf459aa3aa40d0a7f7f1bae7520d435ef991ae14a4ceb74d8c49046", size = 860755, upload-time = "2026-04-17T14:51:38.298Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/fe/abeae8d0d2899e191d67c6c7f065f7e52a953f30b21ef327fa49084e4af9/langchain_core-1.3.1.tar.gz", hash = "sha256:41b384055799f93f34520df6bf7b80e2e5e23153cdfd46874251c6c9916ea030", size = 862403, upload-time = "2026-04-23T18:54:01.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/e2/dbfa347aa072a6dc4cd38d6f9ebfc730b4c14c258c47f480f4c5c546f177/langchain_core-1.3.0-py3-none-any.whl", hash = "sha256:baf16ee028475df177b9ab8869a751c79406d64a6f12125b93802991b566cced", size = 515140, upload-time = "2026-04-17T14:51:36.274Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c2/8493be505921857988db068b7c027f28a9b1587b4425c6a32b1221c9c9fe/langchain_core-1.3.1-py3-none-any.whl", hash = "sha256:8b13d19d3bed3f4768df12c7f6932d2ada715f3ac9fd020c63d28c693968269e", size = 515879, upload-time = "2026-04-23T18:53:59.94Z" }, ] [[package]] @@ -1751,7 +1751,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "langchain-core", specifier = ">=1.0.0" }, + { name = "langchain-core", specifier = ">=1.3.1" }, { name = "langgraph-checkpoint", editable = "../checkpoint" }, ] diff --git a/libs/prebuilt/langgraph/prebuilt/tool_node.py b/libs/prebuilt/langgraph/prebuilt/tool_node.py index d8a1f3182..d97dba2c3 100644 --- a/libs/prebuilt/langgraph/prebuilt/tool_node.py +++ b/libs/prebuilt/langgraph/prebuilt/tool_node.py @@ -859,14 +859,30 @@ class ToolNode(RunnableCallable): def _combine_tool_outputs( self, - outputs: list[ToolMessage | Command], + outputs: list[ToolMessage | Command | list[ToolMessage | Command]], input_type: Literal["list", "dict", "tool_calls"], ) -> list[Command | list[ToolMessage] | dict[str, list[ToolMessage]]]: + # Flatten list entries from tools that returned multiple items + flat_outputs: list[ToolMessage | Command] + if any(isinstance(output, list) for output in outputs): + flat_outputs = [] + for output in outputs: + if isinstance(output, list): + flat_outputs.extend(output) + else: + flat_outputs.append(output) + else: + flat_outputs = cast("list[ToolMessage | Command]", outputs) + # preserve existing behavior for non-command tool outputs for backwards # compatibility - if not any(isinstance(output, Command) for output in outputs): + if not any(isinstance(output, Command) for output in flat_outputs): # TypedDict, pydantic, dataclass, etc. should all be able to load from dict - return outputs if input_type == "list" else {self._messages_key: outputs} + return ( + flat_outputs + if input_type == "list" + else {self._messages_key: flat_outputs} + ) # LangGraph will automatically handle list of Command and non-command node # updates @@ -876,7 +892,7 @@ class ToolNode(RunnableCallable): # combine all parent commands with goto into a single parent command parent_command: Command | None = None - for output in outputs: + for output in flat_outputs: if isinstance(output, Command): if ( output.graph is Command.PARENT @@ -906,7 +922,7 @@ class ToolNode(RunnableCallable): request: ToolCallRequest, input_type: Literal["list", "dict", "tool_calls"], config: RunnableConfig, - ) -> ToolMessage | Command: + ) -> ToolMessage | Command | list[Command | ToolMessage]: """Execute tool call with configured error handling. Args: @@ -915,7 +931,7 @@ class ToolNode(RunnableCallable): config: Runnable configuration. Returns: - ToolMessage or Command. + ToolMessage, Command, or list of Command/ToolMessage. Raises: Exception: If tool fails and handle_tool_errors is False. @@ -947,6 +963,11 @@ class ToolNode(RunnableCallable): call["name"], exc, call["args"], filtered_errors ) from exc + # Inside try so validation errors route through _handle_tool_errors + return self._normalize_tool_response( + response, request.tool_call, input_type + ) + # GraphInterrupt is a special exception that will always be raised. # It can be triggered in the following scenarios, # Where GraphInterrupt(GraphBubbleUp) is raised from an `interrupt` invocation @@ -988,23 +1009,12 @@ class ToolNode(RunnableCallable): status="error", ) - # Process successful response - if isinstance(response, Command): - # Validate Command before returning to handler - return self._validate_tool_command(response, request.tool_call, input_type) - if isinstance(response, ToolMessage): - response.content = cast("str | list", msg_content_output(response.content)) - return response - - msg = f"Tool {call['name']} returned unexpected type: {type(response)}" - raise TypeError(msg) - def _run_one( self, call: ToolCall, input_type: Literal["list", "dict", "tool_calls"], tool_runtime: ToolRuntime, - ) -> ToolMessage | Command: + ) -> ToolMessage | Command | list[Command | ToolMessage]: """Execute single tool call with wrap_tool_call wrapper if configured. Args: @@ -1059,7 +1069,7 @@ class ToolNode(RunnableCallable): request: ToolCallRequest, input_type: Literal["list", "dict", "tool_calls"], config: RunnableConfig, - ) -> ToolMessage | Command: + ) -> ToolMessage | Command | list[Command | ToolMessage]: """Execute tool call asynchronously with configured error handling. Args: @@ -1068,7 +1078,7 @@ class ToolNode(RunnableCallable): config: Runnable configuration. Returns: - ToolMessage or Command. + ToolMessage, Command, or list of Command/ToolMessage. Raises: Exception: If tool fails and handle_tool_errors is False. @@ -1100,6 +1110,11 @@ class ToolNode(RunnableCallable): call["name"], exc, call["args"], filtered_errors ) from exc + # Inside try so validation errors route through _handle_tool_errors + return self._normalize_tool_response( + response, request.tool_call, input_type + ) + # GraphInterrupt is a special exception that will always be raised. # It can be triggered in the following scenarios, # Where GraphInterrupt(GraphBubbleUp) is raised from an `interrupt` invocation @@ -1141,23 +1156,12 @@ class ToolNode(RunnableCallable): status="error", ) - # Process successful response - if isinstance(response, Command): - # Validate Command before returning to handler - return self._validate_tool_command(response, request.tool_call, input_type) - if isinstance(response, ToolMessage): - response.content = cast("str | list", msg_content_output(response.content)) - return response - - msg = f"Tool {call['name']} returned unexpected type: {type(response)}" - raise TypeError(msg) - async def _arun_one( self, call: ToolCall, input_type: Literal["list", "dict", "tool_calls"], tool_runtime: ToolRuntime, - ) -> ToolMessage | Command: + ) -> ToolMessage | Command | list[Command | ToolMessage]: """Execute single tool call asynchronously with awrap_tool_call wrapper if configured. Args: @@ -1404,11 +1408,84 @@ class ToolNode(RunnableCallable): tool_call_copy["args"] = {**stripped_args, **injected_args} return tool_call_copy + def _normalize_tool_response( + self, + response: Any, + tool_call: ToolCall, + input_type: Literal["list", "dict", "tool_calls"], + ) -> ToolMessage | Command | list[Command | ToolMessage]: + """Validate and normalize a tool's raw return value.""" + if isinstance(response, Command): + return self._validate_tool_command(response, tool_call, input_type) + if isinstance(response, ToolMessage): + response.content = cast("str | list", msg_content_output(response.content)) + return response + if isinstance(response, list): + if all(isinstance(r, (Command, ToolMessage)) for r in response): + return self._validate_tool_command_list(response, tool_call, input_type) + msg = ( + f"Tool {tool_call['name']} returned a list with invalid element " + "types: expected all Command or ToolMessage" + ) + raise TypeError(msg) + msg = f"Tool {tool_call['name']} returned unexpected type: {type(response)}" + raise TypeError(msg) + + def _validate_tool_command_list( + self, + response: list[Command | ToolMessage], + tool_call: ToolCall, + input_type: Literal["list", "dict", "tool_calls"], + ) -> list[Command | ToolMessage]: + """Validate a list of Command/ToolMessage returned by a single tool call. + + Requires exactly one terminating ToolMessage (matching the outer tool_call_id) + across the list — either as a top-level element or nested in a + Command.update["messages"]. + """ + expected_id = tool_call["id"] + + terminator_count = 0 + for item in response: + if isinstance(item, ToolMessage): + if item.tool_call_id == expected_id: + terminator_count += 1 + elif isinstance(item, Command) and isinstance(item.update, dict): + for msg in item.update.get(self._messages_key, []): + if isinstance(msg, ToolMessage) and msg.tool_call_id == expected_id: + terminator_count += 1 + + if terminator_count != 1: + msg = ( + f"Tool {tool_call['name']} returned a list with " + f"{terminator_count} messages bound to tool_call_id " + f"{expected_id!r}; expected exactly one terminating ToolMessage." + ) + raise ValueError(msg) + + # Per-Command normalization still runs, but the list-level count above + # already guarantees exactly one terminator, so individual Commands may + # lack one. + validated: list[Command | ToolMessage] = [] + for item in response: + if isinstance(item, Command): + validated.append( + self._validate_tool_command( + item, tool_call, input_type, require_terminator=False + ) + ) + else: + item.content = cast("str | list", msg_content_output(item.content)) + validated.append(item) + return validated + def _validate_tool_command( self, command: Command, call: ToolCall, input_type: Literal["list", "dict", "tool_calls"], + *, + require_terminator: bool = True, ) -> Command: if isinstance(command.update, dict): # input type is dict when ToolNode is invoked with a dict input @@ -1458,7 +1535,11 @@ class ToolNode(RunnableCallable): # validate that we always have a ToolMessage matching the tool call in # Command.update if command is sent to the CURRENT graph - if updated_command.graph is None and not has_matching_tool_message: + if ( + require_terminator + and updated_command.graph is None + and not has_matching_tool_message + ): example_update = ( '`Command(update={"messages": ' '[ToolMessage("Success", tool_call_id=tool_call_id), ...]}, ...)`' diff --git a/libs/prebuilt/pyproject.toml b/libs/prebuilt/pyproject.toml index 640f03e7d..38d2b4c39 100644 --- a/libs/prebuilt/pyproject.toml +++ b/libs/prebuilt/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ ] dependencies = [ "langgraph-checkpoint>=2.1.0,<5.0.0", - "langchain-core>=1.0.0", + "langchain-core>=1.3.1", ] [project.urls] diff --git a/libs/prebuilt/tests/test_tool_node.py b/libs/prebuilt/tests/test_tool_node.py index 8d0519655..c30787f74 100644 --- a/libs/prebuilt/tests/test_tool_node.py +++ b/libs/prebuilt/tests/test_tool_node.py @@ -2223,3 +2223,195 @@ def test_tool_node_injected_state_overwrites_llm_value() -> None: ) tool_message = result["messages"][-1] assert tool_message.content == "PUBLIC_DATA" + + +class _ReturningTool(BaseTool): + """A tool that returns a configured value verbatim.""" + + name: str = "list_tool" + description: str = "Returns a configured value" + return_value: Any = None + + def _run(self, **kwargs: Any) -> Any: + return self.return_value + + async def _arun(self, **kwargs: Any) -> Any: + return self.return_value + + +def _list_tool_call(outer_id: str = "call-1") -> dict[str, Any]: + return {"name": "list_tool", "args": {}, "id": outer_id, "type": "tool_call"} + + +def _invoke_returning( + return_value: Any, + *, + outer_id: str = "call-1", + handle_tool_errors: bool = True, +) -> Any: + node = ToolNode( + [_ReturningTool(return_value=return_value)], + handle_tool_errors=handle_tool_errors, + ) + return node.invoke( + {"messages": [AIMessage("", tool_calls=[_list_tool_call(outer_id)])]}, + config=_create_config_with_runtime(), + ) + + +def test_tool_node_list_return_command_and_tool_message() -> None: + """Valid: tool returns [Command(update={...}), ToolMessage(...)].""" + outer_id = "call-1" + result = _invoke_returning( + [ + Command(update={"foo": "bar"}), + ToolMessage(content="done", tool_call_id=outer_id), + ] + ) + assert isinstance(result, list) + commands = [r for r in result if isinstance(r, Command)] + assert len(commands) == 1 + assert commands[0].update == {"foo": "bar"} + non_commands = [r for r in result if not isinstance(r, Command)] + assert len(non_commands) == 1 + assert isinstance(non_commands[0], dict) + msgs = non_commands[0]["messages"] + assert len(msgs) == 1 + assert isinstance(msgs[0], ToolMessage) + assert msgs[0].content == "done" + assert msgs[0].tool_call_id == outer_id + + +def test_tool_node_list_return_nested_terminator() -> None: + """Valid: terminator nested inside Command.update['messages'].""" + outer_id = "call-1" + result = _invoke_returning( + [ + Command(update={"foo": "bar"}), + Command( + update={ + "messages": [ToolMessage(content="done", tool_call_id=outer_id)] + } + ), + ] + ) + assert isinstance(result, list) + commands = [r for r in result if isinstance(r, Command)] + assert len(commands) == 2 + updates = [c.update for c in commands] + assert {"foo": "bar"} in updates + msgs_update = next(u for u in updates if "messages" in (u or {})) + assert any( + isinstance(m, ToolMessage) and m.tool_call_id == outer_id + for m in msgs_update["messages"] + ) + + +def test_tool_node_list_return_parent_goto_with_terminator() -> None: + """Valid: [Command(graph=PARENT, goto=[Send(...)]), ToolMessage(...)].""" + outer_id = "call-1" + result = _invoke_returning( + [ + Command(graph=Command.PARENT, goto=[Send("child", {})]), + ToolMessage(content="ok", tool_call_id=outer_id), + ] + ) + assert isinstance(result, list) + parent_cmds = [ + r for r in result if isinstance(r, Command) and r.graph is Command.PARENT + ] + assert len(parent_cmds) == 1 + assert isinstance(parent_cmds[0].goto, list) + assert any(isinstance(s, Send) for s in parent_cmds[0].goto) + non_commands = [r for r in result if not isinstance(r, Command)] + assert len(non_commands) == 1 + + +def test_tool_node_list_return_no_terminator_raises() -> None: + """Invalid: list with no terminating ToolMessage.""" + with pytest.raises(ValueError, match="0 messages bound to tool_call_id"): + _invoke_returning([Command(update={"foo": "bar"})], handle_tool_errors=False) + + +def test_tool_node_list_return_multiple_terminators_raises() -> None: + """Invalid: list with two terminating ToolMessages.""" + outer_id = "call-1" + with pytest.raises(ValueError, match="2 messages bound to tool_call_id"): + _invoke_returning( + [ + ToolMessage(content="a", tool_call_id=outer_id), + ToolMessage(content="b", tool_call_id=outer_id), + ], + handle_tool_errors=False, + ) + + +def test_tool_node_list_return_validation_error_handled() -> None: + """handle_tool_errors=True converts validation errors to an error ToolMessage.""" + result = _invoke_returning([Command(update={"foo": "bar"})]) + assert isinstance(result, dict) + msg = result["messages"][0] + assert isinstance(msg, ToolMessage) + assert msg.status == "error" + assert "0 messages bound to tool_call_id" in msg.content + + +async def test_tool_node_list_return_async_smoke() -> None: + """Async path parallels sync for the happy case.""" + outer_id = "call-1" + node = ToolNode( + [ + _ReturningTool( + return_value=[ + Command(update={"foo": "bar"}), + ToolMessage(content="done", tool_call_id=outer_id), + ] + ) + ] + ) + result = await node.ainvoke( + {"messages": [AIMessage("", tool_calls=[_list_tool_call(outer_id)])]}, + config=_create_config_with_runtime(), + ) + assert isinstance(result, list) + commands = [r for r in result if isinstance(r, Command)] + assert len(commands) == 1 and commands[0].update == {"foo": "bar"} + + +def test_tool_node_list_return_mixed_with_regular_tool() -> None: + """List-returning tool and a regular tool dispatched from the same AIMessage.""" + list_tool_id = "call-list" + regular_tool_id = "call-regular" + list_tool = _ReturningTool( + return_value=[ + Command(update={"foo": "bar"}), + ToolMessage(content="list done", tool_call_id=list_tool_id), + ] + ) + + def regular_tool(x: int) -> str: + """A normal tool.""" + return f"regular: {x}" + + tool_calls = [ + {"name": "list_tool", "args": {}, "id": list_tool_id, "type": "tool_call"}, + { + "name": "regular_tool", + "args": {"x": 7}, + "id": regular_tool_id, + "type": "tool_call", + }, + ] + node = ToolNode([list_tool, regular_tool]) + result = node.invoke( + {"messages": [AIMessage("", tool_calls=tool_calls)]}, + config=_create_config_with_runtime(), + ) + assert isinstance(result, list) + commands = [r for r in result if isinstance(r, Command)] + assert len(commands) == 1 + assert commands[0].update == {"foo": "bar"} + all_msgs = [m for r in result if isinstance(r, dict) for m in r["messages"]] + tool_call_ids = {m.tool_call_id for m in all_msgs} + assert list_tool_id in tool_call_ids + assert regular_tool_id in tool_call_ids diff --git a/libs/prebuilt/uv.lock b/libs/prebuilt/uv.lock index ccf7ed1cb..b5df1f91f 100644 --- a/libs/prebuilt/uv.lock +++ b/libs/prebuilt/uv.lock @@ -249,7 +249,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.3.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -261,9 +261,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/fe/20190232d9b513242899dbb0c2bb77e31b4d61e343743adbe90ebc2603d2/langchain_core-1.3.0.tar.gz", hash = "sha256:14a39f528bf459aa3aa40d0a7f7f1bae7520d435ef991ae14a4ceb74d8c49046", size = 860755, upload-time = "2026-04-17T14:51:38.298Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/fe/abeae8d0d2899e191d67c6c7f065f7e52a953f30b21ef327fa49084e4af9/langchain_core-1.3.1.tar.gz", hash = "sha256:41b384055799f93f34520df6bf7b80e2e5e23153cdfd46874251c6c9916ea030", size = 862403, upload-time = "2026-04-23T18:54:01.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/e2/dbfa347aa072a6dc4cd38d6f9ebfc730b4c14c258c47f480f4c5c546f177/langchain_core-1.3.0-py3-none-any.whl", hash = "sha256:baf16ee028475df177b9ab8869a751c79406d64a6f12125b93802991b566cced", size = 515140, upload-time = "2026-04-17T14:51:36.274Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c2/8493be505921857988db068b7c027f28a9b1587b4425c6a32b1221c9c9fe/langchain_core-1.3.1-py3-none-any.whl", hash = "sha256:8b13d19d3bed3f4768df12c7f6932d2ada715f3ac9fd020c63d28c693968269e", size = 515879, upload-time = "2026-04-23T18:53:59.94Z" }, ] [[package]] @@ -535,7 +535,7 @@ test = [ [package.metadata] requires-dist = [ - { name = "langchain-core", specifier = ">=1.0.0" }, + { name = "langchain-core", specifier = ">=1.3.1" }, { name = "langgraph-checkpoint", editable = "../checkpoint" }, ] diff --git a/libs/sdk-py/uv.lock b/libs/sdk-py/uv.lock index d2a654f02..3e95338ab 100644 --- a/libs/sdk-py/uv.lock +++ b/libs/sdk-py/uv.lock @@ -262,7 +262,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.3.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -274,9 +274,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/fe/20190232d9b513242899dbb0c2bb77e31b4d61e343743adbe90ebc2603d2/langchain_core-1.3.0.tar.gz", hash = "sha256:14a39f528bf459aa3aa40d0a7f7f1bae7520d435ef991ae14a4ceb74d8c49046", size = 860755, upload-time = "2026-04-17T14:51:38.298Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/fe/abeae8d0d2899e191d67c6c7f065f7e52a953f30b21ef327fa49084e4af9/langchain_core-1.3.1.tar.gz", hash = "sha256:41b384055799f93f34520df6bf7b80e2e5e23153cdfd46874251c6c9916ea030", size = 862403, upload-time = "2026-04-23T18:54:01.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/e2/dbfa347aa072a6dc4cd38d6f9ebfc730b4c14c258c47f480f4c5c546f177/langchain_core-1.3.0-py3-none-any.whl", hash = "sha256:baf16ee028475df177b9ab8869a751c79406d64a6f12125b93802991b566cced", size = 515140, upload-time = "2026-04-17T14:51:36.274Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c2/8493be505921857988db068b7c027f28a9b1587b4425c6a32b1221c9c9fe/langchain_core-1.3.1-py3-none-any.whl", hash = "sha256:8b13d19d3bed3f4768df12c7f6932d2ada715f3ac9fd020c63d28c693968269e", size = 515879, upload-time = "2026-04-23T18:53:59.94Z" }, ] [[package]] @@ -422,7 +422,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "langchain-core", specifier = ">=1.0.0" }, + { name = "langchain-core", specifier = ">=1.3.1" }, { name = "langgraph-checkpoint", editable = "../checkpoint" }, ]