mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-08 02:37:52 +02:00
fix(langgraph): merge lc_versions config metadata (#8052)
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
Generated
+4
-4
@@ -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" },
|
||||
|
||||
Generated
+4
-4
@@ -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" },
|
||||
|
||||
Generated
+4
-4
@@ -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 = "." },
|
||||
|
||||
Reference in New Issue
Block a user