initializeAsyncLocalStorageSingleton() causes streaming data leakage from non-streaming tool invocations #340

Closed
opened 2026-02-15 18:16:02 -05:00 by yindo · 3 comments
Owner

Originally created by @Diluka on GitHub (Aug 15, 2025).

Checked other resources

  • I added a very descriptive title to this issue.
  • I searched the LangGraph.js documentation with the integrated search.
  • I used the GitHub search to find a similar question and didn't find it.
  • I am sure that this is a bug in LangGraph.js rather than my code.
  • The bug is not resolved by updating to the latest stable version of LangGraph (or the specific integration package).

Example Code

/**
 * Bug Reproduction: Non-streaming tool invoke() calls leak streaming data
 * 
 * Root cause: initializeAsyncLocalStorageSingleton() in @langchain/langgraph/dist/index.js:4
 * Triggered by: import { getCurrentTaskInput } from '@langchain/langgraph'
 */

import { StringOutputParser } from 'npm:@langchain/core/output_parsers';
import { RunnableSequence } from 'npm:@langchain/core/runnables';
import { tool } from 'npm:@langchain/core/tools';
import { createReactAgent } from 'npm:@langchain/langgraph/prebuilt';
import { ChatOpenAI } from 'npm:@langchain/openai';
import 'npm:dotenv/config';
import z from 'npm:zod';

// This import triggers the bug by loading langgraph/index.js
import 'npm:@langchain/langgraph';

const llm = new ChatOpenAI({ model: 'gpt-4o-mini' });

const processTool = tool(
  async ({ input }) => {
    console.log("🔧 TOOL: Starting non-streaming invoke() call...");
    
    // This should be completely non-streaming, no output should leak
    const chain = RunnableSequence.from([llm, new StringOutputParser()]);
    const result = await chain.invoke([
      { role: 'user', content: `Convert to uppercase: ${input}` }
    ]);
    
    console.log("✅ TOOL: Non-streaming invoke() completed");
    return `Processed: ${result}`;
  },
  {
    name: 'processTool',
    schema: z.object({ input: z.string() }),
  },
);

const agent = createReactAgent({ llm, tools: [processTool] });

console.log("🚀 Streaming test - watch for data leakage during tool execution");
console.log("=" .repeat(70));

const stream = await agent.stream(
  { messages: 'Use the tool to process "test data"' },
  { streamMode: 'messages' }
);

let chunkNumber = 0;
for await (const [chunk] of stream) {
  if (!chunk.content) continue;
  
  chunkNumber++;
  const chunkType = Object.prototype.toString.call(chunk);
  const preview = chunk.content.substring(0, 50);
  
  console.log(`${chunkNumber.toString().padStart(2, '0')}. ${chunkType}: ${preview}${chunk.content.length > 50 ? '...' : ''}`);
}

console.log("=" .repeat(70));
console.log("🔍 ANALYSIS:");
console.log("- Look for AIMessageChunk entries between 'TOOL: Starting' and 'TOOL: completed'");
console.log("- Those chunks contain data from the internal invoke() call");
console.log("- This should NOT happen - invoke() is non-streaming");
console.log("- Root cause: AsyncLocalStorage initialization breaks stream isolation");

Error Message and Stack Trace (if applicable)

🚀 Streaming test - watch for data leakage during tool execution
======================================================================
🔧 TOOL: Starting non-streaming invoke() call...
01. [object AIMessageChunk]: TEST
02. [object AIMessageChunk]:  DATA
✅ TOOL: Non-streaming invoke() completed
03. [object ToolMessage]: Processed: TEST DATA
04. [object AIMessageChunk]: The
05. [object AIMessageChunk]:  processed
06. [object AIMessageChunk]:  output
07. [object AIMessageChunk]:  is
08. [object AIMessageChunk]: :
09. [object AIMessageChunk]:  **
10. [object AIMessageChunk]: TEST
11. [object AIMessageChunk]:  DATA
12. [object AIMessageChunk]: **
13. [object AIMessageChunk]: .
======================================================================
🔍 ANALYSIS:
- Look for AIMessageChunk entries between 'TOOL: Starting' and 'TOOL: completed'
- Those chunks contain data from the internal invoke() call
- This should NOT happen - invoke() is non-streaming
- Root cause: AsyncLocalStorage initialization breaks stream isolation

Description

When I use the agent to call a tool that internally invokes another large model to describe an image and returns a string, I expect the agent to stream only its own messages. However, in the streaming response, I notice that messages from the internal model (used by the tool) are also mixed in, rather than just the agent’s output.
This is caused by import 'npm:@langchain/langgraph-supervisor' (import any function)

