shotgun-sh 0.4.0.dev1__py3-none-any.whl → 0.6.2__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 (135) hide show
  1. shotgun/agents/agent_manager.py +307 -8
  2. shotgun/agents/cancellation.py +103 -0
  3. shotgun/agents/common.py +12 -0
  4. shotgun/agents/config/README.md +0 -1
  5. shotgun/agents/config/manager.py +10 -7
  6. shotgun/agents/config/models.py +5 -27
  7. shotgun/agents/config/provider.py +44 -27
  8. shotgun/agents/conversation/history/token_counting/base.py +51 -9
  9. shotgun/agents/file_read.py +176 -0
  10. shotgun/agents/messages.py +15 -3
  11. shotgun/agents/models.py +24 -1
  12. shotgun/agents/router/models.py +8 -0
  13. shotgun/agents/router/tools/delegation_tools.py +55 -1
  14. shotgun/agents/router/tools/plan_tools.py +88 -7
  15. shotgun/agents/runner.py +17 -2
  16. shotgun/agents/tools/__init__.py +8 -0
  17. shotgun/agents/tools/codebase/directory_lister.py +27 -39
  18. shotgun/agents/tools/codebase/file_read.py +26 -35
  19. shotgun/agents/tools/codebase/query_graph.py +9 -0
  20. shotgun/agents/tools/codebase/retrieve_code.py +9 -0
  21. shotgun/agents/tools/file_management.py +32 -2
  22. shotgun/agents/tools/file_read_tools/__init__.py +7 -0
  23. shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
  24. shotgun/agents/tools/markdown_tools/__init__.py +62 -0
  25. shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
  26. shotgun/agents/tools/markdown_tools/models.py +86 -0
  27. shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
  28. shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
  29. shotgun/agents/tools/markdown_tools/utils.py +453 -0
  30. shotgun/agents/tools/registry.py +44 -6
  31. shotgun/agents/tools/web_search/openai.py +42 -23
  32. shotgun/attachments/__init__.py +41 -0
  33. shotgun/attachments/errors.py +60 -0
  34. shotgun/attachments/models.py +107 -0
  35. shotgun/attachments/parser.py +257 -0
  36. shotgun/attachments/processor.py +193 -0
  37. shotgun/build_constants.py +4 -7
  38. shotgun/cli/clear.py +2 -2
  39. shotgun/cli/codebase/commands.py +181 -65
  40. shotgun/cli/compact.py +2 -2
  41. shotgun/cli/context.py +2 -2
  42. shotgun/cli/error_handler.py +2 -2
  43. shotgun/cli/run.py +90 -0
  44. shotgun/cli/spec/backup.py +2 -1
  45. shotgun/codebase/__init__.py +2 -0
  46. shotgun/codebase/benchmarks/__init__.py +35 -0
  47. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  48. shotgun/codebase/benchmarks/exporters.py +119 -0
  49. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  50. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  51. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  52. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  53. shotgun/codebase/benchmarks/models.py +129 -0
  54. shotgun/codebase/core/__init__.py +4 -0
  55. shotgun/codebase/core/call_resolution.py +91 -0
  56. shotgun/codebase/core/change_detector.py +11 -6
  57. shotgun/codebase/core/errors.py +159 -0
  58. shotgun/codebase/core/extractors/__init__.py +23 -0
  59. shotgun/codebase/core/extractors/base.py +138 -0
  60. shotgun/codebase/core/extractors/factory.py +63 -0
  61. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  62. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  63. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  64. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  65. shotgun/codebase/core/extractors/protocol.py +109 -0
  66. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  67. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  68. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  69. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  70. shotgun/codebase/core/extractors/types.py +15 -0
  71. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  72. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  73. shotgun/codebase/core/gitignore.py +252 -0
  74. shotgun/codebase/core/ingestor.py +644 -354
  75. shotgun/codebase/core/kuzu_compat.py +119 -0
  76. shotgun/codebase/core/language_config.py +239 -0
  77. shotgun/codebase/core/manager.py +256 -46
  78. shotgun/codebase/core/metrics_collector.py +310 -0
  79. shotgun/codebase/core/metrics_types.py +347 -0
  80. shotgun/codebase/core/parallel_executor.py +424 -0
  81. shotgun/codebase/core/work_distributor.py +254 -0
  82. shotgun/codebase/core/worker.py +768 -0
  83. shotgun/codebase/indexing_state.py +86 -0
  84. shotgun/codebase/models.py +94 -0
  85. shotgun/codebase/service.py +13 -0
  86. shotgun/exceptions.py +9 -9
  87. shotgun/main.py +3 -16
  88. shotgun/posthog_telemetry.py +165 -24
  89. shotgun/prompts/agents/file_read.j2 +48 -0
  90. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -47
  91. shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
  92. shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
  93. shotgun/prompts/agents/partials/router_delegation_mode.j2 +21 -22
  94. shotgun/prompts/agents/plan.j2 +14 -0
  95. shotgun/prompts/agents/router.j2 +531 -258
  96. shotgun/prompts/agents/specify.j2 +14 -0
  97. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  98. shotgun/prompts/agents/state/system_state.j2 +13 -11
  99. shotgun/prompts/agents/tasks.j2 +14 -0
  100. shotgun/settings.py +49 -10
  101. shotgun/tui/app.py +149 -18
  102. shotgun/tui/commands/__init__.py +9 -1
  103. shotgun/tui/components/attachment_bar.py +87 -0
  104. shotgun/tui/components/prompt_input.py +25 -28
  105. shotgun/tui/components/status_bar.py +14 -7
  106. shotgun/tui/dependencies.py +3 -8
  107. shotgun/tui/protocols.py +18 -0
  108. shotgun/tui/screens/chat/chat.tcss +15 -0
  109. shotgun/tui/screens/chat/chat_screen.py +766 -235
  110. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  111. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  112. shotgun/tui/screens/chat_screen/command_providers.py +0 -10
  113. shotgun/tui/screens/chat_screen/history/chat_history.py +54 -14
  114. shotgun/tui/screens/chat_screen/history/formatters.py +22 -0
  115. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  116. shotgun/tui/screens/database_locked_dialog.py +219 -0
  117. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  118. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  119. shotgun/tui/screens/model_picker.py +1 -3
  120. shotgun/tui/screens/models.py +11 -0
  121. shotgun/tui/state/processing_state.py +19 -0
  122. shotgun/tui/widgets/widget_coordinator.py +18 -0
  123. shotgun/utils/file_system_utils.py +4 -1
  124. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +87 -34
  125. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/RECORD +128 -79
  126. shotgun/cli/export.py +0 -81
  127. shotgun/cli/plan.py +0 -73
  128. shotgun/cli/research.py +0 -93
  129. shotgun/cli/specify.py +0 -70
  130. shotgun/cli/tasks.py +0 -78
  131. shotgun/sentry_telemetry.py +0 -232
  132. shotgun/tui/screens/onboarding.py +0 -584
  133. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
  134. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
  135. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,453 @@
1
+ """Utility functions for markdown parsing and manipulation."""
2
+
3
+ import re
4
+ from difflib import SequenceMatcher
5
+ from pathlib import Path
6
+
7
+ import aiofiles
8
+ import aiofiles.os
9
+
10
+ from .models import (
11
+ CloseMatch,
12
+ HeadingList,
13
+ HeadingMatch,
14
+ MarkdownFileContext,
15
+ MarkdownHeading,
16
+ SectionMatchResult,
17
+ SectionNumber,
18
+ )
19
+
20
+
21
+ def get_heading_level(line: str) -> int | None:
22
+ """Get the heading level (1-6) from a line, or None if not a heading.
23
+
24
+ Args:
25
+ line: A line of text to check
26
+
27
+ Returns:
28
+ The heading level (1-6) or None if not a heading
29
+ """
30
+ match = re.match(r"^(#{1,6})\s+", line)
31
+ return len(match.group(1)) if match else None
32
+
33
+
34
+ def extract_headings(content: str) -> HeadingList:
35
+ """Extract all headings from markdown content.
36
+
37
+ Args:
38
+ content: The markdown content to parse
39
+
40
+ Returns:
41
+ List of MarkdownHeading objects
42
+ """
43
+ headings: HeadingList = []
44
+ for i, line in enumerate(content.splitlines()):
45
+ level = get_heading_level(line)
46
+ if level is not None:
47
+ headings.append(MarkdownHeading(line_number=i, text=line, level=level))
48
+ return headings
49
+
50
+
51
+ def find_matching_heading(
52
+ headings: HeadingList,
53
+ target: str,
54
+ threshold: float = 0.8,
55
+ ) -> HeadingMatch | None:
56
+ """Find the best matching heading above the similarity threshold.
57
+
58
+ Args:
59
+ headings: List of MarkdownHeading objects
60
+ target: The target heading to match (e.g., "## Requirements")
61
+ threshold: Minimum similarity ratio (0.0-1.0)
62
+
63
+ Returns:
64
+ HeadingMatch with the matched heading and confidence, or None if no match
65
+ """
66
+ best_heading: MarkdownHeading | None = None
67
+ best_ratio = 0.0
68
+
69
+ # Normalize target: strip leading #s and whitespace, lowercase
70
+ norm_target = target.lstrip("#").strip().lower()
71
+
72
+ for heading in headings:
73
+ ratio = SequenceMatcher(None, heading.normalized_text, norm_target).ratio()
74
+
75
+ if ratio > best_ratio and ratio >= threshold:
76
+ best_ratio = ratio
77
+ best_heading = heading
78
+
79
+ if best_heading is not None:
80
+ return HeadingMatch(heading=best_heading, confidence=best_ratio)
81
+ return None
82
+
83
+
84
+ def find_close_matches(
85
+ headings: HeadingList,
86
+ target: str,
87
+ threshold: float = 0.6,
88
+ max_matches: int = 3,
89
+ ) -> list[CloseMatch]:
90
+ """Find headings that are close matches to the target.
91
+
92
+ Used for error messages when no exact match is found.
93
+
94
+ Args:
95
+ headings: List of MarkdownHeading objects
96
+ target: The target heading to match
97
+ threshold: Minimum similarity ratio for inclusion
98
+ max_matches: Maximum number of matches to return
99
+
100
+ Returns:
101
+ List of CloseMatch objects, sorted by confidence descending
102
+ """
103
+ norm_target = target.lstrip("#").strip().lower()
104
+ matches: list[CloseMatch] = []
105
+
106
+ for heading in headings:
107
+ ratio = SequenceMatcher(None, heading.normalized_text, norm_target).ratio()
108
+ if ratio >= threshold:
109
+ matches.append(CloseMatch(heading_text=heading.text, confidence=ratio))
110
+
111
+ # Sort by confidence descending
112
+ matches.sort(key=lambda x: x.confidence, reverse=True)
113
+ return matches[:max_matches]
114
+
115
+
116
+ def find_section_bounds(
117
+ lines: list[str],
118
+ heading_line_num: int,
119
+ heading_level: int,
120
+ ) -> tuple[int, int]:
121
+ """Find the boundaries of a section.
122
+
123
+ The section includes everything from the heading to the next heading
124
+ at the same or higher level (exclusive), or end of file.
125
+
126
+ Args:
127
+ lines: All lines of the file
128
+ heading_line_num: Line number of the section heading
129
+ heading_level: Level of the section heading (1-6)
130
+
131
+ Returns:
132
+ Tuple of (start_line, end_line) where end_line is exclusive
133
+ """
134
+ start = heading_line_num
135
+ end = len(lines) # Default to EOF
136
+
137
+ for i in range(heading_line_num + 1, len(lines)):
138
+ level = get_heading_level(lines[i])
139
+ if level is not None and level <= heading_level:
140
+ end = i
141
+ break
142
+
143
+ return (start, end)
144
+
145
+
146
+ def detect_line_ending(content: str) -> str:
147
+ """Detect the line ending style used in the content.
148
+
149
+ Args:
150
+ content: The file content
151
+
152
+ Returns:
153
+ The line ending string ('\\r\\n' or '\\n')
154
+ """
155
+ if "\r\n" in content:
156
+ return "\r\n"
157
+ return "\n"
158
+
159
+
160
+ def normalize_section_content(content: str) -> str:
161
+ """Normalize content to have no leading whitespace and single trailing newline.
162
+
163
+ Args:
164
+ content: The content to normalize
165
+
166
+ Returns:
167
+ Normalized content
168
+ """
169
+ return content.strip() + "\n"
170
+
171
+
172
+ def split_normalized_content(content: str) -> list[str]:
173
+ """Normalize content and split into lines for insertion.
174
+
175
+ Strips whitespace, ensures consistent formatting, and splits into lines
176
+ ready for insertion into a markdown file.
177
+
178
+ Args:
179
+ content: The content to normalize and split
180
+
181
+ Returns:
182
+ List of lines (without trailing empty line from split)
183
+ """
184
+ normalized = normalize_section_content(content)
185
+ lines = normalized.split("\n")
186
+ # Remove empty last line from split (since normalize_section_content adds \n)
187
+ if lines and lines[-1] == "":
188
+ lines.pop()
189
+ return lines
190
+
191
+
192
+ def parse_section_number(heading_text: str) -> SectionNumber | None:
193
+ """Parse section number from heading text.
194
+
195
+ Matches patterns like:
196
+ - "## 3. Title" -> prefix="3", has_trailing_dot=True
197
+ - "### 4.4 Title" -> prefix="4.4", has_trailing_dot=False
198
+ - "### 4.4. Title" -> prefix="4.4", has_trailing_dot=True
199
+ - "## 10.2.3 Title" -> prefix="10.2.3", has_trailing_dot=False
200
+
201
+ Args:
202
+ heading_text: The full heading line (e.g., "### 4.4 Title")
203
+
204
+ Returns:
205
+ SectionNumber if a number is found, None otherwise
206
+ """
207
+ # Pattern: ## <number>[.<number>...][.] <title>
208
+ # The number must be at the start after the hashes
209
+ match = re.match(r"^#{1,6}\s+(\d+(?:\.\d+)*)(\.?)\s+", heading_text)
210
+ if match:
211
+ return SectionNumber(
212
+ prefix=match.group(1),
213
+ has_trailing_dot=bool(match.group(2)),
214
+ )
215
+ return None
216
+
217
+
218
+ def increment_section_number(section_num: SectionNumber) -> str:
219
+ """Increment the last component of a section number.
220
+
221
+ Examples:
222
+ - "4.4" -> "4.5"
223
+ - "3" with trailing dot -> "4."
224
+ - "10.2.3" -> "10.2.4"
225
+
226
+ Args:
227
+ section_num: The parsed section number
228
+
229
+ Returns:
230
+ The incremented number string (with trailing dot if original had one)
231
+ """
232
+ parts = section_num.prefix.split(".")
233
+ parts[-1] = str(int(parts[-1]) + 1)
234
+ result = ".".join(parts)
235
+ if section_num.has_trailing_dot:
236
+ result += "."
237
+ return result
238
+
239
+
240
+ def decrement_section_number(section_num: SectionNumber) -> str:
241
+ """Decrement the last component of a section number.
242
+
243
+ Examples:
244
+ - "4.5" -> "4.4"
245
+ - "4" with trailing dot -> "3."
246
+
247
+ Args:
248
+ section_num: The parsed section number
249
+
250
+ Returns:
251
+ The decremented number string (with trailing dot if original had one)
252
+ """
253
+ parts = section_num.prefix.split(".")
254
+ parts[-1] = str(int(parts[-1]) - 1)
255
+ result = ".".join(parts)
256
+ if section_num.has_trailing_dot:
257
+ result += "."
258
+ return result
259
+
260
+
261
+ def renumber_headings_after(
262
+ lines: list[str],
263
+ start_line: int,
264
+ heading_level: int,
265
+ increment: bool = True,
266
+ ) -> list[str]:
267
+ """Renumber all numbered headings at the given level after start_line.
268
+
269
+ Only renumbers headings at exactly the same level.
270
+ Stops when encountering a heading at a higher level (lower number).
271
+
272
+ Args:
273
+ lines: All lines of the file
274
+ start_line: Line number to start renumbering from (inclusive)
275
+ heading_level: The heading level to renumber (1-6)
276
+ increment: True to increment numbers, False to decrement
277
+
278
+ Returns:
279
+ New list of lines with renumbered headings
280
+ """
281
+ new_lines = lines.copy()
282
+
283
+ for i in range(start_line, len(new_lines)):
284
+ level = get_heading_level(new_lines[i])
285
+ if level is None:
286
+ continue
287
+
288
+ # Stop if we hit a higher-level heading (parent section ended)
289
+ if level < heading_level:
290
+ break
291
+
292
+ # Only renumber headings at the exact same level
293
+ if level != heading_level:
294
+ continue
295
+
296
+ section_num = parse_section_number(new_lines[i])
297
+ if section_num is None:
298
+ continue
299
+
300
+ # Calculate new number
301
+ if increment:
302
+ new_num = increment_section_number(section_num)
303
+ else:
304
+ new_num = decrement_section_number(section_num)
305
+
306
+ # Replace the number in the heading
307
+ new_lines[i] = re.sub(
308
+ r"^(#{1,6}\s+)\d+(?:\.\d+)*\.?\s+",
309
+ f"\\g<1>{new_num} ",
310
+ new_lines[i],
311
+ )
312
+
313
+ return new_lines
314
+
315
+
316
+ async def load_markdown_file(
317
+ file_path: Path,
318
+ filename: str,
319
+ ) -> MarkdownFileContext | str:
320
+ """Load a markdown file and prepare it for section operations.
321
+
322
+ Handles file reading, line ending detection, CRLF normalization,
323
+ and heading extraction.
324
+
325
+ Args:
326
+ file_path: Absolute path to the file
327
+ filename: Original filename for error messages
328
+
329
+ Returns:
330
+ MarkdownFileContext on success, or error message string on failure
331
+ """
332
+ # Check if file exists
333
+ if not await aiofiles.os.path.exists(file_path):
334
+ return f"Error: File '{filename}' not found"
335
+
336
+ # Read file content (newline="" preserves original line endings)
337
+ async with aiofiles.open(file_path, encoding="utf-8", newline="") as f:
338
+ content = await f.read()
339
+
340
+ # Detect line ending style
341
+ line_ending = detect_line_ending(content)
342
+ lines = content.split("\n")
343
+
344
+ # Remove \r from lines if CRLF
345
+ if line_ending == "\r\n":
346
+ lines = [line.rstrip("\r") for line in lines]
347
+
348
+ # Extract headings
349
+ headings = extract_headings(content)
350
+
351
+ if not headings:
352
+ return f"Error: No headings found in '{filename}'. Cannot manipulate sections in files without headings."
353
+
354
+ return MarkdownFileContext(
355
+ file_path=file_path,
356
+ filename=filename,
357
+ lines=lines,
358
+ line_ending=line_ending,
359
+ headings=headings,
360
+ )
361
+
362
+
363
+ def find_and_validate_section(
364
+ ctx: MarkdownFileContext,
365
+ target_heading: str,
366
+ ) -> SectionMatchResult:
367
+ """Find a section by heading with fuzzy matching and validate the match.
368
+
369
+ Handles:
370
+ - Finding the best matching heading
371
+ - Detecting "no match" with helpful suggestions
372
+ - Detecting ambiguous matches
373
+ - Finding section boundaries
374
+
375
+ Args:
376
+ ctx: The loaded markdown file context
377
+ target_heading: The heading to search for (fuzzy matched)
378
+
379
+ Returns:
380
+ SectionMatchResult with either success data or error message
381
+ """
382
+ # Find matching heading
383
+ match_result = find_matching_heading(ctx.headings, target_heading)
384
+
385
+ if match_result is None:
386
+ # No match found - provide helpful error with available headings
387
+ available = [h.text for h in ctx.headings]
388
+ close = find_close_matches(ctx.headings, target_heading)
389
+
390
+ if close and close[0].confidence >= 0.6:
391
+ # There are close matches but below threshold
392
+ close_display = ", ".join(
393
+ f"'{m.heading_text}' ({int(m.confidence * 100)}%)" for m in close
394
+ )
395
+ return SectionMatchResult(
396
+ error=f"No section matching '{target_heading}' found in {ctx.filename}. "
397
+ f"Did you mean: {close_display}"
398
+ )
399
+ else:
400
+ # List available headings
401
+ available_display = ", ".join(available[:5])
402
+ if len(available) > 5:
403
+ available_display += f" (+{len(available) - 5} more)"
404
+ return SectionMatchResult(
405
+ error=f"No section matching '{target_heading}' found in {ctx.filename}. "
406
+ f"Available headings: {available_display}"
407
+ )
408
+
409
+ matched = match_result.heading
410
+ confidence = match_result.confidence
411
+
412
+ # Check for ambiguous matches (multiple close matches)
413
+ if confidence < 1.0:
414
+ close = find_close_matches(
415
+ ctx.headings, target_heading, threshold=confidence - 0.1
416
+ )
417
+ if len(close) > 1 and close[1].confidence >= confidence - 0.05:
418
+ # Second match is very close to first - ambiguous
419
+ close_display = ", ".join(
420
+ f"'{m.heading_text}' ({int(m.confidence * 100)}%)" for m in close[:3]
421
+ )
422
+ return SectionMatchResult(
423
+ error=f"Multiple sections closely match '{target_heading}' in {ctx.filename}: "
424
+ f"{close_display}. Please be more specific."
425
+ )
426
+
427
+ # Find section boundaries
428
+ start_line, end_line = find_section_bounds(
429
+ ctx.lines, matched.line_number, matched.level
430
+ )
431
+
432
+ return SectionMatchResult(
433
+ heading=matched,
434
+ confidence=confidence,
435
+ start_line=start_line,
436
+ end_line=end_line,
437
+ )
438
+
439
+
440
+ async def write_markdown_file(ctx: MarkdownFileContext, new_lines: list[str]) -> None:
441
+ """Write modified lines back to a markdown file.
442
+
443
+ Args:
444
+ ctx: The markdown file context (provides path and line ending)
445
+ new_lines: The new lines to write
446
+ """
447
+ new_content = ctx.line_ending.join(new_lines)
448
+ # Ensure file ends with a newline (standard for text files, prevents corruption
449
+ # when multiple operations are performed sequentially)
450
+ if new_content and not new_content.endswith(ctx.line_ending):
451
+ new_content += ctx.line_ending
452
+ async with aiofiles.open(ctx.file_path, "w", encoding="utf-8", newline="") as f:
453
+ await f.write(new_content)
@@ -12,10 +12,10 @@ from collections.abc import Callable
12
12
  from enum import StrEnum
13
13
  from typing import TypeVar, overload
14
14
 
15
- import sentry_sdk
16
15
  from pydantic import BaseModel
17
16
 
18
17
  from shotgun.logging_config import get_logger
18
+ from shotgun.posthog_telemetry import track_event
19
19
 
20
20
  logger = get_logger(__name__)
21
21
 
@@ -41,11 +41,13 @@ class ToolDisplayConfig(BaseModel):
41
41
  Attributes:
42
42
  display_text: Text to show (e.g., "Reading file", "Querying code")
43
43
  key_arg: Primary argument to extract from tool args for display
44
+ secondary_key_arg: Optional secondary argument to display alongside primary
44
45
  hide: Whether to completely hide this tool call from the UI
45
46
  """
46
47
 
47
48
  display_text: str
48
49
  key_arg: str
50
+ secondary_key_arg: str | None = None
49
51
  hide: bool = False
50
52
 
51
53
 
@@ -64,6 +66,16 @@ def register_tool(
64
66
  ) -> Callable[[F], F]: ...
65
67
 
66
68
 
69
+ @overload
70
+ def register_tool(
71
+ category: ToolCategory,
72
+ display_text: str,
73
+ key_arg: str,
74
+ *,
75
+ secondary_key_arg: str,
76
+ ) -> Callable[[F], F]: ...
77
+
78
+
67
79
  @overload
68
80
  def register_tool(
69
81
  category: ToolCategory,
@@ -74,11 +86,23 @@ def register_tool(
74
86
  ) -> Callable[[F], F]: ...
75
87
 
76
88
 
89
+ @overload
77
90
  def register_tool(
78
91
  category: ToolCategory,
79
92
  display_text: str,
80
93
  key_arg: str,
81
94
  *,
95
+ secondary_key_arg: str,
96
+ hide: bool,
97
+ ) -> Callable[[F], F]: ...
98
+
99
+
100
+ def register_tool(
101
+ category: ToolCategory,
102
+ display_text: str,
103
+ key_arg: str,
104
+ *,
105
+ secondary_key_arg: str | None = None,
82
106
  hide: bool = False,
83
107
  ) -> Callable[[F], F]:
84
108
  """Decorator to register a tool's category and display configuration.
