fix(core): adapt tracer to langsmith ls.address agent addressing

- No `default` project when `LANGSMITH_AGENT_*` env vars are set, so the
  env address isn't sent beside a project.
- A project named in code clears the env address on the run.
- `LangChainTracer` takes an `address`, filled from `ls.address(...)`
  tracing contexts and addressed `@traceable` parents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kiewan VillatelandClaude Opus 5.5 committed 2026-09-28 11:21:25 +02:00
1 parent 94ee02b304
commit 2d47e8dad5
4 files changed
+160 -101

No files matched your search

@@ -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
+25 -18
View File
@@ -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[
+36 -16
View File
@@ -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))
@@ -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(