Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 21 additions & 18 deletions src/sentry/seer/explorer/client.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import logging
from typing import Any
from typing import Any, Literal

import orjson
import requests
Expand Down Expand Up @@ -96,21 +96,36 @@ def execute(cls, organization, **kwargs):
Args:
organization: Sentry organization
user: User for permission checks and user-specific context (can be User, AnonymousUser, or None)
category_key: Optional category key for filtering/grouping runs (e.g., "bug-fixer", "trace-analyzer"). Must be provided together with category_value. Makes it easy to retrieve runs for your feature later.
category_value: Optional category value for filtering/grouping runs (e.g., issue ID, trace ID). Must be provided together with category_key. Makes it easy to retrieve a specific run for your feature later.
artifact_schema: Optional Pydantic model to generate a structured artifact at the end of the run
custom_tools: Optional list of `ExplorerTool` objects to make available as tools to the agent. Each tool must inherit from ExplorerTool and implement get_params() and execute(). Tools are automatically given access to the organization context. Tool classes must be module-level (not nested classes).
intelligence_level: Optionally set the intelligence level of the agent. Higher intelligence gives better result quality at the cost of significantly higher latency and cost.
"""

def __init__(
self,
organization: Organization,
user: User | AnonymousUser | None = None,
category_key: str | None = None,
category_value: str | None = None,
artifact_schema: type[BaseModel] | None = None,
custom_tools: list[type[ExplorerTool]] | None = None,
intelligence_level: Literal["low", "medium", "high"] = "medium",
):
self.organization = organization
self.user = user
self.artifact_schema = artifact_schema
self.custom_tools = custom_tools or []
self.intelligence_level = intelligence_level
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So do we pick the model on the based on the intelligence_level level? Like 2.5 Flash-lite for low or Sonnet 4.5 / Opus 4 for high?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah

self.category_key = category_key
self.category_value = category_value

# Validate that category_key and category_value are provided together
if category_key == "" or category_value == "":
raise ValueError("category_key and category_value cannot be empty strings")
if bool(category_key) != bool(category_value):
raise ValueError("category_key and category_value must be provided together")

# Validate access on init
has_access, error = has_seer_explorer_access_with_detail(organization, user)
Expand All @@ -121,21 +136,13 @@ def start_run(
self,
prompt: str,
on_page_context: str | None = None,
category_key: str | None = None,
category_value: str | None = None,
) -> int:
"""
Start a new Seer Explorer session.

The client automatically collects user/org context (teams, projects, etc.)
and sends it to Seer for the agent to use. If artifact_schema was provided
in the constructor, it will be automatically included.

Args:
prompt: The initial task/query for the agent
on_page_context: Optional context from the user's screen
category_key: Optional category key for filtering/grouping runs
category_value: Optional category value for filtering/grouping runs

Returns:
int: The run ID that can be used to fetch results or continue the conversation
Expand All @@ -152,6 +159,7 @@ def start_run(
"insert_index": None,
"on_page_context": on_page_context,
"user_org_context": collect_user_org_context(self.user, self.organization),
"intelligence_level": self.intelligence_level,
}

# Add artifact schema if provided
Expand All @@ -164,11 +172,9 @@ def start_run(
extract_tool_schema(tool).dict() for tool in self.custom_tools
]

if category_key or category_value:
if not category_key or not category_value:
raise ValueError("category_key and category_value must be provided together")
payload["category_key"] = category_key
payload["category_value"] = category_value
if self.category_key and self.category_value:
payload["category_key"] = self.category_key
payload["category_value"] = self.category_value

body = orjson.dumps(payload, option=orjson.OPT_NON_STR_KEYS)

Expand All @@ -193,10 +199,7 @@ def continue_run(
on_page_context: str | None = None,
) -> int:
"""
Continue an existing Seer Explorer session.

This allows you to add follow-up queries to an ongoing conversation.
User context is NOT collected again (it was already captured at start).
Continue an existing Seer Explorer session. This allows you to add follow-up queries to an ongoing conversation.

Args:
run_id: The run ID from start_run()
Expand Down
52 changes: 44 additions & 8 deletions tests/sentry/seer/explorer/test_explorer_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,35 +103,71 @@ def test_start_run_with_categories(self, mock_collect_context, mock_post, mock_a
mock_response.json.return_value = {"run_id": 999}
mock_post.return_value = mock_response

client = SeerExplorerClient(self.organization, self.user)
run_id = client.start_run("Fix bug", category_key="bug-fixer", category_value="issue-123")
client = SeerExplorerClient(
self.organization, self.user, category_key="bug-fixer", category_value="issue-123"
)
run_id = client.start_run("Fix bug")

assert run_id == 999
body = orjson.loads(mock_post.call_args[1]["data"])
assert body["category_key"] == "bug-fixer"
assert body["category_value"] == "issue-123"

@patch("sentry.seer.explorer.client.has_seer_explorer_access_with_detail")
def test_start_run_category_key_only_raises_error(self, mock_access):
def test_init_category_key_only_raises_error(self, mock_access):
"""Test that ValueError is raised when only category_key is provided"""
mock_access.return_value = (True, None)

client = SeerExplorerClient(self.organization, self.user)
with pytest.raises(
ValueError, match="category_key and category_value must be provided together"
):
client.start_run("Test query", category_key="bug-fixer")
SeerExplorerClient(self.organization, self.user, category_key="bug-fixer")

@patch("sentry.seer.explorer.client.has_seer_explorer_access_with_detail")
def test_start_run_category_value_only_raises_error(self, mock_access):
def test_init_category_value_only_raises_error(self, mock_access):
"""Test that ValueError is raised when only category_value is provided"""
mock_access.return_value = (True, None)

client = SeerExplorerClient(self.organization, self.user)
with pytest.raises(
ValueError, match="category_key and category_value must be provided together"
):
client.start_run("Test query", category_value="issue-123")
SeerExplorerClient(self.organization, self.user, category_value="issue-123")

@patch("sentry.seer.explorer.client.has_seer_explorer_access_with_detail")
def test_client_init_with_intelligence_level(self, mock_access):
"""Test that intelligence_level is stored"""
mock_access.return_value = (True, None)

client = SeerExplorerClient(self.organization, self.user, intelligence_level="high")
assert client.intelligence_level == "high"

@patch("sentry.seer.explorer.client.has_seer_explorer_access_with_detail")
def test_client_init_default_intelligence_level(self, mock_access):
"""Test that intelligence_level defaults to 'medium'"""
mock_access.return_value = (True, None)

client = SeerExplorerClient(self.organization, self.user)
assert client.intelligence_level == "medium"

@patch("sentry.seer.explorer.client.has_seer_explorer_access_with_detail")
@patch("sentry.seer.explorer.client.requests.post")
@patch("sentry.seer.explorer.client.collect_user_org_context")
def test_start_run_includes_intelligence_level(
self, mock_collect_context, mock_post, mock_access
):
"""Test that intelligence_level is included in the payload"""
mock_access.return_value = (True, None)
mock_collect_context.return_value = {"user_id": self.user.id}
mock_response = MagicMock()
mock_response.json.return_value = {"run_id": 555}
mock_post.return_value = mock_response

client = SeerExplorerClient(self.organization, self.user, intelligence_level="low")
run_id = client.start_run("Test query")

assert run_id == 555
body = orjson.loads(mock_post.call_args[1]["data"])
assert body["intelligence_level"] == "low"

@patch("sentry.seer.explorer.client.has_seer_explorer_access_with_detail")
@patch("sentry.seer.explorer.client.requests.post")
Expand Down
Loading