shotgun-sh 0.2.8.dev2__py3-none-any.whl → 0.3.3.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. shotgun/agents/agent_manager.py +382 -60
  2. shotgun/agents/common.py +15 -9
  3. shotgun/agents/config/README.md +89 -0
  4. shotgun/agents/config/__init__.py +10 -1
  5. shotgun/agents/config/constants.py +0 -6
  6. shotgun/agents/config/manager.py +383 -82
  7. shotgun/agents/config/models.py +122 -18
  8. shotgun/agents/config/provider.py +81 -15
  9. shotgun/agents/config/streaming_test.py +119 -0
  10. shotgun/agents/context_analyzer/__init__.py +28 -0
  11. shotgun/agents/context_analyzer/analyzer.py +475 -0
  12. shotgun/agents/context_analyzer/constants.py +9 -0
  13. shotgun/agents/context_analyzer/formatter.py +115 -0
  14. shotgun/agents/context_analyzer/models.py +212 -0
  15. shotgun/agents/conversation/__init__.py +18 -0
  16. shotgun/agents/conversation/filters.py +164 -0
  17. shotgun/agents/conversation/history/chunking.py +278 -0
  18. shotgun/agents/{history → conversation/history}/compaction.py +36 -5
  19. shotgun/agents/{history → conversation/history}/constants.py +5 -0
  20. shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
  21. shotgun/agents/{history → conversation/history}/history_processors.py +380 -8
  22. shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +25 -1
  23. shotgun/agents/{history → conversation/history}/token_counting/base.py +14 -3
  24. shotgun/agents/{history → conversation/history}/token_counting/openai.py +11 -1
  25. shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +8 -0
  26. shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +3 -1
  27. shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -3
  28. shotgun/agents/{conversation_manager.py → conversation/manager.py} +36 -20
  29. shotgun/agents/{conversation_history.py → conversation/models.py} +8 -92
  30. shotgun/agents/error/__init__.py +11 -0
  31. shotgun/agents/error/models.py +19 -0
  32. shotgun/agents/export.py +2 -2
  33. shotgun/agents/plan.py +2 -2
  34. shotgun/agents/research.py +3 -3
  35. shotgun/agents/runner.py +230 -0
  36. shotgun/agents/specify.py +2 -2
  37. shotgun/agents/tasks.py +2 -2
  38. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  39. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  40. shotgun/agents/tools/codebase/file_read.py +11 -2
  41. shotgun/agents/tools/codebase/query_graph.py +6 -0
  42. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  43. shotgun/agents/tools/file_management.py +27 -7
  44. shotgun/agents/tools/registry.py +217 -0
  45. shotgun/agents/tools/web_search/__init__.py +8 -8
  46. shotgun/agents/tools/web_search/anthropic.py +8 -2
  47. shotgun/agents/tools/web_search/gemini.py +7 -1
  48. shotgun/agents/tools/web_search/openai.py +8 -2
  49. shotgun/agents/tools/web_search/utils.py +2 -2
  50. shotgun/agents/usage_manager.py +16 -11
  51. shotgun/api_endpoints.py +7 -3
  52. shotgun/build_constants.py +2 -2
  53. shotgun/cli/clear.py +53 -0
  54. shotgun/cli/compact.py +188 -0
  55. shotgun/cli/config.py +8 -5
  56. shotgun/cli/context.py +154 -0
  57. shotgun/cli/error_handler.py +24 -0
  58. shotgun/cli/export.py +34 -34
  59. shotgun/cli/feedback.py +4 -2
  60. shotgun/cli/models.py +1 -0
  61. shotgun/cli/plan.py +34 -34
  62. shotgun/cli/research.py +18 -10
  63. shotgun/cli/spec/__init__.py +5 -0
  64. shotgun/cli/spec/backup.py +81 -0
  65. shotgun/cli/spec/commands.py +132 -0
  66. shotgun/cli/spec/models.py +48 -0
  67. shotgun/cli/spec/pull_service.py +219 -0
  68. shotgun/cli/specify.py +20 -19
  69. shotgun/cli/tasks.py +34 -34
  70. shotgun/cli/update.py +16 -2
  71. shotgun/codebase/core/change_detector.py +5 -3
  72. shotgun/codebase/core/code_retrieval.py +4 -2
  73. shotgun/codebase/core/ingestor.py +163 -15
  74. shotgun/codebase/core/manager.py +13 -4
  75. shotgun/codebase/core/nl_query.py +1 -1
  76. shotgun/codebase/models.py +2 -0
  77. shotgun/exceptions.py +357 -0
  78. shotgun/llm_proxy/__init__.py +17 -0
  79. shotgun/llm_proxy/client.py +215 -0
  80. shotgun/llm_proxy/models.py +137 -0
  81. shotgun/logging_config.py +60 -27
  82. shotgun/main.py +77 -11
  83. shotgun/posthog_telemetry.py +38 -29
  84. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -2
  85. shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
  86. shotgun/prompts/agents/plan.j2 +16 -0
  87. shotgun/prompts/agents/research.j2 +16 -3
  88. shotgun/prompts/agents/specify.j2 +54 -1
  89. shotgun/prompts/agents/state/system_state.j2 +0 -2
  90. shotgun/prompts/agents/tasks.j2 +16 -0
  91. shotgun/prompts/history/chunk_summarization.j2 +34 -0
  92. shotgun/prompts/history/combine_summaries.j2 +53 -0
  93. shotgun/sdk/codebase.py +14 -3
  94. shotgun/sentry_telemetry.py +163 -16
  95. shotgun/settings.py +243 -0
  96. shotgun/shotgun_web/__init__.py +67 -1
  97. shotgun/shotgun_web/client.py +42 -1
  98. shotgun/shotgun_web/constants.py +46 -0
  99. shotgun/shotgun_web/exceptions.py +29 -0
  100. shotgun/shotgun_web/models.py +390 -0
  101. shotgun/shotgun_web/shared_specs/__init__.py +32 -0
  102. shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
  103. shotgun/shotgun_web/shared_specs/hasher.py +83 -0
  104. shotgun/shotgun_web/shared_specs/models.py +71 -0
  105. shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
  106. shotgun/shotgun_web/shared_specs/utils.py +34 -0
  107. shotgun/shotgun_web/specs_client.py +703 -0
  108. shotgun/shotgun_web/supabase_client.py +31 -0
  109. shotgun/telemetry.py +10 -33
  110. shotgun/tui/app.py +310 -46
  111. shotgun/tui/commands/__init__.py +1 -1
  112. shotgun/tui/components/context_indicator.py +179 -0
  113. shotgun/tui/components/mode_indicator.py +70 -0
  114. shotgun/tui/components/status_bar.py +48 -0
  115. shotgun/tui/containers.py +91 -0
  116. shotgun/tui/dependencies.py +39 -0
  117. shotgun/tui/layout.py +5 -0
  118. shotgun/tui/protocols.py +45 -0
  119. shotgun/tui/screens/chat/__init__.py +5 -0
  120. shotgun/tui/screens/chat/chat.tcss +54 -0
  121. shotgun/tui/screens/chat/chat_screen.py +1531 -0
  122. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +243 -0
  123. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  124. shotgun/tui/screens/chat/help_text.py +40 -0
  125. shotgun/tui/screens/chat/prompt_history.py +48 -0
  126. shotgun/tui/screens/chat.tcss +11 -0
  127. shotgun/tui/screens/chat_screen/command_providers.py +91 -4
  128. shotgun/tui/screens/chat_screen/hint_message.py +76 -1
  129. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  130. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  131. shotgun/tui/screens/chat_screen/history/chat_history.py +115 -0
  132. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  133. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  134. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  135. shotgun/tui/screens/confirmation_dialog.py +191 -0
  136. shotgun/tui/screens/directory_setup.py +45 -41
  137. shotgun/tui/screens/feedback.py +14 -7
  138. shotgun/tui/screens/github_issue.py +111 -0
  139. shotgun/tui/screens/model_picker.py +77 -32
  140. shotgun/tui/screens/onboarding.py +580 -0
  141. shotgun/tui/screens/pipx_migration.py +205 -0
  142. shotgun/tui/screens/provider_config.py +116 -35
  143. shotgun/tui/screens/shared_specs/__init__.py +21 -0
  144. shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
  145. shotgun/tui/screens/shared_specs/models.py +56 -0
  146. shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
  147. shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
  148. shotgun/tui/screens/shotgun_auth.py +112 -18
  149. shotgun/tui/screens/spec_pull.py +288 -0
  150. shotgun/tui/screens/welcome.py +137 -11
  151. shotgun/tui/services/__init__.py +5 -0
  152. shotgun/tui/services/conversation_service.py +187 -0
  153. shotgun/tui/state/__init__.py +7 -0
  154. shotgun/tui/state/processing_state.py +185 -0
  155. shotgun/tui/utils/mode_progress.py +14 -7
  156. shotgun/tui/widgets/__init__.py +5 -0
  157. shotgun/tui/widgets/widget_coordinator.py +263 -0
  158. shotgun/utils/file_system_utils.py +22 -2
  159. shotgun/utils/marketing.py +110 -0
  160. shotgun/utils/update_checker.py +69 -14
  161. shotgun_sh-0.3.3.dev1.dist-info/METADATA +472 -0
  162. shotgun_sh-0.3.3.dev1.dist-info/RECORD +229 -0
  163. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
  164. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +1 -0
  165. {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +1 -1
  166. shotgun/tui/screens/chat.py +0 -996
  167. shotgun/tui/screens/chat_screen/history.py +0 -335
  168. shotgun_sh-0.2.8.dev2.dist-info/METADATA +0 -126
  169. shotgun_sh-0.2.8.dev2.dist-info/RECORD +0 -155
  170. /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
  171. /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
  172. /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
  173. /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
  174. /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
  175. /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
@@ -1,13 +1,17 @@
1
1
  """Manager for handling conversation persistence operations."""
2
2
 
3
+ import asyncio
3
4
  import json
4
- import shutil
5
5
  from pathlib import Path
6
6
 
7
+ import aiofiles
8
+ import aiofiles.os
9
+
7
10
  from shotgun.logging_config import get_logger
8
11
  from shotgun.utils import get_shotgun_home
12
+ from shotgun.utils.file_system_utils import async_copy_file
9
13
 
10
- from .conversation_history import ConversationHistory
14
+ from .models import ConversationHistory
11
15
 
12
16
  logger = get_logger(__name__)
13
17
 
@@ -27,14 +31,14 @@ class ConversationManager:
27
31
  else:
28
32
  self.conversation_path = conversation_path
29
33
 
30
- def save(self, conversation: ConversationHistory) -> None:
34
+ async def save(self, conversation: ConversationHistory) -> None:
31
35
  """Save conversation history to file.
32
36
 
33
37
  Args:
34
38
  conversation: ConversationHistory to save
35
39
  """
36
40
  # Ensure directory exists
37
- self.conversation_path.parent.mkdir(parents=True, exist_ok=True)
41
+ await aiofiles.os.makedirs(self.conversation_path.parent, exist_ok=True)
38
42
 
39
43
  try:
40
44
  # Update timestamp
@@ -42,11 +46,17 @@ class ConversationManager:
42
46
 
43
47
  conversation.updated_at = datetime.now()
44
48
 
45
- # Serialize to JSON using Pydantic's model_dump
46
- data = conversation.model_dump(mode="json")
49
+ # Serialize to JSON in background thread to avoid blocking event loop
50
+ # This is crucial for large conversations (5k+ tokens)
51
+ data = await asyncio.to_thread(conversation.model_dump, mode="json")
52
+ json_content = await asyncio.to_thread(
53
+ json.dumps, data, indent=2, ensure_ascii=False
54
+ )
47
55
 
48
- with open(self.conversation_path, "w", encoding="utf-8") as f:
49
- json.dump(data, f, indent=2, ensure_ascii=False)
56
+ async with aiofiles.open(
57
+ self.conversation_path, "w", encoding="utf-8"
58
+ ) as f:
59
+ await f.write(json_content)
50
60
 
51
61
  logger.debug("Conversation saved to %s", self.conversation_path)
52
62
 
@@ -56,21 +66,26 @@ class ConversationManager:
56
66
  )
