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-09-17 15:17:42
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-langsmith (Old)
 and      /work/SRC/openSUSE:Factory/.python-langsmith.new.383539 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-langsmith"

Thu Sep 17 15:17:42 2026 rev:26 rq:1378295 version:0.12.5

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-langsmith/python-langsmith.changes        
2026-09-10 17:40:56.071555550 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-langsmith.new.383539/python-langsmith.changes
    2026-09-17 15:18:29.919645001 +0200
@@ -1,0 +2,15 @@
+Wed Sep 16 07:25:49 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to 0.12.5:
+  * Report the configured tracing sample rate on every run so
+    sampled traces still recover the client's total trace potential
+  * ADK traces carry stable tool-call ids so results pair instead
+    of rendering as Unknown
+  * Sandbox run() no longer waits ~1s on the server's TCP close
+    after the command has already exited
+  * openai-agents guardrail and subagent spans are tagged
+    structurally instead of leaking into the conversation as root
+  * Sandbox exceptions keep the API error_id so support tickets
+    can quote it
+
+-------------------------------------------------------------------

Old:
----
  langsmith-0.12.4.tar.gz

New:
----
  langsmith-0.12.5.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-langsmith.spec ++++++
--- /var/tmp/diff_new_pack.Qo57CP/_old  2026-09-17 15:18:31.134696003 +0200
+++ /var/tmp/diff_new_pack.Qo57CP/_new  2026-09-17 15:18:31.138696171 +0200
@@ -17,7 +17,7 @@
 
 
 Name:           python-langsmith
-Version:        0.12.4
+Version:        0.12.5
 Release:        0
 Summary:        Client library for the LangSmith LLM tracing and evaluation 
platform
 License:        MIT

++++++ langsmith-0.12.4.tar.gz -> langsmith-0.12.5.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.12.4/.bumpversion.cfg 
new/langsmith-0.12.5/.bumpversion.cfg
--- old/langsmith-0.12.4/.bumpversion.cfg       2020-02-02 01:00:00.000000000 
+0100
+++ new/langsmith-0.12.5/.bumpversion.cfg       2020-02-02 01:00:00.000000000 
+0100
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 0.12.4
+current_version = 0.12.5
 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.12.4/PKG-INFO 
new/langsmith-0.12.5/PKG-INFO
--- old/langsmith-0.12.4/PKG-INFO       2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.12.5/PKG-INFO       2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
 Metadata-Version: 2.5
 Name: langsmith
-Version: 0.12.4
+Version: 0.12.5
 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.12.4/langsmith/__init__.py 
new/langsmith-0.12.5/langsmith/__init__.py
--- old/langsmith-0.12.4/langsmith/__init__.py  2020-02-02 01:00:00.000000000 
+0100
+++ new/langsmith-0.12.5/langsmith/__init__.py  2020-02-02 01:00:00.000000000 
+0100
@@ -50,7 +50,7 @@
 
 # Avoid calling into importlib on every call to __version__
 
-__version__ = "0.12.4"
+__version__ = "0.12.5"
 version = __version__  # for backwards compatibility
 
 # Metadata key to hide a traced run from LangSmith's Messages View.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.12.4/langsmith/client.py 
new/langsmith-0.12.5/langsmith/client.py
--- old/langsmith-0.12.4/langsmith/client.py    2020-02-02 01:00:00.000000000 
+0100
+++ new/langsmith-0.12.5/langsmith/client.py    2020-02-02 01:00:00.000000000 
+0100
@@ -2473,6 +2473,7 @@
         if self._omit_traced_runtime_info:
             return
         runtime_env = ls_env.get_runtime_environment()
+        sample_rate = self.tracing_sample_rate
         for run_create in runs:
             run_extra = cast(dict, run_create.setdefault("extra", {}))
             # update runtime
@@ -2482,6 +2483,8 @@
             metadata: dict = run_extra.setdefault("metadata", {})
             langchain_metadata = ls_env.get_langchain_env_var_metadata()
             added = {k: v for k, v in langchain_metadata.items() if k not in 
metadata}
+            if sample_rate is not None:
+                added["ls_tracing_sample_rate"] = sample_rate
             if added:
                 metadata.update(self._hide_run_metadata(added))
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.12.4/langsmith/integrations/google_adk/_client.py 
new/langsmith-0.12.5/langsmith/integrations/google_adk/_client.py
--- old/langsmith-0.12.4/langsmith/integrations/google_adk/_client.py   
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.12.5/langsmith/integrations/google_adk/_client.py   
2020-02-02 01:00:00.000000000 +0100
@@ -14,7 +14,11 @@
 from langsmith.run_helpers import get_current_run_tree, set_tracing_parent, 
trace
 
 from ._config import get_tracing_config
-from ._messages import convert_llm_request_to_messages, has_function_calls
+from ._messages import (
+    ToolIdState,
+    convert_llm_request_to_messages,
+    has_function_calls,
+)
 from ._usage import extract_model_name, extract_usage_from_response
 
 _LS_PROVIDER_VERTEXAI = "google_vertexai"
@@ -353,6 +357,7 @@
     )
 
     posted = False
