#11647·langchainjs

createToolCallTransformer: a tool that throws crashes the process with an unhandled rejection under streamEvents v3

Author: DemianLiCreated Sep 15, 2026Updated Sep 15, 2026

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.js in 1.5.11 is byte-identical, and main has the same code.)

Example Code

typescript
// 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.0

Description

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) in createToolCallTransformer (tool-call.ts:262): rejects every still-pending call's output when the run fails.
  • createSubagentTransformer (subagent.ts:212): rejects each subagent handle's output. Each handle also carries its own nested createToolCallTransformer, which is not covered by the root transformer's isOwnEvent filter. With deepagents' task tool delegating to a subagent whose tool throws, we measured three unhandled rejections in one run: the subagent's tool call, the root task call, and Subagent <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:

typescript
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

Source: langchain-ai/langchainjs