How I Debugged LangChain #34974: A Case Study in ContextVar Thread Affinity
TL;DR: A 10-line fix for a bug that broke Human-in-the-Loop for 5 months. Root cause: Python's ContextVar doesn't cross thread boundaries when an async def dispatches to a thread pool executor. The fix: copy_context().
Two days ago, I saw a GitHub Issue that had been open since February 2026.
LangChain #34974: HumanInTheLoopMiddleware + ainvoke() → RuntimeError: Called get_config outside of a runnable context.
5 months. 2 unmerged PRs. A thread full of developers trying different workarounds — switching checkpointer backends, upgrading Python versions — all treating symptoms instead of the root cause.
I decided to build a diagnostic tool to trace it properly. Here's what happened.
Step 1: Trace the Error Chain
The error stack told a clear story:
langchain/agents/middleware/human_in_the_loop.py:381 → aafter_model (async wrapper)
langchain/agents/middleware/human_in_the_loop.py:331 → after_model (sync) → interrupt()
langgraph/types.py:515 → interrupt → get_config()["configurable"]
langgraph/config.py:29 → get_config → ⚡ RuntimeError
The crash happens at line 29 of langgraph/config.py:
def get_config():
config = _get_config_var.get(None)
if config is None:
raise RuntimeError("Called get_config outside of a runnable context")
return config
_get_config_var is a ContextVar. So the question became: why is it None when interrupt() is called?
Step 2: Follow the Thread (Literally)
HumanInTheLoopMiddleware has two methods:
class HumanInTheLoopMiddleware(BaseMiddleware):
async def aafter_model(self, state, runtime):
# async version
decisions = await asyncio.to_thread(self.after_model, state, runtime)
...
def after_model(self, state, runtime):
# sync version
decisions = interrupt(hitl_request)["decisions"]
...
aafter_model is async def, running in the asyncio event loop thread. It calls asyncio.to_thread(self.after_model, ...), which dispatches the sync method to a ThreadPoolExecutor.
Here's the problem: Python's ContextVar is thread-affine. When after_model() runs in a thread pool worker, it inherits a fresh ContextVar namespace — _get_config_var is unset. interrupt() tries to read it → crash.
This is why:
-
Python 3.10 ❌ — different default event loop policy (
ProactorEventLoopon Windows,SelectorEventLoopon Linux/macOS) changes how threads interact with asyncio - Python 3.11 ✅ — keenborder786 couldn't reproduce in pure script mode (no FastAPI), because the thread pool wasn't involved
- FastAPI makes it consistent — FastAPI's ASGI server always dispatches through a thread pool, so the bug reproduces 100% of the time in production
Step 3: The Fix — 10 Lines, Zero Dependencies
from contextvars import copy_context
class HumanInTheLoopMiddleware(BaseMiddleware):
async def aafter_model(self, state, runtime):
ctx = copy_context() # capture current ContextVar snapshot
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None,
lambda: ctx.run(self.after_model, state, runtime) # restore in worker thread
)
copy_context() captures the calling thread's ContextVar state. ctx.run() restores it in the target thread before executing the function. This is the canonical pattern from PEP 567 — it's what CPython itself uses.
Alternatively, if interrupt() supports async (which it does in langgraph 1.0.x), the cleaner fix is to move everything inline into aafter_model and delete after_model entirely.
What I Learned Building ARK
This debug took me about 2 hours — including building the diagnostic tool that generated the report. That tool, ARK, is an open-source agent health monitoring system I've been working on.
ARK works by:
- Listening to GitHub Issues for agent crash patterns
- Tracing the error stack to find the root cause (not just the crash point)
- Generating a structured diagnostic report with health scores and fix suggestions
- Publishing the report to a CDN and the Issue thread
The report for this Issue hit 42/100 — the HITL core function scored only 15 because it's completely broken in async paths. But the root cause is a single ContextVar line. Low-hanging fruit, if you know where to look.
If you're dealing with similar agent crashes, the full diagnostic report with evidence tracing is at:
👉 ARK Diagnostic Report — #34974
Or run a quick health check on your own setup:
🛡️ Stop Firefighting Your Agents
Your agent crashes don't wait for business hours. They hit while you sleep, while you ship, while you're busy.
→ Run a free 30-second diagnosis — see exactly what's about to break.
- Lifetime license ¥360 — fix everything, once.
- Subscription ¥65/mo — 7×24 crash monitoring + real-time alerts + auto-updated protection rules. Cancel anytime.
The best time to add continuous monitoring is right after your first crash. The second best time is now.
Top comments (0)