holmesgpt 0.13.2__py3-none-any.whl → 0.16.2a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. holmes/__init__.py +1 -1
  2. holmes/clients/robusta_client.py +17 -4
  3. holmes/common/env_vars.py +40 -1
  4. holmes/config.py +114 -144
  5. holmes/core/conversations.py +53 -14
  6. holmes/core/feedback.py +191 -0
  7. holmes/core/investigation.py +18 -22
  8. holmes/core/llm.py +489 -88
  9. holmes/core/models.py +103 -1
  10. holmes/core/openai_formatting.py +13 -0
  11. holmes/core/prompt.py +1 -1
  12. holmes/core/safeguards.py +4 -4
  13. holmes/core/supabase_dal.py +293 -100
  14. holmes/core/tool_calling_llm.py +423 -323
  15. holmes/core/tools.py +311 -33
  16. holmes/core/tools_utils/token_counting.py +14 -0
  17. holmes/core/tools_utils/tool_context_window_limiter.py +57 -0
  18. holmes/core/tools_utils/tool_executor.py +13 -8
  19. holmes/core/toolset_manager.py +155 -4
  20. holmes/core/tracing.py +6 -1
  21. holmes/core/transformers/__init__.py +23 -0
  22. holmes/core/transformers/base.py +62 -0
  23. holmes/core/transformers/llm_summarize.py +174 -0
  24. holmes/core/transformers/registry.py +122 -0
  25. holmes/core/transformers/transformer.py +31 -0
  26. holmes/core/truncation/compaction.py +59 -0
  27. holmes/core/truncation/dal_truncation_utils.py +23 -0
  28. holmes/core/truncation/input_context_window_limiter.py +218 -0
  29. holmes/interactive.py +177 -24
  30. holmes/main.py +7 -4
  31. holmes/plugins/prompts/_fetch_logs.jinja2 +26 -1
  32. holmes/plugins/prompts/_general_instructions.jinja2 +1 -2
  33. holmes/plugins/prompts/_runbook_instructions.jinja2 +23 -12
  34. holmes/plugins/prompts/conversation_history_compaction.jinja2 +88 -0
  35. holmes/plugins/prompts/generic_ask.jinja2 +2 -4
  36. holmes/plugins/prompts/generic_ask_conversation.jinja2 +2 -1
  37. holmes/plugins/prompts/generic_ask_for_issue_conversation.jinja2 +2 -1
  38. holmes/plugins/prompts/generic_investigation.jinja2 +2 -1
  39. holmes/plugins/prompts/investigation_procedure.jinja2 +48 -0
  40. holmes/plugins/prompts/kubernetes_workload_ask.jinja2 +2 -1
  41. holmes/plugins/prompts/kubernetes_workload_chat.jinja2 +2 -1
  42. holmes/plugins/runbooks/__init__.py +117 -18
  43. holmes/plugins/runbooks/catalog.json +2 -0
  44. holmes/plugins/toolsets/__init__.py +21 -8
  45. holmes/plugins/toolsets/aks-node-health.yaml +46 -0
  46. holmes/plugins/toolsets/aks.yaml +64 -0
  47. holmes/plugins/toolsets/atlas_mongodb/mongodb_atlas.py +26 -36
  48. holmes/plugins/toolsets/azure_sql/azure_sql_toolset.py +0 -1
  49. holmes/plugins/toolsets/azure_sql/tools/analyze_connection_failures.py +10 -7
  50. holmes/plugins/toolsets/azure_sql/tools/analyze_database_connections.py +9 -6
  51. holmes/plugins/toolsets/azure_sql/tools/analyze_database_health_status.py +8 -6
  52. holmes/plugins/toolsets/azure_sql/tools/analyze_database_performance.py +8 -6
  53. holmes/plugins/toolsets/azure_sql/tools/analyze_database_storage.py +9 -6
  54. holmes/plugins/toolsets/azure_sql/tools/get_active_alerts.py +9 -7
  55. holmes/plugins/toolsets/azure_sql/tools/get_slow_queries.py +9 -6
  56. holmes/plugins/toolsets/azure_sql/tools/get_top_cpu_queries.py +9 -6
  57. holmes/plugins/toolsets/azure_sql/tools/get_top_data_io_queries.py +9 -6
  58. holmes/plugins/toolsets/azure_sql/tools/get_top_log_io_queries.py +9 -6
  59. holmes/plugins/toolsets/bash/bash_toolset.py +10 -13
  60. holmes/plugins/toolsets/bash/common/bash.py +7 -7
  61. holmes/plugins/toolsets/cilium.yaml +284 -0
  62. holmes/plugins/toolsets/coralogix/toolset_coralogix_logs.py +5 -3
  63. holmes/plugins/toolsets/datadog/datadog_api.py +490 -24
  64. holmes/plugins/toolsets/datadog/datadog_logs_instructions.jinja2 +21 -10
  65. holmes/plugins/toolsets/datadog/toolset_datadog_general.py +349 -216
  66. holmes/plugins/toolsets/datadog/toolset_datadog_logs.py +190 -19
  67. holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py +101 -44
  68. holmes/plugins/toolsets/datadog/toolset_datadog_rds.py +13 -16
  69. holmes/plugins/toolsets/datadog/toolset_datadog_traces.py +25 -31
  70. holmes/plugins/toolsets/git.py +51 -46
  71. holmes/plugins/toolsets/grafana/common.py +15 -3
  72. holmes/plugins/toolsets/grafana/grafana_api.py +46 -24
  73. holmes/plugins/toolsets/grafana/grafana_tempo_api.py +454 -0
  74. holmes/plugins/toolsets/grafana/loki/instructions.jinja2 +9 -0
  75. holmes/plugins/toolsets/grafana/loki/toolset_grafana_loki.py +117 -0
  76. holmes/plugins/toolsets/grafana/toolset_grafana.py +211 -91
  77. holmes/plugins/toolsets/grafana/toolset_grafana_dashboard.jinja2 +27 -0
  78. holmes/plugins/toolsets/grafana/toolset_grafana_tempo.jinja2 +246 -11
  79. holmes/plugins/toolsets/grafana/toolset_grafana_tempo.py +653 -293
  80. holmes/plugins/toolsets/grafana/trace_parser.py +1 -1
  81. holmes/plugins/toolsets/internet/internet.py +6 -7
  82. holmes/plugins/toolsets/internet/notion.py +5 -6
  83. holmes/plugins/toolsets/investigator/core_investigation.py +42 -34
  84. holmes/plugins/toolsets/kafka.py +25 -36
  85. holmes/plugins/toolsets/kubernetes.yaml +58 -84
  86. holmes/plugins/toolsets/kubernetes_logs.py +6 -6
  87. holmes/plugins/toolsets/kubernetes_logs.yaml +32 -0
  88. holmes/plugins/toolsets/logging_utils/logging_api.py +80 -4
  89. holmes/plugins/toolsets/mcp/toolset_mcp.py +181 -55
  90. holmes/plugins/toolsets/newrelic/__init__.py +0 -0
  91. holmes/plugins/toolsets/newrelic/new_relic_api.py +125 -0
  92. holmes/plugins/toolsets/newrelic/newrelic.jinja2 +41 -0
  93. holmes/plugins/toolsets/newrelic/newrelic.py +163 -0
  94. holmes/plugins/toolsets/opensearch/opensearch.py +10 -17
  95. holmes/plugins/toolsets/opensearch/opensearch_logs.py +7 -7
  96. holmes/plugins/toolsets/opensearch/opensearch_ppl_query_docs.jinja2 +1616 -0
  97. holmes/plugins/toolsets/opensearch/opensearch_query_assist.py +78 -0
  98. holmes/plugins/toolsets/opensearch/opensearch_query_assist_instructions.jinja2 +223 -0
  99. holmes/plugins/toolsets/opensearch/opensearch_traces.py +13 -16
  100. holmes/plugins/toolsets/openshift.yaml +283 -0
  101. holmes/plugins/toolsets/prometheus/prometheus.py +915 -390
  102. holmes/plugins/toolsets/prometheus/prometheus_instructions.jinja2 +43 -2
  103. holmes/plugins/toolsets/prometheus/utils.py +28 -0
  104. holmes/plugins/toolsets/rabbitmq/toolset_rabbitmq.py +9 -10
  105. holmes/plugins/toolsets/robusta/robusta.py +236 -65
  106. holmes/plugins/toolsets/robusta/robusta_instructions.jinja2 +26 -9
  107. holmes/plugins/toolsets/runbook/runbook_fetcher.py +137 -26
  108. holmes/plugins/toolsets/service_discovery.py +1 -1
  109. holmes/plugins/toolsets/servicenow_tables/instructions.jinja2 +83 -0
  110. holmes/plugins/toolsets/servicenow_tables/servicenow_tables.py +426 -0
  111. holmes/plugins/toolsets/utils.py +88 -0
  112. holmes/utils/config_utils.py +91 -0
  113. holmes/utils/default_toolset_installation_guide.jinja2 +1 -22
  114. holmes/utils/env.py +7 -0
  115. holmes/utils/global_instructions.py +75 -10
  116. holmes/utils/holmes_status.py +2 -1
  117. holmes/utils/holmes_sync_toolsets.py +0 -2
  118. holmes/utils/krr_utils.py +188 -0
  119. holmes/utils/sentry_helper.py +41 -0
  120. holmes/utils/stream.py +61 -7
  121. holmes/version.py +34 -14
  122. holmesgpt-0.16.2a0.dist-info/LICENSE +178 -0
  123. {holmesgpt-0.13.2.dist-info → holmesgpt-0.16.2a0.dist-info}/METADATA +29 -27
  124. {holmesgpt-0.13.2.dist-info → holmesgpt-0.16.2a0.dist-info}/RECORD +126 -102
  125. holmes/core/performance_timing.py +0 -72
  126. holmes/plugins/toolsets/grafana/tempo_api.py +0 -124
  127. holmes/plugins/toolsets/grafana/toolset_grafana_loki.py +0 -110
  128. holmes/plugins/toolsets/newrelic.py +0 -231
  129. holmes/plugins/toolsets/servicenow/install.md +0 -37
  130. holmes/plugins/toolsets/servicenow/instructions.jinja2 +0 -3
  131. holmes/plugins/toolsets/servicenow/servicenow.py +0 -219
  132. holmesgpt-0.13.2.dist-info/LICENSE.txt +0 -21
  133. {holmesgpt-0.13.2.dist-info → holmesgpt-0.16.2a0.dist-info}/WHEEL +0 -0
  134. {holmesgpt-0.13.2.dist-info → holmesgpt-0.16.2a0.dist-info}/entry_points.txt +0 -0
