shotgun-sh 0.2.29.dev2__py3-none-any.whl → 0.6.1.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of shotgun-sh might be problematic. Click here for more details.

Files changed (161) hide show
  1. shotgun/agents/agent_manager.py +497 -30
  2. shotgun/agents/cancellation.py +103 -0
  3. shotgun/agents/common.py +90 -77
  4. shotgun/agents/config/README.md +0 -1
  5. shotgun/agents/config/manager.py +52 -8
  6. shotgun/agents/config/models.py +48 -45
  7. shotgun/agents/config/provider.py +44 -29
  8. shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
  9. shotgun/agents/conversation/history/token_counting/base.py +51 -9
  10. shotgun/agents/export.py +12 -13
  11. shotgun/agents/file_read.py +176 -0
  12. shotgun/agents/messages.py +15 -3
  13. shotgun/agents/models.py +90 -2
  14. shotgun/agents/plan.py +12 -13
  15. shotgun/agents/research.py +13 -10
  16. shotgun/agents/router/__init__.py +47 -0
  17. shotgun/agents/router/models.py +384 -0
  18. shotgun/agents/router/router.py +185 -0
  19. shotgun/agents/router/tools/__init__.py +18 -0
  20. shotgun/agents/router/tools/delegation_tools.py +557 -0
  21. shotgun/agents/router/tools/plan_tools.py +403 -0
  22. shotgun/agents/runner.py +17 -2
  23. shotgun/agents/specify.py +12 -13
  24. shotgun/agents/tasks.py +12 -13
  25. shotgun/agents/tools/__init__.py +8 -0
  26. shotgun/agents/tools/codebase/directory_lister.py +27 -39
  27. shotgun/agents/tools/codebase/file_read.py +26 -35
  28. shotgun/agents/tools/codebase/query_graph.py +9 -0
  29. shotgun/agents/tools/codebase/retrieve_code.py +9 -0
  30. shotgun/agents/tools/file_management.py +81 -3
  31. shotgun/agents/tools/file_read_tools/__init__.py +7 -0
  32. shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
  33. shotgun/agents/tools/markdown_tools/__init__.py +62 -0
  34. shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
  35. shotgun/agents/tools/markdown_tools/models.py +86 -0
  36. shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
  37. shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
  38. shotgun/agents/tools/markdown_tools/utils.py +453 -0
  39. shotgun/agents/tools/registry.py +41 -0
  40. shotgun/agents/tools/web_search/__init__.py +1 -2
  41. shotgun/agents/tools/web_search/gemini.py +1 -3
  42. shotgun/agents/tools/web_search/openai.py +42 -23
  43. shotgun/attachments/__init__.py +41 -0
  44. shotgun/attachments/errors.py +60 -0
  45. shotgun/attachments/models.py +107 -0
  46. shotgun/attachments/parser.py +257 -0
  47. shotgun/attachments/processor.py +193 -0
  48. shotgun/cli/clear.py +2 -2
  49. shotgun/cli/codebase/commands.py +181 -65
  50. shotgun/cli/compact.py +2 -2
  51. shotgun/cli/context.py +2 -2
  52. shotgun/cli/run.py +90 -0
  53. shotgun/cli/spec/backup.py +2 -1
  54. shotgun/cli/spec/commands.py +2 -0
  55. shotgun/cli/spec/models.py +18 -0
  56. shotgun/cli/spec/pull_service.py +122 -68
  57. shotgun/codebase/__init__.py +2 -0
  58. shotgun/codebase/benchmarks/__init__.py +35 -0
  59. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  60. shotgun/codebase/benchmarks/exporters.py +119 -0
  61. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  62. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  63. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  64. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  65. shotgun/codebase/benchmarks/models.py +129 -0
  66. shotgun/codebase/core/__init__.py +4 -0
  67. shotgun/codebase/core/call_resolution.py +91 -0
  68. shotgun/codebase/core/change_detector.py +11 -6
  69. shotgun/codebase/core/errors.py +159 -0
  70. shotgun/codebase/core/extractors/__init__.py +23 -0
  71. shotgun/codebase/core/extractors/base.py +138 -0
  72. shotgun/codebase/core/extractors/factory.py +63 -0
  73. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  74. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  75. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  76. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  77. shotgun/codebase/core/extractors/protocol.py +109 -0
  78. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  79. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  80. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  81. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  82. shotgun/codebase/core/extractors/types.py +15 -0
  83. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  84. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  85. shotgun/codebase/core/gitignore.py +252 -0
  86. shotgun/codebase/core/ingestor.py +644 -354
  87. shotgun/codebase/core/kuzu_compat.py +119 -0
  88. shotgun/codebase/core/language_config.py +239 -0
  89. shotgun/codebase/core/manager.py +256 -46
  90. shotgun/codebase/core/metrics_collector.py +310 -0
  91. shotgun/codebase/core/metrics_types.py +347 -0
  92. shotgun/codebase/core/parallel_executor.py +424 -0
  93. shotgun/codebase/core/work_distributor.py +254 -0
  94. shotgun/codebase/core/worker.py +768 -0
  95. shotgun/codebase/indexing_state.py +86 -0
  96. shotgun/codebase/models.py +94 -0
  97. shotgun/codebase/service.py +13 -0
  98. shotgun/exceptions.py +1 -1
  99. shotgun/main.py +2 -10
  100. shotgun/prompts/agents/export.j2 +2 -0
  101. shotgun/prompts/agents/file_read.j2 +48 -0
  102. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +20 -28
  103. shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
  104. shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
  105. shotgun/prompts/agents/partials/router_delegation_mode.j2 +35 -0
  106. shotgun/prompts/agents/plan.j2 +43 -1
  107. shotgun/prompts/agents/research.j2 +75 -20
  108. shotgun/prompts/agents/router.j2 +713 -0
  109. shotgun/prompts/agents/specify.j2 +94 -4
  110. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  111. shotgun/prompts/agents/state/system_state.j2 +24 -15
  112. shotgun/prompts/agents/tasks.j2 +77 -23
  113. shotgun/settings.py +44 -0
  114. shotgun/shotgun_web/shared_specs/upload_pipeline.py +38 -0
  115. shotgun/tui/app.py +90 -23
  116. shotgun/tui/commands/__init__.py +9 -1
  117. shotgun/tui/components/attachment_bar.py +87 -0
  118. shotgun/tui/components/mode_indicator.py +120 -25
  119. shotgun/tui/components/prompt_input.py +23 -28
  120. shotgun/tui/components/status_bar.py +5 -4
  121. shotgun/tui/dependencies.py +58 -8
  122. shotgun/tui/protocols.py +37 -0
  123. shotgun/tui/screens/chat/chat.tcss +24 -1
  124. shotgun/tui/screens/chat/chat_screen.py +1374 -211
  125. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  126. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  127. shotgun/tui/screens/chat_screen/command_providers.py +0 -97
  128. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  129. shotgun/tui/screens/chat_screen/history/chat_history.py +49 -6
  130. shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
  131. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  132. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  133. shotgun/tui/screens/chat_screen/messages.py +219 -0
  134. shotgun/tui/screens/database_locked_dialog.py +219 -0
  135. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  136. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  137. shotgun/tui/screens/model_picker.py +14 -9
  138. shotgun/tui/screens/models.py +11 -0
  139. shotgun/tui/screens/shotgun_auth.py +50 -0
  140. shotgun/tui/screens/spec_pull.py +2 -0
  141. shotgun/tui/state/processing_state.py +19 -0
  142. shotgun/tui/utils/mode_progress.py +20 -86
  143. shotgun/tui/widgets/__init__.py +2 -1
  144. shotgun/tui/widgets/approval_widget.py +152 -0
  145. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  146. shotgun/tui/widgets/plan_panel.py +129 -0
  147. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  148. shotgun/tui/widgets/widget_coordinator.py +18 -0
  149. shotgun/utils/file_system_utils.py +4 -1
  150. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/METADATA +88 -34
  151. shotgun_sh-0.6.1.dev1.dist-info/RECORD +292 -0
  152. shotgun/cli/export.py +0 -81
  153. shotgun/cli/plan.py +0 -73
  154. shotgun/cli/research.py +0 -93
  155. shotgun/cli/specify.py +0 -70
  156. shotgun/cli/tasks.py +0 -78
  157. shotgun/tui/screens/onboarding.py +0 -580
  158. shotgun_sh-0.2.29.dev2.dist-info/RECORD +0 -229
  159. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/WHEEL +0 -0
  160. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/entry_points.txt +0 -0
  161. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.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 OpenAIChatModel
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.GPT_5_1
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.CLAUDE_HAIKU_4_5
54
+ return ModelName.CLAUDE_SONNET_4_5
55
55
  if _get_api_key(config.openai.api_key):
