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
@@ -0,0 +1,418 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ from typing import Any, List, Optional
5
+
6
+ from benedict import benedict
7
+ from pydantic import FilePath
8
+
9
+ from holmes.core.supabase_dal import SupabaseDal
10
+ from holmes.core.tools import Toolset, ToolsetStatusEnum, ToolsetTag, ToolsetType
11
+ from holmes.plugins.toolsets import load_builtin_toolsets, load_toolsets_from_config
12
+ from holmes.utils.definitions import CUSTOM_TOOLSET_LOCATION
13
+
14
+ DEFAULT_TOOLSET_STATUS_LOCATION = os.path.expanduser("~/.holmes/toolsets_status.json")
15
+
16
+
17
+ class ToolsetManager:
18
+ """
19
+ ToolsetManager is responsible for managing toolset locally.
20
+ It can refresh the status of all toolsets and cache the status to a file.
21
+ It also provides methods to get toolsets by name and to get the list of all toolsets.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ toolsets: Optional[dict[str, dict[str, Any]]] = None,
27
+ custom_toolsets: Optional[List[FilePath]] = None,
28
+ custom_toolsets_from_cli: Optional[List[FilePath]] = None,
29
+ toolset_status_location: Optional[FilePath] = None,
30
+ ):
31
+ self.toolsets = toolsets
32
+ self.custom_toolsets = custom_toolsets
33
+
34
+ if toolset_status_location is None:
35
+ toolset_status_location = FilePath(DEFAULT_TOOLSET_STATUS_LOCATION)
36
+
37
+ # holmes container uses CUSTOM_TOOLSET_LOCATION to load custom toolsets
38
+ if os.path.isfile(CUSTOM_TOOLSET_LOCATION):
39
+ if self.custom_toolsets is None:
40
+ self.custom_toolsets = []
41
+ self.custom_toolsets.append(FilePath(CUSTOM_TOOLSET_LOCATION))
42
+
43
+ self.custom_toolsets_from_cli = custom_toolsets_from_cli
44
+ self.toolset_status_location = toolset_status_location
45
+
46
+ @property
47
+ def cli_tool_tags(self) -> List[ToolsetTag]:
48
+ """
49
+ Returns the list of toolset tags that are relevant for CLI tools.
50
+ """
51
+ return [ToolsetTag.CORE, ToolsetTag.CLI]
52
+
53
+ @property
54
+ def server_tool_tags(self) -> List[ToolsetTag]:
55
+ """
56
+ Returns the list of toolset tags that are relevant for server tools.
57
+ """
58
+ return [ToolsetTag.CORE, ToolsetTag.CLUSTER]
59
+
60
+ def _list_all_toolsets(
61
+ self,
62
+ dal: Optional[SupabaseDal] = None,
63
+ check_prerequisites=True,
64
+ enable_all_toolsets=False,
65
+ toolset_tags: Optional[List[ToolsetTag]] = None,
66
+ ) -> List[Toolset]:
67
+ """
68
+ List all built-in and custom toolsets.
69
+
70
+ The method loads toolsets in this order, with later sources overriding earlier ones:
71
+ 1. Built-in toolsets
72
+ 2. Toolsets defined in self.toolsets can override both built-in and add new custom toolsets
73
+ 3. custom toolset from config can override both built-in and add new custom toolsets # for backward compatibility
74
+ """
75
+ # Load built-in toolsets
76
+ builtin_toolsets = load_builtin_toolsets(dal)
77
+ toolsets_by_name: dict[str, Toolset] = {
78
+ toolset.name: toolset for toolset in builtin_toolsets
79
+ }
80
+ builtin_toolsets_names = list(toolsets_by_name.keys())
81
+
82
+ if enable_all_toolsets:
83
+ for toolset in toolsets_by_name.values():
84
+ toolset.enabled = True
85
+
86
+ # build-in toolset is enabled when it's explicitly enabled in the toolset or custom toolset config
87
+ if self.toolsets is not None:
88
+ toolsets_from_config = self._load_toolsets_from_config(
89
+ self.toolsets, builtin_toolsets_names, dal
90
+ )
91
+
92
+ if toolsets_from_config:
93
+ self.add_or_merge_onto_toolsets(
94
+ toolsets_from_config,
95
+ toolsets_by_name,
96
+ )
97
+
98
+ # custom toolset should not override built-in toolsets
99
+ # to test the new change of built-in toolset, we should make code change and re-compile the program
100
+ custom_toolsets = self.load_custom_toolsets(builtin_toolsets_names)
101
+ self.add_or_merge_onto_toolsets(
102
+ custom_toolsets,
103
+ toolsets_by_name,
104
+ )
105
+
106
+ if toolset_tags is not None:
107
+ toolsets_by_name = {
108
+ name: toolset
109
+ for name, toolset in toolsets_by_name.items()
110
+ if any(tag in toolset_tags for tag in toolset.tags)
111
+ }
112
+
113
+ # check_prerequisites against each enabled toolset
114
+ if not check_prerequisites:
115
+ return list(toolsets_by_name.values())
116
+ for _, toolset in toolsets_by_name.items():
117
+ if toolset.enabled:
118
+ toolset.check_prerequisites()
119
+ else:
120
+ toolset.status = ToolsetStatusEnum.DISABLED
121
+
122
+ return list(toolsets_by_name.values())
123
+
124
+ def _load_toolsets_from_config(
125
+ self,
126
+ toolsets: dict[str, dict[str, Any]],
127
+ builtin_toolset_names: list[str],
128
+ dal: Optional[SupabaseDal] = None,
129
+ ) -> List[Toolset]:
130
+ if toolsets is None:
131
+ logging.debug("No toolsets configured, skipping loading toolsets")
132
+ return []
133
+
134
+ builtin_toolsets_dict: dict[str, dict[str, Any]] = {}
135
+ custom_toolsets_dict: dict[str, dict[str, Any]] = {}
136
+ for toolset_name, toolset_config in toolsets.items():
137
+ if toolset_name in builtin_toolset_names:
138
+ # build-in types was assigned when loaded
139
+ builtin_toolsets_dict[toolset_name] = toolset_config
140
+ else:
141
+ if toolset_config.get("type") is None:
142
+ toolset_config["type"] = ToolsetType.CUSTOMIZED.value
143
+ # custom toolsets defaults to enabled when not explicitly disabled
144
+ if toolset_config.get("enabled", True) is False:
145
+ toolset_config["enabled"] = False
146
+ else:
147
+ toolset_config["enabled"] = True
148
+ custom_toolsets_dict[toolset_name] = toolset_config
149
+
150
+ # built-in toolsets and built-in MCP servers in the config can override the existing fields of built-in toolsets
151
+ builtin_toolsets = load_toolsets_from_config(
152
+ builtin_toolsets_dict, strict_check=False
153
+ )
154
+ # custom toolsets or MCP servers are expected to defined required fields
155
+ custom_toolsets = load_toolsets_from_config(
156
+ toolsets=custom_toolsets_dict, strict_check=True
157
+ )
158
+
159
+ return builtin_toolsets + custom_toolsets
160
+
161
+ def refresh_toolset_status(
162
+ self,
163
+ dal: Optional[SupabaseDal] = None,
164
+ enable_all_toolsets=False,
165
+ toolset_tags: Optional[List[ToolsetTag]] = None,
166
+ ):
167
+ """
168
+ Refresh the status of all toolsets and cache the status to a file.
169
+ Loading cached toolsets status saves the time for runtime tool executor checking the status of each toolset
170
+
171
+ enabled toolset when:
172
+ - build-in toolset specified in the config and not explicitly disabled
173
+ - custom toolset not explicitly disabled
174
+ """
175
+
176
+ all_toolsets = self._list_all_toolsets(
177
+ dal=dal,
178
+ check_prerequisites=True,
179
+ enable_all_toolsets=enable_all_toolsets,
180
+ toolset_tags=toolset_tags,
181
+ )
182
+
183
+ if self.toolset_status_location and not os.path.exists(
184
+ os.path.dirname(self.toolset_status_location)
185
+ ):
186
+ os.makedirs(os.path.dirname(self.toolset_status_location))
187
+ with open(self.toolset_status_location, "w") as f:
188
+ toolset_status = [
189
+ json.loads(
190
+ toolset.model_dump_json(
191
+ include={"name", "status", "enabled", "type", "path", "error"}
192
+ )
193
+ )
194
+ for toolset in all_toolsets
195
+ ]
196
+ json.dump(toolset_status, f, indent=2)
197
+ logging.info(f"Toolset statuses are cached to {self.toolset_status_location}")
198
+
199
+ def load_toolset_with_status(
200
+ self,
201
+ dal: Optional[SupabaseDal] = None,
202
+ refresh_status: bool = False,
203
+ enable_all_toolsets=False,
204
+ toolset_tags: Optional[List[ToolsetTag]] = None,
205
+ ) -> List[Toolset]:
206
+ """
207
+ Load the toolset with status from the cache file.
208
+ 1. load the built-in toolsets
209
+ 2. load the custom toolsets from config, and override the built-in toolsets
210
+ 3. load the custom toolsets from CLI, and raise error if the custom toolset from CLI conflicts with existing toolsets
211
+ """
212
+
213
+ if not os.path.exists(self.toolset_status_location) or refresh_status:
214
+ logging.info("Refreshing available datasources (toolsets)")
215
+ self.refresh_toolset_status(
216
+ dal, enable_all_toolsets=enable_all_toolsets, toolset_tags=toolset_tags
217
+ )
218
+ using_cached = False
219
+ else:
220
+ using_cached = True
221
+
222
+ cached_toolsets: List[dict[str, Any]] = []
223
+ with open(self.toolset_status_location, "r") as f:
224
+ cached_toolsets = json.load(f)
225
+
226
+ # load status from cached file and update the toolset details
227
+ toolsets_status_by_name: dict[str, dict[str, Any]] = {
228
+ cached_toolset["name"]: cached_toolset for cached_toolset in cached_toolsets
229
+ }
230
+ all_toolsets_with_status = self._list_all_toolsets(
231
+ dal=dal, check_prerequisites=False, toolset_tags=toolset_tags
232
+ )
233
+
234
+ for toolset in all_toolsets_with_status:
235
+ if toolset.name in toolsets_status_by_name:
236
+ # Update the status and error from the cached status
237
+ cached_status = toolsets_status_by_name[toolset.name]
238
+ toolset.status = ToolsetStatusEnum(cached_status["status"])
239
+ toolset.error = cached_status.get("error", None)
240
+ toolset.enabled = cached_status.get("enabled", True)
241
+ toolset.type = ToolsetType(
242
+ cached_status.get("type", ToolsetType.BUILTIN)
243
+ )
244
+ toolset.path = cached_status.get("path", None)
245
+ # check prerequisites for only enabled toolset when the toolset is loaded from cache
246
+ if (
247
+ toolset.enabled
248
+ and toolset.status == ToolsetStatusEnum.ENABLED
249
+ and using_cached
250
+ ):
251
+ toolset.check_prerequisites() # type: ignore
252
+
253
+ # CLI custom toolsets status are not cached, and their prerequisites are always checked whenever the CLI runs.
254
+ custom_toolsets_from_cli = self._load_toolsets_from_paths(
255
+ self.custom_toolsets_from_cli,
256
+ list(toolsets_status_by_name.keys()),
257
+ check_conflict_default=True,
258
+ )
259
+ # custom toolsets from cli as experimental toolset should not override custom toolsets from config
260
+ for custom_toolset_from_cli in custom_toolsets_from_cli:
261
+ if custom_toolset_from_cli.name in toolsets_status_by_name:
262
+ raise ValueError(
263
+ f"Toolset {custom_toolset_from_cli.name} from cli is already defined in existing toolset"
264
+ )
265
+ # status of custom toolsets from cli is not cached, and we need to check prerequisites every time the cli runs.
266
+ custom_toolset_from_cli.check_prerequisites()
267
+
268
+ all_toolsets_with_status.extend(custom_toolsets_from_cli)
269
+ if using_cached:
270
+ num_available_toolsets = len(
271
+ [toolset for toolset in all_toolsets_with_status if toolset.enabled]
272
+ )
273
+ logging.info(
274
+ f"Using {num_available_toolsets} datasources (toolsets). To refresh: `holmes toolset refresh`"
275
+ )
276
+ return all_toolsets_with_status
277
+
278
+ def list_console_toolsets(
279
+ self, dal: Optional[SupabaseDal] = None, refresh_status=False
280
+ ) -> List[Toolset]:
281
+ """
282
+ List all enabled toolsets that cli tools can use.
283
+
284
+ listing console toolset does not refresh toolset status by default, and expects the status to be
285
+ refreshed specifically and cached locally.
286
+ """
287
+ toolsets_with_status = self.load_toolset_with_status(
288
+ dal,
289
+ refresh_status=refresh_status,
290
+ enable_all_toolsets=True,
291
+ toolset_tags=self.cli_tool_tags,
292
+ )
293
+ return toolsets_with_status
294
+
295
+ # TODO(mainred): cache and refresh periodically toolset status for server if necessary
296
+ def list_server_toolsets(
297
+ self, dal: Optional[SupabaseDal] = None, refresh_status=True
298
+ ) -> List[Toolset]:
299
+ """
300
+ List all toolsets that are enabled and have the server tool tags.
301
+
302
+ server will sync the status of toolsets to DB during startup instead of local cache.
303
+ Refreshing the status by default for server to keep the toolsets up-to-date instead of relying on local cache.
304
+ """
305
+ toolsets_with_status = self._list_all_toolsets(
306
+ dal,
307
+ check_prerequisites=True,
308
+ enable_all_toolsets=False,
309
+ toolset_tags=self.server_tool_tags,
310
+ )
311
+ return toolsets_with_status
312
+
313
+ def _load_toolsets_from_paths(
314
+ self,
315
+ toolset_paths: Optional[List[FilePath]],
316
+ builtin_toolsets_names: list[str],
317
+ check_conflict_default: bool = False,
318
+ ) -> List[Toolset]:
319
+ if not toolset_paths:
320
+ logging.debug("No toolsets configured, skipping loading toolsets")
321
+ return []
322
+
323
+ loaded_custom_toolsets: List[Toolset] = []
324
+ for toolset_path in toolset_paths:
325
+ if not os.path.isfile(toolset_path):
326
+ raise FileNotFoundError(f"toolset file {toolset_path} does not exist")
327
+
328
+ try:
329
+ parsed_yaml = benedict(toolset_path)
330
+ except Exception as e:
331
+ raise ValueError(
332
+ f"Failed to load toolsets from {toolset_path}, error: {e}"
333
+ ) from e
334
+ toolsets_config: dict[str, dict[str, Any]] = parsed_yaml.get("toolsets", {})
335
+ mcp_config: dict[str, dict[str, Any]] = parsed_yaml.get("mcp_servers", {})
336
+
337
+ for server_config in mcp_config.values():
338
+ server_config["type"] = ToolsetType.MCP
339
+
340
+ for toolset_config in toolsets_config.values():
341
+ toolset_config["path"] = toolset_path
342
+
343
+ toolsets_config.update(mcp_config)
344
+
345
+ if not toolsets_config:
346
+ raise ValueError(
347
+ f"No 'toolsets' or 'mcp_servers' key found in: {toolset_path}"
348
+ )
349
+
350
+ toolsets_from_config = self._load_toolsets_from_config(
351
+ toolsets_config, builtin_toolsets_names
352
+ )
353
+ if check_conflict_default:
354
+ for toolset in toolsets_from_config:
355
+ if toolset.name in builtin_toolsets_names:
356
+ raise Exception(
357
+ f"Toolset {toolset.name} is already defined in the built-in toolsets. "
358
+ "Please rename the custom toolset or remove it from the custom toolsets configuration."
359
+ )
360
+
361
+ loaded_custom_toolsets.extend(toolsets_from_config)
362
+
363
+ return loaded_custom_toolsets
364
+
365
+ def load_custom_toolsets(self, builtin_toolsets_names: list[str]) -> list[Toolset]:
366
+ """
367
+ Loads toolsets config from custom toolset path with YAMLToolset class.
368
+
369
+ Example configuration:
370
+ # override the built-in toolsets with custom toolsets
371
+ kubernetes/logs:
372
+ enabled: false
373
+
374
+ # define a custom toolset with strictly defined fields
375
+ test/configurations:
376
+ enabled: true
377
+ icon_url: "example.com"
378
+ description: "test_description"
379
+ docs_url: "https://docs.docker.com/"
380
+ prerequisites:
381
+ - env:
382
+ - API_ENDPOINT
383
+ - command: "curl ${API_ENDPOINT}"
384
+ additional_instructions: "jq -r '.result.results[].userData | fromjson | .text | fromjson | .log'"
385
+ tools:
386
+ - name: "curl_example"
387
+ description: "Perform a curl request to example.com using variables"
388
+ command: "curl -X GET '{{api_endpoint}}?query={{ query_param }}' "
389
+ """
390
+ if not self.custom_toolsets and not self.custom_toolsets_from_cli:
391
+ logging.debug(
392
+ "No custom toolsets configured, skipping loading custom toolsets"
393
+ )
394
+ return []
395
+
396
+ loaded_custom_toolsets: List[Toolset] = []
397
+ custom_toolsets = self._load_toolsets_from_paths(
398
+ self.custom_toolsets, builtin_toolsets_names
399
+ )
400
+ loaded_custom_toolsets.extend(custom_toolsets)
401
+
402
+ return loaded_custom_toolsets
403
+
404
+ def add_or_merge_onto_toolsets(
405
+ self,
406
+ new_toolsets: list[Toolset],
407
+ existing_toolsets_by_name: dict[str, Toolset],
408
+ ) -> None:
409
+ """
410
+ Add new or merge toolsets onto existing toolsets.
411
+ """
412
+
413
+ for new_toolset in new_toolsets:
414
+ if new_toolset.name in existing_toolsets_by_name.keys():
415
+ existing_toolsets_by_name[new_toolset.name].override_with(new_toolset)
416
+ else:
417
+ existing_toolsets_by_name[new_toolset.name] = new_toolset
418
+ existing_toolsets_by_name[new_toolset.name] = new_toolset
holmes/interactive.py ADDED
@@ -0,0 +1,229 @@
1
+ import logging
2
+ from enum import Enum
3
+ from typing import Optional, List
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from prompt_toolkit import PromptSession
8
+ from prompt_toolkit.completion import Completer, Completion
9
+ from prompt_toolkit.history import InMemoryHistory
10
+ from prompt_toolkit.styles import Style
11
+ from rich.console import Console
12
+ from rich.markdown import Markdown, Panel
13
+
14
+ from holmes.core.prompt import build_initial_ask_messages
15
+ from holmes.core.tool_calling_llm import ToolCallingLLM, ToolCallResult
16
+ from holmes.core.tools import pretty_print_toolset_status
17
+
18
+
19
+ class SlashCommands(Enum):
20
+ EXIT = "/exit"
21
+ HELP = "/help"
22
+ RESET = "/reset"
23
+ TOOLS_CONFIG = "/config"
24
+ TOGGLE_TOOL_OUTPUT = "/toggle-output"
25
+ SHOW_OUTPUT = "/output"
26
+
27
+
28
+ SLASH_COMMANDS_REFERENCE = {
29
+ SlashCommands.EXIT.value: "Exit interactive mode",
30
+ SlashCommands.HELP.value: "Show help message with all commands",
31
+ SlashCommands.RESET.value: "Reset the conversation context",
32
+ SlashCommands.TOOLS_CONFIG.value: "Show available toolsets and their status",
33
+ SlashCommands.TOGGLE_TOOL_OUTPUT.value: "Toggle tool output display on/off",
34
+ SlashCommands.SHOW_OUTPUT.value: "Show all tool outputs from last response",
35
+ }
36
+
37
+ ALL_SLASH_COMMANDS = [cmd.value for cmd in SlashCommands]
38
+
39
+
40
+ class SlashCommandCompleter(Completer):
41
+ def __init__(self):
42
+ self.commands = SLASH_COMMANDS_REFERENCE
43
+
44
+ def get_completions(self, document, complete_event):
45
+ text = document.text_before_cursor
46
+ if text.startswith("/"):
47
+ word = text
48
+ for cmd, description in self.commands.items():
49
+ if cmd.startswith(word):
50
+ yield Completion(
51
+ cmd, start_position=-len(word), display=f"{cmd} - {description}"
52
+ )
53
+
54
+
55
+ USER_COLOR = "#DEFCC0" # light green
56
+ AI_COLOR = "#00FFFF" # cyan
57
+ TOOLS_COLOR = "magenta"
58
+ HELP_COLOR = "cyan" # same as AI_COLOR for now
59
+ ERROR_COLOR = "red"
60
+ STATUS_COLOR = "yellow"
61
+
62
+ WELCOME_BANNER = f"[bold {HELP_COLOR}]Welcome to HolmesGPT:[/bold {HELP_COLOR}] Type '{SlashCommands.EXIT.value}' to exit, '{SlashCommands.HELP.value}' for commands."
63
+
64
+
65
+ def format_tool_call_output(tool_call: ToolCallResult) -> str:
66
+ """
67
+ Format a single tool call result for display in a rich panel.
68
+
69
+ Args:
70
+ tool_call: ToolCallResult object containing the tool execution result
71
+
72
+ Returns:
73
+ Formatted string for display in a rich panel
74
+ """
75
+ result = tool_call.result
76
+ output_str = result.get_stringified_data()
77
+
78
+ color = result.status.to_color()
79
+ MAX_CHARS = 500
80
+ if len(output_str) == 0:
81
+ content = f"[{color}]<empty>[/{color}]"
82
+ elif len(output_str) > MAX_CHARS:
83
+ truncated = output_str[:MAX_CHARS].strip()
84
+ remaining_chars = len(output_str) - MAX_CHARS
85
+ content = f"[{color}]{truncated}[/{color}]\n\n[dim]... truncated ({remaining_chars:,} more chars)[/dim]"
86
+ else:
87
+ content = f"[{color}]{output_str}[/{color}]"
88
+
89
+ return content
90
+
91
+
92
+ def display_tool_calls(tool_calls: List[ToolCallResult], console: Console) -> None:
93
+ """
94
+ Display tool calls in rich panels.
95
+
96
+ Args:
97
+ tool_calls: List of ToolCallResult objects to display
98
+ console: Rich console for output
99
+ """
100
+ console.print(
101
+ f"[bold {TOOLS_COLOR}]Used {len(tool_calls)} tools[/bold {TOOLS_COLOR}]"
102
+ )
103
+ for tool_call in tool_calls:
104
+ preview_output = format_tool_call_output(tool_call)
105
+ title = f"{tool_call.result.status.to_emoji()} {tool_call.description} -> returned {tool_call.result.return_code}"
106
+
107
+ console.print(
108
+ Panel(
109
+ preview_output,
110
+ padding=(1, 2),
111
+ border_style=TOOLS_COLOR,
112
+ title=title,
113
+ )
114
+ )
115
+
116
+
117
+ def run_interactive_loop(
118
+ ai: ToolCallingLLM,
119
+ console: Console,
120
+ system_prompt_rendered: str,
121
+ initial_user_input: Optional[str],
122
+ include_files: Optional[List[Path]],
123
+ post_processing_prompt: Optional[str],
124
+ show_tool_output: bool,
125
+ ) -> None:
126
+ style = Style.from_dict(
127
+ {
128
+ "prompt": USER_COLOR,
129
+ }
130
+ )
131
+
132
+ command_completer = SlashCommandCompleter()
133
+ history = InMemoryHistory()
134
+ if initial_user_input:
135
+ history.append_string(initial_user_input)
136
+ session = PromptSession(
137
+ completer=command_completer,
138
+ history=history,
139
+ ) # type: ignore
140
+ input_prompt = [("class:prompt", "User: ")]
141
+
142
+ console.print(WELCOME_BANNER)
143
+ if initial_user_input:
144
+ console.print(
145
+ f"[bold {USER_COLOR}]User:[/bold {USER_COLOR}] {initial_user_input}"
146
+ )
147
+ messages = None
148
+ last_response = None
149
+
150
+ while True:
151
+ try:
152
+ if initial_user_input:
153
+ user_input = initial_user_input
154
+ initial_user_input = None
155
+ else:
156
+ user_input = session.prompt(input_prompt, style=style) # type: ignore
157
+
158
+ if user_input.startswith("/"):
159
+ command = user_input.strip().lower()
160
+ if command == SlashCommands.EXIT.value:
161
+ return
162
+ elif command == SlashCommands.HELP.value:
163
+ console.print(
164
+ f"[bold {HELP_COLOR}]Available commands:[/bold {HELP_COLOR}]"
165
+ )
166
+ for cmd, description in SLASH_COMMANDS_REFERENCE.items():
167
+ console.print(f" [bold]{cmd}[/bold] - {description}")
168
+ elif command == SlashCommands.RESET.value:
169
+ console.print(
170
+ f"[bold {STATUS_COLOR}]Context reset. You can now ask a new question.[/bold {STATUS_COLOR}]"
171
+ )
172
+ messages = None
173
+ continue
174
+ elif command == SlashCommands.TOOLS_CONFIG.value:
175
+ pretty_print_toolset_status(ai.tool_executor.toolsets, console)
176
+ elif command == SlashCommands.TOGGLE_TOOL_OUTPUT.value:
177
+ show_tool_output = not show_tool_output
178
+ status = "enabled" if show_tool_output else "disabled"
179
+ console.print(
180
+ f"[bold yellow]Tool output display {status}.[/bold yellow]"
181
+ )
182
+ elif command == SlashCommands.SHOW_OUTPUT.value:
183
+ if last_response is None or not last_response.tool_calls:
184
+ console.print(
185
+ f"[bold {ERROR_COLOR}]No tool calls available from the last response.[/bold {ERROR_COLOR}]"
186
+ )
187
+ continue
188
+
189
+ display_tool_calls(last_response.tool_calls, console)
190
+ else:
191
+ console.print(f"Unknown command: {command}")
192
+ continue
193
+ elif not user_input.strip():
194
+ continue
195
+
196
+ if messages is None:
197
+ messages = build_initial_ask_messages(
198
+ console, system_prompt_rendered, user_input, include_files
199
+ )
200
+ else:
201
+ messages.append({"role": "user", "content": user_input})
202
+
203
+ console.print(f"\n[bold {AI_COLOR}]Thinking...[/bold {AI_COLOR}]\n")
204
+ response = ai.call(messages, post_processing_prompt)
205
+ messages = response.messages # type: ignore
206
+ last_response = response
207
+
208
+ if show_tool_output and response.tool_calls:
209
+ display_tool_calls(response.tool_calls, console)
210
+ console.print(
211
+ Panel(
212
+ Markdown(f"{response.result}"),
213
+ padding=(1, 2),
214
+ border_style=AI_COLOR,
215
+ title=f"[bold {AI_COLOR}]AI Response[/bold {AI_COLOR}]",
216
+ title_align="left",
217
+ )
218
+ )
219
+ console.print("")
220
+ except typer.Abort:
221
+ break
222
+ except EOFError: # Handle Ctrl+D
223
+ break
224
+ except Exception as e:
225
+ logging.error("An error occurred during interactive mode:", exc_info=e)
226
+ console.print(f"[bold {ERROR_COLOR}]Error: {e}[/bold {ERROR_COLOR}]")
227
+ console.print(
228
+ f"[bold {STATUS_COLOR}]Exiting interactive mode.[/bold {STATUS_COLOR}]"
229
+ )