Understanding the Async/Sync Bridging Pattern in Open Notebook's LangGraph Nodes

Open Notebook uses an async-to-sync bridging pattern in its LangGraph nodes to safely call asynchronous services like model provisioning from synchronous graph nodes, preventing RuntimeError when no event loop is running.

Open Notebook orchestrates AI workflows using LangGraph, where some graph nodes must invoke asynchronous utilities from synchronous implementations. This architectural requirement creates a runtime conflict that the async/sync bridging pattern resolves by intelligently managing event loop states across different execution contexts.

Why Open Notebook Needs Async/Sync Bridging

LangGraph workflows in Open Notebook are fundamentally async, but certain nodes like call_model_with_messages in the chat graph are implemented as synchronous functions. These nodes need to call async services such as provision_langchain_model from open_notebook/ai/provision.py.

Directly awaiting an async function inside a sync node raises RuntimeError: no running event loop. The bridging pattern detects whether the code runs inside an existing async context and adapts its execution strategy accordingly.

How the Async/Sync Bridging Pattern Works

The implementation in open_notebook/graphs/chat.py follows a three-step detection and execution strategy.

Detecting the Event Loop State

The code first attempts to retrieve the current event loop using asyncio.get_running_loop(). If this succeeds, the graph is running inside an async pipeline.

Thread-Based Execution for Nested Loops

When an existing loop is detected, the pattern spawns a separate thread with its own event loop to avoid nested loop conflicts. It uses concurrent.futures.ThreadPoolExecutor to submit the async work:

try:
    asyncio.get_running_loop()  # Check if we're inside an async graph

    with concurrent.futures.ThreadPoolExecutor() as executor:
        future = executor.submit(run_in_new_loop)
        model = future.result()

The run_in_new_loop helper creates a fresh event loop in the new thread, executes the async call, and returns the result:

def run_in_new_loop():
    new_loop = asyncio.new_event_loop()
    asyncio.set_event_loop(new_loop)
    try:
        return new_loop.run_until_complete(
            provision_langchain_model(str(payload), model_id, "chat", max_tokens=8192)
        )
    finally:
        new_loop.close()
        asyncio.set_event_loop(None)

Direct Execution for Sync Contexts

If asyncio.get_running_loop() raises RuntimeError, no loop exists (typical in CLI tools or tests). The pattern falls back to asyncio.run():

except RuntimeError:  # No loop - e.g., CLI call

    model = asyncio.run(
        provision_langchain_model(str(payload), model_id, "chat", max_tokens=8192)
    )

Implementation in the Chat Graph

The complete implementation appears in open_notebook/graphs/chat.py within the call_model_with_messages function. This synchronous node prepares a message payload, bridges to the async provision_langchain_model utility, and then invokes the model:

def call_model_with_messages(state: ThreadState, config: RunnableConfig) -> dict:
    # …prepare payload…

    # -------- Async-to-Sync bridge ----------

    def run_in_new_loop():
        new_loop = asyncio.new_event_loop()
        asyncio.set_event_loop(new_loop)
        try:
            return new_loop.run_until_complete(
                provision_langchain_model(str(payload), model_id, "chat", max_tokens=8192)
            )
        finally:
            new_loop.close()
            asyncio.set_event_loop(None)

    try:
        asyncio.get_running_loop()  # We are inside an async graph

        with concurrent.futures.ThreadPoolExecutor() as exe:
            model = exe.submit(run_in_new_loop).result()
    except RuntimeError:  # No loop – e.g., CLI call

        model = asyncio.run(
            provision_langchain_model(str(payload), model_id, "chat", max_tokens=8192)
        )
    # -----------------------------------------

    ai_message = model.invoke(payload)
    # …process response…

This ensures the node works whether invoked from LangGraph's async pipeline or a synchronous script.

Contrast with Pure Async Nodes

Not all nodes require this bridging. The source graph in open_notebook/graphs/source.py implements nodes as pure async functions, eliminating the need for loop detection:


# open_notebook/graphs/source.py

async def content_process(state: SourceState) -> dict:
    # async calls to content_core and ModelManager

    processed_state = await extract_content(content_state)
    ...

This contrast illustrates Open Notebook's architectural flexibility: fully async workflows use native await, while mixed sync/async contexts employ the bridging pattern to maintain compatibility.

Summary

  • The async/sync bridging pattern prevents RuntimeError when synchronous LangGraph nodes call async services like provision_langchain_model.
  • It detects the current event loop state using asyncio.get_running_loop() to determine the execution strategy.
  • For nested async contexts, it spawns a ThreadPoolExecutor with an isolated event loop via run_in_new_loop.
  • For pure sync contexts, it falls back to asyncio.run() for straightforward execution.
  • This pattern is implemented in open_notebook/graphs/chat.py but unnecessary in fully async nodes like those in open_notebook/graphs/source.py.

Frequently Asked Questions

What causes the "no running event loop" error in LangGraph?

This error occurs when synchronous Python code attempts to await a coroutine or call asyncio.get_running_loop() while no event loop is active. In Open Notebook's chat graph, the call_model_with_messages node runs as a sync function but needs to invoke the async provision_langchain_model utility, triggering this error without the bridging pattern.

Why not make all nodes async instead of using a bridge?

While Open Notebook's source graph (source.py) uses pure async nodes, the chat graph requires synchronous nodes for specific LangGraph integration patterns or legacy compatibility. The bridging pattern allows selective async service usage without refactoring entire graph segments to async/await syntax.

Is the ThreadPoolExecutor approach thread-safe?

Yes. The pattern creates a new event loop within a dedicated thread for each async invocation, preventing conflicts with the parent loop. The run_in_new_loop helper properly cleans up by closing the loop and resetting the thread-local event loop in its finally block, ensuring deterministic resource management.

Where else in Open Notebook is this pattern used?

Currently, the async/sync bridging pattern is primarily implemented in open_notebook/graphs/chat.py for the call_model_with_messages node. Other nodes like those in open_notebook/graphs/source.py are fully async and do not require this bridge, as they can directly await async services.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →