holmesgpt 0.13.2__py3-none-any.whl → 0.18.4__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 (188) hide show
  1. holmes/__init__.py +3 -5
  2. holmes/clients/robusta_client.py +20 -6
  3. holmes/common/env_vars.py +58 -3
  4. holmes/common/openshift.py +1 -1
  5. holmes/config.py +123 -148
  6. holmes/core/conversations.py +71 -15
  7. holmes/core/feedback.py +191 -0
  8. holmes/core/investigation.py +31 -39
  9. holmes/core/investigation_structured_output.py +3 -3
  10. holmes/core/issue.py +1 -1
  11. holmes/core/llm.py +508 -88
  12. holmes/core/models.py +108 -4
  13. holmes/core/openai_formatting.py +14 -1
  14. holmes/core/prompt.py +48 -3
  15. holmes/core/runbooks.py +1 -0
  16. holmes/core/safeguards.py +8 -6
  17. holmes/core/supabase_dal.py +295 -100
  18. holmes/core/tool_calling_llm.py +489 -428
  19. holmes/core/tools.py +325 -56
  20. holmes/core/tools_utils/token_counting.py +21 -0
  21. holmes/core/tools_utils/tool_context_window_limiter.py +40 -0
  22. holmes/core/tools_utils/tool_executor.py +0 -13
  23. holmes/core/tools_utils/toolset_utils.py +1 -0
  24. holmes/core/toolset_manager.py +191 -5
  25. holmes/core/tracing.py +19 -3
  26. holmes/core/transformers/__init__.py +23 -0
  27. holmes/core/transformers/base.py +63 -0
  28. holmes/core/transformers/llm_summarize.py +175 -0
  29. holmes/core/transformers/registry.py +123 -0
  30. holmes/core/transformers/transformer.py +32 -0
  31. holmes/core/truncation/compaction.py +94 -0
  32. holmes/core/truncation/dal_truncation_utils.py +23 -0
  33. holmes/core/truncation/input_context_window_limiter.py +219 -0
  34. holmes/interactive.py +228 -31
  35. holmes/main.py +23 -40
  36. holmes/plugins/interfaces.py +2 -1
  37. holmes/plugins/prompts/__init__.py +2 -1
  38. holmes/plugins/prompts/_fetch_logs.jinja2 +31 -6
  39. holmes/plugins/prompts/_general_instructions.jinja2 +1 -2
  40. holmes/plugins/prompts/_runbook_instructions.jinja2 +24 -12
  41. holmes/plugins/prompts/base_user_prompt.jinja2 +7 -0
  42. holmes/plugins/prompts/conversation_history_compaction.jinja2 +89 -0
  43. holmes/plugins/prompts/generic_ask.jinja2 +0 -4
  44. holmes/plugins/prompts/generic_ask_conversation.jinja2 +0 -1
  45. holmes/plugins/prompts/generic_ask_for_issue_conversation.jinja2 +0 -1
  46. holmes/plugins/prompts/generic_investigation.jinja2 +0 -1
  47. holmes/plugins/prompts/investigation_procedure.jinja2 +50 -1
  48. holmes/plugins/prompts/kubernetes_workload_ask.jinja2 +0 -1
  49. holmes/plugins/prompts/kubernetes_workload_chat.jinja2 +0 -1
  50. holmes/plugins/runbooks/__init__.py +145 -17
  51. holmes/plugins/runbooks/catalog.json +2 -0
  52. holmes/plugins/sources/github/__init__.py +4 -2
  53. holmes/plugins/sources/prometheus/models.py +1 -0
  54. holmes/plugins/toolsets/__init__.py +44 -27
  55. holmes/plugins/toolsets/aks-node-health.yaml +46 -0
  56. holmes/plugins/toolsets/aks.yaml +64 -0
  57. holmes/plugins/toolsets/atlas_mongodb/mongodb_atlas.py +38 -47
  58. holmes/plugins/toolsets/azure_sql/apis/alert_monitoring_api.py +3 -2
  59. holmes/plugins/toolsets/azure_sql/apis/azure_sql_api.py +2 -1
  60. holmes/plugins/toolsets/azure_sql/apis/connection_failure_api.py +3 -2
  61. holmes/plugins/toolsets/azure_sql/apis/connection_monitoring_api.py +3 -1
  62. holmes/plugins/toolsets/azure_sql/apis/storage_analysis_api.py +3 -1
  63. holmes/plugins/toolsets/azure_sql/azure_sql_toolset.py +12 -13
  64. holmes/plugins/toolsets/azure_sql/tools/analyze_connection_failures.py +15 -12
  65. holmes/plugins/toolsets/azure_sql/tools/analyze_database_connections.py +15 -12
  66. holmes/plugins/toolsets/azure_sql/tools/analyze_database_health_status.py +11 -11
  67. holmes/plugins/toolsets/azure_sql/tools/analyze_database_performance.py +11 -9
  68. holmes/plugins/toolsets/azure_sql/tools/analyze_database_storage.py +15 -12
  69. holmes/plugins/toolsets/azure_sql/tools/get_active_alerts.py +15 -15
  70. holmes/plugins/toolsets/azure_sql/tools/get_slow_queries.py +11 -8
  71. holmes/plugins/toolsets/azure_sql/tools/get_top_cpu_queries.py +11 -8
  72. holmes/plugins/toolsets/azure_sql/tools/get_top_data_io_queries.py +11 -8
  73. holmes/plugins/toolsets/azure_sql/tools/get_top_log_io_queries.py +11 -8
  74. holmes/plugins/toolsets/azure_sql/utils.py +0 -32
  75. holmes/plugins/toolsets/bash/argocd/__init__.py +3 -3
  76. holmes/plugins/toolsets/bash/aws/__init__.py +4 -4
  77. holmes/plugins/toolsets/bash/azure/__init__.py +4 -4
  78. holmes/plugins/toolsets/bash/bash_toolset.py +11 -15
  79. holmes/plugins/toolsets/bash/common/bash.py +23 -13
  80. holmes/plugins/toolsets/bash/common/bash_command.py +1 -1
  81. holmes/plugins/toolsets/bash/common/stringify.py +1 -1
  82. holmes/plugins/toolsets/bash/kubectl/__init__.py +2 -1
  83. holmes/plugins/toolsets/bash/kubectl/constants.py +0 -1
  84. holmes/plugins/toolsets/bash/kubectl/kubectl_get.py +3 -4
  85. holmes/plugins/toolsets/bash/parse_command.py +12 -13
  86. holmes/plugins/toolsets/cilium.yaml +284 -0
  87. holmes/plugins/toolsets/connectivity_check.py +124 -0
  88. holmes/plugins/toolsets/coralogix/api.py +132 -119
  89. holmes/plugins/toolsets/coralogix/coralogix.jinja2 +14 -0
  90. holmes/plugins/toolsets/coralogix/toolset_coralogix.py +219 -0
  91. holmes/plugins/toolsets/coralogix/utils.py +15 -79
  92. holmes/plugins/toolsets/datadog/datadog_api.py +525 -26
  93. holmes/plugins/toolsets/datadog/datadog_logs_instructions.jinja2 +55 -11
  94. holmes/plugins/toolsets/datadog/datadog_metrics_instructions.jinja2 +3 -3
  95. holmes/plugins/toolsets/datadog/datadog_models.py +59 -0
  96. holmes/plugins/toolsets/datadog/datadog_url_utils.py +213 -0
  97. holmes/plugins/toolsets/datadog/instructions_datadog_traces.jinja2 +165 -28
  98. holmes/plugins/toolsets/datadog/toolset_datadog_general.py +417 -241
  99. holmes/plugins/toolsets/datadog/toolset_datadog_logs.py +234 -214
  100. holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py +167 -79
  101. holmes/plugins/toolsets/datadog/toolset_datadog_traces.py +374 -363
  102. holmes/plugins/toolsets/elasticsearch/__init__.py +6 -0
  103. holmes/plugins/toolsets/elasticsearch/elasticsearch.py +834 -0
  104. holmes/plugins/toolsets/elasticsearch/opensearch_ppl_query_docs.jinja2 +1616 -0
  105. holmes/plugins/toolsets/elasticsearch/opensearch_query_assist.py +78 -0
  106. holmes/plugins/toolsets/elasticsearch/opensearch_query_assist_instructions.jinja2 +223 -0
  107. holmes/plugins/toolsets/git.py +54 -50
  108. holmes/plugins/toolsets/grafana/base_grafana_toolset.py +16 -4
  109. holmes/plugins/toolsets/grafana/common.py +13 -29
  110. holmes/plugins/toolsets/grafana/grafana_tempo_api.py +455 -0
  111. holmes/plugins/toolsets/grafana/loki/instructions.jinja2 +25 -0
  112. holmes/plugins/toolsets/grafana/loki/toolset_grafana_loki.py +191 -0
  113. holmes/plugins/toolsets/grafana/loki_api.py +4 -0
  114. holmes/plugins/toolsets/grafana/toolset_grafana.py +293 -89
  115. holmes/plugins/toolsets/grafana/toolset_grafana_dashboard.jinja2 +49 -0
  116. holmes/plugins/toolsets/grafana/toolset_grafana_tempo.jinja2 +246 -11
  117. holmes/plugins/toolsets/grafana/toolset_grafana_tempo.py +820 -292
  118. holmes/plugins/toolsets/grafana/trace_parser.py +4 -3
  119. holmes/plugins/toolsets/internet/internet.py +15 -16
  120. holmes/plugins/toolsets/internet/notion.py +9 -11
  121. holmes/plugins/toolsets/investigator/core_investigation.py +44 -36
  122. holmes/plugins/toolsets/investigator/model.py +3 -1
  123. holmes/plugins/toolsets/json_filter_mixin.py +134 -0
  124. holmes/plugins/toolsets/kafka.py +36 -42
  125. holmes/plugins/toolsets/kubernetes.yaml +317 -113
  126. holmes/plugins/toolsets/kubernetes_logs.py +9 -9
  127. holmes/plugins/toolsets/kubernetes_logs.yaml +32 -0
  128. holmes/plugins/toolsets/logging_utils/logging_api.py +94 -8
  129. holmes/plugins/toolsets/mcp/toolset_mcp.py +218 -64
  130. holmes/plugins/toolsets/newrelic/new_relic_api.py +165 -0
  131. holmes/plugins/toolsets/newrelic/newrelic.jinja2 +65 -0
  132. holmes/plugins/toolsets/newrelic/newrelic.py +320 -0
  133. holmes/plugins/toolsets/openshift.yaml +283 -0
  134. holmes/plugins/toolsets/prometheus/prometheus.py +1202 -421
  135. holmes/plugins/toolsets/prometheus/prometheus_instructions.jinja2 +54 -5
  136. holmes/plugins/toolsets/prometheus/utils.py +28 -0
  137. holmes/plugins/toolsets/rabbitmq/api.py +23 -4
  138. holmes/plugins/toolsets/rabbitmq/toolset_rabbitmq.py +13 -14
  139. holmes/plugins/toolsets/robusta/robusta.py +239 -68
  140. holmes/plugins/toolsets/robusta/robusta_instructions.jinja2 +26 -9
  141. holmes/plugins/toolsets/runbook/runbook_fetcher.py +157 -27
  142. holmes/plugins/toolsets/service_discovery.py +1 -1
  143. holmes/plugins/toolsets/servicenow_tables/instructions.jinja2 +83 -0
  144. holmes/plugins/toolsets/servicenow_tables/servicenow_tables.py +426 -0
  145. holmes/plugins/toolsets/utils.py +88 -0
  146. holmes/utils/config_utils.py +91 -0
  147. holmes/utils/connection_utils.py +31 -0
  148. holmes/utils/console/result.py +10 -0
  149. holmes/utils/default_toolset_installation_guide.jinja2 +1 -22
  150. holmes/utils/env.py +7 -0
  151. holmes/utils/file_utils.py +2 -1
  152. holmes/utils/global_instructions.py +60 -11
  153. holmes/utils/holmes_status.py +6 -4
  154. holmes/utils/holmes_sync_toolsets.py +0 -2
  155. holmes/utils/krr_utils.py +188 -0
  156. holmes/utils/log.py +15 -0
  157. holmes/utils/markdown_utils.py +2 -3
  158. holmes/utils/memory_limit.py +58 -0
  159. holmes/utils/sentry_helper.py +64 -0
  160. holmes/utils/stream.py +69 -8
  161. holmes/utils/tags.py +4 -3
  162. holmes/version.py +37 -15
  163. holmesgpt-0.18.4.dist-info/LICENSE +178 -0
  164. {holmesgpt-0.13.2.dist-info → holmesgpt-0.18.4.dist-info}/METADATA +35 -31
  165. holmesgpt-0.18.4.dist-info/RECORD +258 -0
  166. holmes/core/performance_timing.py +0 -72
  167. holmes/plugins/toolsets/aws.yaml +0 -80
  168. holmes/plugins/toolsets/coralogix/toolset_coralogix_logs.py +0 -112
  169. holmes/plugins/toolsets/datadog/datadog_traces_formatter.py +0 -310
  170. holmes/plugins/toolsets/datadog/toolset_datadog_rds.py +0 -739
  171. holmes/plugins/toolsets/grafana/grafana_api.py +0 -42
  172. holmes/plugins/toolsets/grafana/tempo_api.py +0 -124
  173. holmes/plugins/toolsets/grafana/toolset_grafana_loki.py +0 -110
  174. holmes/plugins/toolsets/newrelic.py +0 -231
  175. holmes/plugins/toolsets/opensearch/opensearch.py +0 -257
  176. holmes/plugins/toolsets/opensearch/opensearch_logs.py +0 -161
  177. holmes/plugins/toolsets/opensearch/opensearch_traces.py +0 -218
  178. holmes/plugins/toolsets/opensearch/opensearch_traces_instructions.jinja2 +0 -12
  179. holmes/plugins/toolsets/opensearch/opensearch_utils.py +0 -166
  180. holmes/plugins/toolsets/servicenow/install.md +0 -37
  181. holmes/plugins/toolsets/servicenow/instructions.jinja2 +0 -3
  182. holmes/plugins/toolsets/servicenow/servicenow.py +0 -219
  183. holmes/utils/keygen_utils.py +0 -6
  184. holmesgpt-0.13.2.dist-info/LICENSE.txt +0 -21
  185. holmesgpt-0.13.2.dist-info/RECORD +0 -234
  186. /holmes/plugins/toolsets/{opensearch → newrelic}/__init__.py +0 -0
  187. {holmesgpt-0.13.2.dist-info → holmesgpt-0.18.4.dist-info}/WHEEL +0 -0
  188. {holmesgpt-0.13.2.dist-info → holmesgpt-0.18.4.dist-info}/entry_points.txt +0 -0