56
- return ModelName.GPT_5_1
56
+ return ModelName.GPT_5_2
57
57
  if _get_api_key(config.google.api_key):
58
- return ModelName.GEMINI_2_5_PRO
58
+ return ModelName.GEMINI_3_PRO_PREVIEW
59
59
 
60
60
  # Fallback: system-wide default
61
- return ModelName.CLAUDE_HAIKU_4_5
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] = OpenAIChatModel(
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] = OpenAIChatModel(
142
+ _model_cache[cache_key] = OpenAIResponsesModel(
143
143
  model_name,
144
144
  provider=openai_provider,
145
145
  settings=ModelSettings(max_tokens=max_tokens),
@@ -257,25 +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("OpenAI API key not configured. Set via config.")
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.1
265
- model_name = requested_model if requested_model else ModelName.GPT_5_1
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.GPT_5_1
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
- ModelName.GPT_5,
275
- ModelName.GPT_5_MINI,
276
276
  ModelName.GPT_5_1,
277
- ModelName.GPT_5_1_CODEX,
278
- ModelName.GPT_5_1_CODEX_MINI,
277
+ ModelName.GPT_5_2,
279
278
  ):
280
279
  # Check if streaming capability has been tested
281
280
  streaming_capability = config.openai.supports_streaming
@@ -309,15 +308,17 @@ async def get_provider_model(
309
308
  )
310
309
 
311
310
  elif provider_enum == ProviderType.ANTHROPIC:
312
- api_key = _get_api_key(config.anthropic.api_key)
311
+ api_key = _get_api_key(config.anthropic.api_key, "ANTHROPIC_API_KEY")
313
312
  if not api_key:
314
- raise ValueError("Anthropic API key not configured. Set via config.")
313
+ raise ValueError(
314
+ "Anthropic API key not configured. Set via config or ANTHROPIC_API_KEY env var."
315
+ )
315
316
 
316
- # Use requested model or default to claude-haiku-4-5
317
- model_name = requested_model if requested_model else ModelName.CLAUDE_HAIKU_4_5
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
318
319
  # Gracefully fall back if model doesn't exist
319
320
  if model_name not in MODEL_SPECS:
320
- model_name = ModelName.CLAUDE_HAIKU_4_5
321
+ model_name = ModelName.CLAUDE_SONNET_4_5
321
322
  spec = MODEL_SPECS[model_name]
322
323
 
323
324
  # Create fully configured ModelConfig
@@ -331,15 +332,19 @@ async def get_provider_model(
331
332
  )
332
333
 
333
334
  elif provider_enum == ProviderType.GOOGLE:
334
- api_key = _get_api_key(config.google.api_key)
335
+ api_key = _get_api_key(config.google.api_key, "GEMINI_API_KEY")
335
336
  if not api_key:
336
- raise ValueError("Gemini API key not configured. Set via config.")
337
+ raise ValueError(
338
+ "Gemini API key not configured. Set via config or GEMINI_API_KEY env var."
339
+ )
337
340
 
338
- # Use requested model or default to gemini-2.5-pro
339
- model_name = requested_model if requested_model else ModelName.GEMINI_2_5_PRO
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
+ )
340
345
  # Gracefully fall back if model doesn't exist
