holmesgpt 0.11.5__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.

Potentially problematic release.


This version of holmesgpt might be problematic. Click here for more details.

Files changed (183) hide show
  1. holmes/.git_archival.json +7 -0
  2. holmes/__init__.py +76 -0
  3. holmes/__init__.py.bak +76 -0
  4. holmes/clients/robusta_client.py +24 -0
  5. holmes/common/env_vars.py +47 -0
  6. holmes/config.py +526 -0
  7. holmes/core/__init__.py +0 -0
  8. holmes/core/conversations.py +578 -0
  9. holmes/core/investigation.py +152 -0
  10. holmes/core/investigation_structured_output.py +264 -0
  11. holmes/core/issue.py +54 -0
  12. holmes/core/llm.py +250 -0
  13. holmes/core/models.py +157 -0
  14. holmes/core/openai_formatting.py +51 -0
  15. holmes/core/performance_timing.py +72 -0
  16. holmes/core/prompt.py +42 -0
  17. holmes/core/resource_instruction.py +17 -0
  18. holmes/core/runbooks.py +26 -0
  19. holmes/core/safeguards.py +120 -0
  20. holmes/core/supabase_dal.py +540 -0
  21. holmes/core/tool_calling_llm.py +798 -0
  22. holmes/core/tools.py +566 -0
  23. holmes/core/tools_utils/__init__.py +0 -0
  24. holmes/core/tools_utils/tool_executor.py +65 -0
  25. holmes/core/tools_utils/toolset_utils.py +52 -0
  26. holmes/core/toolset_manager.py +418 -0
  27. holmes/interactive.py +229 -0
  28. holmes/main.py +1041 -0
  29. holmes/plugins/__init__.py +0 -0
  30. holmes/plugins/destinations/__init__.py +6 -0
  31. holmes/plugins/destinations/slack/__init__.py +2 -0
  32. holmes/plugins/destinations/slack/plugin.py +163 -0
  33. holmes/plugins/interfaces.py +32 -0
  34. holmes/plugins/prompts/__init__.py +48 -0
  35. holmes/plugins/prompts/_current_date_time.jinja2 +1 -0
  36. holmes/plugins/prompts/_default_log_prompt.jinja2 +11 -0
  37. holmes/plugins/prompts/_fetch_logs.jinja2 +36 -0
  38. holmes/plugins/prompts/_general_instructions.jinja2 +86 -0
  39. holmes/plugins/prompts/_global_instructions.jinja2 +12 -0
  40. holmes/plugins/prompts/_runbook_instructions.jinja2 +13 -0
  41. holmes/plugins/prompts/_toolsets_instructions.jinja2 +56 -0
  42. holmes/plugins/prompts/generic_ask.jinja2 +36 -0
  43. holmes/plugins/prompts/generic_ask_conversation.jinja2 +32 -0
  44. holmes/plugins/prompts/generic_ask_for_issue_conversation.jinja2 +50 -0
  45. holmes/plugins/prompts/generic_investigation.jinja2 +42 -0
  46. holmes/plugins/prompts/generic_post_processing.jinja2 +13 -0
  47. holmes/plugins/prompts/generic_ticket.jinja2 +12 -0
  48. holmes/plugins/prompts/investigation_output_format.jinja2 +32 -0
  49. holmes/plugins/prompts/kubernetes_workload_ask.jinja2 +84 -0
  50. holmes/plugins/prompts/kubernetes_workload_chat.jinja2 +39 -0
  51. holmes/plugins/runbooks/README.md +22 -0
  52. holmes/plugins/runbooks/__init__.py +100 -0
  53. holmes/plugins/runbooks/catalog.json +14 -0
  54. holmes/plugins/runbooks/jira.yaml +12 -0
  55. holmes/plugins/runbooks/kube-prometheus-stack.yaml +10 -0
  56. holmes/plugins/runbooks/networking/dns_troubleshooting_instructions.md +66 -0
  57. holmes/plugins/runbooks/upgrade/upgrade_troubleshooting_instructions.md +44 -0
  58. holmes/plugins/sources/github/__init__.py +77 -0
  59. holmes/plugins/sources/jira/__init__.py +123 -0
  60. holmes/plugins/sources/opsgenie/__init__.py +93 -0
  61. holmes/plugins/sources/pagerduty/__init__.py +147 -0
  62. holmes/plugins/sources/prometheus/__init__.py +0 -0
  63. holmes/plugins/sources/prometheus/models.py +104 -0
  64. holmes/plugins/sources/prometheus/plugin.py +154 -0
  65. holmes/plugins/toolsets/__init__.py +171 -0
  66. holmes/plugins/toolsets/aks-node-health.yaml +65 -0
  67. holmes/plugins/toolsets/aks.yaml +86 -0
  68. holmes/plugins/toolsets/argocd.yaml +70 -0
  69. holmes/plugins/toolsets/atlas_mongodb/instructions.jinja2 +8 -0
  70. holmes/plugins/toolsets/atlas_mongodb/mongodb_atlas.py +307 -0
  71. holmes/plugins/toolsets/aws.yaml +76 -0
  72. holmes/plugins/toolsets/azure_sql/__init__.py +0 -0
  73. holmes/plugins/toolsets/azure_sql/apis/alert_monitoring_api.py +600 -0
  74. holmes/plugins/toolsets/azure_sql/apis/azure_sql_api.py +309 -0
  75. holmes/plugins/toolsets/azure_sql/apis/connection_failure_api.py +445 -0
  76. holmes/plugins/toolsets/azure_sql/apis/connection_monitoring_api.py +251 -0
  77. holmes/plugins/toolsets/azure_sql/apis/storage_analysis_api.py +317 -0
  78. holmes/plugins/toolsets/azure_sql/azure_base_toolset.py +55 -0
  79. holmes/plugins/toolsets/azure_sql/azure_sql_instructions.jinja2 +137 -0
  80. holmes/plugins/toolsets/azure_sql/azure_sql_toolset.py +183 -0
  81. holmes/plugins/toolsets/azure_sql/install.md +66 -0
  82. holmes/plugins/toolsets/azure_sql/tools/__init__.py +1 -0
  83. holmes/plugins/toolsets/azure_sql/tools/analyze_connection_failures.py +324 -0
  84. holmes/plugins/toolsets/azure_sql/tools/analyze_database_connections.py +243 -0
  85. holmes/plugins/toolsets/azure_sql/tools/analyze_database_health_status.py +205 -0
  86. holmes/plugins/toolsets/azure_sql/tools/analyze_database_performance.py +249 -0
  87. holmes/plugins/toolsets/azure_sql/tools/analyze_database_storage.py +373 -0
  88. holmes/plugins/toolsets/azure_sql/tools/get_active_alerts.py +237 -0
  89. holmes/plugins/toolsets/azure_sql/tools/get_slow_queries.py +172 -0
  90. holmes/plugins/toolsets/azure_sql/tools/get_top_cpu_queries.py +170 -0
  91. holmes/plugins/toolsets/azure_sql/tools/get_top_data_io_queries.py +188 -0
  92. holmes/plugins/toolsets/azure_sql/tools/get_top_log_io_queries.py +180 -0
  93. holmes/plugins/toolsets/azure_sql/utils.py +83 -0
  94. holmes/plugins/toolsets/bash/__init__.py +0 -0
  95. holmes/plugins/toolsets/bash/bash_instructions.jinja2 +14 -0
  96. holmes/plugins/toolsets/bash/bash_toolset.py +208 -0
  97. holmes/plugins/toolsets/bash/common/bash.py +52 -0
  98. holmes/plugins/toolsets/bash/common/config.py +14 -0
  99. holmes/plugins/toolsets/bash/common/stringify.py +25 -0
  100. holmes/plugins/toolsets/bash/common/validators.py +24 -0
  101. holmes/plugins/toolsets/bash/grep/__init__.py +52 -0
  102. holmes/plugins/toolsets/bash/kubectl/__init__.py +100 -0
  103. holmes/plugins/toolsets/bash/kubectl/constants.py +96 -0
  104. holmes/plugins/toolsets/bash/kubectl/kubectl_describe.py +66 -0
  105. holmes/plugins/toolsets/bash/kubectl/kubectl_events.py +88 -0
  106. holmes/plugins/toolsets/bash/kubectl/kubectl_get.py +108 -0
  107. holmes/plugins/toolsets/bash/kubectl/kubectl_logs.py +20 -0
  108. holmes/plugins/toolsets/bash/kubectl/kubectl_run.py +46 -0
  109. holmes/plugins/toolsets/bash/kubectl/kubectl_top.py +81 -0
  110. holmes/plugins/toolsets/bash/parse_command.py +103 -0
  111. holmes/plugins/toolsets/confluence.yaml +19 -0
  112. holmes/plugins/toolsets/consts.py +5 -0
  113. holmes/plugins/toolsets/coralogix/api.py +158 -0
  114. holmes/plugins/toolsets/coralogix/toolset_coralogix_logs.py +103 -0
  115. holmes/plugins/toolsets/coralogix/utils.py +181 -0
  116. holmes/plugins/toolsets/datadog.py +153 -0
  117. holmes/plugins/toolsets/docker.yaml +46 -0
  118. holmes/plugins/toolsets/git.py +756 -0
  119. holmes/plugins/toolsets/grafana/__init__.py +0 -0
  120. holmes/plugins/toolsets/grafana/base_grafana_toolset.py +54 -0
  121. holmes/plugins/toolsets/grafana/common.py +68 -0
  122. holmes/plugins/toolsets/grafana/grafana_api.py +31 -0
  123. holmes/plugins/toolsets/grafana/loki_api.py +89 -0
  124. holmes/plugins/toolsets/grafana/tempo_api.py +124 -0
  125. holmes/plugins/toolsets/grafana/toolset_grafana.py +102 -0
  126. holmes/plugins/toolsets/grafana/toolset_grafana_loki.py +102 -0
  127. holmes/plugins/toolsets/grafana/toolset_grafana_tempo.jinja2 +10 -0
  128. holmes/plugins/toolsets/grafana/toolset_grafana_tempo.py +299 -0
  129. holmes/plugins/toolsets/grafana/trace_parser.py +195 -0
  130. holmes/plugins/toolsets/helm.yaml +42 -0
  131. holmes/plugins/toolsets/internet/internet.py +275 -0
  132. holmes/plugins/toolsets/internet/notion.py +137 -0
  133. holmes/plugins/toolsets/kafka.py +638 -0
  134. holmes/plugins/toolsets/kubernetes.yaml +255 -0
  135. holmes/plugins/toolsets/kubernetes_logs.py +426 -0
  136. holmes/plugins/toolsets/kubernetes_logs.yaml +42 -0
  137. holmes/plugins/toolsets/logging_utils/__init__.py +0 -0
  138. holmes/plugins/toolsets/logging_utils/logging_api.py +217 -0
  139. holmes/plugins/toolsets/logging_utils/types.py +0 -0
  140. holmes/plugins/toolsets/mcp/toolset_mcp.py +135 -0
  141. holmes/plugins/toolsets/newrelic.py +222 -0
  142. holmes/plugins/toolsets/opensearch/__init__.py +0 -0
  143. holmes/plugins/toolsets/opensearch/opensearch.py +245 -0
  144. holmes/plugins/toolsets/opensearch/opensearch_logs.py +151 -0
  145. holmes/plugins/toolsets/opensearch/opensearch_traces.py +211 -0
  146. holmes/plugins/toolsets/opensearch/opensearch_traces_instructions.jinja2 +12 -0
  147. holmes/plugins/toolsets/opensearch/opensearch_utils.py +166 -0
  148. holmes/plugins/toolsets/prometheus/prometheus.py +818 -0
  149. holmes/plugins/toolsets/prometheus/prometheus_instructions.jinja2 +38 -0
  150. holmes/plugins/toolsets/rabbitmq/api.py +398 -0
  151. holmes/plugins/toolsets/rabbitmq/rabbitmq_instructions.jinja2 +37 -0
  152. holmes/plugins/toolsets/rabbitmq/toolset_rabbitmq.py +222 -0
  153. holmes/plugins/toolsets/robusta/__init__.py +0 -0
  154. holmes/plugins/toolsets/robusta/robusta.py +235 -0
  155. holmes/plugins/toolsets/robusta/robusta_instructions.jinja2 +24 -0
  156. holmes/plugins/toolsets/runbook/__init__.py +0 -0
  157. holmes/plugins/toolsets/runbook/runbook_fetcher.py +78 -0
  158. holmes/plugins/toolsets/service_discovery.py +92 -0
  159. holmes/plugins/toolsets/servicenow/install.md +37 -0
  160. holmes/plugins/toolsets/servicenow/instructions.jinja2 +3 -0
  161. holmes/plugins/toolsets/servicenow/servicenow.py +198 -0
  162. holmes/plugins/toolsets/slab.yaml +20 -0
  163. holmes/plugins/toolsets/utils.py +137 -0
  164. holmes/plugins/utils.py +14 -0
  165. holmes/utils/__init__.py +0 -0
  166. holmes/utils/cache.py +84 -0
  167. holmes/utils/cert_utils.py +40 -0
  168. holmes/utils/default_toolset_installation_guide.jinja2 +44 -0
  169. holmes/utils/definitions.py +13 -0
  170. holmes/utils/env.py +53 -0
  171. holmes/utils/file_utils.py +56 -0
  172. holmes/utils/global_instructions.py +20 -0
  173. holmes/utils/holmes_status.py +22 -0
  174. holmes/utils/holmes_sync_toolsets.py +80 -0
  175. holmes/utils/markdown_utils.py +55 -0
  176. holmes/utils/pydantic_utils.py +54 -0
  177. holmes/utils/robusta.py +10 -0
  178. holmes/utils/tags.py +97 -0
  179. holmesgpt-0.11.5.dist-info/LICENSE.txt +21 -0
  180. holmesgpt-0.11.5.dist-info/METADATA +400 -0
  181. holmesgpt-0.11.5.dist-info/RECORD +183 -0
  182. holmesgpt-0.11.5.dist-info/WHEEL +4 -0
  183. holmesgpt-0.11.5.dist-info/entry_points.txt +3 -0
