createToolCallTransformer: a tool that throws crashes the process with an unhandled rejection under streamEvents v3
Checked other resources
- This is a bug, not a usage question. For questions, please use the LangChain Forum (https://forum.langchain.com/).
- I added a very descriptive title to this issue.
- I searched the LangChain.js documentation with the integrated search.
- I used the GitHub search to find a similar question and didn't find it. (Closest is #10933, which covers interrupts only; see below.)
- I am sure that this is a bug in LangChain.js rather than my code.
- The bug is not resolved by updating to the latest stable version of LangChain (or the specific integration package). (Reproduced on
[email protected];dist/agents/transformers/tool-call.jsin1.5.11is byte-identical, andmainhas the same code.)
Example Code
// repro.mjs — `node repro.mjs` or `node repro.mjs handled`
import {
AIMessage,
createAgent,
createMiddleware,
fakeModel,
tool,
ToolMessage,
} from "langchain";
import { z } from "zod";
const flaky = tool(
async () => {
throw new Error("Service temporarily overloaded");
},
{ name: "flaky", description: "Always throws.", schema: z.object({}) },
);
// Optional: convert the tool error into an error ToolMessage. Does not help.
const handleToolErrors = createMiddleware({
name: "HandleToolErrors",
wrapToolCall: async (request, handler) => {
try {
return await handler(request);
} catch (error) {
return new ToolMessage({
content: `Tool failed: ${error.message}`,
tool_call_id: request.toolCall.id,
status: "error",
});
}
},
});
const model = fakeModel()
.respondWithTools([{ name: "flaky", args: {} }])
.respond(new AIMessage("The tool failed, sorry."));
const agent = createAgent({
model,
tools: [flaky],
middleware: process.argv[2] === "handled" ? [handleToolErrors] : [],
});
const run = await agent.streamEvents(
{ messages: [{ role: "user", content: "call the tool" }] },
{ version: "v3" },
);
// Consume the raw protocol events only; we never read run.toolCalls.
for await (const event of run) {
if (event.method === "tools") console.log("tools:", event.params.data.event);
}
console.log("stream finished normally");Error Message and Stack Trace (if applicable)
Same output with and without the handled argument; exit code 1:
tools: tool-started
tools: tool-error
stream finished normally
file:///…/node_modules/langchain/dist/agents/transformers/tool-call.js:152
pending.rejectOutput(new Error(message));
^
Error: Service temporarily overloaded
at Object.process (file:///…/node_modules/langchain/dist/agents/transformers/tool-call.js:152:29)
at StreamMux.push (file:///…/node_modules/@langchain/langgraph/dist/stream/mux.js:173:66)
at pump (file:///…/node_modules/@langchain/langgraph/dist/stream/mux.js:321:36)
Node.js v25.9.0Description
What happens: when a tool's body throws a regular error (not an interrupt) and the run is consumed with streamEvents({ version: "v3" }), the process exits with an unhandled rejection. The run itself recovers: the model receives the tool error and the stream finishes normally. Then Node kills the process, because createToolCallTransformer rejected the per-call output promise (libs/langchain/src/agents/transformers/tool-call.ts:235 on main), and nothing is required to await it.
Expected: a tool error surfaces through run.toolCalls[i].output / .status / .error for consumers that read them. It should not become a process-level unhandled rejection for consumers that don't.
Handling the error in wrapToolCall does not help (the handled variant above). The tool-error event is emitted by the tool's own run manager inside StructuredTool.call in @langchain/core (handleToolError(e) and then rethrow, dist/tools/index.js:141-143 in 1.2.9). That happens before any middleware's catch runs, so the promise is rejected even though the run recovers.
Relation to #10933 / #11087: #11087 made any tool interrupt keep the call pending, which fixed the case #10933 reported. Genuine tool errors still reach pending.rejectOutput(...), and so do these sites with the same pattern:
fail(err)increateToolCallTransformer(tool-call.ts:262): rejects every still-pending call'soutputwhen the run fails.createSubagentTransformer(subagent.ts:212): rejects each subagent handle'soutput. Each handle also carries its own nestedcreateToolCallTransformer, which is not covered by the root transformer'sisOwnEventfilter. With deepagents'tasktool delegating to a subagent whose tool throws, we measured three unhandled rejections in one run: the subagent's tool call, the roottaskcall, andSubagent <name> failed.
Impact: any long-lived server that consumes v3 runs (for example a web backend streaming streamEvents to a browser) is taken down, with every session on it, by a single tool error. We hit it in production-like testing when a delegated subagent's model call got a provider overload error. agent.stream(…, { streamMode: ["updates", "values"] }) does not build these projections and is not affected.
Suggested fix: mark the projection promises as handled when they are created, for example output.catch(() => {}) right after new Promise(...) in both transformers. Consumers that await call.output still observe the rejection. Only the process-level unhandled-rejection report goes away.
Workaround we use today: before reading the stream, drain the projections and attach no-op handlers:
const ignore = () => {};
const markToolCalls = (calls) =>
void (async () => {
for await (const call of calls) call.output.catch(ignore);
})().catch(ignore);
const markSubagents = (subagents) =>
void (async () => {
for await (const sub of subagents) {
sub.output.catch(ignore);
markToolCalls(sub.toolCalls);
markSubagents(sub.subagents);
}
})().catch(ignore);
const run = await agent.streamEvents(input, { version: "v3" });
markToolCalls(run.toolCalls);
markSubagents(run.subagents);
for await (const event of run) { /* … */ }On the repro above this keeps the process alive. We also verified it in a real server process and with delegated subagents. #10933 argues that attaching handlers from userland can lose the race in some cases. We have not observed that for thrown tools, but a fix in the transformers would not depend on timing.
System Info
[email protected](also checked1.5.11: identical transformer code),@langchain/[email protected],@langchain/[email protected],[email protected](subagent case only)- macOS 26.6.2 (arm64)
- Node v25.9.0
- pnpm 10.34.5
Source: langchain-ai/langchainjs