mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-30 21:45:08 +02:00
fix(prebuilt): hydrate ToolNode state from channels via pregel helpers (#7594)
## Summary When `ToolNode` receives a bare `[tool_call]` list via the Send API (the dispatch shape `create_agent` will use once langchain-ai/langchain#36960 lands), hydrate `ToolRuntime.state` from the current channel values instead of requiring the dispatcher to inline the full agent state dict into every `Send.arg`. Motivation: the paired langchain PR drops the `ToolCallWithContext` wrapper from `create_agent`'s tool dispatch, which eliminates an O(N²) storage term on `__pregel_tasks` checkpoint writes. Without this companion change there would be no path for the tool node to see the graph state. ## What changed - `libs/prebuilt/langgraph/prebuilt/tool_node.py` — `_extract_state` grows a third branch for list-form input. When the input is a list whose last entry is a `ToolCall` dict, read the current channel values via `CONFIG_KEY_READ` and return them as the state dict. The full new logic is four lines inline in `_extract_state`: ```python read = config.get(CONF, {}).get(CONFIG_KEY_READ) if read is None: return {} # Pregel installs CONFIG_KEY_READ as # `functools.partial(local_read, scratchpad, channels, managed, task)`. channels = read.args[1] return cast("dict[str, Any]", read(list(channels), False)) ``` - No changes to the pregel read machinery (`local_read`, `ChannelRead`). - Only channel values are read; managed values have their own injection path (`ToolRuntime.context`, `InjectedContext`) and were never in the pre-fix inlined state dict, so we don't add them here. - Falls back to `{}` when invoked outside a Pregel context (e.g. direct `ToolNode(...).invoke([tool_call])` from a test harness), which preserves existing `ToolNode` direct-invocation test behavior. - `libs/prebuilt/tests/test_on_tool_call.py` — two new tests covering the list-form hydration path (sync + async). They build a `functools.partial` that matches Pregel's real `CONFIG_KEY_READ` shape and assert `ToolRuntime.state` reflects the current channel values. ## Why it's safe - **Same snapshot semantics as before.** `Send` is emitted at end-of-super-step-N; consumed at start-of-super-step-N+1. Channels at that point reflect every write from super-step N (including the new AIMessage the tool calls originated from). Parallel tool tasks in the tools super-step all read the same values since sibling writes don't land until end-of-super-step. - **Legacy `ToolCallWithContext` path preserved.** External dispatchers that still inline state continue to work unchanged — `_extract_state` checks that branch first. ## Test plan - [x] `make test` in `libs/prebuilt` — **204 pass** - [x] Two new hydration tests (sync + async) green - [x] `make format` / `make lint` / `mypy` clean --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
85cd64ed69
commit
f4aee546ad
@@ -1320,6 +1320,98 @@ async def test_state_extraction_with_tool_call_with_context_async() -> None:
|
||||
assert "tool_call" not in state_seen[0]
|
||||
|
||||
|
||||
def _config_with_channel_read(
|
||||
channel_values: dict[str, object],
|
||||
store: BaseStore | None = None,
|
||||
) -> RunnableConfig:
|
||||
"""Build a config that mimics `CONFIG_KEY_READ` as Pregel installs it.
|
||||
|
||||
Pregel always installs a `functools.partial(local_read, scratchpad,
|
||||
channels, managed, task)`, and `ToolNode` introspects that partial to
|
||||
learn channel names. The stub matches the shape: partial whose second and
|
||||
third positional args are `channels` and `managed` mappings.
|
||||
"""
|
||||
import functools
|
||||
|
||||
channels_stub = {k: None for k in channel_values}
|
||||
managed_stub: dict[str, object] = {}
|
||||
|
||||
# Shape matches pregel's real partial:
|
||||
# functools.partial(local_read, scratchpad, channels, managed, task)
|
||||
def _read(scratchpad, channels, managed, task, select, fresh): # noqa: ARG001
|
||||
if isinstance(select, str):
|
||||
return channel_values[select]
|
||||
return {k: channel_values[k] for k in select if k in channel_values}
|
||||
|
||||
read = functools.partial(_read, None, channels_stub, managed_stub, None)
|
||||
cfg = _create_config_with_runtime(store)
|
||||
cfg["configurable"]["__pregel_read"] = read
|
||||
return cfg
|
||||
|
||||
|
||||
def test_list_form_send_hydrates_state_from_channel_read() -> None:
|
||||
"""Send('tools', [tool_call]) with no inlined state should hydrate
|
||||
ToolRuntime.state from CONFIG_KEY_READ (full state read)."""
|
||||
state_seen = []
|
||||
|
||||
def state_inspector_handler(
|
||||
request: ToolCallRequest,
|
||||
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
state_seen.append(request.state)
|
||||
return execute(request)
|
||||
|
||||
channel_values = {
|
||||
"messages": [AIMessage("from channels")],
|
||||
"files": {"/a.md": "body"},
|
||||
}
|
||||
|
||||
tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler)
|
||||
|
||||
tool_call: ToolCall = {
|
||||
"name": "add",
|
||||
"args": {"a": 1, "b": 2},
|
||||
"id": "call_1",
|
||||
"type": "tool_call",
|
||||
}
|
||||
|
||||
tool_node.invoke([tool_call], config=_config_with_channel_read(channel_values))
|
||||
|
||||
assert len(state_seen) == 1
|
||||
got = state_seen[0]
|
||||
assert got == channel_values
|
||||
assert "messages" in got and "files" in got
|
||||
|
||||
|
||||
async def test_list_form_send_hydrates_state_async() -> None:
|
||||
state_seen = []
|
||||
|
||||
def state_inspector_handler(
|
||||
request: ToolCallRequest,
|
||||
execute: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
state_seen.append(request.state)
|
||||
return execute(request)
|
||||
|
||||
channel_values = {"messages": [AIMessage("from channels")], "files": {}}
|
||||
|
||||
tool_node = ToolNode([add], wrap_tool_call=state_inspector_handler)
|
||||
|
||||
tool_call: ToolCall = {
|
||||
"name": "add",
|
||||
"args": {"a": 1, "b": 2},
|
||||
"id": "call_1",
|
||||
"type": "tool_call",
|
||||
}
|
||||
|
||||
await tool_node.ainvoke(
|
||||
[tool_call], config=_config_with_channel_read(channel_values)
|
||||
)
|
||||
|
||||
assert len(state_seen) == 1
|
||||
assert state_seen[0] == channel_values
|
||||
|
||||
|
||||
def test_tool_call_request_is_frozen() -> None:
|
||||
"""Test that ToolCallRequest raises deprecation warnings on direct attribute reassignment."""
|
||||
tool_call: ToolCall = {"name": "add", "args": {"a": 1, "b": 2}, "id": "call_1"}
|
||||
|
||||
Reference in New Issue
Block a user