shotgun-sh 0.2.11__py3-none-any.whl → 0.2.11.dev2__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 (73) hide show
  1. shotgun/agents/agent_manager.py +28 -194
  2. shotgun/agents/common.py +8 -14
  3. shotgun/agents/config/manager.py +33 -64
  4. shotgun/agents/config/models.py +1 -25
  5. shotgun/agents/config/provider.py +2 -2
  6. shotgun/agents/context_analyzer/analyzer.py +24 -2
  7. shotgun/agents/conversation_manager.py +19 -35
  8. shotgun/agents/export.py +2 -2
  9. shotgun/agents/history/history_processors.py +3 -99
  10. shotgun/agents/history/token_counting/anthropic.py +1 -17
  11. shotgun/agents/history/token_counting/base.py +3 -14
  12. shotgun/agents/history/token_counting/openai.py +1 -11
  13. shotgun/agents/history/token_counting/sentencepiece_counter.py +0 -8
  14. shotgun/agents/history/token_counting/tokenizer_cache.py +1 -3
  15. shotgun/agents/history/token_counting/utils.py +3 -0
  16. shotgun/agents/plan.py +2 -2
  17. shotgun/agents/research.py +3 -3
  18. shotgun/agents/specify.py +2 -2
  19. shotgun/agents/tasks.py +2 -2
  20. shotgun/agents/tools/codebase/file_read.py +2 -5
  21. shotgun/agents/tools/file_management.py +7 -11
  22. shotgun/agents/tools/web_search/__init__.py +8 -8
  23. shotgun/agents/tools/web_search/anthropic.py +2 -2
  24. shotgun/agents/tools/web_search/gemini.py +1 -1
  25. shotgun/agents/tools/web_search/openai.py +1 -1
  26. shotgun/agents/tools/web_search/utils.py +2 -2
  27. shotgun/agents/usage_manager.py +11 -16
  28. shotgun/build_constants.py +2 -2
  29. shotgun/cli/clear.py +1 -2
  30. shotgun/cli/compact.py +3 -3
  31. shotgun/cli/config.py +5 -8
  32. shotgun/cli/context.py +2 -2
  33. shotgun/cli/export.py +1 -1
  34. shotgun/cli/feedback.py +2 -4
  35. shotgun/cli/plan.py +1 -1
  36. shotgun/cli/research.py +1 -1
  37. shotgun/cli/specify.py +1 -1
  38. shotgun/cli/tasks.py +1 -1
  39. shotgun/codebase/core/change_detector.py +3 -5
  40. shotgun/codebase/core/code_retrieval.py +2 -4
  41. shotgun/codebase/core/ingestor.py +8 -10
  42. shotgun/codebase/core/manager.py +3 -3
  43. shotgun/codebase/core/nl_query.py +1 -1
  44. shotgun/logging_config.py +17 -10
  45. shotgun/main.py +1 -3
  46. shotgun/posthog_telemetry.py +4 -14
  47. shotgun/sentry_telemetry.py +2 -22
  48. shotgun/telemetry.py +1 -3
  49. shotgun/tui/app.py +65 -71
  50. shotgun/tui/components/context_indicator.py +0 -43
  51. shotgun/tui/containers.py +17 -15
  52. shotgun/tui/dependencies.py +2 -2
  53. shotgun/tui/screens/chat/chat_screen.py +40 -164
  54. shotgun/tui/screens/chat/help_text.py +15 -16
  55. shotgun/tui/screens/chat_screen/command_providers.py +0 -10
  56. shotgun/tui/screens/feedback.py +4 -4
  57. shotgun/tui/screens/model_picker.py +20 -21
  58. shotgun/tui/screens/provider_config.py +27 -50
  59. shotgun/tui/screens/shotgun_auth.py +2 -2
  60. shotgun/tui/screens/welcome.py +11 -14
  61. shotgun/tui/services/conversation_service.py +14 -16
  62. shotgun/tui/utils/mode_progress.py +7 -14
  63. shotgun/tui/widgets/widget_coordinator.py +0 -15
  64. shotgun/utils/file_system_utils.py +0 -19
  65. {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/METADATA +1 -2
  66. {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/RECORD +69 -73
  67. shotgun/exceptions.py +0 -32
  68. shotgun/tui/screens/github_issue.py +0 -102
  69. shotgun/tui/screens/onboarding.py +0 -431
  70. shotgun/utils/marketing.py +0 -110
  71. {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/WHEEL +0 -0
  72. {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/entry_points.txt +0 -0
  73. {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,5 @@
1
1
  """Pydantic models for configuration."""
2
2
 
3
- from datetime import datetime
4
3
  from enum import StrEnum
5
4
 
6
5
  from pydantic import BaseModel, Field, PrivateAttr, SecretStr
@@ -171,21 +170,6 @@ class ShotgunAccountConfig(BaseModel):
171
170
  )
172
171
 
173
172
 
174
- class MarketingMessageRecord(BaseModel):
175
- """Record of when a marketing message was shown to the user."""
176
-
177
- shown_at: datetime = Field(description="Timestamp when the message was shown")
178
-
179
-
180
- class MarketingConfig(BaseModel):
181
- """Configuration for marketing messages shown to users."""
182
-
183
- messages: dict[str, MarketingMessageRecord] = Field(
184
- default_factory=dict,
185
- description="Tracking which marketing messages have been shown. Key is message ID (e.g., 'github_star_v1')",
186
- )
187
-
188
-
189
173
  class ShotgunConfig(BaseModel):
190
174
  """Main configuration for Shotgun CLI."""
191
175
 
@@ -200,16 +184,8 @@ class ShotgunConfig(BaseModel):
200
184
  shotgun_instance_id: str = Field(
201
185
  description="Unique shotgun instance identifier (also used for anonymous telemetry)",
202
186
  )
203
- config_version: int = Field(default=4, description="Configuration schema version")
187
+ config_version: int = Field(default=3, description="Configuration schema version")
204
188
  shown_welcome_screen: bool = Field(
205
189
  default=False,
206
190
  description="Whether the welcome screen has been shown to the user",
207
191
  )
208
- shown_onboarding_popup: datetime | None = Field(
209
- default=None,
210
- description="Timestamp when the onboarding popup was shown to the user (ISO8601 format)",
211
- )
212
- marketing: MarketingConfig = Field(
213
- default_factory=MarketingConfig,
214
- description="Marketing messages configuration and tracking",
215
- )
@@ -170,7 +170,7 @@ def get_or_create_model(
170
170
  return _model_cache[cache_key]
171
171
 
172
172
 
173
- async def get_provider_model(
173
+ def get_provider_model(
174
174
  provider_or_model: ProviderType | ModelName | None = None,
175
175
  ) -> ModelConfig:
176
176
  """Get a fully configured ModelConfig with API key and Model instance.
@@ -189,7 +189,7 @@ async def get_provider_model(
189
189
  """
190
190
  config_manager = get_config_manager()
191
191
  # Use cached config for read-only access (performance)
192
- config = await config_manager.load(force_reload=False)
192
+ config = config_manager.load(force_reload=False)
193
193
 
194
194
  # Priority 1: Check if Shotgun key exists - if so, use it for ANY model
195
195
  shotgun_api_key = _get_api_key(config.shotgun.api_key)
@@ -67,13 +67,26 @@ class ContextAnalyzer:
67
67
  for msg in reversed(message_history):
68
68
  if isinstance(msg, ModelResponse) and msg.usage:
69
69
  last_input_tokens = msg.usage.input_tokens + msg.usage.cache_read_tokens
70
+ logger.debug(
71
+ f"[ANALYZER] Found last response with usage - "
72
+ f"input_tokens={msg.usage.input_tokens}, "
73
+ f"cache_read_tokens={msg.usage.cache_read_tokens}, "
74
+ f"total={last_input_tokens}"
75
+ )
70
76
  break
71
77
 
72
78
  if last_input_tokens == 0:
73
- # Fallback to token estimation (no logging to reduce verbosity)
79
+ logger.warning(
80
+ f"[ANALYZER] No usage data found in message history! "
81
+ f"message_count={len(message_history)}, "
82
+ f"response_count={sum(1 for m in message_history if isinstance(m, ModelResponse))}"
83
+ )
84
+ # Fallback to token estimation
85
+ logger.info("[ANALYZER] Falling back to token estimation")
74
86
  last_input_tokens = await estimate_tokens_from_messages(
75
87
  message_history, self.model_config
76
88
  )
89
+ logger.debug(f"[ANALYZER] Estimated tokens: {last_input_tokens}")
77
90
 
78
91
  # Step 2: Calculate total output tokens (sum across all responses)
79
92
  for msg in message_history:
@@ -234,7 +247,16 @@ class ContextAnalyzer:
234
247
  # If no content, put all in agent responses
235
248
  agent_response_tokens = total_output_tokens
236
249
 
237
- # Token allocation complete (no logging to reduce verbosity)
250
+ logger.debug(
251
+ f"Token allocation complete: user={user_tokens}, agent_responses={agent_response_tokens}, "
252
+ f"system_prompts={system_prompt_tokens}, system_status={system_status_tokens}, "
253
+ f"codebase_understanding={codebase_understanding_tokens}, "
254
+ f"artifact_management={artifact_management_tokens}, web_research={web_research_tokens}, "
255
+ f"unknown={unknown_tokens}"
256
+ )
257
+ logger.debug(
258
+ f"Input tokens (from last response): {last_input_tokens}, Output tokens (sum): {total_output_tokens}"
259
+ )
238
260
 
239
261
  # Create TokenAllocation model
240
262
  return TokenAllocation(
@@ -1,15 +1,11 @@
1
1
  """Manager for handling conversation persistence operations."""
2
2
 
3
- import asyncio
4
3
  import json
4
+ import shutil
5
5
  from pathlib import Path
6
6
 
7
- import aiofiles
8
- import aiofiles.os
9
-
10
7
  from shotgun.logging_config import get_logger
11
8
  from shotgun.utils import get_shotgun_home
12
- from shotgun.utils.file_system_utils import async_copy_file
13
9
 
14
10
  from .conversation_history import ConversationHistory
15
11
 
@@ -31,14 +27,14 @@ class ConversationManager:
31
27
  else:
32
28
  self.conversation_path = conversation_path
33
29
 
34
- async def save(self, conversation: ConversationHistory) -> None:
30
+ def save(self, conversation: ConversationHistory) -> None:
35
31
  """Save conversation history to file.
36
32
 
37
33
  Args:
38
34
  conversation: ConversationHistory to save
39
35
  """
40
36
  # Ensure directory exists
41
- await aiofiles.os.makedirs(self.conversation_path.parent, exist_ok=True)
37
+ self.conversation_path.parent.mkdir(parents=True, exist_ok=True)
42
38
 
43
39
  try:
44
40
  # Update timestamp
@@ -46,17 +42,11 @@ class ConversationManager:
46
42
 
47
43
  conversation.updated_at = datetime.now()
48
44
 
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
- )
45
+ # Serialize to JSON using Pydantic's model_dump
46
+ data = conversation.model_dump(mode="json")
55
47
 
56
- async with aiofiles.open(
57
- self.conversation_path, "w", encoding="utf-8"
58
- ) as f:
59
- await f.write(json_content)
48
+ with open(self.conversation_path, "w", encoding="utf-8") as f:
49
+ json.dump(data, f, indent=2, ensure_ascii=False)
60
50
 
61
51
  logger.debug("Conversation saved to %s", self.conversation_path)
62
52
 
@@ -66,26 +56,21 @@ class ConversationManager:
66
56
  )
67
57
  # Don't raise - we don't want to interrupt the user's session
68
58
 
69
- async def load(self) -> ConversationHistory | None:
59
+ def load(self) -> ConversationHistory | None:
70
60
  """Load conversation history from file.
71
61
 
72
62
  Returns:
73
63
  ConversationHistory if file exists and is valid, None otherwise
74
64
  """
75
- if not await aiofiles.os.path.exists(self.conversation_path):
65
+ if not self.conversation_path.exists():
76
66
  logger.debug("No conversation history found at %s", self.conversation_path)
77
67
  return None
78
68
 
79
69
  try:
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
- )
70
+ with open(self.conversation_path, encoding="utf-8") as f:
71
+ data = json.load(f)
72
+
73
+ conversation = ConversationHistory.model_validate(data)
89
74
  logger.debug(
90
75
  "Conversation loaded from %s with %d agent messages",
91
76
  self.conversation_path,
@@ -104,7 +89,7 @@ class ConversationManager:
104
89
  # Create a backup of the corrupted file for debugging
105
90
  backup_path = self.conversation_path.with_suffix(".json.backup")
106
91
  try:
107
- await async_copy_file(self.conversation_path, backup_path)
92
+ shutil.copy2(self.conversation_path, backup_path)
108
93
  logger.info("Backed up corrupted conversation to %s", backup_path)
109
94
  except Exception as backup_error: # pragma: no cover
110
95
  logger.warning("Failed to backup corrupted file: %s", backup_error)
@@ -120,12 +105,11 @@ class ConversationManager:
120
105
  )
121
106
  return None
122
107
 
123
- async def clear(self) -> None:
108
+ def clear(self) -> None:
124
109
  """Delete the conversation history file."""
125
- if await aiofiles.os.path.exists(self.conversation_path):
110
+ if self.conversation_path.exists():
126
111
  try:
127
- # Use asyncio.to_thread for unlink operation
128
- await asyncio.to_thread(self.conversation_path.unlink)
112
+ self.conversation_path.unlink()
129
113
  logger.debug(
130
114
  "Conversation history cleared at %s", self.conversation_path
131
115
  )
@@ -134,10 +118,10 @@ class ConversationManager:
134
118
  "Failed to clear conversation at %s: %s", self.conversation_path, e
135
119
  )
136
120
 
137
- async def exists(self) -> bool:
121
+ def exists(self) -> bool:
138
122
  """Check if a conversation history file exists.
139
123
 
140
124
  Returns:
141
125
  True if conversation file exists, False otherwise
142
126
  """
143
- return await aiofiles.os.path.exists(str(self.conversation_path))
127
+ return self.conversation_path.exists()
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
- async def create_export_agent(
26
+ 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 @@ async 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 = await create_base_agent(
42
+ agent, deps = create_base_agent(
43
43
  system_prompt_fn,
44
44
  agent_runtime_options,
45
45
  provider=provider,
@@ -1,9 +1,7 @@
1
1
  """History processors for managing conversation history in Shotgun agents."""
2
2
 
3
- from collections.abc import Awaitable, Callable
4
3
  from typing import TYPE_CHECKING, Any, Protocol
5
4
 
6
- from anthropic import APIStatusError
7
5
  from pydantic_ai import ModelSettings
8
6
  from pydantic_ai.messages import (
9
7
  ModelMessage,
@@ -16,7 +14,6 @@ from pydantic_ai.messages import (
16
14
  from shotgun.agents.llm import shotgun_model_request
17
15
  from shotgun.agents.messages import AgentSystemPrompt, SystemStatusPrompt
18
16
  from shotgun.agents.models import AgentDeps
19
- from shotgun.exceptions import ContextSizeLimitExceeded
20
17
  from shotgun.logging_config import get_logger
21
18
  from shotgun.posthog_telemetry import track_event
22
19
  from shotgun.prompts import PromptLoader
@@ -54,86 +51,6 @@ logger = get_logger(__name__)
54
51
  prompt_loader = PromptLoader()
55
52
 
56
53
 
57
- async def _safe_token_estimation(
58
- estimation_func: Callable[..., Awaitable[int]],
59
- model_name: str,
60
- max_tokens: int,
61
- *args: Any,
62
- **kwargs: Any,
63
- ) -> int:
64
- """Safely estimate tokens with proper error handling.
65
-
66
- Wraps token estimation functions to handle failures gracefully.
67
- Only RuntimeError (from token counters) is wrapped in ContextSizeLimitExceeded.
68
- Other errors (network, auth) are allowed to bubble up.
69
-
70
- Args:
71
- estimation_func: Async function that estimates tokens
72
- model_name: Name of the model for error messages
73
- max_tokens: Maximum tokens for the model
74
- *args: Arguments to pass to estimation_func
75
- **kwargs: Keyword arguments to pass to estimation_func
76
-
77
- Returns:
78
- Token count from estimation_func
79
-
80
- Raises:
81
- ContextSizeLimitExceeded: If token counting fails with RuntimeError
82
- Exception: Any other exceptions from estimation_func
83
- """
84
- try:
85
- return await estimation_func(*args, **kwargs)
86
- except Exception as e:
87
- # Log the error with full context
88
- logger.warning(
89
- f"Token counting failed for {model_name}",
90
- extra={
91
- "error_type": type(e).__name__,
92
- "error_message": str(e),
93
- "model": model_name,
94
- },
95
- )
96
-
97
- # Token counting behavior with oversized context (verified via testing):
98
- #
99
- # 1. OpenAI/tiktoken:
100
- # - Successfully counts any size (tested with 752K tokens, no error)
101
- # - Library errors: ValueError, KeyError, AttributeError, SSLError (file/cache issues)
102
- # - Wrapped as: RuntimeError by our counter
103
- #
104
- # 2. Gemini/SentencePiece:
105
- # - Successfully counts any size (tested with 752K tokens, no error)
106
- # - Library errors: RuntimeError, IOError, TypeError (file/model loading issues)
107
- # - Wrapped as: RuntimeError by our counter
108
- #
109
- # 3. Anthropic API:
110
- # - Successfully counts large token counts (tested with 752K tokens, no error)
111
- # - Only enforces 32 MB request size limit (not token count)
112
- # - Raises: APIStatusError(413) with error type 'request_too_large' for 32MB+ requests
113
- # - Other API errors: APIConnectionError, RateLimitError, APIStatusError (4xx/5xx)
114
- # - Wrapped as: RuntimeError by our counter
115
- #
116
- # IMPORTANT: No provider raises errors for "too many tokens" during counting.
117
- # Token count validation happens separately by comparing count to max_input_tokens.
118
- #
119
- # We wrap RuntimeError (library-level failures from tiktoken/sentencepiece).
120
- # We also wrap Anthropic's 413 error (request exceeds 32 MB) as it indicates
121
- # context is effectively too large and needs user action to reduce it.
122
- if isinstance(e, RuntimeError):
123
- raise ContextSizeLimitExceeded(
124
- model_name=model_name, max_tokens=max_tokens
125
- ) from e
126
-
127
- # Check for Anthropic's 32 MB request size limit (APIStatusError with status 413)
128
- if isinstance(e, APIStatusError) and e.status_code == 413:
129
- raise ContextSizeLimitExceeded(
130
- model_name=model_name, max_tokens=max_tokens
131
- ) from e
132
-
133
- # Re-raise other exceptions (network errors, auth failures, etc.)
134
- raise
135
-
136
-
137
54
  def is_summary_part(part: Any) -> bool:
138
55
  """Check if a message part is a compacted summary."""
139
56
  return isinstance(part, TextPart) and part.content.startswith(SUMMARY_MARKER)
@@ -240,15 +157,9 @@ async def token_limit_compactor(
240
157
 
241
158
  if last_summary_index is not None:
242
159
  # Check if post-summary conversation exceeds threshold for incremental compaction
243
- post_summary_tokens = await _safe_token_estimation(
244
- estimate_post_summary_tokens,
245
- deps.llm_model.name,
246
- model_max_tokens,
247
- messages,
248
- last_summary_index,
249
- deps.llm_model,
160
+ post_summary_tokens = await estimate_post_summary_tokens(
161
+ messages, last_summary_index, deps.llm_model
250
162
  )
251
-
252
163
  post_summary_percentage = (
253
164
  (post_summary_tokens / max_tokens) * 100 if max_tokens > 0 else 0
254
165
  )
@@ -455,14 +366,7 @@ async def token_limit_compactor(
455
366
 
456
367
  else:
457
368
  # Check if total conversation exceeds threshold for full compaction
458
- total_tokens = await _safe_token_estimation(
459
- estimate_tokens_from_messages,
460
- deps.llm_model.name,
461
- model_max_tokens,
462
- messages,
463
- deps.llm_model,
464
- )
465
-
369
+ total_tokens = await estimate_tokens_from_messages(messages, deps.llm_model)
466
370
  total_percentage = (total_tokens / max_tokens) * 100 if max_tokens > 0 else 0
467
371
 
468
372
  logger.debug(
@@ -72,23 +72,11 @@ class AnthropicTokenCounter(TokenCounter):
72
72
  Raises:
73
73
  RuntimeError: If API call fails
74
74
  """
75
- # Handle empty text to avoid unnecessary API calls
76
- # Anthropic API requires non-empty content, so we need a strict check
77
- if not text or not text.strip():
78
- return 0
79
-
80
- # Additional validation: ensure the text has actual content
81
- # Some edge cases might have only whitespace or control characters
82
- cleaned_text = text.strip()
83
- if not cleaned_text:
84
- return 0
85
-
86
75
  try:
87
76
  # Anthropic API expects messages format and model parameter
88
77
  # Use await with async client
89
78
  result = await self.client.messages.count_tokens(
90
- messages=[{"role": "user", "content": cleaned_text}],
91
- model=self.model_name,
79
+ messages=[{"role": "user", "content": text}], model=self.model_name
92
80
  )
93
81
  return result.input_tokens
94
82
  except Exception as e:
@@ -119,9 +107,5 @@ class AnthropicTokenCounter(TokenCounter):
119
107
  Raises:
120
108
  RuntimeError: If token counting fails
121
109
  """
122
- # Handle empty message list early
123
- if not messages:
124
- return 0
125
-
126
110
  total_text = extract_text_from_messages(messages)
127
111
  return await self.count_tokens(total_text)
@@ -56,23 +56,12 @@ def extract_text_from_messages(messages: list[ModelMessage]) -> str:
56
56
  if hasattr(message, "parts"):
57
57
  for part in message.parts:
58
58
  if hasattr(part, "content") and isinstance(part.content, str):
59
- # Only add non-empty content
60
- if part.content.strip():
61
- text_parts.append(part.content)
59
+ text_parts.append(part.content)
62
60
  else:
63
61
  # Handle non-text parts (tool calls, etc.)
64
- part_str = str(part)
65
- if part_str.strip():
66
- text_parts.append(part_str)
62
+ text_parts.append(str(part))
67
63
  else:
68
64
  # Handle messages without parts
69
- msg_str = str(message)
70
- if msg_str.strip():
71
- text_parts.append(msg_str)
72
-
73
- # If no valid text parts found, return a minimal placeholder
74
- # This ensures we never send completely empty content to APIs
75
- if not text_parts:
76
- return "."
65
+ text_parts.append(str(message))
77
66
 
78
67
  return "\n".join(text_parts)
@@ -57,15 +57,9 @@ class OpenAITokenCounter(TokenCounter):
57
57
  Raises:
58
58
  RuntimeError: If token counting fails
59
59
  """
60
- # Handle empty text to avoid unnecessary encoding
61
- if not text or not text.strip():
62
- return 0
63
-
64
60
  try:
65
61
  return len(self.encoding.encode(text))
66
- except BaseException as e:
67
- # Must catch BaseException to handle PanicException from tiktoken's Rust layer
68
- # which can occur with extremely long texts. Regular Exception won't catch it.
62
+ except Exception as e:
69
63
  raise RuntimeError(
70
64
  f"Failed to count tokens for OpenAI model {self.model_name}"
71
65
  ) from e
@@ -82,9 +76,5 @@ class OpenAITokenCounter(TokenCounter):
82
76
  Raises:
83
77
  RuntimeError: If token counting fails
84
78
  """
85
- # Handle empty message list early
86
- if not messages:
87
- return 0
88
-
89
79
  total_text = extract_text_from_messages(messages)
90
80
  return await self.count_tokens(total_text)
@@ -88,10 +88,6 @@ class SentencePieceTokenCounter(TokenCounter):
88
88
  Raises:
89
89
  RuntimeError: If token counting fails
90
90
  """
91
- # Handle empty text to avoid unnecessary tokenization
92
- if not text or not text.strip():
93
- return 0
94
-
95
91
  await self._ensure_tokenizer()
96
92
 
97
93
  if self.sp is None:
@@ -119,9 +115,5 @@ class SentencePieceTokenCounter(TokenCounter):
119
115
  Raises:
120
116
  RuntimeError: If token counting fails
121
117
  """
122
- # Handle empty message list early
123
- if not messages:
124
- return 0
125
-
126
118
  total_text = extract_text_from_messages(messages)
127
119
  return await self.count_tokens(total_text)
@@ -3,7 +3,6 @@
3
3
  import hashlib
4
4
  from pathlib import Path
5
5
 
6
- import aiofiles
7
6
  import httpx
8
7
 
9
8
  from shotgun.logging_config import get_logger
@@ -79,8 +78,7 @@ async def download_gemini_tokenizer() -> Path:
79
78
 
80
79
  # Atomic write: write to temp file first, then rename
81
80
  temp_path = cache_path.with_suffix(".tmp")
82
- async with aiofiles.open(temp_path, "wb") as f:
83
- await f.write(content)
81
+ temp_path.write_bytes(content)
84
82
  temp_path.rename(cache_path)
85
83
 
86
84
  logger.info(f"Gemini tokenizer downloaded and cached at {cache_path}")
@@ -44,6 +44,9 @@ def get_token_counter(model_config: ModelConfig) -> TokenCounter:
44
44
 
45
45
  # Return cached instance if available
46
46
  if cache_key in _token_counter_cache:
47
+ logger.debug(
48
+ f"Reusing cached token counter for {model_config.provider.value}:{model_config.name}"
49
+ )
47
50
  return _token_counter_cache[cache_key]
48
51
 
49
52
  # Create new instance and cache it
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
- async def create_plan_agent(
26
+ 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 @@ async 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 = await create_base_agent(
42
+ agent, deps = 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
- async def create_research_agent(
29
+ 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 @@ async 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 = await get_available_web_search_tools()
44
+ web_search_tools = 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 @@ async 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 = await create_base_agent(
56
+ agent, deps = create_base_agent(
57
57
  system_prompt_fn,
58
58
  agent_runtime_options,
59
59
  load_codebase_understanding_tools=True,
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
- async def create_specify_agent(
26
+ 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 @@ async 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 = await create_base_agent(
42
+ agent, deps = 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
- async def create_tasks_agent(
26
+ 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 @@ async 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 = await create_base_agent(
42
+ agent, deps = create_base_agent(
43
43
  system_prompt_fn,
44
44
  agent_runtime_options,
45
45
  provider=provider,
@@ -2,7 +2,6 @@
2
2
 
3
3
  from pathlib import Path
4
4
 
5
- import aiofiles
6
5
  from pydantic_ai import RunContext
7
6
 
8
7
  from shotgun.agents.models import AgentDeps
@@ -94,8 +93,7 @@ async def file_read(
94
93
  # Read file contents
95
94
  encoding_used = "utf-8"
96
95
  try:
97
- async with aiofiles.open(full_file_path, encoding="utf-8") as f:
98
- content = await f.read()
96
+ content = full_file_path.read_text(encoding="utf-8")
99
97
  size_bytes = full_file_path.stat().st_size
100
98
 
101
99
  logger.debug(
@@ -121,8 +119,7 @@ async def file_read(
121
119
  try:
122
120
  # Try with different encoding
123
121
  encoding_used = "latin-1"
124
- async with aiofiles.open(full_file_path, encoding="latin-1") as f:
125
- content = await f.read()
122
+ content = full_file_path.read_text(encoding="latin-1")
126
123
  size_bytes = full_file_path.stat().st_size
127
124
 
128
125
  # Detect language from file extension