Feature request: Support features requiring async local storage (interrupt, functional API, etc) in web environment #177

Open
opened 2026-02-15 17:16:46 -05:00 by yindo · 11 comments
Owner

Originally created by @rothnic on GitHub (Feb 19, 2025).

I am building a graph that is focused on iterative extraction of unstructured data (extraction agent), then when ready, I wanted to transfer to my cms agent to get the content into my CMS. I was building this out so it could run in a web environment and the combination of using the web functionality and the newer functional API caused me to run into issues.

Context

  • langgraph = 0.2.45(@langchain/core@0.3.39)

Summary

I followed this multi-agent functional API example, but used the task function exported from web like this cannot be used in the web environment that i can tell:

Task

import { type BaseMessageLike } from "@langchain/core/messages"
import { task } from "@langchain/langgraph/web"

import { cmsAgent, extractionAgent } from "~agents/discountAgents"

export const callExtractionAgent = task(
  "callExtractionAgent",
  async (messages: BaseMessageLike[]) => {
    const response = await extractionAgent.invoke({ messages })
    return response.messages
  }
)

Agent

import { createReactAgent } from "@langchain/langgraph/prebuilt"
import { ChatOpenAI } from "@langchain/openai"
...
export const extractionAgent = createReactAgent({
  llm: new ChatOpenAI({
    temperature: 0,
    streaming: true,
    openAIApiKey: process.env.PLASMO_PUBLIC_OPENAI_KEY,
    model: "gpt-4o-mini"
  }),
  tools: [extractOfferTool, transferToCmsAgent],
  stateModifier: extractionAgentSystemPrompt
})

Error

However, when i got to the point of calling extractionAgent.invoke within my test within the jsdom environment using jest, I ran into an error that traced back to this code within pregel/call.cjs

function call({ func, name, retry }, ...args) {
    const config = singletons_1.AsyncLocalStorageProviderSingleton.getRunnableConfig();       // this will be undefined
    if (typeof config.configurable?.[constants_js_1.CONFIG_KEY_CALL] === "function") {
        return config.configurable[constants_js_1.CONFIG_KEY_CALL](func, name, args, {
            retry,
            callbacks: config.callbacks,
        });
    }
    throw new Error("Async local storage not initialized. Please call initializeAsyncLocalStorageSingleton() before using this function.");
}

The config returned from getRunnableConfig() was undefined, so the if statement blew up at config.configurable since config was undefined. TypeError: Cannot read properties of undefined (reading 'configurable')

Workaround

To fix this issue, I knew I needed to pass the config through as defined here. However, the task() api doesn't allow passing through the config
from what I can tell. So, I had to move away from using a task() and instead just used a function instead with the signature callExtractionAgent(messages, config). Then I updated my function to transfer to the agent like this:

Original

export const callExtractionAgent = task(
  "callExtractionAgent",
  async (messages: BaseMessageLike[]) => {
    const response = await extractionAgent.invoke({ messages })
    return response.messages
  }
)

Updated


// No longer a task
export async function callExtractionAgent(
  messages: BaseMessageLike[],
  config?: RunnableConfig
) {
  const response = await extractionAgent.invoke({ messages }, config)
  return response.messages
}

export async function callActiveAgent(
  state: typeof DiscountFlowState.State,
  config?: RunnableConfig
) {
  const messages = state.messages
  let agentMessages

  if (state.activeAgent === "cms") {
    // Pass `config` to callCMSAgent
    agentMessages = await callCMSAgent(messages, config)              // Cant pass config into a task 
  } else {
    // Default to extraction agent
    agentMessages = await callExtractionAgent(messages, config)     // Cant pass config into a task
  }

  return {
    messages: agentMessages
  }
}

Also Tried (Still Fails)

I did circle back and try passing through the config in the task, but ended up with the same error

export const callExtractionAgent = task(
  "callExtractionAgent",
  async (messages: BaseMessageLike[], config: RunnableConfig) => {
    const response = await extractionAgent.invoke({ messages }, config)     // this still fails
    return response.messages
  }
)

