Skip to content

Commit 0c7d671

Browse files
authored
ref(explorer): accept pydantic model for custom tools (#104366)
Change custom tool definitions in explorer client to take pydantic BaseModels for simpler code and simpler dev experience. Requires getsentry/seer#4175 to support Part of AIML-1969
1 parent 2cff096 commit 0c7d671

File tree

4 files changed

+156
-315
lines changed

4 files changed

+156
-315
lines changed

src/sentry/seer/explorer/client.py

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -73,31 +73,23 @@ class Solution(BaseModel):
7373
solution = state.get_artifact("solution", Solution)
7474
7575
# WITH CUSTOM TOOLS
76-
from sentry.seer.explorer.custom_tool_utils import ExplorerTool, ExplorerToolParam, StringType
76+
from pydantic import BaseModel, Field
77+
from sentry.seer.explorer.custom_tool_utils import ExplorerTool
7778
78-
class DeploymentStatusTool(ExplorerTool):
79-
@classmethod
80-
def get_description(cls):
81-
return "Check if a service is deployed in an environment"
79+
class DeploymentStatusParams(BaseModel):
80+
environment: str = Field(description="Environment name (e.g., 'production', 'staging')")
81+
service: str = Field(description="Service name")
82+
83+
class DeploymentStatusTool(ExplorerTool[DeploymentStatusParams]):
84+
params_model = DeploymentStatusParams
8285
8386
@classmethod
84-
def get_params(cls):
85-
return [
86-
ExplorerToolParam(
87-
name="environment",
88-
description="Environment name (e.g., 'production', 'staging')",
89-
type=StringType(),
90-
),
91-
ExplorerToolParam(
92-
name="service",
93-
description="Service name",
94-
type=StringType(),
95-
),
96-
]
87+
def get_description(cls) -> str:
88+
return "Check if a service is deployed in an environment"
9789
9890
@classmethod
99-
def execute(cls, organization, **kwargs):
100-
return "deployed" if check_deployment(organization, kwargs["environment"], kwargs["service"]) else "not deployed"
91+
def execute(cls, organization, params: DeploymentStatusParams) -> str:
92+
return "deployed" if check_deployment(organization, params.environment, params.service) else "not deployed"
10193
10294
client = SeerExplorerClient(
10395
organization,
@@ -112,7 +104,7 @@ def execute(cls, organization, **kwargs):
112104
user: User for permission checks and user-specific context (can be User, AnonymousUser, or None)
113105
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.
114106
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.
115-
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).
107+
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).
116108
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.
117109
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.
118110
"""
@@ -123,7 +115,7 @@ def __init__(
123115
user: User | AnonymousUser | None = None,
124116
category_key: str | None = None,
125117
category_value: str | None = None,
126-
custom_tools: list[type[ExplorerTool]] | None = None,
118+
custom_tools: list[type[ExplorerTool[Any]]] | None = None,
127119
intelligence_level: Literal["low", "medium", "high"] = "medium",
128120
is_interactive: bool = False,
129121
):

src/sentry/seer/explorer/client_models.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,7 @@ class CustomToolDefinition(BaseModel):
117117
name: str
118118
module_path: str
119119
description: str
120-
parameters: list[dict[str, Any]]
121-
required: list[str]
120+
param_schema: dict[str, Any] # JSON schema from Pydantic model
122121

123122

124123
class ExplorerRun(BaseModel):

src/sentry/seer/explorer/custom_tool_utils.py

Lines changed: 46 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -2,128 +2,59 @@
22

33
import importlib
44
from abc import ABC, abstractmethod
5-
from enum import StrEnum
6-
from typing import Any, Literal
5+
from typing import Any, Generic, TypeVar
76

87
from pydantic import BaseModel
98

109
from sentry.models.organization import Organization
1110
from sentry.seer.explorer.client_models import CustomToolDefinition
1211

12+
ParamsT = TypeVar("ParamsT", bound=BaseModel)
1313

14-
class ExplorerParamType(StrEnum):
15-
"""Allowed parameter types for Explorer tools."""
1614

17-
STRING = "string"
18-
INTEGER = "integer"
19-
NUMBER = "number"
20-
BOOLEAN = "boolean"
21-
ARRAY = "array"
22-
23-
24-
# Type specifications for different parameter types
25-
class StringType(BaseModel):
26-
"""Simple string type."""
27-
28-
kind: Literal["string"] = "string"
29-
30-
31-
class IntegerType(BaseModel):
32-
"""Simple integer type."""
33-
34-
kind: Literal["integer"] = "integer"
35-
36-
37-
class NumberType(BaseModel):
38-
"""Simple number (float) type."""
39-
40-
kind: Literal["number"] = "number"
41-
42-
43-
class BooleanType(BaseModel):
44-
"""Simple boolean type."""
45-
46-
kind: Literal["boolean"] = "boolean"
47-
48-
49-
class EnumType(BaseModel):
50-
"""String restricted to specific values."""
51-
52-
kind: Literal["enum"] = "enum"
53-
values: list[str]
54-
55-
56-
class ArrayType(BaseModel):
57-
"""Array with typed elements."""
58-
59-
kind: Literal["array"] = "array"
60-
item_type: ExplorerParamType
61-
62-
63-
ParamTypeSpec = StringType | IntegerType | NumberType | BooleanType | EnumType | ArrayType
64-
65-
66-
class ExplorerToolParam(BaseModel):
67-
"""Parameter definition for an Explorer tool.
68-
69-
Examples:
70-
# String parameter
71-
ExplorerToolParam(
72-
name="query",
73-
description="Search query",
74-
type=StringType()
75-
)
15+
class ExplorerTool(ABC, Generic[ParamsT]):
16+
"""Base class for custom Explorer tools.
7617
77-
# Array of strings
78-
ExplorerToolParam(
79-
name="tags",
80-
description="List of tags",
81-
type=ArrayType(item_type=ExplorerParamType.STRING)
82-
)
18+
Define parameters via a Pydantic model.
8319
84-
# Enum parameter
85-
ExplorerToolParam(
86-
name="status",
87-
description="Status",
88-
type=EnumType(values=["active", "inactive"])
89-
)
90-
"""
91-
92-
name: str
93-
description: str
94-
type: ParamTypeSpec
95-
required: bool = True
20+
Example:
21+
from pydantic import BaseModel, Field
9622
23+
class DeploymentStatusParams(BaseModel):
24+
environment: str = Field(description="Environment name (e.g., 'production', 'staging')")
25+
service: str = Field(description="Service name")
9726
98-
class ExplorerTool(ABC):
99-
"""Base class for custom Explorer tools.
27+
class DeploymentStatusTool(ExplorerTool[DeploymentStatusParams]):
28+
params_model = DeploymentStatusParams
10029
101-
Example:
102-
class DeploymentStatusTool(ExplorerTool):
10330
@classmethod
10431
def get_description(cls) -> str:
10532
return "Check if a service is deployed in an environment"
10633
10734
@classmethod
108-
def get_params(cls) -> list[ExplorerToolParam]:
109-
return [
110-
ExplorerToolParam(
111-
name="environment",
112-
description="Environment name",
113-
type=StringType(),
114-
),
115-
ExplorerToolParam(
116-
name="service",
117-
description="Service name",
118-
type=StringType(),
119-
),
120-
]
121-
122-
@classmethod
123-
def execute(cls, organization: Organization, **kwargs) -> str:
124-
return check_deployment(organization, kwargs["environment"], kwargs["service"])
35+
def execute(cls, organization: Organization, params: DeploymentStatusParams) -> str:
36+
return check_deployment(organization, params.environment, params.service)
12537
"""
12638