341
346
  if model_name not in MODEL_SPECS:
342
- model_name = ModelName.GEMINI_2_5_PRO
347
+ model_name = ModelName.GEMINI_3_PRO_PREVIEW
343
348
  spec = MODEL_SPECS[model_name]
344
349
 
345
350
  # Create fully configured ModelConfig
@@ -375,16 +380,26 @@ def _has_provider_key(config: "ShotgunConfig", provider: ProviderType) -> bool:
375
380
  return False
376
381
 
377
382
 
378
- def _get_api_key(config_key: SecretStr | None) -> str | None:
379
- """Get API key from config.
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.
380
387
 
381
388
  Args:
382
389
  config_key: API key from configuration
390
+ env_var_name: Optional environment variable name to check as fallback
383
391
 
384
392
  Returns:
385
393
  API key string or None
386
394
  """
395
+ # First check config
387
396
  if config_key is not None:
388
397
  return config_key.get_secret_value()
389
398
 
399
+ # Fallback to environment variable
400
+ if env_var_name:
401
+ import os
402
+
403
+ return os.environ.get(env_var_name)
404
+
390
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
- # Pattern for parsing file_read output (codebase files)
47
- # Format: **File**: `path`\n**Size**: N bytes\n[optional encoding]\n\n**Content**:\n```lang\ncontent```
48
- CODEBASE_FILE_PATTERN = re.compile(
49
- r"\*\*File\*\*:\s*`([^`]+)`\s*\n" # File path
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
- def _parse_codebase_file_content(
60
- content: str,
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
- Tuple of (file_path, size_bytes, language, actual_content) or None if not parseable
59
+ The file path or None if format doesn't match
69
60
  """
70
- match = CODEBASE_FILE_PATTERN.search(content)
71
- if not match:
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
- file_path = match.group(1)
75
- size_bytes = int(match.group(2))
76
- language = match.group(3) or ""
77
- actual_content = match.group(4)
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
- return file_path, size_bytes, language, actual_content
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(modified_messages) - retention_window)
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(modified_messages):
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
- parsed = _parse_codebase_file_content(content)
163
- if parsed:
164
- file_path, size_bytes, language, actual_content = parsed
165
- # Only replace if actual content is substantial
166
- if len(actual_content) >= MIN_CONTENT_LENGTH:
167
- replacement = _create_codebase_placeholder(
168
- file_path, size_bytes, language
169
- )
170
- logger.debug(
171
- f"Deduplicating codebase file: {file_path} "
172
- f"({size_bytes} bytes)"
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
- # Replace message with new parts if modified
217
+ # Only create a new message if parts were actually modified
207
218
  if message_modified:
208
- modified_messages[msg_idx] = ModelRequest(parts=new_parts)
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
- if hasattr(part, "content") and isinstance(part.content, str):
59
- # Only add non-empty content
60
- if part.content.strip():
61
- text_parts.append(part.content)
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 non-text parts (tool calls, etc.)
104
+ # Handle other parts (tool calls, etc.) - but check for binary
64
105
  part_str = str(part)
65
- if part_str.strip():
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[Agent[AgentDeps, AgentResponse], AgentDeps]:
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: Agent[AgentDeps, AgentResponse],
53
- instruction: str,
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 instruction.
57
+ """Export artifacts based on the given prompt.
58
58
 
59
59
  Args:
60
60
  agent: The configured export agent
61
- instruction: The export instruction
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 instruction: %s", instruction)
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=full_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