Skip to content

Commit 5d6d25a

Browse files
committed
feat(explorer): add on_completion hook to client
1 parent b24f5f3 commit 5d6d25a

File tree

4 files changed

+236
-0
lines changed

4 files changed

+236
-0
lines changed

src/sentry/seer/endpoints/seer_rpc.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
rpc_get_trace_for_transaction,
8787
rpc_get_transactions_for_project,
8888
)
89+
from sentry.seer.explorer.on_completion_hook import call_on_completion_hook
8990
from sentry.seer.explorer.tools import (
9091
execute_table_query,
9192
execute_timeseries_query,
@@ -1039,6 +1040,7 @@ def check_repository_integrations_status(*, repository_integrations: list[dict[s
10391040
"get_trace_item_attributes": get_trace_item_attributes,
10401041
"get_repository_definition": get_repository_definition,
10411042
"call_custom_tool": call_custom_tool,
1043+
"call_on_completion_hook": call_on_completion_hook,
10421044
"get_log_attributes_for_trace": get_log_attributes_for_trace,
10431045
"get_metric_attributes_for_trace": get_metric_attributes_for_trace,
10441046
#

src/sentry/seer/explorer/client.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
poll_until_done,
1919
)
2020
from sentry.seer.explorer.custom_tool_utils import ExplorerTool, extract_tool_schema
21+
from sentry.seer.explorer.on_completion_hook import (
22+
ExplorerOnCompletionHook,
23+
extract_hook_definition,
24+
)
2125
from sentry.seer.models import SeerPermissionError
2226
from sentry.seer.signed_seer_api import sign_with_seer_secret
2327
from sentry.users.models.user import User
@@ -97,6 +101,22 @@ def execute(cls, organization, params: DeploymentStatusParams) -> str:
97101
custom_tools=[DeploymentStatusTool]
98102
)
99103
run_id = client.start_run("Check if payment-service is deployed in production")
104+
105+
# WITH ON-COMPLETION HOOK
106+
from sentry.seer.explorer.on_completion_hook import ExplorerOnCompletionHook
107+
108+
class NotifyOnComplete(ExplorerOnCompletionHook):
109+
@classmethod
110+
def execute(cls, organization: Organization, run_id: int) -> None:
111+
# Called when the agent completes (regardless of status)
112+
send_notification(organization, f"Explorer run {run_id} completed")
113+
114+
client = SeerExplorerClient(
115+
organization,
116+
user,
117+
on_completion=NotifyOnComplete
118+
)
119+
run_id = client.start_run("Analyze this issue")
100120
```
101121
102122
Args:
@@ -105,6 +125,7 @@ def execute(cls, organization, params: DeploymentStatusParams) -> str:
105125
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.
106126
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.
107127
custom_tools: Optional list of `ExplorerTool` classes to make available as tools to the agent. Each tool must inherit from ExplorerTool, define a params_model (Pydantic BaseModel), and implement execute(). Tools are automatically given access to the organization context. Tool classes must be module-level (not nested classes).
128+
on_completion_hook: Optional `ExplorerOnCompletionHook` class to call when the agent completes. The hook's execute() method receives the organization and run ID. This is called whether or not the agent was successful. Hook classes must be module-level (not nested classes).
108129
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.
109130
is_interactive: Enable full interactive, human-like features of the agent. Only enable if you support *all* available interactions in Seer. An example use of this is the explorer chat in Sentry UI.
110131
"""
@@ -116,12 +137,14 @@ def __init__(
116137
category_key: str | None = None,
117138
category_value: str | None = None,
118139
custom_tools: list[type[ExplorerTool[Any]]] | None = None,
140+
on_completion_hook: type[ExplorerOnCompletionHook] | None = None,
119141
intelligence_level: Literal["low", "medium", "high"] = "medium",
120142
is_interactive: bool = False,
121143
):
122144
self.organization = organization
123145
self.user = user
124146
self.custom_tools = custom_tools or []
147+
self.on_completion_hook = on_completion_hook
125148
self.intelligence_level = intelligence_level
126149
self.category_key = category_key
127150
self.category_value = category_value
@@ -188,6 +211,10 @@ def start_run(
188211
extract_tool_schema(tool).dict() for tool in self.custom_tools
189212
]
190213

214+
# Add on-completion hook if provided
215+
if self.on_completion_hook:
216+
payload["on_completion_hook"] = extract_hook_definition(self.on_completion_hook).dict()
217+
191218
if self.category_key and self.category_value:
192219
payload["category_key"] = self.category_key
193220
payload["category_value"] = self.category_value
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
from __future__ import annotations
2+
3+
import importlib
4+
from abc import ABC, abstractmethod
5+
6+
from pydantic import BaseModel
7+
8+
from sentry.models.organization import Organization
9+
10+
11+
class OnCompletionHookDefinition(BaseModel):
12+
"""Definition of an on-completion hook to pass to Seer."""
13+
14+
module_path: str
15+
16+
17+
class ExplorerOnCompletionHook(ABC):
18+
"""Base class for Explorer on-completion hooks.
19+
20+
Hooks are called when an Explorer agent run completes (regardless of status).
21+
22+
Example:
23+
class MyCompletionHook(ExplorerOnCompletionHook):
24+
@classmethod
25+
def execute(cls, organization: Organization, run_id: int) -> None:
26+
# Do something when the run completes
27+
notify_user(organization, run_id)
28+
29+
# Pass to client
30+
client = SeerExplorerClient(
31+
organization,
32+
user,
33+
on_completion_hook=MyCompletionHook
34+
)
35+
"""
36+
37+
@classmethod
38+
@abstractmethod
39+
def execute(cls, organization: Organization, run_id: int) -> None:
40+
"""Execute the hook when the agent completes.
41+
42+
Args:
43+
organization: The organization context
44+
run_id: The ID of the completed run
45+
"""
46+
...
47+
48+
@classmethod
49+
def get_module_path(cls) -> str:
50+
"""Get the full module path for this hook class."""
51+
if not hasattr(cls, "__module__") or not hasattr(cls, "__name__"):
52+
raise ValueError(f"Hook class {cls} must have __module__ and __name__ attributes")
53+
if not cls.__module__ or not cls.__name__:
54+
raise ValueError(f"Hook class {cls} has empty __module__ or __name__")
55+
return f"{cls.__module__}.{cls.__name__}"
56+
57+
58+
def extract_hook_definition(
59+
hook_class: type[ExplorerOnCompletionHook],
60+
) -> OnCompletionHookDefinition:
61+
"""Extract hook definition from an ExplorerOnCompletionHook class."""
62+
# Enforce module-level classes only (no nested classes)
63+
if "." in hook_class.__qualname__:
64+
raise ValueError(
65+
f"Hook class {hook_class.__name__} must be a module-level class. "
66+
f"Nested classes are not supported. (qualname: {hook_class.__qualname__})"
67+
)
68+
69+
return OnCompletionHookDefinition(module_path=hook_class.get_module_path())
70+
71+
72+
def call_on_completion_hook(
73+
*,
74+
module_path: str,
75+
organization_id: int,
76+
run_id: int,
77+
allowed_prefixes: tuple[str, ...] = ("sentry.",),
78+
) -> None:
79+
"""Dynamically import and call an on-completion hook class.
80+
81+
Args:
82+
module_path: Full module path to the hook class (e.g., "sentry.api.MyHook")
83+
organization_id: Organization ID to load and pass to the hook
84+
run_id: The run ID that completed
85+
allowed_prefixes: Tuple of allowed module path prefixes for security
86+
"""
87+
# Only allow imports from approved package prefixes
88+
if not any(module_path.startswith(prefix) for prefix in allowed_prefixes):
89+
raise ValueError(
90+
f"Module path must start with one of {allowed_prefixes}, got: {module_path}"
91+
)
92+
93+
# Load the organization
94+
try:
95+
organization = Organization.objects.get(id=organization_id)
96+
except Organization.DoesNotExist:
97+
raise ValueError(f"Organization with id {organization_id} does not exist")
98+
99+
# Split module path and class name
100+
parts = module_path.rsplit(".", 1)
101+
if len(parts) != 2:
102+
raise ValueError(f"Invalid module path: {module_path}")
103+
104+
module_name, class_name = parts
105+
106+
# Import the hook class
107+
try:
108+
module = importlib.import_module(module_name)
109+
hook_class = getattr(module, class_name)
110+
except (ImportError, AttributeError) as e:
111+
raise ValueError(f"Could not import {module_path}: {e}")
112+
113+
# Validate it's an ExplorerOnCompletionHook subclass
114+
if not isinstance(hook_class, type) or not issubclass(hook_class, ExplorerOnCompletionHook):
115+
raise ValueError(
116+
f"{module_path} must be a class that inherits from ExplorerOnCompletionHook"
117+
)
118+
119+
# Execute the hook
120+
hook_class.execute(organization, run_id)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import pytest
2+
3+
from sentry.models.organization import Organization
4+
from sentry.seer.explorer.on_completion_hook import (
5+
ExplorerOnCompletionHook,
6+
OnCompletionHookDefinition,
7+
call_on_completion_hook,
8+
extract_hook_definition,
9+
)
10+
from sentry.testutils.cases import TestCase
11+
12+
13+
# Test hook class (defined at module level as required)
14+
class SampleCompletionHook(ExplorerOnCompletionHook):
15+
@classmethod
16+
def execute(cls, organization: Organization, run_id: int) -> None:
17+
# Side effect: write to organization options so we can verify execution
18+
organization.update_option("test_hook_run_id", run_id)
19+
20+
21+
class OnCompletionHookTest(TestCase):
22+
def test_extract_hook_definition(self):
23+
"""Test extracting hook definition from a hook class."""
24+
hook_def = extract_hook_definition(SampleCompletionHook)
25+
26+
assert isinstance(hook_def, OnCompletionHookDefinition)
27+
assert hook_def.module_path.endswith("test_on_completion_hook.SampleCompletionHook")
28+
29+
def test_extract_hook_definition_nested_class_raises(self):
30+
"""Test that nested classes are rejected."""
31+
32+
class OuterClass:
33+
class NestedHook(ExplorerOnCompletionHook):
34+
@classmethod
35+
def execute(cls, organization: Organization, run_id: int) -> None:
36+
pass
37+
38+
with pytest.raises(ValueError) as cm:
39+
extract_hook_definition(OuterClass.NestedHook)
40+
assert "module-level class" in str(cm.value)
41+
42+
def test_call_on_completion_hook_success(self):
43+
"""Test calling a completion hook successfully."""
44+
module_path = "tests.sentry.seer.explorer.test_on_completion_hook.SampleCompletionHook"
45+
46+
call_on_completion_hook(
47+
module_path=module_path,
48+
organization_id=self.organization.id,
49+
run_id=12345,
50+
allowed_prefixes=("sentry.", "tests.sentry."),
51+
)
52+
53+
# Verify side effect: hook wrote run_id to organization options
54+
assert self.organization.get_option("test_hook_run_id") == 12345
55+
56+
def test_call_on_completion_hook_security_restriction(self):
57+
"""Test that module path must start with allowed prefix."""
58+
with pytest.raises(ValueError) as cm:
59+
call_on_completion_hook(
60+
module_path="malicious.module.Hook",
61+
organization_id=self.organization.id,
62+
run_id=123,
63+
allowed_prefixes=("sentry.",),
64+
)
65+
assert "must start with one of" in str(cm.value)
66+
67+
def test_call_on_completion_hook_invalid_module(self):
68+
"""Test calling a non-existent hook module."""
69+
with pytest.raises(ValueError) as cm:
70+
call_on_completion_hook(
71+
module_path="sentry.nonexistent.module.Hook",
72+
organization_id=self.organization.id,
73+
run_id=123,
74+
)
75+
assert "Could not import" in str(cm.value)
76+
77+
def test_call_on_completion_hook_not_a_hook_class(self):
78+
"""Test calling something that isn't an ExplorerOnCompletionHook."""
79+
# BaseModel is importable but not an ExplorerOnCompletionHook
80+
with pytest.raises(ValueError) as cm:
81+
call_on_completion_hook(
82+
module_path="pydantic.BaseModel",
83+
organization_id=self.organization.id,
84+
run_id=123,
85+
allowed_prefixes=("pydantic.",),
86+
)
87+
assert "must be a class that inherits from ExplorerOnCompletionHook" in str(cm.value)

0 commit comments

Comments
 (0)