shotgun-sh 0.2.29.dev2__py3-none-any.whl → 0.6.1.dev1__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 shotgun-sh might be problematic. Click here for more details.

Files changed (161) hide show
  1. shotgun/agents/agent_manager.py +497 -30
  2. shotgun/agents/cancellation.py +103 -0
  3. shotgun/agents/common.py +90 -77
  4. shotgun/agents/config/README.md +0 -1
  5. shotgun/agents/config/manager.py +52 -8
  6. shotgun/agents/config/models.py +48 -45
  7. shotgun/agents/config/provider.py +44 -29
  8. shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
  9. shotgun/agents/conversation/history/token_counting/base.py +51 -9
  10. shotgun/agents/export.py +12 -13
  11. shotgun/agents/file_read.py +176 -0
  12. shotgun/agents/messages.py +15 -3
  13. shotgun/agents/models.py +90 -2
  14. shotgun/agents/plan.py +12 -13
  15. shotgun/agents/research.py +13 -10
  16. shotgun/agents/router/__init__.py +47 -0
  17. shotgun/agents/router/models.py +384 -0
  18. shotgun/agents/router/router.py +185 -0
  19. shotgun/agents/router/tools/__init__.py +18 -0
  20. shotgun/agents/router/tools/delegation_tools.py +557 -0
  21. shotgun/agents/router/tools/plan_tools.py +403 -0
  22. shotgun/agents/runner.py +17 -2
  23. shotgun/agents/specify.py +12 -13
  24. shotgun/agents/tasks.py +12 -13
  25. shotgun/agents/tools/__init__.py +8 -0
  26. shotgun/agents/tools/codebase/directory_lister.py +27 -39
  27. shotgun/agents/tools/codebase/file_read.py +26 -35
  28. shotgun/agents/tools/codebase/query_graph.py +9 -0
  29. shotgun/agents/tools/codebase/retrieve_code.py +9 -0
  30. shotgun/agents/tools/file_management.py +81 -3
  31. shotgun/agents/tools/file_read_tools/__init__.py +7 -0
  32. shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
  33. shotgun/agents/tools/markdown_tools/__init__.py +62 -0
  34. shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
  35. shotgun/agents/tools/markdown_tools/models.py +86 -0
  36. shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
  37. shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
  38. shotgun/agents/tools/markdown_tools/utils.py +453 -0
  39. shotgun/agents/tools/registry.py +41 -0
  40. shotgun/agents/tools/web_search/__init__.py +1 -2
  41. shotgun/agents/tools/web_search/gemini.py +1 -3
  42. shotgun/agents/tools/web_search/openai.py +42 -23
  43. shotgun/attachments/__init__.py +41 -0
  44. shotgun/attachments/errors.py +60 -0
  45. shotgun/attachments/models.py +107 -0
  46. shotgun/attachments/parser.py +257 -0
  47. shotgun/attachments/processor.py +193 -0
  48. shotgun/cli/clear.py +2 -2
  49. shotgun/cli/codebase/commands.py +181 -65
  50. shotgun/cli/compact.py +2 -2
  51. shotgun/cli/context.py +2 -2
  52. shotgun/cli/run.py +90 -0
  53. shotgun/cli/spec/backup.py +2 -1
  54. shotgun/cli/spec/commands.py +2 -0
  55. shotgun/cli/spec/models.py +18 -0
  56. shotgun/cli/spec/pull_service.py +122 -68
  57. shotgun/codebase/__init__.py +2 -0
  58. shotgun/codebase/benchmarks/__init__.py +35 -0
  59. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  60. shotgun/codebase/benchmarks/exporters.py +119 -0
  61. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  62. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  63. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  64. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  65. shotgun/codebase/benchmarks/models.py +129 -0
  66. shotgun/codebase/core/__init__.py +4 -0
  67. shotgun/codebase/core/call_resolution.py +91 -0
  68. shotgun/codebase/core/change_detector.py +11 -6
  69. shotgun/codebase/core/errors.py +159 -0
  70. shotgun/codebase/core/extractors/__init__.py +23 -0
  71. shotgun/codebase/core/extractors/base.py +138 -0
  72. shotgun/codebase/core/extractors/factory.py +63 -0
  73. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  74. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  75. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  76. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  77. shotgun/codebase/core/extractors/protocol.py +109 -0
  78. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  79. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  80. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  81. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  82. shotgun/codebase/core/extractors/types.py +15 -0
  83. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  84. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  85. shotgun/codebase/core/gitignore.py +252 -0
  86. shotgun/codebase/core/ingestor.py +644 -354
  87. shotgun/codebase/core/kuzu_compat.py +119 -0
  88. shotgun/codebase/core/language_config.py +239 -0
  89. shotgun/codebase/core/manager.py +256 -46
  90. shotgun/codebase/core/metrics_collector.py +310 -0
  91. shotgun/codebase/core/metrics_types.py +347 -0
  92. shotgun/codebase/core/parallel_executor.py +424 -0
  93. shotgun/codebase/core/work_distributor.py +254 -0
  94. shotgun/codebase/core/worker.py +768 -0
  95. shotgun/codebase/indexing_state.py +86 -0
  96. shotgun/codebase/models.py +94 -0
  97. shotgun/codebase/service.py +13 -0
  98. shotgun/exceptions.py +1 -1
  99. shotgun/main.py +2 -10
  100. shotgun/prompts/agents/export.j2 +2 -0
  101. shotgun/prompts/agents/file_read.j2 +48 -0
  102. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +20 -28
  103. shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
  104. shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
  105. shotgun/prompts/agents/partials/router_delegation_mode.j2 +35 -0
  106. shotgun/prompts/agents/plan.j2 +43 -1
  107. shotgun/prompts/agents/research.j2 +75 -20
  108. shotgun/prompts/agents/router.j2 +713 -0
  109. shotgun/prompts/agents/specify.j2 +94 -4
  110. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  111. shotgun/prompts/agents/state/system_state.j2 +24 -15
  112. shotgun/prompts/agents/tasks.j2 +77 -23
  113. shotgun/settings.py +44 -0
  114. shotgun/shotgun_web/shared_specs/upload_pipeline.py +38 -0
  115. shotgun/tui/app.py +90 -23
  116. shotgun/tui/commands/__init__.py +9 -1
  117. shotgun/tui/components/attachment_bar.py +87 -0
  118. shotgun/tui/components/mode_indicator.py +120 -25
  119. shotgun/tui/components/prompt_input.py +23 -28
  120. shotgun/tui/components/status_bar.py +5 -4
  121. shotgun/tui/dependencies.py +58 -8
  122. shotgun/tui/protocols.py +37 -0
  123. shotgun/tui/screens/chat/chat.tcss +24 -1
  124. shotgun/tui/screens/chat/chat_screen.py +1374 -211
  125. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  126. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  127. shotgun/tui/screens/chat_screen/command_providers.py +0 -97
  128. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  129. shotgun/tui/screens/chat_screen/history/chat_history.py +49 -6
  130. shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
  131. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  132. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  133. shotgun/tui/screens/chat_screen/messages.py +219 -0
  134. shotgun/tui/screens/database_locked_dialog.py +219 -0
  135. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  136. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  137. shotgun/tui/screens/model_picker.py +14 -9
  138. shotgun/tui/screens/models.py +11 -0
  139. shotgun/tui/screens/shotgun_auth.py +50 -0
  140. shotgun/tui/screens/spec_pull.py +2 -0
  141. shotgun/tui/state/processing_state.py +19 -0
  142. shotgun/tui/utils/mode_progress.py +20 -86
  143. shotgun/tui/widgets/__init__.py +2 -1
  144. shotgun/tui/widgets/approval_widget.py +152 -0
  145. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  146. shotgun/tui/widgets/plan_panel.py +129 -0
  147. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  148. shotgun/tui/widgets/widget_coordinator.py +18 -0
  149. shotgun/utils/file_system_utils.py +4 -1
  150. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/METADATA +88 -34
  151. shotgun_sh-0.6.1.dev1.dist-info/RECORD +292 -0
  152. shotgun/cli/export.py +0 -81
  153. shotgun/cli/plan.py +0 -73
  154. shotgun/cli/research.py +0 -93
  155. shotgun/cli/specify.py +0 -70
  156. shotgun/cli/tasks.py +0 -78
  157. shotgun/tui/screens/onboarding.py +0 -580
  158. shotgun_sh-0.2.29.dev2.dist-info/RECORD +0 -229
  159. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/WHEEL +0 -0
  160. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/entry_points.txt +0 -0
  161. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.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)
