From 66927436efc2949a8a72d4b2a68ad30e8327186e Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Sat, 22 Nov 2025 11:29:45 -0500 Subject: [PATCH 1/3] feat(explorer): add config for intelligence level to client --- src/sentry/seer/explorer/client.py | 37 ++++++------- .../seer/explorer/test_explorer_client.py | 52 ++++++++++++++++--- 2 files changed, 63 insertions(+), 26 deletions(-) diff --git a/src/sentry/seer/explorer/client.py b/src/sentry/seer/explorer/client.py index f8945e5ecc8782..1f8e47aae83528 100644 --- a/src/sentry/seer/explorer/client.py +++ b/src/sentry/seer/explorer/client.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, Literal import orjson import requests @@ -96,21 +96,34 @@ 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 + self.category_key = category_key + self.category_value = category_value + + # Validate that category_key and category_value are provided together + if (category_key is None) != (category_value is None): + 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) @@ -121,21 +134,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 @@ -152,6 +157,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 @@ -164,11 +170,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) @@ -193,10 +197,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() diff --git a/tests/sentry/seer/explorer/test_explorer_client.py b/tests/sentry/seer/explorer/test_explorer_client.py index 0e0f25c39f1257..b3ffcf2ecd2501 100644 --- a/tests/sentry/seer/explorer/test_explorer_client.py +++ b/tests/sentry/seer/explorer/test_explorer_client.py @@ -103,8 +103,10 @@ 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"]) @@ -112,26 +114,60 @@ def test_start_run_with_categories(self, mock_collect_context, mock_post, mock_a 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") From cb3b224c5011616e805adb8c6d21813c9a73eb3a Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Mon, 24 Nov 2025 08:19:45 -0500 Subject: [PATCH 2/3] truthiness --- src/sentry/seer/explorer/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/seer/explorer/client.py b/src/sentry/seer/explorer/client.py index 1f8e47aae83528..2b72c559ea9cb1 100644 --- a/src/sentry/seer/explorer/client.py +++ b/src/sentry/seer/explorer/client.py @@ -122,7 +122,7 @@ def __init__( self.category_value = category_value # Validate that category_key and category_value are provided together - if (category_key is None) != (category_value is None): + if bool(category_key) != bool(category_value): raise ValueError("category_key and category_value must be provided together") # Validate access on init From de8c6d072d3e32a0c9dad9dcab5a6c886ce94f1d Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Mon, 24 Nov 2025 08:21:02 -0500 Subject: [PATCH 3/3] empty strs --- src/sentry/seer/explorer/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sentry/seer/explorer/client.py b/src/sentry/seer/explorer/client.py index 2b72c559ea9cb1..368d41ac237cbc 100644 --- a/src/sentry/seer/explorer/client.py +++ b/src/sentry/seer/explorer/client.py @@ -122,6 +122,8 @@ def __init__( 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")