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.
Files changed (135) hide show
  1. shotgun/agents/agent_manager.py +307 -8
  2. shotgun/agents/cancellation.py +103 -0
  3. shotgun/agents/common.py +12 -0
  4. shotgun/agents/config/README.md +0 -1
  5. shotgun/agents/config/manager.py +10 -7
  6. shotgun/agents/config/models.py +5 -27
  7. shotgun/agents/config/provider.py +44 -27
  8. shotgun/agents/conversation/history/token_counting/base.py +51 -9
  9. shotgun/agents/file_read.py +176 -0
  10. shotgun/agents/messages.py +15 -3
  11. shotgun/agents/models.py +24 -1
  12. shotgun/agents/router/models.py +8 -0
  13. shotgun/agents/router/tools/delegation_tools.py +55 -1
  14. shotgun/agents/router/tools/plan_tools.py +88 -7
  15. shotgun/agents/runner.py +17 -2
  16. shotgun/agents/tools/__init__.py +8 -0
  17. shotgun/agents/tools/codebase/directory_lister.py +27 -39
  18. shotgun/agents/tools/codebase/file_read.py +26 -35
  19. shotgun/agents/tools/codebase/query_graph.py +9 -0
  20. shotgun/agents/tools/codebase/retrieve_code.py +9 -0
  21. shotgun/agents/tools/file_management.py +32 -2
  22. shotgun/agents/tools/file_read_tools/__init__.py +7 -0
  23. shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
  24. shotgun/agents/tools/markdown_tools/__init__.py +62 -0
  25. shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
  26. shotgun/agents/tools/markdown_tools/models.py +86 -0
  27. shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
  28. shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
  29. shotgun/agents/tools/markdown_tools/utils.py +453 -0
  30. shotgun/agents/tools/registry.py +44 -6
  31. shotgun/agents/tools/web_search/openai.py +42 -23
  32. shotgun/attachments/__init__.py +41 -0
  33. shotgun/attachments/errors.py +60 -0
  34. shotgun/attachments/models.py +107 -0
  35. shotgun/attachments/parser.py +257 -0
  36. shotgun/attachments/processor.py +193 -0
  37. shotgun/build_constants.py +4 -7
  38. shotgun/cli/clear.py +2 -2
  39. shotgun/cli/codebase/commands.py +181 -65
  40. shotgun/cli/compact.py +2 -2
  41. shotgun/cli/context.py +2 -2
  42. shotgun/cli/error_handler.py +2 -2
  43. shotgun/cli/run.py +90 -0
  44. shotgun/cli/spec/backup.py +2 -1
  45. shotgun/codebase/__init__.py +2 -0
  46. shotgun/codebase/benchmarks/__init__.py +35 -0
  47. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  48. shotgun/codebase/benchmarks/exporters.py +119 -0
  49. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  50. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  51. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  52. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  53. shotgun/codebase/benchmarks/models.py +129 -0
  54. shotgun/codebase/core/__init__.py +4 -0
  55. shotgun/codebase/core/call_resolution.py +91 -0
  56. shotgun/codebase/core/change_detector.py +11 -6
  57. shotgun/codebase/core/errors.py +159 -0
  58. shotgun/codebase/core/extractors/__init__.py +23 -0
  59. shotgun/codebase/core/extractors/base.py +138 -0
  60. shotgun/codebase/core/extractors/factory.py +63 -0
  61. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  62. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  63. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  64. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  65. shotgun/codebase/core/extractors/protocol.py +109 -0
  66. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  67. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  68. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  69. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  70. shotgun/codebase/core/extractors/types.py +15 -0
  71. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  72. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  73. shotgun/codebase/core/gitignore.py +252 -0
  74. shotgun/codebase/core/ingestor.py +644 -354
  75. shotgun/codebase/core/kuzu_compat.py +119 -0
  76. shotgun/codebase/core/language_config.py +239 -0
  77. shotgun/codebase/core/manager.py +256 -46
  78. shotgun/codebase/core/metrics_collector.py +310 -0
  79. shotgun/codebase/core/metrics_types.py +347 -0
  80. shotgun/codebase/core/parallel_executor.py +424 -0
  81. shotgun/codebase/core/work_distributor.py +254 -0
  82. shotgun/codebase/core/worker.py +768 -0
  83. shotgun/codebase/indexing_state.py +86 -0
  84. shotgun/codebase/models.py +94 -0
  85. shotgun/codebase/service.py +13 -0
  86. shotgun/exceptions.py +9 -9
  87. shotgun/main.py +3 -16
  88. shotgun/posthog_telemetry.py +165 -24
  89. shotgun/prompts/agents/file_read.j2 +48 -0
  90. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -47
  91. shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
  92. shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
  93. shotgun/prompts/agents/partials/router_delegation_mode.j2 +21 -22
  94. shotgun/prompts/agents/plan.j2 +14 -0
  95. shotgun/prompts/agents/router.j2 +531 -258
  96. shotgun/prompts/agents/specify.j2 +14 -0
  97. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  98. shotgun/prompts/agents/state/system_state.j2 +13 -11
  99. shotgun/prompts/agents/tasks.j2 +14 -0
  100. shotgun/settings.py +49 -10
  101. shotgun/tui/app.py +149 -18
  102. shotgun/tui/commands/__init__.py +9 -1
  103. shotgun/tui/components/attachment_bar.py +87 -0
  104. shotgun/tui/components/prompt_input.py +25 -28
  105. shotgun/tui/components/status_bar.py +14 -7
  106. shotgun/tui/dependencies.py +3 -8
  107. shotgun/tui/protocols.py +18 -0
  108. shotgun/tui/screens/chat/chat.tcss +15 -0
  109. shotgun/tui/screens/chat/chat_screen.py +766 -235
  110. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  111. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  112. shotgun/tui/screens/chat_screen/command_providers.py +0 -10
  113. shotgun/tui/screens/chat_screen/history/chat_history.py +54 -14
  114. shotgun/tui/screens/chat_screen/history/formatters.py +22 -0
  115. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  116. shotgun/tui/screens/database_locked_dialog.py +219 -0
  117. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  118. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  119. shotgun/tui/screens/model_picker.py +1 -3
  120. shotgun/tui/screens/models.py +11 -0
  121. shotgun/tui/state/processing_state.py +19 -0
  122. shotgun/tui/widgets/widget_coordinator.py +18 -0
  123. shotgun/utils/file_system_utils.py +4 -1
  124. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +87 -34
  125. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/RECORD +128 -79
  126. shotgun/cli/export.py +0 -81
  127. shotgun/cli/plan.py +0 -73
  128. shotgun/cli/research.py +0 -93
  129. shotgun/cli/specify.py +0 -70
  130. shotgun/cli/tasks.py +0 -78
  131. shotgun/sentry_telemetry.py +0 -232
  132. shotgun/tui/screens/onboarding.py +0 -584
  133. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
  134. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
  135. {shotgun_sh-0.4.0.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/licenses/LICENSE +0 -0
@@ -26,10 +26,8 @@ class ModelName(StrEnum):
26
26
  """Available AI model names."""
27
27
 
28
28
  GPT_5_1 = "gpt-5.1"
29
- GPT_5_1_CODEX = "gpt-5.1-codex"
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.GPT_5_1_CODEX: ModelSpec(
114
- name=ModelName.GPT_5_1_CODEX,
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.1-codex",
119
- short_name="GPT-5.1 Codex",
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 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,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("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
276
  ModelName.GPT_5_1,
275
- ModelName.GPT_5_1_CODEX,
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("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
+ )
313
316
 
314
- # Use requested model or default to claude-haiku-4-5
315
- 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
316
319
  # Gracefully fall back if model doesn't exist
317
320
  if model_name not in MODEL_SPECS:
318
- model_name = ModelName.CLAUDE_HAIKU_4_5
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("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
+ )
335
340
 
336
- # Use requested model or default to gemini-2.5-pro
337
- 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
+ )
338
345
  # Gracefully fall back if model doesn't exist
339
346
  if model_name not in MODEL_SPECS:
340
- model_name = ModelName.GEMINI_2_5_PRO
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(config_key: SecretStr | None) -> str | None:
377
- """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.
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
- 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
@@ -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
@@ -1,12 +1,13 @@
1
1
  """Custom message types for Shotgun agents.
2
2
 
3
- This module defines specialized SystemPromptPart subclasses to distinguish
4
- between different types of system prompts in the agent pipeline.
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:
@@ -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