Issue:

  1. The task() api doesn't allow passing through the config (I tried passing it through and still ended up with the same error)
  2. There really should be a more clear warning or error when you are running in a web environment anytime something is called without passing in config, rather than just trying to read from async storage and triggering an obscure error
Originally created by @rothnic on GitHub (Feb 19, 2025). I am building a graph that is focused on iterative extraction of unstructured data (extraction agent), then when ready, I wanted to transfer to my cms agent to get the content into my CMS. I was building this out so it could run in a web environment and the combination of using the web functionality and the newer functional API caused me to run into issues. ## Context * langgraph = 0.2.45(@langchain/core@0.3.39) ## Summary I followed [this multi-agent functional API example](https://langchain-ai.github.io/langgraphjs/how-tos/multi-agent-multi-turn-convo-functional/#setup), but used the task function exported from web like this cannot be used in the web environment that i can tell: ### Task ```js import { type BaseMessageLike } from "@langchain/core/messages" import { task } from "@langchain/langgraph/web" import { cmsAgent, extractionAgent } from "~agents/discountAgents" export const callExtractionAgent = task( "callExtractionAgent", async (messages: BaseMessageLike[]) => { const response = await extractionAgent.invoke({ messages }) return response.messages } ) ``` ### Agent ```js import { createReactAgent } from "@langchain/langgraph/prebuilt" import { ChatOpenAI } from "@langchain/openai" ... export const extractionAgent = createReactAgent({ llm: new ChatOpenAI({ temperature: 0, streaming: true, openAIApiKey: process.env.PLASMO_PUBLIC_OPENAI_KEY, model: "gpt-4o-mini" }), tools: [extractOfferTool, transferToCmsAgent], stateModifier: extractionAgentSystemPrompt }) ``` ### Error However, when i got to the point of calling extractionAgent.invoke within my test within the jsdom environment using jest, I ran into an error that traced back to this code within `pregel/call.cjs` ```js function call({ func, name, retry }, ...args) { const config = singletons_1.AsyncLocalStorageProviderSingleton.getRunnableConfig(); // this will be undefined if (typeof config.configurable?.[constants_js_1.CONFIG_KEY_CALL] === "function") { return config.configurable[constants_js_1.CONFIG_KEY_CALL](func, name, args, { retry, callbacks: config.callbacks, }); } throw new Error("Async local storage not initialized. Please call initializeAsyncLocalStorageSingleton() before using this function."); } ``` The config returned from `getRunnableConfig()` was undefined, so the if statement blew up at `config.configurable` since config was undefined. `TypeError: Cannot read properties of undefined (reading 'configurable')` ## Workaround To fix this issue, I knew I needed to pass the config through as defined [here](https://langchain-ai.github.io/langgraphjs/how-tos/use-in-web-environments/). However, the `task()` api doesn't allow passing through the config from what I can tell. So, I had to move away from using a `task()` and instead just used a function instead with the signature `callExtractionAgent(messages, config)`. Then I updated my function to transfer to the agent like this: Original ```js export const callExtractionAgent = task( "callExtractionAgent", async (messages: BaseMessageLike[]) => { const response = await extractionAgent.invoke({ messages }) return response.messages } ) ``` Updated ```js // No longer a task export async function callExtractionAgent( messages: BaseMessageLike[], config?: RunnableConfig ) { const response = await extractionAgent.invoke({ messages }, config) return response.messages } export async function callActiveAgent( state: typeof DiscountFlowState.State, config?: RunnableConfig ) { const messages = state.messages let agentMessages if (state.activeAgent === "cms") { // Pass `config` to callCMSAgent agentMessages = await callCMSAgent(messages, config) // Cant pass config into a task } else { // Default to extraction agent agentMessages = await callExtractionAgent(messages, config) // Cant pass config into a task } return { messages: agentMessages } } ``` ## Also Tried (Still Fails) I did circle back and try passing through the config in the task, but ended up with the same error ```js export const callExtractionAgent = task( "callExtractionAgent", async (messages: BaseMessageLike[], config: RunnableConfig) => { const response = await extractionAgent.invoke({ messages }, config) // this still fails return response.messages } ) ``` ## Issue: 1. The `task()` api doesn't allow passing through the config (I tried passing it through and still ended up with the same error) 2. There really should be a more clear warning or error when you are running in a web environment anytime something is called without passing in config, rather than just trying to read from async storage and triggering an obscure error
yindo added the enhancement label 2026-02-15 17:16:46 -05:00
Author
Owner