@@ -30,6 +30,8 @@ class ToolCategory(StrEnum):
30
30
  ARTIFACT_MANAGEMENT = "artifact_management"
31
31
  WEB_RESEARCH = "web_research"
32
32
  AGENT_RESPONSE = "agent_response"
33
+ PLANNING = "planning"
34
+ DELEGATION = "delegation"
33
35
  UNKNOWN = "unknown"
34
36
 
35
37
 
@@ -39,11 +41,13 @@ class ToolDisplayConfig(BaseModel):
39
41
  Attributes:
40
42
  display_text: Text to show (e.g., "Reading file", "Querying code")
41
43
  key_arg: Primary argument to extract from tool args for display
44
+ secondary_key_arg: Optional secondary argument to display alongside primary
42
45
  hide: Whether to completely hide this tool call from the UI
43
46
  """
44
47
 
45
48
  display_text: str
46
49
  key_arg: str
50
+ secondary_key_arg: str | None = None
47
51
  hide: bool = False
48
52
 
49
53
 
@@ -62,6 +66,16 @@ def register_tool(
62
66
  ) -> Callable[[F], F]: ...
63
67
 
64
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
+
65
79
  @overload
66
80
  def register_tool(
67
81
  category: ToolCategory,
@@ -72,11 +86,23 @@ def register_tool(
72
86
  ) -> Callable[[F], F]: ...
73
87
 
74
88
 
89
+ @overload
75
90
  def register_tool(
76
91
  category: ToolCategory,
77
92
  display_text: str,
78
93
  key_arg: str,
79
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,
80
106
  hide: bool = False,
81
107
  ) -> Callable[[F], F]:
82
108
  """Decorator to register a tool's category and display configuration.
