diff --git a/libs/core/langchain_core/callbacks/manager.py b/libs/core/langchain_core/callbacks/manager.py index 77387d0a79..ca30e246af 100644 --- a/libs/core/langchain_core/callbacks/manager.py +++ b/libs/core/langchain_core/callbacks/manager.py @@ -2427,7 +2427,7 @@ def _configure( from langchain_core.tracers.context import ( # noqa: PLC0415 _configure_hooks, - _get_tracer_project, + _get_tracer_destination, _tracing_v2_is_enabled, tracing_v2_callback_var, ) @@ -2505,7 +2505,7 @@ def _configure( ) raise RuntimeError(msg) - tracer_project = _get_tracer_project() + tracer_project, tracer_address = _get_tracer_destination() debug = _get_debug() if verbose or debug or tracing_v2_enabled_: if verbose and not any( @@ -2531,6 +2531,7 @@ def _configure( try: handler = LangChainTracer( project_name=tracer_project, + address=tracer_address, client=( run_tree.client if run_tree is not None diff --git a/libs/core/langchain_core/tracers/context.py b/libs/core/langchain_core/tracers/context.py index 0e6fbb666d..a543b111b3 100644 --- a/libs/core/langchain_core/tracers/context.py +++ b/libs/core/langchain_core/tracers/context.py @@ -111,10 +111,13 @@ def _get_trace_callbacks( callback_manager: CallbackManager | AsyncCallbackManager | None = None, ) -> Callbacks: if _tracing_v2_is_enabled(): - project_name_ = project_name or _get_tracer_project() + address = None + if not project_name: + project_name, address = _get_tracer_destination() tracer = tracing_v2_callback_var.get() or LangChainTracer( - project_name=project_name_, + project_name=project_name, example_id=example_id, + address=address, ) if callback_manager is None: cb = cast("Callbacks", [tracer]) @@ -139,24 +142,28 @@ def _tracing_v2_is_enabled() -> bool | Literal["local"]: def _get_tracer_project() -> str | None: + return _get_tracer_destination()[0] + + +def _get_tracer_destination() -> tuple[str | None, Any]: + """Get the `(project, address)` langsmith's tracing context sends runs to. + + At most one is set. Both are `None` when the `LANGSMITH_AGENT_*` env vars + address the runs and nothing is named in code. + """ tracing_context = ls_rh.get_tracing_context() run_tree = tracing_context["parent"] - if run_tree is None and tracing_context["project_name"] is not None: - return cast("str", tracing_context["project_name"]) - return getattr( - run_tree, - "session_name", - getattr( - # Note, if people are trying to nest @traceable functions and the - # tracing_v2_enabled context manager, this will likely mess up the - # tree structure. - tracing_v2_callback_var.get(), - "project", - # `None` only when the environment addresses runs to a LangSmith - # agent and no project is configured. - _get_default_project_name(), - ), - ) + if run_tree is None: + if tracing_context["project_name"] is not None: + return cast("str", tracing_context["project_name"]), None + # Only set on langsmith versions with agent addressing. + if (address := tracing_context.get("address")) is not None: + return None, address + return _get_default_project_name(), None + # Note, if people are trying to nest @traceable functions and the + # tracing_v2_enabled context manager, this will likely mess up the + # tree structure. + return run_tree.session_name, getattr(run_tree, "address", None) _configure_hooks: list[ diff --git a/libs/core/langchain_core/tracers/langchain.py b/libs/core/langchain_core/tracers/langchain.py index 7605b1c9ea..9956d11d48 100644 --- a/libs/core/langchain_core/tracers/langchain.py +++ b/libs/core/langchain_core/tracers/langchain.py @@ -81,24 +81,22 @@ def get_client() -> Client: return rt.get_cached_client() +def _env_addresses_agent() -> bool: + """Whether the `LANGSMITH_AGENT_*` env vars address runs to an agent.""" + return any( + ls_utils.get_env_var(name, namespaces=("LANGSMITH",)) + for name in ("AGENT_ID", "AGENT_ENVIRONMENT") + ) + + def _get_default_project_name() -> str | None: - """Get the project to trace to when none is passed to the tracer. + """Get the project to trace to when none is named in code. - When the environment addresses runs to a LangSmith agent - (`LANGSMITH_AGENT_ID` / `LANGSMITH_AGENT_ENVIRONMENT`), only a project the - user configured is returned. Falling back to the invented `'default'` - project would send it alongside the agent, and the API rejects a run - addressed to both. - - Agent addressing requires `langsmith>=0.14.0`; older versions have no agent - to address, so the helpers are looked up rather than imported. + `None` when the `LANGSMITH_AGENT_*` env vars are set: langsmith then sends + the run to that agent, and the API rejects a run that also names a project. """ - get_agent_id = getattr(ls_utils, "get_tracer_agent_id", None) - get_agent_environment = getattr(ls_utils, "get_tracer_agent_environment", None) - if (get_agent_id is not None and get_agent_id() is not None) or ( - get_agent_environment is not None and get_agent_environment() is not None - ): - return ls_utils.get_tracer_project(return_default_value=False) + if _env_addresses_agent(): + return None return ls_utils.get_tracer_project() @@ -165,6 +163,7 @@ class LangChainTracer(BaseTracer): tags: list[str] | None = None, *, metadata: Mapping[str, str] | None = None, + address: Any = None, **kwargs: Any, ) -> None: """Initialize the LangChain tracer. @@ -183,13 +182,25 @@ class LangChainTracer(BaseTracer): metadata: Additional metadata to include if it isn't already in the run. Defaults to None. + address: A `langsmith.address(...)` to send runs to instead of a + project. + + Ignored if `project_name` is set. + + !!! warning "Experimental" + + Requires a langsmith version with agent addressing, which + is in beta. **kwargs: Additional keyword arguments. """ super().__init__(**kwargs) self.example_id = ( UUID(example_id) if isinstance(example_id, str) else example_id ) - self.project_name = project_name or _get_default_project_name() + self.address = None if project_name else address + self.project_name = project_name or ( + None if address is not None else _get_default_project_name() + ) self.client = client or get_client() self.tags = tags or [] self.latest_run: Run | None = None @@ -232,6 +243,7 @@ class LangChainTracer(BaseTracer): project_name=self.project_name, client=self.client, tags=merged_tags, + address=self.address, metadata=merged_metadata, run_map=self.run_map, order_map=self.order_map, @@ -241,6 +253,14 @@ class LangChainTracer(BaseTracer): def _start_trace(self, run: Run) -> None: if self.project_name: run.session_name = self.project_name + # Absent on langsmith versions without agent addressing. + if "address" in type(run).model_fields: + if self.project_name: + # A project named in code wins over an address from the env. + run.address = None + elif self.address is not None: + run.session_name = None # type: ignore[assignment,unused-ignore] + run.address = self.address if self.tags is not None: if run.tags: run.tags = sorted(set(run.tags + self.tags)) diff --git a/libs/core/tests/unit_tests/tracers/test_langchain.py b/libs/core/tests/unit_tests/tracers/test_langchain.py index 325ac6dec1..402b8c82f7 100644 --- a/libs/core/tests/unit_tests/tracers/test_langchain.py +++ b/libs/core/tests/unit_tests/tracers/test_langchain.py @@ -3,12 +3,13 @@ import threading import time import unittest.mock import uuid +from collections.abc import Iterator from typing import Any from uuid import UUID +import langsmith import pytest from langsmith import Client -from langsmith import utils as ls_utils from langsmith.run_helpers import tracing_context from langsmith.run_trees import RunTree from langsmith.utils import get_env_var, get_tracer_project @@ -157,21 +158,41 @@ def test_correct_get_tracer_project( assert projects == [expected_project_name] -def _clear_tracer_env_caches() -> None: - getters: tuple[Any, ...] = ( - get_env_var, - get_tracer_project, - getattr(ls_utils, "get_tracer_agent_id", None), - getattr(ls_utils, "get_tracer_agent_environment", None), - ) - for getter in getters: - if hasattr(getter, "cache_clear"): - getter.cache_clear() +_TRACER_ENV_VARS = ( + "LANGSMITH_PROJECT", + "LANGCHAIN_PROJECT", + "LANGCHAIN_SESSION", + "HOSTED_LANGSERVE_PROJECT_NAME", + "LANGSMITH_AGENT_ID", + "LANGSMITH_AGENT_ENVIRONMENT", +) + +_requires_address = pytest.mark.skipif( + not hasattr(langsmith, "address"), + reason="agent addressing requires a langsmith version with `ls.address`", +) + + +@pytest.fixture +def tracer_env() -> Iterator[pytest.MonkeyPatch]: + """Start from no tracer env vars, and leave nothing in the env caches.""" + _clear_env_caches() + with pytest.MonkeyPatch.context() as mp: + for k in _TRACER_ENV_VARS: + mp.delenv(k, raising=False) + yield mp + _clear_env_caches() + + +def _clear_env_caches() -> None: + get_env_var.cache_clear() # type: ignore[attr-defined] + get_tracer_project.cache_clear() @pytest.mark.parametrize( ("envvars", "expected_project_name"), [ + ({}, "default"), ({"LANGSMITH_AGENT_ID": "my-agent"}, None), ({"LANGSMITH_AGENT_ENVIRONMENT": "development"}, None), ( @@ -187,70 +208,80 @@ def _clear_tracer_env_caches() -> None: "LANGSMITH_AGENT_ENVIRONMENT": "development", "LANGSMITH_PROJECT": "configured", }, - "configured", + None, ), ], ids=[ - "no 'default' project with agent id", - "no 'default' project with agent environment", - "no 'default' project with agent pair", - "keep a configured project with agent pair", + "'default' project without agent env vars", + "no project with agent id", + "no project with agent environment", + "no project with agent pair", + "no env project with agent pair", ], ) -def test_tracer_project_with_agent_addressing( - envvars: dict[str, str], expected_project_name: str | None +def test_tracer_project_with_agent_env( + tracer_env: pytest.MonkeyPatch, + envvars: dict[str, str], + expected_project_name: str | None, ) -> None: - if not hasattr(ls_utils, "get_tracer_agent_environment"): - pytest.skip("agent addressing requires langsmith>=0.14.0") - _clear_tracer_env_caches() - try: - with pytest.MonkeyPatch.context() as mp: - for k in ( - "LANGSMITH_PROJECT", - "LANGCHAIN_PROJECT", - "LANGCHAIN_SESSION", - "HOSTED_LANGSERVE_PROJECT_NAME", - "LANGSMITH_AGENT_ID", - "LANGSMITH_AGENT_ENVIRONMENT", - ): - mp.delenv(k, raising=False) - for k, v in envvars.items(): - mp.setenv(k, v) - - tracer = LangChainTracer(client=unittest.mock.MagicMock(spec=Client)) - assert tracer.project_name == expected_project_name - finally: - # The env getters are cached, so leave nothing behind for later tests. - _clear_tracer_env_caches() + for k, v in envvars.items(): + tracer_env.setenv(k, v) + tracer = LangChainTracer(client=unittest.mock.MagicMock(spec=Client)) + assert tracer.project_name == expected_project_name -def test_automatic_tracing_omits_default_project_with_agent_addressing() -> None: - if not hasattr(ls_utils, "get_tracer_agent_environment"): - pytest.skip("agent addressing requires langsmith>=0.14.0") - _clear_tracer_env_caches() - try: - with pytest.MonkeyPatch.context() as mp: - for k in ( - "LANGSMITH_PROJECT", - "LANGCHAIN_PROJECT", - "LANGCHAIN_SESSION", - "HOSTED_LANGSERVE_PROJECT_NAME", - ): - mp.delenv(k, raising=False) - mp.setenv("LANGSMITH_TRACING", "true") - mp.setenv("LANGSMITH_AGENT_ID", "my-agent") - mp.setenv("LANGSMITH_AGENT_ENVIRONMENT", "development") +def _posted_destinations(client: unittest.mock.MagicMock) -> list[tuple[Any, Any]]: + return [ + (call.kwargs.get("session_name"), call.kwargs.get("address")) + for call in client.create_run.call_args_list + ] - client = unittest.mock.MagicMock(spec=Client) - with tracing_context(client=client): - RunnableLambda(lambda x: x).invoke(1) - assert client.create_run.call_count == 1 - posted = client.create_run.call_args.kwargs - assert posted.get("session_name") is None - assert posted.get("agent_id") == "my-agent" - finally: - _clear_tracer_env_caches() +def _nested_chain() -> RunnableLambda[int, int]: + inner: RunnableLambda[int, int] = RunnableLambda(lambda x: x) + return RunnableLambda(inner.invoke) + + +@_requires_address +def test_automatic_tracing_uses_agent_env(tracer_env: pytest.MonkeyPatch) -> None: + tracer_env.setenv("LANGSMITH_TRACING", "true") + tracer_env.setenv("LANGSMITH_AGENT_ID", "my-agent") + tracer_env.setenv("LANGSMITH_AGENT_ENVIRONMENT", "development") + client = unittest.mock.MagicMock(spec=Client) + with tracing_context(client=client): + _nested_chain().invoke(1) + expected = langsmith.address(agent_id="my-agent", agent_environment="development") + assert _posted_destinations(client) == [(None, expected)] * 2 + + +@_requires_address +def test_project_in_code_wins_over_agent_env(tracer_env: pytest.MonkeyPatch) -> None: + tracer_env.setenv("LANGSMITH_AGENT_ID", "my-agent") + tracer_env.setenv("LANGSMITH_AGENT_ENVIRONMENT", "development") + client = unittest.mock.MagicMock(spec=Client) + tracer = LangChainTracer(client=client, project_name="configured") + _nested_chain().invoke(1, {"callbacks": [tracer]}) + assert _posted_destinations(client) == [("configured", None)] * 2 + + +@_requires_address +def test_address_from_tracing_context(tracer_env: pytest.MonkeyPatch) -> None: + tracer_env.setenv("LANGSMITH_TRACING", "true") + client = unittest.mock.MagicMock(spec=Client) + support = langsmith.address(agent_id="support", agent_environment="staging") + with support.tracing_context(client=client): + _nested_chain().invoke(1) + assert _posted_destinations(client) == [(None, support)] * 2 + + +@_requires_address +def test_address_from_traceable_parent(tracer_env: pytest.MonkeyPatch) -> None: + tracer_env.setenv("LANGSMITH_TRACING", "true") + client = unittest.mock.MagicMock(spec=Client) + support = langsmith.address(agent_id="support", agent_environment="staging") + with support.trace("parent", client=client): + _nested_chain().invoke(1) + assert _posted_destinations(client) == [(None, support)] * 3 @pytest.mark.parametrize(