From 385033fd9cfc356a0ed9028bef53f5490e9c0939 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Fri, 12 Jun 2026 16:20:38 -0400 Subject: [PATCH] fix(langgraph): merge `lc_versions` config metadata (#8052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve LangChain package-version trace metadata when graph-bound config and invoke-time config both contribute `lc_versions`. The earlier broad nested metadata merge has been narrowed to the LangChain-owned `lc_versions` namespace, so arbitrary user metadata keeps the existing last-writer-wins behavior. ## Changes - Add a shared metadata merge path used by `merge_configs()` and `ensure_config()` so top-level metadata keys are preserved across bound and runtime configs. - Special-case only `metadata["lc_versions"]` for one-level package-version accumulation; duplicate package entries remain last-writer-wins and non-mapping values still replace. - Keep generic nested metadata maps, including user-owned `metadata["versions"]`, as replacement-only to avoid changing arbitrary metadata semantics. - Raise the `langchain-core` lower bound to `>=1.4.7` so LangGraph’s `lc_versions` handling aligns with the lc-core package-version instrumentation. - Cover both config merge helpers with tests for `lc_versions` accumulation, non-recursive replacement within the package map, generic nested metadata replacement, and defensive copying of mapping values. ## Test note The stream event assertions for `test_imp_exception` now avoid depending on leaked internal task-path metadata. With older `langchain-core`, callback metadata could be mutated by later task runs, so every task event in this test appeared to have the final task path index. That made even the `task_with_exception` start event report `metadata["langgraph_node"] == "my_task"`, which is inconsistent with the event name. `langchain-core>=1.4.6` preserves per-event metadata more accurately: the first `my_task`, `task_with_exception`, and second `my_task` report distinct task path indexes. The test now asserts the stable behavior instead: event sequence, tags, required metadata, root stream payloads, exception handling, and final outputs, without requiring the old leaked task index. --- libs/langgraph/langgraph/_internal/_config.py | 59 ++- libs/langgraph/pyproject.toml | 2 +- libs/langgraph/tests/test_large_cases.py | 7 + .../langgraph/tests/test_large_cases_async.py | 7 + libs/langgraph/tests/test_pregel.py | 2 + libs/langgraph/tests/test_pregel_async.py | 383 +++++------------- libs/langgraph/tests/test_utils.py | 154 +++++++ libs/langgraph/uv.lock | 8 +- libs/prebuilt/uv.lock | 8 +- libs/sdk-py/uv.lock | 8 +- 10 files changed, 340 insertions(+), 298 deletions(-) diff --git a/libs/langgraph/langgraph/_internal/_config.py b/libs/langgraph/langgraph/_internal/_config.py index 61d3dbec7..8bbbacbf5 100644 --- a/libs/langgraph/langgraph/_internal/_config.py +++ b/libs/langgraph/langgraph/_internal/_config.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections import ChainMap -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from os import getenv from typing import Any, cast @@ -79,6 +79,42 @@ def patch_checkpoint_map( return config +def _copy_mapping_value(value: Any) -> Any: + return dict(value) if isinstance(value, Mapping) else value + + +def _merge_metadata( + base: Mapping[str, Any] | None, new: Mapping[str, Any] | None +) -> dict[str, Any]: + """Merge metadata without mutating inputs. + + Top-level keys merge with newer values winning. `lc_versions` is the only + mapping-valued key that merges one level deeper, so independent LangChain + packages can contribute package versions without changing generic metadata + semantics. Mapping values are copied one level, so deeper nested objects + remain shared. `None` inputs are treated as empty metadata. + + Mirrors `langchain_core.runnables.config._merge_metadata_dicts` so configs + that pass through LangGraph's merge keep the same `lc_versions` semantics; + keep the two in sync. Unlike lc-core, this copies mapping values one level + (intentionally more defensive — do not "simplify" back to a shared ref). + """ + merged = {key: _copy_mapping_value(value) for key, value in (base or {}).items()} + for key, value in (new or {}).items(): + if ( + key == "lc_versions" + and isinstance(merged.get(key), Mapping) + and isinstance(value, Mapping) + ): + merged[key] = { + **cast(Mapping[str, Any], merged[key]), + **value, + } + else: + merged[key] = _copy_mapping_value(value) + return merged + + def _merge_callbacks(base: Callbacks, new: Callbacks) -> Callbacks: """Merge two callbacks values (None / list / BaseCallbackManager). @@ -126,10 +162,10 @@ def merge_configs(*configs: RunnableConfig | None) -> RunnableConfig: if not value: continue if key == "metadata": - if base_value := base.get(key): - base[key] = {**base_value, **value} # type: ignore - else: - base[key] = value # type: ignore[literal-required] + base[key] = _merge_metadata( + cast(Mapping[str, Any] | None, base.get(key)), + cast(Mapping[str, Any], value), + ) elif key == "tags": if base_value := base.get(key): base[key] = [*base_value, *value] # type: ignore @@ -327,14 +363,11 @@ def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig: empty.get("callbacks"), cast(Callbacks, v) ) elif k == "metadata": - # Shallow-merge metadata dicts across configs so values - # bound via with_config(...) (e.g. user_id) are preserved - # when later configs supply other metadata keys. - existing = empty.get("metadata") - empty["metadata"] = ( - {**cast(dict, existing), **cast(dict, v)} - if existing - else cast(dict, v).copy() + # Matches merge_configs: top-level metadata keys merge, and + # only `lc_versions` merges one level deeper. + empty["metadata"] = _merge_metadata( + cast(Mapping[str, Any] | None, empty.get("metadata")), + cast(Mapping[str, Any], v), ) elif k == "tags": # Concatenate tags across configs so values bound via diff --git a/libs/langgraph/pyproject.toml b/libs/langgraph/pyproject.toml index 13588ad9b..16179f7f5 100644 --- a/libs/langgraph/pyproject.toml +++ b/libs/langgraph/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ 'Programming Language :: Python :: 3.13', ] dependencies = [ - "langchain-core>=1.4.0,<2", + "langchain-core>=1.4.7,<2", "langgraph-checkpoint>=4.1.0,<5.0.0", "langgraph-sdk>=0.4.2,<0.5.0", "langgraph-prebuilt>=1.1.0,<1.2.0", diff --git a/libs/langgraph/tests/test_large_cases.py b/libs/langgraph/tests/test_large_cases.py index bd2b780a0..890866924 100644 --- a/libs/langgraph/tests/test_large_cases.py +++ b/libs/langgraph/tests/test_large_cases.py @@ -9,6 +9,7 @@ import pytest from langchain_core.messages import AIMessage, AnyMessage, ToolCall from langchain_core.runnables import RunnableConfig, RunnableMap, RunnablePick from langchain_core.tools import tool +from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION from langgraph.checkpoint.base import BaseCheckpointSaver from langgraph.checkpoint.memory import InMemorySaver from langgraph.prebuilt.chat_agent_executor import create_react_agent @@ -1399,6 +1400,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None: "ls_provider": "fakechatmodel", "ls_model_type": "chat", "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, }, ), ( @@ -1461,6 +1463,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None: "ls_provider": "fakechatmodel", "ls_model_type": "chat", "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, }, ), ] @@ -1513,6 +1516,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None: "ls_provider": "fakechatmodel", "ls_model_type": "chat", "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, }, ), ] @@ -6884,6 +6888,7 @@ def test_weather_subgraph( "ls_provider": "fakemessageslistchatmodel", "ls_model_type": "chat", "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, }, ), ), @@ -6911,6 +6916,7 @@ def test_weather_subgraph( "ls_provider": "fakemessageslistchatmodel", "ls_model_type": "chat", "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, }, ), ), @@ -6947,6 +6953,7 @@ def test_weather_subgraph( "ls_provider": "fakemessageslistchatmodel", "ls_model_type": "chat", "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, }, ), ] diff --git a/libs/langgraph/tests/test_large_cases_async.py b/libs/langgraph/tests/test_large_cases_async.py index 2455333ab..1db144aa4 100644 --- a/libs/langgraph/tests/test_large_cases_async.py +++ b/libs/langgraph/tests/test_large_cases_async.py @@ -11,6 +11,7 @@ from typing import ( import pytest from langchain_core.messages import AnyMessage, ToolCall from langchain_core.runnables import RunnableConfig, RunnablePick +from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION from langgraph.checkpoint.base import BaseCheckpointSaver from langgraph.prebuilt.chat_agent_executor import create_react_agent from langgraph.prebuilt.tool_node import ToolNode @@ -1150,6 +1151,7 @@ async def test_prebuilt_tool_chat() -> None: "ls_provider": "fakechatmodel", "ls_model_type": "chat", "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, }, ), ( @@ -1212,6 +1214,7 @@ async def test_prebuilt_tool_chat() -> None: "ls_provider": "fakechatmodel", "ls_model_type": "chat", "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, }, ), ] @@ -1264,6 +1267,7 @@ async def test_prebuilt_tool_chat() -> None: "ls_provider": "fakechatmodel", "ls_model_type": "chat", "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, }, ), ] @@ -3981,6 +3985,7 @@ async def test_weather_subgraph( "ls_provider": "fakemessageslistchatmodel", "ls_model_type": "chat", "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, }, ), ), @@ -4008,6 +4013,7 @@ async def test_weather_subgraph( "ls_provider": "fakemessageslistchatmodel", "ls_model_type": "chat", "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, }, ), ), @@ -4044,6 +4050,7 @@ async def test_weather_subgraph( "ls_provider": "fakemessageslistchatmodel", "ls_model_type": "chat", "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, }, ), ] diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index c686a8071..0aae1318e 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -23,6 +23,7 @@ from langchain_core.runnables import ( RunnablePassthrough, ) from langchain_core.runnables.graph import Edge +from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION from langgraph.cache.base import BaseCache from langgraph.checkpoint.base import ( BaseCheckpointSaver, @@ -6923,6 +6924,7 @@ def test_tags_stream_mode_messages() -> None: "ls_provider": "genericfakechatmodel", "ls_model_type": "chat", "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, "tags": ["meow"], }, ) diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 85393c73b..1a1a4734b 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -24,6 +24,7 @@ from langchain_core.language_models import GenericFakeChatModel from langchain_core.messages import HumanMessage from langchain_core.runnables import RunnableConfig, RunnableLambda, RunnablePassthrough from langchain_core.utils.aiter import aclosing +from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION from langgraph.cache.base import BaseCache from langgraph.checkpoint.base import ( BaseCheckpointSaver, @@ -7604,6 +7605,7 @@ async def test_tags_stream_mode_messages() -> None: "ls_provider": "genericfakechatmodel", "ls_model_type": "chat", "ls_integration": "langchain_chat_model", + "lc_versions": {"langchain-core": LANGCHAIN_CORE_VERSION}, "tags": ["meow"], }, ) @@ -8761,280 +8763,117 @@ async def test_imp_exception( {"my_workflow": "done"}, ] - assert [c async for c in my_workflow.astream_events(1, thread1)] == [ - { - "event": "on_chain_start", - "data": {"input": 1}, - "name": "LangGraph", - "tags": [], - "run_id": AnyStr(), - "metadata": {"thread_id": "1", "ls_integration": "langgraph"}, - "parent_ids": [], - }, - { - "event": "on_chain_start", - "data": {"input": 1}, - "name": "my_workflow", - "tags": ["graph:step:4"], - "run_id": AnyStr(), - "metadata": { - "thread_id": "1", - "ls_integration": "langgraph", - "langgraph_step": 4, - "langgraph_node": "my_workflow", - "langgraph_triggers": ("__start__",), - "langgraph_path": ("__pregel_pull", "my_workflow"), - "langgraph_checkpoint_ns": AnyStr(), - }, - "parent_ids": [AnyStr()], - }, - { - "event": "on_chain_start", - "data": {"input": {"number": 1}}, - "name": "my_task", - "tags": ["seq:step:1"], - "run_id": AnyStr(), - "metadata": { - "thread_id": "1", - "ls_integration": "langgraph", - "langgraph_step": 4, - "langgraph_node": "my_task", - "langgraph_triggers": ("__pregel_push",), - "langgraph_path": ( - "__pregel_push", - ("__pregel_pull", "my_workflow"), - 2, - True, - ), - "langgraph_checkpoint_ns": AnyStr(), - }, - "parent_ids": [ - AnyStr(), - AnyStr(), - ], - }, - { - "event": "on_chain_stream", - "run_id": AnyStr(), - "name": "my_task", - "tags": ["seq:step:1"], - "metadata": { - "thread_id": "1", - "ls_integration": "langgraph", - "langgraph_step": 4, - "langgraph_node": "my_task", - "langgraph_triggers": ("__pregel_push",), - "langgraph_path": ( - "__pregel_push", - ("__pregel_pull", "my_workflow"), - 2, - True, - ), - "langgraph_checkpoint_ns": AnyStr(), - }, - "data": {"chunk": 2}, - "parent_ids": [ - AnyStr(), - AnyStr(), - ], - }, - { - "event": "on_chain_end", - "data": {"output": 2, "input": {"number": 1}}, - "run_id": AnyStr(), - "name": "my_task", - "tags": ["seq:step:1"], - "metadata": { - "thread_id": "1", - "ls_integration": "langgraph", - "langgraph_step": 4, - "langgraph_node": "my_task", - "langgraph_triggers": ("__pregel_push",), - "langgraph_path": ( - "__pregel_push", - ("__pregel_pull", "my_workflow"), - 2, - True, - ), - "langgraph_checkpoint_ns": AnyStr(), - }, - "parent_ids": [ - AnyStr(), - AnyStr(), - ], - }, - { - "event": "on_chain_stream", - "run_id": AnyStr(), - "name": "LangGraph", - "tags": [], - "metadata": {"thread_id": "1", "ls_integration": "langgraph"}, - "data": {"chunk": {"my_task": 2}}, - "parent_ids": [], - }, - { - "event": "on_chain_start", - "data": {"input": {"number": 1}}, - "name": "task_with_exception", - "tags": ["seq:step:1"], - "run_id": AnyStr(), - "metadata": { - "thread_id": "1", - "ls_integration": "langgraph", - "langgraph_step": 4, - "langgraph_node": "my_task", - "langgraph_triggers": ("__pregel_push",), - "langgraph_path": ( - "__pregel_push", - ("__pregel_pull", "my_workflow"), - 2, - True, - ), - "langgraph_checkpoint_ns": AnyStr(), - }, - "parent_ids": [ - AnyStr(), - AnyStr(), - ], - }, - { - "event": "on_chain_start", - "data": {"input": {"number": 1}}, - "name": "my_task", - "tags": ["seq:step:1"], - "run_id": AnyStr(), - "metadata": { - "thread_id": "1", - "ls_integration": "langgraph", - "langgraph_step": 4, - "langgraph_node": "my_task", - "langgraph_triggers": ("__pregel_push",), - "langgraph_path": ( - "__pregel_push", - ("__pregel_pull", "my_workflow"), - 2, - True, - ), - "langgraph_checkpoint_ns": AnyStr(), - }, - "parent_ids": [ - AnyStr(), - AnyStr(), - ], - }, - { - "event": "on_chain_stream", - "run_id": AnyStr(), - "name": "my_task", - "tags": ["seq:step:1"], - "metadata": { - "thread_id": "1", - "ls_integration": "langgraph", - "langgraph_step": 4, - "langgraph_node": "my_task", - "langgraph_triggers": ("__pregel_push",), - "langgraph_path": ( - "__pregel_push", - ("__pregel_pull", "my_workflow"), - 2, - True, - ), - "langgraph_checkpoint_ns": AnyStr(), - }, - "data": {"chunk": 2}, - "parent_ids": [ - AnyStr(), - AnyStr(), - ], - }, - { - "event": "on_chain_end", - "data": {"output": 2, "input": {"number": 1}}, - "run_id": AnyStr(), - "name": "my_task", - "tags": ["seq:step:1"], - "metadata": { - "thread_id": "1", - "ls_integration": "langgraph", - "langgraph_step": 4, - "langgraph_node": "my_task", - "langgraph_triggers": ("__pregel_push",), - "langgraph_path": ( - "__pregel_push", - ("__pregel_pull", "my_workflow"), - 2, - True, - ), - "langgraph_checkpoint_ns": AnyStr(), - }, - "parent_ids": [ - AnyStr(), - AnyStr(), - ], - }, - { - "event": "on_chain_stream", - "run_id": AnyStr(), - "name": "my_workflow", - "tags": ["graph:step:4"], - "metadata": { - "thread_id": "1", - "ls_integration": "langgraph", - "langgraph_step": 4, - "langgraph_node": "my_workflow", - "langgraph_triggers": ("__start__",), - "langgraph_path": ("__pregel_pull", "my_workflow"), - "langgraph_checkpoint_ns": AnyStr(), - }, - "data": {"chunk": "done"}, - "parent_ids": [AnyStr()], - }, - { - "event": "on_chain_stream", - "run_id": AnyStr(), - "name": "LangGraph", - "tags": [], - "metadata": {"thread_id": "1", "ls_integration": "langgraph"}, - "data": {"chunk": {"my_task": 2}}, - "parent_ids": [], - }, - { - "event": "on_chain_end", - "data": {"output": "done", "input": 1}, - "run_id": AnyStr(), - "name": "my_workflow", - "tags": ["graph:step:4"], - "metadata": { - "thread_id": "1", - "ls_integration": "langgraph", - "langgraph_step": 4, - "langgraph_node": "my_workflow", - "langgraph_triggers": ("__start__",), - "langgraph_path": ("__pregel_pull", "my_workflow"), - "langgraph_checkpoint_ns": AnyStr(), - }, - "parent_ids": [AnyStr()], - }, - { - "event": "on_chain_stream", - "run_id": AnyStr(), - "name": "LangGraph", - "tags": [], - "metadata": {"thread_id": "1", "ls_integration": "langgraph"}, - "data": {"chunk": {"my_workflow": "done"}}, - "parent_ids": [], - }, - { - "event": "on_chain_end", - "data": {"output": "done"}, - "run_id": AnyStr(), - "name": "LangGraph", - "tags": [], - "metadata": {"thread_id": "1", "ls_integration": "langgraph"}, - "parent_ids": [], - }, + events = [c async for c in my_workflow.astream_events(1, thread1)] + assert [(event["event"], event["name"]) for event in events] == [ + ("on_chain_start", "LangGraph"), + ("on_chain_start", "my_workflow"), + ("on_chain_start", "my_task"), + ("on_chain_stream", "my_task"), + ("on_chain_end", "my_task"), + ("on_chain_stream", "LangGraph"), + ("on_chain_start", "task_with_exception"), + ("on_chain_start", "my_task"), + ("on_chain_stream", "my_task"), + ("on_chain_end", "my_task"), + ("on_chain_stream", "my_workflow"), + ("on_chain_stream", "LangGraph"), + ("on_chain_end", "my_workflow"), + ("on_chain_stream", "LangGraph"), + ("on_chain_end", "LangGraph"), ] + root_events = [event for event in events if event["name"] == "LangGraph"] + for event in root_events: + assert event["tags"] == [] + assert event["metadata"]["thread_id"] == "1" + assert event["metadata"]["ls_integration"] == "langgraph" + assert event["parent_ids"] == [] + + workflow_events = [event for event in events if event["name"] == "my_workflow"] + for event in workflow_events: + assert event["tags"] == ["graph:step:4"] + metadata = event["metadata"] + assert metadata["thread_id"] == "1" + assert metadata["ls_integration"] == "langgraph" + assert metadata["langgraph_step"] == 4 + assert metadata["langgraph_node"] == "my_workflow" + assert metadata["langgraph_triggers"] == ("__start__",) + assert metadata["langgraph_path"] == ("__pregel_pull", "my_workflow") + assert isinstance(metadata["langgraph_checkpoint_ns"], str) + assert len(event["parent_ids"]) == 1 + + task_events = [event for event in events if event["name"] == "my_task"] + assert len(task_events) == 6 + for event in task_events: + assert event["tags"] == ["seq:step:1"] + metadata = event["metadata"] + assert metadata["thread_id"] == "1" + assert metadata["ls_integration"] == "langgraph" + assert metadata["langgraph_step"] == 4 + assert metadata["langgraph_node"] == "my_task" + assert metadata["langgraph_triggers"] == ("__pregel_push",) + assert metadata["langgraph_path"][:2] == ( + "__pregel_push", + ("__pregel_pull", "my_workflow"), + ) + assert metadata["langgraph_path"][-1] is True + assert isinstance(metadata["langgraph_checkpoint_ns"], str) + assert len(event["parent_ids"]) == 2 + + task_with_exception_event = next( + event for event in events if event["name"] == "task_with_exception" + ) + assert task_with_exception_event["event"] == "on_chain_start" + assert task_with_exception_event["data"] == {"input": {"number": 1}} + assert task_with_exception_event["tags"] == ["seq:step:1"] + metadata = task_with_exception_event["metadata"] + assert metadata["thread_id"] == "1" + assert metadata["ls_integration"] == "langgraph" + assert metadata["langgraph_step"] == 4 + assert metadata["langgraph_node"] == "task_with_exception" + assert metadata["langgraph_triggers"] == ("__pregel_push",) + assert metadata["langgraph_path"][:2] == ( + "__pregel_push", + ("__pregel_pull", "my_workflow"), + ) + assert metadata["langgraph_path"][-1] is True + assert isinstance(metadata["langgraph_checkpoint_ns"], str) + assert len(task_with_exception_event["parent_ids"]) == 2 + + assert [ + event["data"] + for event in events + if event["event"] == "on_chain_stream" and event["name"] == "LangGraph" + ] == [ + {"chunk": {"my_task": 2}}, + {"chunk": {"my_task": 2}}, + {"chunk": {"my_workflow": "done"}}, + ] + assert [ + event["data"] + for event in events + if event["event"] == "on_chain_stream" and event["name"] == "my_task" + ] == [{"chunk": 2}, {"chunk": 2}] + assert [ + event["data"] + for event in events + if event["event"] == "on_chain_end" and event["name"] == "my_task" + ] == [ + {"output": 2, "input": {"number": 1}}, + {"output": 2, "input": {"number": 1}}, + ] + assert [ + event["data"] + for event in events + if event["event"] == "on_chain_stream" and event["name"] == "my_workflow" + ] == [{"chunk": "done"}] + assert [ + event["data"] + for event in events + if event["event"] == "on_chain_end" and event["name"] == "my_workflow" + ] == [{"output": "done", "input": 1}] + assert events[-1]["data"] == {"output": "done"} + @pytest.mark.parametrize("with_timeout", [False, "inner", "outer", "both"]) @pytest.mark.parametrize("subgraph_persist", [True, False]) diff --git a/libs/langgraph/tests/test_utils.py b/libs/langgraph/tests/test_utils.py index 65735683b..d962727b8 100644 --- a/libs/langgraph/tests/test_utils.py +++ b/libs/langgraph/tests/test_utils.py @@ -25,6 +25,7 @@ from langgraph._internal._config import ( _merge_callbacks, ensure_config, get_callback_manager_for_config, + merge_configs, ) from langgraph._internal._fields import ( _is_optional_type, @@ -520,6 +521,159 @@ def test_ensure_config_metadata_later_wins_per_key() -> None: assert merged["metadata"]["shared"] == "from_b" +def test_merge_configs_merges_metadata_lc_versions() -> None: + a = { + "metadata": { + "lc_versions": {"langgraph": "1.2.4"}, + "lc_agent_name": "agent", + } + } + b = {"metadata": {"lc_versions": {"langchain-core": "1.2.0"}}} + merged = merge_configs(a, b) + assert merged["metadata"]["lc_versions"] == { + "langgraph": "1.2.4", + "langchain-core": "1.2.0", + } + assert merged["metadata"]["lc_agent_name"] == "agent" + + +def test_ensure_config_merges_metadata_lc_versions() -> None: + a = { + "metadata": { + "lc_versions": {"langgraph": "1.2.4"}, + "lc_agent_name": "agent", + } + } + b = {"metadata": {"lc_versions": {"langchain-core": "1.2.0"}}} + merged = ensure_config(a, b) + assert merged["metadata"]["lc_versions"] == { + "langgraph": "1.2.4", + "langchain-core": "1.2.0", + } + assert merged["metadata"]["lc_agent_name"] == "agent" + + +@pytest.mark.parametrize("merge", [merge_configs, ensure_config]) +def test_metadata_lc_versions_later_values_win_without_recursive_merge( + merge: Callable[..., RunnableConfig], +) -> None: + a = { + "metadata": { + "lc_versions": { + "langgraph": "1.2.4", + "nested": {"only_a": "A", "shared": "from_a"}, + } + } + } + b = { + "metadata": { + "lc_versions": { + "langchain-core": "1.2.0", + "nested": {"only_b": "B", "shared": "from_b"}, + } + } + } + merged = merge(a, b) + assert merged["metadata"]["lc_versions"] == { + "langgraph": "1.2.4", + "langchain-core": "1.2.0", + "nested": {"only_b": "B", "shared": "from_b"}, + } + + +@pytest.mark.parametrize("merge", [merge_configs, ensure_config]) +def test_metadata_nested_mappings_other_than_lc_versions_are_replaced( + merge: Callable[..., RunnableConfig], +) -> None: + a = {"metadata": {"versions": {"langgraph": "1.2.4"}, "mode": "bound"}} + b = { + "metadata": { + "versions": {"langchain-core": "1.2.0"}, + "mode": {"source": "runtime"}, + } + } + merged = merge(a, b) + assert merged["metadata"]["versions"] == {"langchain-core": "1.2.0"} + assert merged["metadata"]["mode"] == {"source": "runtime"} + + +@pytest.mark.parametrize("merge", [merge_configs, ensure_config]) +def test_metadata_non_mapping_values_later_wins( + merge: Callable[..., RunnableConfig], +) -> None: + a = {"metadata": {"lc_versions": {"langgraph": "1.2.4"}, "mode": "bound"}} + b = {"metadata": {"lc_versions": "runtime", "mode": {"source": "runtime"}}} + merged = merge(a, b) + assert merged["metadata"]["lc_versions"] == "runtime" + assert merged["metadata"]["mode"] == {"source": "runtime"} + + +@pytest.mark.parametrize("merge", [merge_configs, ensure_config]) +def test_metadata_lc_versions_merge_does_not_mutate_inputs( + merge: Callable[..., RunnableConfig], +) -> None: + a_versions = {"langgraph": "1.2.4"} + b_versions = {"langchain-core": "1.2.0"} + a = {"metadata": {"lc_versions": a_versions}} + b = {"metadata": {"lc_versions": b_versions}} + merged = merge(a, b) + assert a == {"metadata": {"lc_versions": {"langgraph": "1.2.4"}}} + assert b == {"metadata": {"lc_versions": {"langchain-core": "1.2.0"}}} + assert merged["metadata"]["lc_versions"] is not a_versions + assert merged["metadata"]["lc_versions"] is not b_versions + + merged["metadata"]["lc_versions"]["langgraph"] = "changed" + assert a_versions == {"langgraph": "1.2.4"} + assert b_versions == {"langchain-core": "1.2.0"} + + +@pytest.mark.parametrize("merge", [merge_configs, ensure_config]) +def test_metadata_single_sided_mapping_values_are_copied( + merge: Callable[..., RunnableConfig], +) -> None: + base_versions = {"langgraph": "1.2.4"} + new_versions = {"langchain-core": "1.2.0"} + + merged_base_only = merge({"metadata": {"lc_versions": base_versions}}) + merged_new_only = merge( + {"metadata": {}}, + {"metadata": {"lc_versions": new_versions}}, + ) + + assert merged_base_only["metadata"]["lc_versions"] is not base_versions + assert merged_new_only["metadata"]["lc_versions"] is not new_versions + + merged_base_only["metadata"]["lc_versions"]["langgraph"] = "changed" + merged_new_only["metadata"]["lc_versions"]["langchain-core"] = "changed" + assert base_versions == {"langgraph": "1.2.4"} + assert new_versions == {"langchain-core": "1.2.0"} + + +@pytest.mark.parametrize("merge", [merge_configs, ensure_config]) +def test_metadata_empty_incoming_preserves_base_lc_versions( + merge: Callable[..., RunnableConfig], +) -> None: + a = {"metadata": {"lc_versions": {"langgraph": "1.2.4"}}} + b = {"metadata": {"lc_versions": {}}} + merged = merge(a, b) + assert merged["metadata"]["lc_versions"] == {"langgraph": "1.2.4"} + + +@pytest.mark.parametrize("merge", [merge_configs, ensure_config]) +def test_metadata_lc_versions_accumulate_across_more_than_two_configs( + merge: Callable[..., RunnableConfig], +) -> None: + a = {"metadata": {"lc_versions": {"langgraph": "1.2.4"}}} + b = {"metadata": {"lc_versions": {"langchain-core": "1.2.0"}}} + c = {"metadata": {"lc_versions": {"langchain": "1.1.0"}}} + merged = merge(a, b, c) + assert merged["metadata"]["lc_versions"] == { + "langgraph": "1.2.4", + "langchain-core": "1.2.0", + "langchain": "1.1.0", + } + + def test_ensure_config_merges_tags_across_configs() -> None: a = {"tags": ["alpha"]} b = {"tags": ["beta"]} diff --git a/libs/langgraph/uv.lock b/libs/langgraph/uv.lock index 2f1e6f906..d40d7f82f 100644 --- a/libs/langgraph/uv.lock +++ b/libs/langgraph/uv.lock @@ -1350,7 +1350,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.4.0" +version = "1.4.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -1363,9 +1363,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/2b/fffaff399d20a56d40b9562fa19701e91abd72d8c9d9bc8c2673077b56b6/langchain_core-1.4.7.tar.gz", hash = "sha256:7a825d77de0a3f39adbd9d09612a75e85527e14a52c1601089bcc062972d9f2b", size = 952522, upload-time = "2026-06-12T19:23:57.588Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" }, + { url = "https://files.pythonhosted.org/packages/de/3e/dcdffa60078ae7b3a00ebb4cbbf1a204a14c3609983c604886523a7d4418/langchain_core-1.4.7-py3-none-any.whl", hash = "sha256:bcadd51951140ecdcba98311dbd931ba5de02a5ba8a2288dad5069c1eea2a13d", size = 554941, upload-time = "2026-06-12T19:23:55.826Z" }, ] [[package]] @@ -1454,7 +1454,7 @@ test = [ [package.metadata] requires-dist = [ - { name = "langchain-core", specifier = ">=1.4.0,<2" }, + { name = "langchain-core", specifier = ">=1.4.7,<2" }, { name = "langgraph-checkpoint", editable = "../checkpoint" }, { name = "langgraph-prebuilt", editable = "../prebuilt" }, { name = "langgraph-sdk", editable = "../sdk-py" }, diff --git a/libs/prebuilt/uv.lock b/libs/prebuilt/uv.lock index 3f20a0d02..1b62b4175 100644 --- a/libs/prebuilt/uv.lock +++ b/libs/prebuilt/uv.lock @@ -253,7 +253,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.4.0" +version = "1.4.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -266,9 +266,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/2b/fffaff399d20a56d40b9562fa19701e91abd72d8c9d9bc8c2673077b56b6/langchain_core-1.4.7.tar.gz", hash = "sha256:7a825d77de0a3f39adbd9d09612a75e85527e14a52c1601089bcc062972d9f2b", size = 952522, upload-time = "2026-06-12T19:23:57.588Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" }, + { url = "https://files.pythonhosted.org/packages/de/3e/dcdffa60078ae7b3a00ebb4cbbf1a204a14c3609983c604886523a7d4418/langchain_core-1.4.7-py3-none-any.whl", hash = "sha256:bcadd51951140ecdcba98311dbd931ba5de02a5ba8a2288dad5069c1eea2a13d", size = 554941, upload-time = "2026-06-12T19:23:55.826Z" }, ] [[package]] @@ -298,7 +298,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "langchain-core", specifier = ">=1.4.0,<2" }, + { name = "langchain-core", specifier = ">=1.4.7,<2" }, { name = "langgraph-checkpoint", editable = "../checkpoint" }, { name = "langgraph-prebuilt", editable = "." }, { name = "langgraph-sdk", editable = "../sdk-py" }, diff --git a/libs/sdk-py/uv.lock b/libs/sdk-py/uv.lock index cbaef4a31..bf24f1383 100644 --- a/libs/sdk-py/uv.lock +++ b/libs/sdk-py/uv.lock @@ -266,7 +266,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.4.0" +version = "1.4.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -279,9 +279,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/2b/fffaff399d20a56d40b9562fa19701e91abd72d8c9d9bc8c2673077b56b6/langchain_core-1.4.7.tar.gz", hash = "sha256:7a825d77de0a3f39adbd9d09612a75e85527e14a52c1601089bcc062972d9f2b", size = 952522, upload-time = "2026-06-12T19:23:57.588Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" }, + { url = "https://files.pythonhosted.org/packages/de/3e/dcdffa60078ae7b3a00ebb4cbbf1a204a14c3609983c604886523a7d4418/langchain_core-1.4.7-py3-none-any.whl", hash = "sha256:bcadd51951140ecdcba98311dbd931ba5de02a5ba8a2288dad5069c1eea2a13d", size = 554941, upload-time = "2026-06-12T19:23:55.826Z" }, ] [[package]] @@ -311,7 +311,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "langchain-core", specifier = ">=1.4.0,<2" }, + { name = "langchain-core", specifier = ">=1.4.7,<2" }, { name = "langgraph-checkpoint", editable = "../checkpoint" }, { name = "langgraph-prebuilt", editable = "../prebuilt" }, { name = "langgraph-sdk", editable = "." },