holmes/interactive.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  import os
3
+ import re
3
4
  import subprocess
4
5
  import tempfile
5
6
  import threading
@@ -26,11 +27,18 @@ from prompt_toolkit.widgets import TextArea
26
27
  from pygments.lexers import guess_lexer
27
28
  from rich.console import Console
28
29
  from rich.markdown import Markdown, Panel
30
+ from rich.markup import escape
29
31
 
30
32
  from holmes.common.env_vars import ENABLE_CLI_TOOL_APPROVAL
31
33
  from holmes.core.config import config_path_dir
34
+ from holmes.core.feedback import (
35
+ PRIVACY_NOTICE_BANNER,
36
+ Feedback,
37
+ FeedbackCallback,
38
+ UserFeedback,
39
+ )
32
40
  from holmes.core.prompt import build_initial_ask_messages
33
- from holmes.core.tool_calling_llm import ToolCallingLLM, ToolCallResult
41
+ from holmes.core.tool_calling_llm import LLMResult, ToolCallingLLM, ToolCallResult
34
42
  from holmes.core.tools import StructuredToolResult, pretty_print_toolset_status
35
43
  from holmes.core.tracing import DummyTracer
36
44
  from holmes.utils.colors import (
@@ -42,6 +50,7 @@ from holmes.utils.colors import (
42
50
  USER_COLOR,
43
51
  )
44
52
  from holmes.utils.console.consts import agent_name
53
+ from holmes.utils.file_utils import write_json_file
45
54
  from holmes.version import check_version_async
46
55
 
47
56
 
@@ -62,19 +71,25 @@ class SlashCommands(Enum):
62
71
  )