+    id_state = ToolIdState()
 
     def _capture_inputs_and_post() -> None:
         """Capture the final llm_request state and post the run 
(idempotent)."""
@@ -361,7 +366,11 @@
             return
 
         model_name = extract_model_name(llm_request) if llm_request else None
-        messages = convert_llm_request_to_messages(llm_request) if llm_request 
else None
+        messages = (
+            convert_llm_request_to_messages(llm_request, id_state)
+            if llm_request
+            else None
+        )
         tools = extract_tools_from_llm_request(llm_request) if llm_request 
else []
 
         if messages:
@@ -421,16 +430,22 @@
             and content_source.content
         ):
             parts = getattr(content_source.content, "parts", None) or []
-            text_parts, tool_calls = [], []
+            text_parts: list[str] = []
+            tool_calls: list[dict[str, Any]] = []
 
-            for i, part in enumerate(parts):
+            for part in parts:
                 if hasattr(part, "text") and part.text:
                     text_parts.append(str(part.text))
                 elif hasattr(part, "function_call") and part.function_call:
                     fc = part.function_call
                     tool_calls.append(
                         {
-                            "id": f"call_{i}",
+                            "id": id_state.call_id(
+                                {
+                                    "id": getattr(fc, "id", None),
+                                    "name": getattr(fc, "name", ""),
+                                }
+                            ),
                             "type": "function",
                             "function": {
                                 "name": getattr(fc, "name", ""),
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.12.4/langsmith/integrations/google_adk/_messages.py 
new/langsmith-0.12.5/langsmith/integrations/google_adk/_messages.py
--- old/langsmith-0.12.4/langsmith/integrations/google_adk/_messages.py 
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.12.5/langsmith/integrations/google_adk/_messages.py 
2020-02-02 01:00:00.000000000 +0100
@@ -3,7 +3,9 @@
 from __future__ import annotations
 
 import base64
+import itertools
 import json
+from collections import defaultdict, deque
 from typing import Any
 
 
@@ -45,19 +47,25 @@
 
     if hasattr(part, "function_call") and part.function_call:
         fc = part.function_call
-        return {
+        call: dict[str, Any] = {
             "type": "tool_use",
             "name": getattr(fc, "name", "unknown"),
             "input": dict(getattr(fc, "args", None) or {}),
         }
+        if call_id := getattr(fc, "id", None):
+            call["id"] = str(call_id)
+        return call
 
     if hasattr(part, "function_response") and part.function_response:
         fr = part.function_response
-        return {
+        result: dict[str, Any] = {
             "type": "tool_result",
             "name": getattr(fr, "name", "unknown"),
             "content": _safe_serialize(getattr(fr, "response", None)),
         }
+        if result_id := getattr(fr, "id", None):
+            result["tool_use_id"] = str(result_id)
+        return result
 
     if hasattr(part, "text") and part.text is not None:
         return {"type": "text", "text": str(part.text)}
@@ -107,7 +115,37 @@
     return str(obj)
 
 
-def convert_llm_request_to_messages(llm_request: Any) -> list[dict[str, Any]]:
+class ToolIdState:
+    """Shared tool call id numbering for one LLM call."""
+
+    def __init__(self) -> None:
+        self._fallback_ids = itertools.count()
+        self._unanswered: dict[str, deque[str]] = defaultdict(deque)
+
+    def call_id(self, tool_call: dict[str, Any]) -> str:
+        """Return the id of an assistant tool call, preferring the one ADK 
set."""
+        call_id = str(tool_call.get("id") or 
f"ls-adk-{next(self._fallback_ids)}")
+        self._unanswered[tool_call.get("name", "")].append(call_id)
+        return call_id
+
+    def result_id(self, tool_result: dict[str, Any]) -> str:
+        """Return the id of the call a tool result answers, or "" if 
unknown."""
+        pending = self._unanswered[tool_result.get("name", "")]
+        own_id = tool_result.get("tool_use_id")
+        if own_id:
+            # Drop the call this answers, so a later result whose id ADK 
stripped
+            # cannot re-pair with it.
+            try:
+                pending.remove(str(own_id))
+            except ValueError:
+                pass
+            return str(own_id)
+        return str(pending.popleft() if pending else "")
+
+
+def convert_llm_request_to_messages(
+    llm_request: Any, id_state: ToolIdState | None = None
+) -> list[dict[str, Any]]:
     """Convert LlmRequest to OpenAI-compatible message format."""
     messages: list[dict[str, Any]] = []
 
@@ -122,6 +160,8 @@
     if not contents:
         return messages
 
+    id_state = id_state or ToolIdState()
+
     for content in contents:
         role = getattr(content, "role", "user")
         if role == "model":
@@ -148,29 +188,28 @@
                     "content": " ".join(text_parts) if text_parts else None,
                     "tool_calls": [
                         {
-                            "id": f"call_{i}",
+                            "id": id_state.call_id(tc),
                             "type": "function",
                             "function": {
                                 "name": tc.get("name", ""),
                                 "arguments": json.dumps(tc.get("input", {})),
                             },
                         }
-                        for i, tc in enumerate(tool_calls)
+                        for tc in tool_calls
                     ],
                 }
             )
         elif tool_results:
             for tr in tool_results:
                 c = tr.get("content")
-                messages.append(
-                    {
-                        "role": "tool",
-                        "name": tr.get("name", ""),
-                        "content": (
-                            json.dumps(c) if isinstance(c, dict) else str(c or 
"")
-                        ),
-                    }
-                )
+                message = {
+                    "role": "tool",
+                    "name": tr.get("name", ""),
+                    "content": (json.dumps(c) if isinstance(c, dict) else 
str(c or "")),
+                }
+                if tool_call_id := id_state.result_id(tr):
+                    message["tool_call_id"] = tool_call_id
+                messages.append(message)
         else:
             messages.append(
                 {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.12.4/langsmith/integrations/openai_agents_sdk/_openai_agents.py 
new/langsmith-0.12.5/langsmith/integrations/openai_agents_sdk/_openai_agents.py
--- 
old/langsmith-0.12.4/langsmith/integrations/openai_agents_sdk/_openai_agents.py 
    2020-02-02 01:00:00.000000000 +0100
+++ 
new/langsmith-0.12.5/langsmith/integrations/openai_agents_sdk/_openai_agents.py 
    2020-02-02 01:00:00.000000000 +0100
@@ -99,6 +99,33 @@
 
 if HAVE_AGENTS:
 
+    def _resolve_openai_agents_ls_agent_type(
+        span: "tracing.Span",
+        parent_run: "rt.RunTree",
+        existing_tag: Optional[str],
+    ) -> Optional[str]:
+        """Return an ls_agent_type for the span, or None to leave it alone.
+
+        If the run already has middleware/subagent/compaction, keep it.
+        Otherwise guardrails become middleware and agents under any tool
+        become subagents.
+
+        Openai-agents structural counterpart to ``resolveVercelLsAgentType``.
+        """
+        if existing_tag in NON_ROOT_LS_AGENT_TYPES:
+            return None
+        if isinstance(span.span_data, tracing.GuardrailSpanData):
+            return "middleware"
+        # Walk the full parent chain, not just the direct parent: as_tool
+        # inserts a chain run between the tool and the inner agent span.
+        if isinstance(span.span_data, tracing.AgentSpanData):
+            cursor: Optional[rt.RunTree] = parent_run
+            while cursor is not None:
+                if cursor.run_type == "tool":
+                    return "subagent"
+                cursor = cursor.parent_run
+        return None
+
     class OpenAIAgentsTracingProcessor(tracing.TracingProcessor):  # type: 
ignore[no-redef]
         """Tracing processor for the [OpenAI Agents 
SDK](https://openai.github.io/openai-agents-python/).
 
@@ -174,9 +201,6 @@
             self._last_response_outputs: dict = {}
 
             self._runs: dict[str, rt.RunTree] = {}
-            self._span_data_types: dict[
-                str, type
-            ] = {}  # Track span data types by span_id
             self._unposted_traces: set[str] = set()
             self._unposted_spans: set[str] = set()
 
@@ -321,25 +345,15 @@
                     else None,
                 )
 
-                # Add ls_agent_type metadata for agent spans that are children 
of
-                # function spans (i.e., agents used as tools via as_tool()).
-                # Note: Handoff agents are considered root agents, not 
subagents,
-                # since they take over the conversation rather than being 
called
-                # as tools.
-                if isinstance(span.span_data, tracing.AgentSpanData):
-                    # Check if parent span is a function span (agent used as 
tool)
-                    parent_span_data_type = (
-                        self._span_data_types.get(span.parent_id)
-                        if span.parent_id
-                        else None
-                    )
-                    if parent_span_data_type is tracing.FunctionSpanData:
-                        metadata = child_run.extra.setdefault("metadata", {})
-                        if metadata.get("ls_agent_type") not in 
NON_ROOT_LS_AGENT_TYPES:
-                            metadata["ls_agent_type"] = "subagent"
-
-                # Track span data type for parent lookups
-                self._span_data_types[span.span_id] = type(span.span_data)
+                # Handoffs replace the caller rather than run as a tool, so
+                # a handoff agent has no tool ancestor and correctly stays
+                # untagged here.
+                metadata = child_run.extra.setdefault("metadata", {})
+                structural_tag = _resolve_openai_agents_ls_agent_type(
+                    span, parent_run, metadata.get("ls_agent_type")
+                )
+                if structural_tag is not None:
+                    metadata["ls_agent_type"] = structural_tag
 
                 # Delay posting for spans whose inputs aren't available at 
start
                 if isinstance(
@@ -359,9 +373,6 @@
 
         def on_span_end(self, span: tracing.Span) -> None:
             run = self._runs.pop(span.span_id, None)
-            self._span_data_types.pop(
-                span.span_id, None
-            )  # Clean up span data type tracking
             if not run:
                 return
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.12.4/langsmith/sandbox/_helpers.py 
new/langsmith-0.12.5/langsmith/sandbox/_helpers.py
--- old/langsmith-0.12.4/langsmith/sandbox/_helpers.py  2020-02-02 
01:00:00.000000000 +0100
+++ new/langsmith-0.12.5/langsmith/sandbox/_helpers.py  2020-02-02 
01:00:00.000000000 +0100
@@ -96,6 +96,12 @@
 # =============================================================================
 
 
+def _message_with_error_id(message: str, error_id: Any) -> str:
+    if isinstance(error_id, str) and error_id:
+        return f"{message} (error_id={error_id})"
+    return message
+
+
 def parse_error_response(error: httpx.HTTPStatusError) -> dict[str, Any]:
     """Parse standardized error response.
 
@@ -113,7 +119,9 @@
         if isinstance(detail, dict):
             return {
                 "error_type": detail.get("error"),
-                "message": detail.get("message", str(error)),
+                "message": _message_with_error_id(
+                    detail.get("message", str(error)), detail.get("error_id")
+                ),
             }
 
         # Pydantic validation error format: {"detail": [{"loc": [...], "msg": 
"..."}]}
@@ -144,7 +152,9 @@
         if isinstance(detail, dict):
             return {
                 "error_type": detail.get("error"),
-                "message": detail.get("message", str(error)),
+                "message": _message_with_error_id(
+                    detail.get("message", str(error)), detail.get("error_id")
+                ),
             }
 
         return {"error_type": None, "message": detail or str(error)}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.12.4/langsmith/sandbox/_models.py 
new/langsmith-0.12.5/langsmith/sandbox/_models.py
--- old/langsmith-0.12.4/langsmith/sandbox/_models.py   2020-02-02 
01:00:00.000000000 +0100
+++ new/langsmith-0.12.5/langsmith/sandbox/_models.py   2020-02-02 
01:00:00.000000000 +0100
@@ -693,6 +693,8 @@
                     exit_code=msg["exit_code"],
                 )
                 self._exhausted = True
+                # Finish the generator now so the WebSocket closes here, not 
at GC.
+                next(self._stream, None)
                 return
         raise SandboxConnectionError("Command stream ended without exit 
message")
 
@@ -956,6 +958,11 @@
                     exit_code=msg["exit_code"],
                 )
                 self._exhausted = True
+                # Finish the generator now so the WebSocket closes here, not 
at GC.
+                try:
+                    await self._stream.__anext__()
+                except StopAsyncIteration:
+                    pass
                 return
         raise SandboxConnectionError("Command stream ended without exit 
message")
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.12.4/langsmith/sandbox/_ws_execute.py 
new/langsmith-0.12.5/langsmith/sandbox/_ws_execute.py
--- old/langsmith-0.12.4/langsmith/sandbox/_ws_execute.py       2020-02-02 
01:00:00.000000000 +0100
+++ new/langsmith-0.12.5/langsmith/sandbox/_ws_execute.py       2020-02-02 
01:00:00.000000000 +0100
@@ -55,8 +55,10 @@
 WS_OPEN_TIMEOUT = _env_timeout("SANDBOX_WS_TIMEOUT_OPEN", 30)
 WS_PING_INTERVAL = _env_timeout("SANDBOX_WS_TIMEOUT_PING_INTERVAL", 30)
 WS_PING_TIMEOUT = _env_timeout("SANDBOX_WS_TIMEOUT_PING", 60)
-# Kept short: a dead peer would otherwise stall teardown for the full duration.
-WS_CLOSE_TIMEOUT = _env_timeout("SANDBOX_WS_TIMEOUT_CLOSE", 10)
+# Bounds the wait for the server's TCP close after the close handshake. The
+# server closes right after "exit", but its FIN trails the close frame by ~1s
+# through the proxy chain, and every run() paid that wait.
+WS_CLOSE_TIMEOUT = _env_timeout("SANDBOX_WS_TIMEOUT_CLOSE", 0.1)
 # Ceiling on the whole connect phase. Without it, retrying a blackholed 
handshake
 # costs MAX_AUTO_RECONNECTS + 1 full open timeouts plus backoff.
 WS_CONNECT_BUDGET = _env_timeout("SANDBOX_WS_TIMEOUT_CONNECT_BUDGET", 120)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.12.4/tests/unit_tests/sandbox/test_async_ws_execute.py 
new/langsmith-0.12.5/tests/unit_tests/sandbox/test_async_ws_execute.py
--- old/langsmith-0.12.4/tests/unit_tests/sandbox/test_async_ws_execute.py      
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.12.5/tests/unit_tests/sandbox/test_async_ws_execute.py      
2020-02-02 01:00:00.000000000 +0100
@@ -148,6 +148,23 @@
         assert result.exit_code == 0
 
     @pytest.mark.asyncio
+    async def test_result_finishes_stream_generator(self):
+        """The stream's cleanup runs before result returns, not at GC."""
+        closed = False
+
+        async def stream() -> AsyncIterator[dict]:
+            nonlocal closed
+            try:
+                yield _started_msg()
+                yield _exit_msg(0)
+            finally:
+                closed = True
+
+        handle = AsyncCommandHandle(stream(), None, self._make_sandbox_mock())
+        assert (await handle.result).exit_code == 0
+        assert closed
+
+    @pytest.mark.asyncio
     async def test_no_started_message(self):
         stream = _make_async_stream([_stdout_msg("data")])
         sandbox = self._make_sandbox_mock()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.12.4/tests/unit_tests/sandbox/test_helpers.py 
new/langsmith-0.12.5/tests/unit_tests/sandbox/test_helpers.py
--- old/langsmith-0.12.4/tests/unit_tests/sandbox/test_helpers.py       
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.12.5/tests/unit_tests/sandbox/test_helpers.py       
2020-02-02 01:00:00.000000000 +0100
@@ -2,7 +2,7 @@
 
 import httpx
 
-from langsmith.sandbox._helpers import merge_headers
+from langsmith.sandbox._helpers import merge_headers, parse_error_response
 
 
 def test_merge_headers_override_wins() -> None:
@@ -37,3 +37,28 @@
         v.decode() for k, v in request.headers.raw if k.lower() == 
b"x-service-key"
     ]
     assert on_wire == ["override"]
+
+
+def test_parse_error_response_preserves_error_id() -> None:
+    request = httpx.Request(
+        "POST", "https://example.com/v2/sandboxes/boxes/box/snapshot";
+    )
+    response = httpx.Response(
+        500,
+        request=request,
+        json={
+            "detail": {
+                "error": "SandboxSnapshotFailed",
+                "message": "Snapshot capture failed.",
+                "error_id": "6a39f608-e9aa-4247-8e61-846870563681",
+            }
+        },
+    )
+    error = httpx.HTTPStatusError("server error", request=request, 
response=response)
+
+    assert parse_error_response(error) == {
+        "error_type": "SandboxSnapshotFailed",
+        "message": (
+            "Snapshot capture failed. 
(error_id=6a39f608-e9aa-4247-8e61-846870563681)"
+        ),
+    }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.12.4/tests/unit_tests/sandbox/test_ws_execute.py 
new/langsmith-0.12.5/tests/unit_tests/sandbox/test_ws_execute.py
--- old/langsmith-0.12.4/tests/unit_tests/sandbox/test_ws_execute.py    
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.12.5/tests/unit_tests/sandbox/test_ws_execute.py    
2020-02-02 01:00:00.000000000 +0100
@@ -266,6 +266,22 @@
         assert result.stdout == "output"
         assert result.exit_code == 0
 
+    def test_result_finishes_stream_generator(self):
+        """The stream's cleanup runs before result returns, not at GC."""
+        closed = False
+
+        def stream() -> Iterator[dict]:
+            nonlocal closed
+            try:
+                yield _started_msg()
+                yield _exit_msg(0)
+            finally:
+                closed = True
+
+        handle = CommandHandle(stream(), None, self._make_sandbox_mock())
+        assert handle.result.exit_code == 0
+        assert closed
+
     def test_no_started_message(self):
         stream = _make_stream([_stdout_msg("data")])
         sandbox = self._make_sandbox_mock()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/langsmith-0.12.4/tests/unit_tests/test_client.py 
new/langsmith-0.12.5/tests/unit_tests/test_client.py
--- old/langsmith-0.12.4/tests/unit_tests/test_client.py        2020-02-02 
01:00:00.000000000 +0100
+++ new/langsmith-0.12.5/tests/unit_tests/test_client.py        2020-02-02 
01:00:00.000000000 +0100
@@ -2912,6 +2912,102 @@
     assert [post["id"] for post in batch_data.get("post", [])] == 
[str(SAMPLED_RUN_ID)]
 
 
+def test_sample_rate_is_reported_on_created_runs() -> None:
+    """The configured rate has to ride out on the run, not just gate it.
+
+    Without it the sampled subset cannot be scaled back up to the customer's
+    total trace potential, and nothing anywhere reports the omission.
+    """
+    session = mock.MagicMock(spec=requests.Session)
+    client = _client(session, tracing_sampling_rate=0.5)
+
+    client.create_run(
+        "sampled_run",
+        inputs={"in": "put"},
+        run_type="llm",
+        id=SAMPLED_RUN_ID,
+    )
+
+    payload = _find_request_payload(session, "POST", "/runs")
+    metadata = (payload.get("extra") or {}).get("metadata") or {}
+    assert metadata["ls_tracing_sample_rate"] == 0.5
+
+
+def test_sample_rate_overrides_an_inherited_value() -> None:
+    """The client's own rate wins over one already on the run.
+
+    ``RunTree.create_child`` copies the parent's metadata onto every child, so 
a
+    run traced by a client at one rate can arrive carrying another's -- and a
+    caller can set the key directly. Either way the reported rate has to be the
+    rate this client actually sampled at, or the extrapolation is wrong rather
+    than merely missing.
+    """
+    session = mock.MagicMock(spec=requests.Session)
+    client = _client(session, tracing_sampling_rate=0.5)
+
+    client.create_run(
+        "sampled_run",
+        inputs={"in": "put"},
+        run_type="llm",
+        id=SAMPLED_RUN_ID,
+        extra={"metadata": {"ls_tracing_sample_rate": 0.9, "user": "x"}},
+    )
+
+    payload = _find_request_payload(session, "POST", "/runs")
+    metadata = payload["extra"]["metadata"]
+    assert metadata["ls_tracing_sample_rate"] == 0.5
+    assert metadata["user"] == "x"
+
+
+def test_sample_rate_survives_multipart_batching() -> None:
+    """The rate must ride out through the multipart batch path too.
+
+    ``test_sample_rate_is_reported_on_created_runs`` covers the single-run POST
+    path (``auto_batch_tracing=False``). This covers the other one: runs queued
+    through the auto-batcher and flushed to ``/runs/multipart`` as separate
+    multipart parts must carry the rate on each posted run, not just on the
+    dict handed to ``create_run``.
+    """
+    mock_session = MagicMock()
+    mock_response = MagicMock()
+    mock_response.status_code = 200
+    mock_session.request.return_value = mock_response
+
+    client = Client(
+        api_key="test-api-key",
+        auto_batch_tracing=True,
+        tracing_sampling_rate=1.0,
+        session=mock_session,
+    )
+
+    run_ids = [uuid.uuid4() for _ in range(2)]
+    for run_id in run_ids:
+        client.create_run(
+            **_sampling_run(run_id),
+            extra={"metadata": {"user_id": f"user-{run_id}"}},
+        )
+    client.flush()
+
+    post_calls = [
+        call
+        for call in mock_session.request.mock_calls
+        if call.args
+        and call.args[0] == "POST"
+        and call.args[1].endswith("/runs/multipart")
+    ]
+    assert len(post_calls) == 1
+    data = post_calls[0].kwargs["data"]
+    if isinstance(data, bytes):
+        data = data.decode("utf-8")
+    batch_data = parse_request_data(data)
+    posted = batch_data.get("post", [])
+    assert {post["id"] for post in posted} == {str(run_id) for run_id in 
run_ids}
+    for post in posted:
+        metadata = post["extra"]["metadata"]
+        assert metadata["ls_tracing_sample_rate"] == 1.0
+        assert metadata["user_id"] == f"user-{post['id']}"
+
+
 # Golden decisions at rate 0.5. The JS SDK asserts this exact table in
 # js/src/tests/client.test.ts: both must agree, or a trace sampled in by one
 # SDK is dropped by the other. Regenerate both sides together, never one.
@@ -4603,6 +4699,88 @@
 
 
 @patch("langsmith.client.requests.Session")
+def test_sample_rate_survives_zstd_compression(mock_session_cls: mock.Mock) -> 
None:
+    """The compression path must carry the sampling rate like any other send 
path.
+
+    ``_insert_runtime_env`` stamps ``ls_tracing_sample_rate`` into 
``extra.metadata``
+    before the run is handed to the compressor. Decompress the exact bytes 
posted
+    over the wire and parse the multipart body to prove the rate survives the
+    zstd round trip, not just the pre-compression dict.
+    """
+    import zstandard
+
+    mock_session = MagicMock()
+    mock_response = MagicMock()
+    mock_response.status_code = 200
+    mock_session.request.return_value = mock_response
+    mock_session_cls.return_value = mock_session
+
+    with patch.dict("os.environ", {}, clear=True):
+        info = ls_schemas.LangSmithInfo(
+            version="0.6.0",
+            instance_flags={"zstd_compression_enabled": True},
+            batch_ingest_config=ls_schemas.BatchIngestConfig(
+                use_multipart_endpoint=True,
+                size_limit=1,
+                size_limit_bytes=128,
+                scale_up_nthreads_limit=4,
+                scale_up_qsize_trigger=3,
+                scale_down_nempty_trigger=1,
+            ),
+        )
+        client = Client(
+            api_url="http://localhost:1984";,
+            api_key="123",
+            auto_batch_tracing=True,
+            session=mock_session,
+            info=info,
+            tracing_sampling_rate=1.0,
+        )
+
+        run_id = uuid.uuid4()
+        client.create_run(
+            name="my_test_run",
+            run_type="llm",
+            inputs={"some_key": "some_val" * 1000},
+            id=run_id,
+            trace_id=run_id,
+            dotted_order=str(run_id),
+            extra={"metadata": {"user_id": "user-123"}},
+        )
+
+        if client.tracing_queue:
+            client.tracing_queue.join()
+        if client._futures is not None:
+            for fut in client._futures:
+                fut.result()
+
+    time.sleep(0.1)
+
+    post_calls = [
+        call_obj
+        for call_obj in mock_session.request.mock_calls
+        if call_obj.args and call_obj.args[0] == "POST"
+    ]
+    assert len(post_calls) >= 1, (
+        "Expected at least one POST to the compression endpoint"
+    )
+
+    call_data = post_calls[0][2]["data"]
+    if hasattr(call_data, "read"):
+        call_data = call_data.read()
+
+    decompressed = zstandard.ZstdDecompressor().decompress(
+        call_data, max_output_size=10_000_000
+    )
+    batch_data = parse_request_data(decompressed)
+    posted = [run for run in batch_data.get("post", []) if run.get("id") == 
str(run_id)]
+    assert len(posted) == 1
+    metadata = posted[0]["extra"]["metadata"]
+    assert metadata["ls_tracing_sample_rate"] == 1.0
+    assert metadata["user_id"] == "user-123"
+
+
+@patch("langsmith.client.requests.Session")
 def test_create_feedback_with_zstd_compression(mock_session_cls: mock.Mock) -> 
None:
     """Test that feedback is sent using zstd compression when compression is 
enabled."""
     # Prepare a mocked session
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.12.4/tests/unit_tests/wrappers/test_google_adk.py 
new/langsmith-0.12.5/tests/unit_tests/wrappers/test_google_adk.py
--- old/langsmith-0.12.4/tests/unit_tests/wrappers/test_google_adk.py   
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.12.5/tests/unit_tests/wrappers/test_google_adk.py   
2020-02-02 01:00:00.000000000 +0100
@@ -425,3 +425,134 @@
         f"Expected active context inside tool to be the tool span "
         f"({tool_run['id']}), got {captured_run_id}"
     )
