hi @Idrees , it’s an architectural split between the two stacks today - not a bug in your code. In Python the same ToolNode does double duty (low-level graph node and the agent’s tool runner), so it injects state. In JS those two roles live in two different ToolNode classes - the one in @langchain/langgraph/prebuilt is intentionally minimal, and the one used by createAgent is what actually populates ToolRuntime.
-
Is the difference intentional? Yes, today. Python’s
ToolNodecalls_inject_tool_args()which fillsstate/store/runtime fromtool_runtime(source). Python’screate_agentjust reuses that sameToolNode(source -from langgraph.prebuilt.tool_node import ToolNode). In JS, theToolNodeyou imported from@langchain/langgraph/prebuiltonly forwardsRunnableConfigtotool.invoke- it never builds aToolRuntime(source). Note that there’s noInjectedState/InjectedStorein JS at all - that whole annotation surface doesn’t exist there. -
What actually populates
ToolRuntime.statein JS - it’s a differentToolNode.createAgentin thelangchainpackage uses its ownToolNode(langchain/src/agents/nodes/ToolNode.ts):
const output = await invokableTool.invoke(
{ ...toolCall, type: "tool_call" },
{
...config,
config,
toolCallId: toolCall.id!,
state: config.configurable?.__pregel_scratchpad?.currentTaskInput,
signal: mergeAbortSignals(this.signal, config.signal),
}
);
There’s an end-to-end test in langchain/src/agents/tests/tools.test.ts that asserts runtime.state, runtime.context, runtime.toolCallId, runtime.config, runtime.writer, runtime.store all work when you use createAgent. So the docs page is right about createAgent - but the prebuilt ToolNode doesn’t go through that path.
- Three patterns that actually work in JS today:
A - preferred - use createAgent:
import { createAgent } from "langchain";
import { tool, type ToolRuntime } from "@langchain/core/tools";
const greet = tool(
async ({ name }, runtime: ToolRuntime<typeof stateSchema>) => {
return `Hello ${name}! User: ${runtime.state.userId ?? "unknown"}`;
},
{ name: "greet", description: "...", schema: z.object({ name: z.string() }) }
);
const agent = createAgent({ model, tools: [greet], stateSchema });
B - if you must keep a bare StateGraph + prebuilt ToolNode - don’t try to make the prebuilt node inject. Read the current node’s input from inside the tool using the official helper getCurrentTaskInput exported from @langchain/langgraph (source):
import { getCurrentTaskInput, type LangGraphRunnableConfig } from "@langchain/langgraph";
const greet = tool(
async ({ name }, config: LangGraphRunnableConfig) => {
const state = getCurrentTaskInput<MyState>(config);
return `Hello ${name}! User: ${state.userId ?? "unknown"}`;
},
{ /* ... */ }
);
That’s the same source the agent ToolNode reads from - config.configurable.__pregel_scratchpad.currentTaskInput - just exposed via the supported helper.
C - subclass ToolNode and put state + toolCallId + config on the invoke options yourself if you really want the ToolRuntime-shaped second argument under a bare graph. That’s exactly what langchain’s agent ToolNode does, so the pattern is supported.
-
About
tool.invoke(toolCall, { configurable: { state } })- it works in isolation but it’s the least portable choice. Nothing else in the pipeline readsconfigurable.state; the actual scratchpad key isconfigurable.__pregel_scratchpad.currentTaskInput. So you’d have to read it back yourself, andgetCurrentTaskInput(config)won’t see it. Prefer A → B → C → only this as a last resort. -
Is injection planned for the prebuilt JS
ToolNode? I can’t speak for the roadmap, but the direction of travel is clear:createAgentis the high-level path in JS (mirroring Python’screate_agent), and the JS prebuiltToolNodehas stayed deliberately minimal. Related threads landed on the same guidance - see #2178, #1995, #2344. I wouldn’t wait on the prebuiltToolNodeto growstateinjection - use one of the three patterns above.
Hope this helps!