holmesgpt 0.11.5__py3-none-any.whl → 0.12.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (40) hide show
  1. holmes/__init__.py +1 -1
  2. holmes/common/env_vars.py +8 -4
  3. holmes/config.py +52 -13
  4. holmes/core/investigation_structured_output.py +7 -0
  5. holmes/core/llm.py +14 -4
  6. holmes/core/models.py +24 -0
  7. holmes/core/tool_calling_llm.py +48 -6
  8. holmes/core/tools.py +7 -4
  9. holmes/core/toolset_manager.py +24 -5
  10. holmes/core/tracing.py +224 -0
  11. holmes/interactive.py +761 -44
  12. holmes/main.py +59 -127
  13. holmes/plugins/prompts/_fetch_logs.jinja2 +4 -0
  14. holmes/plugins/prompts/kubernetes_workload_ask.jinja2 +2 -10
  15. holmes/plugins/toolsets/__init__.py +10 -2
  16. holmes/plugins/toolsets/azure_sql/apis/azure_sql_api.py +2 -1
  17. holmes/plugins/toolsets/coralogix/toolset_coralogix_logs.py +3 -0
  18. holmes/plugins/toolsets/datadog/datadog_api.py +161 -0
  19. holmes/plugins/toolsets/datadog/datadog_metrics_instructions.jinja2 +26 -0
  20. holmes/plugins/toolsets/datadog/datadog_traces_formatter.py +310 -0
  21. holmes/plugins/toolsets/datadog/instructions_datadog_traces.jinja2 +51 -0
  22. holmes/plugins/toolsets/datadog/toolset_datadog_logs.py +267 -0
  23. holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py +488 -0
  24. holmes/plugins/toolsets/datadog/toolset_datadog_traces.py +689 -0
  25. holmes/plugins/toolsets/grafana/toolset_grafana_loki.py +3 -0
  26. holmes/plugins/toolsets/internet/internet.py +1 -1
  27. holmes/plugins/toolsets/logging_utils/logging_api.py +9 -3
  28. holmes/plugins/toolsets/opensearch/opensearch_logs.py +3 -0
  29. holmes/plugins/toolsets/utils.py +6 -2
  30. holmes/utils/cache.py +4 -4
  31. holmes/utils/console/consts.py +2 -0
  32. holmes/utils/console/logging.py +95 -0
  33. holmes/utils/console/result.py +37 -0
  34. {holmesgpt-0.11.5.dist-info → holmesgpt-0.12.0.dist-info}/METADATA +3 -4
  35. {holmesgpt-0.11.5.dist-info → holmesgpt-0.12.0.dist-info}/RECORD +38 -29
  36. {holmesgpt-0.11.5.dist-info → holmesgpt-0.12.0.dist-info}/WHEEL +1 -1
  37. holmes/__init__.py.bak +0 -76
  38. holmes/plugins/toolsets/datadog.py +0 -153
  39. {holmesgpt-0.11.5.dist-info → holmesgpt-0.12.0.dist-info}/LICENSE.txt +0 -0
  40. {holmesgpt-0.11.5.dist-info → holmesgpt-0.12.0.dist-info}/entry_points.txt +0 -0
holmes/interactive.py CHANGED
@@ -1,40 +1,63 @@
1
1
  import logging
2
+ import os
3
+ import subprocess
4
+ import tempfile
5
+ import threading
6
+ from collections import defaultdict
2
7
  from enum import Enum
3
- from typing import Optional, List
4
8
  from pathlib import Path
9
+ from typing import Optional, List, DefaultDict
5
10
 
6
11
  import typer
7
12
  from prompt_toolkit import PromptSession
8
- from prompt_toolkit.completion import Completer, Completion
9
- from prompt_toolkit.history import InMemoryHistory
13
+ from prompt_toolkit.application import Application
14
+ from prompt_toolkit.completion import Completer, Completion, merge_completers
15
+ from prompt_toolkit.completion.filesystem import ExecutableCompleter, PathCompleter
16
+ from prompt_toolkit.history import InMemoryHistory, FileHistory
17
+ from prompt_toolkit.document import Document
18
+ from prompt_toolkit.key_binding import KeyBindings
19
+ from prompt_toolkit.layout import Layout
20
+ from prompt_toolkit.layout.containers import HSplit, Window
21
+ from prompt_toolkit.layout.controls import FormattedTextControl
22
+ from prompt_toolkit.shortcuts.prompt import CompleteStyle
10
23
  from prompt_toolkit.styles import Style
24
+ from prompt_toolkit.widgets import TextArea
25
+
11
26
  from rich.console import Console
12
27
  from rich.markdown import Markdown, Panel
13
28
 
14
29
  from holmes.core.prompt import build_initial_ask_messages
15
30
  from holmes.core.tool_calling_llm import ToolCallingLLM, ToolCallResult
16
31
  from holmes.core.tools import pretty_print_toolset_status
32
+ from holmes.core.tracing import DummySpan
17
33
 
18
34
 
19
35
  class SlashCommands(Enum):