holmes/core/tools.py CHANGED
@@ -8,40 +8,69 @@ import tempfile
8
8
  from abc import ABC, abstractmethod
9
9
  from datetime import datetime
10
10
  from enum import Enum
11
- from typing import Any, Callable, Dict, List, Optional, OrderedDict, Tuple, Union
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ Any,
14
+ Callable,
15
+ Dict,
16
+ List,
17
+ Optional,
18
+ OrderedDict,
19
+ Tuple,
20
+ Union,
21
+ )
12
22
 
13
23
  from jinja2 import Template
14
- from pydantic import BaseModel, ConfigDict, Field, FilePath, model_validator
24
+ from pydantic import (
25
+ BaseModel,
26
+ ConfigDict,
27
+ Field,
28
+ FilePath,
29
+ model_validator,
30
+ PrivateAttr,
31
+ )
15
32
  from rich.console import Console
16
33
 
34
+ from holmes.core.llm import LLM
17
35
  from holmes.core.openai_formatting import format_tool_to_open_ai_standard
18
36
  from holmes.plugins.prompts import load_and_render_prompt
37
+ from holmes.core.transformers import (
38
+ registry,
39
+ TransformerError,
40
+ Transformer,
41
+ )
42
+
43
+ if TYPE_CHECKING:
44
+ from holmes.core.transformers import BaseTransformer
45
+ from holmes.utils.config_utils import merge_transformers
19
46
  import time
