shotgun-sh 0.3.3.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 +497 -30
- shotgun/agents/cancellation.py +103 -0
- shotgun/agents/common.py +90 -77
- shotgun/agents/config/README.md +0 -1
- shotgun/agents/config/manager.py +52 -8
- shotgun/agents/config/models.py +21 -27
- shotgun/agents/config/provider.py +44 -27
- shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
- shotgun/agents/conversation/history/token_counting/base.py +51 -9
- shotgun/agents/export.py +12 -13
- shotgun/agents/file_read.py +176 -0
- shotgun/agents/messages.py +15 -3
- shotgun/agents/models.py +90 -2
- shotgun/agents/plan.py +12 -13
- shotgun/agents/research.py +13 -10
- shotgun/agents/router/__init__.py +47 -0
- shotgun/agents/router/models.py +384 -0
- shotgun/agents/router/router.py +185 -0
- shotgun/agents/router/tools/__init__.py +18 -0
- shotgun/agents/router/tools/delegation_tools.py +557 -0
- shotgun/agents/router/tools/plan_tools.py +403 -0
- shotgun/agents/runner.py +17 -2
- shotgun/agents/specify.py +12 -13
- shotgun/agents/tasks.py +12 -13
- 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 +81 -3
- 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 +46 -6
- shotgun/agents/tools/web_search/__init__.py +1 -2
- shotgun/agents/tools/web_search/gemini.py +1 -3
- 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/export.j2 +2 -0
- shotgun/prompts/agents/file_read.j2 +48 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -52
- 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 +35 -0
- shotgun/prompts/agents/plan.j2 +38 -12
- shotgun/prompts/agents/research.j2 +70 -31
- shotgun/prompts/agents/router.j2 +713 -0
- shotgun/prompts/agents/specify.j2 +53 -16
- shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
- shotgun/prompts/agents/state/system_state.j2 +24 -13
- shotgun/prompts/agents/tasks.j2 +72 -34
- shotgun/settings.py +49 -10
- shotgun/tui/app.py +154 -24
- shotgun/tui/commands/__init__.py +9 -1
- shotgun/tui/components/attachment_bar.py +87 -0
- shotgun/tui/components/mode_indicator.py +120 -25
- shotgun/tui/components/prompt_input.py +25 -28
- shotgun/tui/components/status_bar.py +14 -7
- shotgun/tui/dependencies.py +58 -8
- shotgun/tui/protocols.py +55 -0
- shotgun/tui/screens/chat/chat.tcss +24 -1
- shotgun/tui/screens/chat/chat_screen.py +1376 -213
- 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 -97
- shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
- shotgun/tui/screens/chat_screen/history/chat_history.py +58 -6
- shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
- shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
- shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
- shotgun/tui/screens/chat_screen/messages.py +219 -0
- 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/utils/mode_progress.py +20 -86
- shotgun/tui/widgets/__init__.py +2 -1
- shotgun/tui/widgets/approval_widget.py +152 -0
- shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
- shotgun/tui/widgets/plan_panel.py +129 -0
- shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
- shotgun/tui/widgets/widget_coordinator.py +18 -0
- shotgun/utils/file_system_utils.py +4 -1
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +88 -35
- shotgun_sh-0.6.2.dist-info/RECORD +291 -0
- 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 -580
- shotgun_sh-0.3.3.dev1.dist-info/RECORD +0 -229
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
@@ -5,8 +5,6 @@ tool returns before LLM-based compaction. Files are still accessible via
|
|
|
5
5
|
`retrieve_code` (codebase) or `read_file` (.shotgun/ folder).
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
import copy
|
|
9
|
-
import re
|
|
10
8
|
from enum import StrEnum
|
|
11
9
|
from typing import Any
|
|
12
10
|
|
|
@@ -43,40 +41,46 @@ SHOTGUN_PLACEHOLDER = (
|
|
|
43
41
|
"**Content**: [Removed for compaction - file persisted in .shotgun/ folder]"
|
|
44
42
|
)
|
|
45
43
|
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
r"\*\*Size\*\*:\s*(\d+)\s*bytes\s*\n" # Size in bytes
|
|
51
|
-
r"(?:\*\*Encoding\*\*:.*?\n)?" # Optional encoding line
|
|
52
|
-
r"\n\*\*Content\*\*:\s*\n" # Blank line + Content header
|
|
53
|
-
r"```(\w*)\n" # Language tag
|
|
54
|
-
r"(.*?)```", # Actual content
|
|
55
|
-
re.DOTALL,
|
|
56
|
-
)
|
|
44
|
+
# Simple prefix for detecting file_read output format
|
|
45
|
+
# Instead of using regex, we just check for the expected prefix and extract the file path
|
|
46
|
+
CODEBASE_FILE_PREFIX = "**File**: `"
|
|
47
|
+
|
|
57
48
|
|
|
49
|
+
def _extract_file_path(content: str) -> str | None:
|
|
50
|
+
"""Extract file path from file_read tool return content.
|
|
58
51
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
) -> tuple[str, int, str, str] | None:
|
|
62
|
-
"""Parse file_read tool return content.
|
|
52
|
+
Uses simple string operations instead of regex for maximum performance.
|
|
53
|
+
The file_read tool output format is: **File**: `path`\\n...
|
|
63
54
|
|
|
64
55
|
Args:
|
|
65
56
|
content: The tool return content string
|
|
66
57
|
|
|
67
58
|
Returns:
|
|
68
|
-
|
|
59
|
+
The file path or None if format doesn't match
|
|
69
60
|
"""
|
|
70
|
-
|
|
71
|
-
if not
|
|
61
|
+
# Fast check: content must start with expected prefix
|
|
62
|
+
if not content.startswith(CODEBASE_FILE_PREFIX):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
# Find the closing backtick after the prefix
|
|
66
|
+
prefix_len = len(CODEBASE_FILE_PREFIX)
|
|
67
|
+
backtick_pos = content.find("`", prefix_len)
|
|
68
|
+
|
|
69
|
+
if backtick_pos == -1:
|
|
72
70
|
return None
|
|
73
71
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
72
|
+
return content[prefix_len:backtick_pos]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _get_language_from_path(file_path: str) -> str:
|
|
76
|
+
"""Infer programming language from file extension."""
|
|
77
|
+
from pathlib import Path
|
|
78
|
+
|
|
79
|
+
from shotgun.codebase.core.language_config import get_language_config
|
|
78
80
|
|
|
79
|
-
|
|
81
|
+
ext = Path(file_path).suffix
|
|
82
|
+
config = get_language_config(ext)
|
|
83
|
+
return config.name if config else "unknown"
|
|
80
84
|
|
|
81
85
|
|
|
82
86
|
def _create_codebase_placeholder(file_path: str, size_bytes: int, language: str) -> str:
|
|
@@ -110,6 +114,11 @@ def deduplicate_file_content(
|
|
|
110
114
|
This is a deterministic pre-compaction pass that reduces tokens without
|
|
111
115
|
requiring an LLM. Files remain accessible via their respective tools.
|
|
112
116
|
|
|
117
|
+
This function uses copy-on-write semantics: only messages that need
|
|
118
|
+
modification are copied, while unmodified messages are reused by reference.
|
|
119
|
+
This significantly reduces memory allocation and processing time for large
|
|
120
|
+
conversations where only a subset of messages contain file content.
|
|
121
|
+
|
|
113
122
|
Args:
|
|
114
123
|
messages: Conversation history
|
|
115
124
|
retention_window: Keep full content in last N messages (for recent context)
|
|
@@ -120,15 +129,17 @@ def deduplicate_file_content(
|
|
|
120
129
|
if not messages:
|
|
121
130
|
return messages, 0
|
|
122
131
|
|
|
123
|
-
# Deep copy to avoid modifying original
|
|
124
|
-
modified_messages = copy.deepcopy(messages)
|
|
125
132
|
total_tokens_saved = 0
|
|
126
133
|
files_deduplicated = 0
|
|
127
134
|
|
|
128
135
|
# Calculate retention boundary (keep last N messages intact)
|
|
129
|
-
retention_start = max(0, len(
|
|
136
|
+
retention_start = max(0, len(messages) - retention_window)
|
|
137
|
+
|
|
138
|
+
# Track which message indices need replacement
|
|
139
|
+
# We use a dict to store index -> new_message mappings
|
|
140
|
+
replacements: dict[int, ModelMessage] = {}
|
|
130
141
|
|
|
131
|
-
for msg_idx, message in enumerate(
|
|
142
|
+
for msg_idx, message in enumerate(messages):
|
|
132
143
|
# Skip messages in retention window
|
|
133
144
|
if msg_idx >= retention_start:
|
|
134
145
|
continue
|
|
@@ -159,18 +170,18 @@ def deduplicate_file_content(
|
|
|
159
170
|
|
|
160
171
|
# Handle codebase file reads (file_read)
|
|
161
172
|
if tool_name == FileReadTool.CODEBASE:
|
|
162
|
-
|
|
163
|
-
if
|
|
164
|
-
|
|
165
|
-
#
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
173
|
+
file_path = _extract_file_path(content)
|
|
174
|
+
if file_path:
|
|
175
|
+
# Use content length as size estimate (includes formatting overhead
|
|
176
|
+
# but close enough for deduplication purposes)
|
|
177
|
+
size_bytes = len(content)
|
|
178
|
+
language = _get_language_from_path(file_path)
|
|
179
|
+
replacement = _create_codebase_placeholder(
|
|
180
|
+
file_path, size_bytes, language
|
|
181
|
+
)
|
|
182
|
+
logger.debug(
|
|
183
|
+
f"Deduplicating codebase file: {file_path} ({size_bytes} bytes)"
|
|
184
|
+
)
|
|
174
185
|
|
|
175
186
|
# Handle .shotgun/ file reads (read_file)
|
|
176
187
|
elif tool_name == FileReadTool.SHOTGUN_FOLDER:
|
|
@@ -203,9 +214,21 @@ def deduplicate_file_content(
|
|
|
203
214
|
else:
|
|
204
215
|
new_parts.append(part)
|
|
205
216
|
|
|
206
|
-
#
|
|
217
|
+
# Only create a new message if parts were actually modified
|
|
207
218
|
if message_modified:
|
|
208
|
-
|
|
219
|
+
replacements[msg_idx] = ModelRequest(parts=new_parts)
|
|
220
|
+
|
|
221
|
+
# If no modifications were made, return original list (no allocation needed)
|
|
222
|
+
if not replacements:
|
|
223
|
+
return messages, 0
|
|
224
|
+
|
|
225
|
+
# Build result list with copy-on-write: reuse unmodified messages
|
|
226
|
+
modified_messages: list[ModelMessage] = []
|
|
227
|
+
for idx, msg in enumerate(messages):
|
|
228
|
+
if idx in replacements:
|
|
229
|
+
modified_messages.append(replacements[idx])
|
|
230
|
+
else:
|
|
231
|
+
modified_messages.append(msg)
|
|
209
232
|
|
|
210
233
|
if files_deduplicated > 0:
|
|
211
234
|
logger.info(
|
|
@@ -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
|
shotgun/agents/export.py
CHANGED
|
@@ -2,16 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
from functools import partial
|
|
4
4
|
|
|
5
|
-
from pydantic_ai import (
|
|
6
|
-
Agent,
|
|
7
|
-
)
|
|
8
5
|
from pydantic_ai.agent import AgentRunResult
|
|
9
6
|
from pydantic_ai.messages import ModelMessage
|
|
10
7
|
|
|
11
8
|
from shotgun.agents.config import ProviderType
|
|
9
|
+
from shotgun.agents.models import ShotgunAgent
|
|
12
10
|
from shotgun.logging_config import get_logger
|
|
13
11
|
|
|
14
12
|
from .common import (
|
|
13
|
+
EventStreamHandler,
|
|
15
14
|
add_system_status_message,
|
|
16
15
|
build_agent_system_prompt,
|
|
17
16
|
create_base_agent,
|
|
@@ -25,7 +24,7 @@ logger = get_logger(__name__)
|
|
|
25
24
|
|
|
26
25
|
async def create_export_agent(
|
|
27
26
|
agent_runtime_options: AgentRuntimeOptions, provider: ProviderType | None = None
|
|
28
|
-
) -> tuple[
|
|
27
|
+
) -> tuple[ShotgunAgent, AgentDeps]:
|
|
29
28
|
"""Create an export agent with file management capabilities.
|
|
30
29
|
|
|
31
30
|
Args:
|
|
@@ -49,39 +48,39 @@ async def create_export_agent(
|
|
|
49
48
|
|
|
50
49
|
|
|
51
50
|
async def run_export_agent(
|
|
52
|
-
agent:
|
|
53
|
-
|
|
51
|
+
agent: ShotgunAgent,
|
|
52
|
+
prompt: str,
|
|
54
53
|
deps: AgentDeps,
|
|
55
54
|
message_history: list[ModelMessage] | None = None,
|
|
55
|
+
event_stream_handler: EventStreamHandler | None = None,
|
|
56
56
|
) -> AgentRunResult[AgentResponse]:
|
|
57
|
-
"""Export artifacts based on the given
|
|
57
|
+
"""Export artifacts based on the given prompt.
|
|
58
58
|
|
|
59
59
|
Args:
|
|
60
60
|
agent: The configured export agent
|
|
61
|
-
|
|
61
|
+
prompt: The export prompt
|
|
62
62
|
deps: Agent dependencies
|
|
63
63
|
message_history: Optional message history for conversation continuity
|
|
64
|
+
event_stream_handler: Optional callback for streaming events
|
|
64
65
|
|
|
65
66
|
Returns:
|
|
66
67
|
AgentRunResult containing the export process output
|
|
67
68
|
"""
|
|
68
|
-
logger.debug("📤 Starting export for
|
|
69
|
+
logger.debug("📤 Starting export for prompt: %s", prompt)
|
|
69
70
|
|
|
70
71
|
message_history = await add_system_status_message(deps, message_history)
|
|
71
72
|
|
|
72
|
-
# Let the agent use its tools to read existing artifacts and export them
|
|
73
|
-
full_prompt = f"Export artifacts or findings based on: {instruction}"
|
|
74
|
-
|
|
75
73
|
try:
|
|
76
74
|
# Create usage limits for responsible API usage
|
|
77
75
|
usage_limits = create_usage_limits()
|
|
78
76
|
|
|
79
77
|
result = await run_agent(
|
|
80
78
|
agent=agent,
|
|
81
|
-
prompt=
|
|
79
|
+
prompt=prompt,
|
|
82
80
|
deps=deps,
|
|
83
81
|
message_history=message_history,
|
|
84
82
|
usage_limits=usage_limits,
|
|
83
|
+
event_stream_handler=event_stream_handler,
|
|
85
84
|
)
|
|
86
85
|
|
|
87
86
|
logger.debug("✅ Export completed successfully")
|
|
@@ -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
|