shotgun-sh 0.4.0.dev1__py3-none-any.whl → 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. shotgun/agents/agent_manager.py +307 -8
  2. shotgun/agents/cancellation.py +103 -0
  3. shotgun/agents/common.py +12 -0
  4. shotgun/agents/config/README.md +0 -1
  5. shotgun/agents/config/manager.py +10 -7
  6. shotgun/agents/config/models.py +5 -27
  7. shotgun/agents/config/provider.py +44 -27
  8. shotgun/agents/conversation/history/token_counting/base.py +51 -9
  9. shotgun/agents/file_read.py +176 -0
  10. shotgun/agents/messages.py +15 -3
  11. shotgun/agents/models.py +24 -1
  12. shotgun/agents/router/models.py +8 -0
  13. shotgun/agents/router/tools/delegation_tools.py +55 -1
  14. shotgun/agents/router/tools/plan_tools.py +88 -7
  15. shotgun/agents/runner.py +17 -2
  16. shotgun/agents/tools/__init__.py +8 -0
  17. shotgun/agents/tools/codebase/directory_lister.py +27 -39
  18. shotgun/agents/tools/codebase/file_read.py +26 -35
  19. shotgun/agents/tools/codebase/query_graph.py +9 -0
  20. shotgun/agents/tools/codebase/retrieve_code.py +9 -0
  21. shotgun/agents/tools/file_management.py +32 -2
  22. shotgun/agents/tools/file_read_tools/__init__.py +7 -0
  23. shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
  24. shotgun/agents/tools/markdown_tools/__init__.py +62 -0
  25. shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
  26. shotgun/agents/tools/markdown_tools/models.py +86 -0
  27. shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
  28. shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
  29. shotgun/agents/tools/markdown_tools/utils.py +453 -0
  30. shotgun/agents/tools/registry.py +44 -6
  31. shotgun/agents/tools/web_search/openai.py +42 -23
  32. shotgun/attachments/__init__.py +41 -0
  33. shotgun/attachments/errors.py +60 -0
  34. shotgun/attachments/models.py +107 -0
  35. shotgun/attachments/parser.py +257 -0
  36. shotgun/attachments/processor.py +193 -0
  37. shotgun/build_constants.py +4 -7
  38. shotgun/cli/clear.py +2 -2
  39. shotgun/cli/codebase/commands.py +181 -65
  40. shotgun/cli/compact.py +2 -2
  41. shotgun/cli/context.py +2 -2
  42. shotgun/cli/error_handler.py +2 -2
  43. shotgun/cli/run.py +90 -0
  44. shotgun/cli/spec/backup.py +2 -1
  45. shotgun/codebase/__init__.py +2 -0
  46. shotgun/codebase/benchmarks/__init__.py +35 -0
  47. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  48. shotgun/codebase/benchmarks/exporters.py +119 -0
  49. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  50. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  51. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  52. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  53. shotgun/codebase/benchmarks/models.py +129 -0
  54. shotgun/codebase/core/__init__.py +4 -0
  55. shotgun/codebase/core/call_resolution.py +91 -0
  56. shotgun/codebase/core/change_detector.py +11 -6
  57. shotgun/codebase/core/errors.py +159 -0
  58. shotgun/codebase/core/extractors/__init__.py +23 -0
  59. shotgun/codebase/core/extractors/base.py +138 -0
  60. shotgun/codebase/core/extractors/factory.py +63 -0
  61. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  62. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  63. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  64. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  65. shotgun/codebase/core/extractors/protocol.py +109 -0
  66. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  67. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  68. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  69. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  70. shotgun/codebase/core/extractors/types.py +15 -0
  71. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  72. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  73. shotgun/codebase/core/gitignore.py +252 -0
  74. shotgun/codebase/core/ingestor.py +644 -354
  75. shotgun/codebase/core/kuzu_compat.py +119 -0
  76. shotgun/codebase/core/language_config.py +239 -0
  77. shotgun/codebase/core/manager.py +256 -46
  78. shotgun/codebase/core/metrics_collector.py +310 -0
  79. shotgun/codebase/core/metrics_types.py +347 -0
  80. shotgun/codebase/core/parallel_executor.py +424 -0
  81. shotgun/codebase/core/work_distributor.py +254 -0
  82. shotgun/codebase/core/worker.py +768 -0
  83. shotgun/codebase/indexing_state.py +86 -0
  84. shotgun/codebase/models.py +94 -0
  85. shotgun/codebase/service.py +13 -0
  86. shotgun/exceptions.py +9 -9
  87. shotgun/main.py +3 -16
  88. shotgun/posthog_telemetry.py +165 -24
  89. shotgun/prompts/agents/file_read.j2 +48 -0
  90. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -47
  91. shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
  92. shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
  93. shotgun/prompts/agents/partials/router_delegation_mode.j2 +21 -22
  94. shotgun/prompts/agents/plan.j2 +14 -0
  95. shotgun/prompts/agents/router.j2 +531 -258
  96. shotgun/prompts/agents/specify.j2 +14 -0
  97. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  98. shotgun/prompts/agents/state/system_state.j2 +13 -11
  99. shotgun/prompts/agents/tasks.j2 +14 -0
  100. shotgun/settings.py +49 -10
  101. shotgun/tui/app.py +149 -18
  102. shotgun/tui/commands/__init__.py +9 -1
  103. shotgun/tui/components/attachment_bar.py +87 -0
  104. shotgun/tui/components/prompt_input.py +25 -28
  105. shotgun/tui/components/status_bar.py +14 -7
  106. shotgun/tui/dependencies.py +3 -8
  107. shotgun/tui/protocols.py +18 -0
  108. shotgun/tui/screens/chat/chat.tcss +15 -0
  109. shotgun/tui/screens/chat/chat_screen.py +766 -235
  110. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  111. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  112. shotgun/tui/screens/chat_screen/command_providers.py +0 -10
  113. shotgun/tui/screens/chat_screen/history/chat_history.py +54 -14
  114. shotgun/tui/screens/chat_screen/history/formatters.py +22 -0
  115. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  116. shotgun/tui/screens/database_locked_dialog.py +219 -0
  117. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  118. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  119. shotgun/tui/screens/model_picker.py +1 -3
  120. shotgun/tui/screens/models.py +11 -0
  121. shotgun/tui/state/processing_state.py +19 -0
  122. shotgun/tui/widgets/widget_coordinator.py +18 -0
  123. shotgun/utils/file_system_utils.py +4 -1
  124. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +87 -34
  125. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/RECORD +128 -79
  126. shotgun/cli/export.py +0 -81
  127. shotgun/cli/plan.py +0 -73
  128. shotgun/cli/research.py +0 -93
  129. shotgun/cli/specify.py +0 -70
  130. shotgun/cli/tasks.py +0 -78
  131. shotgun/sentry_telemetry.py +0 -232
  132. shotgun/tui/screens/onboarding.py +0 -584
  133. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
  134. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
  135. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/licenses/LICENSE +0 -0
