chore: port tool node improvements back to langgraph (#6321)

namespace decisions

```
langgraph.prebuilt
  ├── ToolRuntime  # new
# all of the other stuff that was already there

langgraph.prebuilt.tool_node
  ├── ToolNode
  ├── ToolCallRequest  # new
  ├── ToolRuntime  # new
  ├── InjectedState
  ├── InjectedStore
  ├── ToolCallWrapper
  ├── AsyncToolCallWrapper
  ├── tools_condition
```
```
langchain.tools
  ├── ToolRuntime  # now from langgraph.prebuilt
  ├── InjectedState  # now from langgraph.prebuilt
  ├── InjectedStore  # now from langgraph.prebuilt
  ├── ToolException
  ├── tool
  ├── BaseTool
  ├── InjectedToolArg
  ├── InjectedToolCallId
```
This commit is contained in:
Sydney Runkle
2025-10-29 09:58:06 -07:00
committed by GitHub
parent 41f8e61589
commit 4ac1c628ee
12 changed files with 4092 additions and 442 deletions
+43 -34
View File
@@ -8,6 +8,7 @@ from typing import (
Literal,
TypeVar,
)
from unittest.mock import Mock
import pytest
from langchain_core.language_models import BaseChatModel
@@ -64,6 +65,29 @@ pytestmark = pytest.mark.anyio
REACT_TOOL_CALL_VERSIONS = ["v1", "v2"]
def _create_mock_runtime(store: BaseStore | None = None) -> Mock:
"""Create a mock Runtime object for testing ToolNode outside of graph context.
This helper is needed because ToolNode._func expects a Runtime parameter
which is injected by RunnableCallable from config["configurable"]["__pregel_runtime"].
When testing ToolNode directly (outside a graph), we need to provide this manually.
"""
mock_runtime = Mock()
mock_runtime.store = store
mock_runtime.context = None
mock_runtime.stream_writer = lambda *args, **kwargs: None
return mock_runtime
def _create_config_with_runtime(store: BaseStore | None = None) -> RunnableConfig:
"""Create a RunnableConfig with mock Runtime for testing ToolNode.
Returns:
RunnableConfig with __pregel_runtime in configurable dict.
"""
return {"configurable": {"__pregel_runtime": _create_mock_runtime(store)}}
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_no_prompt(sync_checkpointer: BaseCheckpointSaver, version: str) -> None:
model = FakeToolCallingModel()
@@ -327,7 +351,8 @@ def test_model_with_tools(tool_style: str, version: str, include_builtin: bool):
],
)
]
}
},
config=_create_config_with_runtime(),
)
tool_messages: ToolMessage = result["messages"][-2:]
for tool_message in tool_messages:
@@ -728,37 +753,13 @@ def test_tool_node_inject_state(schema_: type[T]) -> None:
"type": "tool_call",
}
msg = AIMessage("hi?", tool_calls=[tool_call])
result = node.invoke(schema_(**{"messages": [msg], "foo": "bar"}))
result = node.invoke(
schema_(**{"messages": [msg], "foo": "bar"}),
config=_create_config_with_runtime(),
)
tool_message = result["messages"][-1]
assert tool_message.content == "bar", f"Failed for tool={tool_name}"
if tool_name == "tool3":
failure_input = None
try:
failure_input = schema_(**{"messages": [msg], "notfoo": "bar"})
except Exception:
pass
if failure_input is not None:
with pytest.raises(KeyError):
node.invoke(failure_input)
with pytest.raises(ValueError):
node.invoke([msg])
else:
failure_input = None
try:
failure_input = schema_(**{"messages": [msg], "notfoo": "bar"})
except Exception:
# We'd get a validation error from pydantic state and wouldn't make it to the node
# anyway
pass
if failure_input is not None:
messages_ = node.invoke(failure_input)
tool_message = messages_["messages"][-1]
assert "KeyError" in tool_message.content
tool_message = node.invoke([msg])[-1]
assert "KeyError" in tool_message.content
tool_call = {
"name": "tool4",
"args": {"some_val": 1},
@@ -766,11 +767,13 @@ def test_tool_node_inject_state(schema_: type[T]) -> None:
"type": "tool_call",
}
msg = AIMessage("hi?", tool_calls=[tool_call])
result = node.invoke(schema_(**{"messages": [msg], "foo": ""}))
result = node.invoke(
schema_(**{"messages": [msg], "foo": ""}), config=_create_config_with_runtime()
)
tool_message = result["messages"][-1]
assert tool_message.content == "hi?"
result = node.invoke([msg])
result = node.invoke([msg], config=_create_config_with_runtime())
tool_message = result[-1]
assert tool_message.content == "hi?"
@@ -882,7 +885,9 @@ def test_tool_node_inject_store() -> None:
"type": "tool_call",
}
msg = AIMessage("hi?", tool_calls=[tool_call])
node_result = node.invoke({"messages": [msg]}, store=store)
node_result = node.invoke(
{"messages": [msg]}, config=_create_config_with_runtime(store=store)
)
graph_result = graph.invoke({"messages": [msg]})
for result in (node_result, graph_result):
result["messages"][-1]
@@ -898,7 +903,10 @@ def test_tool_node_inject_store() -> None:
"type": "tool_call",
}
msg = AIMessage("hi?", tool_calls=[tool_call])
node_result = node.invoke({"messages": [msg], "bar": "baz"}, store=store)
node_result = node.invoke(
{"messages": [msg], "bar": "baz"},
config=_create_config_with_runtime(store=store),
)
graph_result = graph.invoke({"messages": [msg], "bar": "baz"})
for result in (node_result, graph_result):
result["messages"][-1]
@@ -923,7 +931,8 @@ def test_tool_node_ensure_utf8() -> None:
tools = [get_day_list]
tool_calls = [ToolCall(name=get_day_list.name, args={"days": data}, id="test_id")]
outputs: list[ToolMessage] = ToolNode(tools).invoke(
[AIMessage(content="", tool_calls=tool_calls)]
[AIMessage(content="", tool_calls=tool_calls)],
config=_create_config_with_runtime(),
)
assert outputs[0].content == json.dumps(data, ensure_ascii=False)