+
+
+def _tool_call_request(*calls: tuple[str | None, str | None], name: str = 
"weather"):
+    """Build an LlmRequest of one tool call and its response per (call, 
result) id."""
+    from google.adk.models.llm_request import LlmRequest
+    from google.genai import types
+
+    contents = []
+    for call_id, result_id in calls:
+        contents.append(
+            types.Content(
+                role="model",
+                parts=[
+                    types.Part(
+                        function_call=types.FunctionCall(
+                            id=call_id, name=name, args={"city": "Haifa"}
+                        )
+                    )
+                ],
+            )
+        )
+        contents.append(
+            types.Content(
+                role="user",
+                parts=[
+                    types.Part(
+                        function_response=types.FunctionResponse(
+                            id=result_id, name=name, response={"c": 29}
+                        )
+                    )
+                ],
+            )
+        )
+    return LlmRequest(contents=contents)
+
+
+def _call_and_result_ids(llm_request):
+    from langsmith.integrations.google_adk._messages import (
+        convert_llm_request_to_messages,
+    )
+
+    messages = convert_llm_request_to_messages(llm_request)
+    return (
+        [m["tool_calls"][0]["id"] for m in messages if m.get("tool_calls")],
+        [m.get("tool_call_id") for m in messages if m["role"] == "tool"],
+    )
+
+
+def test_tool_messages_pair_with_the_ids_adk_assigned():
+    """When ADK keeps its ids, a tool message carries the id of the call it 
answers."""
+    calls, results = _call_and_result_ids(
+        _tool_call_request(("adk-1111", "adk-1111"), ("adk-2222", "adk-2222"))
+    )
+
+    assert calls == ["adk-1111", "adk-2222"]
+    assert results == calls
+
+
+def test_tool_messages_pair_when_adk_strips_its_ids():
+    """The default Gemini path: ADK strips every ``adk-`` id before the 
request.
+
+    Pinned to ADK's own stripping rather than a hand-built request, because 
this,
+    not the branch above, is what a plain ``Gemini`` agent produces.
+    """
+    from google.adk.flows.llm_flows.functions import (
+        generate_client_function_call_id,
+        remove_client_function_call_id,
+    )
+
+    minted = [generate_client_function_call_id() for _ in range(2)]
+    llm_request = _tool_call_request(*((call_id, call_id) for call_id in 
minted))
+    for content in llm_request.contents:
+        remove_client_function_call_id(content)
+
+    calls, results = _call_and_result_ids(llm_request)
+
+    assert all(call_id is None for call_id in _raw_part_ids(llm_request)), (
+        "ADK no longer strips its ids; the fallback is no longer the primary 
path"
+    )
+    assert len(set(calls)) == 2, calls
+    assert results == calls
+
+
+def _raw_part_ids(llm_request):
+    ids = []
+    for content in llm_request.contents:
+        for part in content.parts:
+            fc, fr = part.function_call, part.function_response
+            ids.append(fc.id if fc else fr.id)
+    return ids
+
+
[email protected](
+    "ids, expected_calls, expected_results",
+    [
+        # A provider id on one side is carried over to the other.
+        ([("call_abc", None)], ["call_abc"], ["call_abc"]),
+        # Except the other way round: nothing links the two, so they stay 
apart.
+        ([(None, "call_abc")], ["ls-adk-0"], ["call_abc"]),
+        # A synthesised id must not collide with a real one.
+        (
+            [(None, None), ("call_0", "call_0")],
+            ["ls-adk-0", "call_0"],
+            ["ls-adk-0", "call_0"],
+        ),
+        # An answered call must not be re-paired with a later stripped result.
+        (
+            [("adk-1", "adk-1"), (None, None)],
+            ["adk-1", "ls-adk-0"],
+            ["adk-1", "ls-adk-0"],
+        ),
+    ],
+    ids=["call-id-only", "result-id-only", "no-collision", "mixed-history"],
+)
+def test_partial_tool_call_ids(ids, expected_calls, expected_results):
+    """An id present on one side only must not mis-pair the other."""
+    calls, results = _call_and_result_ids(_tool_call_request(*ids))
+
+    assert calls == expected_calls
+    assert results == expected_results
+
+
+def test_orphan_tool_result_gets_no_id():
+    """A result with no call to answer gets no id rather than an invented 
one."""
+    llm_request = _tool_call_request((None, None))
+    llm_request.contents = [c for c in llm_request.contents if c.role != 
"model"]
+
+    calls, results = _call_and_result_ids(llm_request)
+
+    assert calls == []
+    assert results == [None]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/langsmith-0.12.4/tests/unit_tests/wrappers/test_openai_agents_processor.py 
new/langsmith-0.12.5/tests/unit_tests/wrappers/test_openai_agents_processor.py
--- 
old/langsmith-0.12.4/tests/unit_tests/wrappers/test_openai_agents_processor.py  
    2020-02-02 01:00:00.000000000 +0100