@@ -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 execution plan",
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 execution plan with goal: %s", input.goal)
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 _i, step in enumerate(plan.steps):
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
- return ToolResult(
188
- success=True,
189
- message=f"Marked step '{step.title}' as complete.",
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(self, prompt: str) -> None:
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(prompt=prompt)
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
@@ -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], graph_id: str, directory: str = "."
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
- graph_id: Graph ID to identify the repository
29
- directory: Path to directory relative to repository root (default: ".")
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
- if not ctx.deps.codebase_service:
38
- return DirectoryListResult(
39
- success=False,
40
- directory=directory,
41
- full_path="",
42
- error="No codebase indexed",
43
- )
44
-
45
- # Get the graph to find the repository path
46
- try:
47
- graphs = await ctx.deps.codebase_service.list_graphs()
48
- graph = next((g for g in graphs if g.graph_id == graph_id), None)
49
- except Exception as e:
50
- logger.error("Error getting graph: %s", e)
51
- return DirectoryListResult(
52
- success=False,
53
- directory=directory,
54
- full_path="",
55
- error=f"Could not find graph with ID '{graph_id}'",
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 repository
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 repository bounds"
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 '{directory}' not found in repository",
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], graph_id: str, file_path: str
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
- graph_id: Graph ID to identify the repository
31
- file_path: Path to file relative to repository root
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
- if not ctx.deps.codebase_service:
40
- return FileReadResult(
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
- # Validate the file path is within the repository
66
- repo_path = Path(graph.repo_path).resolve()
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 repository
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 repository bounds"
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 '{file_path}' not found in repository",
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)
@@ -0,0 +1,7 @@
1
+ """File reading tools for the FileRead agent."""
2
+
3
+ from shotgun.agents.tools.file_read_tools.multimodal_file_read import (
4
+ multimodal_file_read,
5
+ )
6
+
7
+ __all__ = ["multimodal_file_read"]