39+
# Define a Pydantic model for parameters
40+
params_model: type[ParamsT]
41+
42+
def __init_subclass__(cls, **kwargs: Any) -> None:
43+
super().__init_subclass__(**kwargs)
44+
# Skip validation for abstract subclasses
45+
if ABC in cls.__bases__:
46+
return
47+
if not hasattr(cls, "params_model") or cls.params_model is None:
48+
raise TypeError(
49+
f"{cls.__name__} must define a params_model class attribute. "
50+
"Use an empty BaseModel if no parameters are needed."
51+
)
52+
if not isinstance(cls.params_model, type) or not issubclass(cls.params_model, BaseModel):
53+
raise TypeError(
54+
f"{cls.__name__}.params_model must be a Pydantic BaseModel subclass, "
55+
f"got {type(cls.params_model)}"
56+
)
57+
12758
@classmethod
12859
@abstractmethod
12960
def get_description(cls) -> str:
@@ -132,14 +63,8 @@ def get_description(cls) -> str:
13263

13364
@classmethod
13465
@abstractmethod
135-
def get_params(cls) -> list[ExplorerToolParam]:
136-
"""Return the list of parameter definitions for this tool."""
137-
...
138-
139-
@classmethod
140-
@abstractmethod
141-
def execute(cls, organization: Organization, **kwargs) -> str:
142-
"""Execute the tool with the given organization and parameters."""
66+
def execute(cls, organization: Organization, params: ParamsT) -> str:
67+
"""Execute the tool with the given organization and validated parameters."""
14368
...
14469

