shotgun-sh 0.4.0.dev1__py3-none-any.whl → 0.6.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shotgun/agents/agent_manager.py +307 -8
- shotgun/agents/cancellation.py +103 -0
- shotgun/agents/common.py +12 -0
- shotgun/agents/config/README.md +0 -1
- shotgun/agents/config/manager.py +10 -7
- shotgun/agents/config/models.py +5 -27
- shotgun/agents/config/provider.py +44 -27
- shotgun/agents/conversation/history/token_counting/base.py +51 -9
- shotgun/agents/file_read.py +176 -0
- shotgun/agents/messages.py +15 -3
- shotgun/agents/models.py +24 -1
- shotgun/agents/router/models.py +8 -0
- shotgun/agents/router/tools/delegation_tools.py +55 -1
- shotgun/agents/router/tools/plan_tools.py +88 -7
- shotgun/agents/runner.py +17 -2
- shotgun/agents/tools/__init__.py +8 -0
- shotgun/agents/tools/codebase/directory_lister.py +27 -39
- shotgun/agents/tools/codebase/file_read.py +26 -35
- shotgun/agents/tools/codebase/query_graph.py +9 -0
- shotgun/agents/tools/codebase/retrieve_code.py +9 -0
- shotgun/agents/tools/file_management.py +32 -2
- shotgun/agents/tools/file_read_tools/__init__.py +7 -0
- shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
- shotgun/agents/tools/markdown_tools/__init__.py +62 -0
- shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
- shotgun/agents/tools/markdown_tools/models.py +86 -0
- shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
- shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
- shotgun/agents/tools/markdown_tools/utils.py +453 -0
- shotgun/agents/tools/registry.py +44 -6
- shotgun/agents/tools/web_search/openai.py +42 -23
- shotgun/attachments/__init__.py +41 -0
- shotgun/attachments/errors.py +60 -0
- shotgun/attachments/models.py +107 -0
- shotgun/attachments/parser.py +257 -0
- shotgun/attachments/processor.py +193 -0
- shotgun/build_constants.py +4 -7
- shotgun/cli/clear.py +2 -2
- shotgun/cli/codebase/commands.py +181 -65
- shotgun/cli/compact.py +2 -2
- shotgun/cli/context.py +2 -2
- shotgun/cli/error_handler.py +2 -2
- shotgun/cli/run.py +90 -0
- shotgun/cli/spec/backup.py +2 -1
- shotgun/codebase/__init__.py +2 -0
- shotgun/codebase/benchmarks/__init__.py +35 -0
- shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
- shotgun/codebase/benchmarks/exporters.py +119 -0
- shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
- shotgun/codebase/benchmarks/formatters/base.py +34 -0
- shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
- shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
- shotgun/codebase/benchmarks/models.py +129 -0
- shotgun/codebase/core/__init__.py +4 -0
- shotgun/codebase/core/call_resolution.py +91 -0
- shotgun/codebase/core/change_detector.py +11 -6
- shotgun/codebase/core/errors.py +159 -0
- shotgun/codebase/core/extractors/__init__.py +23 -0
- shotgun/codebase/core/extractors/base.py +138 -0
- shotgun/codebase/core/extractors/factory.py +63 -0
- shotgun/codebase/core/extractors/go/__init__.py +7 -0
- shotgun/codebase/core/extractors/go/extractor.py +122 -0
- shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
- shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
- shotgun/codebase/core/extractors/protocol.py +109 -0
- shotgun/codebase/core/extractors/python/__init__.py +7 -0
- shotgun/codebase/core/extractors/python/extractor.py +141 -0
- shotgun/codebase/core/extractors/rust/__init__.py +7 -0
- shotgun/codebase/core/extractors/rust/extractor.py +139 -0
- shotgun/codebase/core/extractors/types.py +15 -0
- shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
- shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
- shotgun/codebase/core/gitignore.py +252 -0
- shotgun/codebase/core/ingestor.py +644 -354
- shotgun/codebase/core/kuzu_compat.py +119 -0
- shotgun/codebase/core/language_config.py +239 -0
- shotgun/codebase/core/manager.py +256 -46
- shotgun/codebase/core/metrics_collector.py +310 -0
- shotgun/codebase/core/metrics_types.py +347 -0
- shotgun/codebase/core/parallel_executor.py +424 -0
- shotgun/codebase/core/work_distributor.py +254 -0
- shotgun/codebase/core/worker.py +768 -0
- shotgun/codebase/indexing_state.py +86 -0
- shotgun/codebase/models.py +94 -0
- shotgun/codebase/service.py +13 -0
- shotgun/exceptions.py +9 -9
- shotgun/main.py +3 -16
- shotgun/posthog_telemetry.py +165 -24
- shotgun/prompts/agents/file_read.j2 +48 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -47
- shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
- shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
- shotgun/prompts/agents/partials/router_delegation_mode.j2 +21 -22
- shotgun/prompts/agents/plan.j2 +14 -0
- shotgun/prompts/agents/router.j2 +531 -258
- shotgun/prompts/agents/specify.j2 +14 -0
- shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
- shotgun/prompts/agents/state/system_state.j2 +13 -11
- shotgun/prompts/agents/tasks.j2 +14 -0
- shotgun/settings.py +49 -10
- shotgun/tui/app.py +149 -18
- shotgun/tui/commands/__init__.py +9 -1
- shotgun/tui/components/attachment_bar.py +87 -0
- shotgun/tui/components/prompt_input.py +25 -28
- shotgun/tui/components/status_bar.py +14 -7
- shotgun/tui/dependencies.py +3 -8
- shotgun/tui/protocols.py +18 -0
- shotgun/tui/screens/chat/chat.tcss +15 -0
- shotgun/tui/screens/chat/chat_screen.py +766 -235
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
- shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
- shotgun/tui/screens/chat_screen/command_providers.py +0 -10
- shotgun/tui/screens/chat_screen/history/chat_history.py +54 -14
- shotgun/tui/screens/chat_screen/history/formatters.py +22 -0
- shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
- shotgun/tui/screens/database_locked_dialog.py +219 -0
- shotgun/tui/screens/database_timeout_dialog.py +158 -0
- shotgun/tui/screens/kuzu_error_dialog.py +135 -0
- shotgun/tui/screens/model_picker.py +1 -3
- shotgun/tui/screens/models.py +11 -0
- shotgun/tui/state/processing_state.py +19 -0
- shotgun/tui/widgets/widget_coordinator.py +18 -0
- shotgun/utils/file_system_utils.py +4 -1
- {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +87 -34
- {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/RECORD +128 -79
- shotgun/cli/export.py +0 -81
- shotgun/cli/plan.py +0 -73
- shotgun/cli/research.py +0 -93
- shotgun/cli/specify.py +0 -70
- shotgun/cli/tasks.py +0 -78
- shotgun/sentry_telemetry.py +0 -232
- shotgun/tui/screens/onboarding.py +0 -584
- {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/licenses/LICENSE +0 -0
shotgun/agents/config/models.py
CHANGED
|
@@ -26,10 +26,8 @@ class ModelName(StrEnum):
|
|
|
26
26
|
"""Available AI model names."""
|
|
27
27
|
|
|
28
28
|
GPT_5_1 = "gpt-5.1"
|
|
29
|
-
|
|
30
|
-
GPT_5_1_CODEX_MINI = "gpt-5.1-codex-mini"
|
|
29
|
+
GPT_5_2 = "gpt-5.2"
|
|
31
30
|
CLAUDE_OPUS_4_5 = "claude-opus-4-5"
|
|
32
|
-
CLAUDE_SONNET_4 = "claude-sonnet-4"
|
|
33
31
|
CLAUDE_SONNET_4_5 = "claude-sonnet-4-5"
|
|
34
32
|
CLAUDE_HAIKU_4_5 = "claude-haiku-4-5"
|
|
35
33
|
GEMINI_2_5_PRO = "gemini-2.5-pro"
|
|
@@ -110,21 +108,13 @@ MODEL_SPECS: dict[ModelName, ModelSpec] = {
|
|
|
110
108
|
litellm_proxy_model_name="openai/gpt-5.1",
|
|
111
109
|
short_name="GPT-5.1",
|
|
112
110
|
),
|
|
113
|
-
ModelName.
|
|
114
|
-
name=ModelName.
|
|
111
|
+
ModelName.GPT_5_2: ModelSpec(
|
|
112
|
+
name=ModelName.GPT_5_2,
|
|
115
113
|
provider=ProviderType.OPENAI,
|
|
116
114
|
max_input_tokens=272_000,
|
|
117
115
|
max_output_tokens=128_000,
|
|
118
|
-
litellm_proxy_model_name="openai/gpt-5.
|
|
119
|
-
short_name="GPT-5.
|
|
120
|
-
),
|
|
121
|
-
ModelName.GPT_5_1_CODEX_MINI: ModelSpec(
|
|
122
|
-
name=ModelName.GPT_5_1_CODEX_MINI,
|
|
123
|
-
provider=ProviderType.OPENAI,
|
|
124
|
-
max_input_tokens=272_000,
|
|
125
|
-
max_output_tokens=128_000,
|
|
126
|
-
litellm_proxy_model_name="openai/gpt-5.1-codex-mini",
|
|
127
|
-
short_name="GPT-5.1 Codex Mini",
|
|
116
|
+
litellm_proxy_model_name="openai/gpt-5.2",
|
|
117
|
+
short_name="GPT-5.2",
|
|
128
118
|
),
|
|
129
119
|
ModelName.CLAUDE_SONNET_4_5: ModelSpec(
|
|
130
120
|
name=ModelName.CLAUDE_SONNET_4_5,
|
|
@@ -166,14 +156,6 @@ MODEL_SPECS: dict[ModelName, ModelSpec] = {
|
|
|
166
156
|
litellm_proxy_model_name="anthropic/claude-opus-4-5",
|
|
167
157
|
short_name="Opus 4.5",
|
|
168
158
|
),
|
|
169
|
-
ModelName.CLAUDE_SONNET_4: ModelSpec(
|
|
170
|
-
name=ModelName.CLAUDE_SONNET_4,
|
|
171
|
-
provider=ProviderType.ANTHROPIC,
|
|
172
|
-
max_input_tokens=200_000,
|
|
173
|
-
max_output_tokens=64_000,
|
|
174
|
-
litellm_proxy_model_name="anthropic/claude-sonnet-4",
|
|
175
|
-
short_name="Sonnet 4",
|
|
176
|
-
),
|
|
177
159
|
ModelName.GEMINI_2_5_FLASH_LITE: ModelSpec(
|
|
178
160
|
name=ModelName.GEMINI_2_5_FLASH_LITE,
|
|
179
161
|
provider=ProviderType.GOOGLE,
|
|
@@ -273,10 +255,6 @@ class ShotgunConfig(BaseModel):
|
|
|
273
255
|
default=False,
|
|
274
256
|
description="Whether the welcome screen has been shown to the user",
|
|
275
257
|
)
|
|
276
|
-
shown_onboarding_popup: datetime | None = Field(
|
|
277
|
-
default=None,
|
|
278
|
-
description="Timestamp when the onboarding popup was shown to the user (ISO8601 format)",
|
|
279
|
-
)
|
|
280
258
|
marketing: MarketingConfig = Field(
|
|
281
259
|
default_factory=MarketingConfig,
|
|
282
260
|
description="Marketing messages configuration and tracking",
|
|
@@ -4,7 +4,7 @@ from pydantic import SecretStr
|
|
|
4
4
|
from pydantic_ai.models import Model
|
|
5
5
|
from pydantic_ai.models.anthropic import AnthropicModel, AnthropicModelSettings
|
|
6
6
|
from pydantic_ai.models.google import GoogleModel
|
|
7
|
-
from pydantic_ai.models.openai import
|
|
7
|
+
from pydantic_ai.models.openai import OpenAIResponsesModel
|
|
8
8
|
from pydantic_ai.providers.anthropic import AnthropicProvider
|
|
9
9
|
from pydantic_ai.providers.google import GoogleProvider
|
|
10
10
|
from pydantic_ai.providers.openai import OpenAIProvider
|
|
@@ -47,18 +47,18 @@ def get_default_model_for_provider(config: ShotgunConfig) -> ModelName:
|
|
|
47
47
|
"""
|
|
48
48
|
# Priority 1: Shotgun Account
|
|
49
49
|
if _get_api_key(config.shotgun.api_key):
|
|
50
|
-
return ModelName.
|
|
50
|
+
return ModelName.CLAUDE_SONNET_4_5
|
|
51
51
|
|
|
52
52
|
# Priority 2: Individual provider keys
|
|
53
53
|
if _get_api_key(config.anthropic.api_key):
|
|
54
|
-
return ModelName.
|
|
54
|
+
return ModelName.CLAUDE_SONNET_4_5
|
|
55
55
|
if _get_api_key(config.openai.api_key):
|
|
56
|
-
return ModelName.
|
|
56
|
+
return ModelName.GPT_5_2
|
|
57
57
|
if _get_api_key(config.google.api_key):
|
|
58
|
-
return ModelName.
|
|
58
|
+
return ModelName.GEMINI_3_PRO_PREVIEW
|
|
59
59
|
|
|
60
60
|
# Fallback: system-wide default
|
|
61
|
-
return ModelName.
|
|
61
|
+
return ModelName.CLAUDE_SONNET_4_5
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
def get_or_create_model(
|
|
@@ -130,7 +130,7 @@ def get_or_create_model(
|
|
|
130
130
|
# OpenAI and Google: Use LiteLLMProvider (OpenAI-compatible format)
|
|
131
131
|
# Google's GoogleProvider doesn't support base_url, so use LiteLLM
|
|
132
132
|
litellm_provider = create_litellm_provider(api_key)
|
|
133
|
-
_model_cache[cache_key] =
|
|
133
|
+
_model_cache[cache_key] = OpenAIResponsesModel(
|
|
134
134
|
litellm_model_name,
|
|
135
135
|
provider=litellm_provider,
|
|
136
136
|
settings=ModelSettings(max_tokens=max_tokens),
|
|
@@ -139,7 +139,7 @@ def get_or_create_model(
|
|
|
139
139
|
# Use native provider implementations with user's API keys
|
|
140
140
|
if provider == ProviderType.OPENAI:
|
|
141
141
|
openai_provider = OpenAIProvider(api_key=api_key)
|
|
142
|
-
_model_cache[cache_key] =
|
|
142
|
+
_model_cache[cache_key] = OpenAIResponsesModel(
|
|
143
143
|
model_name,
|
|
144
144
|
provider=openai_provider,
|
|
145
145
|
settings=ModelSettings(max_tokens=max_tokens),
|
|
@@ -257,23 +257,24 @@ async def get_provider_model(
|
|
|
257
257
|
requested_model = None # Will use provider's default model
|
|
258
258
|
|
|
259
259
|
if provider_enum == ProviderType.OPENAI:
|
|
260
|
-
api_key = _get_api_key(config.openai.api_key)
|
|
260
|
+
api_key = _get_api_key(config.openai.api_key, "OPENAI_API_KEY")
|
|
261
261
|
if not api_key:
|
|
262
|
-
raise ValueError(
|
|
262
|
+
raise ValueError(
|
|
263
|
+
"OpenAI API key not configured. Set via config or OPENAI_API_KEY env var."
|
|
264
|
+
)
|
|
263
265
|
|
|
264
|
-
# Use requested model or default to gpt-5.
|
|
265
|
-
model_name = requested_model if requested_model else ModelName.
|
|
266
|
+
# Use requested model or default to gpt-5.2
|
|
267
|
+
model_name = requested_model if requested_model else ModelName.GPT_5_2
|
|
266
268
|
# Gracefully fall back if model doesn't exist
|
|
267
269
|
if model_name not in MODEL_SPECS:
|
|
268
|
-
model_name = ModelName.
|
|
270
|
+
model_name = ModelName.GPT_5_2
|
|
269
271
|
spec = MODEL_SPECS[model_name]
|
|
270
272
|
|
|
271
273
|
# Check and test streaming capability for GPT-5 family models
|
|
272
274
|
supports_streaming = True # Default to True for all models
|
|
273
275
|
if model_name in (
|
|
274
276
|
ModelName.GPT_5_1,
|
|
275
|
-
ModelName.
|
|
276
|
-
ModelName.GPT_5_1_CODEX_MINI,
|
|
277
|
+
ModelName.GPT_5_2,
|
|
277
278
|
):
|
|
278
279
|
# Check if streaming capability has been tested
|
|
279
280
|
streaming_capability = config.openai.supports_streaming
|
|
@@ -307,15 +308,17 @@ async def get_provider_model(
|
|
|
307
308
|
)
|
|
308
309
|
|
|
309
310
|
elif provider_enum == ProviderType.ANTHROPIC:
|
|
310
|
-
api_key = _get_api_key(config.anthropic.api_key)
|
|
311
|
+
api_key = _get_api_key(config.anthropic.api_key, "ANTHROPIC_API_KEY")
|
|
311
312
|
if not api_key:
|
|
312
|
-
raise ValueError(
|
|
313
|
+
raise ValueError(
|
|
314
|
+
"Anthropic API key not configured. Set via config or ANTHROPIC_API_KEY env var."
|
|
315
|
+
)
|
|
313
316
|
|
|
314
|
-
# Use requested model or default to claude-
|
|
315
|
-
model_name = requested_model if requested_model else ModelName.
|
|
317
|
+
# Use requested model or default to claude-sonnet-4-5
|
|
318
|
+
model_name = requested_model if requested_model else ModelName.CLAUDE_SONNET_4_5
|
|
316
319
|
# Gracefully fall back if model doesn't exist
|
|
317
320
|
if model_name not in MODEL_SPECS:
|
|
318
|
-
model_name = ModelName.
|
|
321
|
+
model_name = ModelName.CLAUDE_SONNET_4_5
|
|
319
322
|
spec = MODEL_SPECS[model_name]
|
|
320
323
|
|
|
321
324
|
# Create fully configured ModelConfig
|
|
@@ -329,15 +332,19 @@ async def get_provider_model(
|
|
|
329
332
|
)
|
|
330
333
|
|
|
331
334
|
elif provider_enum == ProviderType.GOOGLE:
|
|
332
|
-
api_key = _get_api_key(config.google.api_key)
|
|
335
|
+
api_key = _get_api_key(config.google.api_key, "GEMINI_API_KEY")
|
|
333
336
|
if not api_key:
|
|
334
|
-
raise ValueError(
|
|
337
|
+
raise ValueError(
|
|
338
|
+
"Gemini API key not configured. Set via config or GEMINI_API_KEY env var."
|
|
339
|
+
)
|
|
335
340
|
|
|
336
|
-
# Use requested model or default to gemini-
|
|
337
|
-
model_name =
|
|
341
|
+
# Use requested model or default to gemini-3-pro-preview
|
|
342
|
+
model_name = (
|
|
343
|
+
requested_model if requested_model else ModelName.GEMINI_3_PRO_PREVIEW
|
|
344
|
+
)
|
|
338
345
|
# Gracefully fall back if model doesn't exist
|
|
339
346
|
if model_name not in MODEL_SPECS:
|
|
340
|
-
model_name = ModelName.
|
|
347
|
+
model_name = ModelName.GEMINI_3_PRO_PREVIEW
|
|
341
348
|
spec = MODEL_SPECS[model_name]
|
|
342
349
|
|
|
343
350
|
# Create fully configured ModelConfig
|
|
@@ -373,16 +380,26 @@ def _has_provider_key(config: "ShotgunConfig", provider: ProviderType) -> bool:
|
|
|
373
380
|
return False
|
|
374
381
|
|
|
375
382
|
|
|
376
|
-
def _get_api_key(
|
|
377
|
-
|
|
383
|
+
def _get_api_key(
|
|
384
|
+
config_key: SecretStr | None, env_var_name: str | None = None
|
|
385
|
+
) -> str | None:
|
|
386
|
+
"""Get API key from config or environment variable.
|
|
378
387
|
|
|
379
388
|
Args:
|
|
380
389
|
config_key: API key from configuration
|
|
390
|
+
env_var_name: Optional environment variable name to check as fallback
|
|
381
391
|
|
|
382
392
|
Returns:
|
|
383
393
|
API key string or None
|
|
384
394
|
"""
|
|
395
|
+
# First check config
|
|
385
396
|
if config_key is not None:
|
|
386
397
|
return config_key.get_secret_value()
|
|
387
398
|
|
|
399
|
+
# Fallback to environment variable
|
|
400
|
+
if env_var_name:
|
|
401
|
+
import os
|
|
402
|
+
|
|
403
|
+
return os.environ.get(env_var_name)
|
|
404
|
+
|
|
388
405
|
return None
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from abc import ABC, abstractmethod
|
|
4
4
|
|
|
5
|
-
from pydantic_ai.messages import ModelMessage
|
|
5
|
+
from pydantic_ai.messages import BinaryContent, ModelMessage
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class TokenCounter(ABC):
|
|
@@ -41,33 +41,75 @@ class TokenCounter(ABC):
|
|
|
41
41
|
"""
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
def _extract_text_from_content(content: object) -> str | None:
|
|
45
|
+
"""Extract text from a content object, skipping BinaryContent.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
content: A content object (str, BinaryContent, list, etc.)
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Extracted text or None if content is binary/empty
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(content, BinaryContent):
|
|
54
|
+
return None
|
|
55
|
+
if isinstance(content, str):
|
|
56
|
+
return content.strip() if content.strip() else None
|
|
57
|
+
if isinstance(content, list):
|
|
58
|
+
# Content can be a list like ['text', BinaryContent(...), 'more text']
|
|
59
|
+
text_items = []
|
|
60
|
+
for item in content:
|
|
61
|
+
extracted = _extract_text_from_content(item)
|
|
62
|
+
if extracted:
|
|
63
|
+
text_items.append(extracted)
|
|
64
|
+
return "\n".join(text_items) if text_items else None
|
|
65
|
+
# For other types, convert to string but skip if it looks like binary
|
|
66
|
+
text = str(content)
|
|
67
|
+
# Skip if it's a BinaryContent repr (contains raw bytes)
|
|
68
|
+
if "BinaryContent(data=b" in text:
|
|
69
|
+
return None
|
|
70
|
+
return text.strip() if text.strip() else None
|
|
71
|
+
|
|
72
|
+
|
|
44
73
|
def extract_text_from_messages(messages: list[ModelMessage]) -> str:
|
|
45
74
|
"""Extract all text content from messages for token counting.
|
|
46
75
|
|
|
76
|
+
Note: BinaryContent (PDFs, images) is skipped because:
|
|
77
|
+
1. str(BinaryContent) includes raw bytes which tokenize terribly
|
|
78
|
+
2. Claude uses fixed token costs for images/PDFs based on dimensions/pages,
|
|
79
|
+
not raw data size
|
|
80
|
+
3. Including binary data in text token counting causes massive overestimates
|
|
81
|
+
(e.g., 127KB of PDFs -> 267K tokens instead of ~few thousand)
|
|
82
|
+
|
|
47
83
|
Args:
|
|
48
84
|
messages: List of PydanticAI messages
|
|
49
85
|
|
|
50
86
|
Returns:
|
|
51
|
-
Combined text content from all messages
|
|
87
|
+
Combined text content from all messages (excluding binary content)
|
|
52
88
|
"""
|
|
53
89
|
text_parts = []
|
|
54
90
|
|
|
55
91
|
for message in messages:
|
|
56
92
|
if hasattr(message, "parts"):
|
|
57
93
|
for part in message.parts:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
94
|
+
# Skip BinaryContent directly
|
|
95
|
+
if isinstance(part, BinaryContent):
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
# Check if part has content attribute (UserPromptPart, etc.)
|
|
99
|
+
if hasattr(part, "content"):
|
|
100
|
+
extracted = _extract_text_from_content(part.content)
|
|
101
|
+
if extracted:
|
|
102
|
+
text_parts.append(extracted)
|
|
62
103
|
else:
|
|
63
|
-
# Handle
|
|
104
|
+
# Handle other parts (tool calls, etc.) - but check for binary
|
|
64
105
|
part_str = str(part)
|
|
65
|
-
if
|
|
106
|
+
# Skip if it contains BinaryContent repr
|
|
107
|
+
if "BinaryContent(data=b" not in part_str and part_str.strip():
|
|
66
108
|
text_parts.append(part_str)
|
|
67
109
|
else:
|
|
68
110
|
# Handle messages without parts
|
|
69
111
|
msg_str = str(message)
|
|
70
|
-
if msg_str.strip():
|
|
112
|
+
if "BinaryContent(data=b" not in msg_str and msg_str.strip():
|
|
71
113
|
text_parts.append(msg_str)
|
|
72
114
|
|
|
73
115
|
# If no valid text parts found, return a minimal placeholder
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""FileRead agent factory - lightweight agent for searching and reading files.
|
|
2
|
+
|
|
3
|
+
This agent is designed for finding and reading files (including PDFs and images)
|
|
4
|
+
without the overhead of full codebase understanding tools.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from functools import partial
|
|
8
|
+
|
|
9
|
+
from pydantic_ai import Agent, RunContext, UsageLimits
|
|
10
|
+
from pydantic_ai.agent import AgentRunResult
|
|
11
|
+
from pydantic_ai.messages import ModelMessage
|
|
12
|
+
|
|
13
|
+
from shotgun.agents.config import ProviderType, get_provider_model
|
|
14
|
+
from shotgun.agents.models import (
|
|
15
|
+
AgentDeps,
|
|
16
|
+
AgentResponse,
|
|
17
|
+
AgentRuntimeOptions,
|
|
18
|
+
AgentType,
|
|
19
|
+
ShotgunAgent,
|
|
20
|
+
)
|
|
21
|
+
from shotgun.logging_config import get_logger
|
|
22
|
+
from shotgun.prompts import PromptLoader
|
|
23
|
+
from shotgun.sdk.services import get_codebase_service
|
|
24
|
+
from shotgun.utils import ensure_shotgun_directory_exists
|
|
25
|
+
|
|
26
|
+
from .common import (
|
|
27
|
+
EventStreamHandler,
|
|
28
|
+
add_system_status_message,
|
|
29
|
+
run_agent,
|
|
30
|
+
)
|
|
31
|
+
from .conversation.history import token_limit_compactor
|
|
32
|
+
from .tools import directory_lister, file_read, read_file
|
|
33
|
+
from .tools.file_read_tools import multimodal_file_read
|
|
34
|
+
|
|
35
|
+
logger = get_logger(__name__)
|
|
36
|
+
|
|
37
|
+
# Prompt loader instance
|
|
38
|
+
prompt_loader = PromptLoader()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _build_file_read_system_prompt(ctx: RunContext[AgentDeps]) -> str:
|
|
42
|
+
"""Build system prompt for FileRead agent."""
|
|
43
|
+
template = prompt_loader.load_template("agents/file_read.j2")
|
|
44
|
+
return template.render(
|
|
45
|
+
interactive_mode=ctx.deps.interactive_mode,
|
|
46
|
+
mode="file_read",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def create_file_read_agent(
|
|
51
|
+
agent_runtime_options: AgentRuntimeOptions,
|
|
52
|
+
provider: ProviderType | None = None,
|
|
53
|
+
) -> tuple[ShotgunAgent, AgentDeps]:
|
|
54
|
+
"""Create a lightweight file reading agent.
|
|
55
|
+
|
|
56
|
+
This agent has minimal tools focused on file discovery and reading:
|
|
57
|
+
- directory_lister: List directory contents
|
|
58
|
+
- file_read: Read text files (from codebase tools)
|
|
59
|
+
- read_file: Read files by path
|
|
60
|
+
- multimodal_file_read: Read PDFs/images with BinaryContent
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
agent_runtime_options: Agent runtime options
|
|
64
|
+
provider: Optional provider override
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Tuple of (Configured agent, Agent dependencies)
|
|
68
|
+
"""
|
|
69
|
+
logger.debug("Initializing FileRead agent")
|
|
70
|
+
ensure_shotgun_directory_exists()
|
|
71
|
+
|
|
72
|
+
# Get configured model
|
|
73
|
+
model_config = await get_provider_model(provider)
|
|
74
|
+
logger.debug(
|
|
75
|
+
"FileRead agent using %s model: %s",
|
|
76
|
+
model_config.provider.value.upper(),
|
|
77
|
+
model_config.name,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Create minimal dependencies (no heavy codebase analysis)
|
|
81
|
+
codebase_service = get_codebase_service()
|
|
82
|
+
|
|
83
|
+
deps = AgentDeps(
|
|
84
|
+
**agent_runtime_options.model_dump(),
|
|
85
|
+
llm_model=model_config,
|
|
86
|
+
codebase_service=codebase_service,
|
|
87
|
+
system_prompt_fn=partial(_build_file_read_system_prompt),
|
|
88
|
+
agent_mode=AgentType.FILE_READ,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# History processor for context management
|
|
92
|
+
async def history_processor(messages: list[ModelMessage]) -> list[ModelMessage]:
|
|
93
|
+
class ProcessorContext:
|
|
94
|
+
def __init__(self, deps: AgentDeps):
|
|
95
|
+
self.deps = deps
|
|
96
|
+
self.usage = None
|
|
97
|
+
|
|
98
|
+
ctx = ProcessorContext(deps)
|
|
99
|
+
return await token_limit_compactor(ctx, messages)
|
|
100
|
+
|
|
101
|
+
# Create agent with structured output
|
|
102
|
+
model = model_config.model_instance
|
|
103
|
+
agent: ShotgunAgent = Agent(
|
|
104
|
+
model,
|
|
105
|
+
output_type=AgentResponse,
|
|
106
|
+
deps_type=AgentDeps,
|
|
107
|
+
instrument=True,
|
|
108
|
+
history_processors=[history_processor],
|
|
109
|
+
retries=3,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Register only file reading tools (no write tools, no codebase query tools)
|
|
113
|
+
agent.tool(read_file) # Basic file read
|
|
114
|
+
agent.tool(file_read) # Codebase file read with CWD fallback
|
|
115
|
+
agent.tool(directory_lister) # List directories
|
|
116
|
+
agent.tool(multimodal_file_read) # PDF/image reading with BinaryContent
|
|
117
|
+
|
|
118
|
+
logger.debug("FileRead agent created with minimal tools")
|
|
119
|
+
return agent, deps
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def create_file_read_usage_limits() -> UsageLimits:
|
|
123
|
+
"""Create conservative usage limits for FileRead agent.
|
|
124
|
+
|
|
125
|
+
FileRead should be quick - if it can't find the file in a few turns,
|
|
126
|
+
it should give up.
|
|
127
|
+
"""
|
|
128
|
+
return UsageLimits(
|
|
129
|
+
request_limit=10, # Max 10 API calls
|
|
130
|
+
request_tokens_limit=50_000, # 50k input tokens
|
|
131
|
+
response_tokens_limit=8_000, # 8k output tokens
|
|
132
|
+
total_tokens_limit=60_000, # 60k total
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def run_file_read_agent(
|
|
137
|
+
agent: ShotgunAgent,
|
|
138
|
+
prompt: str,
|
|
139
|
+
deps: AgentDeps,
|
|
140
|
+
message_history: list[ModelMessage] | None = None,
|
|
141
|
+
event_stream_handler: EventStreamHandler | None = None,
|
|
142
|
+
) -> AgentRunResult[AgentResponse]:
|
|
143
|
+
"""Run the FileRead agent to search for and read files.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
agent: The configured FileRead agent
|
|
147
|
+
prompt: The file search prompt (e.g., "find the user stories PDF")
|
|
148
|
+
deps: Agent dependencies
|
|
149
|
+
message_history: Optional message history
|
|
150
|
+
event_stream_handler: Optional callback for streaming events
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
AgentRunResult with response and files_found
|
|
154
|
+
"""
|
|
155
|
+
logger.debug("FileRead agent searching: %s", prompt)
|
|
156
|
+
|
|
157
|
+
message_history = await add_system_status_message(deps, message_history)
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
usage_limits = create_file_read_usage_limits()
|
|
161
|
+
|
|
162
|
+
result = await run_agent(
|
|
163
|
+
agent=agent,
|
|
164
|
+
prompt=prompt,
|
|
165
|
+
deps=deps,
|
|
166
|
+
message_history=message_history,
|
|
167
|
+
usage_limits=usage_limits,
|
|
168
|
+
event_stream_handler=event_stream_handler,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
logger.debug("FileRead agent completed successfully")
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
except Exception as e:
|
|
175
|
+
logger.error("FileRead agent failed: %s", str(e))
|
|
176
|
+
raise
|
shotgun/agents/messages.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""Custom message types for Shotgun agents.
|
|
2
2
|
|
|
3
|
-
This module defines specialized
|
|
4
|
-
between different types of
|
|
3
|
+
This module defines specialized message part subclasses to distinguish
|
|
4
|
+
between different types of prompts in the agent pipeline.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Literal
|
|
8
9
|
|
|
9
|
-
from pydantic_ai.messages import SystemPromptPart
|
|
10
|
+
from pydantic_ai.messages import SystemPromptPart, UserPromptPart
|
|
10
11
|
|
|
11
12
|
from shotgun.agents.models import AgentType
|
|
12
13
|
|
|
@@ -33,3 +34,14 @@ class SystemStatusPrompt(SystemPromptPart):
|
|
|
33
34
|
"""
|
|
34
35
|
|
|
35
36
|
prompt_type: str = "status"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class InternalPromptPart(UserPromptPart):
|
|
41
|
+
"""User prompt that is system-generated rather than actual user input.
|
|
42
|
+
|
|
43
|
+
Used for internal continuation prompts like file resume messages.
|
|
44
|
+
These should be hidden from the UI but preserved in agent history for context.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
part_kind: Literal["internal-prompt"] = "internal-prompt" # type: ignore[assignment]
|
shotgun/agents/models.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Pydantic models for agent dependencies and configuration."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
from asyncio import Future, Queue
|
|
4
|
+
from asyncio import Event, Future, Queue
|
|
5
5
|
from collections.abc import Callable
|
|
6
6
|
from datetime import datetime
|
|
7
7
|
from enum import StrEnum
|
|
@@ -87,6 +87,23 @@ class AgentResponse(BaseModel):
|
|
|
87
87
|
Optional list of clarifying questions to ask the user.
|
|
88
88
|
- Single question: Shown as a non-blocking suggestion (user can answer or continue with other prompts)
|
|
89
89
|
- Multiple questions (2+): Asked sequentially in Q&A mode (blocks input until all answered or cancelled)
|
|
90
|
+
""",
|
|
91
|
+
)
|
|
92
|
+
files_found: list[str] | None = Field(
|
|
93
|
+
default=None,
|
|
94
|
+
description="""
|
|
95
|
+
Optional list of absolute file paths found by the agent.
|
|
96
|
+
Used by FileReadAgent to return paths of files it searched and found.
|
|
97
|
+
The delegation tool can then load these files as multimodal content.
|
|
98
|
+
""",
|
|
99
|
+
)
|
|
100
|
+
file_requests: list[str] | None = Field(
|
|
101
|
+
default=None,
|
|
102
|
+
description="""
|
|
103
|
+
Optional list of file paths the agent wants to read.
|
|
104
|
+
When set, the agent loop exits, files are loaded as BinaryContent,
|
|
105
|
+
and the loop resumes with file content in the next prompt.
|
|
106
|
+
Use this for PDFs, images, or other binary files you need to analyze.
|
|
90
107
|
""",
|
|
91
108
|
)
|
|
92
109
|
|
|
@@ -100,6 +117,7 @@ class AgentType(StrEnum):
|
|
|
100
117
|
TASKS = "tasks"
|
|
101
118
|
EXPORT = "export"
|
|
102
119
|
ROUTER = "router"
|
|
120
|
+
FILE_READ = "file_read"
|
|
103
121
|
|
|
104
122
|
|
|
105
123
|
class PipelineConfigEntry(BaseModel):
|
|
@@ -373,6 +391,11 @@ class AgentDeps(AgentRuntimeOptions):
|
|
|
373
391
|
description="Context when agent is delegated to by router",
|
|
374
392
|
)
|
|
375
393
|
|
|
394
|
+
cancellation_event: Event | None = Field(
|
|
395
|
+
default=None,
|
|
396
|
+
description="Event set when the operation should be cancelled",
|
|
397
|
+
)
|
|
398
|
+
|
|
376
399
|
|
|
377
400
|
# Rebuild model to resolve forward references after imports are available
|
|
378
401
|
try:
|
shotgun/agents/router/models.py
CHANGED
|
@@ -216,6 +216,10 @@ class DelegationResult(BaseModel):
|
|
|
216
216
|
files_modified: list[str] = Field(
|
|
217
217
|
default_factory=list, description="Files modified by sub-agent"
|
|
218
218
|
)
|
|
219
|
+
files_found: list[str] = Field(
|
|
220
|
+
default_factory=list,
|
|
221
|
+
description="Files found by sub-agent (used by FileReadAgent)",
|
|
222
|
+
)
|
|
219
223
|
has_questions: bool = Field(
|
|
220
224
|
default=False, description="Whether sub-agent has clarifying questions"
|
|
221
225
|
)
|
|
@@ -358,6 +362,10 @@ class RouterDeps(AgentDeps):
|
|
|
358
362
|
# Set by create_plan tool when plan.needs_approval() returns True
|
|
359
363
|
# Excluded from serialization as it's transient UI state
|
|
360
364
|
pending_approval: PendingApproval | None = Field(default=None, exclude=True)
|
|
365
|
+
# Completion state for Drafting mode
|
|
366
|
+
# Set by mark_step_done when plan completes in drafting mode
|
|
367
|
+
# Excluded from serialization as it's transient UI state
|
|
368
|
+
pending_completion: bool = Field(default=False, exclude=True)
|
|
361
369
|
# Event stream handler for forwarding sub-agent streaming events to UI
|
|
362
370
|
# This is set by the AgentManager when running the router with streaming
|
|
363
371
|
# Excluded from serialization as it's a callable
|