🔗 Complete Problem Link

import '@langchain/langgraph-supervisor'

supervisor → handoff.js → import { getCurrentTaskInput } from '@langchain/langgraph'

@langchain/langgraph/index.js Loading

Line 4: initializeAsyncLocalStorageSingleton(); Static Execution

AsyncLocalStorageProviderSingleton Global Initialization

Asynchronous Context Propagation Mechanism Changes

The streaming process of the non-streaming invoke() call within the tool is captured by the outer layer

Streaming data leaks to the outer agent stream

https://github.com/langchain-ai/langgraphjs/blob/main/libs/langgraph/src/index.ts#L6

System Info

latest

Originally created by @Diluka on GitHub (Aug 15, 2025). ### Checked other resources - [x] I added a very descriptive title to this issue. - [x] I searched the LangGraph.js documentation with the integrated search. - [x] I used the GitHub search to find a similar question and didn't find it. - [x] I am sure that this is a bug in LangGraph.js rather than my code. - [x] The bug is not resolved by updating to the latest stable version of LangGraph (or the specific integration package). ### Example Code ```ts /** * Bug Reproduction: Non-streaming tool invoke() calls leak streaming data * * Root cause: initializeAsyncLocalStorageSingleton() in @langchain/langgraph/dist/index.js:4 * Triggered by: import { getCurrentTaskInput } from '@langchain/langgraph' */ import { StringOutputParser } from 'npm:@langchain/core/output_parsers'; import { RunnableSequence } from 'npm:@langchain/core/runnables'; import { tool } from 'npm:@langchain/core/tools'; import { createReactAgent } from 'npm:@langchain/langgraph/prebuilt'; import { ChatOpenAI } from 'npm:@langchain/openai'; import 'npm:dotenv/config'; import z from 'npm:zod'; // This import triggers the bug by loading langgraph/index.js import 'npm:@langchain/langgraph'; const llm = new ChatOpenAI({ model: 'gpt-4o-mini' }); const processTool = tool( async ({ input }) => { console.log("🔧 TOOL: Starting non-streaming invoke() call..."); // This should be completely non-streaming, no output should leak const chain = RunnableSequence.from([llm, new StringOutputParser()]); const result = await chain.invoke([ { role: 'user', content: `Convert to uppercase: ${input}` } ]); console.log("✅ TOOL: Non-streaming invoke() completed"); return `Processed: ${result}`; }, { name: 'processTool', schema: z.object({ input: z.string() }), }, ); const agent = createReactAgent({ llm, tools: [processTool] }); console.log("🚀 Streaming test - watch for data leakage during tool execution"); console.log("=" .repeat(70)); const stream = await agent.stream( { messages: 'Use the tool to process "test data"' }, { streamMode: 'messages' } ); let chunkNumber = 0; for await (const [chunk] of stream) { if (!chunk.content) continue; chunkNumber++; const chunkType = Object.prototype.toString.call(chunk); const preview = chunk.content.substring(0, 50); console.log(`${chunkNumber.toString().padStart(2, '0')}. ${chunkType}: ${preview}${chunk.content.length > 50 ? '...' : ''}`); } console.log("=" .repeat(70)); console.log("🔍 ANALYSIS:"); console.log("- Look for AIMessageChunk entries between 'TOOL: Starting' and 'TOOL: completed'"); console.log("- Those chunks contain data from the internal invoke() call"); console.log("- This should NOT happen - invoke() is non-streaming"); console.log("- Root cause: AsyncLocalStorage initialization breaks stream isolation"); ``` ### Error Message and Stack Trace (if applicable) ``` 🚀 Streaming test - watch for data leakage during tool execution ====================================================================== 🔧 TOOL: Starting non-streaming invoke() call... 01. [object AIMessageChunk]: TEST 02. [object AIMessageChunk]: DATA ✅ TOOL: Non-streaming invoke() completed 03. [object ToolMessage]: Processed: TEST DATA 04. [object AIMessageChunk]: The 05. [object AIMessageChunk]: processed 06. [object AIMessageChunk]: output 07. [object AIMessageChunk]: is 08. [object AIMessageChunk]: : 09. [object AIMessageChunk]: ** 10. [object AIMessageChunk]: TEST 11. [object AIMessageChunk]: DATA 12. [object AIMessageChunk]: ** 13. [object AIMessageChunk]: . ====================================================================== 🔍 ANALYSIS: - Look for AIMessageChunk entries between 'TOOL: Starting' and 'TOOL: completed' - Those chunks contain data from the internal invoke() call - This should NOT happen - invoke() is non-streaming - Root cause: AsyncLocalStorage initialization breaks stream isolation ``` ### Description When I use the agent to call a tool that internally invokes another large model to describe an image and returns a string, I expect the agent to stream only its own messages. However, in the streaming response, I notice that messages from the internal model (used by the tool) are also mixed in, rather than just the agent’s output. This is caused by `import 'npm:@langchain/langgraph-supervisor'` (import any function) 🔗 Complete Problem Link import '@langchain/langgraph-supervisor' ↓ supervisor → handoff.js → import { getCurrentTaskInput } from '@langchain/langgraph' ↓ @langchain/langgraph/index.js Loading ↓ Line 4: initializeAsyncLocalStorageSingleton(); Static Execution ↓ AsyncLocalStorageProviderSingleton Global Initialization ↓ Asynchronous Context Propagation Mechanism Changes ↓ The streaming process of the non-streaming invoke() call within the tool is captured by the outer layer ↓ Streaming data leaks to the outer agent stream https://github.com/langchain-ai/langgraphjs/blob/main/libs/langgraph/src/index.ts#L6 ### System Info latest
yindo closed this issue 2026-02-15 18:16:02 -05:00
Author
Owner

