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 ToolNode calls _inject_tool_args() which fills state/store/runtime from tool_runtime (source). Python’s create_agent just reuses that same ToolNode (source - from langgraph.prebuilt.tool_node import ToolNode). In JS, the ToolNode you imported from @langchain/langgraph/prebuilt only forwards RunnableConfig to tool.invoke - it never builds a ToolRuntime (source). Note that there’s no InjectedState/InjectedStore in JS at all - that whole annotation surface doesn’t exist there.
-
What actually populates ToolRuntime.state in JS - it’s a different ToolNode. createAgent in the langchain package uses its own ToolNode (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 reads configurable.state; the actual scratchpad key is configurable.__pregel_scratchpad.currentTaskInput. So you’d have to read it back yourself, and getCurrentTaskInput(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: createAgent is the high-level path in JS (mirroring Python’s create_agent), and the JS prebuilt ToolNode has stayed deliberately minimal. Related threads landed on the same guidance - see #2178, #1995, #2344. I wouldn’t wait on the prebuilt ToolNode to grow state injection - use one of the three patterns above.
Hope this helps!