63
72
  CONTEXT = ("/context", "Show conversation context size and token count")
64
73
  SHOW = ("/show", "Show specific tool output in scrollable view")
74
+ FEEDBACK = ("/feedback", "Provide feedback on the agent's response")
65
75
 
66
76
  def __init__(self, command, description):
67
77
  self.command = command
68
78
  self.description = description
69
79
 
70
80
 
71
- SLASH_COMMANDS_REFERENCE = {cmd.command: cmd.description for cmd in SlashCommands}
72
- ALL_SLASH_COMMANDS = [cmd.command for cmd in SlashCommands]
73
-
74
-
75
81
  class SlashCommandCompleter(Completer):
76
- def __init__(self):
77
- self.commands = SLASH_COMMANDS_REFERENCE
82
+ def __init__(self, unsupported_commands: Optional[List[str]] = None):
83
+ # Build commands dictionary, excluding unsupported commands
84
+ all_commands = {cmd.command: cmd.description for cmd in SlashCommands}
85
+ if unsupported_commands:
86
+ self.commands = {
87
+ cmd: desc
88
+ for cmd, desc in all_commands.items()
89
+ if cmd not in unsupported_commands
90
+ }
91
+ else:
92
+ self.commands = all_commands
78
93
 
79
94
  def get_completions(self, document, complete_event):