57
67
  # Don't raise - we don't want to interrupt the user's session
58
68
 
59
- def load(self) -> ConversationHistory | None:
69
+ async def load(self) -> ConversationHistory | None:
60
70
  """Load conversation history from file.
61
71
 
62
72
  Returns:
63
73
  ConversationHistory if file exists and is valid, None otherwise
64
74
  """
65
- if not self.conversation_path.exists():
75
+ if not await aiofiles.os.path.exists(self.conversation_path):
66
76
  logger.debug("No conversation history found at %s", self.conversation_path)
67
77
  return None
68
78
 
69
79
  try:
70
- with open(self.conversation_path, encoding="utf-8") as f:
71
- data = json.load(f)
72
-
73
- conversation = ConversationHistory.model_validate(data)
80
+ async with aiofiles.open(self.conversation_path, encoding="utf-8") as f:
81
+ content = await f.read()
82
+ # Deserialize JSON in background thread to avoid blocking
83
+ data = await asyncio.to_thread(json.loads, content)
84
+
85
+ # Validate model in background thread for large conversations
86
+ conversation = await asyncio.to_thread(
87
+ ConversationHistory.model_validate, data
88
+ )
74
89
  logger.debug(
75
90
  "Conversation loaded from %s with %d agent messages",
76
91
  self.conversation_path,
@@ -89,7 +104,7 @@ class ConversationManager:
89
104
  # Create a backup of the corrupted file for debugging
90
105
  backup_path = self.conversation_path.with_suffix(".json.backup")
91
106
  try:
92
- shutil.copy2(self.conversation_path, backup_path)
107
+ await async_copy_file(self.conversation_path, backup_path)
93
108
  logger.info("Backed up corrupted conversation to %s", backup_path)
94
109
  except Exception as backup_error: # pragma: no cover
95
110
  logger.warning("Failed to backup corrupted file: %s", backup_error)
@@ -105,11 +120,12 @@ class ConversationManager:
105
120
  )
