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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. holmes/__init__.py +1 -1
  2. holmes/clients/robusta_client.py +17 -4
  3. holmes/common/env_vars.py +40 -1
  4. holmes/config.py +114 -144
  5. holmes/core/conversations.py +53 -14
  6. holmes/core/feedback.py +191 -0
  7. holmes/core/investigation.py +18 -22
  8. holmes/core/llm.py +489 -88
  9. holmes/core/models.py +103 -1
  10. holmes/core/openai_formatting.py +13 -0
  11. holmes/core/prompt.py +1 -1
  12. holmes/core/safeguards.py +4 -4
  13. holmes/core/supabase_dal.py +293 -100
  14. holmes/core/tool_calling_llm.py +423 -323
  15. holmes/core/tools.py +311 -33
  16. holmes/core/tools_utils/token_counting.py +14 -0
  17. holmes/core/tools_utils/tool_context_window_limiter.py +57 -0
  18. holmes/core/tools_utils/tool_executor.py +13 -8
  19. holmes/core/toolset_manager.py +155 -4
  20. holmes/core/tracing.py +6 -1
  21. holmes/core/transformers/__init__.py +23 -0
  22. holmes/core/transformers/base.py +62 -0
  23. holmes/core/transformers/llm_summarize.py +174 -0
  24. holmes/core/transformers/registry.py +122 -0
  25. holmes/core/transformers/transformer.py +31 -0
  26. holmes/core/truncation/compaction.py +59 -0
  27. holmes/core/truncation/dal_truncation_utils.py +23 -0
  28. holmes/core/truncation/input_context_window_limiter.py +218 -0
  29. holmes/interactive.py +177 -24
  30. holmes/main.py +7 -4
  31. holmes/plugins/prompts/_fetch_logs.jinja2 +26 -1
  32. holmes/plugins/prompts/_general_instructions.jinja2 +1 -2
  33. holmes/plugins/prompts/_runbook_instructions.jinja2 +23 -12
  34. holmes/plugins/prompts/conversation_history_compaction.jinja2 +88 -0
  35. holmes/plugins/prompts/generic_ask.jinja2 +2 -4
  36. holmes/plugins/prompts/generic_ask_conversation.jinja2 +2 -1
  37. holmes/plugins/prompts/generic_ask_for_issue_conversation.jinja2 +2 -1
  38. holmes/plugins/prompts/generic_investigation.jinja2 +2 -1
  39. holmes/plugins/prompts/investigation_procedure.jinja2 +48 -0
  40. holmes/plugins/prompts/kubernetes_workload_ask.jinja2 +2 -1
  41. holmes/plugins/prompts/kubernetes_workload_chat.jinja2 +2 -1
  42. holmes/plugins/runbooks/__init__.py +117 -18
  43. holmes/plugins/runbooks/catalog.json +2 -0
  44. holmes/plugins/toolsets/__init__.py +21 -8
  45. holmes/plugins/toolsets/aks-node-health.yaml +46 -0
  46. holmes/plugins/toolsets/aks.yaml +64 -0
  47. holmes/plugins/toolsets/atlas_mongodb/mongodb_atlas.py +26 -36
  48. holmes/plugins/toolsets/azure_sql/azure_sql_toolset.py +0 -1
  49. holmes/plugins/toolsets/azure_sql/tools/analyze_connection_failures.py +10 -7
  50. holmes/plugins/toolsets/azure_sql/tools/analyze_database_connections.py +9 -6
  51. holmes/plugins/toolsets/azure_sql/tools/analyze_database_health_status.py +8 -6
  52. holmes/plugins/toolsets/azure_sql/tools/analyze_database_performance.py +8 -6
  53. holmes/plugins/toolsets/azure_sql/tools/analyze_database_storage.py +9 -6
  54. holmes/plugins/toolsets/azure_sql/tools/get_active_alerts.py +9 -7
  55. holmes/plugins/toolsets/azure_sql/tools/get_slow_queries.py +9 -6
  56. holmes/plugins/toolsets/azure_sql/tools/get_top_cpu_queries.py +9 -6
  57. holmes/plugins/toolsets/azure_sql/tools/get_top_data_io_queries.py +9 -6
  58. holmes/plugins/toolsets/azure_sql/tools/get_top_log_io_queries.py +9 -6
  59. holmes/plugins/toolsets/bash/bash_toolset.py +10 -13
  60. holmes/plugins/toolsets/bash/common/bash.py +7 -7
  61. holmes/plugins/toolsets/cilium.yaml +284 -0
  62. holmes/plugins/toolsets/coralogix/toolset_coralogix_logs.py +5 -3
  63. holmes/plugins/toolsets/datadog/datadog_api.py +490 -24
  64. holmes/plugins/toolsets/datadog/datadog_logs_instructions.jinja2 +21 -10
  65. holmes/plugins/toolsets/datadog/toolset_datadog_general.py +349 -216
  66. holmes/plugins/toolsets/datadog/toolset_datadog_logs.py +190 -19
  67. holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py +101 -44
  68. holmes/plugins/toolsets/datadog/toolset_datadog_rds.py +13 -16
  69. holmes/plugins/toolsets/datadog/toolset_datadog_traces.py +25 -31
  70. holmes/plugins/toolsets/git.py +51 -46
  71. holmes/plugins/toolsets/grafana/common.py +15 -3
  72. holmes/plugins/toolsets/grafana/grafana_api.py +46 -24
  73. holmes/plugins/toolsets/grafana/grafana_tempo_api.py +454 -0
  74. holmes/plugins/toolsets/grafana/loki/instructions.jinja2 +9 -0
  75. holmes/plugins/toolsets/grafana/loki/toolset_grafana_loki.py +117 -0
  76. holmes/plugins/toolsets/grafana/toolset_grafana.py +211 -91
  77. holmes/plugins/toolsets/grafana/toolset_grafana_dashboard.jinja2 +27 -0
  78. holmes/plugins/toolsets/grafana/toolset_grafana_tempo.jinja2 +246 -11
  79. holmes/plugins/toolsets/grafana/toolset_grafana_tempo.py +653 -293
  80. holmes/plugins/toolsets/grafana/trace_parser.py +1 -1
  81. holmes/plugins/toolsets/internet/internet.py +6 -7
  82. holmes/plugins/toolsets/internet/notion.py +5 -6
  83. holmes/plugins/toolsets/investigator/core_investigation.py +42 -34
  84. holmes/plugins/toolsets/kafka.py +25 -36
  85. holmes/plugins/toolsets/kubernetes.yaml +58 -84
  86. holmes/plugins/toolsets/kubernetes_logs.py +6 -6
  87. holmes/plugins/toolsets/kubernetes_logs.yaml +32 -0
  88. holmes/plugins/toolsets/logging_utils/logging_api.py +80 -4
  89. holmes/plugins/toolsets/mcp/toolset_mcp.py +181 -55
  90. holmes/plugins/toolsets/newrelic/__init__.py +0 -0
  91. holmes/plugins/toolsets/newrelic/new_relic_api.py +125 -0
  92. holmes/plugins/toolsets/newrelic/newrelic.jinja2 +41 -0
  93. holmes/plugins/toolsets/newrelic/newrelic.py +163 -0
  94. holmes/plugins/toolsets/opensearch/opensearch.py +10 -17
  95. holmes/plugins/toolsets/opensearch/opensearch_logs.py +7 -7
  96. holmes/plugins/toolsets/opensearch/opensearch_ppl_query_docs.jinja2 +1616 -0
  97. holmes/plugins/toolsets/opensearch/opensearch_query_assist.py +78 -0
  98. holmes/plugins/toolsets/opensearch/opensearch_query_assist_instructions.jinja2 +223 -0
  99. holmes/plugins/toolsets/opensearch/opensearch_traces.py +13 -16
  100. holmes/plugins/toolsets/openshift.yaml +283 -0
  101. holmes/plugins/toolsets/prometheus/prometheus.py +915 -390
  102. holmes/plugins/toolsets/prometheus/prometheus_instructions.jinja2 +43 -2
  103. holmes/plugins/toolsets/prometheus/utils.py +28 -0
  104. holmes/plugins/toolsets/rabbitmq/toolset_rabbitmq.py +9 -10
  105. holmes/plugins/toolsets/robusta/robusta.py +236 -65
  106. holmes/plugins/toolsets/robusta/robusta_instructions.jinja2 +26 -9
  107. holmes/plugins/toolsets/runbook/runbook_fetcher.py +137 -26
  108. holmes/plugins/toolsets/service_discovery.py +1 -1
  109. holmes/plugins/toolsets/servicenow_tables/instructions.jinja2 +83 -0
  110. holmes/plugins/toolsets/servicenow_tables/servicenow_tables.py +426 -0
  111. holmes/plugins/toolsets/utils.py +88 -0
  112. holmes/utils/config_utils.py +91 -0
  113. holmes/utils/default_toolset_installation_guide.jinja2 +1 -22
  114. holmes/utils/env.py +7 -0
  115. holmes/utils/global_instructions.py +75 -10
  116. holmes/utils/holmes_status.py +2 -1
  117. holmes/utils/holmes_sync_toolsets.py +0 -2
  118. holmes/utils/krr_utils.py +188 -0
  119. holmes/utils/sentry_helper.py +41 -0
  120. holmes/utils/stream.py +61 -7
  121. holmes/version.py +34 -14
  122. holmesgpt-0.16.2a0.dist-info/LICENSE +178 -0
  123. {holmesgpt-0.13.2.dist-info → holmesgpt-0.16.2a0.dist-info}/METADATA +29 -27
  124. {holmesgpt-0.13.2.dist-info → holmesgpt-0.16.2a0.dist-info}/RECORD +126 -102
  125. holmes/core/performance_timing.py +0 -72
  126. holmes/plugins/toolsets/grafana/tempo_api.py +0 -124
  127. holmes/plugins/toolsets/grafana/toolset_grafana_loki.py +0 -110
  128. holmes/plugins/toolsets/newrelic.py +0 -231
  129. holmes/plugins/toolsets/servicenow/install.md +0 -37
  130. holmes/plugins/toolsets/servicenow/instructions.jinja2 +0 -3
  131. holmes/plugins/toolsets/servicenow/servicenow.py +0 -219
  132. holmesgpt-0.13.2.dist-info/LICENSE.txt +0 -21
  133. {holmesgpt-0.13.2.dist-info → holmesgpt-0.16.2a0.dist-info}/WHEEL +0 -0
  134. {holmesgpt-0.13.2.dist-info → holmesgpt-0.16.2a0.dist-info}/entry_points.txt +0 -0
