Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-langsmith for
openSUSE:Factory checked in at 2026-07-22 19:06:39
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-langsmith (Old)
and /work/SRC/openSUSE:Factory/.python-langsmith.new.24530 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-langsmith"
Wed Jul 22 19:06:39 2026 rev:13 rq:1367136 version:0.10.9
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-langsmith/python-langsmith.changes
2026-07-21 23:09:14.435918277 +0200
+++
/work/SRC/openSUSE:Factory/.python-langsmith.new.24530/python-langsmith.changes
2026-07-22 19:08:42.980479020 +0200
@@ -1,0 +2,5 @@
+Wed Jul 22 13:38:41 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to 0.10.9
+
+-------------------------------------------------------------------
Old:
----
langsmith-0.10.7.tar.gz
New:
----
langsmith-0.10.9.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-langsmith.spec ++++++
--- /var/tmp/diff_new_pack.Q51sL8/_old 2026-07-22 19:08:43.448495055 +0200
+++ /var/tmp/diff_new_pack.Q51sL8/_new 2026-07-22 19:08:43.452495192 +0200
@@ -17,7 +17,7 @@
Name: python-langsmith
-Version: 0.10.7
+Version: 0.10.9
Release: 0
Summary: Client library for the LangSmith LLM tracing and evaluation
platform
License: MIT
++++++ langsmith-0.10.7.tar.gz -> langsmith-0.10.9.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.7/.bumpversion.cfg
new/langsmith-0.10.9/.bumpversion.cfg
--- old/langsmith-0.10.7/.bumpversion.cfg 2020-02-02 01:00:00.000000000
+0100
+++ new/langsmith-0.10.9/.bumpversion.cfg 2020-02-02 01:00:00.000000000
+0100
@@ -1,5 +1,5 @@
[bumpversion]
-current_version = 0.10.7
+current_version = 0.10.9
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
serialize = {major}.{minor}.{patch}
search = {current_version}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.7/PKG-INFO
new/langsmith-0.10.9/PKG-INFO
--- old/langsmith-0.10.7/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: langsmith
-Version: 0.10.7
+Version: 0.10.9
Summary: Client library to connect to the LangSmith Observability and
Evaluation Platform.
Project-URL: Homepage, https://smith.langchain.com/
Project-URL: Documentation, https://docs.smith.langchain.com/
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.7/langsmith/__init__.py
new/langsmith-0.10.9/langsmith/__init__.py
--- old/langsmith-0.10.7/langsmith/__init__.py 2020-02-02 01:00:00.000000000
+0100
+++ new/langsmith-0.10.9/langsmith/__init__.py 2020-02-02 01:00:00.000000000
+0100
@@ -41,11 +41,15 @@
from langsmith.run_trees import RunTree, configure
from langsmith.testing._internal import test, unit
from langsmith.utils import ContextThreadPoolExecutor
- from langsmith.uuid import uuid7, uuid7_from_datetime
+ from langsmith.uuid import (
+ compute_run_id_for_secondary_replica,
+ uuid7,
+ uuid7_from_datetime,
+ )
# Avoid calling into importlib on every call to __version__
-__version__ = "0.10.7"
+__version__ = "0.10.9"
version = __version__ # for backwards compatibility
# Metadata key to hide a traced run from LangSmith's Messages View.
@@ -144,6 +148,10 @@
from langsmith.run_trees import configure
return configure
+ elif name == "compute_run_id_for_secondary_replica":
+ from langsmith.uuid import compute_run_id_for_secondary_replica
+
+ return compute_run_id_for_secondary_replica
elif name == "uuid7":
from langsmith.uuid import uuid7
@@ -238,6 +246,7 @@
"get_current_run_tree",
"set_run_metadata",
"ContextThreadPoolExecutor",
+ "compute_run_id_for_secondary_replica",
"uuid7",
"uuid7_from_datetime",
"set_runtime_overrides",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.7/langsmith/async_client.py
new/langsmith-0.10.9/langsmith/async_client.py
--- old/langsmith-0.10.7/langsmith/async_client.py 2020-02-02
01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/langsmith/async_client.py 2020-02-02
01:00:00.000000000 +0100
@@ -52,6 +52,9 @@
from langsmith._openapi_client.resources.online_evaluators import (
AsyncOnlineEvaluatorsResource as AsyncEvaluatorsResource,
)
+ from langsmith._openapi_client.resources.public.public import (
+ AsyncPublicResource,
+ )
from langsmith._openapi_client.resources.sandboxes.sandboxes import (
AsyncSandboxesResource,
)
@@ -339,6 +342,11 @@
"""Access the traces resource (query, list_runs)."""
return self._langsmith_api.traces
+ @property
+ def public(self) -> AsyncPublicResource:
+ """Access the public shared-run resource."""
+ return self._langsmith_api.public
+
async def __aenter__(self) -> AsyncClient:
"""Enter the async client."""
if self._cache is not None:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.7/langsmith/client.py
new/langsmith-0.10.9/langsmith/client.py
--- old/langsmith-0.10.7/langsmith/client.py 2020-02-02 01:00:00.000000000
+0100
+++ new/langsmith-0.10.9/langsmith/client.py 2020-02-02 01:00:00.000000000
+0100
@@ -272,6 +272,9 @@
from langsmith._openapi_client.resources.online_evaluators import (
AsyncOnlineEvaluatorsResource as AsyncEvaluatorsResource,
)
+ from langsmith._openapi_client.resources.public.public import (
+ AsyncPublicResource,
+ )
from langsmith._openapi_client.resources.runs import AsyncRunsResource
from langsmith._openapi_client.resources.sandboxes.sandboxes import (
AsyncSandboxesResource,
@@ -1524,6 +1527,12 @@
_check_backend_version(self.info.version)
return self._get_langsmith_api().traces
+ @property
+ def public(self) -> AsyncPublicResource:
+ """Access the public shared-run resource."""
+ _check_backend_version(self.info.version)
+ return self._get_langsmith_api().public
+
def _dump_failed_trace(
self,
body_fn: Callable[[], bytes],
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.7/langsmith/env/_runtime_env.py
new/langsmith-0.10.9/langsmith/env/_runtime_env.py
--- old/langsmith-0.10.7/langsmith/env/_runtime_env.py 2020-02-02
01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/langsmith/env/_runtime_env.py 2020-02-02
01:00:00.000000000 +0100
@@ -177,6 +177,9 @@
"LANGCHAIN_PROJECT",
"LANGCHAIN_SESSION",
"LANGSMITH_RUNS_ENDPOINTS",
+ # Control-plane signing secrets the substring filter misses; excluded
here.
+ "LANGSMITH_SIGNING_JWKS",
+ "LANGSMITH_SANDBOX_CALLBACK_SIGNING_JWK",
}
langchain_metadata = {
k: v
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.7/langsmith/integrations/strands_agents/exporter.py
new/langsmith-0.10.9/langsmith/integrations/strands_agents/exporter.py
--- old/langsmith-0.10.7/langsmith/integrations/strands_agents/exporter.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/langsmith/integrations/strands_agents/exporter.py
2020-02-02 01:00:00.000000000 +0100
@@ -166,6 +166,7 @@
input_messages: list[dict[str, Any]] = []
output_messages: list[dict[str, Any]] = []
+ tool_output_completion: str | None = None
remaining_events: list[Any] = []
for event in span.events:
@@ -173,13 +174,31 @@
attrs = dict(event.attributes) if event.attributes else {}
if name == "gen_ai.choice":
- # The final model response is the only true output
- msg = self._event_to_message(
- name, attrs, tool_id_to_name=tool_id_to_name
- )
- if tool_call_id:
- msg["tool_call_id"] = tool_call_id
- output_messages.append(msg)
+ if operation == "execute_tool":
+ # In an 'execute_tool' span, Strands adds tool results
+ # in a gen_ai.choice event. We convert it to expected
+ # tool result block.
+ raw = attrs.get("message", "[]")
+ try:
+ content = json.loads(raw) if isinstance(raw, str) else
raw
+ except (json.JSONDecodeError, TypeError):
+ content = raw
+ tool_output_completion = json.dumps(
+ {
+ "role": "tool",
+ "content": self._extract_tool_output(content),
+ "name": tool_name,
+ "tool_call_id": tool_call_id,
+ }
+ )
+ else:
+ # The final model response is the only true output
+ msg = self._event_to_message(
+ name, attrs, tool_id_to_name=tool_id_to_name
+ )
+ if tool_call_id:
+ msg["tool_call_id"] = tool_call_id
+ output_messages.append(msg)
elif name in self._MESSAGE_EVENTS:
msg = self._event_to_message(
name, attrs, tool_id_to_name=tool_id_to_name
@@ -198,7 +217,9 @@
new_attrs: dict[str, Any] = dict(span_attrs)
if input_messages:
new_attrs["gen_ai.prompt"] = json.dumps({"messages":
input_messages})
- if output_messages:
+ if tool_output_completion is not None:
+ new_attrs["gen_ai.completion"] = tool_output_completion
+ elif output_messages:
new_attrs["gen_ai.completion"] = json.dumps(output_messages[-1])
# Map gen_ai.operation.name to langsmith.span.kind (run type).
@@ -372,6 +393,35 @@
return msg
@staticmethod
+ def _extract_tool_output(content: Any) -> str:
+ """Extract plain-text output from tool result content blocks.
+
+ Strands tool spans emit their output as a list of content blocks
+ (e.g. ``[{"text": "Hippopotamus"}]``). This extracts the text and
+ returns it as a plain string for ``gen_ai.completion`` on tool spans.
+ """
+ if isinstance(content, str):
+ return content
+
+ if isinstance(content, list):
+ text_parts: list[str] = []
+ for block in content:
+ if isinstance(block, dict) and "text" in block and len(block)
== 1:
+ text_parts.append(block["text"])
+ else:
+ try:
+ text_parts.append(json.dumps(block,
ensure_ascii=False))
+ except (TypeError, ValueError):
+ text_parts.append(str(block))
+ if text_parts:
+ return "\n".join(text_parts) if len(text_parts) > 1 else
text_parts[0]
+
+ try:
+ return json.dumps(content, ensure_ascii=False)
+ except (TypeError, ValueError):
+ return str(content)
+
+ @staticmethod
def _stringify_tool_content(content: Any) -> str:
"""Ensure tool message content is a string.
@@ -395,12 +445,14 @@
Bedrock uses implicit typing (the key name *is* the type)::
{"text": "hello"}
+ {"reasoningContent": {"reasoningText": {"text": "...",
"signature": "..."}}}
{"toolUse": {"toolUseId": "x", "name": "f", "input": {...}}}
{"toolResult": {"toolUseId": "x", "status": "success", "content":
[...]}}
LangSmith expects explicit ``type`` fields::
{"type": "text", "text": "hello"}
+ {"type": "thinking", "thinking": "...", "signature": "..."}
{"type": "tool_use", "id": "x", "name": "f", "input": {...}}
{
"type": "tool_result",
@@ -417,6 +469,15 @@
if "text" in block and len(block) == 1:
return {"type": "text", "text": block["text"]}
+ if "reasoningContent" in block:
+ rc = block["reasoningContent"]
+ reasoning_text = rc.get("reasoningText", {})
+ return {
+ "type": "thinking",
+ "thinking": reasoning_text.get("text", ""),
+ "signature": reasoning_text.get("signature", ""),
+ }
+
if "toolUse" in block:
tu = block["toolUse"]
return {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.7/langsmith/run_trees.py
new/langsmith-0.10.9/langsmith/run_trees.py
--- old/langsmith-0.10.7/langsmith/run_trees.py 2020-02-02 01:00:00.000000000
+0100
+++ new/langsmith-0.10.9/langsmith/run_trees.py 2020-02-02 01:00:00.000000000
+0100
@@ -57,6 +57,11 @@
api_key: NotRequired[str]
auth: AuthHeaders
project_name: Optional[str]
+ primary: bool
+ """Whether this replica keeps the original run IDs.
+
+ When omitted, legacy project-based remapping behavior is preserved.
+ """
updates: Optional[dict]
client: Optional[Client]
"""Optional dedicated :class:`~langsmith.Client` for this replica.
@@ -72,7 +77,9 @@
"""
-_HEADER_SAFE_REPLICA_FIELDS: frozenset[str] = frozenset({"project_name",
"updates"})
+_HEADER_SAFE_REPLICA_FIELDS: frozenset[str] = frozenset(
+ {"project_name", "primary", "updates"}
+)
# Untrusted header-supplied replica `updates` is merged into the run, so
restrict it
# to a fail-closed allow-list. `reroot` (re-parenting control) is always kept;
@@ -126,6 +133,8 @@
for key, value in replica.items()
if key in _HEADER_SAFE_REPLICA_FIELDS
}
+ if "primary" in filtered and not isinstance(filtered["primary"], bool):
+ filtered.pop("primary")
if "updates" in filtered:
filtered["updates"] = _sanitize_header_updates(filtered.get("updates"))
return cast(WriteReplica, filtered)
@@ -702,11 +711,15 @@
run_dict.pop("parent_run_id", None)
def _remap_for_project(
- self, project_name: str, updates: Optional[dict] = None
+ self,
+ project_name: str,
+ updates: Optional[dict] = None,
+ *,
+ primary: Optional[bool] = None,
) -> dict:
"""Rewrites ids/dotted_order for a given project with optional
updates."""
run_dict = self._get_dicts_safe()
- if project_name == self.session_name:
+ if primary is None and project_name == self.session_name:
return run_dict
if updates and updates.get("reroot", False):
@@ -714,6 +727,13 @@
if distributed_parent_id:
self._slice_parent_id(distributed_parent_id, run_dict)
+ if primary:
+ dup = utils.deepish_copy(run_dict)
+ dup["session_name"] = project_name
+ if updates:
+ dup.update(updates)
+ return dup
+
old_id = run_dict["id"]
new_id = uuid7_deterministic(UUID(str(old_id)), project_name)
# trace id
@@ -760,7 +780,9 @@
for replica in self.replicas:
project_name = replica.get("project_name") or self.session_name
updates = replica.get("updates")
- run_dict = self._remap_for_project(project_name, updates)
+ run_dict = self._remap_for_project(
+ project_name, updates, primary=replica.get("primary")
+ )
api_url, api_key, service_key, tenant_id, authorization,
cookie = (
_extract_replica_auth(replica)
)
@@ -829,7 +851,9 @@
for replica in self.replicas:
project_name = replica.get("project_name") or self.session_name
updates = replica.get("updates")
- run_dict = self._remap_for_project(project_name, updates)
+ run_dict = self._remap_for_project(
+ project_name, updates, primary=replica.get("primary")
+ )
api_url, api_key, service_key, tenant_id, authorization,
cookie = (
_extract_replica_auth(replica)
)
@@ -1188,6 +1212,8 @@
api_url = item.get("api_url")
api_key = item.get("api_key")
project_name = item.get("project_name")
+ primary = item.get("primary")
+ has_primary = "primary" in item
if not isinstance(api_url, str):
logger.warning(
@@ -1210,14 +1236,22 @@
)
continue
- replicas.append(
- WriteReplica(
- api_url=api_url.rstrip("/"),
- auth=AuthHeaders(api_key=api_key),
- project_name=project_name,
- updates=None,
+ if has_primary and not isinstance(primary, bool):
+ logger.warning(
+ f"Invalid primary type in LANGSMITH_RUNS_ENDPOINTS: "
+ f"expected boolean, got {type(primary).__name__}"
)
+ continue
+
+ replica = WriteReplica(
+ api_url=api_url.rstrip("/"),
+ auth=AuthHeaders(api_key=api_key),
+ project_name=project_name,
+ updates=None,
)
+ if has_primary:
+ replica["primary"] = cast(bool, primary)
+ replicas.append(replica)
return replicas
elif isinstance(parsed, dict):
_check_endpoint_env_unset(parsed)
@@ -1283,11 +1317,10 @@
replicas: Optional[Sequence[WriteReplica]],
) -> list[WriteReplica]:
"""Convert replicas to WriteReplica format."""
- if replicas is None:
- return _get_write_replicas_from_env()
-
- # All replicas should now be WriteReplica dicts
- return list(replicas)
+ ensured = _get_write_replicas_from_env() if replicas is None else
list(replicas)
+ if sum(replica.get("primary") is True for replica in ensured) > 1:
+ raise ValueError("Only one replica can be marked as primary.")
+ return ensured
def _parse_dotted_order(dotted_order: str) -> list[tuple[datetime, UUID]]:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.7/langsmith/uuid.py
new/langsmith-0.10.9/langsmith/uuid.py
--- old/langsmith-0.10.7/langsmith/uuid.py 2020-02-02 01:00:00.000000000
+0100
+++ new/langsmith-0.10.9/langsmith/uuid.py 2020-02-02 01:00:00.000000000
+0100
@@ -9,6 +9,7 @@
import uuid as _uuid
from ._internal._uuid import uuid7 as _uuid7
+from ._internal._uuid import uuid7_deterministic as _uuid7_deterministic
def uuid7() -> _uuid.UUID:
@@ -35,4 +36,29 @@
return _uuid7(nanoseconds)
-__all__ = ["uuid7", "uuid7_from_datetime"]
+def compute_run_id_for_secondary_replica(
+ run_id: _uuid.UUID | str, project_name: str
+) -> _uuid.UUID:
+ """Compute the run ID used for a secondary tracing replica.
+
+ Args:
+ run_id: The original UUID v7 run ID.
+ project_name: The secondary replica's destination project name.
+
+ Returns:
+ uuid.UUID: The run ID used in the secondary replica destination.
+
+ Raises:
+ ValueError: If ``run_id`` is not UUID v7, or if ``project_name`` is
+ empty or invalid.
+ """
+ if not isinstance(project_name, str) or not project_name:
+ raise ValueError("project_name must be a non-empty string")
+
+ parsed_run_id = _uuid.UUID(str(run_id))
+ if parsed_run_id.version != 7:
+ raise ValueError("run_id must be a UUID v7")
+ return _uuid7_deterministic(parsed_run_id, project_name)
+
+
+__all__ = ["compute_run_id_for_secondary_replica", "uuid7",
"uuid7_from_datetime"]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.7/tests/integration_tests/test_v2_public_runs.py
new/langsmith-0.10.9/tests/integration_tests/test_v2_public_runs.py
--- old/langsmith-0.10.7/tests/integration_tests/test_v2_public_runs.py
1970-01-01 01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/tests/integration_tests/test_v2_public_runs.py
2020-02-02 01:00:00.000000000 +0100
@@ -0,0 +1,151 @@
+"""Smoke integration tests for the v2 public.runs resource.
+
+Covers ``Client.public.runs`` (retrieve, query) against a live backend. Public
+shared-run reads are authenticated by the share token in the path; the client's
+API key is created only to mint that token via ``runs.share.create``.
+"""
+
+import datetime
+import logging
+import time
+from typing import Any, Callable
+
+import pytest
+
+from langsmith import uuid7
+from langsmith.client import Client
+from langsmith.utils import get_env_var
+
+logger = logging.getLogger(__name__)
+
+# Public run fields to request; START_TIME is needed as the retrieve
coordinate.
+_SELECTS = ["ID", "NAME", "RUN_TYPE", "STATUS", "START_TIME"]
+
+
+def _wait_for_sync(
+ condition: Callable[[], bool],
+ max_sleep_time: int = 60,
+ sleep_time: int = 3,
+) -> None:
+ start = time.time()
+ while time.time() - start < max_sleep_time:
+ try:
+ if condition():
+ return
+ except Exception:
+ logger.debug("Error checking sync condition", exc_info=True)
+ time.sleep(sleep_time)
+ raise ValueError(f"Condition not met within {max_sleep_time}s")
+
+
+async def _wait_for_async(
+ condition: Callable[[], Any],
+ max_sleep_time: int = 60,
+ sleep_time: int = 3,
+) -> Any:
+ """Poll `condition` until it returns a truthy value, then return it."""
+ start = time.time()
+ while time.time() - start < max_sleep_time:
+ try:
+ result = await condition()
+ if result:
+ return result
+ except Exception:
+ logger.debug("Error checking async condition", exc_info=True)
+ time.sleep(sleep_time)
+ raise ValueError(f"Condition not met within {max_sleep_time}s")
+
+
[email protected]
+def langchain_client() -> Client:
+ get_env_var.cache_clear()
+ return Client()
+
+
[email protected]
+async def shared_run(langchain_client: Client):
+ """Create a project with one root run and share it.
+
+ Yields a dict with run_id, project_id, and share_token.
+ """
+ project_name = f"__test_v2_public_runs_{uuid7().hex[:12]}"
+ if langchain_client.has_project(project_name=project_name):
+ langchain_client.delete_project(project_name=project_name)
+
+ run_id = str(uuid7())
+ now = datetime.datetime.now(datetime.timezone.utc)
+ langchain_client.create_run(
+ id=run_id,
+ name="run_1",
+ inputs={"i": 1},
+ run_type="llm",
+ project_name=project_name,
+ start_time=now,
+ )
+
+ def _run_indexed() -> bool:
+ return (
+ next(langchain_client.list_runs(project_name=project_name,
limit=1), None)
+ is not None
+ )
+
+ _wait_for_sync(_run_indexed, max_sleep_time=90, sleep_time=2)
+
+ run = langchain_client.read_run(run_id)
+ project = langchain_client.read_project(project_name=project_name)
+ project_id = str(project.id)
+ trace_id = str(run.trace_id) if run.trace_id else run_id
+
+ async def _create_share():
+ return await langchain_client.runs.share.create(
+ run_id, session_id=project_id, trace_id=trace_id
+ )
+
+ # share.create resolves the trace root from SmithDB, which may lag
indexing.
+ resp = await _wait_for_async(_create_share, max_sleep_time=90)
+
+ try:
+ yield {
+ "run_id": run_id,
+ "project_id": project_id,
+ "share_token": resp.share_token,
+ }
+ finally:
+ if langchain_client.has_project(project_name=project_name):
+ langchain_client.delete_project(project_name=project_name)
+
+
+async def test_public_runs_query(langchain_client: Client, shared_run) -> None:
+ """public.runs.query() returns the shared trace's runs by share token."""
+ share_token = shared_run["share_token"]
+ run_id = shared_run["run_id"]
+
+ async def _ready():
+ page = await langchain_client.public.runs.query(share_token,
selects=_SELECTS)
+ return page if any(str(i.id) == run_id for i in page.items) else None
+
+ page = await _wait_for_async(_ready, max_sleep_time=90)
+ assert run_id in {str(i.id) for i in page.items}
+
+
+async def test_public_runs_retrieve(langchain_client: Client, shared_run) ->
None:
+ """public.runs.retrieve() returns a single run by (share token, id,
start_time)."""
+ share_token = shared_run["share_token"]
+ run_id = shared_run["run_id"]
+
+ async def _retrieve():
+ # Derive the exact start_time coordinate from the public read path
itself,
+ # so it matches whatever the backend stored (retrieve is a point
lookup).
+ page = await langchain_client.public.runs.query(share_token,
selects=_SELECTS)
+ item = next((i for i in page.items if str(i.id) == run_id), None)
+ if item is None or item.start_time is None:
+ return None
+ return await langchain_client.public.runs.retrieve(
+ run_id,
+ share_token=share_token,
+ selects=_SELECTS,
+ start_time=item.start_time,
+ )
+
+ run = await _wait_for_async(_retrieve, max_sleep_time=90)
+ assert str(run.id) == run_id
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.7/tests/integration_tests/test_v2_runs_share.py
new/langsmith-0.10.9/tests/integration_tests/test_v2_runs_share.py
--- old/langsmith-0.10.7/tests/integration_tests/test_v2_runs_share.py
1970-01-01 01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/tests/integration_tests/test_v2_runs_share.py
2020-02-02 01:00:00.000000000 +0100
@@ -0,0 +1,141 @@
+"""Smoke integration tests for the v2 runs.share resource.
+
+Covers ``Client.runs.share`` (create, delete) against a live backend.
+"""
+
+import datetime
+import logging
+import time
+import uuid
+from typing import Any, Callable
+
+import pytest
+
+from langsmith import uuid7
+from langsmith.client import Client
+from langsmith.utils import get_env_var
+
+logger = logging.getLogger(__name__)
+
+
+def _wait_for_sync(
+ condition: Callable[[], bool],
+ max_sleep_time: int = 60,
+ sleep_time: int = 3,
+) -> None:
+ start = time.time()
+ while time.time() - start < max_sleep_time:
+ try:
+ if condition():
+ return
+ except Exception:
+ logger.debug("Error checking sync condition", exc_info=True)
+ time.sleep(sleep_time)
+ raise ValueError(f"Condition not met within {max_sleep_time}s")
+
+
+async def _wait_for_async(
+ condition: Callable[[], Any],
+ max_sleep_time: int = 60,
+ sleep_time: int = 3,
+) -> Any:
+ """Poll `condition` until it returns a truthy value, then return it."""
+ start = time.time()
+ while time.time() - start < max_sleep_time:
+ try:
+ result = await condition()
+ if result:
+ return result
+ except Exception:
+ logger.debug("Error checking async condition", exc_info=True)
+ time.sleep(sleep_time)
+ raise ValueError(f"Condition not met within {max_sleep_time}s")
+
+
[email protected]
+def langchain_client() -> Client:
+ get_env_var.cache_clear()
+ return Client()
+
+
[email protected]
+def project_with_run(langchain_client: Client):
+ """Create a project with one root run.
+
+ Yields a dict with run_id, trace_id, and project_id (== session_id).
+ """
+ project_name = f"__test_v2_runs_share_{uuid7().hex[:12]}"
+ if langchain_client.has_project(project_name=project_name):
+ langchain_client.delete_project(project_name=project_name)
+
+ run_id = str(uuid7())
+ now = datetime.datetime.now(datetime.timezone.utc)
+ langchain_client.create_run(
+ id=run_id,
+ name="run_1",
+ inputs={"i": 1},
+ run_type="llm",
+ project_name=project_name,
+ start_time=now,
+ )
+
+ def _run_indexed() -> bool:
+ return (
+ next(langchain_client.list_runs(project_name=project_name,
limit=1), None)
+ is not None
+ )
+
+ _wait_for_sync(_run_indexed, max_sleep_time=90, sleep_time=2)
+
+ run = langchain_client.read_run(run_id)
+ project = langchain_client.read_project(project_name=project_name)
+ trace_id = str(run.trace_id) if run.trace_id else run_id
+ try:
+ yield {
+ "run_id": run_id,
+ "trace_id": trace_id,
+ "project_id": str(project.id),
+ }
+ finally:
+ if langchain_client.has_project(project_name=project_name):
+ langchain_client.delete_project(project_name=project_name)
+
+
+async def test_runs_share_create(langchain_client: Client, project_with_run)
-> None:
+ """runs.share.create() mints a valid share token for a run's trace root."""
+ run = project_with_run
+
+ async def _create():
+ return await langchain_client.runs.share.create(
+ run["run_id"],
+ session_id=run["project_id"],
+ trace_id=run["trace_id"],
+ )
+
+ # share.create resolves the trace root from SmithDB, which may lag
indexing.
+ resp = await _wait_for_async(_create, max_sleep_time=90)
+ # Raises ValueError if the token is not a valid UUID.
+ assert uuid.UUID(resp.share_token)
+
+
+async def test_runs_share_delete(langchain_client: Client, project_with_run)
-> None:
+ """runs.share.delete() removes the share token and is idempotent."""
+ run = project_with_run
+
+ async def _create():
+ return await langchain_client.runs.share.create(
+ run["run_id"],
+ session_id=run["project_id"],
+ trace_id=run["trace_id"],
+ )
+
+ await _wait_for_async(_create, max_sleep_time=90)
+
+ # Delete returns None (204) and is idempotent: a second delete also
+ # succeeds. Success is "does not raise".
+ await langchain_client.runs.share.delete(
+ run["trace_id"], session_id=run["project_id"]
+ )
+ await langchain_client.runs.share.delete(
+ run["trace_id"], session_id=run["project_id"]
+ )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.7/tests/integration_tests/wrappers/test_strands_agents.py
new/langsmith-0.10.9/tests/integration_tests/wrappers/test_strands_agents.py
---
old/langsmith-0.10.7/tests/integration_tests/wrappers/test_strands_agents.py
2020-02-02 01:00:00.000000000 +0100
+++
new/langsmith-0.10.9/tests/integration_tests/wrappers/test_strands_agents.py
2020-02-02 01:00:00.000000000 +0100
@@ -19,6 +19,7 @@
SpanExportResult,
)
from strands import Agent
+from strands.models.bedrock import BedrockModel
from strands_tools import file_read, file_write, journal, python_repl, shell
from langsmith.integrations.otel.processor import OtelExporter
@@ -183,3 +184,104 @@
roles = [message["role"] for message in prompt["messages"]]
assert "user" in roles
assert "system" in roles
+
+ tool_spans = [
+ span for span in recorder.spans if span.name.startswith("execute_tool")
+ ]
+ assert tool_spans, [span.name for span in recorder.spans]
+ for tool_span in tool_spans:
+ assert tool_span.attributes["langsmith.span.kind"] == "tool"
+ assert _span_has_completion(tool_span)
+ completion = json.loads(tool_span.attributes["gen_ai.completion"])
+ assert completion["role"] == "tool"
+ assert "content" in completion
+ assert "name" in completion
+ assert "tool_call_id" in completion
+
+
[email protected](
+ not (
+ os.getenv("AWS_ACCESS_KEY_ID")
+ or os.getenv("AWS_PROFILE")
+ or os.getenv("AWS_WEB_IDENTITY_TOKEN_FILE")
+ ),
+ reason="Live Strands/Bedrock integration test requires AWS credentials.",
+)
[email protected](
+ not os.getenv("LANGSMITH_API_KEY"),
+ reason="Live Strands/LangSmith integration test requires
LANGSMITH_API_KEY.",
+)
+def test_exporter_transforms_thinking_blocks(tmp_path, monkeypatch):
+ """Invoke a Strands Agent with extended thinking and verify thinking
blocks."""
+ (tmp_path / "otel_strands_share.py").write_text(
+ "def setup_langsmith_telemetry():\n"
+ " return 'sets up Strands OTEL tracing for LangSmith'\n",
+ encoding="utf-8",
+ )
+ monkeypatch.chdir(tmp_path)
+
+ recorder = RecordingSpanExporter()
+ langsmith_exporter = OtelExporter()
+ delegate = TeeSpanExporter(recorder, langsmith_exporter)
+ provider = TracerProvider()
+ provider.add_span_processor(
+ SimpleSpanProcessor(LangSmithSpanExporter(delegate=delegate))
+ )
+
+ import strands.telemetry.tracer as strands_tracer
+
+ previous_tracer = _install_strands_tracer(provider)
+ try:
+ model = BedrockModel(
+ model_id=MODEL_ID,
+ additional_request_fields={
+ "thinking": {"type": "enabled", "budget_tokens": 5000}
+ },
+ max_tokens=16000,
+ )
+ agent = Agent(
+ model=model,
+ tools=[file_read],
+ system_prompt=SYSTEM_PROMPT,
+ )
+
+ response = agent(INPUT)
+ provider.force_flush()
+ finally:
+ provider.shutdown()
+ strands_tracer._tracer_instance = previous_tracer
+
+ assert response is not None
+
+ llm_spans = [span for span in recorder.spans if span.name == "chat"]
+ assert llm_spans, [span.name for span in recorder.spans]
+ assert any(_span_has_prompt_text(span, INPUT) for span in llm_spans)
+ assert any(_span_has_completion(span) for span in llm_spans)
+
+ for llm_span in llm_spans:
+ assert llm_span.attributes["langsmith.span.kind"] == "llm"
+ assert llm_span.attributes["gen_ai.request.model"] == MODEL_ID
+ assert llm_span.attributes["langsmith.metadata.ls_provider"] ==
"amazon_bedrock"
+ assert llm_span.attributes["langsmith.metadata.ls_model_type"] ==
"chat"
+ assert not any(event.name.startswith("gen_ai.") for event in
llm_span.events)
+
+ found_thinking = False
+ for span in llm_spans:
+ completion_str = span.attributes.get("gen_ai.completion")
+ if not isinstance(completion_str, str):
+ continue
+ completion = json.loads(completion_str)
+ messages = completion if isinstance(completion, list) else [completion]
+ for msg in messages:
+ content = msg.get("content", [])
+ if isinstance(content, list):
+ for block in content:
+ if isinstance(block, dict) and block.get("type") ==
"thinking":
+ found_thinking = True
+ assert isinstance(block["thinking"], str)
+ assert len(block["thinking"]) > 0
+
+ assert found_thinking, (
+ "Expected at least one thinking block in LLM completions. "
+ f"Spans checked: {len(llm_spans)}"
+ )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.7/tests/unit_tests/test_env.py
new/langsmith-0.10.9/tests/unit_tests/test_env.py
--- old/langsmith-0.10.7/tests/unit_tests/test_env.py 2020-02-02
01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/tests/unit_tests/test_env.py 2020-02-02
01:00:00.000000000 +0100
@@ -1,7 +1,7 @@
import pytest
from langsmith.env import __all__ as env_all
-from langsmith.env import get_git_info
+from langsmith.env import get_git_info, get_langchain_env_var_metadata
_EXPECTED = [
"get_docker_compose_command",
@@ -32,3 +32,20 @@
assert "langsmith-sdk" in git_info["remote_url"]
except AssertionError:
pytest.skip("Git information is not available, skipping test.")
+
+
[email protected](
+ "secret_var",
+ ["LANGSMITH_SIGNING_JWKS", "LANGSMITH_SANDBOX_CALLBACK_SIGNING_JWK"],
+)
+def test_env_var_metadata_excludes_signing_secrets(
+ monkeypatch: pytest.MonkeyPatch, secret_var: str
+) -> None:
+ monkeypatch.setenv(secret_var, "super-secret-value")
+ monkeypatch.setenv("LANGSMITH_LANGGRAPH_API_VARIANT", "local")
+ monkeypatch.setenv("LANGCHAIN_REVISION_ID", "abc123")
+ get_langchain_env_var_metadata.cache_clear()
+ metadata = get_langchain_env_var_metadata()
+ assert secret_var not in metadata
+ assert metadata["LANGSMITH_LANGGRAPH_API_VARIANT"] == "local"
+ assert metadata["revision_id"] == "abc123"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.7/tests/unit_tests/test_replica_endpoints.py
new/langsmith-0.10.9/tests/unit_tests/test_replica_endpoints.py
--- old/langsmith-0.10.7/tests/unit_tests/test_replica_endpoints.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/tests/unit_tests/test_replica_endpoints.py
2020-02-02 01:00:00.000000000 +0100
@@ -8,7 +8,7 @@
import pytest
-from langsmith import Client
+from langsmith import Client, compute_run_id_for_secondary_replica
from langsmith import utils as ls_utils
from langsmith.run_trees import (
ApiKeyAuth,
@@ -77,6 +77,15 @@
assert result[1]["auth"]["api_key"] == "key2"
assert result[1].get("project_name") is None
+ def test_ensure_write_replicas_rejects_multiple_primary_replicas(self):
+ replicas = [
+ WriteReplica(project_name="primary-1", primary=True),
+ WriteReplica(project_name="primary-2", primary=True),
+ ]
+
+ with pytest.raises(ValueError, match="Only one replica"):
+ _ensure_write_replicas(replicas)
+
class TestEnvironmentVariableParsing:
"""Test environment variable parsing for LANGSMITH_RUNS_ENDPOINTS."""
@@ -357,11 +366,13 @@
"api_url": "https://api.example.com",
"api_key": "key1",
"project_name": "project-prod",
+ "primary": True,
},
{
"api_url": "https://api.example.com",
"api_key": "key2",
"project_name": "project-staging",
+ "primary": False,
},
{"api_url": "https://api.example.com", "api_key": "key3"},
]
@@ -386,6 +397,30 @@
None,
]
assert all(r["updates"] is None for r in result)
+ assert [r.get("primary") for r in result] == [True, False, None]
+
+ @pytest.mark.parametrize("primary", ["true", 1, None])
+ def test_parse_new_array_format_rejects_invalid_primary(self, primary):
+ env_var = json.dumps(
+ [
+ {
+ "api_url": "https://invalid.example.com",
+ "api_key": "invalid-key",
+ "primary": primary,
+ },
+ {
+ "api_url": "https://valid.example.com",
+ "api_key": "valid-key",
+ "primary": False,
+ },
+ ]
+ )
+
+ result = _parse_write_replicas_from_env_var(env_var)
+
+ assert len(result) == 1
+ assert result[0]["api_url"] == "https://valid.example.com"
+ assert result[0]["primary"] is False
def test_parse_object_format(self):
"""Test parsing object format."""
@@ -740,6 +775,7 @@
api_url="https://replica1.example.com",
auth=ApiKeyAuth(api_key="replica1-key"),
project_name="replica1-project",
+ primary=True,
),
WriteReplica(
api_url="https://replica2.example.com",
@@ -766,6 +802,10 @@
assert calls[0][1]["api_url"] == "https://replica1.example.com"
assert calls[1][1]["api_key"] == "replica2-key"
assert calls[1][1]["api_url"] == "https://replica2.example.com"
+ assert calls[0][1]["id"] == run_tree.id
+ assert calls[1][1]["id"] == compute_run_id_for_secondary_replica(
+ run_tree.id, "replica2-project"
+ )
def test_run_tree_with_service_auth_replicas(self):
"""Test RunTree with ServiceAuth replicas."""
@@ -825,6 +865,9 @@
call_args = client.update_run.call_args
assert call_args[1]["api_key"] == "replica-key"
assert call_args[1]["api_url"] == "https://replica.example.com"
+ assert call_args[1]["run_id"] == compute_run_id_for_secondary_replica(
+ run_tree.id, "replica-project"
+ )
def test_run_tree_preserves_env_replica_project_names(self):
client = Mock()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.7/tests/unit_tests/test_run_trees.py
new/langsmith-0.10.9/tests/unit_tests/test_run_trees.py
--- old/langsmith-0.10.7/tests/unit_tests/test_run_trees.py 2020-02-02
01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/tests/unit_tests/test_run_trees.py 2020-02-02
01:00:00.000000000 +0100
@@ -343,10 +343,18 @@
root = RunTree(name="Root", inputs={}, client=mock_client,
session_name="original")
child = root.create_child(name="Child")
- # Same project: no remapping
+ # Omitted primary preserves legacy same-project behavior.
same = child._remap_for_project("original")
assert same["id"] == child.id
+ # Explicit primary preserves IDs regardless of project.
+ primary = child._remap_for_project("replica", primary=True)
+ assert primary["id"] == child.id
+
+ # Explicit non-primary remaps even within the same project.
+ non_primary = child._remap_for_project("original", primary=False)
+ assert non_primary["id"] == uuid7_deterministic(child.id, "original")
+
# Different project: IDs remapped deterministically
r1 = child._remap_for_project("replica")
r2 = child._remap_for_project("replica")
@@ -531,6 +539,7 @@
"api_key": "injected-key",
"api_url": "https://evil.com/exfil",
"project_name": "legit-project",
+ "primary": True,
"updates": {"reroot": True},
}
]
@@ -550,9 +559,30 @@
assert "api_key" not in replica
assert "api_url" not in replica
assert replica.get("project_name") == "legit-project"
+ assert replica.get("primary") is True
assert replica.get("updates") == {"reroot": True}
+def test_from_headers_drops_non_boolean_replica_primary():
+ replicas_json = json.dumps(
+ [{"project_name": "replica-project", "primary": "false"}]
+ )
+ baggage = f"langsmith-replicas={urllib.parse.quote(replicas_json)}"
+ headers = {
+ "langsmith-trace":
"20240101T000000000000Z00000000-0000-0000-0000-000000000001",
+ "baggage": baggage,
+ }
+
+ parsed = RunTree.from_headers(headers)
+
+ assert parsed is not None
+ assert parsed.replicas is not None
+ replica = parsed.replicas[0]
+ assert replica.get("primary") is None
+ remapped = parsed._remap_for_project(replica["project_name"])
+ assert remapped["id"] != parsed.id
+
+
def test_from_headers_allowlists_update_fields():
"""Only allow-listed fields survive in an untrusted baggage replica's
`updates`."""
replicas_json = json.dumps(
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.7/tests/unit_tests/test_uuid_v7.py
new/langsmith-0.10.9/tests/unit_tests/test_uuid_v7.py
--- old/langsmith-0.10.7/tests/unit_tests/test_uuid_v7.py 2020-02-02
01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/tests/unit_tests/test_uuid_v7.py 2020-02-02
01:00:00.000000000 +0100
@@ -2,6 +2,9 @@
from typing import Any
from unittest.mock import MagicMock
+import pytest
+
+from langsmith import compute_run_id_for_secondary_replica
from langsmith._internal._uuid import uuid7, uuid7_deterministic
from langsmith.client import Client
from langsmith.run_helpers import traceable
@@ -94,6 +97,35 @@
assert update_kwargs["start_time"] == fixed_start
+def test_compute_run_id_for_secondary_replica() -> None:
+ original = "019c0711-e1aa-7223-bf21-12119afe80f7"
+
+ remapped = compute_run_id_for_secondary_replica(original,
"replica-project")
+
+ assert str(remapped) == "019c0711-e1aa-7817-8301-943c0df9a3cd"
+
+ with pytest.raises(ValueError, match="project_name"):
+ compute_run_id_for_secondary_replica(original, "")
+ with pytest.raises(ValueError, match="UUID v7"):
+ compute_run_id_for_secondary_replica(
+ "12345678-1234-4234-8234-123456789abc", "replica-project"
+ )
+
+
+def test_internal_replica_remapping_accepts_non_uuid7() -> None:
+ original = "12345678-1234-4234-8234-123456789abc"
+ run_tree = RunTree(
+ id=original,
+ name="test",
+ inputs={},
+ project_name="original-project",
+ )
+
+ remapped = run_tree._remap_for_project("replica-project", primary=False)
+
+ assert remapped["id"].version == 7
+
+
def test_uuid7_deterministic_produces_expected_values() -> None:
"""Test with hard-coded values to ensure compatibility across
implementations.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.7/tests/unit_tests/wrappers/test_strands_agents.py
new/langsmith-0.10.9/tests/unit_tests/wrappers/test_strands_agents.py
--- old/langsmith-0.10.7/tests/unit_tests/wrappers/test_strands_agents.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.9/tests/unit_tests/wrappers/test_strands_agents.py
2020-02-02 01:00:00.000000000 +0100
@@ -267,6 +267,166 @@
assert result["status"] == "success"
assert result["content"] == [{"type": "text", "text": "result"}]
+ def test_convert_content_block_reasoning(self):
+ """Test conversion of reasoningContent content blocks."""
+ block = {
+ "reasoningContent": {
+ "reasoningText": {
+ "text": "Let me break down the problem step by step.",
+ "signature": "Ev4Q_signature_data",
+ }
+ }
+ }
+ result = LangSmithSpanExporter._convert_content_block(block)
+
+ assert result == {
+ "type": "thinking",
+ "thinking": "Let me break down the problem step by step.",
+ "signature": "Ev4Q_signature_data",
+ }
+
+ def test_convert_content_block_reasoning_missing_signature(self):
+ """Test conversion of reasoningContent blocks without signature."""
+ block = {
+ "reasoningContent": {
+ "reasoningText": {
+ "text": "Thinking about this...",
+ }
+ }
+ }
+ result = LangSmithSpanExporter._convert_content_block(block)
+
+ assert result == {
+ "type": "thinking",
+ "thinking": "Thinking about this...",
+ "signature": "",
+ }
+
+ def test_convert_content_block_reasoning_empty(self):
+ """Test conversion of reasoningContent blocks with missing
reasoningText."""
+ block = {"reasoningContent": {}}
+ result = LangSmithSpanExporter._convert_content_block(block)
+
+ assert result == {
+ "type": "thinking",
+ "thinking": "",
+ "signature": "",
+ }
+
+ def test_transform_tool_span_choice_outputs_tool_envelope(self):
+ """Test that execute_tool span choice events produce tool output
envelope."""
+ delegate = MagicMock()
+ delegate.export.return_value = 0
+
+ exporter = LangSmithSpanExporter(delegate=delegate)
+
+ choice_event = self._make_event(
+ "gen_ai.choice",
+ {
+ "message": json.dumps([{"text": "Hippopotamus"}]),
+ "id": "tooluse_5lnW6BJJLFew8zk9eFpVUt",
+ },
+ )
+ span = self._make_span(
+ name="execute_tool get_magic_word",
+ attributes={
+ "gen_ai.operation.name": "execute_tool",
+ "gen_ai.tool.name": "get_magic_word",
+ "gen_ai.tool.call.id": "tooluse_5lnW6BJJLFew8zk9eFpVUt",
+ },
+ events=[choice_event],
+ )
+
+ exporter.export([span])
+ transformed = delegate.export.call_args[0][0][0]
+
+ completion = json.loads(transformed.attributes["gen_ai.completion"])
+ assert completion == {
+ "role": "tool",
+ "content": "Hippopotamus",
+ "name": "get_magic_word",
+ "tool_call_id": "tooluse_5lnW6BJJLFew8zk9eFpVUt",
+ }
+
+ def test_transform_tool_span_choice_multiple_text_blocks(self):
+ """Test that multi-block tool outputs are joined with newlines."""
+ delegate = MagicMock()
+ delegate.export.return_value = 0
+
+ exporter = LangSmithSpanExporter(delegate=delegate)
+
+ choice_event = self._make_event(
+ "gen_ai.choice",
+ {
+ "message": json.dumps(
+ [
+ {"text": "Line one"},
+ {"text": "Line two"},
+ ]
+ ),
+ "id": "tooluse_abc123",
+ },
+ )
+ span = self._make_span(
+ name="execute_tool my_tool",
+ attributes={
+ "gen_ai.operation.name": "execute_tool",
+ "gen_ai.tool.name": "my_tool",
+ "gen_ai.tool.call.id": "tooluse_abc123",
+ },
+ events=[choice_event],
+ )
+
+ exporter.export([span])
+ transformed = delegate.export.call_args[0][0][0]
+
+ completion = json.loads(transformed.attributes["gen_ai.completion"])
+ assert completion["role"] == "tool"
+ assert completion["content"] == "Line one\nLine two"
+ assert completion["name"] == "my_tool"
+ assert completion["tool_call_id"] == "tooluse_abc123"
+
+ def test_transform_chat_span_choice_still_uses_message_format(self):
+ """Test that chat span choice events still produce
+ message-format completions."""
+ delegate = MagicMock()
+ delegate.export.return_value = 0
+
+ exporter = LangSmithSpanExporter(delegate=delegate)
+
+ choice_event = self._make_event(
+ "gen_ai.choice",
+ {"message": json.dumps([{"text": "Final response"}])},
+ )
+ span = self._make_span(
+ name="chat",
+ attributes={"gen_ai.operation.name": "chat"},
+ events=[choice_event],
+ )
+
+ exporter.export([span])
+ transformed = delegate.export.call_args[0][0][0]
+
+ completion_data =
json.loads(transformed.attributes["gen_ai.completion"])
+ assert completion_data["role"] == "assistant"
+ assert "content" in completion_data
+
+ def test_extract_tool_output_single_text_block(self):
+ """Test extraction from a single text block."""
+ result = LangSmithSpanExporter._extract_tool_output([{"text": "Hello
world"}])
+ assert result == "Hello world"
+
+ def test_extract_tool_output_string_passthrough(self):
+ """Test that strings pass through unchanged."""
+ result = LangSmithSpanExporter._extract_tool_output("already a string")
+ assert result == "already a string"
+
+ def test_extract_tool_output_non_text_block(self):
+ """Test that non-text blocks are JSON serialized."""
+ content = [{"image": "base64data", "format": "png"}]
+ result = LangSmithSpanExporter._extract_tool_output(content)
+ assert json.loads(result) == {"image": "base64data", "format": "png"}
+
def test_flatten_tool_result_message(self):
"""Test flattening of Bedrock tool result messages."""
content_blocks = [