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.
- shotgun/agents/agent_manager.py +307 -8
- shotgun/agents/cancellation.py +103 -0
- shotgun/agents/common.py +12 -0
- shotgun/agents/config/README.md +0 -1
- shotgun/agents/config/manager.py +10 -7
- shotgun/agents/config/models.py +5 -27
- shotgun/agents/config/provider.py +44 -27
- shotgun/agents/conversation/history/token_counting/base.py +51 -9
- shotgun/agents/file_read.py +176 -0
- shotgun/agents/messages.py +15 -3
- shotgun/agents/models.py +24 -1
- shotgun/agents/router/models.py +8 -0
- shotgun/agents/router/tools/delegation_tools.py +55 -1
- shotgun/agents/router/tools/plan_tools.py +88 -7
- shotgun/agents/runner.py +17 -2
- shotgun/agents/tools/__init__.py +8 -0
- shotgun/agents/tools/codebase/directory_lister.py +27 -39
- shotgun/agents/tools/codebase/file_read.py +26 -35
- shotgun/agents/tools/codebase/query_graph.py +9 -0
- shotgun/agents/tools/codebase/retrieve_code.py +9 -0
- shotgun/agents/tools/file_management.py +32 -2
- shotgun/agents/tools/file_read_tools/__init__.py +7 -0
- shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
- shotgun/agents/tools/markdown_tools/__init__.py +62 -0
- shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
- shotgun/agents/tools/markdown_tools/models.py +86 -0
- shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
- shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
- shotgun/agents/tools/markdown_tools/utils.py +453 -0
- shotgun/agents/tools/registry.py +44 -6
- shotgun/agents/tools/web_search/openai.py +42 -23
- shotgun/attachments/__init__.py +41 -0
- shotgun/attachments/errors.py +60 -0
- shotgun/attachments/models.py +107 -0
- shotgun/attachments/parser.py +257 -0
- shotgun/attachments/processor.py +193 -0
- shotgun/build_constants.py +4 -7
- shotgun/cli/clear.py +2 -2
- shotgun/cli/codebase/commands.py +181 -65
- shotgun/cli/compact.py +2 -2
- shotgun/cli/context.py +2 -2
- shotgun/cli/error_handler.py +2 -2
- shotgun/cli/run.py +90 -0
- shotgun/cli/spec/backup.py +2 -1
- shotgun/codebase/__init__.py +2 -0
- shotgun/codebase/benchmarks/__init__.py +35 -0
- shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
- shotgun/codebase/benchmarks/exporters.py +119 -0
- shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
- shotgun/codebase/benchmarks/formatters/base.py +34 -0
- shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
- shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
- shotgun/codebase/benchmarks/models.py +129 -0
- shotgun/codebase/core/__init__.py +4 -0
- shotgun/codebase/core/call_resolution.py +91 -0
- shotgun/codebase/core/change_detector.py +11 -6
- shotgun/codebase/core/errors.py +159 -0
- shotgun/codebase/core/extractors/__init__.py +23 -0
- shotgun/codebase/core/extractors/base.py +138 -0
- shotgun/codebase/core/extractors/factory.py +63 -0
- shotgun/codebase/core/extractors/go/__init__.py +7 -0
- shotgun/codebase/core/extractors/go/extractor.py +122 -0
- shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
- shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
- shotgun/codebase/core/extractors/protocol.py +109 -0
- shotgun/codebase/core/extractors/python/__init__.py +7 -0
- shotgun/codebase/core/extractors/python/extractor.py +141 -0
- shotgun/codebase/core/extractors/rust/__init__.py +7 -0
- shotgun/codebase/core/extractors/rust/extractor.py +139 -0
- shotgun/codebase/core/extractors/types.py +15 -0
- shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
- shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
- shotgun/codebase/core/gitignore.py +252 -0
- shotgun/codebase/core/ingestor.py +644 -354
- shotgun/codebase/core/kuzu_compat.py +119 -0
- shotgun/codebase/core/language_config.py +239 -0
- shotgun/codebase/core/manager.py +256 -46
- shotgun/codebase/core/metrics_collector.py +310 -0
- shotgun/codebase/core/metrics_types.py +347 -0
- shotgun/codebase/core/parallel_executor.py +424 -0
- shotgun/codebase/core/work_distributor.py +254 -0
- shotgun/codebase/core/worker.py +768 -0
- shotgun/codebase/indexing_state.py +86 -0
- shotgun/codebase/models.py +94 -0
- shotgun/codebase/service.py +13 -0
- shotgun/exceptions.py +9 -9
- shotgun/main.py +3 -16
- shotgun/posthog_telemetry.py +165 -24
- shotgun/prompts/agents/file_read.j2 +48 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -47
- shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
- shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
- shotgun/prompts/agents/partials/router_delegation_mode.j2 +21 -22
- shotgun/prompts/agents/plan.j2 +14 -0
- shotgun/prompts/agents/router.j2 +531 -258
- shotgun/prompts/agents/specify.j2 +14 -0
- shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
- shotgun/prompts/agents/state/system_state.j2 +13 -11
- shotgun/prompts/agents/tasks.j2 +14 -0
- shotgun/settings.py +49 -10
- shotgun/tui/app.py +149 -18
- shotgun/tui/commands/__init__.py +9 -1
- shotgun/tui/components/attachment_bar.py +87 -0
- shotgun/tui/components/prompt_input.py +25 -28
- shotgun/tui/components/status_bar.py +14 -7
- shotgun/tui/dependencies.py +3 -8
- shotgun/tui/protocols.py +18 -0
- shotgun/tui/screens/chat/chat.tcss +15 -0
- shotgun/tui/screens/chat/chat_screen.py +766 -235
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
- shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
- shotgun/tui/screens/chat_screen/command_providers.py +0 -10
- shotgun/tui/screens/chat_screen/history/chat_history.py +54 -14
- shotgun/tui/screens/chat_screen/history/formatters.py +22 -0
- shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
- shotgun/tui/screens/database_locked_dialog.py +219 -0
- shotgun/tui/screens/database_timeout_dialog.py +158 -0
- shotgun/tui/screens/kuzu_error_dialog.py +135 -0
- shotgun/tui/screens/model_picker.py +1 -3
- shotgun/tui/screens/models.py +11 -0
- shotgun/tui/state/processing_state.py +19 -0
- shotgun/tui/widgets/widget_coordinator.py +18 -0
- shotgun/utils/file_system_utils.py +4 -1
- {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +87 -34
- {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/RECORD +128 -79
- shotgun/cli/export.py +0 -81
- shotgun/cli/plan.py +0 -73
- shotgun/cli/research.py +0 -93
- shotgun/cli/specify.py +0 -70
- shotgun/cli/tasks.py +0 -78
- shotgun/sentry_telemetry.py +0 -232
- shotgun/tui/screens/onboarding.py +0 -584
- {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
- {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)
|
shotgun/agents/tools/registry.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|