@@ -1,20 +1,85 @@
1
- from typing import List, Optional
2
-
1
+ from typing import Optional, List, TYPE_CHECKING
3
2
  from pydantic import BaseModel
3
+ from holmes.plugins.prompts import load_and_render_prompt
4
+ from holmes.plugins.runbooks import RunbookCatalog
5
+
6
+ if TYPE_CHECKING:
7
+ from holmes.core.resource_instruction import ResourceInstructions
4
8
 
5
9
 
6
10
  class Instructions(BaseModel):
7
11
  instructions: List[str] = []
8
12
 
9
13
 
10
- def add_global_instructions_to_user_prompt(
11
- user_prompt: str, global_instructions: Optional[Instructions]
14
+ def _format_instructions_block(
15
+ items: List[str], header: str = "My instructions to check:"
16
+ ) -> str:
17
+ lines = [f"* {s}" for s in items if isinstance(s, str) and s.strip()]
18
+ if not lines:
19
+ return ""
20
+ bullets = "\n".join(lines) + "\n"
21
+ return f"{header}\n{bullets}"
22
+
23
+
24
+ def _format_resource_instructions(
25
+ resource_instructions: Optional["ResourceInstructions"],
26
+ ) -> List[str]: # type: ignore
27
+ items = []
28
+ if resource_instructions is not None:
29
+ if getattr(resource_instructions, "instructions", None):
30
+ items.extend(resource_instructions.instructions)
31
+ if getattr(resource_instructions, "documents", None):
32
+ for document in resource_instructions.documents:
33
+ items.append(f"fetch information from this URL: {document.url}")
34
+ return items
35
+
36
+
37
+ def add_runbooks_to_user_prompt(
38
+ user_prompt: str,
39
+ runbook_catalog: Optional[RunbookCatalog],
40
+ global_instructions: Optional[Instructions] = None,
41
+ issue_instructions: Optional[List[str]] = None,
42
+ resource_instructions: Optional["ResourceInstructions"] = None, # type: ignore
12
43
  ) -> str:
13
44
  if (
14
- global_instructions
15
- and global_instructions.instructions
16
- and len(global_instructions.instructions[0]) > 0
45
+ not runbook_catalog
46
+ and not issue_instructions
47
+ and not resource_instructions
48
+ and not global_instructions
17
49
  ):
18
- instructions = "\n\n".join(global_instructions.instructions)
19
- user_prompt += f"\n\nGlobal Instructions (use if relevant): {instructions}\n"
20
- return user_prompt
50
+ return user_prompt
51
+
52
+ catalog_str = runbook_catalog.to_prompt_string() if runbook_catalog else ""
53
+
54
+ # Combine and format all instructions
55
+ combined_instructions = []
56
+ if issue_instructions:
57
+ combined_instructions.extend(issue_instructions)
58
+ combined_instructions.extend(_format_resource_instructions(resource_instructions))
59
+ issue_block = (
60
+ _format_instructions_block(combined_instructions)
61
+ if combined_instructions
62
+ else ""
63
+ )
64
+
65
+ gi_list = getattr(global_instructions, "instructions", None) or []
66
+ global_block = (
67
+ _format_instructions_block(
68
+ [s for s in gi_list if isinstance(s, str)], header=""
69
+ )
70
+ if gi_list
71
+ else ""
72
+ )
73
+
74
+ rendered = load_and_render_prompt(
75
+ "builtin://_runbook_instructions.jinja2",
76
+ context={
77
+ "runbook_catalog": catalog_str,
78
+ "custom_instructions": issue_block,
79
+ "global_instructions": global_block,
80
+ },
81
+ )
82
+
83
+ if user_prompt and not user_prompt.endswith("\n"):
84
+ user_prompt += "\n"
85
+ return f"{user_prompt}\n{rendered}"
@@ -1,3 +1,4 @@
1
+ import json
1
2
  from holmes.core.supabase_dal import SupabaseDal
2
3
  from holmes.config import Config
3
4
  from holmes import get_version # type: ignore
@@ -16,7 +17,7 @@ def update_holmes_status_in_db(dal: SupabaseDal, config: Config):
16
17
  dal.upsert_holmes_status(
17
18
  {
18
19
  "cluster_id": config.cluster_name,
19
- "model": config.get_models_list(),
20
+ "model": json.dumps(config.get_models_list()),
20
21
  "version": get_version(),
21
22
  }
22
23
  )
@@ -66,8 +66,6 @@ def render_default_installation_instructions_for_toolset(toolset: Toolset) -> st
66
66
  context: dict[str, Any] = {
67
67
  "env_vars": env_vars if env_vars else [],
68
68
  "toolset_name": toolset.name,
69
- "is_default": toolset.is_default,
70
- "enabled": toolset.enabled,
71
69
  }
72
70
 
73
71
  example_config = toolset.get_example_config()
@@ -0,0 +1,188 @@
1
+ """Utilities for KRR (Kubernetes Resource Recommendations) data processing."""
2
+
3
+ import logging
4
+ from typing import Any, Dict
5
+
6
+
7
+ def parse_cpu(cpu_value: Any) -> float:
8
+ """Parse Kubernetes CPU value to float (in cores).
9
+
10
+ Handles:
11
+ - Numeric values (0.1, 1, etc.) - already in cores
12
+ - String values with 'm' suffix (100m = 0.1 cores)
13
+ - String numeric values ("0.5")
14
+
15
+ Args:
16
+ cpu_value: CPU value to parse (can be int, float, str, or None)
17
+
18
+ Returns:
19
+ CPU value in cores as float, or 0.0 if invalid
20
+ """
21
+ if cpu_value is None or cpu_value == "" or cpu_value == "?":
22
+ return 0.0
23
+ try:
24
+ if isinstance(cpu_value, (int, float)):
25
+ return float(cpu_value)
26
+
27
+ cpu_str = str(cpu_value).strip()
28
+ if cpu_str.endswith("m"):
29
+ return float(cpu_str[:-1]) / 1000.0
30
+ return float(cpu_str)
31
+ except (ValueError, AttributeError, TypeError):
32
+ return 0.0
33
+
34
+
35
+ def parse_memory(memory_value: Any) -> float:
36
+ """Parse Kubernetes memory value to float (in bytes).
37
+
38
+ Handles:
39
+ - Numeric values (already in bytes)
40
+ - String values with units (100Mi, 1Gi, etc.)
41
+ - String numeric values ("1048576")
42
+
43
+ Args:
44
+ memory_value: Memory value to parse (can be int, float, str, or None)
45
+
46
+ Returns:
47
+ Memory value in bytes as float, or 0.0 if invalid
48
+ """
49
+ if memory_value is None or memory_value == "" or memory_value == "?":
50
+ return 0.0
51
+ try:
52
+ if isinstance(memory_value, (int, float)):
53
+ return float(memory_value)
54
+
55
+ memory_str = str(memory_value).strip()
56
+ units = {
57
+ "Ki": 1024,
58
+ "Mi": 1024**2,
59
+ "Gi": 1024**3,
60
+ "Ti": 1024**4,
61
+ "K": 1000,
62
+ "M": 1000**2,
63
+ "G": 1000**3,
64
+ "T": 1000**4,
65
+ }
66
+ for unit, multiplier in units.items():
67
+ if memory_str.endswith(unit):
68
+ return float(memory_str[: -len(unit)]) * multiplier
69
+ return float(memory_str)
70
+ except (ValueError, AttributeError, TypeError):
71
+ return 0.0
72
+
73
+
74
+ # Helper to get numeric value from allocated/recommended, handling "?" strings
75
+ def get_value(data: Dict, field: str, subfield: str) -> Any:
76
+ if not data:
77
+ return 0.0
78
+ val = data.get(field, {}).get(subfield)
79
+ if val is None or val == "?":
80
+ return 0.0
81
+ return val
82
+
83
+
84
+ def calculate_krr_savings(result: Dict, sort_by: str) -> float:
85
+ """Calculate potential savings from KRR recommendation data.
86
+
87
+ The KRR data structure has a 'content' field that contains a list of resource
88
+ recommendations. Each item in the list represents either CPU or memory, with:
89
+ - resource: "cpu" or "memory"
90
+ - allocated: {request: value, limit: value} - current allocation
91
+ - recommended: {request: value, limit: value} - recommended allocation
92
+
93
+ Args:
94
+ result: KRR scan result dictionary with 'content' field
95
+ sort_by: Sorting criteria, one of:
96
+ - "cpu_total": Total CPU savings (requests + limits)
97
+ - "memory_total": Total memory savings (requests + limits)
98
+ - "cpu_requests": CPU requests savings only
99
+ - "memory_requests": Memory requests savings only
100
+ - "cpu_limits": CPU limits savings only
101
+ - "memory_limits": Memory limits savings only
102
+
103
+ Returns:
104
+ Calculated savings as a float (>= 0.0). Returns 0.0 for invalid data
105
+ or when recommended values are higher than allocated.
106
+ """
107
+ try:
108
+ content_list = result.get("content", [])
109
+ if not content_list or not isinstance(content_list, list):
110
+ return 0.0
111
+
112
+ cpu_data = None
113
+ memory_data = None
114
+ for item in content_list:
115
+ if item.get("resource") == "cpu":
116
+ cpu_data = item
117
+ elif item.get("resource") == "memory":
118
+ memory_data = item
119
+
120
+ if not cpu_data and not memory_data:
121
+ return 0.0
122
+
123
+ savings = 0.0
124
+
125
+ if sort_by == "cpu_total" and cpu_data:
126
+ cpu_req_allocated = parse_cpu(get_value(cpu_data, "allocated", "request"))
127
+ cpu_req_recommended = parse_cpu(
128
+ get_value(cpu_data, "recommended", "request")
129
+ )
130
+ cpu_lim_allocated = parse_cpu(get_value(cpu_data, "allocated", "limit"))
131
+ cpu_lim_recommended = parse_cpu(get_value(cpu_data, "recommended", "limit"))
132
+
133
+ savings = (cpu_req_allocated - cpu_req_recommended) + (
134
+ cpu_lim_allocated - cpu_lim_recommended
135
+ )
136
+
137
+ elif sort_by == "memory_total" and memory_data:
138
+ mem_req_allocated = parse_memory(
139
+ get_value(memory_data, "allocated", "request")
140
+ )
141
+ mem_req_recommended = parse_memory(
142
+ get_value(memory_data, "recommended", "request")
143
+ )
144
+ mem_lim_allocated = parse_memory(
145
+ get_value(memory_data, "allocated", "limit")
146
+ )
147
+ mem_lim_recommended = parse_memory(
148
+ get_value(memory_data, "recommended", "limit")
149
+ )
150
+
151
+ savings = (mem_req_allocated - mem_req_recommended) + (
152
+ mem_lim_allocated - mem_lim_recommended
153
+ )
154
+
155
+ elif sort_by == "cpu_requests" and cpu_data:
156
+ cpu_req_allocated = parse_cpu(get_value(cpu_data, "allocated", "request"))
157
+ cpu_req_recommended = parse_cpu(
158
+ get_value(cpu_data, "recommended", "request")
159
+ )
160
+ savings = cpu_req_allocated - cpu_req_recommended
161
+
162
+ elif sort_by == "memory_requests" and memory_data:
163
+ mem_req_allocated = parse_memory(
164
+ get_value(memory_data, "allocated", "request")
165
+ )
166
+ mem_req_recommended = parse_memory(
167
+ get_value(memory_data, "recommended", "request")
168
+ )
169
+ savings = mem_req_allocated - mem_req_recommended
170
+
171
+ elif sort_by == "cpu_limits" and cpu_data:
172
+ cpu_lim_allocated = parse_cpu(get_value(cpu_data, "allocated", "limit"))
173
+ cpu_lim_recommended = parse_cpu(get_value(cpu_data, "recommended", "limit"))
174
+ savings = cpu_lim_allocated - cpu_lim_recommended
175
+
176
+ elif sort_by == "memory_limits" and memory_data:
177
+ mem_lim_allocated = parse_memory(
178
+ get_value(memory_data, "allocated", "limit")
179
+ )
180
+ mem_lim_recommended = parse_memory(
181
+ get_value(memory_data, "recommended", "limit")
182
+ )
183
+ savings = mem_lim_allocated - mem_lim_recommended
184
+
185
+ return savings
186
+ except Exception as e:
187
+ logging.debug(f"Error calculating savings for result: {e}")
188
+ return 0.0
@@ -0,0 +1,41 @@
1
+ import sentry_sdk
2
+ from holmes.core.models import ToolCallResult, TruncationMetadata
3
+
4
+
5
+ def capture_tool_truncations(truncations: list[TruncationMetadata]):
6
+ for truncation in truncations:
7
+ _capture_tool_truncation(truncation)
8
+
9
+
10
+ def _capture_tool_truncation(truncation: TruncationMetadata):
11
+ sentry_sdk.capture_message(
12
+ f"Tool {truncation.tool_name} was truncated",
13
+ level="warning",
14
+ tags={
15
+ "tool_name": truncation.tool_name,
16
+ "tool_original_token_count": truncation.original_token_count,
17
+ "tool_new_token_count": truncation.end_index,
18
+ },
19
+ )
20
+
21
+
22
+ def capture_toolcall_contains_too_many_tokens(
23
+ tool_call_result: ToolCallResult, token_count: int, max_allowed_token_count: int
24
+ ):
25
+ sentry_sdk.capture_message(
26
+ f"Tool call {tool_call_result.tool_name} contains too many tokens",
27
+ level="warning",
28
+ tags={
29
+ "tool_name": tool_call_result.tool_name,
30
+ "tool_original_token_count": token_count,
31
+ "tool_max_allowed_token_count": max_allowed_token_count,
32
+ "tool_description": tool_call_result.description,
33
+ },
34
+ )
35
+
36
+
37
+ def capture_structured_output_incorrect_tool_call():
38
+ sentry_sdk.capture_message(
39
+ "Structured output incorrect tool call",
40
+ level="warning",
41
+ )
holmes/utils/stream.py CHANGED
@@ -1,10 +1,15 @@
1
1
  import json
2
2
  from enum import Enum
3
- from typing import Generator, Optional, List
3
+ from typing import Generator, Optional, List, Union
4
4
  import litellm
5
5
  from pydantic import BaseModel, Field
6
6
  from holmes.core.investigation_structured_output import process_response_into_sections
7
7
  from functools import partial
8
+ import logging
9
+ from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
10
+ from litellm.types.utils import ModelResponse, TextCompletionResponse
11
+
12
+ from holmes.core.llm import TokenCountMetadata, get_llm_usage
8
13
 
9
14
 
10
15
  class StreamEvents(str, Enum):
@@ -13,6 +18,9 @@ class StreamEvents(str, Enum):
13
18
  TOOL_RESULT = "tool_calling_result"
14
19
  ERROR = "error"
15
20
  AI_MESSAGE = "ai_message"
21
+ APPROVAL_REQUIRED = "approval_required"
22
+ TOKEN_COUNT = "token_count"
23
+ CONVERSATION_HISTORY_COMPACTED = "conversation_history_compacted"
16
24
 
17
25
 
18
26
  class StreamMessage(BaseModel):
@@ -61,6 +69,7 @@ def stream_investigate_formatter(
61
69
  "sections": sections or {},
62
70
  "analysis": text_response,
63
71
  "instructions": runbooks or [],
72
+ "metadata": message.data.get("metadata") or {},
64
73
  },
65
74
  )