106
121
  return None
107
122
 
108
- def clear(self) -> None:
123
+ async def clear(self) -> None:
109
124
  """Delete the conversation history file."""
110
- if self.conversation_path.exists():
125
+ if await aiofiles.os.path.exists(self.conversation_path):
111
126
  try:
112
- self.conversation_path.unlink()
127
+ # Use asyncio.to_thread for unlink operation
128
+ await asyncio.to_thread(self.conversation_path.unlink)
113
129
  logger.debug(
114
130
  "Conversation history cleared at %s", self.conversation_path
115
131
  )
@@ -118,10 +134,10 @@ class ConversationManager:
118
134
  "Failed to clear conversation at %s: %s", self.conversation_path, e
119
135
  )
120
136
 
121
- def exists(self) -> bool:
137
+ async def exists(self) -> bool:
122
138
  """Check if a conversation history file exists.
123
139
 
124
140
  Returns:
125
141
  True if conversation file exists, False otherwise
126
142
  """
127
- return self.conversation_path.exists()
143
+ return await aiofiles.os.path.exists(str(self.conversation_path))
@@ -1,7 +1,5 @@
1
- """Models and utilities for persisting TUI conversation history."""
1
+ """Models for persisting TUI conversation history."""
2
2
 
3
- import json
4
- import logging
5
3
  from datetime import datetime
