diff --git a/INSTALLATION.md b/INSTALLATION.md index c76287b3..916d6395 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -129,6 +129,8 @@ Open SWE uses [LangSmith](https://smith.langchain.com/) for: 4. Get your **Tenant ID**: Visit LangSmith, login, then copy the UUID in the URL. Example: if your URL is `https://smith.langchain.com/o/72184268-01ea-4d29-98cc-6cfcf0f2abb0/agents/chat` -> the tenant ID would be `72184268-01ea-4d29-98cc-6cfcf0f2abb0`. Save it as `LANGSMITH_TENANT_ID_PROD`. 5. Get your **Project ID**: open your tracing project in LangSmith, then click on the **ID** button in the top left, directly next to the project name. Save it as `LANGSMITH_TRACING_PROJECT_ID_PROD` +> **Note on per-graph tracing projects.** The graphs trace into separate projects by name — `open-swe-agent` (main agent) and `open-swe-review` (reviewer/analyzer). "View trace" links resolve the correct project ID from these names automatically (via the `LANGSMITH_API_KEY_PROD` client), so make sure projects with these names exist in your tenant. If a name can't be resolved, links fall back to `LANGSMITH_TRACING_PROJECT_ID_PROD`, so set it to whichever project you want links to point at by default. + ### 4b. Configure GitHub OAuth (optional but recommended) This is the **agent-runtime** OAuth provider: it lets each agent run authenticate with the triggering user's own GitHub account, brokered by LangSmith. (It is separate from the dashboard-login OAuth, which uses `GITHUB_APP_CLIENT_ID`/`GITHUB_APP_CLIENT_SECRET` directly — see step 3c.) Without it, all agent operations use the GitHub App's installation token (a shared bot identity). @@ -399,7 +401,7 @@ LANGSMITH_API_KEY_PROD="" # From step 4a LANGCHAIN_TRACING_V2="true" LANGCHAIN_PROJECT="" # LangSmith project name for traces LANGSMITH_TENANT_ID_PROD="" -LANGSMITH_TRACING_PROJECT_ID_PROD="" +LANGSMITH_TRACING_PROJECT_ID_PROD="" # Fallback project ID for "View trace" links; graphs trace into the open-swe-agent / open-swe-review projects by name LANGSMITH_URL_PROD="https://smith.langchain.com" # === LLM === diff --git a/agent/tools/publish_review.py b/agent/tools/publish_review.py index 19f330ab..c15b90ec 100644 --- a/agent/tools/publish_review.py +++ b/agent/tools/publish_review.py @@ -52,6 +52,7 @@ from ..utils.github_token import ( ) from ..utils.langsmith import get_langsmith_trace_url from ..utils.slack import post_slack_thread_reply +from ..utils.tracing import REVIEW_TRACING_PROJECT def publish_review( @@ -175,7 +176,7 @@ async def _resolve_review_trace_url(thread_id: str, config_override: object) -> return None if not thread_id: return None - return get_langsmith_trace_url(thread_id) + return get_langsmith_trace_url(thread_id, project_name=REVIEW_TRACING_PROJECT) def _is_reviewer_eval_mode(configurable: dict[str, Any]) -> bool: diff --git a/agent/utils/langsmith.py b/agent/utils/langsmith.py index 48a7ae86..c0af0924 100644 --- a/agent/utils/langsmith.py +++ b/agent/utils/langsmith.py @@ -10,25 +10,71 @@ 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 _compose_langsmith_project_url() -> str: - """Build the LangSmith project URL base from environment variables.""" + +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 = os.environ.get("LANGSMITH_TRACING_PROJECT_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 and LANGSMITH_TRACING_PROJECT_ID_PROD must be set" + "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) -> str | None: +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_url = _compose_langsmith_project_url(project_name) return f"{project_url}/t/{thread_id}" except Exception: # noqa: BLE001 logger.warning( diff --git a/tests/test_langsmith_trace_url.py b/tests/test_langsmith_trace_url.py new file mode 100644 index 00000000..22310072 --- /dev/null +++ b/tests/test_langsmith_trace_url.py @@ -0,0 +1,68 @@ +import pytest + +from agent.utils import langsmith as ls_utils +from agent.utils.tracing import AGENT_TRACING_PROJECT, REVIEW_TRACING_PROJECT + + +@pytest.fixture(autouse=True) +def _clear_cache() -> None: + ls_utils._PROJECT_ID_CACHE.clear() + + +def _set_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LANGSMITH_URL_PROD", "https://smith.example") + monkeypatch.setenv("LANGSMITH_TENANT_ID_PROD", "tenant-1") + monkeypatch.delenv("LANGSMITH_TRACING_PROJECT_ID_PROD", raising=False) + + +def test_trace_url_resolves_project_id_by_name(monkeypatch: pytest.MonkeyPatch) -> None: + _set_env(monkeypatch) + monkeypatch.setattr( + ls_utils, + "_resolve_project_id_by_name", + lambda name: "agent-pid" if name == AGENT_TRACING_PROJECT else "review-pid", + ) + + agent_url = ls_utils.get_langsmith_trace_url("t1") + review_url = ls_utils.get_langsmith_trace_url("t2", project_name=REVIEW_TRACING_PROJECT) + + assert agent_url == "https://smith.example/o/tenant-1/projects/p/agent-pid/t/t1" + assert review_url == "https://smith.example/o/tenant-1/projects/p/review-pid/t/t2" + + +def test_trace_url_falls_back_to_env_project_id(monkeypatch: pytest.MonkeyPatch) -> None: + _set_env(monkeypatch) + monkeypatch.setenv("LANGSMITH_TRACING_PROJECT_ID_PROD", "env-pid") + monkeypatch.setattr(ls_utils, "_resolve_project_id_by_name", lambda name: None) + + url = ls_utils.get_langsmith_trace_url("t3") + + assert url == "https://smith.example/o/tenant-1/projects/p/env-pid/t/t3" + + +def test_trace_url_none_when_unresolvable(monkeypatch: pytest.MonkeyPatch) -> None: + _set_env(monkeypatch) + monkeypatch.setattr(ls_utils, "_resolve_project_id_by_name", lambda name: None) + + assert ls_utils.get_langsmith_trace_url("t4") is None + + +def test_resolve_project_id_caches_success(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + + class _FakeProject: + id = "pid-123" + + class _FakeClient: + def read_project(self, *, project_name: str) -> _FakeProject: + calls.append(project_name) + return _FakeProject() + + monkeypatch.setattr(ls_utils, "_build_prod_langsmith_client", lambda: _FakeClient()) + + first = ls_utils._resolve_project_id_by_name(AGENT_TRACING_PROJECT) + second = ls_utils._resolve_project_id_by_name(AGENT_TRACING_PROJECT) + + assert first == "pid-123" + assert second == "pid-123" + assert calls == [AGENT_TRACING_PROJECT]