diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index e44308d8bacf46..74f9484dd2642f 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -317,6 +317,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:seer-explorer-context-engine-fe-override-ui-flag", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable code editing tools in Seer Explorer chat manager.add("organizations:seer-explorer-chat-coding", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Enable code mode tools (sentry_api_search/execute) in Seer Explorer + manager.add("organizations:seer-explorer-code-mode-tools", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable structured LLM context (JSON snapshot) instead of ASCII DOM snapshot manager.add("organizations:context-engine-structured-page-context", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable the Seer Overview sections for Seat-Based users diff --git a/src/sentry/seer/endpoints/organization_seer_explorer_chat.py b/src/sentry/seer/endpoints/organization_seer_explorer_chat.py index a0ba5be568ce1d..7281ed0f19047b 100644 --- a/src/sentry/seer/endpoints/organization_seer_explorer_chat.py +++ b/src/sentry/seer/endpoints/organization_seer_explorer_chat.py @@ -172,11 +172,15 @@ def post( ) and features.has( "organizations:seer-explorer-chat-coding", organization, actor=request.user ) + enable_code_mode_tools = features.has( + "organizations:seer-explorer-code-mode-tools", organization, actor=request.user + ) client = SeerExplorerClient( organization, request.user, is_interactive=True, enable_coding=enable_coding, + enable_code_mode_tools=enable_code_mode_tools, ) if run_id: # Continue existing conversation @@ -186,6 +190,7 @@ def post( insert_index=insert_index, on_page_context=on_page_context, page_name=page_name, + request=request, ) else: # Start new conversation @@ -194,6 +199,7 @@ def post( on_page_context=on_page_context, page_name=page_name, override_ce_enable=override_ce_enable, + request=request, ) return Response({"run_id": result_run_id}) diff --git a/src/sentry/seer/explorer/client.py b/src/sentry/seer/explorer/client.py index 1c3d0dd803ad50..a51f4bdde0a265 100644 --- a/src/sentry/seer/explorer/client.py +++ b/src/sentry/seer/explorer/client.py @@ -20,6 +20,7 @@ ExplorerRunsRequest, ExplorerUpdateRequest, collect_user_org_context, + create_explorer_api_token, fetch_run_status, make_explorer_chat_request, make_explorer_runs_request, @@ -196,6 +197,7 @@ def __init__( intelligence_level: Literal["low", "medium", "high"] = "medium", is_interactive: bool = False, enable_coding: bool = False, + enable_code_mode_tools: bool = False, max_iterations: int | None = None, ): self.organization = organization @@ -207,6 +209,7 @@ def __init__( self.category_key = category_key self.category_value = category_value self.is_interactive = is_interactive + self.enable_code_mode_tools = enable_code_mode_tools self.max_iterations = max_iterations if enable_coding and not organization.get_option("sentry:enable_seer_coding", True): @@ -266,6 +269,15 @@ def start_run( if bool(artifact_schema) != bool(artifact_key): raise ValueError("artifact_key and artifact_schema must be provided together") + user_org_context = collect_user_org_context(self.user, self.organization, request=request) + user_auth_token = ( + create_explorer_api_token(self.user, self.organization) + if self.enable_code_mode_tools + and self.user + and not isinstance(self.user, AnonymousUser) + else None + ) + chat_body: ExplorerChatRequest = ExplorerChatRequest( organization_id=self.organization.id, query=prompt, @@ -273,12 +285,12 @@ def start_run( insert_index=None, on_page_context=on_page_context, page_name=page_name, - user_org_context=collect_user_org_context( - self.user, self.organization, request=request - ), + user_org_context=user_org_context, intelligence_level=self.intelligence_level, is_interactive=self.is_interactive, enable_coding=self.enable_coding, + enable_code_mode_tools=self.enable_code_mode_tools, + user_auth_token=user_auth_token, ) if self.max_iterations is not None: @@ -344,6 +356,7 @@ def continue_run( page_name: str | None = None, artifact_key: str | None = None, artifact_schema: type[BaseModel] | None = None, + request: Request | None = None, ) -> int: """ Continue an existing Seer Explorer session. This allows you to add follow-up queries to an ongoing conversation. @@ -366,6 +379,14 @@ def continue_run( if bool(artifact_schema) != bool(artifact_key): raise ValueError("artifact_key and artifact_schema must be provided together") + user_auth_token = ( + create_explorer_api_token(self.user, self.organization) + if self.enable_code_mode_tools + and self.user + and not isinstance(self.user, AnonymousUser) + else None + ) + chat_body: ExplorerChatRequest = ExplorerChatRequest( organization_id=self.organization.id, query=prompt, @@ -375,6 +396,8 @@ def continue_run( page_name=page_name, is_interactive=self.is_interactive, enable_coding=self.enable_coding, + enable_code_mode_tools=self.enable_code_mode_tools, + user_auth_token=user_auth_token, ) if prompt_metadata: diff --git a/src/sentry/seer/explorer/client_utils.py b/src/sentry/seer/explorer/client_utils.py index fa0be21574d64c..0149a2cafb18d9 100644 --- a/src/sentry/seer/explorer/client_utils.py +++ b/src/sentry/seer/explorer/client_utils.py @@ -9,12 +9,13 @@ import logging import time -from datetime import datetime +from datetime import datetime, timedelta from typing import Any, NotRequired, TypedDict import orjson from django.conf import settings from django.contrib.auth.models import AnonymousUser +from django.utils import timezone from rest_framework.request import Request from urllib3 import BaseHTTPResponse, HTTPConnectionPool @@ -57,6 +58,7 @@ class ExplorerChatRequest(TypedDict): intelligence_level: NotRequired[str] is_interactive: NotRequired[bool] enable_coding: NotRequired[bool] + enable_code_mode_tools: NotRequired[bool] project_id: NotRequired[int] query_metadata: NotRequired[dict[str, str]] artifact_key: NotRequired[str] @@ -68,6 +70,7 @@ class ExplorerChatRequest(TypedDict): metadata: NotRequired[dict[str, Any]] is_context_engine_enabled: NotRequired[bool] max_iterations: NotRequired[int] + user_auth_token: NotRequired[str | None] class ExplorerRunsRequest(TypedDict): @@ -297,6 +300,34 @@ def collect_user_org_context( } +def create_explorer_api_token(user: SentryUser | RpcUser, organization: Organization) -> str | None: + """Create a short-lived read-only API token for Seer to call back into Sentry.""" + try: + from sentry.models.apitoken import ApiToken + from sentry.types.token import AuthTokenType + from sentry.users.models.user import User as UserModel + + real_user = UserModel.objects.get(id=user.id) + token = ApiToken.objects.create( + user=real_user, + token_type=AuthTokenType.USER, + scoping_organization_id=organization.id, + scope_list=[ + "org:read", + "project:read", + "event:read", + "alerts:read", + "member:read", + "team:read", + ], + expires_at=timezone.now() + timedelta(hours=1), + ) + return token.plaintext_token + except Exception: + logger.exception("Failed to create short-lived API token for Seer Explorer") + return None + + def fetch_run_status(run_id: int, organization: Organization) -> SeerRunState: """Fetch current run status from Seer.""" body = ExplorerStateRequest(run_id=run_id, organization_id=organization.id) diff --git a/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py b/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py index a72dc1bfddde8c..7496559f3ba582 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_explorer_chat.py @@ -69,13 +69,18 @@ def test_post_new_conversation_calls_client(self, mock_client_class: MagicMock): assert response.status_code == 200 assert response.data == {"run_id": 456} mock_client_class.assert_called_once_with( - self.organization, ANY, is_interactive=True, enable_coding=False + self.organization, + ANY, + is_interactive=True, + enable_coding=False, + enable_code_mode_tools=False, ) mock_client.start_run.assert_called_once_with( prompt="What is this error about?", on_page_context=None, page_name=None, override_ce_enable=True, + request=ANY, ) @patch("sentry.seer.endpoints.organization_seer_explorer_chat.SeerExplorerClient") @@ -104,6 +109,7 @@ def test_post_new_conversation_enable_coding(self, mock_client_class: MagicMock) ANY, is_interactive=True, enable_coding=feature_enabled and option_enabled, + enable_code_mode_tools=False, ) @patch("sentry.seer.endpoints.organization_seer_explorer_chat.SeerExplorerClient") @@ -121,7 +127,11 @@ def test_post_continue_conversation_calls_client(self, mock_client_class: MagicM assert response.status_code == 200 assert response.data == {"run_id": 789} mock_client_class.assert_called_once_with( - self.organization, ANY, is_interactive=True, enable_coding=False + self.organization, + ANY, + is_interactive=True, + enable_coding=False, + enable_code_mode_tools=False, ) mock_client.continue_run.assert_called_once_with( run_id=789, @@ -129,6 +139,7 @@ def test_post_continue_conversation_calls_client(self, mock_client_class: MagicM insert_index=2, on_page_context=None, page_name=None, + request=ANY, ) @patch("sentry.seer.endpoints.organization_seer_explorer_chat.SeerExplorerClient") @@ -152,6 +163,7 @@ def test_post_continue_conversation_enable_coding(self, mock_client_class: Magic ANY, is_interactive=True, enable_coding=feature_enabled and option_enabled, + enable_code_mode_tools=False, ) @patch("sentry.seer.endpoints.organization_seer_explorer_chat.SeerExplorerClient")