20
- EXIT = "/exit"
21
- HELP = "/help"
22
- RESET = "/reset"
23
- TOOLS_CONFIG = "/config"
24
- TOGGLE_TOOL_OUTPUT = "/toggle-output"
25
- SHOW_OUTPUT = "/output"
36
+ EXIT = ("/exit", "Exit interactive mode")
37
+ HELP = ("/help", "Show help message with all commands")
38
+ RESET = ("/reset", "Reset the conversation context")
39
+ TOOLS_CONFIG = ("/tools", "Show available toolsets and their status")
40
+ TOGGLE_TOOL_OUTPUT = (
41
+ "/auto",
42
+ "Toggle auto-display of tool outputs after responses",
43
+ )
44
+ LAST_OUTPUT = ("/last", "Show all tool outputs from last response")
45
+ CLEAR = ("/clear", "Clear the terminal screen")
46
+ RUN = ("/run", "Run a bash command and optionally share with LLM")
47
+ SHELL = (
48
+ "/shell",
49
+ "Drop into interactive shell, then optionally share session with LLM",
50
+ )
51
+ CONTEXT = ("/context", "Show conversation context size and token count")
52
+ SHOW = ("/show", "Show specific tool output in scrollable view")
26
53
 
54
+ def __init__(self, command, description):
55
+ self.command = command
56
+ self.description = description
27
57
 
28
- SLASH_COMMANDS_REFERENCE = {
29
- SlashCommands.EXIT.value: "Exit interactive mode",
30
- SlashCommands.HELP.value: "Show help message with all commands",
31
- SlashCommands.RESET.value: "Reset the conversation context",
32
- SlashCommands.TOOLS_CONFIG.value: "Show available toolsets and their status",
33
- SlashCommands.TOGGLE_TOOL_OUTPUT.value: "Toggle tool output display on/off",
34
- SlashCommands.SHOW_OUTPUT.value: "Show all tool outputs from last response",
35
- }
36
58
 
37
- ALL_SLASH_COMMANDS = [cmd.value for cmd in SlashCommands]
59
+ SLASH_COMMANDS_REFERENCE = {cmd.command: cmd.description for cmd in SlashCommands}
60
+ ALL_SLASH_COMMANDS = [cmd.command for cmd in SlashCommands]
38
61
 
39
62
 
40
63
  class SlashCommandCompleter(Completer):
@@ -52,6 +75,71 @@ class SlashCommandCompleter(Completer):
52
75
  )
53
76
 
54
77
 
78
+ class SmartPathCompleter(Completer):
79
+ """Path completer that works for relative paths starting with ./ or ../"""
80
+
81
+ def __init__(self):
82
+ self.path_completer = PathCompleter()
83
+
84
+ def get_completions(self, document, complete_event):
85
+ text = document.text_before_cursor
86
+ words = text.split()
87
+ if not words:
88
+ return
89
+
90
+ last_word = words[-1]
91
+ # Only complete if the last word looks like a relative path (not absolute paths starting with /)
92
+ if last_word.startswith("./") or last_word.startswith("../"):
93
+ # Create a temporary document with just the path part
94
+ path_doc = Document(last_word, len(last_word))
95
+
96
+ for completion in self.path_completer.get_completions(
97
+ path_doc, complete_event
98
+ ):
99
+ yield Completion(
100
+ completion.text,
101
+ start_position=completion.start_position - len(last_word),
102
+ display=completion.display,
103
+ display_meta=completion.display_meta,
104
+ )
105
+
106
+
107
+ class ConditionalExecutableCompleter(Completer):
108
+ """Executable completer that only works after /run commands"""
109
+
110
+ def __init__(self):
111
+ self.executable_completer = ExecutableCompleter()
112
+
113
+ def get_completions(self, document, complete_event):
114
+ text = document.text_before_cursor
115
+
116
+ # Only provide executable completion if the line starts with /run
117
+ if text.startswith("/run "):
118
+ # Extract the command part after "/run "
119
+ command_part = text[5:] # Remove "/run "
120
+
121
+ # Only complete the first word (the executable name)
122
+ words = command_part.split()
123
+ if len(words) <= 1: # Only when typing the first word
124
+ # Create a temporary document with just the command part
125
+ cmd_doc = Document(command_part, len(command_part))
126
+
127
+ seen_completions = set()
128
+ for completion in self.executable_completer.get_completions(
129
+ cmd_doc, complete_event
130
+ ):
131
+ # Remove duplicates based on text only (display can be FormattedText which is unhashable)
132
+ if completion.text not in seen_completions:
133
+ seen_completions.add(completion.text)
134
+ yield Completion(
135
+ completion.text,
136
+ start_position=completion.start_position
137
+ - len(command_part),
138
+ display=completion.display,
139
+ display_meta=completion.display_meta,
140
+ )
141
+
142
+
55
143
  USER_COLOR = "#DEFCC0" # light green
56
144
  AI_COLOR = "#00FFFF" # cyan
57
145
  TOOLS_COLOR = "magenta"
@@ -59,15 +147,18 @@ HELP_COLOR = "cyan" # same as AI_COLOR for now
59
147
  ERROR_COLOR = "red"
60
148
  STATUS_COLOR = "yellow"
61
149
 
62
- WELCOME_BANNER = f"[bold {HELP_COLOR}]Welcome to HolmesGPT:[/bold {HELP_COLOR}] Type '{SlashCommands.EXIT.value}' to exit, '{SlashCommands.HELP.value}' for commands."
150
+ WELCOME_BANNER = f"[bold {HELP_COLOR}]Welcome to HolmesGPT:[/bold {HELP_COLOR}] Type '{SlashCommands.EXIT.command}' to exit, '{SlashCommands.HELP.command}' for commands."
63
151
 
64
152
 