@@ -87,6 +111,7 @@ def register_tool(
87
111
  category: The ToolCategory enum value for this tool
88
112
  display_text: Text to show (e.g., "Reading file", "Querying code")
89
113
  key_arg: Primary argument name to extract for display (e.g., "query", "filename")
114
+ secondary_key_arg: Optional secondary argument to display alongside primary
90
115
  hide: Whether to hide this tool call completely from the UI (default: False)
91
116
 
92
117
  Returns:
@@ -95,6 +120,7 @@ def register_tool(
95
120
  Display Format:
96
121
  - When key_arg value is missing: Shows just display_text (e.g., "Reading file")
97
122
  - When key_arg value is present: Shows "display_text: key_arg_value" (e.g., "Reading file: foo.py")
123
+ - With secondary_key_arg: Shows "display_text: key_arg_value → secondary_value"
98
124
 
99
125
  Example:
100
126
  @register_tool(
@@ -104,6 +130,15 @@ def register_tool(
104
130
  )
105
131
  async def query_graph(ctx: RunContext[AgentDeps], query: str) -> str:
106
132
  ...
133
+
134
+ @register_tool(
135
+ category=ToolCategory.ARTIFACT_MANAGEMENT,
136
+ display_text="Replacing section",
137
+ key_arg="filename",
138
+ secondary_key_arg="section_heading",
139
+ )
140
+ async def replace_markdown_section(...) -> str:
141
+ ...
107
142
  """
108
143
 
109
144
  def decorator(func: F) -> F:
@@ -115,6 +150,7 @@ def register_tool(
115
150
  config = ToolDisplayConfig(
116
151
  display_text=display_text,
117
152
  key_arg=key_arg,
153
+ secondary_key_arg=secondary_key_arg,
118
154
  hide=hide,
119
155
  )
120
156
  _TOOL_DISPLAY_REGISTRY[tool_name] = config
@@ -130,7 +166,7 @@ tool_category = register_tool
130
166
 
131
167
 
132
168
  def get_tool_category(tool_name: str) -> ToolCategory:
133
- """Get category for a tool, logging unknown tools to Sentry.
169
+ """Get category for a tool, logging unknown tools to telemetry.
134
170
 
135
171
  Args:
136
172
  tool_name: Name of the tool to look up
@@ -142,10 +178,9 @@ def get_tool_category(tool_name: str) -> ToolCategory:
142
178
 
143
179
  if category is None:
144
180
  logger.warning(f"Unknown tool encountered in context analysis: {tool_name}")
145
- sentry_sdk.capture_message(
146
- f"Unknown tool in context analysis: {tool_name}",
147
- level="warning",
148
- extras={"tool_name": tool_name},
181
+ track_event(
182
+ "unknown_tool_encountered",
183
+ properties={"tool_name": tool_name},
149
184
  )
150
185
  return ToolCategory.UNKNOWN
151
186
 
@@ -185,6 +220,7 @@ def register_tool_display(
185
220
  display_text: str,
186
221
  key_arg: str,
187
222
  *,
223
+ secondary_key_arg: str | None = None,
188
224
  hide: bool = False,
189
225
  ) -> None:
190
226
  """Register a display config for a special tool that doesn't have a decorator.
@@ -195,11 +231,13 @@ def register_tool_display(
195
231
  tool_name: Name of the special tool
196
232
  display_text: Text to show (e.g., "Reading file", "Querying code")
197
233
  key_arg: Primary argument name to extract for display
234
+ secondary_key_arg: Optional secondary argument to display alongside primary
198
235
  hide: Whether to hide this tool call completely
199
236
  """
200
237
  config = ToolDisplayConfig(
201
238
  display_text=display_text,
202
239
  key_arg=key_arg,
240
+ secondary_key_arg=secondary_key_arg,
203
241
  hide=hide,
204
242
  )
205
243
  _TOOL_DISPLAY_REGISTRY[tool_name] = config
@@ -1,5 +1,7 @@
1
1
  """OpenAI web search tool implementation."""
2
2
 
3
+ import asyncio
4
+
3
5
  from openai import AsyncOpenAI
4
6
  from opentelemetry import trace
5
7
 
@@ -15,6 +17,9 @@ logger = get_logger(__name__)
15
17
  # Global prompt loader instance
16
18
  prompt_loader = PromptLoader()
17
19
 
20
+ # Timeout for web search API call (in seconds)
21
+ WEB_SEARCH_TIMEOUT = 120 # 2 minutes
22
+
18
23
 
19
24
  @register_tool(
20
25
  category=ToolCategory.WEB_RESEARCH,
@@ -64,29 +69,43 @@ async def openai_web_search_tool(query: str) -> str:
64
69
  )
65
70
 
66
71
  client = AsyncOpenAI(api_key=api_key)
67
- response = await client.responses.create(
68
- model="gpt-5-mini",
69
- input=[
70
- {"role": "user", "content": [{"type": "input_text", "text": prompt}]}
71
- ],
72
- text={
73
- "format": {"type": "text"},
74
- "verbosity": "high",
75
- }, # Increased from medium
76
- reasoning={"effort": "medium", "summary": "auto"},
77
- tools=[
78
- {
79
- "type": "web_search",
80
- "user_location": {"type": "approximate"},
81
- "search_context_size": "high", # Increased from low for more context
82
- }
83
- ],
84
- store=False,
85
- include=[
86
- "reasoning.encrypted_content",
87
- "web_search_call.action.sources", # pyright: ignore[reportArgumentType]
88
- ],
89
- )
72
+
73
+ # Wrap API call with timeout to prevent indefinite hangs
74
+ try:
75
+ response = await asyncio.wait_for(
76
+ client.responses.create(
77
+ model="gpt-5-mini",
78
+ input=[
79
+ {
80
+ "role": "user",
81
+ "content": [{"type": "input_text", "text": prompt}],
82
+ }
83
+ ],
84
+ text={
85
+ "format": {"type": "text"},
86
+ "verbosity": "high",
87
+ },
88
+ reasoning={"effort": "medium", "summary": "auto"},
89
+ tools=[
90
+ {
91
+ "type": "web_search",
92
+ "user_location": {"type": "approximate"},
93
+ "search_context_size": "high",
94
+ }
95
+ ],
96
+ store=False,
97
+ include=[
98
+ "reasoning.encrypted_content",
99
+ "web_search_call.action.sources", # pyright: ignore[reportArgumentType]
100
+ ],
101
+ ),
102
+ timeout=WEB_SEARCH_TIMEOUT,
103
+ )
104
+ except asyncio.TimeoutError:
105
+ error_msg = f"Web search timed out after {WEB_SEARCH_TIMEOUT} seconds"
106
+ logger.warning("⏱️ %s", error_msg)
107
+ span.set_attribute("output.value", f"**Error:**\n {error_msg}\n")
108
+ return error_msg
90
109
 
91
110
  result_text = response.output_text or "No content returned"
92
111