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
+ )