65
- def format_tool_call_output(tool_call: ToolCallResult) -> str:
153
+ def format_tool_call_output(
154
+ tool_call: ToolCallResult, tool_index: Optional[int] = None
155
+ ) -> str:
66
156
  """
67
157
  Format a single tool call result for display in a rich panel.
68
158
 
69
159
  Args:
70
160
  tool_call: ToolCallResult object containing the tool execution result
161
+ tool_index: Optional 1-based index of the tool for /show command
71
162
 
72
163
  Returns:
73
164
  Formatted string for display in a rich panel
@@ -82,26 +173,533 @@ def format_tool_call_output(tool_call: ToolCallResult) -> str:
82
173
  elif len(output_str) > MAX_CHARS:
83
174
  truncated = output_str[:MAX_CHARS].strip()
84
175
  remaining_chars = len(output_str) - MAX_CHARS
85
- content = f"[{color}]{truncated}[/{color}]\n\n[dim]... truncated ({remaining_chars:,} more chars)[/dim]"
176
+ show_hint = f"/show {tool_index}" if tool_index else "/show"
177
+ content = f"[{color}]{truncated}[/{color}]\n\n[dim]... truncated ({remaining_chars:,} more chars) - {show_hint} to view full output[/dim]"
86
178
  else:
87
179
  content = f"[{color}]{output_str}[/{color}]"
88
180
 
89
181
  return content
90
182
 
91
183
 
92
- def display_tool_calls(tool_calls: List[ToolCallResult], console: Console) -> None:
184
+ def build_modal_title(tool_call: ToolCallResult, wrap_status: str) -> str:
185
+ """Build modal title with navigation instructions."""
186
+ return f"{tool_call.description} (exit: q, nav: ↑↓/j/k/g/G/d/u/f/b/space, wrap: w [{wrap_status}])"
187
+
188
+
189
+ def handle_show_command(
190
+ show_arg: str, all_tool_calls_history: List[ToolCallResult], console: Console
191
+ ) -> None:
192
+ """Handle the /show command to display tool outputs."""
193
+ if not all_tool_calls_history:
194
+ console.print(
195
+ f"[bold {ERROR_COLOR}]No tool calls available in the conversation.[/bold {ERROR_COLOR}]"
196
+ )
197
+ return
198
+
199
+ if not show_arg:
200
+ # Show list of available tools
201
+ console.print(
202
+ f"[bold {STATUS_COLOR}]Available tool outputs:[/bold {STATUS_COLOR}]"
203
+ )
204
+ for i, tool_call in enumerate(all_tool_calls_history):
205
+ console.print(f" {i+1}. {tool_call.description}")
206
+ console.print("[dim]Usage: /show <number> or /show <tool_name>[/dim]")
207
+ return
208
+
209
+ # Find tool by number or name
210
+ tool_to_show = None
211
+ try:
212
+ tool_index = int(show_arg) - 1 # Convert to 0-based index
213
+ if 0 <= tool_index < len(all_tool_calls_history):
214
+ tool_to_show = all_tool_calls_history[tool_index]
215
+ else:
216
+ console.print(
217
+ f"[bold {ERROR_COLOR}]Invalid tool index. Use 1-{len(all_tool_calls_history)}[/bold {ERROR_COLOR}]"
218
+ )
219
+ return
220
+ except ValueError:
221
+ # Try to find by tool name/description
222
+ for tool_call in all_tool_calls_history:
223
+ if show_arg.lower() in tool_call.description.lower():
224
+ tool_to_show = tool_call
225
+ break
226
+
227
+ if not tool_to_show:
228
+ console.print(
229
+ f"[bold {ERROR_COLOR}]Tool not found: {show_arg}[/bold {ERROR_COLOR}]"
230
+ )
231
+ return
232
+
233
+ # Show the tool output in modal
234
+ show_tool_output_modal(tool_to_show, console)
235
+
236
+
237
+ def show_tool_output_modal(tool_call: ToolCallResult, console: Console) -> None:
238
+ """
239
+ Display a tool output in a scrollable modal window.
240
+
241
+ Args:
242
+ tool_call: ToolCallResult object to display
243
+ console: Rich console (for fallback display)
244
+ """
245
+ try:
246
+ # Get the full output
247
+ output = tool_call.result.get_stringified_data()
248
+ title = build_modal_title(tool_call, "off") # Word wrap starts disabled
249
+
250
+ # Create text area with the output
251
+ text_area = TextArea(
252
+ text=output,
253
+ read_only=True,
254
+ scrollbar=True,
255
+ line_numbers=False,
256
+ wrap_lines=False, # Disable word wrap by default
257
+ )
258
+
259
+ # Create header
260
+ header = Window(
261
+ FormattedTextControl(title),
262
+ height=1,
263
+ style="reverse",
264
+ )
265
+
266
+ # Create layout
267
+ layout = Layout(
268
+ HSplit(
269
+ [
270
+ header,
271
+ text_area,
272
+ ]
273
+ )
274
+ )
275
+
276
+ # Create key bindings
277
+ bindings = KeyBindings()
278
+
279
+ # Exit commands
280
+ @bindings.add("q")
281
+ @bindings.add("escape")
282
+ def _(event):
283
+ event.app.exit()
284
+
285
+ @bindings.add("c-c")
286
+ def _(event):
287
+ event.app.exit()
288
+
289
+ # Vim/less-like navigation
290
+ @bindings.add("j")
291
+ @bindings.add("down")
292
+ def _(event):
293
+ event.app.layout.focus(text_area)
294
+ text_area.buffer.cursor_down()
295
+
296
+ @bindings.add("k")
297
+ @bindings.add("up")
298
+ def _(event):
299
+ event.app.layout.focus(text_area)
300
+ text_area.buffer.cursor_up()
301
+
302
+ @bindings.add("g")
303
+ @bindings.add("home")
304
+ def _(event):
305
+ event.app.layout.focus(text_area)
306
+ text_area.buffer.cursor_position = 0
307
+
308
+ @bindings.add("G")
309
+ @bindings.add("end")
310
+ def _(event):
311
+ event.app.layout.focus(text_area)
312
+ # Go to last line, then to beginning of that line
313
+ text_area.buffer.cursor_position = len(text_area.buffer.text)
314
+ text_area.buffer.cursor_left(
315
+ count=text_area.buffer.document.cursor_position_col
316
+ )
317
+
318
+ @bindings.add("d")
319
+ @bindings.add("c-d")
320
+ @bindings.add("pagedown")
321
+ def _(event):
322
+ event.app.layout.focus(text_area)
323
+ # Get current window height and scroll by half
324
+ window_height = event.app.output.get_size().rows - 1 # -1 for header
325
+ scroll_amount = max(1, window_height // 2)
326
+ for _ in range(scroll_amount):
327
+ text_area.buffer.cursor_down()
328
+
329
+ @bindings.add("u")
330
+ @bindings.add("c-u")
331
+ @bindings.add("pageup")
332
+ def _(event):
333
+ event.app.layout.focus(text_area)
334
+ # Get current window height and scroll by half
335
+ window_height = event.app.output.get_size().rows - 1 # -1 for header
336
+ scroll_amount = max(1, window_height // 2)
337
+ for _ in range(scroll_amount):
338
+ text_area.buffer.cursor_up()
339
+
340
+ @bindings.add("f")
341
+ @bindings.add("c-f")
342
+ @bindings.add("space")
343
+ def _(event):
344
+ event.app.layout.focus(text_area)
345
+ # Get current window height and scroll by full page
346
+ window_height = event.app.output.get_size().rows - 1 # -1 for header
347
+ scroll_amount = max(1, window_height)
348
+ for _ in range(scroll_amount):
349
+ text_area.buffer.cursor_down()
350
+
351
+ @bindings.add("b")
352
+ @bindings.add("c-b")
353
+ def _(event):
354
+ event.app.layout.focus(text_area)
355
+ # Get current window height and scroll by full page
356
+ window_height = event.app.output.get_size().rows - 1 # -1 for header
357
+ scroll_amount = max(1, window_height)
358
+ for _ in range(scroll_amount):
359
+ text_area.buffer.cursor_up()
360
+
361
+ @bindings.add("w")
362
+ def _(event):
363
+ # Toggle word wrap
364
+ text_area.wrap_lines = not text_area.wrap_lines
365
+ # Update the header to show current wrap state
366
+ wrap_status = "on" if text_area.wrap_lines else "off"
367
+ new_title = build_modal_title(tool_call, wrap_status)
368
+ header.content = FormattedTextControl(new_title)
369
+
370
+ # Create and run application
371
+ app: Application = Application(
372
+ layout=layout,
373
+ key_bindings=bindings,
374
+ full_screen=True,
375
+ )
376
+
377
+ app.run()
378
+
379
+ except Exception as e:
380
+ # Fallback to regular display
381
+ console.print(f"[bold red]Error showing modal: {e}[/bold red]")
382
+ console.print(format_tool_call_output(tool_call))
383
+
384
+
385
+ def handle_context_command(messages, ai: ToolCallingLLM, console: Console) -> None:
386
+ """Handle the /context command to show conversation context statistics."""
387
+ if messages is None:
388
+ console.print(
389
+ f"[bold {STATUS_COLOR}]No conversation context yet.[/bold {STATUS_COLOR}]"
390
+ )
391
+ return
392
+
393
+ # Calculate context statistics
394
+ total_tokens = ai.llm.count_tokens_for_message(messages)
395
+ max_context_size = ai.llm.get_context_window_size()
396
+ max_output_tokens = ai.llm.get_maximum_output_token()
397
+ available_tokens = max_context_size - total_tokens - max_output_tokens
398
+
399
+ # Analyze token distribution by role and tool calls
400
+ role_token_usage: DefaultDict[str, int] = defaultdict(int)
401
+ tool_token_usage: DefaultDict[str, int] = defaultdict(int)
402
+ tool_call_counts: DefaultDict[str, int] = defaultdict(int)
403
+
404
+ for msg in messages:
405
+ role = msg.get("role", "unknown")
406
+ msg_tokens = ai.llm.count_tokens_for_message([msg])
407
+ role_token_usage[role] += msg_tokens
408
+
409
+ # Track individual tool usage
410
+ if role == "tool":
411
+ tool_name = msg.get("name", "unknown_tool")
412
+ tool_token_usage[tool_name] += msg_tokens
413
+ tool_call_counts[tool_name] += 1
414
+
415
+ # Display context information
416
+ console.print(f"[bold {STATUS_COLOR}]Conversation Context:[/bold {STATUS_COLOR}]")
417
+ console.print(
418
+ f" Context used: {total_tokens:,} / {max_context_size:,} tokens ({(total_tokens / max_context_size) * 100:.1f}%)"
419
+ )
420
+ console.print(
421
+ 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}%)"
422
+ )
423
+
424
+ # Show token breakdown by role
425
+ console.print(" Token breakdown:")
426
+ for role in ["system", "user", "assistant", "tool"]:
427
+ if role in role_token_usage:
428
+ tokens = role_token_usage[role]
429
+ percentage = (tokens / total_tokens) * 100 if total_tokens > 0 else 0
430
+ role_name = {
431
+ "system": "system prompt",
432
+ "user": "user messages",
433
+ "assistant": "assistant replies",
434
+ "tool": "tool responses",
435
+ }.get(role, role)
436
+ console.print(f" {role_name}: {tokens:,} tokens ({percentage:.1f}%)")
437
+
438
+ # Show top 4 tools breakdown under tool responses
439
+ if role == "tool" and tool_token_usage:
440
+ sorted_tools = sorted(
441
+ tool_token_usage.items(), key=lambda x: x[1], reverse=True
442
+ )
443
+
444
+ # Show top 4 tools
445
+ for tool_name, tool_tokens in sorted_tools[:4]:
446
+ tool_percentage = (tool_tokens / tokens) * 100 if tokens > 0 else 0
447
+ call_count = tool_call_counts[tool_name]
448
+ console.print(
449
+ f" {tool_name}: {tool_tokens:,} tokens ({tool_percentage:.1f}%) from {call_count} tool calls"
450
+ )
451
+
452
+ # Show "other" category if there are more than 4 tools
453
+ if len(sorted_tools) > 4:
454
+ other_tokens = sum(
455
+ tool_tokens for _, tool_tokens in sorted_tools[4:]
456
+ )
457
+ other_calls = sum(
458
+ tool_call_counts[tool_name] for tool_name, _ in sorted_tools[4:]
459
+ )
460
+ other_percentage = (
461
+ (other_tokens / tokens) * 100 if tokens > 0 else 0
462
+ )
463
+ other_count = len(sorted_tools) - 4
464
+ console.print(
465
+ f" other ({other_count} tools): {other_tokens:,} tokens ({other_percentage:.1f}%) from {other_calls} tool calls"
466
+ )
467
+
468
+ if available_tokens < 0:
469
+ console.print(
470
+ f"[bold {ERROR_COLOR}]⚠️ Context will be truncated on next LLM call[/bold {ERROR_COLOR}]"
471
+ )
472
+
473
+
474
+ def prompt_for_llm_sharing(
475
+ session: PromptSession, style: Style, content: str, content_type: str
476
+ ) -> Optional[str]:
477
+ """
478
+ Prompt user to share content with LLM and return formatted user input.
479
+
480
+ Args:
481
+ session: PromptSession for user input
482
+ style: Style for prompts
483
+ content: The content to potentially share (command output, shell session, etc.)
484
+ content_type: Description of content type (e.g., "command", "shell session")
485
+
486
+ Returns:
487
+ Formatted user input string if user chooses to share, None otherwise
488
+ """
489
+ # Create a temporary session without history for y/n prompts
490
+ temp_session = PromptSession(history=InMemoryHistory()) # type: ignore
491
+
492
+ share_prompt = temp_session.prompt(
493
+ [("class:prompt", f"Share {content_type} with LLM? (Y/n): ")], style=style
494
+ )
495
+
496
+ if not share_prompt.lower().startswith("n"):
497
+ comment_prompt = temp_session.prompt(
498
+ [("class:prompt", "Optional comment/question (press Enter to skip): ")],
499
+ style=style,
500
+ )
501
+
502
+ user_input = f"I {content_type}:\\n\\n```\\n{content}\\n```\\n\\n"
503
+
504
+ if comment_prompt.strip():
505
+ user_input += f"Comment/Question: {comment_prompt.strip()}"
506
+
507
+ return user_input
508
+
509
+ return None
510
+
511
+
512
+ def handle_run_command(
513
+ bash_command: str, session: PromptSession, style: Style, console: Console
514
+ ) -> Optional[str]:
515
+ """
516
+ Handle the /run command to execute a bash command.
517
+
518
+ Args:
519
+ bash_command: The bash command to execute
520
+ session: PromptSession for user input
521
+ style: Style for prompts
522
+ console: Rich console for output
523
+
524
+ Returns:
525
+ Formatted user input string if user chooses to share, None otherwise
526
+ """
527
+ if not bash_command:
528
+ console.print(
529
+ f"[bold {ERROR_COLOR}]Usage: /run <bash_command>[/bold {ERROR_COLOR}]"
530
+ )
531
+ return None
532
+
533
+ result = None
534
+ output = ""
535
+ error_message = ""
536
+
537
+ try:
538
+ console.print(
539
+ f"[bold {STATUS_COLOR}]Running: {bash_command}[/bold {STATUS_COLOR}]"
540
+ )
541
+ result = subprocess.run(
542
+ bash_command, shell=True, capture_output=True, text=True
543
+ )
544
+
545
+ output = result.stdout + result.stderr
546
+ if result.returncode == 0:
547
+ console.print(
548
+ f"[bold green]✓ Command succeeded (exit code: {result.returncode})[/bold green]"
549
+ )
550
+ else:
551
+ console.print(
552
+ f"[bold {ERROR_COLOR}]✗ Command failed (exit code: {result.returncode})[/bold {ERROR_COLOR}]"
553
+ )
554
+
555
+ if output.strip():
556
+ console.print(
557
+ Panel(
558
+ output,
559
+ padding=(1, 2),
560
+ border_style="white",
561
+ title="Command Output",
562
+ title_align="left",
563
+ )
564
+ )
565
+
566
+ except KeyboardInterrupt:
567
+ error_message = "Command interrupted by user"
568
+ console.print(f"[bold {ERROR_COLOR}]{error_message}[/bold {ERROR_COLOR}]")
569
+ except Exception as e:
570
+ error_message = f"Error running command: {e}"
571
+ console.print(f"[bold {ERROR_COLOR}]{error_message}[/bold {ERROR_COLOR}]")
572
+
573
+ # Build command output for sharing
574
+ command_output = f"ran the command: `{bash_command}`\n\n"
575
+ if result is not None:
576
+ command_output += f"Exit code: {result.returncode}\n\n"
577
+ if output.strip():
578
+ command_output += f"Output:\n{output}"
579
+ elif error_message:
580
+ command_output += f"Error: {error_message}"
581
+
582
+ return prompt_for_llm_sharing(session, style, command_output, "ran a command")
583
+
584
+
585
+ def handle_shell_command(
586
+ session: PromptSession, style: Style, console: Console
587
+ ) -> Optional[str]:
93
588
  """
