shotgun-sh 0.3.3.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 (159) hide show
  1. shotgun/agents/agent_manager.py +497 -30
  2. shotgun/agents/cancellation.py +103 -0
  3. shotgun/agents/common.py +90 -77
  4. shotgun/agents/config/README.md +0 -1
  5. shotgun/agents/config/manager.py +52 -8
  6. shotgun/agents/config/models.py +21 -27
  7. shotgun/agents/config/provider.py +44 -27
  8. shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
  9. shotgun/agents/conversation/history/token_counting/base.py +51 -9
  10. shotgun/agents/export.py +12 -13
  11. shotgun/agents/file_read.py +176 -0
  12. shotgun/agents/messages.py +15 -3
  13. shotgun/agents/models.py +90 -2
  14. shotgun/agents/plan.py +12 -13
  15. shotgun/agents/research.py +13 -10
  16. shotgun/agents/router/__init__.py +47 -0
  17. shotgun/agents/router/models.py +384 -0
  18. shotgun/agents/router/router.py +185 -0
  19. shotgun/agents/router/tools/__init__.py +18 -0
  20. shotgun/agents/router/tools/delegation_tools.py +557 -0
  21. shotgun/agents/router/tools/plan_tools.py +403 -0
  22. shotgun/agents/runner.py +17 -2
  23. shotgun/agents/specify.py +12 -13
  24. shotgun/agents/tasks.py +12 -13
  25. shotgun/agents/tools/__init__.py +8 -0
  26. shotgun/agents/tools/codebase/directory_lister.py +27 -39
  27. shotgun/agents/tools/codebase/file_read.py +26 -35
  28. shotgun/agents/tools/codebase/query_graph.py +9 -0
  29. shotgun/agents/tools/codebase/retrieve_code.py +9 -0
  30. shotgun/agents/tools/file_management.py +81 -3
  31. shotgun/agents/tools/file_read_tools/__init__.py +7 -0
  32. shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
  33. shotgun/agents/tools/markdown_tools/__init__.py +62 -0
  34. shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
  35. shotgun/agents/tools/markdown_tools/models.py +86 -0
  36. shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
  37. shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
  38. shotgun/agents/tools/markdown_tools/utils.py +453 -0
  39. shotgun/agents/tools/registry.py +46 -6
  40. shotgun/agents/tools/web_search/__init__.py +1 -2
  41. shotgun/agents/tools/web_search/gemini.py +1 -3
  42. shotgun/agents/tools/web_search/openai.py +42 -23
  43. shotgun/attachments/__init__.py +41 -0
  44. shotgun/attachments/errors.py +60 -0
  45. shotgun/attachments/models.py +107 -0
  46. shotgun/attachments/parser.py +257 -0
  47. shotgun/attachments/processor.py +193 -0
  48. shotgun/build_constants.py +4 -7
  49. shotgun/cli/clear.py +2 -2
  50. shotgun/cli/codebase/commands.py +181 -65
  51. shotgun/cli/compact.py +2 -2
  52. shotgun/cli/context.py +2 -2
  53. shotgun/cli/error_handler.py +2 -2
  54. shotgun/cli/run.py +90 -0
  55. shotgun/cli/spec/backup.py +2 -1
  56. shotgun/codebase/__init__.py +2 -0
  57. shotgun/codebase/benchmarks/__init__.py +35 -0
  58. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  59. shotgun/codebase/benchmarks/exporters.py +119 -0
  60. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  61. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  62. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  63. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  64. shotgun/codebase/benchmarks/models.py +129 -0
  65. shotgun/codebase/core/__init__.py +4 -0
  66. shotgun/codebase/core/call_resolution.py +91 -0
  67. shotgun/codebase/core/change_detector.py +11 -6
  68. shotgun/codebase/core/errors.py +159 -0
  69. shotgun/codebase/core/extractors/__init__.py +23 -0
  70. shotgun/codebase/core/extractors/base.py +138 -0
  71. shotgun/codebase/core/extractors/factory.py +63 -0
  72. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  73. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  74. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  75. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  76. shotgun/codebase/core/extractors/protocol.py +109 -0
  77. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  78. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  79. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  80. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  81. shotgun/codebase/core/extractors/types.py +15 -0
  82. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  83. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  84. shotgun/codebase/core/gitignore.py +252 -0
  85. shotgun/codebase/core/ingestor.py +644 -354
  86. shotgun/codebase/core/kuzu_compat.py +119 -0
  87. shotgun/codebase/core/language_config.py +239 -0
  88. shotgun/codebase/core/manager.py +256 -46
  89. shotgun/codebase/core/metrics_collector.py +310 -0
  90. shotgun/codebase/core/metrics_types.py +347 -0
  91. shotgun/codebase/core/parallel_executor.py +424 -0
  92. shotgun/codebase/core/work_distributor.py +254 -0
  93. shotgun/codebase/core/worker.py +768 -0
  94. shotgun/codebase/indexing_state.py +86 -0
  95. shotgun/codebase/models.py +94 -0
  96. shotgun/codebase/service.py +13 -0
  97. shotgun/exceptions.py +9 -9
  98. shotgun/main.py +3 -16
  99. shotgun/posthog_telemetry.py +165 -24
  100. shotgun/prompts/agents/export.j2 +2 -0
  101. shotgun/prompts/agents/file_read.j2 +48 -0
  102. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -52
  103. shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
  104. shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
  105. shotgun/prompts/agents/partials/router_delegation_mode.j2 +35 -0
  106. shotgun/prompts/agents/plan.j2 +38 -12
  107. shotgun/prompts/agents/research.j2 +70 -31
  108. shotgun/prompts/agents/router.j2 +713 -0
  109. shotgun/prompts/agents/specify.j2 +53 -16
  110. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  111. shotgun/prompts/agents/state/system_state.j2 +24 -13
  112. shotgun/prompts/agents/tasks.j2 +72 -34
  113. shotgun/settings.py +49 -10
  114. shotgun/tui/app.py +154 -24
  115. shotgun/tui/commands/__init__.py +9 -1
  116. shotgun/tui/components/attachment_bar.py +87 -0
  117. shotgun/tui/components/mode_indicator.py +120 -25
  118. shotgun/tui/components/prompt_input.py +25 -28
  119. shotgun/tui/components/status_bar.py +14 -7
  120. shotgun/tui/dependencies.py +58 -8
  121. shotgun/tui/protocols.py +55 -0
  122. shotgun/tui/screens/chat/chat.tcss +24 -1
  123. shotgun/tui/screens/chat/chat_screen.py +1376 -213
  124. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  125. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  126. shotgun/tui/screens/chat_screen/command_providers.py +0 -97
  127. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  128. shotgun/tui/screens/chat_screen/history/chat_history.py +58 -6
  129. shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
  130. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  131. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  132. shotgun/tui/screens/chat_screen/messages.py +219 -0
  133. shotgun/tui/screens/database_locked_dialog.py +219 -0
  134. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  135. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  136. shotgun/tui/screens/model_picker.py +1 -3
  137. shotgun/tui/screens/models.py +11 -0
  138. shotgun/tui/state/processing_state.py +19 -0
  139. shotgun/tui/utils/mode_progress.py +20 -86
  140. shotgun/tui/widgets/__init__.py +2 -1
  141. shotgun/tui/widgets/approval_widget.py +152 -0
  142. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  143. shotgun/tui/widgets/plan_panel.py +129 -0
  144. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  145. shotgun/tui/widgets/widget_coordinator.py +18 -0
  146. shotgun/utils/file_system_utils.py +4 -1
  147. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +88 -35
  148. shotgun_sh-0.6.2.dist-info/RECORD +291 -0
  149. shotgun/cli/export.py +0 -81
  150. shotgun/cli/plan.py +0 -73
  151. shotgun/cli/research.py +0 -93
  152. shotgun/cli/specify.py +0 -70
  153. shotgun/cli/tasks.py +0 -78
  154. shotgun/sentry_telemetry.py +0 -232
  155. shotgun/tui/screens/onboarding.py +0 -580
  156. shotgun_sh-0.3.3.dev1.dist-info/RECORD +0 -229
  157. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
  158. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
  159. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/licenses/LICENSE +0 -0
