`Merge_configs` Drops An Explicit `Recursion_limit=25`
In langchain-core, `merge_configs` calls `ensure_config` on each input, which injects `DEFAULT_RECURSION_LIMIT = 25` when absent. This makes an explicitly provided `recursion_limit=25` indistinguishable from the injected default, causing it to be silently removed from the merged config.
`ensure_config` adds the default recursion limit (25) before `merge_configs` can detect whether the original config explicitly specified that value. The merge logic then treats any occurrence of 25 as an injected default and skips it when a later config omits the field.
Run the following Python snippet:
from langchain_core.runnables.config import RunnableConfig, merge_configs
result = merge_configs({'configurable': {'thread_id': 'test'}}, RunnableConfig(recursion_limit=25))
print(result.get('recursion_limit')) # None
Changing the explicit limit to 26 returns 26, demonstrating the bug.
Fixing Code Block
from typing import Any, Dict, List, Optional
from langchain_core.runnables.config import RunnableConfig, ensure_config, DEFAULT_RECURSION_LIMIT
def _merge_dict(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
"""Recursively merge override into base, with override taking precedence."""
result = dict(base)
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = _merge_dict(result[key], value)
else:
result[key] = value
return result
def merge_configs(*configs: Optional[RunnableConfig]) -> RunnableConfig:
"""Merge multiple RunnableConfigs into one, preserving explicit recursion_limit=25.
Records whether each raw input explicitly sets a non-None ``recursion_limit``
before ``ensure_config`` injects the default. After merging, the last explicit
value (including 25) is restored if necessary.
"""
raw_configs: List[Optional[RunnableConfig]] = list(configs)
explicit_limits: List[Optional[int]] = [] # per config, None if not explicitly set
for config in raw_configs:
if config is None:
explicit_limits.append(None)
continue
# RunnableConfig behaves like a dict at runtime.
if isinstance(config, dict):
raw_limit = config.get("recursion_limit")
else:
raw_limit = getattr(config, "recursion_limit", None)
explicit_limits.append(raw_limit if raw_limit is not None else None)
merged: Dict[str, Any] = {}
for config in raw_configs:
if config is None:
continue
ensured = ensure_config(config)
merged = _merge_dict(merged, ensured)
# After normal merge, if any raw config explicitly set recursion_limit, restore the last one.
# This prevents an explicit 25 from being overwritten by a later omitted default.
last_explicit = None
for limit in explicit_limits:
if limit is not None:
last_explicit = limit
if last_explicit is not None:
merged["recursion_limit"] = last_explicit
return merged # type: ignore[return-value]
The fix records whether each raw configuration explicitly contains a non-None `recursion_limit` before `ensure_config` is called. After the normal recursive merge, the last explicit value is forced back into the merged config. This preserves an explicit 25 while still allowing later explicit values to override earlier ones and preventing an omitted field from overwriting an earlier custom limit.
Edge Case Audit
This fix assumes that any explicit non-None `recursion_limit` should always be preserved. If a caller intentionally sets `recursion_limit=None` to clear the field, this implementation will ignore that and the field will remain set. For concurrency, avoid sharing mutable config objects across threads; make defensive copies if necessary. Rollback: revert to the original `merge_configs` implementation or remove the explicit-limit restoration block.