holmes/core/tools.py ADDED
@@ -0,0 +1,566 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import re
5
+ import shlex
6
+ import subprocess
7
+ import tempfile
8
+ from abc import ABC, abstractmethod
9
+ from datetime import datetime
10
+ from enum import Enum
11
+ from typing import Any, Callable, Dict, List, Optional, OrderedDict, Tuple, Union
12
+
13
+ from jinja2 import Template
14
+ from pydantic import BaseModel, ConfigDict, Field, FilePath, model_validator
15
+ from rich.console import Console
16
+
17
+ from holmes.core.openai_formatting import format_tool_to_open_ai_standard
18
+ from holmes.plugins.prompts import load_and_render_prompt
19
+ import time
20
+ from rich.table import Table
21
+
22
+
23
+ class ToolResultStatus(str, Enum):
24
+ SUCCESS = "success"
25
+ ERROR = "error"
26
+ NO_DATA = "no_data"
27
+
28
+ def to_color(self) -> str:
29
+ if self == ToolResultStatus.SUCCESS:
30
+ return "green"
31
+ elif self == ToolResultStatus.ERROR:
32
+ return "red"
33
+ else:
34
+ return "white"
35
+
36
+ def to_emoji(self) -> str:
37
+ if self == ToolResultStatus.SUCCESS:
38
+ return "✔"
39
+ elif self == ToolResultStatus.ERROR:
40
+ return "❌"
41
+ else:
42
+ return "⚪️"
43
+
44
+
45
+ class StructuredToolResult(BaseModel):
46
+ schema_version: str = "robusta:v1.0.0"
47
+ status: ToolResultStatus
48
+ error: Optional[str] = None
49
+ return_code: Optional[int] = None
50
+ data: Optional[Any] = None
51
+ url: Optional[str] = None
52
+ invocation: Optional[str] = None
53
+ params: Optional[Dict] = None
54
+
55
+ def get_stringified_data(self) -> str:
56
+ if self.data is None:
57
+ return ""
58
+
59
+ if isinstance(self.data, str):
60
+ return self.data
61
+ else:
62
+ try:
63
+ if isinstance(self.data, BaseModel):
64
+ return self.data.model_dump_json(indent=2)
65
+ else:
66
+ return json.dumps(self.data, indent=2)
67
+ except Exception:
68
+ return str(self.data)
69
+
70
+
71
+ def sanitize(param):
72
+ # allow empty strings to be unquoted - useful for optional params
73
+ # it is up to the user to ensure that the command they are using is ok with empty strings
74
+ # and if not to take that into account via an appropriate jinja template
75
+ if param == "":
76
+ return ""
77
+
78
+ return shlex.quote(str(param))
79
+
80
+
81
+ def sanitize_params(params):
82
+ return {k: sanitize(str(v)) for k, v in params.items()}
83
+
84
+
85
+ def format_tool_output(tool_result: Union[str, StructuredToolResult]) -> str:
86
+ if isinstance(tool_result, StructuredToolResult):
87
+ if tool_result.data and isinstance(tool_result.data, str):
88
+ # Display logs and other string outputs in a way that is readable to humans.
89
+ # To do this, we extract them from the result and print them as-is below.
90
+ # The metadata is printed on a single line to
91
+ data = tool_result.data
92
+ tool_result.data = "The raw tool data is printed below this JSON"
93
+ result_str = tool_result.model_dump_json(indent=2, exclude_none=True)
94
+ result_str += f"\n{data}"
95
+ return result_str
96
+ else:
97
+ return tool_result.model_dump_json(indent=2)
98
+ else:
99
+ return tool_result
100
+
101
+
102
+ class ToolsetStatusEnum(str, Enum):
103
+ ENABLED = "enabled"
104
+ DISABLED = "disabled"
105
+ FAILED = "failed"
106
+
107
+
108
+ class ToolsetTag(str, Enum):
109
+ CORE = "core"
110
+ CLUSTER = "cluster"
111
+ CLI = "cli"
112
+
113
+
114
+ class ToolsetType(str, Enum):
115
+ BUILTIN = "built-in"
116
+ CUSTOMIZED = "custom"
117
+ MCP = "mcp"
118
+
119
+
120
+ class ToolParameter(BaseModel):
121
+ description: Optional[str] = None
122
+ type: str = "string"
123
+ required: bool = True
124
+
125
+
126
+ class Tool(ABC, BaseModel):
127
+ name: str
128
+ description: str
129
+ parameters: Dict[str, ToolParameter] = {}
130
+ user_description: Optional[str] = (
131
+ None # templated string to show to the user describing this tool invocation (not seen by llm)
132
+ )
133
+ additional_instructions: Optional[str] = None
134
+
135
+ def get_openai_format(self):
136
+ return format_tool_to_open_ai_standard(
137
+ tool_name=self.name,
138
+ tool_description=self.description,
139
+ tool_parameters=self.parameters,
140
+ )
141
+
142
+ def invoke(self, params: Dict) -> StructuredToolResult:
143
+ logging.info(
144
+ f"Running tool [bold]{self.name}[/bold]: {self.get_parameterized_one_liner(params)}"
145
+ )
146
+ start_time = time.time()
147
+ result = self._invoke(params)
148
+ elapsed = time.time() - start_time
149
+ output_str = (
150
+ result.get_stringified_data()
151
+ if hasattr(result, "get_stringified_data")
152
+ else str(result)
153
+ )
154
+ logging.info(
155
+ f" [dim]Finished in {elapsed:.2f}s, output length: {len(output_str):,} characters[/dim]\n"
156
+ )
157
+ return result
158
+
159
+ @abstractmethod
160
+ def _invoke(self, params: Dict) -> StructuredToolResult:
161
+ pass
162
+
163
+ @abstractmethod
164
+ def get_parameterized_one_liner(self, params: Dict) -> str:
165
+ return ""
166
+
167
+
168
+ class YAMLTool(Tool, BaseModel):
169
+ command: Optional[str] = None
170
+ script: Optional[str] = None
171
+
172
+ def __init__(self, **data):
173
+ super().__init__(**data)
174
+ self.__infer_parameters()
175
+
176
+ def __infer_parameters(self):
177
+ # Find parameters that appear inside self.command or self.script but weren't declared in parameters
178
+ template = self.command or self.script
179
+ inferred_params = re.findall(r"\{\{\s*([\w]+)[\.\|]?.*?\s*\}\}", template)
180
+ # TODO: if filters were used in template, take only the variable name
181
+ # Regular expression to match Jinja2 placeholders with or without filters
182
+ # inferred_params = re.findall(r'\{\{\s*(\w+)(\s*\|\s*[^}]+)?\s*\}\}', self.command)
183
+ # for param_tuple in inferred_params:
184
+ # param = param_tuple[0] # Extract the parameter name
185
+ # if param not in self.parameters:
186
+ # self.parameters[param] = ToolParameter()
187
+ for param in inferred_params:
188
+ if param not in self.parameters:
189
+ self.parameters[param] = ToolParameter()
190
+
191
+ def get_parameterized_one_liner(self, params) -> str:
192
+ params = sanitize_params(params)
193
+ if self.user_description:
194
+ template = Template(self.user_description)
195
+ else:
196
+ cmd_or_script = self.command or self.script
197
+ template = Template(cmd_or_script) # type: ignore
198
+ return template.render(params)
199
+
200
+ def _build_context(self, params):
201
+ params = sanitize_params(params)
202
+ context = {**params}
203
+ return context
204
+
205
+ def _get_status(self, return_code: int, raw_output: str) -> ToolResultStatus:
206
+ if return_code != 0:
207
+ return ToolResultStatus.ERROR
208
+ if raw_output == "":
209
+ return ToolResultStatus.NO_DATA
210
+ return ToolResultStatus.SUCCESS
211
+
212
+ def _invoke(self, params) -> StructuredToolResult:
213
+ if self.command is not None:
214
+ raw_output, return_code, invocation = self.__invoke_command(params)
215
+ else:
216
+ raw_output, return_code, invocation = self.__invoke_script(params) # type: ignore
217
+
218
+ if self.additional_instructions and return_code == 0:
219
+ logging.info(
220
+ f"Applying additional instructions: {self.additional_instructions}"
221
+ )
222
+ output_with_instructions = self.__apply_additional_instructions(raw_output)
223
+ else:
224
+ output_with_instructions = raw_output
225
+
226
+ error = (
227
+ None
228
+ if return_code == 0
229
+ else f"Command `{invocation}` failed with return code {return_code}\nOutput:\n{raw_output}"
230
+ )
231
+ status = self._get_status(return_code, raw_output)
232
+
233
+ return StructuredToolResult(
234
+ status=status,
235
+ error=error,
236
+ return_code=return_code,
237
+ data=output_with_instructions,
238
+ params=params,
239
+ invocation=invocation,
240
+ )
241
+
242
+ def __apply_additional_instructions(self, raw_output: str) -> str:
243
+ try:
244
+ result = subprocess.run(
245
+ self.additional_instructions, # type: ignore
246
+ input=raw_output,
247
+ shell=True,
248
+ text=True,
249
+ capture_output=True,
250
+ check=True,
251
+ )
252
+ return result.stdout.strip()
253
+ except subprocess.CalledProcessError as e:
254
+ logging.error(
255
+ f"Failed to apply additional instructions: {self.additional_instructions}. "
256
+ f"Error: {e.stderr}"
257
+ )
258
+ return f"Error applying additional instructions: {e.stderr}"
259
+
260
+ def __invoke_command(self, params) -> Tuple[str, int, str]:
261
+ context = self._build_context(params)
262
+ command = os.path.expandvars(self.command) # type: ignore
263
+ template = Template(command) # type: ignore
264
+ rendered_command = template.render(context)
265
+ output, return_code = self.__execute_subprocess(rendered_command)
266
+ return output, return_code, rendered_command
267
+
268
+ def __invoke_script(self, params) -> str:
269
+ context = self._build_context(params)
270
+ script = os.path.expandvars(self.script) # type: ignore
271
+ template = Template(script) # type: ignore
272
+ rendered_script = template.render(context)
273
+
274
+ with tempfile.NamedTemporaryFile(
275
+ mode="w+", delete=False, suffix=".sh"
276
+ ) as temp_script:
277
+ temp_script.write(rendered_script)
278
+ temp_script_path = temp_script.name
279
+ subprocess.run(["chmod", "+x", temp_script_path], check=True)
280
+
281
+ try:
282
+ output, return_code = self.__execute_subprocess(temp_script_path)
283
+ finally:
284
+ subprocess.run(["rm", temp_script_path])
285
+ return output, return_code, rendered_script # type: ignore
286
+
287
+ def __execute_subprocess(self, cmd) -> Tuple[str, int]:
288
+ try:
289
+ logging.debug(f"Running `{cmd}`")
290
+ result = subprocess.run(
291
+ cmd,
292
+ shell=True,
293
+ text=True,
294
+ check=False, # do not throw error, we just return the error code
295
+ stdin=subprocess.DEVNULL,
296
+ stdout=subprocess.PIPE,
297
+ stderr=subprocess.STDOUT,
298
+ )
299
+
300
+ return result.stdout.strip(), result.returncode
301
+ except Exception as e:
302
+ logging.error(
303
+ f"An unexpected error occurred while running '{cmd}': {e}",
304
+ exc_info=True,
305
+ )
306
+ output = f"Command execution failed with error: {e}"
307
+ return output, 1
308
+
309
+
310
+ class StaticPrerequisite(BaseModel):
311
+ enabled: bool
312
+ disabled_reason: str
313
+
314
+
315
+ class CallablePrerequisite(BaseModel):
316
+ callable: Callable[[dict[str, Any]], Tuple[bool, str]]
317
+
318
+
319
+ class ToolsetCommandPrerequisite(BaseModel):
320
+ command: str # must complete successfully (error code 0) for prereq to be satisfied
321
+ expected_output: Optional[str] = None # optional
322
+
323
+
324
+ class ToolsetEnvironmentPrerequisite(BaseModel):
325
+ env: List[str] = [] # optional
326
+
327
+
328
+ class Toolset(BaseModel):
329
+ model_config = ConfigDict(extra="forbid")
330
+ experimental: bool = False
331
+
332
+ enabled: bool = False
333
+ name: str
334
+ description: str
335
+ docs_url: Optional[str] = None
336
+ icon_url: Optional[str] = None
337
+ installation_instructions: Optional[str] = None
338
+ additional_instructions: Optional[str] = ""
339
+ prerequisites: List[
340
+ Union[
341
+ StaticPrerequisite,
342
+ ToolsetCommandPrerequisite,
343
+ ToolsetEnvironmentPrerequisite,
344
+ CallablePrerequisite,
345
+ ]
346
+ ] = []
347
+ tools: List[Tool]
348
+ tags: List[ToolsetTag] = Field(
349
+ default_factory=lambda: [ToolsetTag.CORE],
350
+ )
351
+ config: Optional[Any] = None
352
+ is_default: bool = False
353
+ llm_instructions: Optional[str] = None
354
+
355
+ # warning! private attributes are not copied, which can lead to subtle bugs.
356
+ # e.g. l.extend([some_tool]) will reset these private attribute to None
357
+
358
+ # status fields that be cached
359
+ type: Optional[ToolsetType] = None
360
+ path: Optional[FilePath] = None
361
+ status: ToolsetStatusEnum = ToolsetStatusEnum.DISABLED
362
+ error: Optional[str] = None
363
+
364
+ def override_with(self, override: "Toolset") -> None:
365
+ """
366
+ Overrides the current attributes with values from the Toolset loaded from custom config
367
+ if they are not None.
368
+ """
369
+ for field, value in override.model_dump(
370
+ exclude_unset=True,
371
+ exclude=("name"), # type: ignore
372
+ ).items():
373
+ if field in self.model_fields and value not in (None, [], {}, ""):
374
+ setattr(self, field, value)
375
+
376
+ @model_validator(mode="before")
377
+ def preprocess_tools(cls, values):
378
+ additional_instructions = values.get("additional_instructions", "")
379
+ tools_data = values.get("tools", [])
380
+ tools = []
381
+ for tool in tools_data:
382
+ if isinstance(tool, dict):
383
+ tool["additional_instructions"] = additional_instructions
384
+ if isinstance(tool, Tool):
385
+ tool.additional_instructions = additional_instructions
386
+ tools.append(tool)
387
+ values["tools"] = tools
388
+
389
+ return values
390
+
391
+ def get_environment_variables(self) -> List[str]:
392
+ env_vars = set()
393
+
394
+ for prereq in self.prerequisites:
395
+ if isinstance(prereq, ToolsetEnvironmentPrerequisite):
396
+ env_vars.update(prereq.env)
397
+ return list(env_vars)
398
+
399
+ def interpolate_command(self, command: str) -> str:
400
+ interpolated_command = os.path.expandvars(command)
401
+
402
+ return interpolated_command
403
+
404
+ def check_prerequisites(self):
405
+ self.status = ToolsetStatusEnum.ENABLED
406
+
407
+ for prereq in self.prerequisites:
408
+ if isinstance(prereq, ToolsetCommandPrerequisite):
409
+ try:
410
+ command = self.interpolate_command(prereq.command)
411
+ result = subprocess.run(
412
+ command,
413
+ shell=True,
414
+ check=True,
415
+ text=True,
416
+ stdout=subprocess.PIPE,
417
+ stderr=subprocess.PIPE,
418
+ )
419
+ if (
420
+ prereq.expected_output
421
+ and prereq.expected_output not in result.stdout
422
+ ):
423
+ self.status = ToolsetStatusEnum.FAILED
424
+ self.error = f"`{prereq.command}` did not include `{prereq.expected_output}`"
425
+ except subprocess.CalledProcessError as e:
426
+ self.status = ToolsetStatusEnum.FAILED
427
+ self.error = f"`{prereq.command}` returned {e.returncode}"
428
+
429
+ elif isinstance(prereq, ToolsetEnvironmentPrerequisite):
430
+ for env_var in prereq.env:
431
+ if env_var not in os.environ:
432
+ self.status = ToolsetStatusEnum.FAILED
433
+ self.error = f"Environment variable {env_var} was not set"
434
+
435
+ elif isinstance(prereq, StaticPrerequisite):
436
+ if not prereq.enabled:
437
+ self.status = ToolsetStatusEnum.FAILED
438
+ self.error = f"{prereq.disabled_reason}"
439
+
440
+ elif isinstance(prereq, CallablePrerequisite):
441
+ try:
442
+ (enabled, error_message) = prereq.callable(self.config)
443
+ if not enabled:
444
+ self.status = ToolsetStatusEnum.FAILED
445
+ if error_message:
446
+ self.error = f"{error_message}"
447
+ except Exception as e:
448
+ self.status = ToolsetStatusEnum.FAILED
449
+ self.error = f"Prerequisite call failed unexpectedly: {str(e)}"
450
+
451
+ if (
452
+ self.status == ToolsetStatusEnum.DISABLED
453
+ or self.status == ToolsetStatusEnum.FAILED
454
+ ):
455
+ logging.info(f"❌ Toolset {self.name}: {self.error}")
456
+ # no point checking further prerequisites if one failed
457
+ return
458
+
459
+ logging.info(f"✅ Toolset {self.name}")
460
+
461
+ @abstractmethod
462
+ def get_example_config(self) -> Dict[str, Any]:
463
+ return {}
464
+
465
+ def _load_llm_instructions(self, jinja_template: str):
466
+ tool_names = [t.name for t in self.tools]
467
+ self.llm_instructions = load_and_render_prompt(
468
+ prompt=jinja_template,
469
+ context={"tool_names": tool_names, "config": self.config},
470
+ )
471
+
472
+
473
+ class YAMLToolset(Toolset):
474
+ tools: List[YAMLTool] # type: ignore
475
+
476
+ def __init__(self, **kwargs):
477
+ super().__init__(**kwargs)
478
+ if self.llm_instructions:
479
+ self._load_llm_instructions(self.llm_instructions)
480
+
481
+ def get_example_config(self) -> Dict[str, Any]:
482
+ return {}
483
+
484
+
485
+ class ToolsetYamlFromConfig(Toolset):
486
+ """
487
+ ToolsetYamlFromConfig represents a toolset loaded from a YAML configuration file.
488
+ To override a build-in toolset fields, we don't have to explicitly set all required fields,
489
+ instead, we only put the fields we want to override in the YAML file.
490
+ ToolsetYamlFromConfig helps py-pass the pydantic validation of the required fields and together with
491
+ `override_with` method, a build-in toolset object with new configurations is created.
492
+ """
493
+
494
+ name: str
495
+ # YamlToolset is loaded from a YAML file specified by the user and should be enabled by default
496
+ # Built-in toolsets are exception and should be disabled by default when loaded
497
+ enabled: bool = True
498
+ additional_instructions: Optional[str] = None
499
+ prerequisites: List[
500
+ Union[
501
+ StaticPrerequisite,
502
+ ToolsetCommandPrerequisite,
503
+ ToolsetEnvironmentPrerequisite,
504
+ ]
505
+ ] = [] # type: ignore
506
+ tools: Optional[List[YAMLTool]] = [] # type: ignore
507
+ description: Optional[str] = None # type: ignore
508
+ docs_url: Optional[str] = None
509
+ icon_url: Optional[str] = None
510
+ installation_instructions: Optional[str] = None
511
+ config: Optional[Any] = None
512
+ url: Optional[str] = None # MCP toolset
513
+
514
+ def get_example_config(self) -> Dict[str, Any]:
515
+ return {}
516
+
517
+
518
+ class ToolsetDBModel(BaseModel):
519
+ account_id: str
520
+ cluster_id: str
521
+ toolset_name: str
522
+ icon_url: Optional[str] = None
523
+ status: Optional[str] = None
524
+ error: Optional[str] = None
525
+ description: Optional[str] = None
526
+ docs_url: Optional[str] = None
527
+ installation_instructions: Optional[str] = None
528
+ updated_at: str = Field(default_factory=datetime.now().isoformat)
529
+
530
+
531
+ def pretty_print_toolset_status(toolsets: list[Toolset], console: Console) -> None:
532
+ status_fields = ["name", "enabled", "status", "type", "path", "error"]
533
+ toolsets_status = []
534
+ for toolset in sorted(toolsets, key=lambda ts: ts.status.value):
535
+ toolset_status = json.loads(toolset.model_dump_json(include=status_fields)) # type: ignore
536
+
537
+ status_value = toolset_status.get("status", "")
538
+ error_value = toolset_status.get("error", "")
539
+ if status_value == "enabled":
540
+ toolset_status["status"] = "[green]enabled[/green]"
541
+ elif status_value == "failed":
542
+ toolset_status["status"] = "[red]failed[/red]"
543
+ toolset_status["error"] = f"[red]{error_value}[/red]"
544
+ else:
545
+ toolset_status["status"] = f"[yellow]{status_value}[/yellow]"
546
+
547
+ # Replace None with "" for Path and Error columns
548
+ for field in ["path", "error"]:
549
+ if toolset_status.get(field) is None:
550
+ toolset_status[field] = ""
551
+
552
+ order_toolset_status = OrderedDict(
553
+ (k.capitalize(), toolset_status[k])
554
+ for k in status_fields
555
+ if k in toolset_status
556
+ )
557
+ toolsets_status.append(order_toolset_status)
558
+
559
+ table = Table(show_header=True, header_style="bold")
560
+ for col in status_fields:
561
+ table.add_column(col.capitalize())
562
+
563
+ for row in toolsets_status:
564
+ table.add_row(*(str(row.get(col.capitalize(), "")) for col in status_fields))
565
+
566
+ console.print(table)
File without changes
@@ -0,0 +1,65 @@
1
+ import logging
2
+ from typing import List, Optional
3
+
4
+ import sentry_sdk
5
+
6
+ from holmes.core.tools import (
7
+ StructuredToolResult,
8
+ Tool,
9
+ ToolResultStatus,
10
+ Toolset,
11
+ ToolsetStatusEnum,
12
+ )
13
+ from holmes.core.tools_utils.toolset_utils import filter_out_default_logging_toolset
14
+
15
+
16
+ class ToolExecutor:
17
+ def __init__(self, toolsets: List[Toolset]):
18
+ self.toolsets = toolsets
19
+
20
+ enabled_toolsets: list[Toolset] = list(
21
+ filter(
22
+ lambda toolset: toolset.status == ToolsetStatusEnum.ENABLED,
23
+ toolsets,
24
+ )
25
+ )
26
+
27
+ self.enabled_toolsets: list[Toolset] = filter_out_default_logging_toolset(
28
+ enabled_toolsets
29
+ )
30
+
31
+ toolsets_by_name: dict[str, Toolset] = {}
32
+ for ts in self.enabled_toolsets:
33
+ if ts.name in toolsets_by_name:
34
+ logging.warning(f"Overriding toolset '{ts.name}'!")
35
+ toolsets_by_name[ts.name] = ts
36
+
37
+ self.tools_by_name: dict[str, Tool] = {}
38
+ for ts in toolsets_by_name.values():
39
+ for tool in ts.tools:
40
+ if tool.name in self.tools_by_name:
41
+ logging.warning(
42
+ f"Overriding existing tool '{tool.name} with new tool from {ts.name} at {ts.path}'!"
43
+ )
44
+ self.tools_by_name[tool.name] = tool
45
+
46
+ def invoke(self, tool_name: str, params: dict) -> StructuredToolResult:
47
+ tool = self.get_tool_by_name(tool_name)
48
+ return (
49
+ tool.invoke(params)
50
+ if tool
51
+ else StructuredToolResult(
52
+ status=ToolResultStatus.ERROR,
53
+ error=f"Could not find tool named {tool_name}",
54
+ )
55
+ )
56
+
57
+ def get_tool_by_name(self, name: str) -> Optional[Tool]:
58
+ if name in self.tools_by_name:
59
+ return self.tools_by_name[name]
60
+ logging.warning(f"could not find tool {name}. skipping")
61
+ return None
62
+
63
+ @sentry_sdk.trace
64
+ def get_all_tools_openai_format(self):
65
+ return [tool.get_openai_format() for tool in self.tools_by_name.values()]
@@ -0,0 +1,52 @@
1
+ import logging
2
+ from holmes.core.tools import Toolset, ToolsetStatusEnum
3
+ from holmes.plugins.toolsets.logging_utils.logging_api import BasePodLoggingToolset
4
+
5
+
6
+ def filter_out_default_logging_toolset(toolsets: list[Toolset]) -> list[Toolset]:
7
+ """
8
+ Filters the list of toolsets to ensure there is a single enabled BasePodLoggingToolset.
9
+ The selection logic for BasePodLoggingToolset is as follows:
10
+ - If there is exactly one BasePodLoggingToolset, it is returned.
11
+ - If there are multiple enabled BasePodLoggingToolsets and only one is enabled, the enabled one is included, the others are filtered out
12
+ - If there are multiple enabled BasePodLoggingToolsets:
13
+ - Toolsets not named "kubernetes/logs" are preferred.
14
+ - Among the preferred (or if none are preferred, among all enabled),
15
+ the one whose name comes first alphabetically is chosen.
16
+ All other types of toolsets are included as is.
17
+ """
18
+
19
+ logging_toolsets: list[BasePodLoggingToolset] = []
20
+ final_toolsets: list[Toolset] = []
21
+
22
+ for ts in toolsets:
23
+ if (
24
+ isinstance(ts, BasePodLoggingToolset)
25
+ and ts.status == ToolsetStatusEnum.ENABLED
26
+ ):
27
+ logging_toolsets.append(ts)
28
+ else:
29
+ final_toolsets.append(ts)
30
+
31
+ if not logging_toolsets:
32
+ logging.warning("NO ENABLED LOGGING TOOLSET")
33
+ pass
34
+ elif len(logging_toolsets) == 1:
35
+ final_toolsets.append(logging_toolsets[0])
36
+ else:
37
+ non_k8s_logs_candidates = [
38
+ ts for ts in logging_toolsets if ts.name != "kubernetes/logs"
39
+ ]
40
+
41
+ if non_k8s_logs_candidates:
42
+ # Prefer non-"kubernetes/logs" toolsets
43
+ # Sort them to ensure the behaviour is "stable" and does not change across restarts
44
+ non_k8s_logs_candidates.sort(key=lambda ts: ts.name)
45
+ logging.info(f"Using logging toolset {non_k8s_logs_candidates[0].name}")
46
+ final_toolsets.append(non_k8s_logs_candidates[0])
47
+ else:
48
+ logging.info(f"Using logging toolset {logging_toolsets[0].name}")
49
+ # If only "kubernetes/logs" toolsets
50
+ final_toolsets.append(logging_toolsets[0])
51
+
52
+ return final_toolsets