@dqbd commented on GitHub (Sep 11, 2025):

Hello! The issue here is from the fact that we're auto-streaming LLM calls from any nodes. The reason why you're seeing the behaviour only when importing @langchain/langgraph-supervisor is due to the entrypoint of supervisor package auto-initialising the AsyncLocalStorage used to carry over callbacks for streaming (the same behaviour can be observed if you were to import @langchain/langgraph as well).

You can disable the behaviour by adding withConfig({ tags: ["nostream"] }) inside your inner LLM call

const processTool = tool(
  async ({ input }) => {
    console.log("🔧 TOOL: Starting non-streaming invoke() call...");

    // This should be completely non-streaming, no output should leak
    const chain = RunnableSequence.from([
      llm,
      new StringOutputParser(),
    ]).withConfig({ tags: ["nostream"] });

    const result = await chain.invoke([
      { role: "user", content: `Convert to uppercase: ${input}` },
    ]);
    console.log("✅ TOOL: Non-streaming invoke() completed");
    return `Processed: ${result}`;
  },
  {
    name: "processTool",
    schema: z.object({ input: z.string() }),
  },
);
@dqbd commented on GitHub (Sep 11, 2025): Hello! The issue here is from the fact that we're auto-streaming LLM calls from any nodes. The reason why you're seeing the behaviour only when importing `@langchain/langgraph-supervisor` is due to the entrypoint of supervisor package auto-initialising the AsyncLocalStorage used to carry over callbacks for streaming (the same behaviour can be observed if you were to import `@langchain/langgraph` as well). You can disable the behaviour by adding `withConfig({ tags: ["nostream"] })` inside your inner LLM call ```typescript const processTool = tool( async ({ input }) => { console.log("🔧 TOOL: Starting non-streaming invoke() call..."); // This should be completely non-streaming, no output should leak const chain = RunnableSequence.from([ llm, new StringOutputParser(), ]).withConfig({ tags: ["nostream"] }); const result = await chain.invoke([ { role: "user", content: `Convert to uppercase: ${input}` }, ]); console.log("✅ TOOL: Non-streaming invoke() completed"); return `Processed: ${result}`; }, { name: "processTool", schema: z.object({ input: z.string() }), }, ); ```
Author
Owner

@Diluka commented on GitHub (Sep 11, 2025):

This is absolutely a bug, and the root cause analysis proves it's a fundamental design flaw.

The Real Problem: Broken Streaming Boundaries

