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
|
@@ -6,6 +6,7 @@ These tools allow the Router to delegate work to specialized sub-agents
|
|
|
6
6
|
Sub-agents run with isolated message histories to prevent context window bloat.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
import time
|
|
9
10
|
from collections.abc import Awaitable, Callable
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
@@ -33,6 +34,7 @@ from shotgun.agents.specify import create_specify_agent, run_specify_agent
|
|
|
33
34
|
from shotgun.agents.tasks import create_tasks_agent, run_tasks_agent
|
|
34
35
|
from shotgun.agents.tools.registry import ToolCategory, register_tool
|
|
35
36
|
from shotgun.logging_config import get_logger
|
|
37
|
+
from shotgun.posthog_telemetry import track_event
|
|
36
38
|
|
|
37
39
|
logger = get_logger(__name__)
|
|
38
40
|
|
|
@@ -253,6 +255,9 @@ async def _run_sub_agent(
|
|
|
253
255
|
# Set up SubAgentContext so sub-agent knows it's being orchestrated
|
|
254
256
|
sub_agent_deps.sub_agent_context = _build_sub_agent_context(deps)
|
|
255
257
|
|
|
258
|
+
# Propagate cancellation event for responsive ESC handling in sub-agents
|
|
259
|
+
sub_agent_deps.cancellation_event = deps.cancellation_event
|
|
260
|
+
|
|
256
261
|
# Clear sub-agent's file tracker for fresh tracking
|
|
257
262
|
sub_agent_deps.file_tracker.clear()
|
|
258
263
|
|
|
@@ -260,11 +265,23 @@ async def _run_sub_agent(
|
|
|
260
265
|
deps.active_sub_agent = agent_type
|
|
261
266
|
logger.info("Delegating to %s agent: %s", agent_type.value, task[:100])
|
|
262
267
|
|
|
268
|
+
# Track delegation start time and event
|
|
269
|
+
start_time = time.time()
|
|
270
|
+
track_event(
|
|
271
|
+
"delegation_started",
|
|
272
|
+
{
|
|
273
|
+
"target_agent": agent_type.value,
|
|
274
|
+
"task_length": len(task),
|
|
275
|
+
"has_context_hint": context_hint is not None,
|
|
276
|
+
},
|
|
277
|
+
)
|
|
278
|
+
|
|
263
279
|
# Get the run function for this agent type
|
|
264
280
|
_, run_fn = AGENT_FACTORIES[agent_type]
|
|
265
281
|
|
|
266
282
|
# Retry loop for transient errors
|
|
267
283
|
last_error: BaseException | None = None
|
|
284
|
+
retries_attempted = 0
|
|
268
285
|
for attempt in range(MAX_RETRIES + 1):
|
|
269
286
|
try:
|
|
270
287
|
# Run sub-agent with isolated message history and streaming support
|
|
@@ -286,6 +303,11 @@ async def _run_sub_agent(
|
|
|
286
303
|
op.file_path for op in sub_agent_deps.file_tracker.operations
|
|
287
304
|
]
|
|
288
305
|
|
|
306
|
+
# Extract files found (used by FileReadAgent)
|
|
307
|
+
files_found: list[str] = []
|
|
308
|
+
if result and result.output and result.output.files_found:
|
|
309
|
+
files_found = result.output.files_found
|
|
310
|
+
|
|
289
311
|
# Check for clarifying questions
|
|
290
312
|
has_questions = False
|
|
291
313
|
questions: list[str] = []
|
|
@@ -294,9 +316,21 @@ async def _run_sub_agent(
|
|
|
294
316
|
questions = result.output.clarifying_questions
|
|
295
317
|
|
|
296
318
|
logger.info(
|
|
297
|
-
"Sub-agent %s completed. Files modified: %s",
|
|
319
|
+
"Sub-agent %s completed. Files modified: %s, files found: %s",
|
|
298
320
|
agent_type.value,
|
|
299
321
|
files_modified,
|
|
322
|
+
files_found,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Track delegation completion metric
|
|
326
|
+
track_event(
|
|
327
|
+
"delegation_completed",
|
|
328
|
+
{
|
|
329
|
+
"target_agent": agent_type.value,
|
|
330
|
+
"files_modified_count": len(files_modified),
|
|
331
|
+
"has_questions": has_questions,
|
|
332
|
+
"duration_seconds": round(time.time() - start_time, 2),
|
|
333
|
+
},
|
|
300
334
|
)
|
|
301
335
|
|
|
302
336
|
# Clear active_sub_agent
|
|
@@ -306,12 +340,14 @@ async def _run_sub_agent(
|
|
|
306
340
|
success=True,
|
|
307
341
|
response=response_text,
|
|
308
342
|
files_modified=files_modified,
|
|
343
|
+
files_found=files_found,
|
|
309
344
|
has_questions=has_questions,
|
|
310
345
|
questions=questions,
|
|
311
346
|
)
|
|
312
347
|
|
|
313
348
|
except Exception as e:
|
|
314
349
|
last_error = e
|
|
350
|
+
retries_attempted = attempt
|
|
315
351
|
if _is_retryable_error(e) and attempt < MAX_RETRIES:
|
|
316
352
|
logger.warning(
|
|
317
353
|
"Sub-agent %s failed (attempt %d/%d), retrying: %s",
|
|
@@ -329,8 +365,26 @@ async def _run_sub_agent(
|
|
|
329
365
|
attempt + 1,
|
|
330
366
|
str(e),
|
|
331
367
|
)
|
|
368
|
+
# Clear the agent from cache on failure so next request gets a fresh agent
|
|
369
|
+
# This is especially important for request_limit errors
|
|
370
|
+
if agent_type in deps.sub_agent_cache:
|
|
371
|
+
del deps.sub_agent_cache[agent_type]
|
|
372
|
+
logger.debug(
|
|
373
|
+
"Cleared %s from sub_agent_cache after failure", agent_type.value
|
|
374
|
+
)
|
|
332
375
|
break
|
|
333
376
|
|
|
377
|
+
# Track delegation failure metric
|
|
378
|
+
track_event(
|
|
379
|
+
"delegation_failed",
|
|
380
|
+
{
|
|
381
|
+
"target_agent": agent_type.value,
|
|
382
|
+
"error_type": type(last_error).__name__ if last_error else "Unknown",
|
|
383
|
+
"retries_attempted": retries_attempted,
|
|
384
|
+
"duration_seconds": round(time.time() - start_time, 2),
|
|
385
|
+
},
|
|
386
|
+
)
|
|
387
|
+
|
|
334
388
|
# Clear active_sub_agent on failure
|
|
335
389
|
deps.active_sub_agent = None
|
|
336
390
|
|
|
@@ -25,6 +25,7 @@ from shotgun.agents.router.models import (
|
|
|
25
25
|
)
|
|
26
26
|
from shotgun.agents.tools.registry import ToolCategory, register_tool
|
|
27
27
|
from shotgun.logging_config import get_logger
|
|
28
|
+
from shotgun.posthog_telemetry import track_event
|
|
28
29
|
|
|
29
30
|
logger = get_logger(__name__)
|
|
30
31
|
|
|
@@ -44,7 +45,7 @@ def _notify_plan_changed(deps: RouterDeps) -> None:
|
|
|
44
45
|
|
|
45
46
|
@register_tool(
|
|
46
47
|
category=ToolCategory.PLANNING,
|
|
47
|
-
display_text="Creating
|
|
48
|
+
display_text="Creating a Drafting plan",
|
|
48
49
|
key_arg="input",
|
|
49
50
|
)
|
|
50
51
|
async def create_plan(
|
|
@@ -62,7 +63,7 @@ async def create_plan(
|
|
|
62
63
|
Returns:
|
|
63
64
|
ToolResult indicating success or failure
|
|
64
65
|
"""
|
|
65
|
-
logger.debug("Creating
|
|
66
|
+
logger.debug("Creating a Drafting plan with goal: %s", input.goal)
|
|
66
67
|
|
|
67
68
|
# Convert step inputs to ExecutionStep objects
|
|
68
69
|
steps = [
|
|
@@ -107,6 +108,17 @@ async def create_plan(
|
|
|
107
108
|
input.goal,
|
|
108
109
|
)
|
|
109
110
|
|
|
111
|
+
# Track plan creation metric
|
|
112
|
+
track_event(
|
|
113
|
+
"plan_created",
|
|
114
|
+
{
|
|
115
|
+
"step_count": len(steps),
|
|
116
|
+
"goal_preview": input.goal[:100],
|
|
117
|
+
"requires_approval": plan.needs_approval(),
|
|
118
|
+
"router_mode": ctx.deps.router_mode.value,
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
|
|
110
122
|
_notify_plan_changed(ctx.deps)
|
|
111
123
|
|
|
112
124
|
# Return different message based on whether approval is needed
|
|
@@ -151,11 +163,22 @@ async def mark_step_done(
|
|
|
151
163
|
)
|
|
152
164
|
|
|
153
165
|
# Find the step by ID
|
|
154
|
-
for
|
|
166
|
+
for step_index, step in enumerate(plan.steps):
|
|
155
167
|
if step.id == input.step_id:
|
|
156
168
|
step.done = True
|
|
157
169
|
logger.info("Marked step '%s' as done", input.step_id)
|
|
158
170
|
|
|
171
|
+
# Track step completion metric
|
|
172
|
+
completed_count = sum(1 for s in plan.steps if s.done)
|
|
173
|
+
track_event(
|
|
174
|
+
"plan_step_completed",
|
|
175
|
+
{
|
|
176
|
+
"step_position": step_index + 1, # 1-indexed for human readability
|
|
177
|
+
"total_steps": len(plan.steps),
|
|
178
|
+
"steps_remaining": len(plan.steps) - completed_count,
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
|
|
159
182
|
# Advance current_step_index to next incomplete step
|
|
160
183
|
while (
|
|
161
184
|
plan.current_step_index < len(plan.steps)
|
|
@@ -167,6 +190,20 @@ async def mark_step_done(
|
|
|
167
190
|
if plan.is_complete():
|
|
168
191
|
ctx.deps.is_executing = False
|
|
169
192
|
logger.debug("Plan complete, is_executing=False")
|
|
193
|
+
|
|
194
|
+
# Track plan completion metric
|
|
195
|
+
track_event(
|
|
196
|
+
"plan_completed",
|
|
197
|
+
{
|
|
198
|
+
"step_count": len(plan.steps),
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Set pending completion for Drafting mode
|
|
203
|
+
# The TUI will detect this and show the completion message
|
|
204
|
+
if ctx.deps.router_mode == RouterMode.DRAFTING:
|
|
205
|
+
ctx.deps.pending_completion = True
|
|
206
|
+
logger.debug("Set pending_completion=True for drafting mode")
|
|
170
207
|
# Set pending checkpoint for Planning mode
|
|
171
208
|
# The TUI will detect this and show the StepCheckpointWidget
|
|
172
209
|
elif ctx.deps.router_mode == RouterMode.PLANNING:
|
|
@@ -184,10 +221,28 @@ async def mark_step_done(
|
|
|
184
221
|
|
|
185
222
|
_notify_plan_changed(ctx.deps)
|
|
186
223
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
224
|
+
# Return different messages based on mode and plan state
|
|
225
|
+
if plan.is_complete():
|
|
226
|
+
return ToolResult(
|
|
227
|
+
success=True,
|
|
228
|
+
message=f"Marked step '{step.title}' as complete.\n\n"
|
|
229
|
+
"All plan steps are now complete. You may return your final response.",
|
|
230
|
+
)
|
|
231
|
+
elif ctx.deps.router_mode == RouterMode.DRAFTING:
|
|
232
|
+
next_step = plan.current_step()
|
|
233
|
+
return ToolResult(
|
|
234
|
+
success=True,
|
|
235
|
+
message=f"Marked step '{step.title}' as complete.\n\n"
|
|
236
|
+
f"NEXT STEP: {next_step.title if next_step else 'None'}\n"
|
|
237
|
+
"IMPORTANT: Do NOT call final_result. "
|
|
238
|
+
"Immediately delegate the next step.",
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
# Planning mode - checkpoint will be shown
|
|
242
|
+
return ToolResult(
|
|
243
|
+
success=True,
|
|
244
|
+
message=f"Marked step '{step.title}' as complete.",
|
|
245
|
+
)
|
|
191
246
|
|
|
192
247
|
return ToolResult(
|
|
193
248
|
success=False,
|
|
@@ -242,6 +297,15 @@ async def add_step(ctx: RunContext[RouterDeps], input: AddStepInput) -> ToolResu
|
|
|
242
297
|
plan.steps.append(new_step)
|
|
243
298
|
logger.info("Appended step '%s' to end of plan", input.step.id)
|
|
244
299
|
|
|
300
|
+
# Track step added metric
|
|
301
|
+
track_event(
|
|
302
|
+
"plan_step_added",
|
|
303
|
+
{
|
|
304
|
+
"new_step_count": len(plan.steps),
|
|
305
|
+
"position": len(plan.steps), # Appended at end
|
|
306
|
+
},
|
|
307
|
+
)
|
|
308
|
+
|
|
245
309
|
_notify_plan_changed(ctx.deps)
|
|
246
310
|
|
|
247
311
|
return ToolResult(
|
|
@@ -259,6 +323,15 @@ async def add_step(ctx: RunContext[RouterDeps], input: AddStepInput) -> ToolResu
|
|
|
259
323
|
input.after_step_id,
|
|
260
324
|
)
|
|
261
325
|
|
|
326
|
+
# Track step added metric
|
|
327
|
+
track_event(
|
|
328
|
+
"plan_step_added",
|
|
329
|
+
{
|
|
330
|
+
"new_step_count": len(plan.steps),
|
|
331
|
+
"position": i + 2, # 1-indexed, inserted after position i+1
|
|
332
|
+
},
|
|
333
|
+
)
|
|
334
|
+
|
|
262
335
|
_notify_plan_changed(ctx.deps)
|
|
263
336
|
|
|
264
337
|
return ToolResult(
|
|
@@ -303,6 +376,14 @@ async def remove_step(
|
|
|
303
376
|
removed_step = plan.steps.pop(i)
|
|
304
377
|
logger.info("Removed step '%s' from plan", input.step_id)
|
|
305
378
|
|
|
379
|
+
# Track step removed metric
|
|
380
|
+
track_event(
|
|
381
|
+
"plan_step_removed",
|
|
382
|
+
{
|
|
383
|
+
"new_step_count": len(plan.steps),
|
|
384
|
+
},
|
|
385
|
+
)
|
|
386
|
+
|
|
306
387
|
# Adjust current_step_index if needed
|
|
307
388
|
if plan.current_step_index > i:
|
|
308
389
|
plan.current_step_index -= 1
|
shotgun/agents/runner.py
CHANGED
|
@@ -13,6 +13,7 @@ from openai import APIStatusError as OpenAIAPIStatusError
|
|
|
13
13
|
from pydantic_ai.exceptions import ModelHTTPError, UnexpectedModelBehavior
|
|
14
14
|
|
|
15
15
|
from shotgun.agents.error.models import AgentErrorContext
|
|
16
|
+
from shotgun.attachments import FileAttachment
|
|
16
17
|
from shotgun.exceptions import (
|
|
17
18
|
AgentCancelledException,
|
|
18
19
|
BudgetExceededException,
|
|
@@ -29,6 +30,8 @@ from shotgun.exceptions import (
|
|
|
29
30
|
)
|
|
30
31
|
|
|
31
32
|
if TYPE_CHECKING:
|
|
33
|
+
from pydantic_ai import BinaryContent
|
|
34
|
+
|
|
32
35
|
from shotgun.agents.agent_manager import AgentManager
|
|
33
36
|
|
|
34
37
|
logger = logging.getLogger(__name__)
|
|
@@ -66,11 +69,19 @@ class AgentRunner:
|
|
|
66
69
|
"""
|
|
67
70
|
self.agent_manager = agent_manager
|
|
68
71
|
|
|
69
|
-
async def run(
|
|
72
|
+
async def run(
|
|
73
|
+
self,
|
|
74
|
+
prompt: str,
|
|
75
|
+
attachment: FileAttachment | None = None,
|
|
76
|
+
file_contents: list[tuple[str, "BinaryContent"]] | None = None,
|
|
77
|
+
) -> None:
|
|
70
78
|
"""Run the agent with the given prompt.
|
|
71
79
|
|
|
72
80
|
Args:
|
|
73
81
|
prompt: The user's prompt/query
|
|
82
|
+
attachment: Optional file attachment to include as multimodal content.
|
|
83
|
+
file_contents: Optional list of (file_path, BinaryContent) tuples to include
|
|
84
|
+
as multimodal content. Used when resuming after file_requests.
|
|
74
85
|
|
|
75
86
|
Raises:
|
|
76
87
|
Custom exceptions for different error types:
|
|
@@ -88,7 +99,11 @@ class AgentRunner:
|
|
|
88
99
|
- UnknownAgentException: Unknown/unclassified error
|
|
89
100
|
"""
|
|
90
101
|
try:
|
|
91
|
-
await self.agent_manager.run(
|
|
102
|
+
await self.agent_manager.run(
|
|
103
|
+
prompt=prompt,
|
|
104
|
+
attachment=attachment,
|
|
105
|
+
file_contents=file_contents,
|
|
106
|
+
)
|
|
92
107
|
|
|
93
108
|
except asyncio.CancelledError as e:
|
|
94
109
|
# User cancelled - wrap and re-raise as our custom exception
|
shotgun/agents/tools/__init__.py
CHANGED
|
@@ -8,6 +8,11 @@ from .codebase import (
|
|
|
8
8
|
retrieve_code,
|
|
9
9
|
)
|
|
10
10
|
from .file_management import append_file, read_file, write_file
|
|
11
|
+
from .markdown_tools import (
|
|
12
|
+
insert_markdown_section,
|
|
13
|
+
remove_markdown_section,
|
|
14
|
+
replace_markdown_section,
|
|
15
|
+
)
|
|
11
16
|
from .web_search import (
|
|
12
17
|
anthropic_web_search_tool,
|
|
13
18
|
gemini_web_search_tool,
|
|
@@ -23,6 +28,9 @@ __all__ = [
|
|
|
23
28
|
"read_file",
|
|
24
29
|
"write_file",
|
|
25
30
|
"append_file",
|
|
31
|
+
"replace_markdown_section",
|
|
32
|
+
"insert_markdown_section",
|
|
33
|
+
"remove_markdown_section",
|
|
26
34
|
# Codebase understanding tools
|
|
27
35
|
"query_graph",
|
|
28
36
|
"retrieve_code",
|
|
@@ -19,60 +19,48 @@ logger = get_logger(__name__)
|
|
|
19
19
|
key_arg="directory",
|
|
20
20
|
)
|
|
21
21
|
async def directory_lister(
|
|
22
|
-
ctx: RunContext[AgentDeps],
|
|
22
|
+
ctx: RunContext[AgentDeps], directory: str = ".", graph_id: str = ""
|
|
23
23
|
) -> DirectoryListResult:
|
|
24
|
-
"""List directory contents in codebase.
|
|
24
|
+
"""List directory contents in codebase or current working directory.
|
|
25
25
|
|
|
26
26
|
Args:
|
|
27
27
|
ctx: RunContext containing AgentDeps with codebase service
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
directory: Path to directory relative to repository root or CWD (default: ".")
|
|
29
|
+
graph_id: Graph ID to identify the repository (optional - uses CWD if not provided)
|
|
30
30
|
|
|
31
31
|
Returns:
|
|
32
32
|
DirectoryListResult with formatted output via __str__
|
|
33
33
|
"""
|
|
34
|
-
logger.debug("🔧 Listing directory: %s in graph %s", directory, graph_id)
|
|
34
|
+
logger.debug("🔧 Listing directory: %s in graph %s", directory, graph_id or "(CWD)")
|
|
35
35
|
|
|
36
36
|
try:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
if not graph:
|
|
59
|
-
return DirectoryListResult(
|
|
60
|
-
success=False,
|
|
61
|
-
directory=directory,
|
|
62
|
-
full_path="",
|
|
63
|
-
error=f"Graph '{graph_id}' not found",
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
# Validate the directory path is within the repository
|
|
67
|
-
repo_path = Path(graph.repo_path).resolve()
|
|
37
|
+
# Determine the root path - either from indexed codebase or CWD
|
|
38
|
+
repo_path: Path | None = None
|
|
39
|
+
|
|
40
|
+
if graph_id and ctx.deps.codebase_service:
|
|
41
|
+
# Try to get the graph to find the repository path
|
|
42
|
+
try:
|
|
43
|
+
graphs = await ctx.deps.codebase_service.list_graphs()
|
|
44
|
+
graph = next((g for g in graphs if g.graph_id == graph_id), None)
|
|
45
|
+
if graph:
|
|
46
|
+
repo_path = Path(graph.repo_path).resolve()
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.debug("Could not find graph '%s': %s", graph_id, e)
|
|
49
|
+
|
|
50
|
+
# Fall back to CWD if no graph found or no graph_id provided
|
|
51
|
+
if repo_path is None:
|
|
52
|
+
repo_path = Path.cwd().resolve()
|
|
53
|
+
logger.debug("📂 Using CWD as root: %s", repo_path)
|
|
54
|
+
|
|
55
|
+
# Validate the directory path is within the root
|
|
68
56
|
full_dir_path = (repo_path / directory).resolve()
|
|
69
57
|
|
|
70
|
-
# Security check: ensure the resolved path is within the
|
|
58
|
+
# Security check: ensure the resolved path is within the root directory
|
|
71
59
|
try:
|
|
72
60
|
full_dir_path.relative_to(repo_path)
|
|
73
61
|
except ValueError:
|
|
74
62
|
error_msg = (
|
|
75
|
-
f"Access denied: Path '{directory}' is outside
|
|
63
|
+
f"Access denied: Path '{directory}' is outside allowed directory bounds"
|
|
76
64
|
)
|
|
77
65
|
logger.warning("🚨 Security violation attempt: %s", error_msg)
|
|
78
66
|
return DirectoryListResult(
|
|
@@ -88,7 +76,7 @@ async def directory_lister(
|
|
|
88
76
|
success=False,
|
|
89
77
|
directory=directory,
|
|
90
78
|
full_path=str(full_dir_path),
|
|
91
|
-
error=f"Directory
|
|
79
|
+
error=f"Directory not found: {directory}",
|
|
92
80
|
)
|
|
93
81
|
|
|
94
82
|
if not full_dir_path.is_dir():
|
|
@@ -21,57 +21,48 @@ logger = get_logger(__name__)
|
|
|
21
21
|
key_arg="file_path",
|
|
22
22
|
)
|
|
23
23
|
async def file_read(
|
|
24
|
-
ctx: RunContext[AgentDeps],
|
|
24
|
+
ctx: RunContext[AgentDeps], file_path: str, graph_id: str = ""
|
|
25
25
|
) -> FileReadResult:
|
|
26
|
-
"""Read file contents from codebase.
|
|
26
|
+
"""Read file contents from codebase or current working directory.
|
|
27
27
|
|
|
28
28
|
Args:
|
|
29
29
|
ctx: RunContext containing AgentDeps with codebase service
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
file_path: Path to file relative to repository root or CWD
|
|
31
|
+
graph_id: Graph ID to identify the repository (optional - uses CWD if not provided)
|
|
32
32
|
|
|
33
33
|
Returns:
|
|
34
34
|
FileReadResult with formatted output via __str__
|
|
35
35
|
"""
|
|
36
|
-
logger.debug("🔧 Reading file: %s in graph %s", file_path, graph_id)
|
|
36
|
+
logger.debug("🔧 Reading file: %s in graph %s", file_path, graph_id or "(CWD)")
|
|
37
37
|
|
|
38
38
|
try:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
success=False,
|
|
42
|
-
file_path=file_path,
|
|
43
|
-
error="No codebase indexed",
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
# Get the graph to find the repository path
|
|
47
|
-
try:
|
|
48
|
-
graphs = await ctx.deps.codebase_service.list_graphs()
|
|
49
|
-
graph = next((g for g in graphs if g.graph_id == graph_id), None)
|
|
50
|
-
except Exception as e:
|
|
51
|
-
logger.error("Error getting graph: %s", e)
|
|
52
|
-
return FileReadResult(
|
|
53
|
-
success=False,
|
|
54
|
-
file_path=file_path,
|
|
55
|
-
error=f"Could not find graph with ID '{graph_id}'",
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
if not graph:
|
|
59
|
-
return FileReadResult(
|
|
60
|
-
success=False,
|
|
61
|
-
file_path=file_path,
|
|
62
|
-
error=f"Graph '{graph_id}' not found",
|
|
63
|
-
)
|
|
39
|
+
# Determine the root path - either from indexed codebase or CWD
|
|
40
|
+
repo_path: Path | None = None
|
|
64
41
|
|
|
65
|
-
|
|
66
|
-
|
|
42
|
+
if graph_id and ctx.deps.codebase_service:
|
|
43
|
+
# Try to get the graph to find the repository path
|
|
44
|
+
try:
|
|
45
|
+
graphs = await ctx.deps.codebase_service.list_graphs()
|
|
46
|
+
graph = next((g for g in graphs if g.graph_id == graph_id), None)
|
|
47
|
+
if graph:
|
|
48
|
+
repo_path = Path(graph.repo_path).resolve()
|
|
49
|
+
except Exception as e:
|
|
50
|
+
logger.debug("Could not find graph '%s': %s", graph_id, e)
|
|
51
|
+
|
|
52
|
+
# Fall back to CWD if no graph found or no graph_id provided
|
|
53
|
+
if repo_path is None:
|
|
54
|
+
repo_path = Path.cwd().resolve()
|
|
55
|
+
logger.debug("📂 Using CWD as root: %s", repo_path)
|
|
56
|
+
|
|
57
|
+
# Validate the file path is within the root
|
|
67
58
|
full_file_path = (repo_path / file_path).resolve()
|
|
68
59
|
|
|
69
|
-
# Security check: ensure the resolved path is within the
|
|
60
|
+
# Security check: ensure the resolved path is within the root directory
|
|
70
61
|
try:
|
|
71
62
|
full_file_path.relative_to(repo_path)
|
|
72
63
|
except ValueError:
|
|
73
64
|
error_msg = (
|
|
74
|
-
f"Access denied: Path '{file_path}' is outside
|
|
65
|
+
f"Access denied: Path '{file_path}' is outside allowed directory bounds"
|
|
75
66
|
)
|
|
76
67
|
logger.warning("🚨 Security violation attempt: %s", error_msg)
|
|
77
68
|
return FileReadResult(success=False, file_path=file_path, error=error_msg)
|
|
@@ -81,7 +72,7 @@ async def file_read(
|
|
|
81
72
|
return FileReadResult(
|
|
82
73
|
success=False,
|
|
83
74
|
file_path=file_path,
|
|
84
|
-
error=f"File
|
|
75
|
+
error=f"File not found: {file_path}",
|
|
85
76
|
)
|
|
86
77
|
|
|
87
78
|
if full_file_path.is_dir():
|
|
@@ -4,6 +4,7 @@ from pydantic_ai import RunContext
|
|
|
4
4
|
|
|
5
5
|
from shotgun.agents.models import AgentDeps
|
|
6
6
|
from shotgun.agents.tools.registry import ToolCategory, register_tool
|
|
7
|
+
from shotgun.codebase.indexing_state import IndexingState
|
|
7
8
|
from shotgun.codebase.models import QueryType
|
|
8
9
|
from shotgun.logging_config import get_logger
|
|
9
10
|
|
|
@@ -40,6 +41,14 @@ async def query_graph(
|
|
|
40
41
|
error="No codebase indexed",
|
|
41
42
|
)
|
|
42
43
|
|
|
44
|
+
# Check if graph is currently being indexed
|
|
45
|
+
if ctx.deps.codebase_service.indexing.is_active(graph_id):
|
|
46
|
+
return QueryGraphResult(
|
|
47
|
+
success=False,
|
|
48
|
+
query=query,
|
|
49
|
+
error=IndexingState.INDEXING_IN_PROGRESS_ERROR,
|
|
50
|
+
)
|
|
51
|
+
|
|
43
52
|
# Execute natural language query
|
|
44
53
|
result = await ctx.deps.codebase_service.execute_query(
|
|
45
54
|
graph_id=graph_id,
|
|
@@ -8,6 +8,7 @@ from shotgun.agents.models import AgentDeps
|
|
|
8
8
|
from shotgun.agents.tools.registry import ToolCategory, register_tool
|
|
9
9
|
from shotgun.codebase.core.code_retrieval import retrieve_code_by_qualified_name
|
|
10
10
|
from shotgun.codebase.core.language_config import get_language_config
|
|
11
|
+
from shotgun.codebase.indexing_state import IndexingState
|
|
11
12
|
from shotgun.logging_config import get_logger
|
|
12
13
|
|
|
13
14
|
from .models import CodeSnippetResult
|
|
@@ -43,6 +44,14 @@ async def retrieve_code(
|
|
|
43
44
|
error="No codebase indexed",
|
|
44
45
|
)
|
|
45
46
|
|
|
47
|
+
# Check if graph is currently being indexed
|
|
48
|
+
if ctx.deps.codebase_service.indexing.is_active(graph_id):
|
|
49
|
+
return CodeSnippetResult(
|
|
50
|
+
found=False,
|
|
51
|
+
qualified_name=qualified_name,
|
|
52
|
+
error=IndexingState.INDEXING_IN_PROGRESS_ERROR,
|
|
53
|
+
)
|
|
54
|
+
|
|
46
55
|
# Use the existing code retrieval functionality
|
|
47
56
|
code_snippet = await retrieve_code_by_qualified_name(
|
|
48
57
|
manager=ctx.deps.codebase_service.manager,
|
|
@@ -163,19 +163,33 @@ def _validate_shotgun_path(filename: str) -> Path:
|
|
|
163
163
|
return full_path
|
|
164
164
|
|
|
165
165
|
|
|
166
|
+
# Binary file extensions that should be loaded via file_requests
|
|
167
|
+
BINARY_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp"}
|
|
168
|
+
|
|
169
|
+
|
|
166
170
|
@register_tool(
|
|
167
171
|
category=ToolCategory.ARTIFACT_MANAGEMENT,
|
|
168
172
|
display_text="Reading file",
|
|
169
173
|
key_arg="filename",
|
|
170
174
|
)
|
|
171
175
|
async def read_file(ctx: RunContext[AgentDeps], filename: str) -> str:
|
|
172
|
-
"""Read a file from the .shotgun directory.
|
|
176
|
+
"""Read a TEXT file from the .shotgun directory.
|
|
177
|
+
|
|
178
|
+
IMPORTANT: This tool is for TEXT files only (.md, .txt, .json, etc.).
|
|
179
|
+
|
|
180
|
+
For BINARY files (PDFs, images), DO NOT use this tool. Instead:
|
|
181
|
+
- Use file_requests in your response to load binary files
|
|
182
|
+
- Example: {"response": "Let me check that.", "file_requests": ["/path/to/file.pdf"]}
|
|
183
|
+
|
|
184
|
+
Binary file extensions that require file_requests instead:
|
|
185
|
+
- .pdf, .png, .jpg, .jpeg, .gif, .webp
|
|
173
186
|
|
|
174
187
|
Args:
|
|
175
188
|
filename: Relative path to file within .shotgun directory
|
|
176
189
|
|
|
177
190
|
Returns:
|
|
178
|
-
File contents as string
|
|
191
|
+
File contents as string. For binary files, returns instructions
|
|
192
|
+
with the absolute path to use in file_requests.
|
|
179
193
|
|
|
180
194
|
Raises:
|
|
181
195
|
ValueError: If path is outside .shotgun directory
|
|
@@ -189,6 +203,22 @@ async def read_file(ctx: RunContext[AgentDeps], filename: str) -> str:
|
|
|
189
203
|
if not await aiofiles.os.path.exists(file_path):
|
|
190
204
|
raise FileNotFoundError(f"File not found: {filename}")
|
|
191
205
|
|
|
206
|
+
# Check if it's a binary file type (PDF, image)
|
|
207
|
+
suffix = file_path.suffix.lower()
|
|
208
|
+
if suffix in BINARY_EXTENSIONS:
|
|
209
|
+
# Return info for the agent to use file_requests
|
|
210
|
+
logger.debug(
|
|
211
|
+
"📎 Binary file detected (%s), returning path for file_requests: %s",
|
|
212
|
+
suffix,
|
|
213
|
+
file_path,
|
|
214
|
+
)
|
|
215
|
+
return (
|
|
216
|
+
f"This is a binary file ({suffix}) that cannot be read as text. "
|
|
217
|
+
f"To view its contents, include the absolute path in your "
|
|
218
|
+
f"`file_requests` response field:\n\n"
|
|
219
|
+
f"Absolute path: {file_path}"
|
|
220
|
+
)
|
|
221
|
+
|
|
192
222
|
async with aiofiles.open(file_path, encoding="utf-8") as f:
|
|
193
223
|
content = await f.read()
|
|
194
224
|
logger.debug("📄 Read %d characters from %s", len(content), filename)
|