20
47
  from rich.table import Table
21
48
 
49
+ logger = logging.getLogger(__name__)
22
50
 
23
- class ToolResultStatus(str, Enum):
51
+
52
+ class StructuredToolResultStatus(str, Enum):
24
53
  SUCCESS = "success"
25
54
  ERROR = "error"
26
55
  NO_DATA = "no_data"
27
56
  APPROVAL_REQUIRED = "approval_required"
28
57
 
29
58
  def to_color(self) -> str:
30
- if self == ToolResultStatus.SUCCESS:
59
+ if self == StructuredToolResultStatus.SUCCESS:
31
60
  return "green"
32
- elif self == ToolResultStatus.ERROR:
61
+ elif self == StructuredToolResultStatus.ERROR:
33
62
  return "red"
34
- elif self == ToolResultStatus.APPROVAL_REQUIRED:
63
+ elif self == StructuredToolResultStatus.APPROVAL_REQUIRED:
35
64
  return "yellow"
36
65
  else:
37
66
  return "white"
38
67
 
39
68
  def to_emoji(self) -> str:
40
- if self == ToolResultStatus.SUCCESS:
69
+ if self == StructuredToolResultStatus.SUCCESS:
41
70
  return "✔"
42
- elif self == ToolResultStatus.ERROR:
71
+ elif self == StructuredToolResultStatus.ERROR:
43
72
  return "❌"
44
- elif self == ToolResultStatus.APPROVAL_REQUIRED:
73
+ elif self == StructuredToolResultStatus.APPROVAL_REQUIRED:
45
74
  return "⚠️"
46
75
  else:
47
76
  return "⚪️"
@@ -49,7 +78,7 @@ class ToolResultStatus(str, Enum):
49
78
 
