Compare commits

...
Author SHA1 Message Date
Christian Bromann 4dbc8da93d cr 2026-06-03 00:34:09 -07:00
Christian Bromann 85ba90e474 revert 2026-06-03 00:33:47 -07:00
Christian Bromann 3cae2f77ce cr 2026-06-03 00:31:48 -07:00
Christian Bromann 907fefe519 cr 2026-06-02 16:57:51 -07:00
Christian Bromann 9ec5b16107 fix(langgraph): honor per-node cache_policy=None with set_node_defaults
Use a MISSING default on add_node so explicit cache_policy=None opts out
of graph-wide cache defaults instead of being treated as unset.
2026-06-02 16:47:59 -07:00
2 changed files with 122 additions and 7 deletions
+35 -7
View File
@@ -98,6 +98,15 @@ _CHANNEL_BRANCH_TO = "branch:to:{}"
_DEFAULT_ERROR_HANDLER_NODE = "__default_error_handler__"
class _CachePolicyUnset:
"""Sentinel: `cache_policy` was omitted on `add_node` (inherit defaults)."""
__slots__ = ()
_CACHE_POLICY_UNSET = _CachePolicyUnset()
@dataclass(slots=True)
class _NodeDefaults:
"""Default node policies applied to every node at compile time."""
@@ -263,6 +272,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
self.context_schema = context_schema
self._node_defaults: _NodeDefaults = _NodeDefaults()
self._cache_policy_opt_out: set[str] = set()
self._add_schema(self.state_schema)
self._add_schema(self.input_schema, allow_managed=False)
@@ -292,7 +302,8 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
their own via `add_node(..., retry_policy=...)`. Also applies
to error-handler nodes.
cache_policy: Default cache policy for nodes that don't specify
their own via `add_node(..., cache_policy=...)`. Does **not**
their own via `add_node(..., cache_policy=...)`. Per-node
`cache_policy=None` opts out of this default. Does **not**
apply to error-handler nodes.
error_handler: Default error handler invoked when any regular node
raises and does not have its own `error_handler` set via
@@ -668,7 +679,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
metadata: dict[str, Any] | None = None,
input_schema: type[NodeInputT] | None = None,
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
cache_policy: CachePolicy | None = None,
cache_policy: CachePolicy | None | _CachePolicyUnset = _CACHE_POLICY_UNSET,
error_handler: StateNode[Any, ContextT] | None = None,
destinations: dict[str, str] | tuple[str, ...] | None = None,
timeout: float | timedelta | TimeoutPolicy | None = None,
@@ -689,7 +700,10 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
retry_policy: The retry policy for the node.
If a sequence is provided, the first matching policy will be applied.
cache_policy: The cache policy for the node.
cache_policy: The cache policy for the node. Pass `None` explicitly to
disable caching for this node, including when graph defaults from
`set_node_defaults` would otherwise enable it. Omit the argument to
inherit graph defaults.
error_handler: Optional node-level error handler callable for this node.
destinations: Destinations that indicate where a node can route to.
@@ -765,6 +779,15 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
input_schema = cast(type[NodeInputT] | None, input_)
timeout = coerce_timeout_policy(timeout)
explicit_cache_opt_out = False
if isinstance(cache_policy, _CachePolicyUnset):
resolved_cache_policy: CachePolicy | None = None
elif isinstance(cache_policy, CachePolicy):
resolved_cache_policy = cache_policy
else:
explicit_cache_opt_out = True
resolved_cache_policy = None
if not isinstance(node, str):
action = node
if isinstance(action, Runnable):
@@ -789,6 +812,10 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
)
if action is None:
raise RuntimeError
if explicit_cache_opt_out:
self._cache_policy_opt_out.add(node)
else:
self._cache_policy_opt_out.discard(node)
if node in self.nodes:
raise ValueError(f"Node `{node}` already present.")
if node == END or node == START:
@@ -875,7 +902,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
metadata,
input_schema=input_schema,
retry_policy=retry_policy,
cache_policy=cache_policy,
cache_policy=resolved_cache_policy,
error_handler_node=handler_node_name,
ends=ends,
defer=defer,
@@ -887,7 +914,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
metadata,
input_schema=inferred_input_schema,
retry_policy=retry_policy,
cache_policy=cache_policy,
cache_policy=resolved_cache_policy,
error_handler_node=handler_node_name,
ends=ends,
defer=defer,
@@ -899,7 +926,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
metadata,
input_schema=self.state_schema,
retry_policy=retry_policy,
cache_policy=cache_policy,
cache_policy=resolved_cache_policy,
error_handler_node=handler_node_name,
ends=ends,
defer=defer,
@@ -1297,7 +1324,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
)
# Apply builder defaults to node specs. Per-node values always win.
for spec in self.nodes.values():
for node_name, spec in self.nodes.items():
# error_handler: regular nodes only — handlers must never
# catch themselves or other handlers.
if (
@@ -1317,6 +1344,7 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
not spec.is_error_handler
and defaults.cache_policy is not None
and spec.cache_policy is None
and node_name not in self._cache_policy_opt_out
):
spec.cache_policy = defaults.cache_policy
# timeout: all nodes — a stuck handler should be cancelled the
+87
View File
@@ -58,6 +58,7 @@ from langgraph.pregel._retry import (
from langgraph.pregel.protocol import StreamProtocol
from langgraph.runtime import DEFAULT_RUNTIME, ExecutionInfo, Runtime
from langgraph.types import (
CachePolicy,
Command,
PregelExecutableTask,
RetryPolicy,
@@ -2570,6 +2571,92 @@ def test_set_node_defaults_retry_policy_per_node_wins():
assert attempts == 2
def test_set_node_defaults_cache_policy_applies_to_nodes_without_own() -> None:
from langgraph.cache.memory import InMemoryCache
class State(TypedDict):
foo: str
calls = 0
def cached_node(state: State) -> State:
nonlocal calls
calls += 1
return {"foo": "ok"}
graph = (
StateGraph(State)
.set_node_defaults(cache_policy=CachePolicy(ttl=60))
.add_node("cached", cached_node)
.add_edge(START, "cached")
.compile(cache=InMemoryCache())
)
assert graph.nodes["cached"].cache_policy == CachePolicy(ttl=60)
graph.invoke({"foo": ""})
graph.invoke({"foo": ""})
assert calls == 1
def test_set_node_defaults_cache_policy_per_node_wins() -> None:
from langgraph.cache.memory import InMemoryCache
class State(TypedDict):
foo: str
graph = (
StateGraph(State)
.set_node_defaults(cache_policy=CachePolicy(ttl=60))
.add_node("a", lambda state: {"foo": "a"}, cache_policy=CachePolicy(ttl=5))
.add_node("b", lambda state: {"foo": "b"})
.add_edge(START, "a")
.add_edge("a", "b")
.compile(cache=InMemoryCache())
)
assert graph.nodes["a"].cache_policy == CachePolicy(ttl=5)
assert graph.nodes["b"].cache_policy == CachePolicy(ttl=60)
def test_set_node_defaults_cache_policy_explicit_none_opts_out() -> None:
from langgraph.cache.memory import InMemoryCache
class State(TypedDict):
foo: str
cached_calls = 0
uncached_calls = 0
def cached_node(state: State) -> State:
nonlocal cached_calls
cached_calls += 1
return {"foo": "cached"}
def uncached_node(state: State) -> State:
nonlocal uncached_calls
uncached_calls += 1
return {"foo": "uncached"}
graph = (
StateGraph(State)
.set_node_defaults(cache_policy=CachePolicy())
.add_node("cached", cached_node)
.add_node("uncached", uncached_node, cache_policy=None)
.add_edge(START, "cached")
.add_edge("cached", "uncached")
.compile(cache=InMemoryCache())
)
assert graph.nodes["cached"].cache_policy == CachePolicy()
assert graph.nodes["uncached"].cache_policy is None
graph.invoke({"foo": ""})
graph.invoke({"foo": ""})
assert cached_calls == 1
assert uncached_calls == 2
@pytest.mark.anyio
async def test_set_node_defaults_timeout():
class State(TypedDict):