94
- Display tool calls in rich panels.
589
+ Handle the /shell command to start an interactive shell session.
95
590
 
96
591
  Args:
97
- tool_calls: List of ToolCallResult objects to display
592
+ session: PromptSession for user input
593
+ style: Style for prompts
98
594
  console: Rich console for output
595
+
596
+ Returns:
597
+ Formatted user input string if user chooses to share, None otherwise
99
598
  """
599
+ console.print(
600
+ f"[bold {STATUS_COLOR}]Starting interactive shell. Type 'exit' to return to HolmesGPT.[/bold {STATUS_COLOR}]"
601
+ )
602
+ console.print(
603
+ "[dim]Shell session will be recorded and can be shared with LLM when you exit.[/dim]"
604
+ )
605
+
606
+ # Create a temporary file to capture shell session
607
+ with tempfile.NamedTemporaryFile(mode="w+", suffix=".log") as session_file:
608
+ session_log_path = session_file.name
609
+
610
+ try:
611
+ # Start shell with script command to capture session
612
+ shell_env = os.environ.copy()
613
+ shell_env["PS1"] = "\\u@\\h:\\w$ " # Set a clean prompt
614
+
615
+ subprocess.run(f"script -q {session_log_path}", shell=True, env=shell_env)
616
+
617
+ # Read the session log
618
+ session_output = ""
619
+ try:
620
+ with open(session_log_path, "r") as f:
621
+ session_output = f.read()
622
+ except Exception as e:
623
+ console.print(
624
+ f"[bold {ERROR_COLOR}]Error reading session log: {e}[/bold {ERROR_COLOR}]"
625
+ )
626
+ return None
627
+
628
+ if session_output.strip():
629
+ console.print(
630
+ f"[bold {STATUS_COLOR}]Shell session ended.[/bold {STATUS_COLOR}]"
631
+ )
632
+ return prompt_for_llm_sharing(
633
+ session, style, session_output, "had an interactive shell session"
634
+ )
635
+ else:
636
+ console.print(
637
+ f"[bold {STATUS_COLOR}]Shell session ended with no output.[/bold {STATUS_COLOR}]"
638
+ )
639
+ return None
640
+
641
+ except KeyboardInterrupt:
642
+ console.print(
643
+ f"[bold {STATUS_COLOR}]Shell session interrupted.[/bold {STATUS_COLOR}]"
644
+ )
645
+ return None
646
+ except Exception as e:
647
+ console.print(
648
+ f"[bold {ERROR_COLOR}]Error starting shell: {e}[/bold {ERROR_COLOR}]"
649
+ )
650
+ return None
651
+
652
+
653
+ def find_tool_index_in_history(
654
+ tool_call: ToolCallResult, all_tool_calls_history: List[ToolCallResult]
655
+ ) -> Optional[int]:
656
+ """Find the 1-based index of a tool call in the complete history."""
657
+ for i, historical_tool in enumerate(all_tool_calls_history):
658
+ if historical_tool.tool_call_id == tool_call.tool_call_id:
659
+ return i + 1 # 1-based index
660
+ return None
661
+
662
+
663
+ def handle_last_command(
664
+ last_response, console: Console, all_tool_calls_history: List[ToolCallResult]
665
+ ) -> None:
666
+ """Handle the /last command to show recent tool outputs."""
667
+ if last_response is None or not last_response.tool_calls:
668
+ console.print(
669
+ f"[bold {ERROR_COLOR}]No tool calls available from the last response.[/bold {ERROR_COLOR}]"
670
+ )
671
+ return
672
+
673
+ console.print(
674
+ f"[bold {TOOLS_COLOR}]Used {len(last_response.tool_calls)} tools[/bold {TOOLS_COLOR}]"
675
+ )
676
+ for tool_call in last_response.tool_calls:
677
+ tool_index = find_tool_index_in_history(tool_call, all_tool_calls_history)
678
+ preview_output = format_tool_call_output(tool_call, tool_index)
679
+ title = f"{tool_call.result.status.to_emoji()} {tool_call.description} -> returned {tool_call.result.return_code}"
680
+
681
+ console.print(
682
+ Panel(
683
+ preview_output,
684
+ padding=(1, 2),
685
+ border_style=TOOLS_COLOR,
686
+ title=title,
687
+ )
688
+ )
689
+
690
+
691
+ def display_recent_tool_outputs(
692
+ tool_calls: List[ToolCallResult],
693
+ console: Console,
694
+ all_tool_calls_history: List[ToolCallResult],
695
+ ) -> None:
696
+ """Display recent tool outputs in rich panels (for auto-display after responses)."""
100
697
  console.print(
101
698
  f"[bold {TOOLS_COLOR}]Used {len(tool_calls)} tools[/bold {TOOLS_COLOR}]"
102
699
  )
103
700
  for tool_call in tool_calls:
104
- preview_output = format_tool_call_output(tool_call)
701
+ tool_index = find_tool_index_in_history(tool_call, all_tool_calls_history)
702
+ preview_output = format_tool_call_output(tool_call, tool_index)
105
703
  title = f"{tool_call.result.status.to_emoji()} {tool_call.description} -> returned {tool_call.result.return_code}"
106
704
 
107
705
  console.print(
@@ -122,21 +720,75 @@ def run_interactive_loop(
122
720
  include_files: Optional[List[Path]],
123
721
  post_processing_prompt: Optional[str],
124
722
  show_tool_output: bool,
723
+ tracer=None,
125
724
  ) -> None:
725
+ # Initialize tracer - use DummySpan if no tracer provided
726
+ if tracer is None:
727
+ tracer = DummySpan()
728
+
126
729
  style = Style.from_dict(
127
730
  {
128
731
  "prompt": USER_COLOR,
732
+ "bottom-toolbar": "#000000 bg:#ff0000",
733
+ "bottom-toolbar.text": "#aaaa44 bg:#aa4444",
129
734
  }
130
735
  )
131
736
 
132
- command_completer = SlashCommandCompleter()
133
- history = InMemoryHistory()
737
+ # Create merged completer with slash commands, conditional executables, and smart paths
738
+ slash_completer = SlashCommandCompleter()
739
+ executable_completer = ConditionalExecutableCompleter()
740
+ path_completer = SmartPathCompleter()
741
+
742
+ command_completer = merge_completers(
743
+ [slash_completer, executable_completer, path_completer]
744
+ )
745
+
746
+ # Use file-based history
747
+ history_file = os.path.expanduser("~/.holmes/history")
748
+ os.makedirs(os.path.dirname(history_file), exist_ok=True)
749
+ history = FileHistory(history_file)
134
750
  if initial_user_input:
135
751
  history.append_string(initial_user_input)
752
+
753
+ # Create custom key bindings for Ctrl+C behavior
754
+ bindings = KeyBindings()
755
+ status_message = ""
756
+
757
+ @bindings.add("c-c")
758
+ def _(event):
759
+ """Handle Ctrl+C: clear input if text exists, otherwise quit."""
760
+ buffer = event.app.current_buffer
761
+ if buffer.text:
762
+ nonlocal status_message
763
+ status_message = f"Input cleared. Use {SlashCommands.EXIT.command} or Ctrl+C again to quit."
764
+ buffer.reset()
765
+
766
+ # call timer to clear status message after 3 seconds
767
+ def clear_status():
768
+ nonlocal status_message
769
+ status_message = ""
770
+ event.app.invalidate()
771
+
772
+ timer = threading.Timer(3, clear_status)
773
+ timer.start()
774
+ else:
775
+ # Quit if no text
776
+ raise KeyboardInterrupt()
777
+
778
+ def get_bottom_toolbar():
779
+ if status_message:
780
+ return [("bg:#ff0000 fg:#000000", status_message)]
781
+ return None
782
+
136
783
  session = PromptSession(
137
784
  completer=command_completer,
138
785
  history=history,
786
+ complete_style=CompleteStyle.COLUMN,
787
+ reserve_space_for_menu=12,
788
+ key_bindings=bindings,
789
+ bottom_toolbar=get_bottom_toolbar,
139
790
  ) # type: ignore
791
+
140
792
  input_prompt = [("class:prompt", "User: ")]
141
793
 
142
794
  console.print(WELCOME_BANNER)
@@ -146,6 +798,9 @@ def run_interactive_loop(
146
798
  )
147
799
  messages = None
148
800
  last_response = None
801
+ all_tool_calls_history: List[
802
+ ToolCallResult
803
+ ] = [] # Track all tool calls throughout conversation
149
804
 
150
805
  while True:
151
806
  try:
@@ -156,40 +811,78 @@ def run_interactive_loop(
156
811
  user_input = session.prompt(input_prompt, style=style) # type: ignore
157
812
 
158
813
  if user_input.startswith("/"):
159
- command = user_input.strip().lower()
160
- if command == SlashCommands.EXIT.value:
814
+ original_input = user_input.strip()
815
+ command = original_input.lower()
816
+
817
+ # Handle prefix matching for slash commands
818
+ matches = [cmd for cmd in ALL_SLASH_COMMANDS if cmd.startswith(command)]
819
+ if len(matches) == 1:
820
+ command = matches[0]
821
+ elif len(matches) > 1:
822
+ console.print(
823
+ f"[bold {ERROR_COLOR}]Ambiguous command '{command}'. Matches: {', '.join(matches)}[/bold {ERROR_COLOR}]"
824
+ )
825
+ continue
826
+
827
+ if command == SlashCommands.EXIT.command:
161
828
  return
162
- elif command == SlashCommands.HELP.value:
829
+ elif command == SlashCommands.HELP.command:
163
830
  console.print(
164
831
  f"[bold {HELP_COLOR}]Available commands:[/bold {HELP_COLOR}]"
165
832
  )
166
833
  for cmd, description in SLASH_COMMANDS_REFERENCE.items():
167
834
  console.print(f" [bold]{cmd}[/bold] - {description}")
835
+ continue
168
836
  elif command == SlashCommands.RESET.value:
169
837
  console.print(
170
838
  f"[bold {STATUS_COLOR}]Context reset. You can now ask a new question.[/bold {STATUS_COLOR}]"
171
839
  )
172
840
  messages = None
841
+ last_response = None
842
+ all_tool_calls_history.clear()
173
843
  continue
174
- elif command == SlashCommands.TOOLS_CONFIG.value:
844
+ elif command == SlashCommands.TOOLS_CONFIG.command:
175
845
  pretty_print_toolset_status(ai.tool_executor.toolsets, console)
176
- elif command == SlashCommands.TOGGLE_TOOL_OUTPUT.value:
846
+ continue
847
+ elif command == SlashCommands.TOGGLE_TOOL_OUTPUT.command:
177
848
  show_tool_output = not show_tool_output
178
849
  status = "enabled" if show_tool_output else "disabled"
179
850
  console.print(
180
- f"[bold yellow]Tool output display {status}.[/bold yellow]"
851
+ f"[bold yellow]Auto-display of tool outputs {status}.[/bold yellow]"
181
852
  )
182
- elif command == SlashCommands.SHOW_OUTPUT.value:
183
- if last_response is None or not last_response.tool_calls:
184
- console.print(
185
- f"[bold {ERROR_COLOR}]No tool calls available from the last response.[/bold {ERROR_COLOR}]"
186
- )
187
- continue
188
-
189
- display_tool_calls(last_response.tool_calls, console)
853
+ continue
854
+ elif command == SlashCommands.LAST_OUTPUT.command:
855
+ handle_last_command(last_response, console, all_tool_calls_history)
856
+ continue
857
+ elif command == SlashCommands.CLEAR.command:
858
+ console.clear()
859
+ continue
860
+ elif command == SlashCommands.CONTEXT.command:
861
+ handle_context_command(messages, ai, console)
862
+ continue
863
+ elif command.startswith(SlashCommands.SHOW.command):
864
+ # Parse the command to extract tool index or name
865
+ show_arg = original_input[len(SlashCommands.SHOW.command) :].strip()
866
+ handle_show_command(show_arg, all_tool_calls_history, console)
867
+ continue
868
+ elif command.startswith(SlashCommands.RUN.command):
869
+ bash_command = original_input[
870
+ len(SlashCommands.RUN.command) :
871
+ ].strip()
872
+ shared_input = handle_run_command(
873
+ bash_command, session, style, console
874
+ )
875
+ if shared_input is None:
876
+ continue # User chose not to share, continue to next input
877
+ user_input = shared_input
878
+ elif command == SlashCommands.SHELL.command:
879
+ shared_input = handle_shell_command(session, style, console)
880
+ if shared_input is None:
881
+ continue # User chose not to share or no output, continue to next input
882
+ user_input = shared_input
190
883
  else:
191
884
  console.print(f"Unknown command: {command}")
192
- continue
885
+ continue
193
886
  elif not user_input.strip():
194
887
  continue
195
888
 
@@ -201,12 +894,31 @@ def run_interactive_loop(
201
894
  messages.append({"role": "user", "content": user_input})
202
895
 
203
896
  console.print(f"\n[bold {AI_COLOR}]Thinking...[/bold {AI_COLOR}]\n")
204
- response = ai.call(messages, post_processing_prompt)
897
+
898
+ with tracer.start_trace(user_input) as trace_span:
899
+ # Log the user's question as input to the top-level span
900
+ trace_span.log(
901
+ input=user_input,
902
+ metadata={"type": "user_question"},
903
+ )
904
+ response = ai.call(
905
+ messages, post_processing_prompt, trace_span=trace_span
906
+ )
907
+ trace_span.log(
908
+ output=response.result,
909
+ )
910
+ trace_url = tracer.get_trace_url()
911
+
205
912
  messages = response.messages # type: ignore
206
913
  last_response = response
207
914
 
915
+ if response.tool_calls:
916
+ all_tool_calls_history.extend(response.tool_calls)
917
+
208
918
  if show_tool_output and response.tool_calls:
209
- display_tool_calls(response.tool_calls, console)
919
+ display_recent_tool_outputs(
920
+ response.tool_calls, console, all_tool_calls_history
921
+ )
210
922
  console.print(
211
923
  Panel(
212
924
  Markdown(f"{response.result}"),
@@ -216,6 +928,10 @@ def run_interactive_loop(
216
928
  title_align="left",
217
929
  )
218
930
  )
931
+
932
+ if trace_url:
933
+ console.print(f"🔍 View trace: {trace_url}")
934
+
219
935
  console.print("")
220
936
  except typer.Abort:
221
937
  break
@@ -224,6 +940,7 @@ def run_interactive_loop(
224
940
  except Exception as e:
225
941
  logging.error("An error occurred during interactive mode:", exc_info=e)
226
942
  console.print(f"[bold {ERROR_COLOR}]Error: {e}[/bold {ERROR_COLOR}]")
943
+
227
944
  console.print(
228
945
  f"[bold {STATUS_COLOR}]Exiting interactive mode.[/bold {STATUS_COLOR}]"
229
946
  )