One binding deadline for every inner timeout in a LangGraph node — so a timed-out agent salvages a partial answer instead of losing everything.
I build agent workflows on LangGraph, and the failure mode that burned me most wasn't a wrong answer — it was no answer. A long research node runs for minutes, spends real money on LLM and tool calls, then trips a timeout and returns nothing. The user sees "it failed." Everything the run had already produced is thrown away.
This isn't a hypothetical. It maps to a real, open upstream report — langchain-ai/langgraph#5672, "Run Cancellation Causes Loss of Streamed State Not Yet Persisted." When LangGraph's watchdog cancels a node, the cancel is uncooperative: it discards the partial result you could have returned.
A node that does real work has several timeout layers, each re-deriving its own clock: LangGraph's per-node timeout= (or the graph-wide step_timeout) watchdog, an inner agent/tool budget, a retry loop, a sub-planner that "wants" 60 seconds. When those clocks disagree, the inner layers cheerfully dispatch work the outer watchdog is guaranteed to kill.
The subtle trap is that pinning your cooperative cancel to the same number as the watchdog doesn't fix it. The watchdog's clock starts at node entry, before your code runs, so it wins the race deterministically. Equal timeouts lose.
One binding deadline, set at node entry, that every inner timeout clamps to instead of re-deriving. The core primitive is a ~120-line kernel around a single contextvars.ContextVar:
node_deadline_in(seconds) sets the deadline at node entry.clamp_to_node_deadline(budget) returns — so an inner call that wants 5s but has 1.8s left gets 1.8s.min(budget, remaining)cooperative_wait_for(...) / cooperative_poll(...) are asyncio.wait_for and streaming wrappers that never outlast that deadline.Because the deadline lives in a contextvar, and asyncio copies the ambient context when it spawns a task, the scope propagates to the agent task and every subagent task automatically — no threading a deadline through call signatures. Inner calls now yield just under the watchdog, so my try/except actually runs and I return a shorter-but-complete answer.
The whole thing is fail-open: with no active scope, every function behaves exactly as if it weren't there — so dropping it into one node never changes the behavior of the rest of the graph, the tests, or direct invocations.
The runnable demo (python examples/salvage_demo.py, no LangGraph needed) makes the difference concrete: same work, same 2s watchdog, one import. The naive path loses everything in 2.00s; the clamped path salvages three completed steps in 1.80s. (Those are the demo's actual printed numbers, reproduced on a clean checkout.)
Where it stands: the v0.1.0 kernel — the deadline/clamp primitive above — is the published, installable piece (pip install langgraph-node-deadline), MIT-licensed and public. The larger v0.2.0 in the repo (which adds the Hourglass budget layer) is an unreleased launch candidate — feature-complete and carrying a 4 - Beta classifier, but not yet tagged or pushed to PyPI. It's a small tool for one failure mode, not a framework. It has no runtime dependencies. The tests lean on Hypothesis property tests plus an integration test that runs against real LangGraph when it's installed, and a one-off mutmut pass flagged the negative-reserve and NaN edges as having no behavioral coverage, which is how they got fixed. If you have no outer watchdog, or your work isn't cooperative, you don't need this.
The optional Hourglass layer (v0.2, unreleased) generalizes one node's deadline into a run-wide time budget with protected reserves — it guarantees your output phase its runway so a heavy run degrades to a shorter memo instead of timing out into nothing. Building it surfaced a genuine concurrency bug the tests caught: the "active phase" was a single shared instance slot, so concurrent grants under asyncio.gather (and nested grants) read .mode against the wrong phase's reserve. The fix was to track the active grant in a per-task contextvar keyed by (id(hourglass), phase). Nothing ever raised — the reserve was just quietly wrong — so if the tests hadn't caught it I'd have had no reason to look.