6
4
  from typing import Any, cast
7
5
 
@@ -16,99 +14,15 @@ from pydantic_core import to_jsonable_python
16
14
 
17
15
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage
18
16
 
19
- logger = logging.getLogger(__name__)
17
+ from .filters import (
18
+ filter_incomplete_messages,
19
+ filter_orphaned_tool_responses,
20
+ is_tool_call_complete,
21
+ )
20
22
 
21
23
  SerializedMessage = dict[str, Any]
22
24
 
23
25
 
24
- def is_tool_call_complete(tool_call: ToolCallPart) -> bool:
25
- """Check if a tool call has valid, complete JSON arguments.
26
-
27
- Args:
28
- tool_call: The tool call part to validate
29
-
30
- Returns:
31
- True if the tool call args are valid JSON, False otherwise
32
- """
33
- if tool_call.args is None:
34
- return True # No args is valid
35
-
36
- if isinstance(tool_call.args, dict):
37
- return True # Already parsed dict is valid
38
-
39
- if not isinstance(tool_call.args, str):
40
- return False
41
-
42
- # Try to parse the JSON string
43
- try:
44
- json.loads(tool_call.args)
45
- return True
46
- except (json.JSONDecodeError, ValueError) as e:
47
- # Log incomplete tool call detection
48
- args_preview = (
49
- tool_call.args[:100] + "..."
50
- if len(tool_call.args) > 100
51
- else tool_call.args
52
- )
53
- logger.info(
54
- "Detected incomplete tool call in validation",
55
- extra={
56
- "tool_name": tool_call.tool_name,
57
- "tool_call_id": tool_call.tool_call_id,
58
- "args_preview": args_preview,
59
- "error": str(e),
60
- },
61
- )
62
- return False
63
-
64
-
65
- def filter_incomplete_messages(messages: list[ModelMessage]) -> list[ModelMessage]:
66
- """Filter out messages with incomplete tool calls.
67
-
68
- Args:
69
- messages: List of messages to filter
70
-
71
- Returns:
72
- List of messages with only complete tool calls
73
- """
74
- filtered: list[ModelMessage] = []
75
- filtered_count = 0
76
- filtered_tool_names: list[str] = []
77
-
78
- for message in messages:
79
- # Only check ModelResponse messages for tool calls
80
- if not isinstance(message, ModelResponse):
81
- filtered.append(message)
82
- continue
83
-
84
- # Check if any tool calls are incomplete
85
- has_incomplete_tool_call = False
86
- for part in message.parts:
87
- if isinstance(part, ToolCallPart) and not is_tool_call_complete(part):
88
- has_incomplete_tool_call = True
89
- filtered_tool_names.append(part.tool_name)
90
- break
91
-
92
- # Only include messages without incomplete tool calls
93
- if not has_incomplete_tool_call:
94
- filtered.append(message)
95
- else:
96
- filtered_count += 1
97
-
98
- # Log if any messages were filtered
99
- if filtered_count > 0:
100
- logger.info(
101
- "Filtered incomplete messages before saving",
102
- extra={
103
- "filtered_count": filtered_count,
104
- "total_messages": len(messages),
105
- "filtered_tool_names": filtered_tool_names,
106
- },
107
- )
108
-
109
- return filtered
110
-
111
-
112
26
  class ConversationState(BaseModel):
