perf(langchain): stop inlining agent state into tool-dispatch Sends#36960
Merged
Conversation
In create_agent's model_to_tools edge, dispatch each tool call via the
bare list form `Send("tools", [tool_call])` instead of wrapping it in
ToolCallWithContext(state=state, ...). The tool node now hydrates
ToolRuntime.state from graph channels at tool-execution time (see
langchain-ai/langgraph#7594), so inlining the full state dict into
every Send is no longer needed.
This eliminates an O(N^2) storage term on __pregel_tasks checkpoint
writes: previously each turn's Sends carried a serialized snapshot of
the entire messages list at dispatch time. For a 500-turn agent run,
this drops __pregel_tasks storage from ~815 MB to ~482 KB (1,691x
reduction). See benchmark in PR description.
Back-compat: the legacy ToolCallWithContext input shape is still
accepted by ToolNode for any external dispatcher that hasn't migrated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Temporary [tool.uv.sources] overrides so CI exercises this PR together with langchain-ai/langgraph#7594 (which ships the ToolNode state_keys hydration path this PR relies on). Revert after the langgraph release that contains #7594 lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Merging this PR will not alter performance
Comparing Footnotes
|
Sends
Paired langgraph PR no longer takes state_keys — ToolNode now reads the full channel state via CONFIG_KEY_READ internally. Revert create_agent's factory to its original state schema resolution location and drop the state_keys argument. Lockfile updated to the latest commit on sr/tool-call-no-state-inline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a53c614 to
93177e8
Compare
Picks up the simplified inline ToolNode read (no longer uses a helper, drops the unused ChannelRead import). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
93177e8 to
c520e31
Compare
Merged
3 tasks
ccurme (ccurme)
previously approved these changes
Apr 23, 2026
Sydney Runkle (sydney-runkle)
added a commit
to langchain-ai/langgraph
that referenced
this pull request
Apr 27, 2026
#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>
Merges origin/master to pick up langchain-core 1.3.2 (required by langgraph-prebuilt 1.0.12), removes temporary git source pins for langgraph packages (fix is now in the published 1.1.10 release), and updates the langgraph lower bound to >=1.1.10. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Eugene Yurtsev (eyurtsev)
approved these changes
Apr 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stop inlining the full agent state into every tool-dispatch
Sendincreate_agent. Dispatch with the bare list formSend("tools", [tool_call])and letToolNodehydrateToolRuntime.statefrom graph channels at tool-execution time.Depends on langchain-ai/langgraph#7594 which teaches
ToolNodeto read channel state viaCONFIG_KEY_READwhen given a bare tool-call list.uv.lockpins that branch for CI while the langgraph PR is in flight — this pin will be reverted to a publishedlanggraphversion before merge.What was happening
Before this change, every pending tool call produced a
Sendwhose payload was:For any agent that runs many turns,
state["messages"]grows linearly with the conversation. Every super-step that dispatches tools serializes that whole list into everySend, and those Sends live forever in the checkpointer's__pregel_taskswrites. The result is O(N²)__pregel_tasksstorage across a run.What changed
libs/langchain_v1/langchain/agents/factory.py:_make_model_to_tools_edgenow returnsSend("tools", [tool_call])— no inlined state.ToolCallWithContextimport.libs/langchain_v1/pyproject.toml+libs/langchain_v1/uv.lock:[tool.uv.sources]pin onlanggraph,langgraph-prebuilt,langgraph-checkpointto the companion PR branch so CI exercises both changes end-to-end. Revert after langgraph release.Why it's safe
Sendis emitted at the end of the model super-step and consumed at the start of the tools super-step; channels by that point reflect every write from the model super-step (including the new AIMessage). Parallel tool tasks all see the same values since sibling writes don't land until end-of-super-step.ToolCallWithContextinput path is preserved inToolNode— no-op for any external caller still constructing it by hand.Test plan
tests/unit_tests/agents/— 738 passed, 2 skipped, 1 xfailedruff check ./ruff format .— cleanmypy langchain/agents/factory.py— cleanBenchmark
Script runs
create_agentwith a mockGenericFakeChatModeland two tools (write_file,edit_file). Each of the N turns dispatches 2 tool calls. After the run, theInMemorySaveris inspected for bytes stored under__pregel_tasks— the channel that carries the tool-dispatchSendpayloads.Growth shape:
messageslength (full state is inlined), so total TASKS across N turns = Σ(2 × k) for k=1..N ≈ O(N²).tool_calldict. Total TASKS is O(#dispatches), completely independent of conversation length. In this bench with ~2 dispatches/turn: 940–964 bytes per turn across N=5..500, essentially flat.An agent that makes 100 tool calls in a single turn pays the same TASKS cost as one that makes 100 across 50 turns — which is the semantically correct behavior.
Note: the
messageschannel is unchanged by this PR — it's still the dominant storage term (growing O(N²) viaadd_messages). TASKS was a second, compounding cost sitting on top of it; at N=100 it added 40% on top ofmessages, at N=500 it added 67%. After the fix, TASKS is a rounding error regardless of N.