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.
- shotgun/agents/agent_manager.py +497 -30
- shotgun/agents/cancellation.py +103 -0
- shotgun/agents/common.py +90 -77
- shotgun/agents/config/README.md +0 -1
- shotgun/agents/config/manager.py +52 -8
- shotgun/agents/config/models.py +48 -45
- shotgun/agents/config/provider.py +44 -29
- shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
- shotgun/agents/conversation/history/token_counting/base.py +51 -9
- shotgun/agents/export.py +12 -13
- shotgun/agents/file_read.py +176 -0
- shotgun/agents/messages.py +15 -3
- shotgun/agents/models.py +90 -2
- shotgun/agents/plan.py +12 -13
- shotgun/agents/research.py +13 -10
- shotgun/agents/router/__init__.py +47 -0
- shotgun/agents/router/models.py +384 -0
- shotgun/agents/router/router.py +185 -0
- shotgun/agents/router/tools/__init__.py +18 -0
- shotgun/agents/router/tools/delegation_tools.py +557 -0
- shotgun/agents/router/tools/plan_tools.py +403 -0
- shotgun/agents/runner.py +17 -2
- shotgun/agents/specify.py +12 -13
- shotgun/agents/tasks.py +12 -13
- 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 +81 -3
- 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 +41 -0
- shotgun/agents/tools/web_search/__init__.py +1 -2
- shotgun/agents/tools/web_search/gemini.py +1 -3
- 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/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/run.py +90 -0
- shotgun/cli/spec/backup.py +2 -1
- shotgun/cli/spec/commands.py +2 -0
- shotgun/cli/spec/models.py +18 -0
- shotgun/cli/spec/pull_service.py +122 -68
- 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 +1 -1
- shotgun/main.py +2 -10
- shotgun/prompts/agents/export.j2 +2 -0
- shotgun/prompts/agents/file_read.j2 +48 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +20 -28
- 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 +35 -0
- shotgun/prompts/agents/plan.j2 +43 -1
- shotgun/prompts/agents/research.j2 +75 -20
- shotgun/prompts/agents/router.j2 +713 -0
- shotgun/prompts/agents/specify.j2 +94 -4
- shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
- shotgun/prompts/agents/state/system_state.j2 +24 -15
- shotgun/prompts/agents/tasks.j2 +77 -23
- shotgun/settings.py +44 -0
- shotgun/shotgun_web/shared_specs/upload_pipeline.py +38 -0
- shotgun/tui/app.py +90 -23
- shotgun/tui/commands/__init__.py +9 -1
- shotgun/tui/components/attachment_bar.py +87 -0
- shotgun/tui/components/mode_indicator.py +120 -25
- shotgun/tui/components/prompt_input.py +23 -28
- shotgun/tui/components/status_bar.py +5 -4
- shotgun/tui/dependencies.py +58 -8
- shotgun/tui/protocols.py +37 -0
- shotgun/tui/screens/chat/chat.tcss +24 -1
- shotgun/tui/screens/chat/chat_screen.py +1374 -211
- 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 -97
- shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
- shotgun/tui/screens/chat_screen/history/chat_history.py +49 -6
- shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
- shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
- shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
- shotgun/tui/screens/chat_screen/messages.py +219 -0
- 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 +14 -9
- shotgun/tui/screens/models.py +11 -0
- shotgun/tui/screens/shotgun_auth.py +50 -0
- shotgun/tui/screens/spec_pull.py +2 -0
- shotgun/tui/state/processing_state.py +19 -0
- shotgun/tui/utils/mode_progress.py +20 -86
- shotgun/tui/widgets/__init__.py +2 -1
- shotgun/tui/widgets/approval_widget.py +152 -0
- shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
- shotgun/tui/widgets/plan_panel.py +129 -0
- shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
- shotgun/tui/widgets/widget_coordinator.py +18 -0
- shotgun/utils/file_system_utils.py +4 -1
- {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/METADATA +88 -34
- shotgun_sh-0.6.1.dev1.dist-info/RECORD +292 -0
- 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/tui/screens/onboarding.py +0 -580
- shotgun_sh-0.2.29.dev2.dist-info/RECORD +0 -229
- {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/entry_points.txt +0 -0
- {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)
|
shotgun/agents/tools/registry.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|