a7d6d88f6f
CI / changes (push) Has been cancelled
CI / cd libs/checkpoint (push) Has been cancelled
CI / cd libs/checkpoint-conformance (push) Has been cancelled
CI / cd libs/checkpoint-postgres (push) Has been cancelled
CI / cd libs/checkpoint-sqlite (push) Has been cancelled
CI / cd libs/cli (push) Has been cancelled
CI / cd libs/prebuilt (push) Has been cancelled
CI / cd libs/sdk-py (push) Has been cancelled
CI / cd libs/langgraph (push) Has been cancelled
CI / Check SDK methods matching (push) Has been cancelled
CI / Check CLI schema hasn't changed #3.13 (push) Has been cancelled
CI / CLI integration test (push) Has been cancelled
CI / sdk-py integration test (push) Has been cancelled
CI / CI Success (push) Has been cancelled
baseline / benchmark (push) Has been cancelled
Deploy Redirects to GitHub Pages / deploy (push) Has been cancelled
800 lines
27 KiB
Python
800 lines
27 KiB
Python
import functools
|
|
import sys
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from typing import (
|
|
Annotated,
|
|
Any,
|
|
ForwardRef,
|
|
Literal,
|
|
Optional,
|
|
TypeVar,
|
|
Union,
|
|
)
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import langsmith
|
|
import pytest
|
|
from langchain_core.callbacks import BaseCallbackHandler, CallbackManager
|
|
from langchain_core.runnables import RunnableConfig
|
|
from langchain_core.tracers import LangChainTracer
|
|
from typing_extensions import NotRequired, Required, TypedDict
|
|
|
|
from langgraph._internal._config import (
|
|
_is_not_empty,
|
|
_merge_callbacks,
|
|
ensure_config,
|
|
get_callback_manager_for_config,
|
|
merge_configs,
|
|
)
|
|
from langgraph._internal._fields import (
|
|
_is_optional_type,
|
|
get_enhanced_type_hints,
|
|
get_field_default,
|
|
)
|
|
from langgraph._internal._runnable import is_async_callable, is_async_generator
|
|
from langgraph.constants import END
|
|
from langgraph.graph import StateGraph
|
|
from langgraph.graph.state import CompiledStateGraph
|
|
|
|
# ruff: noqa: UP045, UP007
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
|
|
def test_is_async() -> None:
|
|
async def func() -> None:
|
|
pass
|
|
|
|
assert is_async_callable(func)
|
|
wrapped_func = functools.wraps(func)(func)
|
|
assert is_async_callable(wrapped_func)
|
|
|
|
def sync_func() -> None:
|
|
pass
|
|
|
|
assert not is_async_callable(sync_func)
|
|
wrapped_sync_func = functools.wraps(sync_func)(sync_func)
|
|
assert not is_async_callable(wrapped_sync_func)
|
|
|
|
class AsyncFuncCallable:
|
|
async def __call__(self) -> None:
|
|
pass
|
|
|
|
runnable = AsyncFuncCallable()
|
|
assert is_async_callable(runnable)
|
|
wrapped_runnable = functools.wraps(runnable)(runnable)
|
|
assert is_async_callable(wrapped_runnable)
|
|
|
|
class SyncFuncCallable:
|
|
def __call__(self) -> None:
|
|
pass
|
|
|
|
sync_runnable = SyncFuncCallable()
|
|
assert not is_async_callable(sync_runnable)
|
|
wrapped_sync_runnable = functools.wraps(sync_runnable)(sync_runnable)
|
|
assert not is_async_callable(wrapped_sync_runnable)
|
|
|
|
|
|
def test_is_generator() -> None:
|
|
async def gen():
|
|
yield
|
|
|
|
assert is_async_generator(gen)
|
|
|
|
wrapped_gen = functools.wraps(gen)(gen)
|
|
assert is_async_generator(wrapped_gen)
|
|
|
|
def sync_gen():
|
|
yield
|
|
|
|
assert not is_async_generator(sync_gen)
|
|
wrapped_sync_gen = functools.wraps(sync_gen)(sync_gen)
|
|
assert not is_async_generator(wrapped_sync_gen)
|
|
|
|
class AsyncGenCallable:
|
|
async def __call__(self):
|
|
yield
|
|
|
|
runnable = AsyncGenCallable()
|
|
assert is_async_generator(runnable)
|
|
wrapped_runnable = functools.wraps(runnable)(runnable)
|
|
assert is_async_generator(wrapped_runnable)
|
|
|
|
class SyncGenCallable:
|
|
def __call__(self):
|
|
yield
|
|
|
|
sync_runnable = SyncGenCallable()
|
|
assert not is_async_generator(sync_runnable)
|
|
wrapped_sync_runnable = functools.wraps(sync_runnable)(sync_runnable)
|
|
assert not is_async_generator(wrapped_sync_runnable)
|
|
|
|
|
|
@pytest.fixture
|
|
def rt_graph() -> CompiledStateGraph:
|
|
class State(TypedDict):
|
|
foo: int
|
|
node_run_id: int
|
|
|
|
def node(_: State):
|
|
from langsmith import get_current_run_tree # type: ignore
|
|
|
|
return {"node_run_id": get_current_run_tree().id} # type: ignore
|
|
|
|
graph = StateGraph(State)
|
|
graph.add_node(node)
|
|
graph.set_entry_point("node")
|
|
graph.add_edge("node", END)
|
|
return graph.compile()
|
|
|
|
|
|
def test_runnable_callable_tracing_nested(rt_graph: CompiledStateGraph) -> None:
|
|
with patch("langsmith.client.Client", spec=langsmith.Client) as mock_client:
|
|
with patch("langchain_core.tracers.langchain.get_client") as mock_get_client:
|
|
mock_get_client.return_value = mock_client
|
|
with langsmith.tracing_context(enabled=True):
|
|
res = rt_graph.invoke({"foo": 1})
|
|
assert isinstance(res["node_run_id"], uuid.UUID)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.version_info < (3, 11),
|
|
reason="Python 3.11+ is required for async contextvars support",
|
|
)
|
|
async def test_runnable_callable_tracing_nested_async(
|
|
rt_graph: CompiledStateGraph,
|
|
) -> None:
|
|
with patch("langsmith.client.Client", spec=langsmith.Client) as mock_client:
|
|
with patch("langchain_core.tracers.langchain.get_client") as mock_get_client:
|
|
mock_get_client.return_value = mock_client
|
|
with langsmith.tracing_context(enabled=True):
|
|
res = await rt_graph.ainvoke({"foo": 1})
|
|
assert isinstance(res["node_run_id"], uuid.UUID)
|
|
|
|
|
|
def test_is_optional_type():
|
|
assert _is_optional_type(None)
|
|
assert not _is_optional_type(type(None))
|
|
assert _is_optional_type(Optional[list])
|
|
assert not _is_optional_type(int)
|
|
assert _is_optional_type(Optional[Literal[1, 2, 3]])
|
|
assert not _is_optional_type(Literal[1, 2, 3])
|
|
assert _is_optional_type(Optional[list[int]])
|
|
assert _is_optional_type(Optional[dict[str, int]])
|
|
assert not _is_optional_type(list[int | None])
|
|
assert _is_optional_type(Union[str | None, int | None])
|
|
assert _is_optional_type(Union[str | None | int | None, float | None | dict | None])
|
|
assert not _is_optional_type(Union[str | int, float | dict])
|
|
|
|
assert _is_optional_type(Union[int, None])
|
|
assert _is_optional_type(Union[str, None, int])
|
|
assert _is_optional_type(Union[None, str, int])
|
|
assert not _is_optional_type(Union[int, str])
|
|
|
|
assert not _is_optional_type(Any) # Do we actually want this?
|
|
assert _is_optional_type(Optional[Any])
|
|
|
|
class MyClass:
|
|
pass
|
|
|
|
assert _is_optional_type(Optional[MyClass])
|
|
assert not _is_optional_type(MyClass)
|
|
assert _is_optional_type(Optional[ForwardRef("MyClass")])
|
|
assert not _is_optional_type(ForwardRef("MyClass"))
|
|
|
|
assert _is_optional_type(Optional[list[int] | dict[str, int | None]])
|
|
assert not _is_optional_type(Union[list[int], dict[str, int | None]])
|
|
|
|
assert _is_optional_type(Optional[Callable[[int], str]])
|
|
assert not _is_optional_type(Callable[[int], str | None])
|
|
|
|
T = TypeVar("T")
|
|
assert _is_optional_type(Optional[T])
|
|
assert not _is_optional_type(T)
|
|
|
|
U = TypeVar("U", bound=T | None) # type: ignore
|
|
assert _is_optional_type(U)
|
|
|
|
|
|
def test_is_required():
|
|
class MyBaseTypedDict(TypedDict):
|
|
val_1: Required[str | None]
|
|
val_2: Required[str]
|
|
val_3: NotRequired[str]
|
|
val_4: NotRequired[str | None]
|
|
val_5: Annotated[NotRequired[int], "foo"]
|
|
val_6: NotRequired[Annotated[int, "foo"]]
|
|
val_7: Annotated[Required[int], "foo"]
|
|
val_8: Required[Annotated[int, "foo"]]
|
|
val_9: str | None
|
|
val_10: str
|
|
|
|
annos = MyBaseTypedDict.__annotations__
|
|
assert get_field_default("val_1", annos["val_1"], MyBaseTypedDict) == ...
|
|
assert get_field_default("val_2", annos["val_2"], MyBaseTypedDict) == ...
|
|
assert get_field_default("val_3", annos["val_3"], MyBaseTypedDict) is None
|
|
assert get_field_default("val_4", annos["val_4"], MyBaseTypedDict) is None
|
|
# See https://peps.python.org/pep-0655/#interaction-with-annotated
|
|
assert get_field_default("val_5", annos["val_5"], MyBaseTypedDict) is None
|
|
assert get_field_default("val_6", annos["val_6"], MyBaseTypedDict) is None
|
|
assert get_field_default("val_7", annos["val_7"], MyBaseTypedDict) == ...
|
|
assert get_field_default("val_8", annos["val_8"], MyBaseTypedDict) == ...
|
|
assert get_field_default("val_9", annos["val_9"], MyBaseTypedDict) is None
|
|
assert get_field_default("val_10", annos["val_10"], MyBaseTypedDict) == ...
|
|
|
|
class MyChildDict(MyBaseTypedDict):
|
|
val_11: int
|
|
val_11b: int | None
|
|
val_11c: int | None | str
|
|
|
|
class MyGrandChildDict(MyChildDict, total=False):
|
|
val_12: int
|
|
val_13: Required[str]
|
|
|
|
cannos = MyChildDict.__annotations__
|
|
gcannos = MyGrandChildDict.__annotations__
|
|
assert get_field_default("val_11", cannos["val_11"], MyChildDict) == ...
|
|
assert get_field_default("val_11b", cannos["val_11b"], MyChildDict) is None
|
|
assert get_field_default("val_11c", cannos["val_11c"], MyChildDict) is None
|
|
assert get_field_default("val_12", gcannos["val_12"], MyGrandChildDict) is None
|
|
assert get_field_default("val_9", gcannos["val_9"], MyGrandChildDict) is None
|
|
assert get_field_default("val_13", gcannos["val_13"], MyGrandChildDict) == ...
|
|
|
|
|
|
def test_enhanced_type_hints() -> None:
|
|
from dataclasses import dataclass
|
|
from typing import Annotated
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
class MyTypedDict(TypedDict):
|
|
val_1: str
|
|
val_2: int = 42
|
|
val_3: str = "default"
|
|
|
|
hints = list(get_enhanced_type_hints(MyTypedDict))
|
|
assert len(hints) == 3
|
|
assert hints[0] == ("val_1", str, None, None)
|
|
assert hints[1] == ("val_2", int, 42, None)
|
|
assert hints[2] == ("val_3", str, "default", None)
|
|
|
|
@dataclass
|
|
class MyDataclass:
|
|
val_1: str
|
|
val_2: int = 42
|
|
val_3: str = "default"
|
|
|
|
hints = list(get_enhanced_type_hints(MyDataclass))
|
|
assert len(hints) == 3
|
|
assert hints[0] == ("val_1", str, None, None)
|
|
assert hints[1] == ("val_2", int, 42, None)
|
|
assert hints[2] == ("val_3", str, "default", None)
|
|
|
|
class MyPydanticModel(BaseModel):
|
|
val_1: str
|
|
val_2: int = 42
|
|
val_3: str = Field(default="default", description="A description")
|
|
|
|
hints = list(get_enhanced_type_hints(MyPydanticModel))
|
|
assert len(hints) == 3
|
|
assert hints[0] == ("val_1", str, None, None)
|
|
assert hints[1] == ("val_2", int, 42, None)
|
|
assert hints[2] == ("val_3", str, "default", "A description")
|
|
|
|
class MyPydanticModelWithAnnotated(BaseModel):
|
|
val_1: Annotated[str, Field(description="A description")]
|
|
val_2: Annotated[int, Field(default=42)]
|
|
val_3: Annotated[
|
|
str, Field(default="default", description="Another description")
|
|
]
|
|
|
|
hints = list(get_enhanced_type_hints(MyPydanticModelWithAnnotated))
|
|
assert len(hints) == 3
|
|
assert hints[0] == ("val_1", str, None, "A description")
|
|
assert hints[1] == ("val_2", int, 42, None)
|
|
assert hints[2] == ("val_3", str, "default", "Another description")
|
|
|
|
|
|
def test_is_not_empty() -> None:
|
|
assert _is_not_empty("foo")
|
|
assert _is_not_empty("")
|
|
assert _is_not_empty(1)
|
|
assert _is_not_empty(0)
|
|
assert not _is_not_empty(None)
|
|
assert not _is_not_empty([])
|
|
assert not _is_not_empty(())
|
|
assert not _is_not_empty({})
|
|
|
|
|
|
def test_configurable_metadata() -> None:
|
|
config = {
|
|
"configurable": {
|
|
"a-key": "foo",
|
|
"somesecretval": "bar",
|
|
"sometoken": "thetoken",
|
|
"__dontinclude": "bar",
|
|
"includeme": "hi",
|
|
"andme": 42,
|
|
"nested": {"foo": "bar"},
|
|
"nooverride": -2,
|
|
"thread_id": "th-123",
|
|
"checkpoint_id": "ckpt-1",
|
|
"checkpoint_ns": "ns-1",
|
|
"task_id": "task-1",
|
|
"run_id": "run-456",
|
|
"assistant_id": "asst-789",
|
|
"graph_id": "graph-0",
|
|
"model": "gpt-4o",
|
|
"user_id": "uid-1",
|
|
"cron_id": "cron-1",
|
|
"langgraph_auth_user_id": "user-1",
|
|
},
|
|
"metadata": {"nooverride": 18},
|
|
}
|
|
merged = ensure_config(config)
|
|
metadata = merged["metadata"]
|
|
assert set(metadata) == {
|
|
"nooverride",
|
|
"assistant_id",
|
|
"thread_id",
|
|
"checkpoint_id",
|
|
"run_id",
|
|
"graph_id",
|
|
"checkpoint_ns",
|
|
"task_id",
|
|
}
|
|
assert metadata["nooverride"] == 18
|
|
|
|
|
|
def test_callback_manager_copies_whitelisted_configurable_ids_to_metadata() -> None:
|
|
config = {
|
|
"configurable": {
|
|
"thread_id": "th-123",
|
|
"checkpoint_id": "ckpt-1",
|
|
"checkpoint_ns": "ns-1",
|
|
"task_id": "task-1",
|
|
"run_id": "run-456",
|
|
"assistant_id": "asst-789",
|
|
"graph_id": "graph-0",
|
|
"model": "gpt-4o",
|
|
"user_id": "uid-1",
|
|
"cron_id": "cron-1",
|
|
"langgraph_auth_user_id": "user-1",
|
|
},
|
|
"metadata": {
|
|
"thread_id": "from-metadata",
|
|
"nooverride": 18,
|
|
},
|
|
}
|
|
manager = ensure_config(config)
|
|
callback_manager = get_callback_manager_for_config(manager)
|
|
assert callback_manager.metadata == {
|
|
"thread_id": "from-metadata",
|
|
"nooverride": 18,
|
|
"checkpoint_id": "ckpt-1",
|
|
"checkpoint_ns": "ns-1",
|
|
"task_id": "task-1",
|
|
"run_id": "run-456",
|
|
"assistant_id": "asst-789",
|
|
"graph_id": "graph-0",
|
|
}
|
|
|
|
|
|
def test_callback_manager_copies_configurable_ids_to_tracing_metadata() -> None:
|
|
tracer = LangChainTracer(client=MagicMock())
|
|
config: RunnableConfig = {
|
|
"configurable": {
|
|
"thread_id": "th-123",
|
|
"checkpoint_id": "ckpt-1",
|
|
"checkpoint_ns": "ns-1",
|
|
"task_id": "task-1",
|
|
"run_id": "run-456",
|
|
"assistant_id": "asst-789",
|
|
"graph_id": "graph-0",
|
|
"model": "gpt-4o",
|
|
"user_id": "uid-1",
|
|
"cron_id": "cron-1",
|
|
"langgraph_auth_user_id": "user-1",
|
|
"includeme": "hi",
|
|
"andme": 42,
|
|
"__dontinclude": "bar",
|
|
"some_api_key": "secret",
|
|
"custom_setting": {"nested": True},
|
|
},
|
|
"metadata": {
|
|
"thread_id": "from-metadata",
|
|
"user_id": "from-metadata-user",
|
|
"includeme": "from-metadata",
|
|
},
|
|
"callbacks": [tracer],
|
|
}
|
|
|
|
manager = ensure_config(config)
|
|
callback_manager = get_callback_manager_for_config(manager)
|
|
handlers = callback_manager.handlers
|
|
tracers = [handler for handler in handlers if isinstance(handler, LangChainTracer)]
|
|
assert len(tracers) == 1
|
|
tracer = tracers[0]
|
|
assert tracer.tracing_metadata == {
|
|
"checkpoint_id": "ckpt-1",
|
|
"checkpoint_ns": "ns-1",
|
|
"task_id": "task-1",
|
|
"run_id": "run-456",
|
|
"assistant_id": "asst-789",
|
|
"graph_id": "graph-0",
|
|
"model": "gpt-4o",
|
|
"cron_id": "cron-1",
|
|
"andme": 42,
|
|
"includeme": "hi",
|
|
"thread_id": "th-123",
|
|
"user_id": "uid-1",
|
|
}
|
|
|
|
|
|
class _TrackingCB(BaseCallbackHandler):
|
|
"""Minimal callback handler used only as a sentinel for merge tests."""
|
|
|
|
def __init__(self, tag: str) -> None:
|
|
self.tag = tag
|
|
|
|
def __eq__(self, other: object) -> bool:
|
|
return isinstance(other, _TrackingCB) and self.tag == other.tag
|
|
|
|
def __hash__(self) -> int:
|
|
return hash(self.tag)
|
|
|
|
|
|
def test_merge_callbacks_none_base_list_new() -> None:
|
|
cb = _TrackingCB("a")
|
|
merged = _merge_callbacks(None, [cb])
|
|
assert merged == [cb]
|
|
|
|
|
|
def test_merge_callbacks_list_base_list_new() -> None:
|
|
a, b = _TrackingCB("a"), _TrackingCB("b")
|
|
merged = _merge_callbacks([a], [b])
|
|
assert merged == [a, b]
|
|
|
|
|
|
def test_merge_callbacks_list_base_manager_new() -> None:
|
|
a = _TrackingCB("a")
|
|
mgr = CallbackManager(handlers=[_TrackingCB("b")])
|
|
merged = _merge_callbacks([a], mgr)
|
|
assert isinstance(merged, CallbackManager)
|
|
assert _TrackingCB("a") in merged.handlers
|
|
assert _TrackingCB("b") in merged.handlers
|
|
|
|
|
|
def test_merge_callbacks_manager_base_list_new() -> None:
|
|
mgr = CallbackManager(handlers=[_TrackingCB("a")])
|
|
b = _TrackingCB("b")
|
|
merged = _merge_callbacks(mgr, [b])
|
|
assert isinstance(merged, CallbackManager)
|
|
assert _TrackingCB("a") in merged.handlers
|
|
assert _TrackingCB("b") in merged.handlers
|
|
|
|
|
|
def test_merge_callbacks_manager_base_manager_new() -> None:
|
|
mgr_a = CallbackManager(handlers=[_TrackingCB("a")])
|
|
mgr_b = CallbackManager(handlers=[_TrackingCB("b")])
|
|
merged = _merge_callbacks(mgr_a, mgr_b)
|
|
assert isinstance(merged, CallbackManager)
|
|
assert _TrackingCB("a") in merged.handlers
|
|
assert _TrackingCB("b") in merged.handlers
|
|
|
|
|
|
def test_merge_callbacks_none_base_none_new() -> None:
|
|
merged = _merge_callbacks(None, None)
|
|
assert merged is None
|
|
|
|
|
|
def test_ensure_config_merges_configurable_across_configs() -> None:
|
|
a = {"configurable": {"ls_agent_type": "root"}}
|
|
b = {"configurable": {"thread_id": "T1"}}
|
|
merged = ensure_config(a, b)
|
|
assert merged["configurable"]["ls_agent_type"] == "root"
|
|
assert merged["configurable"]["thread_id"] == "T1"
|
|
|
|
|
|
def test_ensure_config_configurable_later_wins_per_key() -> None:
|
|
a = {"configurable": {"shared": "from_a", "only_a": "A"}}
|
|
b = {"configurable": {"shared": "from_b", "only_b": "B"}}
|
|
merged = ensure_config(a, b)
|
|
assert merged["configurable"]["shared"] == "from_b" # later wins per key
|
|
assert merged["configurable"]["only_a"] == "A"
|
|
assert merged["configurable"]["only_b"] == "B"
|
|
|
|
|
|
def test_ensure_config_explicit_configurable_replaces_ambient() -> None:
|
|
# An explicit checkpoint coordinate (here a new thread_id) starts a fresh
|
|
# lineage and drops the ambient run context (e.g. a parent task's
|
|
# checkpoint_ns), so a child graph does not inherit it.
|
|
from langchain_core.runnables.config import var_child_runnable_config
|
|
|
|
token = var_child_runnable_config.set(
|
|
{"configurable": {"checkpoint_ns": "p:parent-task", "checkpoint_id": "cid"}}
|
|
)
|
|
try:
|
|
merged = ensure_config({"configurable": {"thread_id": "child"}})
|
|
finally:
|
|
var_child_runnable_config.reset(token)
|
|
assert merged["configurable"]["thread_id"] == "child"
|
|
assert "checkpoint_ns" not in merged["configurable"]
|
|
assert "checkpoint_id" not in merged["configurable"]
|
|
|
|
|
|
def test_ensure_config_ambient_inherited_when_no_explicit_configurable() -> None:
|
|
# With no explicit configurable, the ambient run context is inherited
|
|
# unchanged (stateless subgraph / interrupt-resume pattern).
|
|
from langchain_core.runnables.config import var_child_runnable_config
|
|
|
|
token = var_child_runnable_config.set(
|
|
{"configurable": {"checkpoint_ns": "p:parent-task"}}
|
|
)
|
|
try:
|
|
merged = ensure_config({"tags": ["t"]})
|
|
finally:
|
|
var_child_runnable_config.reset(token)
|
|
assert merged["configurable"]["checkpoint_ns"] == "p:parent-task"
|
|
|
|
|
|
def test_ensure_config_explicit_configurables_still_merge_over_ambient() -> None:
|
|
# A new thread_id drops the ambient, but explicit configs still shallow-merge
|
|
# among themselves, so a with_config(...) value (ls_agent_type) survives
|
|
# alongside an invoke-time thread_id.
|
|
from langchain_core.runnables.config import var_child_runnable_config
|
|
|
|
token = var_child_runnable_config.set(
|
|
{"configurable": {"checkpoint_ns": "p:parent-task"}}
|
|
)
|
|
try:
|
|
merged = ensure_config(
|
|
{"configurable": {"ls_agent_type": "root"}},
|
|
{"configurable": {"thread_id": "child"}},
|
|
)
|
|
finally:
|
|
var_child_runnable_config.reset(token)
|
|
assert merged["configurable"]["ls_agent_type"] == "root"
|
|
assert merged["configurable"]["thread_id"] == "child"
|
|
assert "checkpoint_ns" not in merged["configurable"]
|
|
|
|
|
|
def test_ensure_config_non_coordinate_config_keeps_ambient_checkpoint_ns() -> None:
|
|
# A nested subagent is invoked with a non-coordinate configurable key
|
|
# (ls_agent_type) and no thread_id; it must keep the inherited checkpoint_ns
|
|
# so it stays a discoverable child of the parent run (deepagents `task` tool).
|
|
from langchain_core.runnables.config import var_child_runnable_config
|
|
|
|
token = var_child_runnable_config.set(
|
|
{"configurable": {"thread_id": "parent", "checkpoint_ns": "p:parent-task"}}
|
|
)
|
|
try:
|
|
merged = ensure_config({"configurable": {"ls_agent_type": "subagent"}})
|
|
finally:
|
|
var_child_runnable_config.reset(token)
|
|
assert merged["configurable"]["ls_agent_type"] == "subagent"
|
|
assert merged["configurable"]["checkpoint_ns"] == "p:parent-task"
|
|
assert merged["configurable"]["thread_id"] == "parent"
|
|
|
|
|
|
def test_ensure_config_same_thread_id_still_clears_ambient() -> None:
|
|
# A child that reuses the parent's thread_id is still addressing its own root
|
|
# namespace on that thread, so the parent task's checkpoint_ns must not leak
|
|
# in; otherwise the child writes state that get_state cannot read back.
|
|
from langchain_core.runnables.config import var_child_runnable_config
|
|
|
|
token = var_child_runnable_config.set(
|
|
{"configurable": {"thread_id": "shared", "checkpoint_ns": "p:parent-task"}}
|
|
)
|
|
try:
|
|
merged = ensure_config({"configurable": {"thread_id": "shared"}})
|
|
finally:
|
|
var_child_runnable_config.reset(token)
|
|
assert merged["configurable"]["thread_id"] == "shared"
|
|
assert "checkpoint_ns" not in merged["configurable"]
|
|
|
|
|
|
def test_ensure_config_merges_metadata_across_configs() -> None:
|
|
a = {"metadata": {"user_id": "U1"}}
|
|
b = {"metadata": {"correlation_id": "C1"}}
|
|
merged = ensure_config(a, b)
|
|
assert merged["metadata"]["user_id"] == "U1"
|
|
assert merged["metadata"]["correlation_id"] == "C1"
|
|
|
|
|
|
def test_ensure_config_metadata_later_wins_per_key() -> None:
|
|
a = {"metadata": {"shared": "from_a"}}
|
|
b = {"metadata": {"shared": "from_b"}}
|
|
merged = ensure_config(a, b)
|
|
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"]}
|
|
merged = ensure_config(a, b)
|
|
assert merged["tags"] == ["alpha", "beta"]
|
|
|
|
|
|
def test_ensure_config_tags_concat_preserves_order_and_duplicates() -> None:
|
|
# Plain concat (matches merge_configs in this file — no dedup, no sort).
|
|
a = {"tags": ["shared", "alpha"]}
|
|
b = {"tags": ["shared", "beta"]}
|
|
merged = ensure_config(a, b)
|
|
assert merged["tags"] == ["shared", "alpha", "shared", "beta"]
|
|
|
|
|
|
def test_ensure_config_merges_callbacks_across_configs() -> None:
|
|
a_cb = _TrackingCB("a")
|
|
b_cb = _TrackingCB("b")
|
|
merged = ensure_config({"callbacks": [a_cb]}, {"callbacks": [b_cb]})
|
|
assert merged["callbacks"] == [a_cb, b_cb]
|
|
|
|
|
|
def test_ensure_config_none_inputs_ignored() -> None:
|
|
# mixed with None should not raise
|
|
merged = ensure_config(None, {"tags": ["t"]}, None)
|
|
assert merged["tags"] == ["t"]
|
|
|
|
|
|
def test_ensure_config_empty_inputs() -> None:
|
|
# everything empty -> defaults
|
|
merged = ensure_config()
|
|
assert merged["tags"] == []
|
|
assert merged["configurable"] == {}
|
|
assert merged["callbacks"] is None
|