AI & Agent Dev Bug Sandbox logo
AI & Agent Dev Bug Sandbox
Back to Radar

`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.

mediumConfidence 95%Langchain-CoreAffected V1.6.1

Origin Analysis

`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.

Ecosystem Topology