shotgun-sh 0.2.3.dev2__py3-none-any.whl → 0.2.11.dev5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of shotgun-sh might be problematic. Click here for more details.

Files changed (132) hide show
  1. shotgun/agents/agent_manager.py +664 -75
  2. shotgun/agents/common.py +76 -70
  3. shotgun/agents/config/constants.py +0 -6
  4. shotgun/agents/config/manager.py +78 -36
  5. shotgun/agents/config/models.py +41 -1
  6. shotgun/agents/config/provider.py +70 -15
  7. shotgun/agents/context_analyzer/__init__.py +28 -0
  8. shotgun/agents/context_analyzer/analyzer.py +471 -0
  9. shotgun/agents/context_analyzer/constants.py +9 -0
  10. shotgun/agents/context_analyzer/formatter.py +115 -0
  11. shotgun/agents/context_analyzer/models.py +212 -0
  12. shotgun/agents/conversation_history.py +125 -2
  13. shotgun/agents/conversation_manager.py +57 -19
  14. shotgun/agents/export.py +6 -7
  15. shotgun/agents/history/compaction.py +9 -4
  16. shotgun/agents/history/context_extraction.py +93 -6
  17. shotgun/agents/history/history_processors.py +14 -2
  18. shotgun/agents/history/token_counting/anthropic.py +49 -11
  19. shotgun/agents/history/token_counting/base.py +14 -3
  20. shotgun/agents/history/token_counting/openai.py +8 -0
  21. shotgun/agents/history/token_counting/sentencepiece_counter.py +8 -0
  22. shotgun/agents/history/token_counting/tokenizer_cache.py +3 -1
  23. shotgun/agents/history/token_counting/utils.py +0 -3
  24. shotgun/agents/models.py +50 -2
  25. shotgun/agents/plan.py +6 -7
  26. shotgun/agents/research.py +7 -8
  27. shotgun/agents/specify.py +6 -7
  28. shotgun/agents/tasks.py +6 -7
  29. shotgun/agents/tools/__init__.py +0 -2
  30. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  31. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  32. shotgun/agents/tools/codebase/file_read.py +11 -2
  33. shotgun/agents/tools/codebase/query_graph.py +6 -0
  34. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  35. shotgun/agents/tools/file_management.py +82 -16
  36. shotgun/agents/tools/registry.py +217 -0
  37. shotgun/agents/tools/web_search/__init__.py +30 -18
  38. shotgun/agents/tools/web_search/anthropic.py +26 -5
  39. shotgun/agents/tools/web_search/gemini.py +23 -11
  40. shotgun/agents/tools/web_search/openai.py +22 -13
  41. shotgun/agents/tools/web_search/utils.py +2 -2
  42. shotgun/agents/usage_manager.py +16 -11
  43. shotgun/api_endpoints.py +7 -3
  44. shotgun/build_constants.py +1 -1
  45. shotgun/cli/clear.py +53 -0
  46. shotgun/cli/compact.py +186 -0
  47. shotgun/cli/config.py +8 -5
  48. shotgun/cli/context.py +111 -0
  49. shotgun/cli/export.py +1 -1
  50. shotgun/cli/feedback.py +4 -2
  51. shotgun/cli/models.py +1 -0
  52. shotgun/cli/plan.py +1 -1
  53. shotgun/cli/research.py +1 -1
  54. shotgun/cli/specify.py +1 -1
  55. shotgun/cli/tasks.py +1 -1
  56. shotgun/cli/update.py +16 -2
  57. shotgun/codebase/core/change_detector.py +5 -3
  58. shotgun/codebase/core/code_retrieval.py +4 -2
  59. shotgun/codebase/core/ingestor.py +10 -8
  60. shotgun/codebase/core/manager.py +13 -4
  61. shotgun/codebase/core/nl_query.py +1 -1
  62. shotgun/llm_proxy/__init__.py +5 -2
  63. shotgun/llm_proxy/clients.py +12 -7
  64. shotgun/logging_config.py +18 -27
  65. shotgun/main.py +73 -11
  66. shotgun/posthog_telemetry.py +23 -7
  67. shotgun/prompts/agents/export.j2 +18 -1
  68. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
  69. shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
  70. shotgun/prompts/agents/plan.j2 +1 -1
  71. shotgun/prompts/agents/research.j2 +1 -1
  72. shotgun/prompts/agents/specify.j2 +270 -3
  73. shotgun/prompts/agents/state/system_state.j2 +4 -0
  74. shotgun/prompts/agents/tasks.j2 +1 -1
  75. shotgun/prompts/loader.py +2 -2
  76. shotgun/prompts/tools/web_search.j2 +14 -0
  77. shotgun/sentry_telemetry.py +7 -16
  78. shotgun/settings.py +238 -0
  79. shotgun/telemetry.py +18 -33
  80. shotgun/tui/app.py +243 -43
  81. shotgun/tui/commands/__init__.py +1 -1
  82. shotgun/tui/components/context_indicator.py +179 -0
  83. shotgun/tui/components/mode_indicator.py +70 -0
  84. shotgun/tui/components/status_bar.py +48 -0
  85. shotgun/tui/containers.py +91 -0
  86. shotgun/tui/dependencies.py +39 -0
  87. shotgun/tui/protocols.py +45 -0
  88. shotgun/tui/screens/chat/__init__.py +5 -0
  89. shotgun/tui/screens/chat/chat.tcss +54 -0
  90. shotgun/tui/screens/chat/chat_screen.py +1202 -0
  91. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  92. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  93. shotgun/tui/screens/chat/help_text.py +40 -0
  94. shotgun/tui/screens/chat/prompt_history.py +48 -0
  95. shotgun/tui/screens/chat.tcss +11 -0
  96. shotgun/tui/screens/chat_screen/command_providers.py +78 -2
  97. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  98. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  99. shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
  100. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  101. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  102. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  103. shotgun/tui/screens/confirmation_dialog.py +151 -0
  104. shotgun/tui/screens/feedback.py +4 -4
  105. shotgun/tui/screens/github_issue.py +102 -0
  106. shotgun/tui/screens/model_picker.py +49 -24
  107. shotgun/tui/screens/onboarding.py +431 -0
  108. shotgun/tui/screens/pipx_migration.py +153 -0
  109. shotgun/tui/screens/provider_config.py +50 -27
  110. shotgun/tui/screens/shotgun_auth.py +2 -2
  111. shotgun/tui/screens/welcome.py +32 -10
  112. shotgun/tui/services/__init__.py +5 -0
  113. shotgun/tui/services/conversation_service.py +184 -0
  114. shotgun/tui/state/__init__.py +7 -0
  115. shotgun/tui/state/processing_state.py +185 -0
  116. shotgun/tui/utils/mode_progress.py +14 -7
  117. shotgun/tui/widgets/__init__.py +5 -0
  118. shotgun/tui/widgets/widget_coordinator.py +262 -0
  119. shotgun/utils/datetime_utils.py +77 -0
  120. shotgun/utils/file_system_utils.py +22 -2
  121. shotgun/utils/marketing.py +110 -0
  122. shotgun/utils/update_checker.py +69 -14
  123. shotgun_sh-0.2.11.dev5.dist-info/METADATA +130 -0
  124. shotgun_sh-0.2.11.dev5.dist-info/RECORD +193 -0
  125. {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev5.dist-info}/entry_points.txt +1 -0
  126. {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev5.dist-info}/licenses/LICENSE +1 -1
  127. shotgun/agents/tools/user_interaction.py +0 -37
  128. shotgun/tui/screens/chat.py +0 -804
  129. shotgun/tui/screens/chat_screen/history.py +0 -352
  130. shotgun_sh-0.2.3.dev2.dist-info/METADATA +0 -467
  131. shotgun_sh-0.2.3.dev2.dist-info/RECORD +0 -154
  132. {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev5.dist-info}/WHEEL +0 -0
@@ -8,7 +8,6 @@ from .codebase import (
8
8
  retrieve_code,
9
9
  )
10
10
  from .file_management import append_file, read_file, write_file
11
- from .user_interaction import ask_user
12
11
  from .web_search import (
13
12
  anthropic_web_search_tool,
14
13
  gemini_web_search_tool,
@@ -21,7 +20,6 @@ __all__ = [
21
20
  "anthropic_web_search_tool",
22
21
  "gemini_web_search_tool",
23
22
  "get_available_web_search_tools",
24
- "ask_user",
25
23
  "read_file",
26
24
  "write_file",
27
25
  "append_file",
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
  from pydantic_ai import RunContext
9
9
 
10
10
  from shotgun.agents.models import AgentDeps
11
+ from shotgun.agents.tools.registry import ToolCategory, register_tool
11
12
  from shotgun.logging_config import get_logger
12
13
 
13
14
  from .models import ShellCommandResult
@@ -48,6 +49,11 @@ DANGEROUS_PATTERNS = [
48
49
  ]
49
50
 
50
51
 
52
+ @register_tool(
53
+ category=ToolCategory.CODEBASE_UNDERSTANDING,
54
+ display_text="Running shell",
55
+ key_arg="command",
56
+ )
51
57
  async def codebase_shell(
52
58
  ctx: RunContext[AgentDeps],
53
59
  command: str,
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
  from pydantic_ai import RunContext
6
6
 
7
7
  from shotgun.agents.models import AgentDeps
8
+ from shotgun.agents.tools.registry import ToolCategory, register_tool
8
9
  from shotgun.logging_config import get_logger
9
10
 
10
11
  from .models import DirectoryListResult
@@ -12,6 +13,11 @@ from .models import DirectoryListResult
12
13
  logger = get_logger(__name__)
13
14
 
14
15
 
16
+ @register_tool(
17
+ category=ToolCategory.CODEBASE_UNDERSTANDING,
18
+ display_text="Listing directory",
19
+ key_arg="directory",
20
+ )
15
21
  async def directory_lister(
16
22
  ctx: RunContext[AgentDeps], graph_id: str, directory: str = "."
17
23
  ) -> DirectoryListResult:
@@ -2,9 +2,11 @@
2
2
 
3
3
  from pathlib import Path
4
4
 
5
+ import aiofiles
5
6
  from pydantic_ai import RunContext
6
7
 
7
8
  from shotgun.agents.models import AgentDeps
9
+ from shotgun.agents.tools.registry import ToolCategory, register_tool
8
10
  from shotgun.codebase.core.language_config import get_language_config
9
11
  from shotgun.logging_config import get_logger
10
12
 
@@ -13,6 +15,11 @@ from .models import FileReadResult
13
15
  logger = get_logger(__name__)
14
16
 
15
17
 
18
+ @register_tool(
19
+ category=ToolCategory.CODEBASE_UNDERSTANDING,
20
+ display_text="Reading file",
21
+ key_arg="file_path",
22
+ )
16
23
  async def file_read(
17
24
  ctx: RunContext[AgentDeps], graph_id: str, file_path: str
18
25
  ) -> FileReadResult:
@@ -87,7 +94,8 @@ async def file_read(
87
94
  # Read file contents
88
95
  encoding_used = "utf-8"
89
96
  try:
90
- content = full_file_path.read_text(encoding="utf-8")
97
+ async with aiofiles.open(full_file_path, encoding="utf-8") as f:
98
+ content = await f.read()
91
99
  size_bytes = full_file_path.stat().st_size
92
100
 
93
101
  logger.debug(
@@ -113,7 +121,8 @@ async def file_read(
113
121
  try:
114
122
  # Try with different encoding
115
123
  encoding_used = "latin-1"
116
- content = full_file_path.read_text(encoding="latin-1")
124
+ async with aiofiles.open(full_file_path, encoding="latin-1") as f:
125
+ content = await f.read()
117
126
  size_bytes = full_file_path.stat().st_size
118
127
 
119
128
  # Detect language from file extension
@@ -3,6 +3,7 @@
3
3
  from pydantic_ai import RunContext
4
4
 
5
5
  from shotgun.agents.models import AgentDeps
6
+ from shotgun.agents.tools.registry import ToolCategory, register_tool
6
7
  from shotgun.codebase.models import QueryType
7
8
  from shotgun.logging_config import get_logger
8
9
 
@@ -11,6 +12,11 @@ from .models import QueryGraphResult
11
12
  logger = get_logger(__name__)
12
13
 
13
14
 
15
+ @register_tool(
16
+ category=ToolCategory.CODEBASE_UNDERSTANDING,
17
+ display_text="Querying code",
18
+ key_arg="query",
19
+ )
14
20
  async def query_graph(
15
21
  ctx: RunContext[AgentDeps], graph_id: str, query: str
16
22
  ) -> QueryGraphResult:
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
  from pydantic_ai import RunContext
6
6
 
7
7
  from shotgun.agents.models import AgentDeps
8
+ from shotgun.agents.tools.registry import ToolCategory, register_tool
8
9
  from shotgun.codebase.core.code_retrieval import retrieve_code_by_qualified_name
9
10
  from shotgun.codebase.core.language_config import get_language_config
10
11
  from shotgun.logging_config import get_logger
@@ -14,6 +15,11 @@ from .models import CodeSnippetResult
14
15
  logger = get_logger(__name__)
15
16
 
16
17
 
18
+ @register_tool(
19
+ category=ToolCategory.CODEBASE_UNDERSTANDING,
20
+ display_text="Retrieving code",
21
+ key_arg="qualified_name",
22
+ )
17
23
  async def retrieve_code(
18
24
  ctx: RunContext[AgentDeps], graph_id: str, qualified_name: str
19
25
  ) -> CodeSnippetResult:
@@ -6,20 +6,30 @@ These tools are restricted to the .shotgun directory for security.
6
6
  from pathlib import Path
7
7
  from typing import Literal
8
8
 
9
+ import aiofiles
10
+ import aiofiles.os
9
11
  from pydantic_ai import RunContext
10
12
 
11
13
  from shotgun.agents.models import AgentDeps, AgentType, FileOperationType
14
+ from shotgun.agents.tools.registry import ToolCategory, register_tool
12
15
  from shotgun.logging_config import get_logger
13
16
  from shotgun.utils.file_system_utils import get_shotgun_base_path
14
17
 
15
18
  logger = get_logger(__name__)
16
19
 
17
20
  # Map agent modes to their allowed directories/files (in workflow order)
18
- AGENT_DIRECTORIES = {
19
- AgentType.RESEARCH: "research.md",
20
- AgentType.SPECIFY: "specification.md",
21
- AgentType.PLAN: "plan.md",
22
- AgentType.TASKS: "tasks.md",
21
+ # Values can be:
22
+ # - A Path: exact file (e.g., Path("research.md"))
23
+ # - A list of Paths: multiple allowed files/directories (e.g., [Path("specification.md"), Path("contracts")])
24
+ # - "*": any file except protected files (for export agent)
25
+ AGENT_DIRECTORIES: dict[AgentType, str | Path | list[Path]] = {
26
+ AgentType.RESEARCH: Path("research.md"),
27
+ AgentType.SPECIFY: [
28
+ Path("specification.md"),
29
+ Path("contracts"),
30
+ ], # Specify can write specs and contract files
31
+ AgentType.PLAN: Path("plan.md"),
32
+ AgentType.TASKS: Path("tasks.md"),
23
33
  AgentType.EXPORT: "*", # Export agent can write anywhere except protected files
24
34
  }
25
35
 
@@ -60,13 +70,52 @@ def _validate_agent_scoped_path(filename: str, agent_mode: AgentType | None) ->
60
70
  # Allow writing anywhere else in .shotgun directory
61
71
  full_path = (base_path / filename).resolve()
62
72
  else:
63
- # For other agents, only allow writing to their specific file
64
- allowed_file = AGENT_DIRECTORIES[agent_mode]
65
- if filename != allowed_file:
73
+ # For other agents, check if they have access to the requested file
74
+ allowed_paths_raw = AGENT_DIRECTORIES[agent_mode]
75
+
76
+ # Convert single Path/string to list of Paths for uniform handling
77
+ if isinstance(allowed_paths_raw, str):
78
+ # Special case: "*" means export agent
79
+ allowed_paths = (
80
+ [Path(allowed_paths_raw)] if allowed_paths_raw != "*" else []
81
+ )
82
+ elif isinstance(allowed_paths_raw, Path):
83
+ allowed_paths = [allowed_paths_raw]
84
+ else:
85
+ # Already a list
86
+ allowed_paths = allowed_paths_raw
87
+
88
+ # Check if filename matches any allowed path
89
+ is_allowed = False
90
+ for allowed_path in allowed_paths:
91
+ allowed_str = str(allowed_path)
92
+
93
+ # Check if it's a directory (no .md extension or suffix)
94
+ # Directories: Path("contracts") has no suffix, files: Path("spec.md") has .md suffix
95
+ if not allowed_path.suffix or (
96
+ allowed_path.suffix and not allowed_str.endswith(".md")
97
+ ):
98
+ # Directory - allow any file within this directory
99
+ # Check both "contracts/file.py" and "contracts" prefix
100
+ if (
101
+ filename.startswith(allowed_str + "/")
102
+ or filename == allowed_str
103
+ ):
104
+ is_allowed = True
105
+ break
106
+ else:
107
+ # Exact file match
108
+ if filename == allowed_str:
109
+ is_allowed = True
110
+ break
111
+
112
+ if not is_allowed:
113
+ allowed_display = ", ".join(f"'{p}'" for p in allowed_paths)
66
114
  raise ValueError(
67
- f"{agent_mode.value.capitalize()} agent can only write to '{allowed_file}'. "
115
+ f"{agent_mode.value.capitalize()} agent can only write to {allowed_display}. "
68
116
  f"Attempted to write to '{filename}'"
69
117
  )
118
+
70
119
  full_path = (base_path / filename).resolve()
71
120
  else:
72
121
  # No agent mode specified, fall back to old validation
@@ -111,6 +160,11 @@ def _validate_shotgun_path(filename: str) -> Path:
111
160
  return full_path
112
161
 
113
162
 
163
+ @register_tool(
164
+ category=ToolCategory.ARTIFACT_MANAGEMENT,
165
+ display_text="Reading file",
166
+ key_arg="filename",
167
+ )
114
168
  async def read_file(ctx: RunContext[AgentDeps], filename: str) -> str:
115
169
  """Read a file from the .shotgun directory.
116
170
 
@@ -129,10 +183,11 @@ async def read_file(ctx: RunContext[AgentDeps], filename: str) -> str:
129
183
  try:
130
184
  file_path = _validate_shotgun_path(filename)
131
185
 
132
- if not file_path.exists():
186
+ if not await aiofiles.os.path.exists(file_path):
133
187
  raise FileNotFoundError(f"File not found: {filename}")
134
188
 
135
- content = file_path.read_text(encoding="utf-8")
189
+ async with aiofiles.open(file_path, encoding="utf-8") as f:
190
+ content = await f.read()
136
191
  logger.debug("📄 Read %d characters from %s", len(content), filename)
137
192
  return content
138
193
 
@@ -142,6 +197,11 @@ async def read_file(ctx: RunContext[AgentDeps], filename: str) -> str:
142
197
  return error_msg
143
198
 
144
199
 
200
+ @register_tool(
201
+ category=ToolCategory.ARTIFACT_MANAGEMENT,
202
+ display_text="Writing file",
203
+ key_arg="filename",
204
+ )
145
205
  async def write_file(
146
206
  ctx: RunContext[AgentDeps],
147
207
  filename: str,
@@ -176,21 +236,22 @@ async def write_file(
176
236
  else:
177
237
  operation = (
178
238
  FileOperationType.CREATED
179
- if not file_path.exists()
239
+ if not await aiofiles.os.path.exists(file_path)
180
240
  else FileOperationType.UPDATED
181
241
  )
182
242
 
183
243
  # Ensure parent directory exists
184
- file_path.parent.mkdir(parents=True, exist_ok=True)
244
+ await aiofiles.os.makedirs(file_path.parent, exist_ok=True)
185
245
 
186
246
  # Write content
187
247
  if mode == "a":
188
- with open(file_path, "a", encoding="utf-8") as f:
189
- f.write(content)
248
+ async with aiofiles.open(file_path, "a", encoding="utf-8") as f:
249
+ await f.write(content)
190
250
  logger.debug("📄 Appended %d characters to %s", len(content), filename)
191
251
  result = f"Successfully appended {len(content)} characters to {filename}"
192
252
  else:
193
- file_path.write_text(content, encoding="utf-8")
253
+ async with aiofiles.open(file_path, "w", encoding="utf-8") as f:
254
+ await f.write(content)
194
255
  logger.debug("📄 Wrote %d characters to %s", len(content), filename)
195
256
  result = f"Successfully wrote {len(content)} characters to {filename}"
196
257
 
@@ -205,6 +266,11 @@ async def write_file(
205
266
  return error_msg
206
267
 
207
268
 
269
+ @register_tool(
270
+ category=ToolCategory.ARTIFACT_MANAGEMENT,
271
+ display_text="Appending to file",
272
+ key_arg="filename",
273
+ )
208
274
  async def append_file(ctx: RunContext[AgentDeps], filename: str, content: str) -> str:
209
275
  """Append content to a file in the .shotgun directory.
210
276
 
@@ -0,0 +1,217 @@
1
+ """Tool category registry using decorators for automatic registration.
2
+
3
+ This module provides a decorator-based system for categorizing tools used by agents.
4
+ Tools can be decorated with @register_tool to automatically register their category,
5
+ which is then used by the context analyzer to break down token usage by tool type.
6
+
7
+ It also provides a display registry system for tool formatting in the TUI, allowing
8
+ tools to declare how they should be displayed when streaming.
9
+ """
10
+
11
+ from collections.abc import Callable
12
+ from enum import StrEnum
13
+ from typing import TypeVar, overload
14
+
15
+ import sentry_sdk
16
+ from pydantic import BaseModel
17
+
18
+ from shotgun.logging_config import get_logger
19
+
20
+ logger = get_logger(__name__)
21
+
22
+ # Type variable for decorated functions
23
+ F = TypeVar("F", bound=Callable[..., object])
24
+
25
+
26
+ class ToolCategory(StrEnum):
27
+ """Categories for agent tools used in context analysis."""
28
+
29
+ CODEBASE_UNDERSTANDING = "codebase_understanding"
30
+ ARTIFACT_MANAGEMENT = "artifact_management"
31
+ WEB_RESEARCH = "web_research"
32
+ AGENT_RESPONSE = "agent_response"
33
+ UNKNOWN = "unknown"
34
+
35
+
36
+ class ToolDisplayConfig(BaseModel):
37
+ """Configuration for how a tool should be displayed in the TUI.
38
+
39
+ Attributes:
40
+ display_text: Text to show (e.g., "Reading file", "Querying code")
41
+ key_arg: Primary argument to extract from tool args for display
42
+ hide: Whether to completely hide this tool call from the UI
43
+ """
44
+
45
+ display_text: str
46
+ key_arg: str
47
+ hide: bool = False
48
+
49
+
50
+ # Global registry mapping tool names to categories
51
+ _TOOL_REGISTRY: dict[str, ToolCategory] = {}
52
+
53
+ # Global registry mapping tool names to display configs
54
+ _TOOL_DISPLAY_REGISTRY: dict[str, ToolDisplayConfig] = {}
55
+
56
+
57
+ @overload
58
+ def register_tool(
59
+ category: ToolCategory,
60
+ display_text: str,
61
+ key_arg: str,
62
+ ) -> Callable[[F], F]: ...
63
+
64
+
65
+ @overload
66
+ def register_tool(
67
+ category: ToolCategory,
68
+ display_text: str,
69
+ key_arg: str,
70
+ *,
71
+ hide: bool,
72
+ ) -> Callable[[F], F]: ...
73
+
74
+
75
+ def register_tool(
76
+ category: ToolCategory,
77
+ display_text: str,
78
+ key_arg: str,
79
+ *,
80
+ hide: bool = False,
81
+ ) -> Callable[[F], F]:
82
+ """Decorator to register a tool's category and display configuration.
83
+
84
+ Args:
85
+ category: The ToolCategory enum value for this tool
86
+ display_text: Text to show (e.g., "Reading file", "Querying code")
87
+ key_arg: Primary argument name to extract for display (e.g., "query", "filename")
88
+ hide: Whether to hide this tool call completely from the UI (default: False)
89
+
90
+ Returns:
91
+ Decorator function that registers the tool and returns it unchanged
92
+
93
+ Display Format:
94
+ - When key_arg value is missing: Shows just display_text (e.g., "Reading file")
95
+ - When key_arg value is present: Shows "display_text: key_arg_value" (e.g., "Reading file: foo.py")
96
+
97
+ Example:
98
+ @register_tool(
99
+ category=ToolCategory.CODEBASE_UNDERSTANDING,
100
+ display_text="Querying code",
101
+ key_arg="query",
102
+ )
103
+ async def query_graph(ctx: RunContext[AgentDeps], query: str) -> str:
104
+ ...
105
+ """
106
+
107
+ def decorator(func: F) -> F:
108
+ tool_name = func.__name__
109
+ _TOOL_REGISTRY[tool_name] = category
110
+ logger.debug(f"Registered tool '{tool_name}' as category '{category.value}'")
111
+
112
+ # Register display config
113
+ config = ToolDisplayConfig(
114
+ display_text=display_text,
115
+ key_arg=key_arg,
116
+ hide=hide,
117
+ )
118
+ _TOOL_DISPLAY_REGISTRY[tool_name] = config
119
+ logger.debug(f"Registered display config for tool '{tool_name}'")
120
+
121
+ return func
122
+
123
+ return decorator
124
+
125
+
126
+ # Backwards compatibility alias
127
+ tool_category = register_tool
128
+
129
+
130
+ def get_tool_category(tool_name: str) -> ToolCategory:
131
+ """Get category for a tool, logging unknown tools to Sentry.
132
+
133
+ Args:
134
+ tool_name: Name of the tool to look up
135
+
136
+ Returns:
137
+ ToolCategory enum value for the tool, or UNKNOWN if not registered
138
+ """
139
+ category = _TOOL_REGISTRY.get(tool_name)
140
+
141
+ if category is None:
142
+ logger.warning(f"Unknown tool encountered in context analysis: {tool_name}")
143
+ sentry_sdk.capture_message(
144
+ f"Unknown tool in context analysis: {tool_name}",
145
+ level="warning",
146
+ extras={"tool_name": tool_name},
147
+ )
148
+ return ToolCategory.UNKNOWN
149
+
150
+ return category
151
+
152
+
153
+ def register_special_tool(tool_name: str, category: ToolCategory) -> None:
154
+ """Register a special tool that doesn't have a decorator.
155
+
156
+ Used for tools like 'final_result' that aren't actual Python functions
157
+ but need to be categorized.
158
+
159
+ Args:
160
+ tool_name: Name of the special tool
161
+ category: Category to assign to this tool
162
+ """
163
+ _TOOL_REGISTRY[tool_name] = category
164
+ logger.debug(
165
+ f"Registered special tool '{tool_name}' as category '{category.value}'"
166
+ )
167
+
168
+
169
+ def get_tool_display_config(tool_name: str) -> ToolDisplayConfig | None:
170
+ """Get display configuration for a tool.
171
+
172
+ Args:
173
+ tool_name: Name of the tool to look up
174
+
175
+ Returns:
176
+ ToolDisplayConfig for the tool, or None if not registered
177
+ """
178
+ return _TOOL_DISPLAY_REGISTRY.get(tool_name)
179
+
180
+
181
+ def register_tool_display(
182
+ tool_name: str,
183
+ display_text: str,
184
+ key_arg: str,
185
+ *,
186
+ hide: bool = False,
187
+ ) -> None:
188
+ """Register a display config for a special tool that doesn't have a decorator.
189
+
190
+ Used for tools like 'final_result' or builtin tools that aren't actual Python functions.
191
+
192
+ Args:
193
+ tool_name: Name of the special tool
194
+ display_text: Text to show (e.g., "Reading file", "Querying code")
195
+ key_arg: Primary argument name to extract for display
196
+ hide: Whether to hide this tool call completely
197
+ """
198
+ config = ToolDisplayConfig(
199
+ display_text=display_text,
200
+ key_arg=key_arg,
201
+ hide=hide,
202
+ )
203
+ _TOOL_DISPLAY_REGISTRY[tool_name] = config
204
+ logger.debug(f"Registered display config for special tool '{tool_name}'")
205
+
206
+
207
+ # Register special tools that don't have decorators
208
+ register_special_tool("final_result", ToolCategory.AGENT_RESPONSE)
209
+ register_tool_display("final_result", display_text="", key_arg="", hide=True)
210
+
211
+ # Register builtin tools (tools that come from Pydantic AI or model providers)
212
+ # These don't have Python function definitions but need display formatting
213
+ register_tool_display(
214
+ "web_search",
215
+ display_text="Searching",
216
+ key_arg="query",
217
+ )
@@ -3,7 +3,10 @@
3
3
  Provides web search capabilities for multiple LLM providers:
4
4
  - OpenAI: Uses Responses API with web_search tool (BYOK only)
5
5
  - Anthropic: Uses Messages API with web_search_20250305 tool (BYOK only)
6
- - Gemini: Uses grounding with Google Search via Pydantic AI (works with Shotgun Account)
6
+ - Gemini: Uses grounding with Google Search via Pydantic AI (Shotgun Account and BYOK)
7
+
8
+ Shotgun Account: Only Gemini web search is available
9
+ BYOK: All tools work with direct provider API keys
7
10
  """
8
11
 
9
12
  from collections.abc import Awaitable, Callable
@@ -23,14 +26,15 @@ logger = get_logger(__name__)
23
26
  WebSearchTool = Callable[[str], Awaitable[str]]
24
27
 
25
28
 
26
- def get_available_web_search_tools() -> list[WebSearchTool]:
29
+ async def get_available_web_search_tools() -> list[WebSearchTool]:
27
30
  """Get list of available web search tools based on configured API keys.
28
31
 
29
- When using Shotgun Account (via LiteLLM proxy):
30
- Only Gemini web search is available (others use provider-specific APIs)
32
+ Works with both Shotgun Account (via LiteLLM proxy) and BYOK (individual provider keys).
31
33
 
32
- When using BYOK (individual provider keys):
33
- All provider tools are available based on their respective keys
34
+ Available tools:
35
+ - Gemini: Available for both Shotgun Account and BYOK
36
+ - Anthropic: BYOK only (uses Messages API with web search)
37
+ - OpenAI: BYOK only (uses Responses API not compatible with LiteLLM proxy)
34
38
 
35
39
  Returns:
36
40
  List of web search tool functions that have API keys configured
@@ -39,33 +43,41 @@ def get_available_web_search_tools() -> list[WebSearchTool]:
39
43
 
40
44
  # Check if using Shotgun Account
41
45
  config_manager = get_config_manager()
42
- config = config_manager.load()
46
+ config = await config_manager.load()
43
47
  has_shotgun_key = config.shotgun.api_key is not None
44
48
 
45
49
  if has_shotgun_key:
46
- # Shotgun Account mode: Only Gemini supports web search via LiteLLM
47
- if is_provider_available(ProviderType.GOOGLE):
48
- logger.info("🔑 Shotgun Account detected - using Gemini web search only")
49
- logger.debug(" OpenAI and Anthropic web search require direct API keys")
50
+ logger.debug("🔑 Shotgun Account - only Gemini web search available")
51
+
52
+ # Gemini: Only search tool available for Shotgun Account
53
+ if await is_provider_available(ProviderType.GOOGLE):
54
+ logger.debug("✅ Gemini web search tool available")
50
55
  tools.append(gemini_web_search_tool)
51
- else:
52
- logger.warning(
53
- "⚠️ Shotgun Account configured but no Gemini key - "
54
- "web search unavailable"
56
+
57
+ # Anthropic: Not available for Shotgun Account (Gemini-only for Shotgun)
58
+ if await is_provider_available(ProviderType.ANTHROPIC):
59
+ logger.debug(
60
+ "⚠️ Anthropic web search requires BYOK (Shotgun Account uses Gemini only)"
61
+ )
62
+
63
+ # OpenAI: Not available for Shotgun Account (Responses API incompatible with proxy)
64
+ if await is_provider_available(ProviderType.OPENAI):
65
+ logger.debug(
66
+ "⚠️ OpenAI web search requires BYOK (Responses API not supported via proxy)"
55
67
  )
56
68
  else:
57
69
  # BYOK mode: Load all available tools based on individual provider keys
58
70
  logger.debug("🔑 BYOK mode - checking all provider web search tools")
59
71
 
60
- if is_provider_available(ProviderType.OPENAI):
72
+ if await is_provider_available(ProviderType.OPENAI):
61
73
  logger.debug("✅ OpenAI web search tool available")
62
74
  tools.append(openai_web_search_tool)
63
75
 
64
- if is_provider_available(ProviderType.ANTHROPIC):
76
+ if await is_provider_available(ProviderType.ANTHROPIC):
65
77
  logger.debug("✅ Anthropic web search tool available")
66
78
  tools.append(anthropic_web_search_tool)
67
79
 
68
- if is_provider_available(ProviderType.GOOGLE):
80
+ if await is_provider_available(ProviderType.GOOGLE):
69
81
  logger.debug("✅ Gemini web search tool available")
70
82
  tools.append(gemini_web_search_tool)
71
83
 
@@ -8,11 +8,22 @@ from shotgun.agents.config import get_provider_model
8
8
  from shotgun.agents.config.constants import MEDIUM_TEXT_8K_TOKENS
9
9
  from shotgun.agents.config.models import ProviderType
10
10
  from shotgun.agents.llm import shotgun_model_request
11
+ from shotgun.agents.tools.registry import ToolCategory, register_tool
11
12
  from shotgun.logging_config import get_logger
13
+ from shotgun.prompts import PromptLoader
14
+ from shotgun.utils.datetime_utils import get_datetime_context
12
15
 
13
16
  logger = get_logger(__name__)
14
17
 
18
+ # Global prompt loader instance
19
+ prompt_loader = PromptLoader()
15
20
 
21
+
22
+ @register_tool(
23
+ category=ToolCategory.WEB_RESEARCH,
24
+ display_text="Searching web",
25
+ key_arg="query",
26
+ )
16
27
  async def anthropic_web_search_tool(query: str) -> str:
17
28
  """Perform a web search using Anthropic's Claude API.
18
29
 
@@ -35,17 +46,27 @@ async def anthropic_web_search_tool(query: str) -> str:
35
46
 
36
47
  # Get model configuration (supports both Shotgun and BYOK)
37
48
  try:
38
- model_config = get_provider_model(ProviderType.ANTHROPIC)
49
+ model_config = await get_provider_model(ProviderType.ANTHROPIC)
39
50
  except ValueError as e:
40
51
  error_msg = f"Anthropic API key not configured: {str(e)}"
41
52
  logger.error("❌ %s", error_msg)
42
53
  span.set_attribute("output.value", f"**Error:**\n {error_msg}\n")
43
54
  return error_msg
44
55
 
56
+ # Get datetime context for the search prompt
57
+ dt_context = get_datetime_context()
58
+
59
+ # Render search prompt from template
60
+ search_prompt = prompt_loader.render(
61
+ "tools/web_search.j2",
62
+ query=query,
63
+ current_datetime=dt_context.datetime_formatted,
64
+ timezone_name=dt_context.timezone_name,
65
+ utc_offset=dt_context.utc_offset,
66
+ )
67
+
45
68
  # Build the request messages
46
- messages: list[ModelMessage] = [
47
- ModelRequest.user_text_prompt(f"Search for: {query}")
48
- ]
69
+ messages: list[ModelMessage] = [ModelRequest.user_text_prompt(search_prompt)]
49
70
 
50
71
  # Use the Messages API with web search tool
51
72
  try:
@@ -120,7 +141,7 @@ async def main() -> None:
120
141
  # Check if API key is available
121
142
  try:
122
143
  if callable(get_provider_model):
123
- model_config = get_provider_model(ProviderType.ANTHROPIC)
144
+ model_config = await get_provider_model(ProviderType.ANTHROPIC)
124
145
  if not model_config.api_key:
125
146
  raise ValueError("No API key configured")
126
147
  except (ValueError, Exception):