66
75
  else:
@@ -76,15 +85,60 @@ def stream_chat_formatter(
76
85
  try:
77
86
  for message in call_stream:
78
87
  if message.event == StreamEvents.ANSWER_END:
88
+ response_data = {
89
+ "analysis": message.data.get("content"),
90
+ "conversation_history": message.data.get("messages"),
91
+ "follow_up_actions": followups,
92
+ "metadata": message.data.get("metadata") or {},
93
+ }
94
+
95
+ yield create_sse_message(StreamEvents.ANSWER_END.value, response_data)
96
+ elif message.event == StreamEvents.APPROVAL_REQUIRED:
97
+ response_data = {
98
+ "analysis": message.data.get("content"),
99
+ "conversation_history": message.data.get("messages"),
100
+ "follow_up_actions": followups,
101
+ }
102
+
103
+ response_data["requires_approval"] = True
104
+ response_data["pending_approvals"] = message.data.get(
105
+ "pending_approvals", []
106
+ )
107
+
79
108
  yield create_sse_message(
80
- StreamEvents.ANSWER_END.value,
81
- {
82
- "analysis": message.data.get("content"),
83
- "conversation_history": message.data.get("messages"),
84
- "follow_up_actions": followups,
85
- },
109
+ StreamEvents.APPROVAL_REQUIRED.value, response_data
86
110
  )
87
111
  else:
88
112
  yield create_sse_message(message.event.value, message.data)
89
113
  except litellm.exceptions.RateLimitError as e:
90
114
  yield create_rate_limit_error_message(str(e))
115
+ except Exception as e:
116
+ logging.error(e)
117
+ if "Model is getting throttled" in str(e): # happens for bedrock
118
+ yield create_rate_limit_error_message(str(e))
119
+ else:
120
+ yield create_sse_error_message(description=str(e), error_code=1, msg=str(e))
121
+
122
+
123
+ def add_token_count_to_metadata(
124
+ tokens: TokenCountMetadata,
125
+ metadata: dict,
126
+ max_context_size: int,
127
+ maximum_output_token: int,
128
+ full_llm_response: Union[
129
+ ModelResponse, CustomStreamWrapper, TextCompletionResponse
130
+ ],
131
+ ):
132
+ metadata["usage"] = get_llm_usage(full_llm_response)
133
+ metadata["tokens"] = tokens.model_dump()
134
+ metadata["max_tokens"] = max_context_size
135
+ metadata["max_output_tokens"] = maximum_output_token
136
+
137
+
138
+ def build_stream_event_token_count(metadata: dict) -> StreamMessage:
139
+ return StreamMessage(
140
+ event=StreamEvents.TOKEN_COUNT,
141
+ data={
142
+ "metadata": metadata,
143
+ },
144
+ )
holmes/version.py CHANGED
@@ -57,11 +57,41 @@ def get_version() -> str:
57
57
  return __version__
58
58
 
59
59
  # we are running from an unreleased dev version
60
+ archival_file_path = os.path.join(this_path, ".git_archival.json")
61
+ if os.path.exists(archival_file_path):
62
+ try:
63
+ with open(archival_file_path, "r") as f:
64
+ archival_data = json.load(f)
65
+ refs = archival_data.get("refs", "")
66
+ hash_short = archival_data.get("hash-short", "")
67
+
68
+ # Check if Git substitution didn't happen (placeholders are still present)
69
+ if "$Format:" in refs or "$Format:" in hash_short:
70
+ # Placeholders not substituted, skip to next method
71
+ pass
72
+ else:
73
+ # Valid archival data found
74
+ return f"dev-{refs}-{hash_short}"
75
+ except Exception:
76
+ pass
77
+
78
+ # Now try git commands for development environments
60
79
  try:
80
+ env = os.environ.copy()
81
+ # Set ceiling to prevent walking up beyond the project root
82
+ # We want to allow access to holmes/.git but not beyond holmes
83
+ project_root = os.path.dirname(this_path) # holmes
84
+ env["GIT_CEILING_DIRECTORIES"] = os.path.dirname(
85
+ project_root
86
+ ) # holmes's parent
87
+
61
88
  # Get the latest git tag
62
89
  tag = (
63
90
  subprocess.check_output(
64
- ["git", "describe", "--tags"], stderr=subprocess.STDOUT, cwd=this_path
91
+ ["git", "describe", "--tags"],
92
+ stderr=subprocess.STDOUT,
93
+ cwd=this_path,
94
+ env=env,
65
95
  )
66
96
  .decode()
67
97
  .strip()
@@ -73,6 +103,7 @@ def get_version() -> str:
73
103
  ["git", "rev-parse", "--abbrev-ref", "HEAD"],
74
104
  stderr=subprocess.STDOUT,
75
105
  cwd=this_path,
106
+ env=env,
76
107
  )
77
108
  .decode()
78
109
  .strip()
@@ -84,6 +115,7 @@ def get_version() -> str:
84
115
  ["git", "status", "--porcelain"],
85
116
  stderr=subprocess.STDOUT,
86
117
  cwd=this_path,
118
+ env=env,
87
119
  )
88
120
  .decode()
89
121
  .strip()
@@ -95,19 +127,7 @@ def get_version() -> str:
95
127
  except Exception:
96
128
  pass
97
129
 
98
- # we are running without git history, but we still might have git archival data (e.g. if we were pip installed)
99
- archival_file_path = os.path.join(this_path, ".git_archival.json")
100
- if os.path.exists(archival_file_path):
101
- try:
102
- with open(archival_file_path, "r") as f:
103
- archival_data = json.load(f)
104
- return f"dev-{archival_data['refs']}-{archival_data['hash-short']}"
105
- except Exception:
106
- pass
107
-
108
- return "dev-version"
109
-
110
- return "unknown-version"
130
+ return "dev-unknown"
111
131
 
112
132
 
113
133
  @cache