50
79
  class StructuredToolResult(BaseModel):
51
80
  schema_version: str = "robusta:v1.0.0"
52
- status: ToolResultStatus
81
+ status: StructuredToolResultStatus
53
82
  error: Optional[str] = None
54
83
  return_code: Optional[int] = None
55
84
  data: Optional[Any] = None
@@ -129,6 +158,16 @@ class ToolParameter(BaseModel):
129
158
  required: bool = True
130
159
  properties: Optional[Dict[str, "ToolParameter"]] = None # For object types
131
160
  items: Optional["ToolParameter"] = None # For array item schemas
161
+ enum: Optional[List[str]] = None # For restricting to specific values
162
+
163
+
164
+ class ToolInvokeContext(BaseModel):
165
+ model_config = ConfigDict(arbitrary_types_allowed=True)
166
+
167
+ tool_number: Optional[int] = None
168
+ user_approved: bool = False
169
+ llm: LLM
170
+ max_token_count: int
132
171
 
133
172
 
134
173
  class Tool(ABC, BaseModel):
@@ -143,6 +182,48 @@ class Tool(ABC, BaseModel):
143
182
  default=None,
144
183
  description="The URL of the icon for the tool, if None will get toolset icon",
145
184
  )
185
+ transformers: Optional[List[Transformer]] = None
186
+
187
+ # Private attribute to store initialized transformer instances for performance
188
+ _transformer_instances: Optional[List["BaseTransformer"]] = PrivateAttr(
189
+ default=None
190
+ )
191
+
192
+ def model_post_init(self, __context) -> None:
193
+ """Initialize transformer instances once during tool creation for better performance."""
194
+ logger.debug(
195
+ f"Tool '{self.name}' model_post_init: creating transformer instances"
196
+ )
197
+
198
+ if self.transformers:
199
+ logger.debug(
200
+ f"Tool '{self.name}' has {len(self.transformers)} transformers to initialize"
201
+ )
202
+ self._transformer_instances = []
203
+ for transformer in self.transformers:
204
+ if not transformer:
205
+ continue
206
+ logger.debug(
207
+ f" Initializing transformer '{transformer.name}' with config: {transformer.config}"
208
+ )
209
+ try:
210
+ # Create transformer instance once and cache it
211
+ transformer_instance = registry.create_transformer(
212
+ transformer.name, transformer.config
213
+ )
214
+ self._transformer_instances.append(transformer_instance)
215
+ logger.debug(
216
+ f"Initialized transformer '{transformer.name}' for tool '{self.name}'"
217
+ )
218
+ except Exception as e:
219
+ logger.warning(
220
+ f"Failed to initialize transformer '{transformer.name}' for tool '{self.name}': {e}"
221
+ )
222
+ # Continue with other transformers, don't fail the entire initialization
223
+ continue
224
+ else:
225
+ logger.debug(f"Tool '{self.name}' has no transformers")
226
+ self._transformer_instances = None
146
227
 
147
228
  def get_openai_format(self, target_model: str):