+++ 
new/langsmith-0.12.5/tests/unit_tests/wrappers/test_openai_agents_processor.py  
    2020-02-02 01:00:00.000000000 +0100
@@ -120,20 +120,24 @@
 
 
 # ---------------------------------------------------------------------------
-# Subagent structural detection (agent-as-tool)
+# ls_agent_type detection (on_span_start)
 # ---------------------------------------------------------------------------
 
 
-def _run_subagent_stamp(existing_tag):
+def _make_run(run_type, parent_run=None):
+    run = mock.MagicMock()
+    run.run_type = run_type
+    run.parent_run = parent_run
+    return run
+
+
+def _run_span_stamp(span_data, parent_run, existing_tag=None):
+    """Fire on_span_start against a hand-built parent run and return the 
tag."""
     client = mock.MagicMock(spec=Client)
     processor = OpenAIAgentsTracingProcessor(client=client)
 
-    parent_span_id = "parent-fn"
-    child_span_id = "child-agent"
-    processor._span_data_types[parent_span_id] = tracing.FunctionSpanData
-
-    parent_run = mock.MagicMock()
-    parent_run.id = "parent-run"
+    parent_span_id = "parent-span"
+    child_span_id = "child-span"
     processor._runs[parent_span_id] = parent_run
 
     child_run = mock.MagicMock()
@@ -146,28 +150,90 @@
         parent_id=parent_span_id,
         trace_id="trace-1",
         started_at=None,
-        span_data=mock.MagicMock(spec=tracing.AgentSpanData),
+        span_data=span_data,
     )
-    child_span.span_data.export = mock.MagicMock(return_value={})
-    child_span.span_data.name = "Some Subagent"
 
     processor.on_span_start(child_span)
     return child_run.extra["metadata"].get("ls_agent_type")
 
 
-def test_subagent_stamps_when_child_untagged():
-    assert _run_subagent_stamp(None) == "subagent"
+def _agent_span_data():
+    span_data = mock.MagicMock(spec=tracing.AgentSpanData)
+    span_data.export = mock.MagicMock(return_value={})
+    span_data.name = "Some Agent"
+    return span_data
+
+
+def _guardrail_span_data():
+    span_data = mock.MagicMock(spec=tracing.GuardrailSpanData)
+    span_data.export = mock.MagicMock(return_value={})
+    span_data.name = "entry_guardrail"
+    return span_data
+
+
+# ---- Subagent (agent run as a tool) ----
+
+
+def test_subagent_stamps_when_agent_parent_is_tool():
+    tool_run = _make_run("tool")
+    assert _run_span_stamp(_agent_span_data(), tool_run) == "subagent"
+
+
+def test_subagent_stamps_when_the_tool_is_further_above():
+    # When an agent runs as a tool, an extra run sits between the tool and
+    # the agent.
+    tool_run = _make_run("tool")
+    chain_run = _make_run("chain", parent_run=tool_run)
+    assert _run_span_stamp(_agent_span_data(), chain_run) == "subagent"
+
+
+def test_agent_with_no_tool_above_it_is_not_tagged():
+    root_chain = _make_run("chain")
+    assert _run_span_stamp(_agent_span_data(), root_chain) is None
+
+
+def test_handoff_agent_inside_a_tool_is_tagged_subagent():
+    # A handoff opens a new agent run beside the one it replaced, so an agent
+    # handed off to inside a tool still has that tool above it.
+    tool_run = _make_run("tool")
+    chain_run = _make_run("chain", parent_run=tool_run)
+    assert _run_span_stamp(_agent_span_data(), chain_run) == "subagent"
 
 
 def test_subagent_overrides_inherited_root():