@@ -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,
@@ -23,7 +23,10 @@ logger = get_logger(__name__)
23
23
  # - A list of Paths: multiple allowed files/directories (e.g., [Path("specification.md"), Path("contracts")])
24
24
  # - "*": any file except protected files (for export agent)
25
25
  AGENT_DIRECTORIES: dict[AgentType, str | Path | list[Path]] = {
26
- AgentType.RESEARCH: Path("research.md"),
26
+ AgentType.RESEARCH: [
27
+ Path("research.md"),
28
+ Path("research"),
29
+ ], # Research can write main file and research folder
27
30
  AgentType.SPECIFY: [
28
31
  Path("specification.md"),
29
32
  Path("contracts"),
@@ -160,19 +163,33 @@ def _validate_shotgun_path(filename: str) -> Path:
160
163
  return full_path
161
164
 
162
165
 
166
+ # Binary file extensions that should be loaded via file_requests
167
+ BINARY_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp"}
168
+
169
+
163
170
  @register_tool(
164
171
  category=ToolCategory.ARTIFACT_MANAGEMENT,
165
172
  display_text="Reading file",
166
173
  key_arg="filename",
167
174
  )
168
175
  async def read_file(ctx: RunContext[AgentDeps], filename: str) -> str:
169
- """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
170
186
 
171
187
  Args:
172
188
  filename: Relative path to file within .shotgun directory
173
189
 
174
190
  Returns:
175
- File contents as string
191
+ File contents as string. For binary files, returns instructions
192
+ with the absolute path to use in file_requests.
176
193
 
177
194
  Raises:
178
195
  ValueError: If path is outside .shotgun directory
@@ -186,6 +203,22 @@ async def read_file(ctx: RunContext[AgentDeps], filename: str) -> str:
186
203
  if not await aiofiles.os.path.exists(file_path):
187
204
  raise FileNotFoundError(f"File not found: {filename}")
188
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
+
189
222
  async with aiofiles.open(file_path, encoding="utf-8") as f:
190
223
  content = await f.read()
191
224
  logger.debug("📄 Read %d characters from %s", len(content), filename)
@@ -282,3 +315,48 @@ async def append_file(ctx: RunContext[AgentDeps], filename: str, content: str) -
282
315
  Success message or error message
283
316
  """
284
317
  return await write_file(ctx, filename, content, mode="a")
318
+
319
+
320
+ @register_tool(
321
+ category=ToolCategory.ARTIFACT_MANAGEMENT,
322
+ display_text="Deleting file",
323
+ key_arg="filename",
324
+ )
325
+ async def delete_file(ctx: RunContext[AgentDeps], filename: str) -> str:
326
+ """Delete a file from the .shotgun directory.
327
+
328
+ Uses the same permission model as write_file - agents can only delete
329
+ files they have permission to write to.
330
+
331
+ Args:
332
+ filename: Relative path to file within .shotgun directory
333
+
334
+ Returns:
335
+ Success message or error message
336
+
337
+ Raises:
338
+ ValueError: If path is outside .shotgun directory or agent lacks permission
339
+ FileNotFoundError: If file does not exist
340
+ """
341
+ logger.debug("🔧 Deleting file: %s", filename)
342
+
343
+ try:
344
+ # Use agent-scoped validation (same as write_file)
345
+ file_path = _validate_agent_scoped_path(filename, ctx.deps.agent_mode)
346
+
347
+ if not await aiofiles.os.path.exists(file_path):
348
+ raise FileNotFoundError(f"File not found: {filename}")
349
+
350
+ # Delete the file
351
+ await aiofiles.os.remove(file_path)
352
+ logger.debug("🗑️ Deleted file: %s", filename)
353
+
354
+ # Track the file operation
355
+ ctx.deps.file_tracker.add_operation(file_path, FileOperationType.DELETED)
356
+
357
+ return f"Successfully deleted {filename}"
358
+
359
+ except Exception as e:
360
+ error_msg = f"Error deleting file '{filename}': {str(e)}"
361
+ logger.error("❌ File delete failed: %s", error_msg)
362
+ return error_msg
@@ -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"]
@@ -0,0 +1,167 @@
1
+ """Multimodal file reading tool that verifies files exist and returns paths.
2
+
3
+ This tool verifies PDFs/images exist and returns their paths for the agent
4
+ to include in `files_found`. The Router then loads these via `file_requests`.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ from pydantic import BaseModel, Field
10
+ from pydantic_ai import RunContext, ToolReturn
11
+
12
+ from shotgun.agents.models import AgentDeps
13
+ from shotgun.agents.tools.registry import ToolCategory, register_tool
14
+ from shotgun.logging_config import get_logger
15
+
16
+ logger = get_logger(__name__)
17
+
18
+ # MIME type mapping for supported file types
19
+ MIME_TYPES: dict[str, str] = {
20
+ ".pdf": "application/pdf",
21
+ ".png": "image/png",
22
+ ".jpg": "image/jpeg",
23
+ ".jpeg": "image/jpeg",
24
+ ".gif": "image/gif",
25
+ ".webp": "image/webp",
26
+ }
27
+
28
+ # Maximum file size for multimodal reading (32MB - Anthropic's limit)
29
+ MAX_FILE_SIZE_BYTES = 32 * 1024 * 1024
30
+
31
+
32
+ class MultimodalFileReadResult(BaseModel):
33
+ """Result from multimodal file read."""
34
+
35
+ success: bool = Field(description="Whether the file was successfully found")
36
+ file_path: str = Field(description="The absolute path to the file")
37
+ file_name: str = Field(default="", description="The file name")
38
+ file_size_bytes: int = Field(default=0, description="File size in bytes")
39
+ mime_type: str = Field(default="", description="MIME type of the file")
40
+ error: str | None = Field(default=None, description="Error message if failed")
41
+
42
+ def __str__(self) -> str:
43
+ if not self.success:
44
+ return f"Error: {self.error}"
45
+ return (
46
+ f"Found: {self.file_name} ({self.file_size_bytes} bytes, {self.mime_type})"
47
+ )
48
+
49
+
50
+ def _get_mime_type(file_path: Path) -> str | None:
51
+ """Get MIME type for a file based on extension."""
52
+ suffix = file_path.suffix.lower()
53
+ return MIME_TYPES.get(suffix)
54
+
55
+
56
+ def _format_file_size(size_bytes: int) -> str:
57
+ """Format file size in human-readable format."""
58
+ if size_bytes < 1024:
59
+ return f"{size_bytes} B"
60
+ elif size_bytes < 1024 * 1024:
61
+ return f"{size_bytes / 1024:.1f} KB"
62
+ else:
63
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
64
+
65
+
66
+ @register_tool(
67
+ category=ToolCategory.CODEBASE_UNDERSTANDING,
68
+ display_text="Reading file (multimodal)",
69
+ key_arg="file_path",
70
+ )
71
+ async def multimodal_file_read(
72
+ ctx: RunContext[AgentDeps],
73
+ file_path: str,
74
+ ) -> ToolReturn:
75
+ """Verify a PDF or image file exists and return its path.
76
+
77
+ This tool checks that the file exists and is a supported type (PDF, image),
78
+ then returns the absolute path. Include this path in your `files_found`
79
+ response so the Router can load it for visual analysis.
80
+
81
+ Args:
82
+ ctx: RunContext containing AgentDeps
83
+ file_path: Path to the file (absolute or relative to CWD)
84
+
85
+ Returns:
86
+ ToolReturn with file info and absolute path
87
+ """
88
+ logger.debug("Checking multimodal file: %s", file_path)
89
+
90
+ try:
91
+ # Resolve the path
92
+ path = Path(file_path).expanduser().resolve()
93
+
94
+ # Check if file exists
95
+ if not path.exists():
96
+ error_result = MultimodalFileReadResult(
97
+ success=False,
98
+ file_path=str(path),
99
+ error=f"File not found: {file_path}",
100
+ )
101
+ return ToolReturn(return_value=str(error_result))
102
+
103
+ if path.is_dir():
104
+ error_result = MultimodalFileReadResult(
105
+ success=False,
106
+ file_path=str(path),
107
+ error=f"'{file_path}' is a directory, not a file",
108
+ )
109
+ return ToolReturn(return_value=str(error_result))
110
+
111
+ # Check MIME type
112
+ mime_type = _get_mime_type(path)
113
+ if mime_type is None:
114
+ supported = ", ".join(MIME_TYPES.keys())
115
+ error_result = MultimodalFileReadResult(
116
+ success=False,
117
+ file_path=str(path),
118
+ error=f"Unsupported file type: {path.suffix}. Supported: {supported}",
119
+ )
120
+ return ToolReturn(return_value=str(error_result))
121
+
122
+ # Check file size
123
+ file_size = path.stat().st_size
124
+ if file_size > MAX_FILE_SIZE_BYTES:
125
+ error_result = MultimodalFileReadResult(
126
+ success=False,
127
+ file_path=str(path),
128
+ file_size_bytes=file_size,
129
+ error=f"File too large: {_format_file_size(file_size)} (max: {_format_file_size(MAX_FILE_SIZE_BYTES)})",
130
+ )
131
+ return ToolReturn(return_value=str(error_result))
132
+
133
+ logger.debug(
134
+ "Found multimodal file: %s (%s, %s)",
135
+ path.name,
136
+ _format_file_size(file_size),
137
+ mime_type,
138
+ )
139
+
140
+ # Return file info with absolute path
141
+ file_type = "PDF" if mime_type == "application/pdf" else "Image"
142
+ summary = f"""{file_type} found: {path.name}
143
+ Size: {_format_file_size(file_size)}
144
+ Type: {mime_type}
145
+ Absolute path: {path}
146
+
147
+ IMPORTANT: Include the absolute path above in your `files_found` response field.
148
+ The Router will then be able to load and analyze this file's visual content."""
149
+
150
+ return ToolReturn(return_value=summary)
151
+
152
+ except PermissionError:
153
+ error_result = MultimodalFileReadResult(
154
+ success=False,
155
+ file_path=file_path,
156
+ error=f"Permission denied: {file_path}",
157
+ )
158
+ return ToolReturn(return_value=str(error_result))
159
+
160
+ except Exception as e:
161
+ logger.error("Error checking multimodal file: %s", str(e))
162
+ error_result = MultimodalFileReadResult(
163
+ success=False,
164
+ file_path=file_path,
165
+ error=f"Error: {str(e)}",
166
+ )
167
+ return ToolReturn(return_value=str(error_result))
@@ -0,0 +1,62 @@
1
+ """Markdown manipulation tools for Pydantic AI agents."""
2
+
3
+ from .insert_section import insert_markdown_section
4
+ from .models import (
5
+ CloseMatch,
6
+ HeadingList,
7
+ HeadingMatch,
8
+ MarkdownFileContext,
9
+ MarkdownHeading,
10
+ SectionMatchResult,
11
+ SectionNumber,
12
+ )
13
+ from .remove_section import remove_markdown_section
14
+ from .replace_section import replace_markdown_section
15
+ from .utils import (
16
+ decrement_section_number,
17
+ detect_line_ending,
18
+ extract_headings,
19
+ find_and_validate_section,
20
+ find_close_matches,
21
+ find_matching_heading,
22
+ find_section_bounds,
23
+ get_heading_level,
24
+ increment_section_number,
25
+ load_markdown_file,
26
+ normalize_section_content,
27
+ parse_section_number,
28
+ renumber_headings_after,
29
+ split_normalized_content,
30
+ write_markdown_file,
31
+ )
32
+
33
+ __all__ = [
34
+ # Tools
35
+ "replace_markdown_section",
36
+ "insert_markdown_section",
37
+ "remove_markdown_section",
38
+ # Models
39
+ "MarkdownHeading",
40
+ "HeadingList",
41
+ "HeadingMatch",
42
+ "CloseMatch",
43
+ "SectionNumber",
44
+ "MarkdownFileContext",
45
+ "SectionMatchResult",
46
+ # Utilities
47
+ "get_heading_level",
48
+ "extract_headings",
49
+ "find_matching_heading",
50
+ "find_close_matches",
51
+ "find_section_bounds",
52
+ "detect_line_ending",
53
+ "normalize_section_content",
54
+ "split_normalized_content",
55
+ "parse_section_number",
56
+ "increment_section_number",
57
+ "decrement_section_number",
58
+ "renumber_headings_after",
59
+ "load_markdown_file",
60
+ "find_and_validate_section",
61
+ "write_markdown_file",
62
+ ]