The issue reproduction clearly demonstrates that implicit auto-initialization code breaks streaming isolation. When an agent calls a tool, and that tool makes internal model calls (regardless of whether they're streaming or not), these internal calls pollute the outer agent's streaming output.

This is not a feature - this is a severe boundary violation.

The Design Flaw Goes Deeper

Inconsistent Import Behavior

The bug can be triggered by simply importing:

import '@langchain/langgraph';

But here's the critical design problem: according to the documentation, this library's exports are primarily accessed through subpaths, which would NOT trigger the default entry point's initialization code:

import { StateGraph } from '@langchain/langgraph/graph';     // Doesn't trigger bug
import { MemorySaver } from '@langchain/langgraph/checkpoint'; // Doesn't trigger bug

The original issue with @langchain/langgraph-supervisor occurred because it uses the root path default import internally, which accidentally triggers the initialization that breaks streaming isolation.

This means users following the documented patterns are safe, but any internal dependency or accidental root import suddenly breaks their code in mysterious ways.

Why This is Architecturally Wrong

1. Implicit Side Effects from Package Imports

The initializeAsyncLocalStorageSingleton() function runs as a side effect of the default entry point import, fundamentally altering the behavior of core APIs. Imports should declare dependencies, not execute behavior-changing code.

2. Stream Context Contamination

Tool-internal model calls are contaminating the parent stream context. When calling:

// Inside a tool
await model.invoke("analyze this data"); // Should be isolated

This call should be completely isolated from the outer agent's streaming context. The fact that it "leaks" indicates broken encapsulation.

3. Hidden Landmines in the Import System

Users following documented subpath imports work fine, but the moment any dependency (or accidental import) touches the root entry point, the entire application's streaming behavior changes. This creates invisible, hard-to-debug issues.

The "Solution" Proves It's a Bug

The fact that you need { tags: ["nostream"] } everywhere proves this is broken by design. If the default behavior requires constant workarounds, the default is wrong.

The Root Cause is Clear

This isn't about "auto-streaming in nodes" - it's about broken encapsulation where:

  • The default entry point executes hidden initialization code
  • Internal tool calls pollute external streaming contexts
  • AsyncLocalStorage breaks API contracts instead of providing isolation
  • The import system creates invisible behavioral dependencies

A library that breaks when you import its main entry point, despite documenting subpath imports, is fundamentally broken.

This is a textbook example of why implicit global state changes are considered an anti-pattern in software architecture.

@Diluka commented on GitHub (Sep 11, 2025): **This is absolutely a bug, and the root cause analysis proves it's a fundamental design flaw.** ## The Real Problem: Broken Streaming Boundaries The issue reproduction clearly demonstrates that **implicit auto-initialization code breaks streaming isolation**. When an agent calls a tool, and that tool makes internal model calls (regardless of whether they're streaming or not), these internal calls pollute the outer agent's streaming output. This is not a feature - this is a **severe boundary violation**. ## The Design Flaw Goes Deeper ### **Inconsistent Import Behavior** The bug can be triggered by simply importing: ```javascript import '@langchain/langgraph'; ``` But here's the critical design problem: **according to the documentation, this library's exports are primarily accessed through subpaths**, which would NOT trigger the default entry point's initialization code: ```javascript import { StateGraph } from '@langchain/langgraph/graph'; // Doesn't trigger bug import { MemorySaver } from '@langchain/langgraph/checkpoint'; // Doesn't trigger bug ``` The original issue with `@langchain/langgraph-supervisor` occurred because **it uses the root path default import** internally, which accidentally triggers the initialization that breaks streaming isolation. This means users following the documented patterns are safe, but any internal dependency or accidental root import suddenly breaks their code in mysterious ways. ## Why This is Architecturally Wrong ### 1. **Implicit Side Effects from Package Imports** The `initializeAsyncLocalStorageSingleton()` function runs as a side effect of the default entry point import, fundamentally altering the behavior of core APIs. **Imports should declare dependencies, not execute behavior-changing code**. ### 2. **Stream Context Contamination** Tool-internal model calls are **contaminating the parent stream context**. When calling: ```javascript // Inside a tool await model.invoke("analyze this data"); // Should be isolated ``` This call should be **completely isolated** from the outer agent's streaming context. The fact that it "leaks" indicates **broken encapsulation**. ### 3. **Hidden Landmines in the Import System** Users following documented subpath imports work fine, but the moment any dependency (or accidental import) touches the root entry point, **the entire application's streaming behavior changes**. This creates invisible, hard-to-debug issues. ## The "Solution" Proves It's a Bug The fact that you need `{ tags: ["nostream"] }` **everywhere** proves this is broken by design. **If the default behavior requires constant workarounds, the default is wrong.** ## The Root Cause is Clear This isn't about "auto-streaming in nodes" - it's about **broken encapsulation** where: - The default entry point executes hidden initialization code - Internal tool calls pollute external streaming contexts - AsyncLocalStorage breaks API contracts instead of providing isolation - The import system creates invisible behavioral dependencies **A library that breaks when you import its main entry point, despite documenting subpath imports, is fundamentally broken.** This is a textbook example of why implicit global state changes are considered an anti-pattern in software architecture.
Author
Owner

@lzj960515 commented on GitHub (Sep 11, 2025):

If this behavior only happens with @langchain/langgraph-supervisor, why not design such functionality into @langchain/langgraph-supervisor instead of causing this problem whenever @langchain/langgraph is imported? @dqbd

@lzj960515 commented on GitHub (Sep 11, 2025): If this behavior only happens with @langchain/langgraph-supervisor, why not design such functionality into @langchain/langgraph-supervisor instead of causing this problem whenever @langchain/langgraph is imported? @dqbd
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: langchain-ai/langgraphjs#340