-    # Structural detection overrides propagated root at agent-as-tool spans.
-    assert _run_subagent_stamp("root") == "subagent"
+    tool_run = _make_run("tool")
+    tag = _run_span_stamp(_agent_span_data(), tool_run, existing_tag="root")
+    assert tag == "subagent"
+
+
[email protected]("user_tag", ["middleware", "compaction", "subagent"])
+def test_subagent_keeps_user_supplied_tag(user_tag):
+    tool_run = _make_run("tool")
+    assert (
+        _run_span_stamp(_agent_span_data(), tool_run, existing_tag=user_tag) 
== user_tag
+    )
 
 
[email protected]("narrowing_tag", ["middleware", "compaction"])
-def test_subagent_preserves_user_narrowing_tag(narrowing_tag):
-    assert _run_subagent_stamp(narrowing_tag) == narrowing_tag
+# ---- Guardrail ----
 
 
-def test_subagent_preserves_existing_subagent_tag():
-    assert _run_subagent_stamp("subagent") == "subagent"
+def test_guardrail_stamps_middleware():
+    root_chain = _make_run("chain")
+    assert _run_span_stamp(_guardrail_span_data(), root_chain) == "middleware"
+
+
+def test_guardrail_overrides_inherited_root():
+    root_chain = _make_run("chain")
+    assert (
+        _run_span_stamp(_guardrail_span_data(), root_chain, 
existing_tag="root")
+        == "middleware"
+    )
+
+
[email protected]("user_tag", ["middleware", "subagent", "compaction"])
+def test_guardrail_keeps_user_supplied_tag(user_tag):
+    root_chain = _make_run("chain")
+    assert (
+        _run_span_stamp(_guardrail_span_data(), root_chain, 
existing_tag=user_tag)
+        == user_tag
+    )

Reply via email to