@@ -85,6 +111,7 @@ def register_tool(
85
111
  category: The ToolCategory enum value for this tool
86
112
  display_text: Text to show (e.g., "Reading file", "Querying code")
87
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
88
115
  hide: Whether to hide this tool call completely from the UI (default: False)
89
116
 
90
117
  Returns:
@@ -93,6 +120,7 @@ def register_tool(
93
120
  Display Format:
94
121
  - When key_arg value is missing: Shows just display_text (e.g., "Reading file")
95
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"
96
124
 
97
125
  Example:
98
126
  @register_tool(
@@ -102,6 +130,15 @@ def register_tool(
102
130
  )
103
131
  async def query_graph(ctx: RunContext[AgentDeps], query: str) -> str:
104
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
+ ...
105
142
  """
106
143
 
107
144
  def decorator(func: F) -> F:
@@ -113,6 +150,7 @@ def register_tool(
113
150
  config = ToolDisplayConfig(
114
151
  display_text=display_text,
115
152
  key_arg=key_arg,
153
+ secondary_key_arg=secondary_key_arg,
116
154
  hide=hide,
117
155
  )
118
156
  _TOOL_DISPLAY_REGISTRY[tool_name] = config
@@ -183,6 +221,7 @@ def register_tool_display(
183
221
  display_text: str,
184
222
  key_arg: str,
185
223
  *,
224
+ secondary_key_arg: str | None = None,
186
225
  hide: bool = False,
187
226
  ) -> None:
188
227
  """Register a display config for a special tool that doesn't have a decorator.
@@ -193,11 +232,13 @@ def register_tool_display(
193
232
  tool_name: Name of the special tool
194
233
  display_text: Text to show (e.g., "Reading file", "Querying code")
195
234
  key_arg: Primary argument name to extract for display
235
+ secondary_key_arg: Optional secondary argument to display alongside primary
196
236
  hide: Whether to hide this tool call completely
197
237
  """
198
238
  config = ToolDisplayConfig(
199
239
  display_text=display_text,
200
240
  key_arg=key_arg,
241
+ secondary_key_arg=secondary_key_arg,
201
242
  hide=hide,
202
243
  )
203
244
  _TOOL_DISPLAY_REGISTRY[tool_name] = config
@@ -44,9 +44,8 @@ async def get_available_web_search_tools() -> list[WebSearchTool]:
44
44
  # Check if using Shotgun Account
45
45
  config_manager = get_config_manager()
46
46
  config = await config_manager.load()
47
- has_shotgun_key = config.shotgun.api_key is not None
48
47
 
49
- if has_shotgun_key:
48
+ if config.shotgun.has_valid_account:
50
49
  logger.debug("🔑 Shotgun Account - only Gemini web search available")
51
50
 
52
51
  # Gemini: Only search tool available for Shotgun Account
@@ -1,7 +1,7 @@
1
1
  """Gemini web search tool implementation."""
2
2
 
3
3
  from opentelemetry import trace
4
- from pydantic_ai.messages import ModelMessage, ModelRequest
4
+ from pydantic_ai.messages import ModelMessage, ModelRequest, TextPart
5
5
  from pydantic_ai.settings import ModelSettings
6
6
 
7
7
  from shotgun.agents.config import get_provider_model
@@ -82,8 +82,6 @@ async def gemini_web_search_tool(query: str) -> str:
82
82
  )
83
83
 
84
84
  # Extract text from response
85
- from pydantic_ai.messages import TextPart
86
-
87
85
  result_text = "No content returned from search"
88
86
  if response.parts:
89
87
  for part in response.parts:
@@ -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