@vadimmelnicuk commented on GitHub (Mar 2, 2025):

I have bumped into the same issue while experimenting with using Functional API in web environment.

@rothnic do you know whether there are any downsides of not using task() wrapper?

Any updates on this issue?

@vadimmelnicuk commented on GitHub (Mar 2, 2025): I have bumped into the same issue while experimenting with using Functional API in web environment. @rothnic do you know whether there are any downsides of not using task() wrapper? Any updates on this issue?
Author
Owner

@rothnic commented on GitHub (Mar 2, 2025):

@rothnic do you know whether there are any downsides of not using task() wrapper?

No idea. It feels like the whole langchain API is rather hard to follow and there are so few examples of langchainjs already, then of the functional API there are fewer, then using it in a web environment are even fewer, so we are in a narrow use case.

@rothnic commented on GitHub (Mar 2, 2025): > [@rothnic](https://github.com/rothnic) do you know whether there are any downsides of not using task() wrapper? No idea. It feels like the whole langchain API is rather hard to follow and there are so few examples of langchainjs already, then of the functional API there are fewer, then using it in a web environment are even fewer, so we are in a narrow use case.
Author
Owner

@benjamincburns commented on GitHub (Mar 3, 2025):

I'm in the process of releasing it (should go out within an hour), but #933 should address this for you. I'll also aim to improve the docs around this particular case (will add a howto).

Preemptive link to the API reference for getConfig: https://langchain-ai.github.io/langgraphjs/reference/functions/langgraph.getConfig.html

@benjamincburns commented on GitHub (Mar 3, 2025): I'm in the process of releasing it (should go out within an hour), but #933 should address this for you. I'll also aim to improve the docs around this particular case (will add a howto). Preemptive link to the API reference for `getConfig`: https://langchain-ai.github.io/langgraphjs/reference/functions/langgraph.getConfig.html
Author
Owner

@benjamincburns commented on GitHub (Mar 3, 2025):

Ahh, my apologies - I missed that this was specific to the web environment. Unfortunately unless you're shimming async_hooks, I don't expect functional API to work on web at the moment. getConfig and even basic task execution rely on async local storage. Sorry for the back-and-forth, I'll leave this one open!

@benjamincburns commented on GitHub (Mar 3, 2025): Ahh, my apologies - I missed that this was specific to the web environment. Unfortunately unless you're shimming `async_hooks`, I don't expect functional API to work on web at the moment. `getConfig` and even basic task execution rely on async local storage. Sorry for the back-and-forth, I'll leave this one open!
Author
Owner

@rothnic commented on GitHub (Mar 10, 2025):

@benjamincburns I was unable to find an actively maintained shim for async_hooks. For context, I was using langgraph within a chrome extension, where having an always running external server seemed like overkill. Having a hard dependency on async hooks feels like it will make development for the web forever difficult

@rothnic commented on GitHub (Mar 10, 2025): @benjamincburns I was unable to find an actively maintained shim for async_hooks. For context, I was using langgraph within a chrome extension, where having an always running external server seemed like overkill. Having a hard dependency on async hooks feels like it will make development for the web forever difficult
Author
Owner

@benjamincburns commented on GitHub (Mar 13, 2025):

@rothnic Just a heads up that we are actively looking into how we can better support environments that don't have ALS. Unfortunately I don't have any conclusive details to share just yet, as we're still in the exploratory phase.

cc @littledivy

@benjamincburns commented on GitHub (Mar 13, 2025): @rothnic Just a heads up that we are actively looking into how we can better support environments that don't have ALS. Unfortunately I don't have any conclusive details to share just yet, as we're still in the exploratory phase. cc @littledivy
Author
Owner

@tyadav7 commented on GitHub (Mar 19, 2025):

Any updates on this?

I am trying to call the interrupt function from langgraph/web but seems like it is not exported from the @langchain/langgraph/web lib. I can use the NodeInterrupt as an alternative but would like to build around the new Human in the loop concept guide.

https://langchain-ai.github.io/langgraphjs/concepts/human_in_the_loop/

@tyadav7 commented on GitHub (Mar 19, 2025): Any updates on this? I am trying to call the interrupt function from langgraph/web but seems like it is not exported from the @langchain/langgraph/web lib. I can use the NodeInterrupt as an alternative but would like to build around the new Human in the loop concept guide. https://langchain-ai.github.io/langgraphjs/concepts/human_in_the_loop/
Author
Owner

@tyadav7 commented on GitHub (Mar 24, 2025):

@rothnic @benjamincburns @jacoblee93

Polyfill for AsyncLocalStorage in the Browser (for LangGraph/Web)

Overview

For those using @langchain/langgraph/web and running langgraph inside the browser, I’ve implemented a polyfill for AsyncLocalStorage. This solution replaces node:async_hooks with a custom implementation that maintains context using a Map() instance in the browser.

Key Details

  • Angular v19: Switched to a custom Webpack configuration to make this work.
  • Polyfilling Node.js Modules: Used node-polyfill-webpack-plugin to handle externalized browser-incompatible libraries (e.g., process, buffer).
  • Interrupt Functionality: Successfully tested and working with this approach.

Webpack Configuration // webpack.config.js

const path = require('path');
const NodePolyfillPlugin = require('node-polyfill-webpack-plugin');
const webpack = require('webpack');
const AsyncHooksPolyfillPlugin = require('./AsyncHooksPolyfillPlugin');

module.exports = {
    resolve: {
        fallback: {
            process: require.resolve("process/browser"),
            buffer: require.resolve("buffer/"),
        },
    },
    plugins: [
        new NodePolyfillPlugin({
            additionalAliases: ['process'],
        }),
        new AsyncHooksPolyfillPlugin(),
        new webpack.ProvidePlugin({
            process: 'process/browser',
            Buffer: ['buffer', 'Buffer'],
        }),
    ],
};

Custom Webpack Plugin for AsyncLocalStorage // AsyncHooksPolyfillPlugin.js

This plugin intercepts module resolution and replaces node:async_hooks with our polyfill.

class AsyncHooksPolyfillPlugin {
    apply(compiler) {
      compiler.hooks.normalModuleFactory.tap("AsyncHooksPolyfillPlugin", (factory) => {
        factory.hooks.beforeResolve.tap("AsyncHooksPolyfillPlugin", (data) => {
          if (!data) return;
          
          // Replace async_hooks with our polyfill
          if (data.request === "node:async_hooks" || data.request === "async_hooks") {
            data.request = require.resolve("./async-local-storage.js");
          }
        });
      });
    }
}

module.exports = AsyncHooksPolyfillPlugin;

AsyncLocalStorage Polyfill async-local-storage.js

This implementation provides a basic AsyncLocalStorage alternative that maintains execution context in the browser.

class AsyncLocalStorage {
	constructor() {
		this.storeMap = new Map();
	}

	run = async (store, callback, ...args) => {
		const executionContext = Symbol(); // Unique key for each execution context
		this.storeMap.set(executionContext, store);

		try {
			return await callback(...args);
		} finally {
			this.storeMap.delete(executionContext);
		}
	}

	getStore = () => {
		for (const value of this.storeMap.values()) {
			return value; // Return the most recent context
		}
		return undefined;
	}

	enterWith = (store) => {
		const executionContext = Symbol();
		this.storeMap.set(executionContext, store);
	}
}

module.exports = {
	AsyncLocalStorage,
};

Considerations

  • This approach is designed only for the current application lifecycle and is not persistent across sessions.
  • Using a persistent storage solution like localforage or Dexie might be overkill unless offline storage is needed.

This is an early-stage implementation, and while I’ve tested interrupt functionality, other features may need validation. Feel free to experiment and improve upon this! 🚀


@tyadav7 commented on GitHub (Mar 24, 2025): @rothnic @benjamincburns @jacoblee93 ## Polyfill for `AsyncLocalStorage` in the Browser (for LangGraph/Web) ### Overview For those using `@langchain/langgraph/web` and running `langgraph` inside the browser, I’ve implemented a polyfill for `AsyncLocalStorage`. This solution replaces `node:async_hooks` with a custom implementation that maintains context using a `Map()` instance in the browser. ### Key Details - **Angular v19**: Switched to a custom Webpack configuration to make this work. - **Polyfilling Node.js Modules**: Used `node-polyfill-webpack-plugin` to handle externalized browser-incompatible libraries (e.g., `process`, `buffer`). - **Interrupt Functionality**: Successfully tested and working with this approach. ### Webpack Configuration // webpack.config.js ```js const path = require('path'); const NodePolyfillPlugin = require('node-polyfill-webpack-plugin'); const webpack = require('webpack'); const AsyncHooksPolyfillPlugin = require('./AsyncHooksPolyfillPlugin'); module.exports = { resolve: { fallback: { process: require.resolve("process/browser"), buffer: require.resolve("buffer/"), }, }, plugins: [ new NodePolyfillPlugin({ additionalAliases: ['process'], }), new AsyncHooksPolyfillPlugin(), new webpack.ProvidePlugin({ process: 'process/browser', Buffer: ['buffer', 'Buffer'], }), ], }; ``` ### Custom Webpack Plugin for `AsyncLocalStorage` // AsyncHooksPolyfillPlugin.js This plugin intercepts module resolution and replaces `node:async_hooks` with our polyfill. ```js class AsyncHooksPolyfillPlugin { apply(compiler) { compiler.hooks.normalModuleFactory.tap("AsyncHooksPolyfillPlugin", (factory) => { factory.hooks.beforeResolve.tap("AsyncHooksPolyfillPlugin", (data) => { if (!data) return; // Replace async_hooks with our polyfill if (data.request === "node:async_hooks" || data.request === "async_hooks") { data.request = require.resolve("./async-local-storage.js"); } }); }); } } module.exports = AsyncHooksPolyfillPlugin; ``` ### `AsyncLocalStorage` Polyfill async-local-storage.js This implementation provides a basic `AsyncLocalStorage` alternative that maintains execution context in the browser. ```js class AsyncLocalStorage { constructor() { this.storeMap = new Map(); } run = async (store, callback, ...args) => { const executionContext = Symbol(); // Unique key for each execution context this.storeMap.set(executionContext, store); try { return await callback(...args); } finally { this.storeMap.delete(executionContext); } } getStore = () => { for (const value of this.storeMap.values()) { return value; // Return the most recent context } return undefined; } enterWith = (store) => { const executionContext = Symbol(); this.storeMap.set(executionContext, store); } } module.exports = { AsyncLocalStorage, }; ``` ### Considerations - This approach is designed **only for the current application lifecycle** and is **not persistent** across sessions. - Using a persistent storage solution like `localforage` or `Dexie` **might be overkill** unless offline storage is needed. This is an early-stage implementation, and while I’ve tested interrupt functionality, other features may need validation. Feel free to experiment and improve upon this! 🚀 ---
Author
Owner

@jacoblee93 commented on GitHub (Mar 24, 2025):

Interesting! Thanks for sharing @tyadav7!

Any edge cases folks should watch out for?

@jacoblee93 commented on GitHub (Mar 24, 2025): Interesting! Thanks for sharing @tyadav7! Any edge cases folks should watch out for?
Author
Owner

@tyadav7 commented on GitHub (Mar 24, 2025):

At this stage, I’ve only tested the interrupt functionality, so I can't confidently speak to other edge cases yet. There may be scenarios that need further validation, especially for more complex use cases. If anyone tries this approach and encounters issues, I’d love to hear about them!

@tyadav7 commented on GitHub (Mar 24, 2025): At this stage, I’ve only tested the interrupt functionality, so I can't confidently speak to other edge cases yet. There may be scenarios that need further validation, especially for more complex use cases. If anyone tries this approach and encounters issues, I’d love to hear about them!
Author
Owner

@benjamincburns commented on GitHub (Mar 25, 2025):

WIP in-browser testing is here: #999

@benjamincburns commented on GitHub (Mar 25, 2025): WIP in-browser testing is here: #999
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: langchain-ai/langgraphjs#177