Skip to content

Commit 9ba5ca8

Browse files
feat(search agent): Start and poll the agent state (#106324)
- endpoints to start agent translation and poll the state for frontend --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent 679f70b commit 9ba5ca8

File tree

5 files changed

+390
-0
lines changed

5 files changed

+390
-0
lines changed

src/sentry/api/urls.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,8 @@
547547
from sentry.seer.endpoints.organization_seer_setup_check import OrganizationSeerSetupCheckEndpoint
548548
from sentry.seer.endpoints.organization_trace_summary import OrganizationTraceSummaryEndpoint
549549
from sentry.seer.endpoints.project_seer_preferences import ProjectSeerPreferencesEndpoint
550+
from sentry.seer.endpoints.search_agent_start import SearchAgentStartEndpoint
551+
from sentry.seer.endpoints.search_agent_state import SearchAgentStateEndpoint
550552
from sentry.seer.endpoints.seer_rpc import SeerRpcServiceEndpoint
551553
from sentry.seer.endpoints.trace_explorer_ai_query import TraceExplorerAIQuery
552554
from sentry.seer.endpoints.trace_explorer_ai_setup import TraceExplorerAISetup
@@ -2368,6 +2370,16 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
23682370
SearchAgentTranslateEndpoint.as_view(),
23692371
name="sentry-api-0-search-agent-translate",
23702372
),
2373+
re_path(
2374+
r"^(?P<organization_id_or_slug>[^/]+)/search-agent/start/$",
2375+
SearchAgentStartEndpoint.as_view(),
2376+
name="sentry-api-0-search-agent-start",
2377+
),
2378+
re_path(
2379+
r"^(?P<organization_id_or_slug>[^/]+)/search-agent/state/(?P<run_id>[^/]+)/$",
2380+
SearchAgentStateEndpoint.as_view(),
2381+
name="sentry-api-0-search-agent-state",
2382+
),
23712383
re_path(
23722384
r"^(?P<organization_id_or_slug>[^/]+)/seer/explorer-chat/(?:(?P<run_id>[^/]+)/)?$",
23732385
OrganizationSeerExplorerChatEndpoint.as_view(),
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import Any
5+
6+
import orjson
7+
import requests
8+
from django.conf import settings
9+
from rest_framework import serializers, status
10+
from rest_framework.request import Request
11+
from rest_framework.response import Response
12+
13+
from sentry import features
14+
from sentry.api.api_owners import ApiOwner
15+
from sentry.api.api_publish_status import ApiPublishStatus
16+
from sentry.api.base import region_silo_endpoint
17+
from sentry.api.bases import OrganizationEndpoint
18+
from sentry.models.organization import Organization
19+
from sentry.seer.endpoints.trace_explorer_ai_setup import OrganizationTraceExplorerAIPermission
20+
from sentry.seer.explorer.client_utils import collect_user_org_context
21+
from sentry.seer.seer_setup import has_seer_access_with_detail
22+
from sentry.seer.signed_seer_api import sign_with_seer_secret
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
class SearchAgentStartSerializer(serializers.Serializer):
28+
project_ids = serializers.ListField(
29+
child=serializers.IntegerField(),
30+
required=True,
31+
allow_empty=False,
32+
help_text="List of project IDs to search in.",
33+
)
34+
natural_language_query = serializers.CharField(
35+
required=True,
36+
allow_blank=False,
37+
help_text="Natural language query to translate.",
38+
)
39+
strategy = serializers.CharField(
40+
required=False,
41+
default="Traces",
42+
help_text="Search strategy to use (Traces, Issues, Logs, Errors).",
43+
)
44+
options = serializers.DictField(
45+
required=False,
46+
allow_null=True,
47+
help_text="Optional configuration options.",
48+
)
49+
50+
def validate_options(self, value: dict[str, Any] | None) -> dict[str, Any] | None:
51+
if value is None:
52+
return None
53+
if "model_name" in value and not isinstance(value["model_name"], str):
54+
raise serializers.ValidationError("model_name must be a string")
55+
return value
56+
57+
58+
def send_search_agent_start_request(
59+
org_id: int,
60+
org_slug: str,
61+
project_ids: list[int],
62+
natural_language_query: str,
63+
strategy: str = "Traces",
64+
user_email: str | None = None,
65+
timezone: str | None = None,
66+
model_name: str | None = None,
67+
) -> dict[str, Any]:
68+
"""
69+
Sends a request to Seer to start an async search agent and returns a run_id for polling.
70+
"""
71+
body_dict: dict[str, Any] = {
72+
"org_id": org_id,
73+
"org_slug": org_slug,
74+
"project_ids": project_ids,
75+
"natural_language_query": natural_language_query,
76+
"strategy": strategy,
77+
}
78+
79+
if user_email:
80+
body_dict["user_email"] = user_email
81+
82+
if timezone:
83+
body_dict["timezone"] = timezone
84+
85+
options: dict[str, Any] = {}
86+
if model_name is not None:
87+
options["model_name"] = model_name
88+
89+
if options:
90+
body_dict["options"] = options
91+
92+
body = orjson.dumps(body_dict)
93+
94+
response = requests.post(
95+
f"{settings.SEER_AUTOFIX_URL}/v1/assisted-query/start",
96+
data=body,
97+
headers={
98+
"content-type": "application/json;charset=utf-8",
99+
**sign_with_seer_secret(body),
100+
},
101+
timeout=30,
102+
)
103+
response.raise_for_status()
104+
return response.json()
105+
106+
107+
@region_silo_endpoint
108+
class SearchAgentStartEndpoint(OrganizationEndpoint):
109+
"""
110+
Endpoint to start an async search agent and return a run_id for polling.
111+
112+
This starts the agent processing in the background and immediately returns
113+
a run_id that can be used with the /search-agent/state/ endpoint to poll
114+
for progress and results.
115+
"""
116+
117+
publish_status = {
118+
"POST": ApiPublishStatus.EXPERIMENTAL,
119+
}
120+
owner = ApiOwner.ML_AI
121+
122+
permission_classes = (OrganizationTraceExplorerAIPermission,)
123+
124+
def post(self, request: Request, organization: Organization) -> Response:
125+
"""
126+
Start an async search agent and return a run_id for polling.
127+
128+
Returns:
129+
{"run_id": int}
130+
"""
131+
serializer = SearchAgentStartSerializer(data=request.data)
132+
if not serializer.is_valid():
133+
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
134+
135+
validated_data = serializer.validated_data
136+
natural_language_query = validated_data["natural_language_query"]
137+
strategy = validated_data.get("strategy", "Traces")
138+
options = validated_data.get("options") or {}
139+
model_name = options.get("model_name")
140+
141+
projects = self.get_projects(
142+
request, organization, project_ids=set(validated_data["project_ids"])
143+
)
144+
project_ids = [project.id for project in projects]
145+
146+
if not features.has(
147+
"organizations:gen-ai-search-agent-translate", organization, actor=request.user
148+
):
149+
return Response(
150+
{"detail": "Feature flag not enabled"},
151+
status=status.HTTP_403_FORBIDDEN,
152+
)
153+
154+
has_seer_access, detail = has_seer_access_with_detail(organization, actor=request.user)
155+
if not has_seer_access:
156+
return Response(
157+
{"detail": detail},
158+
status=status.HTTP_403_FORBIDDEN,
159+
)
160+
161+
if not settings.SEER_AUTOFIX_URL:
162+
return Response(
163+
{"detail": "Seer is not properly configured."},
164+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
165+
)
166+
167+
# Collect user context for the agent
168+
user_org_context = collect_user_org_context(request.user, organization)
169+
user_email = user_org_context.get("user_email")
170+
timezone = user_org_context.get("user_timezone")
171+
172+
try:
173+
data = send_search_agent_start_request(
174+
organization.id,
175+
organization.slug,
176+
project_ids,
177+
natural_language_query,
178+
strategy=strategy,
179+
user_email=user_email,
180+
timezone=timezone,
181+
model_name=model_name,
182+
)
183+
184+
# Validate that run_id is present in the response
185+
run_id = data.get("run_id")
186+
if run_id is None:
187+
logger.error(
188+
"search_agent.missing_run_id",
189+
extra={
190+
"organization_id": organization.id,
191+
"project_ids": project_ids,
192+
"response_data": data,
193+
},
194+
)
195+
return Response(
196+
{"detail": "Failed to start search agent: missing run_id in response"},
197+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
198+
)
199+
200+
# Return the run_id for polling
201+
return Response({"run_id": run_id})
202+
203+
except requests.HTTPError as e:
204+
logger.exception(
205+
"search_agent.start_error",
206+
extra={
207+
"organization_id": organization.id,
208+
"project_ids": project_ids,
209+
"status_code": e.response.status_code if e.response is not None else None,
210+
},
211+
)
212+
return Response(
213+
{"detail": "Failed to start search agent"},
214+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
215+
)
216+
except Exception:
217+
logger.exception(
218+
"search_agent.start_error",
219+
extra={
220+
"organization_id": organization.id,
221+
"project_ids": project_ids,
222+
},
223+
)
224+
return Response(
225+
{"detail": "Failed to start search agent"},
226+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
227+
)

0 commit comments

Comments
 (0)