113
27
  """Represents the complete state of a conversation in memory."""
114
28
 
@@ -142,6 +56,8 @@ class ConversationHistory(BaseModel):
142
56
  """
143
57
  # Filter out messages with incomplete tool calls to prevent corruption
144
58
  filtered_messages = filter_incomplete_messages(messages)
59
+ # Filter out orphaned tool responses (tool responses without tool calls)
60
+ filtered_messages = filter_orphaned_tool_responses(filtered_messages)
145
61
 
146
62
  # Serialize ModelMessage list to JSON-serializable format
147
63
  self.agent_history = to_jsonable_python(
@@ -0,0 +1,11 @@
1
+ """Agent error handling module.
2
+
3
+ This module provides the AgentErrorContext model used by AgentRunner
4
+ for error classification.
5
+ """
6
+
7
+ from shotgun.agents.error.models import AgentErrorContext
8
+
9
+ __all__ = [
10
+ "AgentErrorContext",
11
+ ]
@@ -0,0 +1,19 @@
1
+ """Pydantic models for agent error handling."""
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+
8
+ class AgentErrorContext(BaseModel):
9
+ """Context information needed to classify and handle agent errors.
10
+
11
+ Attributes:
12
+ exception: The exception that was raised
13
+ is_shotgun_account: Whether the user is using a Shotgun Account
14
+ """
15
+
16
+ model_config = ConfigDict(arbitrary_types_allowed=True)
17
+
18
+ exception: Any = Field(...)
19
+ is_shotgun_account: bool
shotgun/agents/export.py CHANGED
@@ -23,7 +23,7 @@ from .models import AgentDeps, AgentResponse, AgentRuntimeOptions, AgentType
23
23
  logger = get_logger(__name__)
24
24
 
25
25
 
26
- def create_export_agent(
26
+ async def create_export_agent(
27
27
  agent_runtime_options: AgentRuntimeOptions, provider: ProviderType | None = None
28
28
  ) -> tuple[Agent[AgentDeps, AgentResponse], AgentDeps]:
29
29
  """Create an export agent with file management capabilities.
