diff --git a/INSTALLATION.md b/INSTALLATION.md index 08b8758f..41272e9c 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -1,3 +1,4 @@ + # Installation Guide This guide walks you through setting up Open SWE end-to-end: local development, GitHub App creation, LangSmith configuration, webhooks, and production deployment. @@ -105,11 +106,13 @@ Open SWE uses [LangSmith](https://smith.langchain.com/) for: - **Tracing**: all agent runs are logged for debugging and observability - **Sandboxes**: each task runs in an isolated LangSmith cloud sandbox -### 4a. Get your API key +### 4a. Get your API key, project and tenant IDs 1. Create a [LangSmith account](https://smith.langchain.com/) if you don't have one 2. Go to **Settings → API Keys → Create API Key** 3. Save it as `LANGSMITH_API_KEY_PROD` +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` ### 4b. Configure GitHub OAuth (optional but recommended) @@ -300,6 +303,9 @@ Create a `.env` file in the project root. Below is the full list — only fill i 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_URL_PROD="https://smith.langchain.com" # === LLM === ANTHROPIC_API_KEY="" # Anthropic API key (default provider) diff --git a/agent/utils/langsmith.py b/agent/utils/langsmith.py new file mode 100644 index 00000000..8c361f13 --- /dev/null +++ b/agent/utils/langsmith.py @@ -0,0 +1,30 @@ +"""LangSmith trace URL utilities.""" + +from __future__ import annotations + +import logging +import os + +logger = logging.getLogger(__name__) + + +def _compose_langsmith_url_base() -> str: + """Build the LangSmith URL base from environment variables.""" + 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") + if not tenant_id or not project_id: + raise ValueError( + "LANGSMITH_TENANT_ID_PROD and LANGSMITH_TRACING_PROJECT_ID_PROD must be set" + ) + return f"{host_url}/o/{tenant_id}/projects/p/{project_id}/r" + + +def get_langsmith_trace_url(run_id: str) -> str | None: + """Build the LangSmith trace URL for a given run ID.""" + try: + url_base = _compose_langsmith_url_base() + return f"{url_base}/{run_id}?poll=true" + except Exception: # noqa: BLE001 + logger.warning("Failed to build LangSmith trace URL for run %s", run_id, exc_info=True) + return None diff --git a/agent/utils/linear.py b/agent/utils/linear.py index 96bb9cd6..723c46e6 100644 --- a/agent/utils/linear.py +++ b/agent/utils/linear.py @@ -7,17 +7,22 @@ import os import httpx +from agent.utils.langsmith import get_langsmith_trace_url + logger = logging.getLogger(__name__) LINEAR_API_KEY = os.environ.get("LINEAR_API_KEY", "") -async def comment_on_linear_issue(issue_id: str, comment_body: str) -> bool: - """Add a comment to a Linear issue. +async def comment_on_linear_issue( + issue_id: str, comment_body: str, parent_id: str | None = None +) -> bool: + """Add a comment to a Linear issue, optionally as a reply to a specific comment. Args: issue_id: The Linear issue ID comment_body: The comment text + parent_id: Optional comment ID to reply to Returns: True if successful, False otherwise @@ -28,8 +33,8 @@ async def comment_on_linear_issue(issue_id: str, comment_body: str) -> bool: url = "https://api.linear.app/graphql" mutation = """ - mutation CommentCreate($issueId: String!, $body: String!) { - commentCreate(input: { issueId: $issueId, body: $body }) { + mutation CommentCreate($issueId: String!, $body: String!, $parentId: String) { + commentCreate(input: { issueId: $issueId, body: $body, parentId: $parentId }) { success comment { id @@ -48,7 +53,11 @@ async def comment_on_linear_issue(issue_id: str, comment_body: str) -> bool: }, json={ "query": mutation, - "variables": {"issueId": issue_id, "body": comment_body}, + "variables": { + "issueId": issue_id, + "body": comment_body, + "parentId": parent_id, + }, }, ) response.raise_for_status() @@ -56,3 +65,14 @@ async def comment_on_linear_issue(issue_id: str, comment_body: str) -> bool: return bool(result.get("data", {}).get("commentCreate", {}).get("success")) except Exception: # noqa: BLE001 return False + + +async def post_linear_trace_comment(issue_id: str, run_id: str, triggering_comment_id: str) -> None: + """Post a trace URL comment on a Linear issue.""" + trace_url = get_langsmith_trace_url(run_id) + if trace_url: + await comment_on_linear_issue( + issue_id, + f"On it! [View trace]({trace_url})", + parent_id=triggering_comment_id or None, + ) diff --git a/agent/utils/slack.py b/agent/utils/slack.py index a581bf29..7da61a05 100644 --- a/agent/utils/slack.py +++ b/agent/utils/slack.py @@ -12,6 +12,8 @@ from typing import Any import httpx +from agent.utils.langsmith import get_langsmith_trace_url + logger = logging.getLogger(__name__) SLACK_API_BASE_URL = "https://slack.com/api" @@ -355,3 +357,12 @@ async def fetch_slack_thread_messages(channel_id: str, thread_ts: str) -> list[d messages.sort(key=lambda item: _parse_ts(item.get("ts"))) return messages + + +async def post_slack_trace_reply(channel_id: str, thread_ts: str, run_id: str) -> None: + """Post a trace URL reply in a Slack thread.""" + trace_url = get_langsmith_trace_url(run_id) + if trace_url: + await post_slack_thread_reply( + channel_id, thread_ts, f"Working on it! <{trace_url}|View trace>" + ) diff --git a/agent/webapp.py b/agent/webapp.py index 7564d55e..399f00cf 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -36,6 +36,7 @@ from .utils.github_comments import ( ) from .utils.github_token import get_github_token_from_thread from .utils.github_user_email_map import GITHUB_USER_EMAIL_MAP +from .utils.linear import post_linear_trace_comment from .utils.linear_team_repo_map import LINEAR_TEAM_TO_REPO from .utils.multimodal import dedupe_urls, extract_image_urls, fetch_image_block from .utils.slack import ( @@ -45,6 +46,7 @@ from .utils.slack import ( get_slack_user_info, get_slack_user_names, post_slack_thread_reply, + post_slack_trace_reply, select_slack_context_messages, strip_bot_mention, verify_slack_signature, @@ -680,12 +682,16 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 if queued: logger.info("Message queued for thread %s, will be processed by middleware", thread_id) + langgraph_client = get_client(url=LANGGRAPH_URL) + runs = await langgraph_client.runs.list(thread_id, limit=1) + if runs: + await post_linear_trace_comment(issue_id, runs[0]["run_id"], triggering_comment_id) else: logger.error("Failed to queue message for thread %s", thread_id) else: logger.info("Creating LangGraph run for thread %s", thread_id) langgraph_client = get_client(url=LANGGRAPH_URL) - await langgraph_client.runs.create( + run = await langgraph_client.runs.create( thread_id, "agent", input={"messages": [{"role": "user", "content": content_blocks}]}, @@ -693,6 +699,7 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 if_not_exists="create", ) logger.info("LangGraph run created successfully for thread %s", thread_id) + await post_linear_trace_comment(issue_id, run["run_id"], triggering_comment_id) async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[str, str]) -> None: @@ -800,7 +807,7 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st langgraph_client = get_client(url=LANGGRAPH_URL) await _upsert_slack_thread_repo_metadata(thread_id, repo_config, langgraph_client) - await langgraph_client.runs.create( + run = await langgraph_client.runs.create( thread_id, "agent", input={"messages": [{"role": "user", "content": content_blocks}]}, @@ -808,6 +815,7 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st if_not_exists="create", multitask_strategy="interrupt", ) + await post_slack_trace_reply(channel_id, thread_ts, run["run_id"]) def verify_linear_signature(body: bytes, signature: str, secret: str) -> bool: