open-swe/agent/utils/langsmith.py
Johannes du Plessis 889b2663d9
fix: point View trace link at the correct tracing project (#1521)
* fix: resolve trace URL project id by tracing project name

Graphs were split into separate LangSmith tracing projects
(open-swe-agent, open-swe-review) but the "View trace" link still used
a single fixed project-id env var pointing at the old combined project.
Resolve the project id from the tracing project name so agent and
reviewer links point at their respective projects, falling back to the
env var when resolution is unavailable.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* docs: document per-graph tracing projects for trace links

View trace links now resolve project IDs from the open-swe-agent /
open-swe-review project names. Document this so fresh deployments create
the right projects instead of relying on a single project ID.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-13 09:37:15 -07:00

174 lines
5.8 KiB
Python

"""LangSmith trace URL utilities."""
from __future__ import annotations
import logging
import os
import uuid
from typing import Any
from langsmith import Client as LangSmithClient
from langsmith.utils import LangSmithNotFoundError
from .tracing import AGENT_TRACING_PROJECT
logger = logging.getLogger(__name__)
_PROJECT_ID_CACHE: dict[str, str] = {}
def _build_prod_langsmith_client() -> LangSmithClient | None:
"""Build a LangSmith client scoped to the prod tenant for project lookups."""
api_key = (
os.environ.get("LANGSMITH_API_KEY_PROD")
or os.environ.get("LANGSMITH_API_KEY")
or os.environ.get("LANGCHAIN_API_KEY")
)
if not api_key:
return None
api_url = os.environ.get("LANGSMITH_ENDPOINT_PROD") or os.environ.get(
"LANGSMITH_ENDPOINT", "https://api.smith.langchain.com"
)
return LangSmithClient(api_key=api_key, api_url=api_url)
def _resolve_project_id_by_name(project_name: str) -> str | None:
"""Resolve a LangSmith project id from its name, caching successful lookups."""
cached = _PROJECT_ID_CACHE.get(project_name)
if cached:
return cached
client = _build_prod_langsmith_client()
if client is None:
return None
try:
project = client.read_project(project_name=project_name)
except LangSmithNotFoundError:
return None
except Exception: # noqa: BLE001
logger.warning("Failed to resolve LangSmith project id for %s", project_name, exc_info=True)
return None
project_id = getattr(project, "id", None)
if not project_id:
return None
resolved = str(project_id)
_PROJECT_ID_CACHE[project_name] = resolved
return resolved
def _compose_langsmith_project_url(project_name: str = AGENT_TRACING_PROJECT) -> str:
"""Build the LangSmith project URL base for a given tracing project name."""
host_url = os.environ.get("LANGSMITH_URL_PROD", "https://smith.langchain.com")
tenant_id = os.environ.get("LANGSMITH_TENANT_ID_PROD")
project_id = _resolve_project_id_by_name(project_name) or os.environ.get(
"LANGSMITH_TRACING_PROJECT_ID_PROD"
)
if not tenant_id or not project_id:
raise ValueError(
"LANGSMITH_TENANT_ID_PROD must be set and the tracing project id must be resolvable"
)
return f"{host_url}/o/{tenant_id}/projects/p/{project_id}"
def get_langsmith_trace_url(
thread_id: str, project_name: str = AGENT_TRACING_PROJECT
) -> str | None:
"""Build the LangSmith thread URL for a given thread ID."""
try:
project_url = _compose_langsmith_project_url(project_name)
return f"{project_url}/t/{thread_id}"
except Exception: # noqa: BLE001
logger.warning(
"Failed to build LangSmith trace URL for thread %s", thread_id, exc_info=True
)
return None
def _build_langsmith_feedback_clients() -> tuple[LangSmithClient, ...]:
"""Build feedback clients from current env. Re-read each call so rotated
keys / late secret hydration are picked up."""
clients: list[LangSmithClient] = []
seen: set[tuple[str, str]] = set()
api_endpoint = os.environ.get("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com")
client_configs = (
(
os.environ.get("LANGSMITH_API_KEY") or os.environ.get("LANGCHAIN_API_KEY"),
api_endpoint,
),
(
os.environ.get("LANGSMITH_API_KEY_PROD"),
os.environ.get("LANGSMITH_ENDPOINT_PROD", api_endpoint),
),
)
for api_key, api_url in client_configs:
if not api_key or not api_url:
continue
identity = (api_key, api_url)
if identity in seen:
continue
clients.append(LangSmithClient(api_key=api_key, api_url=api_url))
seen.add(identity)
return tuple(clients)
def _feedback_id(run_id: str, key: str) -> uuid.UUID:
return uuid.uuid5(uuid.NAMESPACE_URL, f"langsmith-feedback:{run_id}:{key}")
def create_langsmith_feedback(
run_id: str,
key: str,
*,
score: float,
comment: str | None = None,
source_info: dict[str, Any] | None = None,
) -> bool:
"""Create or update deterministic feedback on all configured LangSmith clients."""
clients = _build_langsmith_feedback_clients()
if not clients:
logger.warning("No LangSmith API key configured, skipping feedback")
return False
feedback_id = _feedback_id(run_id, key)
any_success = False
for client in clients:
try:
client.create_feedback(
run_id=run_id,
key=key,
score=score,
comment=comment,
source_info=source_info,
feedback_source_type="api",
feedback_id=feedback_id,
)
any_success = True
except Exception:
try:
client.update_feedback(feedback_id, score=score, comment=comment)
any_success = True
except Exception:
logger.exception("Failed to create or update LangSmith feedback for run %s", run_id)
return any_success
def delete_langsmith_feedback(run_id: str, key: str) -> bool:
"""Delete deterministic feedback from all configured LangSmith clients."""
clients = _build_langsmith_feedback_clients()
if not clients:
logger.warning("No LangSmith API key configured, skipping feedback deletion")
return False
feedback_id = _feedback_id(run_id, key)
any_success = False
for client in clients:
try:
client.delete_feedback(feedback_id)
any_success = True
except LangSmithNotFoundError:
any_success = True
except Exception:
logger.exception("Failed to delete LangSmith feedback for run %s", run_id)
return any_success