@@ -39,7 +39,7 @@ def create_export_agent(
39
39
  # Use partial to create system prompt function for export agent
40
40
  system_prompt_fn = partial(build_agent_system_prompt, "export")
41
41
 
42
- agent, deps = create_base_agent(
42
+ agent, deps = await create_base_agent(
43
43
  system_prompt_fn,
44
44
  agent_runtime_options,
45
45
  provider=provider,
shotgun/agents/plan.py CHANGED
@@ -23,7 +23,7 @@ from .models import AgentDeps, AgentResponse, AgentRuntimeOptions, AgentType
23
23
  logger = get_logger(__name__)
24
24
 
25
25
 
26
- def create_plan_agent(
26
+ async def create_plan_agent(
27
27
  agent_runtime_options: AgentRuntimeOptions, provider: ProviderType | None = None
28
28
  ) -> tuple[Agent[AgentDeps, AgentResponse], AgentDeps]:
29
29
  """Create a plan agent with artifact management capabilities.
@@ -39,7 +39,7 @@ def create_plan_agent(
39
39
  # Use partial to create system prompt function for plan agent
40
40
  system_prompt_fn = partial(build_agent_system_prompt, "plan")
41
41
 
42
- agent, deps = create_base_agent(
42
+ agent, deps = await create_base_agent(
43
43
  system_prompt_fn,
44
44
  agent_runtime_options,
45
45
  load_codebase_understanding_tools=True,
@@ -26,7 +26,7 @@ from .tools import get_available_web_search_tools
26
26
  logger = get_logger(__name__)
27
27
 
28
28
 
29
- def create_research_agent(
29
+ async def create_research_agent(
30
30
  agent_runtime_options: AgentRuntimeOptions, provider: ProviderType | None = None
31
31
  ) -> tuple[Agent[AgentDeps, AgentResponse], AgentDeps]:
32
32
  """Create a research agent with web search and artifact management capabilities.
@@ -41,7 +41,7 @@ def create_research_agent(
41
41
  logger.debug("Initializing research agent")
42
42
 
43
43
  # Get available web search tools based on configured API keys
44
- web_search_tools = get_available_web_search_tools()
44
+ web_search_tools = await get_available_web_search_tools()
45
45
  if web_search_tools:
46
46
  logger.info(
47
47
  "Research agent configured with %d web search tool(s)",
@@ -53,7 +53,7 @@ def create_research_agent(
53
53
  # Use partial to create system prompt function for research agent
54
54
  system_prompt_fn = partial(build_agent_system_prompt, "research")
55
55
 
56
- agent, deps = create_base_agent(
56
+ agent, deps = await create_base_agent(
57
57
  system_prompt_fn,
58
58
  agent_runtime_options,
59
59
  load_codebase_understanding_tools=True,
@@ -0,0 +1,230 @@
1
+ """Unified agent execution with consistent error handling.
2
+
3
+ This module provides a reusable agent runner that wraps agent execution exceptions
4
+ in user-friendly custom exceptions that can be caught and displayed by TUI or CLI.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ from typing import TYPE_CHECKING, NoReturn
10
+
11
+ from anthropic import APIStatusError as AnthropicAPIStatusError
12
+ from openai import APIStatusError as OpenAIAPIStatusError
13
+ from pydantic_ai.exceptions import ModelHTTPError, UnexpectedModelBehavior
14
+
15
+ from shotgun.agents.error.models import AgentErrorContext
16
+ from shotgun.exceptions import (
17
+ AgentCancelledException,
18
+ BudgetExceededException,
19
+ BYOKAuthenticationException,
20
+ BYOKGenericAPIException,
21
+ BYOKQuotaBillingException,
22
+ BYOKRateLimitException,
23
+ BYOKServiceOverloadException,
24
+ ContextSizeLimitExceeded,
25
+ GenericAPIStatusException,
26
+ ShotgunRateLimitException,
27
+ ShotgunServiceOverloadException,
28
+ UnknownAgentException,
29
+ )
30
+
31
+ if TYPE_CHECKING:
32
+ from shotgun.agents.agent_manager import AgentManager
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class AgentRunner:
38
+ """Unified agent execution wrapper with consistent error handling.
39
+
40
+ This class wraps agent execution and converts any exceptions into
41
+ user-friendly custom exceptions that can be caught and displayed by the
42
+ calling interface (TUI or CLI).
43
+
44
+ The runner:
45
+ - Executes the agent
46
+ - Logs errors for debugging
47
+ - Wraps exceptions in custom exception types (AgentCancelledException,
48
+ BYOKRateLimitException, etc.)
49
+ - Lets exceptions propagate to caller for display
50
+
51
+ Example:
52
+ >>> runner = AgentRunner(agent_manager)
53
+ >>> try:
54
+ >>> await runner.run("Write a hello world function")
55
+ >>> except ContextSizeLimitExceeded as e:
56
+ >>> print(e.to_markdown())
57
+ >>> except BYOKRateLimitException as e:
58
+ >>> print(e.to_plain_text())
59
+ """
60
+
61
+ def __init__(self, agent_manager: "AgentManager"):
62
+ """Initialize the agent runner.
63
+
64
+ Args:
65
+ agent_manager: The agent manager to execute
66
+ """
67
+ self.agent_manager = agent_manager
68
+
69
+ async def run(self, prompt: str) -> None:
70
+ """Run the agent with the given prompt.
71
+
72
+ Args:
73
+ prompt: The user's prompt/query
74
+
75
+ Raises:
76
+ Custom exceptions for different error types:
77
+ - AgentCancelledException: User cancelled the operation
78
+ - ContextSizeLimitExceeded: Context too large for model
79
+ - BudgetExceededException: Shotgun Account budget exceeded
80
+ - BYOKRateLimitException: BYOK rate limit hit
81
+ - BYOKQuotaBillingException: BYOK quota/billing issue
82
+ - BYOKAuthenticationException: BYOK authentication failed
83
+ - BYOKServiceOverloadException: BYOK service overloaded
84
+ - BYOKGenericAPIException: Generic BYOK API error
85
+ - ShotgunServiceOverloadException: Shotgun service overloaded
86
+ - ShotgunRateLimitException: Shotgun rate limit hit
87
+ - GenericAPIStatusException: Generic API error
88
+ - UnknownAgentException: Unknown/unclassified error
89
+ """
90
+ try:
91
+ await self.agent_manager.run(prompt=prompt)
92
+
93
+ except asyncio.CancelledError as e:
94
+ # User cancelled - wrap and re-raise as our custom exception
95
+ context = self._create_error_context(e)
96
+ self._classify_and_raise(context)
97
+
98
+ except ContextSizeLimitExceeded as e:
99
+ # Already a custom exception - log and re-raise
100
+ logger.info(
101
+ "Context size limit exceeded",
102
+ extra={
103
+ "max_tokens": e.max_tokens,
104
+ "model_name": e.model_name,
105
+ },
106
+ )
107
+ raise
108
+
109
+ except Exception as e:
110
+ # Log with full stack trace to shotgun.log
111
+ logger.exception(
112
+ "Agent run failed",
113
+ extra={
114
+ "agent_mode": self.agent_manager._current_agent_type.value,
115
+ "error_type": type(e).__name__,
116
+ },
117
+ )
118
+
119
+ # Create error context and wrap/raise custom exception
120
+ context = self._create_error_context(e)
121
+ self._classify_and_raise(context)
122
+
123
+ def _create_error_context(self, exception: BaseException) -> AgentErrorContext:
124
+ """Create error context from exception and agent state.
125
+
126
+ Args:
127
+ exception: The exception that was raised
128
+
129
+ Returns:
130
+ AgentErrorContext with all necessary information for classification
131
+ """
132
+ return AgentErrorContext(
133
+ exception=exception,
134
+ is_shotgun_account=self.agent_manager.deps.llm_model.is_shotgun_account,
135
+ )
136
+
137
+ def _classify_and_raise(self, context: AgentErrorContext) -> NoReturn:
138
+ """Classify an exception and raise the appropriate custom exception.
139
+
140
+ Args:
141
+ context: Context information about the error
142
+
143
+ Raises:
144
+ Custom exception based on the error type
145
+ """
146
+ exception = context.exception
147
+ error_name = type(exception).__name__
148
+ error_message = str(exception)
149
+
150
+ # Check for cancellation
151
+ if isinstance(exception, asyncio.CancelledError):
152
+ raise AgentCancelledException() from exception
153
+
154
+ # Check for context size limit exceeded
155
+ if isinstance(exception, ContextSizeLimitExceeded):
156
+ # Already the right exception type, re-raise it
157
+ raise exception
158
+
159
+ # Check for budget exceeded (Shotgun Account only)
160
+ if (
161
+ context.is_shotgun_account
162
+ and "apistatuserror" in error_name.lower()
163
+ and "budget" in error_message.lower()
164
+ and "exceeded" in error_message.lower()
165
+ ):
166
+ raise BudgetExceededException(message=error_message) from exception
167
+
168
+ # Check for empty model response (e.g., model unavailable or misconfigured)
169
+ if isinstance(exception, UnexpectedModelBehavior):
170
+ raise GenericAPIStatusException(
171
+ "The model returned an empty response. This may indicate:\n"
172
+ "- The model is unavailable or misconfigured\n"
173
+ "- A temporary service issue\n\n"
174
+ "Try switching to a different model or try again later."
175
+ ) from exception
176
+
177
+ # Detect API errors
178
+ is_api_error = False
179
+ if isinstance(exception, OpenAIAPIStatusError):
180
+ is_api_error = True
181
+ elif isinstance(exception, AnthropicAPIStatusError):
182
+ is_api_error = True
183
+ elif isinstance(exception, ModelHTTPError):
184
+ # pydantic_ai wraps API errors in ModelHTTPError
185
+ # Check for HTTP error status codes (4xx client errors)
186
+ if 400 <= exception.status_code < 500:
187
+ is_api_error = True
188
+
189
+ # BYOK user API errors
190
+ if not context.is_shotgun_account and is_api_error:
191
+ self._raise_byok_api_error(error_message, exception)
192
+
193
+ # Shotgun Account specific errors
194
+ if "APIStatusError" in error_name:
195
+ if "overload" in error_message.lower():
196
+ raise ShotgunServiceOverloadException(error_message) from exception
197
+ elif "rate" in error_message.lower():
198
+ raise ShotgunRateLimitException(error_message) from exception
199
+ else:
200
+ raise GenericAPIStatusException(error_message) from exception
201
+
202
+ # Unknown error - wrap in our custom exception
203
+ raise UnknownAgentException(exception) from exception
204
+
205
+ def _raise_byok_api_error(
206
+ self, error_message: str, original_exception: Exception
207
+ ) -> NoReturn:
208
+ """Classify and raise API errors for BYOK users into specific types.
209
+
210
+ Args:
211
+ error_message: The error message from the API
212
+ original_exception: The original exception
213
+
214
+ Raises:
215
+ Specific BYOK exception type
216
+ """
217
+ error_lower = error_message.lower()
218
+
219
+ if "rate" in error_lower:
220
+ raise BYOKRateLimitException(error_message) from original_exception
221
+ elif "quota" in error_lower or "billing" in error_lower:
222
+ raise BYOKQuotaBillingException(error_message) from original_exception
223
+ elif "authentication" in error_lower or (
224
+ "invalid" in error_lower and "key" in error_lower
225
+ ):
226
+ raise BYOKAuthenticationException(error_message) from original_exception
227
+ elif "overload" in error_lower:
228
+ raise BYOKServiceOverloadException(error_message) from original_exception
229
+ else:
230
+ raise BYOKGenericAPIException(error_message) from original_exception
shotgun/agents/specify.py CHANGED
@@ -23,7 +23,7 @@ from .models import AgentDeps, AgentResponse, AgentRuntimeOptions, AgentType
23
23
  logger = get_logger(__name__)
24
24
 
25
25
 
26
- def create_specify_agent(
26
+ async def create_specify_agent(
27
27
  agent_runtime_options: AgentRuntimeOptions, provider: ProviderType | None = None
28
28
  ) -> tuple[Agent[AgentDeps, AgentResponse], AgentDeps]:
29
29
  """Create a specify agent with artifact management capabilities.
@@ -39,7 +39,7 @@ def create_specify_agent(
39
39
  # Use partial to create system prompt function for specify agent
40
40
  system_prompt_fn = partial(build_agent_system_prompt, "specify")
41
41
 
42
- agent, deps = create_base_agent(
42
+ agent, deps = await create_base_agent(
43
43
  system_prompt_fn,
44
44
  agent_runtime_options,
45
45
  load_codebase_understanding_tools=True,
shotgun/agents/tasks.py CHANGED
@@ -23,7 +23,7 @@ from .models import AgentDeps, AgentResponse, AgentRuntimeOptions, AgentType
23
23
  logger = get_logger(__name__)
24
24
 
25
25
 
26
- def create_tasks_agent(
26
+ async def create_tasks_agent(
27
27
  agent_runtime_options: AgentRuntimeOptions, provider: ProviderType | None = None
28
28
  ) -> tuple[Agent[AgentDeps, AgentResponse], AgentDeps]:
29
29
  """Create a tasks agent with file management capabilities.
@@ -39,7 +39,7 @@ def create_tasks_agent(
39
39
  # Use partial to create system prompt function for tasks agent
40
40
  system_prompt_fn = partial(build_agent_system_prompt, "tasks")
41
41
 
42
- agent, deps = create_base_agent(
42
+ agent, deps = await create_base_agent(
43
43
  system_prompt_fn,
44
44
  agent_runtime_options,
45
45
  provider=provider,
@@ -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: