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.
- shotgun/agents/agent_manager.py +28 -194
- shotgun/agents/common.py +8 -14
- shotgun/agents/config/manager.py +33 -64
- shotgun/agents/config/models.py +1 -25
- shotgun/agents/config/provider.py +2 -2
- shotgun/agents/context_analyzer/analyzer.py +24 -2
- shotgun/agents/conversation_manager.py +19 -35
- shotgun/agents/export.py +2 -2
- shotgun/agents/history/history_processors.py +3 -99
- shotgun/agents/history/token_counting/anthropic.py +1 -17
- shotgun/agents/history/token_counting/base.py +3 -14
- shotgun/agents/history/token_counting/openai.py +1 -11
- shotgun/agents/history/token_counting/sentencepiece_counter.py +0 -8
- shotgun/agents/history/token_counting/tokenizer_cache.py +1 -3
- shotgun/agents/history/token_counting/utils.py +3 -0
- shotgun/agents/plan.py +2 -2
- shotgun/agents/research.py +3 -3
- shotgun/agents/specify.py +2 -2
- shotgun/agents/tasks.py +2 -2
- shotgun/agents/tools/codebase/file_read.py +2 -5
- shotgun/agents/tools/file_management.py +7 -11
- shotgun/agents/tools/web_search/__init__.py +8 -8
- shotgun/agents/tools/web_search/anthropic.py +2 -2
- shotgun/agents/tools/web_search/gemini.py +1 -1
- shotgun/agents/tools/web_search/openai.py +1 -1
- shotgun/agents/tools/web_search/utils.py +2 -2
- shotgun/agents/usage_manager.py +11 -16
- shotgun/build_constants.py +2 -2
- shotgun/cli/clear.py +1 -2
- shotgun/cli/compact.py +3 -3
- shotgun/cli/config.py +5 -8
- shotgun/cli/context.py +2 -2
- shotgun/cli/export.py +1 -1
- shotgun/cli/feedback.py +2 -4
- shotgun/cli/plan.py +1 -1
- shotgun/cli/research.py +1 -1
- shotgun/cli/specify.py +1 -1
- shotgun/cli/tasks.py +1 -1
- shotgun/codebase/core/change_detector.py +3 -5
- shotgun/codebase/core/code_retrieval.py +2 -4
- shotgun/codebase/core/ingestor.py +8 -10
- shotgun/codebase/core/manager.py +3 -3
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/logging_config.py +17 -10
- shotgun/main.py +1 -3
- shotgun/posthog_telemetry.py +4 -14
- shotgun/sentry_telemetry.py +2 -22
- shotgun/telemetry.py +1 -3
- shotgun/tui/app.py +65 -71
- shotgun/tui/components/context_indicator.py +0 -43
- shotgun/tui/containers.py +17 -15
- shotgun/tui/dependencies.py +2 -2
- shotgun/tui/screens/chat/chat_screen.py +40 -164
- shotgun/tui/screens/chat/help_text.py +15 -16
- shotgun/tui/screens/chat_screen/command_providers.py +0 -10
- shotgun/tui/screens/feedback.py +4 -4
- shotgun/tui/screens/model_picker.py +20 -21
- shotgun/tui/screens/provider_config.py +27 -50
- shotgun/tui/screens/shotgun_auth.py +2 -2
- shotgun/tui/screens/welcome.py +11 -14
- shotgun/tui/services/conversation_service.py +14 -16
- shotgun/tui/utils/mode_progress.py +7 -14
- shotgun/tui/widgets/widget_coordinator.py +0 -15
- shotgun/utils/file_system_utils.py +0 -19
- {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/METADATA +1 -2
- {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/RECORD +69 -73
- shotgun/exceptions.py +0 -32
- shotgun/tui/screens/github_issue.py +0 -102
- shotgun/tui/screens/onboarding.py +0 -431
- shotgun/utils/marketing.py +0 -110
- {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/licenses/LICENSE +0 -0
shotgun/agents/config/models.py
CHANGED
|
@@ -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=
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
50
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
+
def clear(self) -> None:
|
|
124
109
|
"""Delete the conversation history file."""
|
|
125
|
-
if
|
|
110
|
+
if self.conversation_path.exists():
|
|
126
111
|
try:
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
244
|
-
|
|
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
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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/research.py
CHANGED
|
@@ -26,7 +26,7 @@ from .tools import get_available_web_search_tools
|
|
|
26
26
|
logger = get_logger(__name__)
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|