From d7735fb0b254b4778ff70c272f085d34d2fc952d Mon Sep 17 00:00:00 2001 From: jacoblee93 Date: Sun, 19 Apr 2026 03:28:51 -0700 Subject: [PATCH] fix(langgraph): merge configurable dicts across configs in ensure_config Previously, when ensure_config was called with multiple configs (e.g. Pregel.stream's ensure_config(self.config, config)), a later config's configurable dict fully overwrote an earlier one. This caused values bound via with_config({"configurable": {...}}) to be silently dropped whenever the invoke-time config supplied any other configurable key. Concretely, create_agent binds {configurable: {ls_agent_type: 'root'}} via with_config, and any real invocation with a checkpointer supplies {configurable: {thread_id: ...}} at invoke time. The bound ls_agent_type was dropped, so no root-level runs were tagged with ls_agent_type='root' in LangSmith. Fix: merge the configurable dict across configs (stdlib merge_configs in langchain_core already does this correctly; langgraph's merge_configs helper in this same file also does this at line 109). Invoke-time values still override bound values when keys collide. Adds a regression test in tests/test_utils.py. --- libs/langgraph/langgraph/_internal/_config.py | 7 ++++- libs/langgraph/tests/test_utils.py | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/libs/langgraph/langgraph/_internal/_config.py b/libs/langgraph/langgraph/_internal/_config.py index d6fae9b06..92aed3b61 100644 --- a/libs/langgraph/langgraph/_internal/_config.py +++ b/libs/langgraph/langgraph/_internal/_config.py @@ -306,7 +306,12 @@ def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig: for k, v in config.items(): if _is_not_empty(v) and k in CONFIG_KEYS: if k == CONF: - empty[k] = cast(dict, v).copy() + # Merge configurable dicts across configs so that values + # bound via `with_config(...)` (e.g. `ls_agent_type`) are + # preserved when later configs (e.g. invoke-time) only + # specify a subset of keys like `thread_id`. + existing = cast(dict, empty.get(k) or {}) + empty[k] = {**existing, **cast(dict, v)} else: empty[k] = v # type: ignore[literal-required] for k, v in config.items(): diff --git a/libs/langgraph/tests/test_utils.py b/libs/langgraph/tests/test_utils.py index c9e6168ff..e7780aa2f 100644 --- a/libs/langgraph/tests/test_utils.py +++ b/libs/langgraph/tests/test_utils.py @@ -427,3 +427,32 @@ def test_callback_manager_copies_configurable_ids_to_tracing_metadata() -> None: "thread_id": "th-123", "user_id": "uid-1", } + + +def test_ensure_config_merges_configurable_across_configs() -> None: + """`ensure_config(bound, invoke_time)` should merge `configurable` dicts. + + Prior to the fix, a later config's `configurable` dict fully overwrote an + earlier one, causing values bound via `with_config({"configurable": {...}})` + (e.g. `ls_agent_type="root"` set by `create_agent`) to be dropped whenever + an invoke-time config supplied any other configurable key like `thread_id`. + """ + bound: RunnableConfig = { + "configurable": {"ls_agent_type": "root", "custom_setting": "keep_me"}, + "metadata": {"ls_integration": "langchain_create_agent"}, + } + invoke_time: RunnableConfig = { + "configurable": {"thread_id": "t-1"}, + } + merged = ensure_config(bound, invoke_time) + # Both the bound and invoke-time configurable keys are preserved. + assert merged["configurable"] == { + "ls_agent_type": "root", + "custom_setting": "keep_me", + "thread_id": "t-1", + } + # Invoke-time values still override bound values when they collide. + override: RunnableConfig = {"configurable": {"ls_agent_type": "subagent"}} + merged2 = ensure_config(bound, override) + assert merged2["configurable"]["ls_agent_type"] == "subagent" + assert merged2["configurable"]["custom_setting"] == "keep_me"