148
229
  return format_tool_to_open_ai_standard(
@@ -155,32 +236,123 @@ class Tool(ABC, BaseModel):
155
236
  def invoke(
156
237
  self,
157
238
  params: Dict,
158
- tool_number: Optional[int] = None,
159
- user_approved: bool = False,
239
+ context: ToolInvokeContext,
160
240
  ) -> StructuredToolResult:
161
- tool_number_str = f"#{tool_number} " if tool_number else ""
162
- logging.info(
241
+ tool_number_str = f"#{context.tool_number} " if context.tool_number else ""
242
+ logger.info(
163
243
  f"Running tool {tool_number_str}[bold]{self.name}[/bold]: {self.get_parameterized_one_liner(params)}"
164
244
  )
165
245
  start_time = time.time()
166
- result = self._invoke(params=params, user_approved=user_approved)
246
+ result = self._invoke(params=params, context=context)
167
247
  result.icon_url = self.icon_url
248
+
249
+ # Apply transformers to the result
250
+ transformed_result = self._apply_transformers(result)
168
251
  elapsed = time.time() - start_time
169
252
  output_str = (
170
- result.get_stringified_data()
171
- if hasattr(result, "get_stringified_data")
172
- else str(result)
253
+ transformed_result.get_stringified_data()
254
+ if hasattr(transformed_result, "get_stringified_data")
255
+ else str(transformed_result)
173
256
  )
174
- show_hint = f"/show {tool_number}" if tool_number else "/show"
257
+ show_hint = f"/show {context.tool_number}" if context.tool_number else "/show"
175
258
  line_count = output_str.count("\n") + 1 if output_str else 0
176
- logging.info(
259
+ logger.info(
177
260
  f" [dim]Finished {tool_number_str}in {elapsed:.2f}s, output length: {len(output_str):,} characters ({line_count:,} lines) - {show_hint} to view contents[/dim]"
178
261
  )
262
+ return transformed_result
263
+
264
+ def _apply_transformers(self, result: StructuredToolResult) -> StructuredToolResult:
265
+ """
266
+ Apply configured transformers to the tool result.
267
+
268
+ Args:
269
+ result: The original tool result
270
+
271
+ Returns:
272
+ The tool result with transformed data, or original result if transformation fails
273
+ """
274
+ if (
275
+ not self._transformer_instances
276
+ or result.status != StructuredToolResultStatus.SUCCESS
277
+ ):
278
+ return result
279
+
280
+ # Get the output string to transform
281
+ original_data = result.get_stringified_data()
282
+ if not original_data:
283
+ return result
284
+
285
+ transformed_data = original_data
286
+ transformers_applied = []
287
+
288
+ # Use cached transformer instances instead of creating new ones
289
+ for transformer_instance in self._transformer_instances:
290
+ try:
291
+ # Check if transformer should be applied
292
+ if not transformer_instance.should_apply(transformed_data):
293
+ logger.debug(
294
+ f"Transformer '{transformer_instance.name}' skipped for tool '{self.name}' (conditions not met)"
295
+ )
296
+ continue
297
+
298
+ # Apply transformation
299
+ pre_transform_size = len(transformed_data)
300
+ transform_start_time = time.time()
301
+ original_data = transformed_data # Keep a copy for potential reversion
302
+ transformed_data = transformer_instance.transform(transformed_data)
303
+ transform_elapsed = time.time() - transform_start_time
304
+
305
+ # Check if this is llm_summarize and revert if summary is not smaller
306
+ post_transform_size = len(transformed_data)
307
+ if (
308
+ transformer_instance.name == "llm_summarize"
309
+ and post_transform_size >= pre_transform_size
310
+ ):
311
+ # Revert to original data if summary is not smaller
312
+ transformed_data = original_data
313
+ logger.debug(
314
+ f"Transformer '{transformer_instance.name}' reverted for tool '{self.name}' "
315
+ f"(output size {post_transform_size:,} >= input size {pre_transform_size:,})"
316
+ )
317
+ continue # Don't mark as applied
318
+
319
+ transformers_applied.append(transformer_instance.name)
320
+
321
+ # Generic logging - transformers can override this with their own specific metrics
322
+ size_change = post_transform_size - pre_transform_size
323
+ logger.info(
324
+ f"Applied transformer '{transformer_instance.name}' to tool '{self.name}' output "
325
+ f"in {transform_elapsed:.2f}s (size: {pre_transform_size:,} → {post_transform_size:,} chars, "
326
+ f"change: {size_change:+,})"
327
+ )
328
+
329
+ except TransformerError as e:
330
+ logger.warning(
331
+ f"Transformer '{transformer_instance.name}' failed for tool '{self.name}': {e}"
332
+ )
333
+ # Continue with other transformers, don't fail the entire chain
334
+ continue
335
+ except Exception as e:
336
+ logger.error(
337
+ f"Unexpected error applying transformer '{transformer_instance.name}' to tool '{self.name}': {e}"
338
+ )
339
+ # Continue with other transformers
340
+ continue
341
+
342
+ # If any transformers were applied, update the result
343
+ if transformers_applied:
344
+ # Create a copy of the result with transformed data
345
+ result_dict = result.model_dump(exclude={"data"})
346
+ result_dict["data"] = transformed_data
347
+ return StructuredToolResult(**result_dict)
348
+
179
349
  return result
180
350
 
181
351
  @abstractmethod
182
352
  def _invoke(
183
- self, params: dict, user_approved: bool = False
353
+ self,
354
+ params: dict,
355
+ context: ToolInvokeContext,
184
356
  ) -> StructuredToolResult:
185
357
  """
186
358
  params: the tool params
@@ -230,15 +402,19 @@ class YAMLTool(Tool, BaseModel):
230
402
  context = {**params}
231
403
  return context
232
404
 
233
- def _get_status(self, return_code: int, raw_output: str) -> ToolResultStatus:
405
+ def _get_status(
406
+ self, return_code: int, raw_output: str
407
+ ) -> StructuredToolResultStatus:
234
408
  if return_code != 0:
235
- return ToolResultStatus.ERROR
409
+ return StructuredToolResultStatus.ERROR
236
410
  if raw_output == "":
237
- return ToolResultStatus.NO_DATA
238
- return ToolResultStatus.SUCCESS
411
+ return StructuredToolResultStatus.NO_DATA
412
+ return StructuredToolResultStatus.SUCCESS
239
413
 
240
414
  def _invoke(
241
- self, params: dict, user_approved: bool = False
415
+ self,
416
+ params: dict,
417
+ context: ToolInvokeContext,
242
418
  ) -> StructuredToolResult:
243
419
  if self.command is not None:
244
420
  raw_output, return_code, invocation = self.__invoke_command(params)
@@ -246,7 +422,7 @@ class YAMLTool(Tool, BaseModel):
246
422
  raw_output, return_code, invocation = self.__invoke_script(params) # type: ignore
247
423
 
248
424
  if self.additional_instructions and return_code == 0:
249
- logging.info(
425
+ logger.info(
250
426
  f"Applying additional instructions: {self.additional_instructions}"
251
427
  )
252
428
  output_with_instructions = self.__apply_additional_instructions(raw_output)
@@ -281,7 +457,7 @@ class YAMLTool(Tool, BaseModel):
281
457
  )
282
458
  return result.stdout.strip()
283
459
  except subprocess.CalledProcessError as e:
284
- logging.error(
460
+ logger.error(
285
461
  f"Failed to apply additional instructions: {self.additional_instructions}. "
286
462
  f"Error: {e.stderr}"
287
463
  )
@@ -316,7 +492,7 @@ class YAMLTool(Tool, BaseModel):
316
492
 
317
493
  def __execute_subprocess(self, cmd) -> Tuple[str, int]:
318
494
  try:
319
- logging.debug(f"Running `{cmd}`")
495
+ logger.debug(f"Running `{cmd}`")
320
496
  result = subprocess.run(
321
497
  cmd,
322
498
  shell=True,
@@ -329,7 +505,7 @@ class YAMLTool(Tool, BaseModel):
329
505
 
330
506
  return result.stdout.strip(), result.returncode
331
507
  except Exception as e:
332
- logging.error(
508
+ logger.error(
333
509
  f"An unexpected error occurred while running '{cmd}': {e}",
334
510
  exc_info=True,
335
511
  )
@@ -381,6 +557,7 @@ class Toolset(BaseModel):
381
557
  config: Optional[Any] = None
382
558
  is_default: bool = False
383
559
  llm_instructions: Optional[str] = None
560
+ transformers: Optional[List[Transformer]] = None
384
561
 
385
562
  # warning! private attributes are not copied, which can lead to subtle bugs.
386
563
  # e.g. l.extend([some_tool]) will reset these private attribute to None
@@ -406,13 +583,85 @@ class Toolset(BaseModel):
406
583
  @model_validator(mode="before")
407
584
  def preprocess_tools(cls, values):
408
585
  additional_instructions = values.get("additional_instructions", "")
586
+ transformers = values.get("transformers", None)
409
587
  tools_data = values.get("tools", [])
588
+
589
+ # Convert raw dict transformers to Transformer objects BEFORE merging
590
+ if transformers:
591
+ converted_transformers = []
592
+ for t in transformers:
593
+ if isinstance(t, dict):
594
+ try:
595
+ transformer_obj = Transformer(**t)
596
+ # Check if transformer is registered
597
+ from holmes.core.transformers import registry
598
+
599
+ if not registry.is_registered(transformer_obj.name):
600
+ logger.warning(
601
+ f"Invalid toolset transformer configuration: Transformer '{transformer_obj.name}' is not registered"
602
+ )
603
+ continue # Skip invalid transformer
604
+ converted_transformers.append(transformer_obj)
605
+ except Exception as e:
606
+ # Log warning and skip invalid transformer
607
+ logger.warning(
608
+ f"Invalid toolset transformer configuration: {e}"
609
+ )
610
+ continue
611
+ else:
612
+ # Already a Transformer object
613
+ converted_transformers.append(t)
614
+ transformers = converted_transformers if converted_transformers else None
615
+
410
616
  tools = []
411
617
  for tool in tools_data:
412
618
  if isinstance(tool, dict):
413
619
  tool["additional_instructions"] = additional_instructions
620
+
621
+ # Convert tool-level transformers to Transformer objects
622
+ tool_transformers = tool.get("transformers")
623
+ if tool_transformers:
624
+ converted_tool_transformers = []
625
+ for t in tool_transformers:
626
+ if isinstance(t, dict):
627
+ try:
628
+ transformer_obj = Transformer(**t)
629
+ # Check if transformer is registered
630
+ from holmes.core.transformers import registry
631
+
632
+ if not registry.is_registered(transformer_obj.name):
633
+ logger.warning(
634
+ f"Invalid tool transformer configuration: Transformer '{transformer_obj.name}' is not registered"
635
+ )
636
+ continue # Skip invalid transformer
637
+ converted_tool_transformers.append(transformer_obj)
638
+ except Exception as e:
639
+ # Log warning and skip invalid transformer
640
+ logger.warning(
641
+ f"Invalid tool transformer configuration: {e}"
642
+ )
643
+ continue
644
+ else:
645
+ # Already a Transformer object
646
+ converted_tool_transformers.append(t)
647
+ tool_transformers = (
648
+ converted_tool_transformers
649
+ if converted_tool_transformers
650
+ else None
651
+ )
652
+
653
+ # Merge toolset-level transformers with tool-level configs
654
+ tool["transformers"] = merge_transformers(
655
+ base_transformers=transformers,
656
+ override_transformers=tool_transformers,
657
+ )
414
658
  if isinstance(tool, Tool):
415
659
  tool.additional_instructions = additional_instructions
660
+ # Merge toolset-level transformers with tool-level configs
661
+ tool.transformers = merge_transformers( # type: ignore
662
+ base_transformers=transformers,
663
+ override_transformers=tool.transformers,
664
+ )
416
665
  tools.append(tool)
417
666
  values["tools"] = tools
418
667
 
@@ -434,7 +683,26 @@ class Toolset(BaseModel):
434
683
  def check_prerequisites(self):
435
684
  self.status = ToolsetStatusEnum.ENABLED
436
685
 
437
- for prereq in self.prerequisites:
686
+ # Sort prerequisites by type to fail fast on missing env vars before
687
+ # running slow commands (e.g., ArgoCD checks that timeout):
688
+ # 1. Static checks (instant)
689
+ # 2. Environment variable checks (instant, often required by commands)
690
+ # 3. Callable checks (variable speed)
691
+ # 4. Command checks (slowest - may timeout or hang)
692
+ def prereq_priority(prereq):
693
+ if isinstance(prereq, StaticPrerequisite):
694
+ return 0
695
+ elif isinstance(prereq, ToolsetEnvironmentPrerequisite):
696
+ return 1
697
+ elif isinstance(prereq, CallablePrerequisite):
698
+ return 2
699
+ elif isinstance(prereq, ToolsetCommandPrerequisite):
700
+ return 3
701
+ return 4 # Unknown types go last
702
+
703
+ sorted_prereqs = sorted(self.prerequisites, key=prereq_priority)
704
+
705
+ for prereq in sorted_prereqs:
438
706
  if isinstance(prereq, ToolsetCommandPrerequisite):
439
707
  try:
440
708
  command = self.interpolate_command(prereq.command)
@@ -482,11 +750,11 @@ class Toolset(BaseModel):
482
750
  self.status == ToolsetStatusEnum.DISABLED
483
751
  or self.status == ToolsetStatusEnum.FAILED
484
752
  ):
485
- logging.info(f"❌ Toolset {self.name}: {self.error}")
753
+ logger.info(f"❌ Toolset {self.name}: {self.error}")
486
754
  # no point checking further prerequisites if one failed
487
755
  return
488
756
 
489
- logging.info(f"✅ Toolset {self.name}")
757
+ logger.info(f"✅ Toolset {self.name}")
490
758
 
491
759
  @abstractmethod
492
760
  def get_example_config(self) -> Dict[str, Any]:
@@ -499,6 +767,16 @@ class Toolset(BaseModel):
499
767
  context={"tool_names": tool_names, "config": self.config},
500
768
  )
501
769
 
770
+ def _load_llm_instructions_from_file(self, file_dir: str, filename: str) -> None:
771
+ """Helper method to load LLM instructions from a jinja2 template file.
772
+
773
+ Args:
774
+ file_dir: Directory where the template file is located (typically os.path.dirname(__file__))
775
+ filename: Name of the jinja2 template file (e.g., "toolset_grafana_dashboard.jinja2")
776
+ """
777
+ template_file_path = os.path.abspath(os.path.join(file_dir, filename))
778
+ self._load_llm_instructions(jinja_template=f"file://{template_file_path}")
779
+
502
780
 
503
781
  class YAMLToolset(Toolset):
504
782
  tools: List[YAMLTool] # type: ignore
@@ -0,0 +1,14 @@
1
+ from holmes.core.llm import LLM
2
+ from holmes.core.models import format_tool_result_data
3
+ from holmes.core.tools import StructuredToolResult
4
+
5
+
6
+ def count_tool_response_tokens(
7
+ llm: LLM, structured_tool_result: StructuredToolResult
8
+ ) -> int:
9
+ message = {
10
+ "role": "tool",
11
+ "content": format_tool_result_data(structured_tool_result),
12
+ }
13
+ tokens = llm.count_tokens([message])
14
+ return tokens.total_tokens
@@ -0,0 +1,57 @@
1
+ from typing import Optional
2
+ from pydantic import BaseModel
3
+ from holmes.core.llm import LLM
4
+ from holmes.core.tools import StructuredToolResultStatus
5
+ from holmes.core.models import ToolCallResult
6
+ from holmes.utils import sentry_helper
7
+
8
+
9
+ class ToolCallSizeMetadata(BaseModel):
10
+ messages_token: int
11
+ max_tokens_allowed: int
12
+
13
+
14
+ def get_pct_token_count(percent_of_total_context_window: float, llm: LLM) -> int:
15
+ context_window_size = llm.get_context_window_size()
16
+
17
+ if 0 < percent_of_total_context_window and percent_of_total_context_window <= 100:
18
+ return int(context_window_size * percent_of_total_context_window // 100)
19
+ else:
20
+ return context_window_size
21
+
22
+
23
+ def is_tool_call_too_big(
24
+ tool_call_result: ToolCallResult, llm: LLM
25
+ ) -> tuple[bool, Optional[ToolCallSizeMetadata]]:
26
+ if tool_call_result.result.status == StructuredToolResultStatus.SUCCESS:
27
+ message = tool_call_result.as_tool_call_message()
28
+
29
+ tokens = llm.count_tokens(messages=[message])
30
+ max_tokens_allowed = llm.get_max_token_count_for_single_tool()
31
+ return (
32
+ tokens.total_tokens > max_tokens_allowed,
33
+ ToolCallSizeMetadata(
34
+ messages_token=tokens.total_tokens,
35
+ max_tokens_allowed=max_tokens_allowed,
36
+ ),
37
+ )
38
+ return False, None
39
+
40
+
41
+ def prevent_overly_big_tool_response(tool_call_result: ToolCallResult, llm: LLM):
42
+ tool_call_result_is_too_big, metadata = is_tool_call_too_big(
43
+ tool_call_result=tool_call_result, llm=llm
44
+ )
45
+ if tool_call_result_is_too_big and metadata:
46
+ relative_pct = (
47
+ (metadata.messages_token - metadata.max_tokens_allowed)
48
+ / metadata.messages_token
49
+ ) * 100
50
+ error_message = f"The tool call result is too large to return: {metadata.messages_token} tokens.\nThe maximum allowed tokens is {metadata.max_tokens_allowed} which is {format(relative_pct, '.1f')}% smaller.\nInstructions for the LLM: try to repeat the query but proactively narrow down the result so that the tool answer fits within the allowed number of tokens."
51
+ tool_call_result.result.status = StructuredToolResultStatus.ERROR
52
+ tool_call_result.result.data = None
53
+ tool_call_result.result.error = error_message
54
+
55
+ sentry_helper.capture_toolcall_contains_too_many_tokens(
56
+ tool_call_result, metadata.messages_token, metadata.max_tokens_allowed
57
+ )
@@ -6,9 +6,10 @@ import sentry_sdk
6
6
  from holmes.core.tools import (
7
7
  StructuredToolResult,
8
8
  Tool,
9
- ToolResultStatus,
9
+ StructuredToolResultStatus,
10
10
  Toolset,
11
11
  ToolsetStatusEnum,
12
+ ToolInvokeContext,
12
13
  )
13
14
  from holmes.core.tools_utils.toolset_utils import filter_out_default_logging_toolset
14
15
 
@@ -46,16 +47,20 @@ class ToolExecutor:
46
47
  )
47
48
  self.tools_by_name[tool.name] = tool
48
49
 
49
- def invoke(self, tool_name: str, params: dict) -> StructuredToolResult:
50
+ def invoke(
51
+ self, tool_name: str, params: dict, context: ToolInvokeContext
52
+ ) -> StructuredToolResult:
53
+ """TODO: remove this function as it seems unused.
54
+ We call tool_executor.get_tool_by_name() and then tool.invoke() directly instead of this invoke function
55
+ """
50
56
  tool = self.get_tool_by_name(tool_name)
51
- return (
52
- tool.invoke(params)
53
- if tool
54
- else StructuredToolResult(
55
- status=ToolResultStatus.ERROR,
57
+ if not tool:
58
+ return StructuredToolResult(
59
+ status=StructuredToolResultStatus.ERROR,
56
60
  error=f"Could not find tool named {tool_name}",
57
61
  )
58
- )
62
+
63
+ return tool.invoke(params, context)
59
64
 
60
65
  def get_tool_by_name(self, name: str) -> Optional[Tool]:
61
66
  if name in self.tools_by_name: