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.
- holmes/.git_archival.json +7 -0
- holmes/__init__.py +76 -0
- holmes/__init__.py.bak +76 -0
- holmes/clients/robusta_client.py +24 -0
- holmes/common/env_vars.py +47 -0
- holmes/config.py +526 -0
- holmes/core/__init__.py +0 -0
- holmes/core/conversations.py +578 -0
- holmes/core/investigation.py +152 -0
- holmes/core/investigation_structured_output.py +264 -0
- holmes/core/issue.py +54 -0
- holmes/core/llm.py +250 -0
- holmes/core/models.py +157 -0
- holmes/core/openai_formatting.py +51 -0
- holmes/core/performance_timing.py +72 -0
- holmes/core/prompt.py +42 -0
- holmes/core/resource_instruction.py +17 -0
- holmes/core/runbooks.py +26 -0
- holmes/core/safeguards.py +120 -0
- holmes/core/supabase_dal.py +540 -0
- holmes/core/tool_calling_llm.py +798 -0
- holmes/core/tools.py +566 -0
- holmes/core/tools_utils/__init__.py +0 -0
- holmes/core/tools_utils/tool_executor.py +65 -0
- holmes/core/tools_utils/toolset_utils.py +52 -0
- holmes/core/toolset_manager.py +418 -0
- holmes/interactive.py +229 -0
- holmes/main.py +1041 -0
- holmes/plugins/__init__.py +0 -0
- holmes/plugins/destinations/__init__.py +6 -0
- holmes/plugins/destinations/slack/__init__.py +2 -0
- holmes/plugins/destinations/slack/plugin.py +163 -0
- holmes/plugins/interfaces.py +32 -0
- holmes/plugins/prompts/__init__.py +48 -0
- holmes/plugins/prompts/_current_date_time.jinja2 +1 -0
- holmes/plugins/prompts/_default_log_prompt.jinja2 +11 -0
- holmes/plugins/prompts/_fetch_logs.jinja2 +36 -0
- holmes/plugins/prompts/_general_instructions.jinja2 +86 -0
- holmes/plugins/prompts/_global_instructions.jinja2 +12 -0
- holmes/plugins/prompts/_runbook_instructions.jinja2 +13 -0
- holmes/plugins/prompts/_toolsets_instructions.jinja2 +56 -0
- holmes/plugins/prompts/generic_ask.jinja2 +36 -0
- holmes/plugins/prompts/generic_ask_conversation.jinja2 +32 -0
- holmes/plugins/prompts/generic_ask_for_issue_conversation.jinja2 +50 -0
- holmes/plugins/prompts/generic_investigation.jinja2 +42 -0
- holmes/plugins/prompts/generic_post_processing.jinja2 +13 -0
- holmes/plugins/prompts/generic_ticket.jinja2 +12 -0
- holmes/plugins/prompts/investigation_output_format.jinja2 +32 -0
- holmes/plugins/prompts/kubernetes_workload_ask.jinja2 +84 -0
- holmes/plugins/prompts/kubernetes_workload_chat.jinja2 +39 -0
- holmes/plugins/runbooks/README.md +22 -0
- holmes/plugins/runbooks/__init__.py +100 -0
- holmes/plugins/runbooks/catalog.json +14 -0
- holmes/plugins/runbooks/jira.yaml +12 -0
- holmes/plugins/runbooks/kube-prometheus-stack.yaml +10 -0
- holmes/plugins/runbooks/networking/dns_troubleshooting_instructions.md +66 -0
- holmes/plugins/runbooks/upgrade/upgrade_troubleshooting_instructions.md +44 -0
- holmes/plugins/sources/github/__init__.py +77 -0
- holmes/plugins/sources/jira/__init__.py +123 -0
- holmes/plugins/sources/opsgenie/__init__.py +93 -0
- holmes/plugins/sources/pagerduty/__init__.py +147 -0
- holmes/plugins/sources/prometheus/__init__.py +0 -0
- holmes/plugins/sources/prometheus/models.py +104 -0
- holmes/plugins/sources/prometheus/plugin.py +154 -0
- holmes/plugins/toolsets/__init__.py +171 -0
- holmes/plugins/toolsets/aks-node-health.yaml +65 -0
- holmes/plugins/toolsets/aks.yaml +86 -0
- holmes/plugins/toolsets/argocd.yaml +70 -0
- holmes/plugins/toolsets/atlas_mongodb/instructions.jinja2 +8 -0
- holmes/plugins/toolsets/atlas_mongodb/mongodb_atlas.py +307 -0
- holmes/plugins/toolsets/aws.yaml +76 -0
- holmes/plugins/toolsets/azure_sql/__init__.py +0 -0
- holmes/plugins/toolsets/azure_sql/apis/alert_monitoring_api.py +600 -0
- holmes/plugins/toolsets/azure_sql/apis/azure_sql_api.py +309 -0
- holmes/plugins/toolsets/azure_sql/apis/connection_failure_api.py +445 -0
- holmes/plugins/toolsets/azure_sql/apis/connection_monitoring_api.py +251 -0
- holmes/plugins/toolsets/azure_sql/apis/storage_analysis_api.py +317 -0
- holmes/plugins/toolsets/azure_sql/azure_base_toolset.py +55 -0
- holmes/plugins/toolsets/azure_sql/azure_sql_instructions.jinja2 +137 -0
- holmes/plugins/toolsets/azure_sql/azure_sql_toolset.py +183 -0
- holmes/plugins/toolsets/azure_sql/install.md +66 -0
- holmes/plugins/toolsets/azure_sql/tools/__init__.py +1 -0
- holmes/plugins/toolsets/azure_sql/tools/analyze_connection_failures.py +324 -0
- holmes/plugins/toolsets/azure_sql/tools/analyze_database_connections.py +243 -0
- holmes/plugins/toolsets/azure_sql/tools/analyze_database_health_status.py +205 -0
- holmes/plugins/toolsets/azure_sql/tools/analyze_database_performance.py +249 -0
- holmes/plugins/toolsets/azure_sql/tools/analyze_database_storage.py +373 -0
- holmes/plugins/toolsets/azure_sql/tools/get_active_alerts.py +237 -0
- holmes/plugins/toolsets/azure_sql/tools/get_slow_queries.py +172 -0
- holmes/plugins/toolsets/azure_sql/tools/get_top_cpu_queries.py +170 -0
- holmes/plugins/toolsets/azure_sql/tools/get_top_data_io_queries.py +188 -0
- holmes/plugins/toolsets/azure_sql/tools/get_top_log_io_queries.py +180 -0
- holmes/plugins/toolsets/azure_sql/utils.py +83 -0
- holmes/plugins/toolsets/bash/__init__.py +0 -0
- holmes/plugins/toolsets/bash/bash_instructions.jinja2 +14 -0
- holmes/plugins/toolsets/bash/bash_toolset.py +208 -0
- holmes/plugins/toolsets/bash/common/bash.py +52 -0
- holmes/plugins/toolsets/bash/common/config.py +14 -0
- holmes/plugins/toolsets/bash/common/stringify.py +25 -0
- holmes/plugins/toolsets/bash/common/validators.py +24 -0
- holmes/plugins/toolsets/bash/grep/__init__.py +52 -0
- holmes/plugins/toolsets/bash/kubectl/__init__.py +100 -0
- holmes/plugins/toolsets/bash/kubectl/constants.py +96 -0
- holmes/plugins/toolsets/bash/kubectl/kubectl_describe.py +66 -0
- holmes/plugins/toolsets/bash/kubectl/kubectl_events.py +88 -0
- holmes/plugins/toolsets/bash/kubectl/kubectl_get.py +108 -0
- holmes/plugins/toolsets/bash/kubectl/kubectl_logs.py +20 -0
- holmes/plugins/toolsets/bash/kubectl/kubectl_run.py +46 -0
- holmes/plugins/toolsets/bash/kubectl/kubectl_top.py +81 -0
- holmes/plugins/toolsets/bash/parse_command.py +103 -0
- holmes/plugins/toolsets/confluence.yaml +19 -0
- holmes/plugins/toolsets/consts.py +5 -0
- holmes/plugins/toolsets/coralogix/api.py +158 -0
- holmes/plugins/toolsets/coralogix/toolset_coralogix_logs.py +103 -0
- holmes/plugins/toolsets/coralogix/utils.py +181 -0
- holmes/plugins/toolsets/datadog.py +153 -0
- holmes/plugins/toolsets/docker.yaml +46 -0
- holmes/plugins/toolsets/git.py +756 -0
- holmes/plugins/toolsets/grafana/__init__.py +0 -0
- holmes/plugins/toolsets/grafana/base_grafana_toolset.py +54 -0
- holmes/plugins/toolsets/grafana/common.py +68 -0
- holmes/plugins/toolsets/grafana/grafana_api.py +31 -0
- holmes/plugins/toolsets/grafana/loki_api.py +89 -0
- holmes/plugins/toolsets/grafana/tempo_api.py +124 -0
- holmes/plugins/toolsets/grafana/toolset_grafana.py +102 -0
- holmes/plugins/toolsets/grafana/toolset_grafana_loki.py +102 -0
- holmes/plugins/toolsets/grafana/toolset_grafana_tempo.jinja2 +10 -0
- holmes/plugins/toolsets/grafana/toolset_grafana_tempo.py +299 -0
- holmes/plugins/toolsets/grafana/trace_parser.py +195 -0
- holmes/plugins/toolsets/helm.yaml +42 -0
- holmes/plugins/toolsets/internet/internet.py +275 -0
- holmes/plugins/toolsets/internet/notion.py +137 -0
- holmes/plugins/toolsets/kafka.py +638 -0
- holmes/plugins/toolsets/kubernetes.yaml +255 -0
- holmes/plugins/toolsets/kubernetes_logs.py +426 -0
- holmes/plugins/toolsets/kubernetes_logs.yaml +42 -0
- holmes/plugins/toolsets/logging_utils/__init__.py +0 -0
- holmes/plugins/toolsets/logging_utils/logging_api.py +217 -0
- holmes/plugins/toolsets/logging_utils/types.py +0 -0
- holmes/plugins/toolsets/mcp/toolset_mcp.py +135 -0
- holmes/plugins/toolsets/newrelic.py +222 -0
- holmes/plugins/toolsets/opensearch/__init__.py +0 -0
- holmes/plugins/toolsets/opensearch/opensearch.py +245 -0
- holmes/plugins/toolsets/opensearch/opensearch_logs.py +151 -0
- holmes/plugins/toolsets/opensearch/opensearch_traces.py +211 -0
- holmes/plugins/toolsets/opensearch/opensearch_traces_instructions.jinja2 +12 -0
- holmes/plugins/toolsets/opensearch/opensearch_utils.py +166 -0
- holmes/plugins/toolsets/prometheus/prometheus.py +818 -0
- holmes/plugins/toolsets/prometheus/prometheus_instructions.jinja2 +38 -0
- holmes/plugins/toolsets/rabbitmq/api.py +398 -0
- holmes/plugins/toolsets/rabbitmq/rabbitmq_instructions.jinja2 +37 -0
- holmes/plugins/toolsets/rabbitmq/toolset_rabbitmq.py +222 -0
- holmes/plugins/toolsets/robusta/__init__.py +0 -0
- holmes/plugins/toolsets/robusta/robusta.py +235 -0
- holmes/plugins/toolsets/robusta/robusta_instructions.jinja2 +24 -0
- holmes/plugins/toolsets/runbook/__init__.py +0 -0
- holmes/plugins/toolsets/runbook/runbook_fetcher.py +78 -0
- holmes/plugins/toolsets/service_discovery.py +92 -0
- holmes/plugins/toolsets/servicenow/install.md +37 -0
- holmes/plugins/toolsets/servicenow/instructions.jinja2 +3 -0
- holmes/plugins/toolsets/servicenow/servicenow.py +198 -0
- holmes/plugins/toolsets/slab.yaml +20 -0
- holmes/plugins/toolsets/utils.py +137 -0
- holmes/plugins/utils.py +14 -0
- holmes/utils/__init__.py +0 -0
- holmes/utils/cache.py +84 -0
- holmes/utils/cert_utils.py +40 -0
- holmes/utils/default_toolset_installation_guide.jinja2 +44 -0
- holmes/utils/definitions.py +13 -0
- holmes/utils/env.py +53 -0
- holmes/utils/file_utils.py +56 -0
- holmes/utils/global_instructions.py +20 -0
- holmes/utils/holmes_status.py +22 -0
- holmes/utils/holmes_sync_toolsets.py +80 -0
- holmes/utils/markdown_utils.py +55 -0
- holmes/utils/pydantic_utils.py +54 -0
- holmes/utils/robusta.py +10 -0
- holmes/utils/tags.py +97 -0
- holmesgpt-0.11.5.dist-info/LICENSE.txt +21 -0
- holmesgpt-0.11.5.dist-info/METADATA +400 -0
- holmesgpt-0.11.5.dist-info/RECORD +183 -0
- holmesgpt-0.11.5.dist-info/WHEEL +4 -0
- 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
|