80
95
  text = document.text_before_cursor
@@ -233,6 +248,13 @@ def build_modal_title(tool_call: ToolCallResult, wrap_status: str) -> str:
233
248
  return f"{tool_call.description} (exit: q, nav: ↑↓/j/k/g/G/d/u/f/b/space, wrap: w [{wrap_status}])"
234
249
 
235
250
 
251
+ def strip_ansi_codes(text: str) -> str:
252
+ ansi_escape_pattern = re.compile(
253
+ r"\x1b\[[0-9;]*[a-zA-Z]|\033\[[0-9;]*[a-zA-Z]|\^\[\[[0-9;]*[a-zA-Z]"
254
+ )
255
+ return ansi_escape_pattern.sub("", text)
256
+
257
+
236
258
  def detect_lexer(content: str) -> Optional[PygmentsLexer]:
237
259
  """
238
260
  Detect appropriate lexer for content using Pygments' built-in detection.
@@ -314,6 +336,7 @@ def show_tool_output_modal(tool_call: ToolCallResult, console: Console) -> None:
314
336
  try:
315
337
  # Get the full output
316
338
  output = tool_call.result.get_stringified_data()
339
+ output = strip_ansi_codes(output)
317
340
  title = build_modal_title(tool_call, "off") # Word wrap starts disabled
318
341
 
319
342
  # Detect appropriate syntax highlighting
@@ -467,10 +490,14 @@ def handle_context_command(messages, ai: ToolCallingLLM, console: Console) -> No
467
490
  return
468
491
 
469
492
  # Calculate context statistics
470
- total_tokens = ai.llm.count_tokens_for_message(messages)
493
+ tokens_metadata = ai.llm.count_tokens(
494
+ messages
495
+ ) # TODO: pass tools to also count tokens used by input tools
471
496
  max_context_size = ai.llm.get_context_window_size()
472
497
  max_output_tokens = ai.llm.get_maximum_output_token()
473
- available_tokens = max_context_size - total_tokens - max_output_tokens
498
+ available_tokens = (
499
+ max_context_size - tokens_metadata.total_tokens - max_output_tokens
500
+ )
474
501
 
475
502
  # Analyze token distribution by role and tool calls
476
503
  role_token_usage: DefaultDict[str, int] = defaultdict(int)
@@ -479,19 +506,21 @@ def handle_context_command(messages, ai: ToolCallingLLM, console: Console) -> No
479
506
 
480
507
  for msg in messages:
481
508
  role = msg.get("role", "unknown")
482
- msg_tokens = ai.llm.count_tokens_for_message([msg])
483
- role_token_usage[role] += msg_tokens
509
+ message_tokens = ai.llm.count_tokens(
510
+ [msg]
511
+ ) # TODO: pass tools to also count tokens used by input tools
512
+ role_token_usage[role] += message_tokens.total_tokens
484
513
 
485
514
  # Track individual tool usage
486
515
  if role == "tool":
487
516
  tool_name = msg.get("name", "unknown_tool")
488
- tool_token_usage[tool_name] += msg_tokens
517
+ tool_token_usage[tool_name] += message_tokens.total_tokens
489
518
  tool_call_counts[tool_name] += 1
490
519
 
491
520
  # Display context information
492
521
  console.print(f"[bold {STATUS_COLOR}]Conversation Context:[/bold {STATUS_COLOR}]")
493
522
  console.print(
494
- f" Context used: {total_tokens:,} / {max_context_size:,} tokens ({(total_tokens / max_context_size) * 100:.1f}%)"
523
+ f" Context used: {tokens_metadata.total_tokens:,} / {max_context_size:,} tokens ({(tokens_metadata.total_tokens / max_context_size) * 100:.1f}%)"
495
524
  )
496
525
  console.print(
497
526
  f" Space remaining: {available_tokens:,} for input ({(available_tokens / max_context_size) * 100:.1f}%) + {max_output_tokens:,} reserved for output ({(max_output_tokens / max_context_size) * 100:.1f}%)"
@@ -502,7 +531,11 @@ def handle_context_command(messages, ai: ToolCallingLLM, console: Console) -> No
502
531
  for role in ["system", "user", "assistant", "tool"]:
503
532
  if role in role_token_usage:
504
533
  tokens = role_token_usage[role]
505
- percentage = (tokens / total_tokens) * 100 if total_tokens > 0 else 0
534
+ percentage = (
535
+ (tokens / tokens_metadata.total_tokens) * 100
536
+ if tokens_metadata.total_tokens > 0
537
+ else 0
538
+ )
506
539
  role_name = {
507
540
  "system": "system prompt",
508
541
  "user": "user messages",
@@ -811,6 +844,88 @@ def handle_last_command(
811
844
  )
812
845
 
813
846
 
847
+ def handle_feedback_command(
848
+ style: Style,
849
+ console: Console,
850
+ feedback: Feedback,
851
+ feedback_callback: FeedbackCallback,
852
+ ) -> None:
853
+ """Handle the /feedback command to collect user feedback."""
854
+ try:
855
+ # Create a temporary session without history for feedback prompts
856
+ temp_session = PromptSession(history=InMemoryHistory()) # type: ignore
857
+ # Prominent privacy notice to users
858
+ console.print(
859
+ f"[bold {HELP_COLOR}]Privacy Notice:[/bold {HELP_COLOR}] {PRIVACY_NOTICE_BANNER}"
860
+ )
861
+ # A "Cancel" button of equal discoverability to "Sent" or "Submit" buttons must be made available
862
+ console.print(
863
+ "[bold yellow]💡 Tip: Press Ctrl+C at any time to cancel feedback[/bold yellow]"
864
+ )
865
+
866
+ # Ask for thumbs up/down rating with validation
867
+ while True:
868
+ rating_prompt = temp_session.prompt(
869
+ [("class:prompt", "Was this response useful to you? 👍(y)/👎(n): ")],
870
+ style=style,
871
+ )
872
+
873
+ rating_lower = rating_prompt.lower().strip()
874
+ if rating_lower in ["y", "n"]:
875
+ break
876
+ else:
877
+ console.print(
878
+ "[bold red]Please enter only 'y' for yes or 'n' for no.[/bold red]"
879
+ )
880
+
881
+ # Determine rating
882
+ is_positive = rating_lower == "y"
883
+
884
+ # Ask for additional comments
885
+ comment_prompt = temp_session.prompt(
886
+ [
887
+ (
888
+ "class:prompt",
889
+ "Do you want to provide any additional comments for feedback? (press Enter to skip):\n",
890
+ )
891
+ ],
892
+ style=style,
893
+ )
894
+
895
+ comment = comment_prompt.strip() if comment_prompt.strip() else None
896
+
897
+ # Create UserFeedback object
898
+ user_feedback = UserFeedback(is_positive, comment)
899
+
900
+ if comment:
901
+ console.print(
902
+ f'[bold green]✓ Feedback recorded (rating={user_feedback.rating_emoji}, "{escape(comment)}")[/bold green]'
903
+ )
904
+ else:
905
+ console.print(
906
+ f"[bold green]✓ Feedback recorded (rating={user_feedback.rating_emoji}, no comment)[/bold green]"
907
+ )
908
+
909
+ # Final confirmation before submitting
910
+ final_confirmation = temp_session.prompt(
911
+ [("class:prompt", "\nDo you want to submit this feedback? (Y/n): ")],
912
+ style=style,
913
+ )
914
+
915
+ # If user says no, cancel the feedback
916
+ if final_confirmation.lower().strip().startswith("n"):
917
+ console.print("[dim]Feedback cancelled.[/dim]")
918
+ return
919
+
920
+ feedback.user_feedback = user_feedback
921
+ feedback_callback(feedback)
922
+ console.print("[bold green]Thank you for your feedback! 🙏[/bold green]")
923
+
924
+ except KeyboardInterrupt:
925
+ console.print("[dim]Feedback cancelled.[/dim]")
926
+ return
927
+
928
+
814
929
  def display_recent_tool_outputs(
815
930
  tool_calls: List[ToolCallResult],
816
931
  console: Console,
@@ -823,7 +938,10 @@ def display_recent_tool_outputs(
823
938
  for tool_call in tool_calls:
824
939
  tool_index = find_tool_index_in_history(tool_call, all_tool_calls_history)
825
940
  preview_output = format_tool_call_output(tool_call, tool_index)
826
- title = f"{tool_call.result.status.to_emoji()} {tool_call.description} -> returned {tool_call.result.return_code}"
941
+ title = (
942
+ f"{tool_call.result.status.to_emoji()} {tool_call.description} -> "
943
+ f"returned {tool_call.result.return_code}"
944
+ )
827
945
 
828
946
  console.print(
829
947
  Panel(
@@ -835,17 +953,51 @@ def display_recent_tool_outputs(
835
953
  )
836
954
 
837
955
 
956
+ def save_conversation_to_file(
957
+ json_output_file: str,
958
+ messages: List,
959
+ all_tool_calls_history: List[ToolCallResult],
960
+ console: Console,
961
+ ) -> None:
962
+ """Save the current conversation to a JSON file."""
963
+ try:
964
+ # Create LLMResult-like structure for consistency with non-interactive mode
965
+ conversation_result = LLMResult(
966
+ messages=messages,
967
+ tool_calls=all_tool_calls_history,
968
+ result=None, # No single result in interactive mode
969
+ total_cost=0.0, # TODO: Could aggregate costs from all responses if needed
970
+ total_tokens=0,
971
+ prompt_tokens=0,
972
+ completion_tokens=0,
973
+ metadata={
974
+ "session_type": "interactive",
975
+ "total_turns": len([m for m in messages if m.get("role") == "user"]),
976
+ },
977
+ )
978
+ write_json_file(json_output_file, conversation_result.model_dump())
979
+ console.print(
980
+ f"[bold {STATUS_COLOR}]Conversation saved to {json_output_file}[/bold {STATUS_COLOR}]"
981
+ )
982
+ except Exception as e:
983
+ logging.error(f"Failed to save conversation: {e}", exc_info=e)
984
+ console.print(
985
+ f"[bold {ERROR_COLOR}]Failed to save conversation: {e}[/bold {ERROR_COLOR}]"
986
+ )
987
+
988
+
838
989
  def run_interactive_loop(
839
990
  ai: ToolCallingLLM,
840
991
  console: Console,
841
992
  initial_user_input: Optional[str],
842
993
  include_files: Optional[List[Path]],
843
- post_processing_prompt: Optional[str],
844
994
  show_tool_output: bool,
845
995
  tracer=None,
846
996
  runbooks=None,
847
997
  system_prompt_additions: Optional[str] = None,
848
998
  check_version: bool = True,
999
+ feedback_callback: Optional[FeedbackCallback] = None,
1000
+ json_output_file: Optional[str] = None,
849
1001
  ) -> None:
850
1002
  # Initialize tracer - use DummyTracer if no tracer provided
851
1003
  if tracer is None:
@@ -874,7 +1026,11 @@ def run_interactive_loop(
874
1026
  ai.approval_callback = approval_handler
875
1027
 
876
1028
  # Create merged completer with slash commands, conditional executables, show command, and smart paths
877
- slash_completer = SlashCommandCompleter()
1029
+ # TODO: remove unsupported_commands support once we implement feedback callback
1030
+ unsupported_commands = []
1031
+ if feedback_callback is None:
1032
+ unsupported_commands.append(SlashCommands.FEEDBACK.command)
1033
+ slash_completer = SlashCommandCompleter(unsupported_commands)
878
1034
  executable_completer = ConditionalExecutableCompleter()
879
1035
  show_completer = ShowCommandCompleter()
880
1036
  path_completer = SmartPathCompleter()
@@ -891,6 +1047,9 @@ def run_interactive_loop(
891
1047
  if initial_user_input:
892
1048
  history.append_string(initial_user_input)
893
1049
 
1050
+ feedback = Feedback()
1051
+ feedback.metadata.update_llm(ai.llm)
1052
+
894
1053
  # Create custom key bindings for Ctrl+C behavior
895
1054
  bindings = KeyBindings()
896
1055
  status_message = ""
@@ -963,7 +1122,15 @@ def run_interactive_loop(
963
1122
 
964
1123
  input_prompt = [("class:prompt", "User: ")]
965
1124
 
966
- console.print(WELCOME_BANNER)
1125
+ # TODO: merge the /feedback command description to WELCOME_BANNER once we implement feedback callback
1126
+ welcome_banner = WELCOME_BANNER
1127
+ if feedback_callback:
1128
+ welcome_banner = (
1129
+ welcome_banner.rstrip(".")
1130
+ + f", '{SlashCommands.FEEDBACK.command}' to share your thoughts."
1131
+ )
1132
+ console.print(welcome_banner)
1133
+
967
1134
  if initial_user_input:
968
1135
  console.print(
969
1136
  f"[bold {USER_COLOR}]User:[/bold {USER_COLOR}] {initial_user_input}"
@@ -985,30 +1152,44 @@ def run_interactive_loop(
985
1152
  if user_input.startswith("/"):
986
1153
  original_input = user_input.strip()
987
1154
  command = original_input.lower()
988
-
989
1155
  # Handle prefix matching for slash commands
990
- matches = [cmd for cmd in ALL_SLASH_COMMANDS if cmd.startswith(command)]
1156
+ matches = [
1157
+ cmd
1158
+ for cmd in slash_completer.commands.keys()
1159
+ if cmd.startswith(command)
1160
+ ]
991
1161
  if len(matches) == 1:
992
1162
  command = matches[0]
993
1163
  elif len(matches) > 1:
994
1164
  console.print(
995
- f"[bold {ERROR_COLOR}]Ambiguous command '{command}'. Matches: {', '.join(matches)}[/bold {ERROR_COLOR}]"
1165
+ f"[bold {ERROR_COLOR}]Ambiguous command '{command}'. "
1166
+ f"Matches: {', '.join(matches)}[/bold {ERROR_COLOR}]"
996
1167
  )
997
1168
  continue
998
1169
 
999
1170
  if command == SlashCommands.EXIT.command:
1171
+ console.print(
1172
+ f"[bold {STATUS_COLOR}]Exiting interactive mode.[/bold {STATUS_COLOR}]"
1173
+ )
1000
1174
  return
1001
1175
  elif command == SlashCommands.HELP.command:
1002
1176
  console.print(
1003
1177
  f"[bold {HELP_COLOR}]Available commands:[/bold {HELP_COLOR}]"
1004
1178
  )
1005
- for cmd, description in SLASH_COMMANDS_REFERENCE.items():
1179
+ for cmd, description in slash_completer.commands.items():
1180
+ # Only show feedback command if callback is available
1181
+ if (
1182
+ cmd == SlashCommands.FEEDBACK.command
1183
+ and feedback_callback is None
1184
+ ):
1185
+ continue
1006
1186
  console.print(f" [bold]{cmd}[/bold] - {description}")
1007
1187
  continue
1008
1188
  elif command == SlashCommands.CLEAR.command:
1009
1189
  console.clear()
1010
1190
  console.print(
1011
- f"[bold {STATUS_COLOR}]Screen cleared and context reset. You can now ask a new question.[/bold {STATUS_COLOR}]"
1191
+ f"[bold {STATUS_COLOR}]Screen cleared and context reset. "
1192
+ f"You can now ask a new question.[/bold {STATUS_COLOR}]"
1012
1193
  )
1013
1194
  messages = None
1014
1195
  last_response = None
@@ -1052,6 +1233,12 @@ def run_interactive_loop(
1052
1233
  if shared_input is None:
1053
1234
  continue # User chose not to share or no output, continue to next input
1054
1235
  user_input = shared_input
1236
+ elif (
1237
+ command == SlashCommands.FEEDBACK.command
1238
+ and feedback_callback is not None
1239
+ ):
1240
+ handle_feedback_command(style, console, feedback, feedback_callback)
1241
+ continue
1055
1242
  else:
1056
1243
  console.print(f"Unknown command: {command}")
1057
1244
  continue
@@ -1080,7 +1267,6 @@ def run_interactive_loop(
1080
1267
  )
1081
1268
  response = ai.call(
1082
1269
  messages,
1083
- post_processing_prompt,
1084
1270
  trace_span=trace_span,
1085
1271
  tool_number_offset=len(all_tool_calls_history),
1086
1272
  )
@@ -1091,6 +1277,7 @@ def run_interactive_loop(
1091
1277
 
1092
1278
  messages = response.messages # type: ignore
1093
1279
  last_response = response
1280
+ feedback.metadata.add_llm_response(user_input, response.result)
1094
1281
 
1095
1282
  if response.tool_calls:
1096
1283
  all_tool_calls_history.extend(response.tool_calls)
@@ -1111,18 +1298,28 @@ def run_interactive_loop(
1111
1298
  )
1112
1299
  )
1113
1300
 
1114
- if trace_url:
1115
- console.print(f"🔍 View trace: {trace_url}")
1116
-
1117
1301
  console.print("")
1302
+
1303
+ # Save conversation after each AI response
1304
+ if json_output_file and messages:
1305
+ save_conversation_to_file(
1306
+ json_output_file, messages, all_tool_calls_history, console
1307
+ )
1118
1308
  except typer.Abort:
1309
+ console.print(
1310
+ f"[bold {STATUS_COLOR}]Exiting interactive mode.[/bold {STATUS_COLOR}]"
1311
+ )
1119
1312
  break
1120
1313
  except EOFError: # Handle Ctrl+D
1314
+ console.print(
1315
+ f"[bold {STATUS_COLOR}]Exiting interactive mode.[/bold {STATUS_COLOR}]"
1316
+ )
1121
1317
  break
1122
1318
  except Exception as e:
1123
1319
  logging.error("An error occurred during interactive mode:", exc_info=e)
1124
1320
  console.print(f"[bold {ERROR_COLOR}]Error: {e}[/bold {ERROR_COLOR}]")
1125
-
1126
- console.print(
1127
- f"[bold {STATUS_COLOR}]Exiting interactive mode.[/bold {STATUS_COLOR}]"
1128
- )
1321
+ finally:
1322
+ # Print trace URL for debugging (works for both success and error cases)
1323
+ trace_url = tracer.get_trace_url()
1324
+ if trace_url:
1325
+ console.print(f"🔍 View trace: {trace_url}")