14570
@classmethod
@@ -152,14 +77,14 @@ def get_module_path(cls) -> str:
15277
return f"{cls.__module__}.{cls.__name__}"
15378

15479

155-
def extract_tool_schema(tool_class: type[ExplorerTool]) -> CustomToolDefinition:
80+
def extract_tool_schema(tool_class: type[ExplorerTool[Any]]) -> CustomToolDefinition:
15681
"""Extract tool schema from an ExplorerTool class.
15782
15883
Args:
15984
tool_class: A class that inherits from ExplorerTool
16085
16186
Returns:
162-
CustomToolDefinition with the tool's name, description, parameters, and module path
87+
CustomToolDefinition with the tool's name, description, param_schema, and module path
16388
"""
16489
# Enforce module-level classes only (no nested classes)
16590
if "." in tool_class.__qualname__:
@@ -168,43 +93,11 @@ def extract_tool_schema(tool_class: type[ExplorerTool]) -> CustomToolDefinition:
16893
f"Nested classes are not supported. (qualname: {tool_class.__qualname__})"
16994
)
17095

171-
params = tool_class.get_params()
172-
173-
# Convert ExplorerToolParam list to parameter dicts
174-
parameters: list[dict[str, Any]] = []
175-
required: list[str] = []
176-
for param in params:
177-
param_dict: dict[str, Any] = {
178-
"name": param.name,
179-
"description": param.description,
180-
}
181-
182-
# Extract type information based on the type spec
183-
type_spec = param.type
184-
if isinstance(type_spec, EnumType):
185-
param_dict["type"] = "string"
186-
param_dict["enum"] = type_spec.values
187-
elif isinstance(type_spec, ArrayType):
188-
param_dict["type"] = "array"
189-
param_dict["items"] = {"type": type_spec.item_type.value}
190-
else:
191-
# Simple types: StringType, IntegerType, etc.
192-
param_dict["type"] = type_spec.kind
193-
194-
parameters.append(param_dict)
195-
196-
# Track required parameters
197-
if param.required:
198-
required.append(param.name)
199-
200-
description = tool_class.get_description()
201-
20296
return CustomToolDefinition(
20397
name=tool_class.__name__,
20498
module_path=tool_class.get_module_path(),
205-
description=description,
206-
parameters=parameters,
207-
required=required,
99+
description=tool_class.get_description(),
100+
param_schema=tool_class.params_model.schema(),
208101
)
209102

210103

@@ -256,9 +149,15 @@ def call_custom_tool(
256149
if not isinstance(tool_class, type) or not issubclass(tool_class, ExplorerTool):
257150
raise ValueError(f"{module_path} must be a class that inherits from ExplorerTool")
258151

152+
# Validate and parse params through the model
153+
try:
154+
params = tool_class.params_model(**kwargs)
155+
except Exception as e:
156+
raise ValueError(f"Invalid parameters for {module_path}: {e}")
157+
259158
# Execute the tool
260159
try:
261-
result = tool_class.execute(organization, **kwargs)
160+
result = tool_class.execute(organization, params)
262161
except Exception as e:
263162
raise RuntimeError(f"Error executing custom tool {module_path}: {e}")
264163

0 commit comments

Comments
 (0)