shotgun-sh 0.1.9__py3-none-any.whl → 0.2.11__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 (150) hide show
  1. shotgun/agents/agent_manager.py +761 -52
  2. shotgun/agents/common.py +80 -75
  3. shotgun/agents/config/constants.py +21 -10
  4. shotgun/agents/config/manager.py +322 -97
  5. shotgun/agents/config/models.py +114 -84
  6. shotgun/agents/config/provider.py +232 -88
  7. shotgun/agents/context_analyzer/__init__.py +28 -0
  8. shotgun/agents/context_analyzer/analyzer.py +471 -0
  9. shotgun/agents/context_analyzer/constants.py +9 -0
  10. shotgun/agents/context_analyzer/formatter.py +115 -0
  11. shotgun/agents/context_analyzer/models.py +212 -0
  12. shotgun/agents/conversation_history.py +125 -2
  13. shotgun/agents/conversation_manager.py +57 -19
  14. shotgun/agents/export.py +6 -7
  15. shotgun/agents/history/compaction.py +23 -3
  16. shotgun/agents/history/context_extraction.py +93 -6
  17. shotgun/agents/history/history_processors.py +179 -11
  18. shotgun/agents/history/token_counting/__init__.py +31 -0
  19. shotgun/agents/history/token_counting/anthropic.py +127 -0
  20. shotgun/agents/history/token_counting/base.py +78 -0
  21. shotgun/agents/history/token_counting/openai.py +90 -0
  22. shotgun/agents/history/token_counting/sentencepiece_counter.py +127 -0
  23. shotgun/agents/history/token_counting/tokenizer_cache.py +92 -0
  24. shotgun/agents/history/token_counting/utils.py +144 -0
  25. shotgun/agents/history/token_estimation.py +12 -12
  26. shotgun/agents/llm.py +62 -0
  27. shotgun/agents/models.py +59 -4
  28. shotgun/agents/plan.py +6 -7
  29. shotgun/agents/research.py +7 -8
  30. shotgun/agents/specify.py +6 -7
  31. shotgun/agents/tasks.py +6 -7
  32. shotgun/agents/tools/__init__.py +0 -2
  33. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  34. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  35. shotgun/agents/tools/codebase/file_read.py +11 -2
  36. shotgun/agents/tools/codebase/query_graph.py +6 -0
  37. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  38. shotgun/agents/tools/file_management.py +82 -16
  39. shotgun/agents/tools/registry.py +217 -0
  40. shotgun/agents/tools/web_search/__init__.py +55 -16
  41. shotgun/agents/tools/web_search/anthropic.py +76 -51
  42. shotgun/agents/tools/web_search/gemini.py +50 -27
  43. shotgun/agents/tools/web_search/openai.py +26 -17
  44. shotgun/agents/tools/web_search/utils.py +2 -2
  45. shotgun/agents/usage_manager.py +164 -0
  46. shotgun/api_endpoints.py +15 -0
  47. shotgun/cli/clear.py +53 -0
  48. shotgun/cli/codebase/commands.py +71 -2
  49. shotgun/cli/compact.py +186 -0
  50. shotgun/cli/config.py +41 -67
  51. shotgun/cli/context.py +111 -0
  52. shotgun/cli/export.py +1 -1
  53. shotgun/cli/feedback.py +50 -0
  54. shotgun/cli/models.py +3 -2
  55. shotgun/cli/plan.py +1 -1
  56. shotgun/cli/research.py +1 -1
  57. shotgun/cli/specify.py +1 -1
  58. shotgun/cli/tasks.py +1 -1
  59. shotgun/cli/update.py +18 -5
  60. shotgun/codebase/core/change_detector.py +5 -3
  61. shotgun/codebase/core/code_retrieval.py +4 -2
  62. shotgun/codebase/core/ingestor.py +169 -19
  63. shotgun/codebase/core/manager.py +177 -13
  64. shotgun/codebase/core/nl_query.py +1 -1
  65. shotgun/codebase/models.py +28 -3
  66. shotgun/codebase/service.py +14 -2
  67. shotgun/exceptions.py +32 -0
  68. shotgun/llm_proxy/__init__.py +19 -0
  69. shotgun/llm_proxy/clients.py +44 -0
  70. shotgun/llm_proxy/constants.py +15 -0
  71. shotgun/logging_config.py +18 -27
  72. shotgun/main.py +91 -4
  73. shotgun/posthog_telemetry.py +87 -40
  74. shotgun/prompts/agents/export.j2 +18 -1
  75. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
  76. shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
  77. shotgun/prompts/agents/plan.j2 +1 -1
  78. shotgun/prompts/agents/research.j2 +1 -1
  79. shotgun/prompts/agents/specify.j2 +270 -3
  80. shotgun/prompts/agents/state/system_state.j2 +4 -0
  81. shotgun/prompts/agents/tasks.j2 +1 -1
  82. shotgun/prompts/codebase/partials/cypher_rules.j2 +13 -0
  83. shotgun/prompts/loader.py +2 -2
  84. shotgun/prompts/tools/web_search.j2 +14 -0
  85. shotgun/sdk/codebase.py +60 -2
  86. shotgun/sentry_telemetry.py +28 -21
  87. shotgun/settings.py +238 -0
  88. shotgun/shotgun_web/__init__.py +19 -0
  89. shotgun/shotgun_web/client.py +138 -0
  90. shotgun/shotgun_web/constants.py +21 -0
  91. shotgun/shotgun_web/models.py +47 -0
  92. shotgun/telemetry.py +24 -36
  93. shotgun/tui/app.py +275 -23
  94. shotgun/tui/commands/__init__.py +1 -1
  95. shotgun/tui/components/context_indicator.py +179 -0
  96. shotgun/tui/components/mode_indicator.py +70 -0
  97. shotgun/tui/components/status_bar.py +48 -0
  98. shotgun/tui/components/vertical_tail.py +6 -0
  99. shotgun/tui/containers.py +91 -0
  100. shotgun/tui/dependencies.py +39 -0
  101. shotgun/tui/filtered_codebase_service.py +46 -0
  102. shotgun/tui/protocols.py +45 -0
  103. shotgun/tui/screens/chat/__init__.py +5 -0
  104. shotgun/tui/screens/chat/chat.tcss +54 -0
  105. shotgun/tui/screens/chat/chat_screen.py +1234 -0
  106. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  107. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  108. shotgun/tui/screens/chat/help_text.py +40 -0
  109. shotgun/tui/screens/chat/prompt_history.py +48 -0
  110. shotgun/tui/screens/chat.tcss +11 -0
  111. shotgun/tui/screens/chat_screen/command_providers.py +226 -11
  112. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  113. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  114. shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
  115. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  116. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  117. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  118. shotgun/tui/screens/confirmation_dialog.py +151 -0
  119. shotgun/tui/screens/feedback.py +193 -0
  120. shotgun/tui/screens/github_issue.py +102 -0
  121. shotgun/tui/screens/model_picker.py +352 -0
  122. shotgun/tui/screens/onboarding.py +431 -0
  123. shotgun/tui/screens/pipx_migration.py +153 -0
  124. shotgun/tui/screens/provider_config.py +156 -39
  125. shotgun/tui/screens/shotgun_auth.py +295 -0
  126. shotgun/tui/screens/welcome.py +198 -0
  127. shotgun/tui/services/__init__.py +5 -0
  128. shotgun/tui/services/conversation_service.py +184 -0
  129. shotgun/tui/state/__init__.py +7 -0
  130. shotgun/tui/state/processing_state.py +185 -0
  131. shotgun/tui/utils/mode_progress.py +14 -7
  132. shotgun/tui/widgets/__init__.py +5 -0
  133. shotgun/tui/widgets/widget_coordinator.py +262 -0
  134. shotgun/utils/datetime_utils.py +77 -0
  135. shotgun/utils/env_utils.py +13 -0
  136. shotgun/utils/file_system_utils.py +22 -2
  137. shotgun/utils/marketing.py +110 -0
  138. shotgun/utils/source_detection.py +16 -0
  139. shotgun/utils/update_checker.py +73 -21
  140. shotgun_sh-0.2.11.dist-info/METADATA +130 -0
  141. shotgun_sh-0.2.11.dist-info/RECORD +194 -0
  142. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/entry_points.txt +1 -0
  143. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/licenses/LICENSE +1 -1
  144. shotgun/agents/history/token_counting.py +0 -429
  145. shotgun/agents/tools/user_interaction.py +0 -37
  146. shotgun/tui/screens/chat.py +0 -818
  147. shotgun/tui/screens/chat_screen/history.py +0 -222
  148. shotgun_sh-0.1.9.dist-info/METADATA +0 -466
  149. shotgun_sh-0.1.9.dist-info/RECORD +0 -131
  150. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/WHEEL +0 -0
@@ -1,17 +1,26 @@
1
1
  """Agent manager for coordinating multiple AI agents with shared message history."""
2
2
 
3
+ import json
3
4
  import logging
4
5
  from collections.abc import AsyncIterable, Sequence
5
6
  from dataclasses import dataclass, field, is_dataclass, replace
7
+ from pathlib import Path
6
8
  from typing import TYPE_CHECKING, Any, cast
7
9
 
10
+ import logfire
11
+ from tenacity import (
12
+ before_sleep_log,
13
+ retry,
14
+ retry_if_exception,
15
+ stop_after_attempt,
16
+ wait_exponential,
17
+ )
18
+
8
19
  if TYPE_CHECKING:
9
20
  from shotgun.agents.conversation_history import ConversationState
10
21
 
11
22
  from pydantic_ai import (
12
23
  Agent,
13
- DeferredToolRequests,
14
- DeferredToolResults,
15
24
  RunContext,
16
25
  UsageLimits,
17
26
  )
@@ -31,13 +40,33 @@ from pydantic_ai.messages import (
31
40
  SystemPromptPart,
32
41
  ToolCallPart,
33
42
  ToolCallPartDelta,
43
+ UserPromptPart,
34
44
  )
35
45
  from textual.message import Message
36
46
  from textual.widget import Widget
37
47
 
38
48
  from shotgun.agents.common import add_system_prompt_message, add_system_status_message
39
- from shotgun.agents.models import AgentType, FileOperation
49
+ from shotgun.agents.config.models import (
50
+ KeyProvider,
51
+ ModelConfig,
52
+ ModelName,
53
+ ProviderType,
54
+ )
55
+ from shotgun.agents.context_analyzer import (
56
+ ContextAnalysis,
57
+ ContextAnalyzer,
58
+ ContextCompositionTelemetry,
59
+ ContextFormatter,
60
+ )
61
+ from shotgun.agents.models import (
62
+ AgentResponse,
63
+ AgentType,
64
+ FileOperation,
65
+ FileOperationTracker,
66
+ )
67
+ from shotgun.posthog_telemetry import track_event
40
68
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage
69
+ from shotgun.utils.source_detection import detect_source
41
70
 
42
71
  from .export import create_export_agent
43
72
  from .history.compaction import apply_persistent_compaction
@@ -51,6 +80,35 @@ from .tasks import create_tasks_agent
51
80
  logger = logging.getLogger(__name__)
52
81
 
53
82
 
83
+ def _is_retryable_error(exception: BaseException) -> bool:
84
+ """Check if exception should trigger a retry.
85
+
86
+ Args:
87
+ exception: The exception to check.
88
+
89
+ Returns:
90
+ True if the exception is a transient error that should be retried.
91
+ """
92
+ # ValueError for truncated/incomplete JSON
93
+ if isinstance(exception, ValueError):
94
+ error_str = str(exception)
95
+ return "EOF while parsing" in error_str or (
96
+ "JSON" in error_str and "parsing" in error_str
97
+ )
98
+
99
+ # API errors (overload, rate limits)
100
+ exception_name = type(exception).__name__
101
+ if "APIStatusError" in exception_name:
102
+ error_str = str(exception)
103
+ return "overload" in error_str.lower() or "rate" in error_str.lower()
104
+
105
+ # Network errors
106
+ if "ConnectionError" in exception_name or "TimeoutError" in exception_name:
107
+ return True
108
+
109
+ return False
110
+
111
+
54
112
  class MessageHistoryUpdated(Message):
55
113
  """Event posted when the message history is updated."""
56
114
 
@@ -89,6 +147,63 @@ class PartialResponseMessage(Message):
89
147
  self.is_last = is_last
90
148
 
91
149
 
150
+ class ClarifyingQuestionsMessage(Message):
151
+ """Event posted when agent returns clarifying questions."""
152
+
153
+ def __init__(
154
+ self,
155
+ questions: list[str],
156
+ response_text: str,
157
+ ) -> None:
158
+ """Initialize the clarifying questions message.
159
+
160
+ Args:
161
+ questions: List of clarifying questions from the agent
162
+ response_text: The agent's response text before asking questions
163
+ """
164
+ super().__init__()
165
+ self.questions = questions
166
+ self.response_text = response_text
167
+
168
+
169
+ class CompactionStartedMessage(Message):
170
+ """Event posted when conversation compaction starts."""
171
+
172
+
173
+ class CompactionCompletedMessage(Message):
174
+ """Event posted when conversation compaction completes."""
175
+
176
+
177
+ class AgentStreamingStarted(Message):
178
+ """Event posted when agent starts streaming responses."""
179
+
180
+
181
+ class AgentStreamingCompleted(Message):
182
+ """Event posted when agent finishes streaming responses."""
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class ModelConfigUpdated:
187
+ """Data returned when AI model configuration changes.
188
+
189
+ Used as a return value from ModelPickerScreen to communicate model
190
+ selection back to the calling screen.
191
+
192
+ Attributes:
193
+ old_model: Previous model name (None if first selection)
194
+ new_model: New model name
195
+ provider: LLM provider (OpenAI, Anthropic, Google)
196
+ key_provider: Authentication method (BYOK or Shotgun)
197
+ model_config: Complete model configuration
198
+ """
199
+
200
+ old_model: ModelName | None
201
+ new_model: ModelName
202
+ provider: ProviderType
203
+ key_provider: KeyProvider
204
+ model_config: ModelConfig
205
+
206
+
92
207
  @dataclass(slots=True)
93
208
  class _PartialStreamState:
94
209
  """Tracks streamed messages while handling a single agent run."""
@@ -113,14 +228,14 @@ class AgentManager(Widget):
113
228
  super().__init__()
114
229
  self.display = False
115
230
 
231
+ if deps is None:
232
+ raise ValueError("AgentDeps must be provided to AgentManager")
233
+
116
234
  # Use provided deps or create default with interactive mode
117
235
  self.deps = deps
118
236
 
119
- if self.deps is None:
120
- raise ValueError("AgentDeps must be provided to AgentManager")
121
-
122
237
  # Create AgentRuntimeOptions from deps for agent creation
123
- agent_runtime_options = AgentRuntimeOptions(
238
+ self._agent_runtime_options = AgentRuntimeOptions(
124
239
  interactive_mode=self.deps.interactive_mode,
125
240
  working_directory=self.deps.working_directory,
126
241
  is_tui_context=self.deps.is_tui_context,
@@ -129,22 +244,18 @@ class AgentManager(Widget):
129
244
  tasks=self.deps.tasks,
130
245
  )
131
246
 
132
- # Initialize all agents and store their specific deps
133
- self.research_agent, self.research_deps = create_research_agent(
134
- agent_runtime_options=agent_runtime_options
135
- )
136
- self.plan_agent, self.plan_deps = create_plan_agent(
137
- agent_runtime_options=agent_runtime_options
138
- )
139
- self.tasks_agent, self.tasks_deps = create_tasks_agent(
140
- agent_runtime_options=agent_runtime_options
141
- )
142
- self.specify_agent, self.specify_deps = create_specify_agent(
143
- agent_runtime_options=agent_runtime_options
144
- )
145
- self.export_agent, self.export_deps = create_export_agent(
146
- agent_runtime_options=agent_runtime_options
147
- )
247
+ # Lazy initialization - agents created on first access
248
+ self._research_agent: Agent[AgentDeps, AgentResponse] | None = None
249
+ self._research_deps: AgentDeps | None = None
250
+ self._plan_agent: Agent[AgentDeps, AgentResponse] | None = None
251
+ self._plan_deps: AgentDeps | None = None
252
+ self._tasks_agent: Agent[AgentDeps, AgentResponse] | None = None
253
+ self._tasks_deps: AgentDeps | None = None
254
+ self._specify_agent: Agent[AgentDeps, AgentResponse] | None = None
255
+ self._specify_deps: AgentDeps | None = None
256
+ self._export_agent: Agent[AgentDeps, AgentResponse] | None = None
257
+ self._export_deps: AgentDeps | None = None
258
+ self._agents_initialized = False
148
259
 
149
260
  # Track current active agent
150
261
  self._current_agent_type: AgentType = initial_type
@@ -155,8 +266,125 @@ class AgentManager(Widget):
155
266
  self.recently_change_files: list[FileOperation] = []
156
267
  self._stream_state: _PartialStreamState | None = None
157
268
 
269
+ # Q&A mode state for structured output questions
270
+ self._qa_questions: list[str] | None = None
271
+ self._qa_mode_active: bool = False
272
+
273
+ async def _ensure_agents_initialized(self) -> None:
274
+ """Ensure all agents are initialized (lazy initialization)."""
275
+ if self._agents_initialized:
276
+ return
277
+
278
+ # Initialize all agents asynchronously
279
+ self._research_agent, self._research_deps = await create_research_agent(
280
+ agent_runtime_options=self._agent_runtime_options
281
+ )
282
+ self._plan_agent, self._plan_deps = await create_plan_agent(
283
+ agent_runtime_options=self._agent_runtime_options
284
+ )
285
+ self._tasks_agent, self._tasks_deps = await create_tasks_agent(
286
+ agent_runtime_options=self._agent_runtime_options
287
+ )
288
+ self._specify_agent, self._specify_deps = await create_specify_agent(
289
+ agent_runtime_options=self._agent_runtime_options
290
+ )
291
+ self._export_agent, self._export_deps = await create_export_agent(
292
+ agent_runtime_options=self._agent_runtime_options
293
+ )
294
+ self._agents_initialized = True
295
+
296
+ @property
297
+ def research_agent(self) -> Agent[AgentDeps, AgentResponse]:
298
+ """Get research agent (must call _ensure_agents_initialized first)."""
299
+ if self._research_agent is None:
300
+ raise RuntimeError(
301
+ "Agents not initialized. Call _ensure_agents_initialized() first."
302
+ )
303
+ return self._research_agent
304
+
305
+ @property
306
+ def research_deps(self) -> AgentDeps:
307
+ """Get research deps (must call _ensure_agents_initialized first)."""
308
+ if self._research_deps is None:
309
+ raise RuntimeError(
310
+ "Agents not initialized. Call _ensure_agents_initialized() first."
311
+ )
312
+ return self._research_deps
313
+
314
+ @property
315
+ def plan_agent(self) -> Agent[AgentDeps, AgentResponse]:
316
+ """Get plan agent (must call _ensure_agents_initialized first)."""
317
+ if self._plan_agent is None:
318
+ raise RuntimeError(
319
+ "Agents not initialized. Call _ensure_agents_initialized() first."
320
+ )
321
+ return self._plan_agent
322
+
323
+ @property
324
+ def plan_deps(self) -> AgentDeps:
325
+ """Get plan deps (must call _ensure_agents_initialized first)."""
326
+ if self._plan_deps is None:
327
+ raise RuntimeError(
328
+ "Agents not initialized. Call _ensure_agents_initialized() first."
329
+ )
330
+ return self._plan_deps
331
+
332
+ @property
333
+ def tasks_agent(self) -> Agent[AgentDeps, AgentResponse]:
334
+ """Get tasks agent (must call _ensure_agents_initialized first)."""
335
+ if self._tasks_agent is None:
336
+ raise RuntimeError(
337
+ "Agents not initialized. Call _ensure_agents_initialized() first."
338
+ )
339
+ return self._tasks_agent
340
+
341
+ @property
342
+ def tasks_deps(self) -> AgentDeps:
343
+ """Get tasks deps (must call _ensure_agents_initialized first)."""
344
+ if self._tasks_deps is None:
345
+ raise RuntimeError(
346
+ "Agents not initialized. Call _ensure_agents_initialized() first."
347
+ )
348
+ return self._tasks_deps
349
+
350
+ @property
351
+ def specify_agent(self) -> Agent[AgentDeps, AgentResponse]:
352
+ """Get specify agent (must call _ensure_agents_initialized first)."""
353
+ if self._specify_agent is None:
354
+ raise RuntimeError(
355
+ "Agents not initialized. Call _ensure_agents_initialized() first."
356
+ )
357
+ return self._specify_agent
358
+
359
+ @property
360
+ def specify_deps(self) -> AgentDeps:
361
+ """Get specify deps (must call _ensure_agents_initialized first)."""
362
+ if self._specify_deps is None:
363
+ raise RuntimeError(
364
+ "Agents not initialized. Call _ensure_agents_initialized() first."
365
+ )
366
+ return self._specify_deps
367
+
158
368
  @property
159
- def current_agent(self) -> Agent[AgentDeps, str | DeferredToolRequests]:
369
+ def export_agent(self) -> Agent[AgentDeps, AgentResponse]:
370
+ """Get export agent (must call _ensure_agents_initialized first)."""
371
+ if self._export_agent is None:
372
+ raise RuntimeError(
373
+ "Agents not initialized. Call _ensure_agents_initialized() first."
374
+ )
375
+ return self._export_agent
376
+
377
+ @property
378
+ def export_deps(self) -> AgentDeps:
379
+ """Get export deps (must call _ensure_agents_initialized first)."""
380
+ if self._export_deps is None:
381
+ raise RuntimeError(
382
+ "Agents not initialized. Call _ensure_agents_initialized() first."
383
+ )
384
+ return self._export_deps
385
+
386
+ @property
387
+ def current_agent(self) -> Agent[AgentDeps, AgentResponse]:
160
388
  """Get the currently active agent.
161
389
 
162
390
  Returns:
@@ -164,9 +392,7 @@ class AgentManager(Widget):
164
392
  """
165
393
  return self._get_agent(self._current_agent_type)
166
394
 
167
- def _get_agent(
168
- self, agent_type: AgentType
169
- ) -> Agent[AgentDeps, str | DeferredToolRequests]:
395
+ def _get_agent(self, agent_type: AgentType) -> Agent[AgentDeps, AgentResponse]:
170
396
  """Get agent by type.
171
397
 
172
398
  Args:
@@ -243,15 +469,57 @@ class AgentManager(Widget):
243
469
  f"Invalid agent type: {agent_type}. Must be one of: {', '.join(e.value for e in AgentType)}"
244
470
  ) from None
245
471
 
472
+ @retry(
473
+ stop=stop_after_attempt(3),
474
+ wait=wait_exponential(multiplier=1, min=1, max=8),
475
+ retry=retry_if_exception(_is_retryable_error),
476
+ before_sleep=before_sleep_log(logger, logging.WARNING),
477
+ reraise=True,
478
+ )
479
+ async def _run_agent_with_retry(
480
+ self,
481
+ agent: Agent[AgentDeps, AgentResponse],
482
+ prompt: str | None,
483
+ deps: AgentDeps,
484
+ usage_limits: UsageLimits | None,
485
+ message_history: list[ModelMessage],
486
+ event_stream_handler: Any,
487
+ **kwargs: Any,
488
+ ) -> AgentRunResult[AgentResponse]:
489
+ """Run agent with automatic retry on transient errors.
490
+
491
+ Args:
492
+ agent: The agent to run.
493
+ prompt: Optional prompt to send to the agent.
494
+ deps: Agent dependencies.
495
+ usage_limits: Optional usage limits.
496
+ message_history: Message history to provide to agent.
497
+ event_stream_handler: Event handler for streaming.
498
+ **kwargs: Additional keyword arguments.
499
+
500
+ Returns:
501
+ The agent run result.
502
+
503
+ Raises:
504
+ Various exceptions if all retries fail.
505
+ """
506
+ return await agent.run(
507
+ prompt,
508
+ deps=deps,
509
+ usage_limits=usage_limits,
510
+ message_history=message_history,
511
+ event_stream_handler=event_stream_handler,
512
+ **kwargs,
513
+ )
514
+
246
515
  async def run(
247
516
  self,
248
517
  prompt: str | None = None,
249
518
  *,
250
519
  deps: AgentDeps | None = None,
251
520
  usage_limits: UsageLimits | None = None,
252
- deferred_tool_results: DeferredToolResults | None = None,
253
521
  **kwargs: Any,
254
- ) -> AgentRunResult[str | DeferredToolRequests]:
522
+ ) -> AgentRunResult[AgentResponse]:
255
523
  """Run the current agent with automatic message history management.
256
524
 
257
525
  This method wraps the agent's run method, automatically injecting the
@@ -261,12 +529,15 @@ class AgentManager(Widget):
261
529
  prompt: Optional prompt to send to the agent.
262
530
  deps: Optional dependencies override (defaults to manager's deps).
263
531
  usage_limits: Optional usage limits for the agent run.
264
- deferred_tool_results: Optional deferred tool results for continuing a conversation.
265
532
  **kwargs: Additional keyword arguments to pass to the agent.
266
533
 
267
534
  Returns:
268
535
  The agent run result.
269
536
  """
537
+ # Ensure agents are initialized before running
538
+ await self._ensure_agents_initialized()
539
+
540
+ logger.info(f"Running agent {self._current_agent_type.value}")
270
541
  # Use merged deps (shared state + agent-specific system prompt) if not provided
271
542
  if deps is None:
272
543
  deps = self._create_merged_deps(self._current_agent_type)
@@ -277,11 +548,12 @@ class AgentManager(Widget):
277
548
 
278
549
  # Clear file tracker before each run to track only this run's operations
279
550
  deps.file_tracker.clear()
280
- original_messages = self.ui_message_history.copy()
281
551
 
282
- if prompt:
283
- self.ui_message_history.append(ModelRequest.user_text_prompt(prompt))
284
- self._post_messages_updated()
552
+ # Don't manually add the user prompt - Pydantic AI will include it in result.new_messages()
553
+ # This prevents duplicates and confusion with incremental mounting
554
+
555
+ # Save current message history before the run
556
+ original_messages = self.ui_message_history.copy()
285
557
 
286
558
  # Start with persistent message history
287
559
  message_history = self.message_history
@@ -345,37 +617,312 @@ class AgentManager(Widget):
345
617
  model_name = ""
346
618
  if hasattr(deps, "llm_model") and deps.llm_model is not None:
347
619
  model_name = deps.llm_model.name
348
- is_gpt5 = ( # streaming is likely not supported for gpt5. It varies between keys.
349
- "gpt-5" in model_name.lower()
620
+
621
+ # Check if it's a Shotgun account
622
+ is_shotgun_account = (
623
+ hasattr(deps, "llm_model")
624
+ and deps.llm_model is not None
625
+ and deps.llm_model.key_provider == KeyProvider.SHOTGUN
626
+ )
627
+
628
+ # Only disable streaming for GPT-5 if NOT a Shotgun account
629
+ # Shotgun accounts support streaming for GPT-5
630
+ is_gpt5_byok = "gpt-5" in model_name.lower() and not is_shotgun_account
631
+
632
+ # Track message send event
633
+ event_name = f"message_send_{self._current_agent_type.value}"
634
+ track_event(
635
+ event_name,
636
+ {
637
+ "has_prompt": prompt is not None,
638
+ "model_name": model_name,
639
+ },
350
640
  )
351
641
 
352
642
  try:
353
- result: AgentRunResult[
354
- str | DeferredToolRequests
355
- ] = await self.current_agent.run(
356
- prompt,
643
+ result: AgentRunResult[AgentResponse] = await self._run_agent_with_retry(
644
+ agent=self.current_agent,
645
+ prompt=prompt,
357
646
  deps=deps,
358
647
  usage_limits=usage_limits,
359
648
  message_history=message_history,
360
- deferred_tool_results=deferred_tool_results,
361
- event_stream_handler=self._handle_event_stream if not is_gpt5 else None,
649
+ event_stream_handler=self._handle_event_stream
650
+ if not is_gpt5_byok
651
+ else None,
362
652
  **kwargs,
363
653
  )
654
+ except ValueError as e:
655
+ # Handle truncated/incomplete JSON in tool calls specifically
656
+ error_str = str(e)
657
+ if "EOF while parsing" in error_str or (
658
+ "JSON" in error_str and "parsing" in error_str
659
+ ):
660
+ logger.error(
661
+ "Tool call with truncated/incomplete JSON arguments detected",
662
+ extra={
663
+ "agent_mode": self._current_agent_type.value,
664
+ "model_name": model_name,
665
+ "error": error_str,
666
+ },
667
+ )
668
+ logfire.error(
669
+ "Tool call with truncated JSON arguments",
670
+ agent_mode=self._current_agent_type.value,
671
+ model_name=model_name,
672
+ error=error_str,
673
+ )
674
+ # Add helpful hint message for the user
675
+ self.ui_message_history.append(
676
+ HintMessage(
677
+ message="⚠️ The agent attempted an operation with arguments that were too large (truncated JSON). "
678
+ "Try breaking your request into smaller steps or more focused contracts."
679
+ )
680
+ )
681
+ self._post_messages_updated()
682
+ # Re-raise to maintain error visibility
683
+ raise
684
+ except Exception as e:
685
+ # Log the error with full stack trace to shotgun.log and Logfire
686
+ logger.exception(
687
+ "Agent execution failed",
688
+ extra={
689
+ "agent_mode": self._current_agent_type.value,
690
+ "model_name": model_name,
691
+ "error_type": type(e).__name__,
692
+ },
693
+ )
694
+ logfire.exception(
695
+ "Agent execution failed",
696
+ agent_mode=self._current_agent_type.value,
697
+ model_name=model_name,
698
+ error_type=type(e).__name__,
699
+ )
700
+ # Re-raise to let TUI handle user messaging
701
+ raise
364
702
  finally:
365
703
  self._stream_state = None
366
704
 
367
- self.ui_message_history = original_messages + cast(
705
+ # Agent ALWAYS returns AgentResponse with structured output
706
+ agent_response = result.output
707
+ logger.debug(
708
+ "Agent returned structured AgentResponse",
709
+ extra={
710
+ "has_response": agent_response.response is not None,
711
+ "response_length": len(agent_response.response)
712
+ if agent_response.response
713
+ else 0,
714
+ "response_preview": agent_response.response[:100] + "..."
715
+ if agent_response.response and len(agent_response.response) > 100
716
+ else agent_response.response or "(empty)",
717
+ "has_clarifying_questions": bool(agent_response.clarifying_questions),
718
+ "num_clarifying_questions": len(agent_response.clarifying_questions)
719
+ if agent_response.clarifying_questions
720
+ else 0,
721
+ },
722
+ )
723
+
724
+ # Merge agent's response messages, avoiding duplicates
725
+ # The TUI may have already added the user prompt, so check for it
726
+ new_messages = cast(
368
727
  list[ModelRequest | ModelResponse | HintMessage], result.new_messages()
369
728
  )
370
729
 
371
- # Apply compaction to persistent message history to prevent cascading growth
372
- all_messages = result.all_messages()
373
- self.message_history = await apply_persistent_compaction(all_messages, deps)
730
+ # Deduplicate: skip user prompts that are already in original_messages
731
+ deduplicated_new_messages = []
732
+ for msg in new_messages:
733
+ # Check if this is a user prompt that's already in original_messages
734
+ if isinstance(msg, ModelRequest) and any(
735
+ isinstance(part, UserPromptPart) for part in msg.parts
736
+ ):
737
+ # Check if an identical user prompt is already in original_messages
738
+ already_exists = any(
739
+ isinstance(existing, ModelRequest)
740
+ and any(isinstance(p, UserPromptPart) for p in existing.parts)
741
+ and existing.parts == msg.parts
742
+ for existing in original_messages[
743
+ -5:
744
+ ] # Check last 5 messages for efficiency
745
+ )
746
+ if already_exists:
747
+ continue # Skip this duplicate user prompt
748
+
749
+ deduplicated_new_messages.append(msg)
374
750
 
375
- # Log file operations summary if any files were modified
751
+ self.ui_message_history = original_messages + deduplicated_new_messages
752
+
753
+ # Get file operations early so we can use them for contextual messages
376
754
  file_operations = deps.file_tracker.operations.copy()
377
755
  self.recently_change_files = file_operations
378
756
 
757
+ logger.debug(
758
+ "File operations tracked",
759
+ extra={
760
+ "num_file_operations": len(file_operations),
761
+ "operation_files": [Path(op.file_path).name for op in file_operations],
762
+ },
763
+ )
764
+
765
+ # Check if there are clarifying questions
766
+ if agent_response.clarifying_questions:
767
+ logger.info(
768
+ f"Agent has {len(agent_response.clarifying_questions)} clarifying questions"
769
+ )
770
+
771
+ # Add agent's response first if present
772
+ if agent_response.response:
773
+ self.ui_message_history.append(
774
+ HintMessage(message=agent_response.response)
775
+ )
776
+
777
+ # Add file operation hints before questions (so they appear first in UI)
778
+ if file_operations:
779
+ file_hint = self._create_file_operation_hint(file_operations)
780
+ if file_hint:
781
+ self.ui_message_history.append(HintMessage(message=file_hint))
782
+
783
+ if len(agent_response.clarifying_questions) == 1:
784
+ # Single question - treat as non-blocking suggestion, DON'T enter Q&A mode
785
+ self.ui_message_history.append(
786
+ HintMessage(message=f"💡 {agent_response.clarifying_questions[0]}")
787
+ )
788
+ else:
789
+ # Multiple questions (2+) - enter Q&A mode
790
+ self._qa_questions = agent_response.clarifying_questions
791
+ self._qa_mode_active = True
792
+
793
+ # Show intro with list, then first question
794
+ questions_list_with_intro = (
795
+ f"I have {len(agent_response.clarifying_questions)} questions:\n\n"
796
+ + "\n".join(
797
+ f"{i + 1}. {q}"
798
+ for i, q in enumerate(agent_response.clarifying_questions)
799
+ )
800
+ )
801
+ self.ui_message_history.append(
802
+ HintMessage(message=questions_list_with_intro)
803
+ )
804
+ self.ui_message_history.append(
805
+ HintMessage(
806
+ message=f"**Q1:** {agent_response.clarifying_questions[0]}"
807
+ )
808
+ )
809
+
810
+ # Post event to TUI to update Q&A mode state (only for multiple questions)
811
+ self.post_message(
812
+ ClarifyingQuestionsMessage(
813
+ questions=agent_response.clarifying_questions,
814
+ response_text=agent_response.response,
815
+ )
816
+ )
817
+
818
+ # Post UI update with hint messages (file operations will be posted after compaction)
819
+ logger.debug("Posting UI update for Q&A mode with hint messages")
820
+ self._post_messages_updated([])
821
+ else:
822
+ # No clarifying questions - show the response or a default success message
823
+ if agent_response.response and agent_response.response.strip():
824
+ logger.debug(
825
+ "Adding agent response as hint",
826
+ extra={
827
+ "response_preview": agent_response.response[:100] + "..."
828
+ if len(agent_response.response) > 100
829
+ else agent_response.response,
830
+ "has_file_operations": len(file_operations) > 0,
831
+ },
832
+ )
833
+ self.ui_message_history.append(
834
+ HintMessage(message=agent_response.response)
835
+ )
836
+ else:
837
+ # Fallback: response is empty or whitespace
838
+ logger.debug(
839
+ "Agent response was empty, using fallback completion message",
840
+ extra={"has_file_operations": len(file_operations) > 0},
841
+ )
842
+ # Show contextual message based on whether files were modified
843
+ if file_operations:
844
+ self.ui_message_history.append(
845
+ HintMessage(
846
+ message="✅ Task completed - files have been modified"
847
+ )
848
+ )
849
+ else:
850
+ self.ui_message_history.append(
851
+ HintMessage(message="✅ Task completed")
852
+ )
853
+
854
+ # Post UI update immediately so user sees the response without delay
855
+ # (file operations will be posted after compaction to avoid duplicates)
856
+ logger.debug("Posting immediate UI update with hint message")
857
+ self._post_messages_updated([])
858
+
859
+ # Apply compaction to persistent message history to prevent cascading growth
860
+ all_messages = result.all_messages()
861
+ messages_before_compaction = len(all_messages)
862
+ compaction_occurred = False
863
+
864
+ try:
865
+ logger.debug(
866
+ "Starting message history compaction",
867
+ extra={"message_count": len(all_messages)},
868
+ )
869
+ # Notify UI that compaction is starting
870
+ self.post_message(CompactionStartedMessage())
871
+
872
+ self.message_history = await apply_persistent_compaction(all_messages, deps)
873
+
874
+ # Track if compaction actually modified the history
875
+ compaction_occurred = len(self.message_history) != len(all_messages)
876
+
877
+ # Notify UI that compaction is complete
878
+ self.post_message(CompactionCompletedMessage())
879
+
880
+ logger.debug(
881
+ "Completed message history compaction",
882
+ extra={
883
+ "original_count": len(all_messages),
884
+ "compacted_count": len(self.message_history),
885
+ },
886
+ )
887
+ except Exception as e:
888
+ # If compaction fails, log full error with stack trace and use uncompacted messages
889
+ logger.error(
890
+ "Failed to compact message history - using uncompacted messages",
891
+ exc_info=True,
892
+ extra={
893
+ "error": str(e),
894
+ "message_count": len(all_messages),
895
+ "agent_mode": self._current_agent_type.value,
896
+ },
897
+ )
898
+ # Fallback: use uncompacted messages to prevent data loss
899
+ self.message_history = all_messages
900
+
901
+ # Track context composition telemetry
902
+ await self._track_context_analysis(
903
+ compaction_occurred=compaction_occurred,
904
+ messages_before_compaction=messages_before_compaction
905
+ if compaction_occurred
906
+ else None,
907
+ )
908
+
909
+ usage = result.usage()
910
+ if hasattr(deps, "llm_model") and deps.llm_model is not None:
911
+ await deps.usage_manager.add_usage(
912
+ usage, model_name=deps.llm_model.name, provider=deps.llm_model.provider
913
+ )
914
+ else:
915
+ logger.warning(
916
+ "llm_model is None, skipping usage tracking",
917
+ extra={"agent_mode": self._current_agent_type.value},
918
+ )
919
+
920
+ # Post final UI update after compaction completes
921
+ # This ensures widgets that depend on message_history (like context indicator)
922
+ # receive the updated history after compaction
923
+ logger.debug(
924
+ "Posting final UI update after compaction with updated message_history"
925
+ )
379
926
  self._post_messages_updated(file_operations)
380
927
 
381
928
  return result
@@ -387,6 +934,9 @@ class AgentManager(Widget):
387
934
  ) -> None:
388
935
  """Process streamed events and forward partial updates to the UI."""
389
936
 
937
+ # Notify UI that streaming has started
938
+ self.post_message(AgentStreamingStarted())
939
+
390
940
  state = self._stream_state
391
941
  if state is None:
392
942
  state = self._stream_state = _PartialStreamState()
@@ -446,6 +996,55 @@ class AgentManager(Widget):
446
996
  self._post_partial_message(False)
447
997
 
448
998
  elif isinstance(event, FunctionToolCallEvent):
999
+ # Track tool call event
1000
+
1001
+ # Detect source from call stack
1002
+ source = detect_source()
1003
+
1004
+ # Log if tool call has incomplete args (for debugging truncated JSON)
1005
+ if isinstance(event.part.args, str):
1006
+ try:
1007
+ json.loads(event.part.args)
1008
+ except (json.JSONDecodeError, ValueError):
1009
+ args_preview = (
1010
+ event.part.args[:100] + "..."
1011
+ if len(event.part.args) > 100
1012
+ else event.part.args
1013
+ )
1014
+ logger.warning(
1015
+ "FunctionToolCallEvent received with incomplete JSON args",
1016
+ extra={
1017
+ "tool_name": event.part.tool_name,
1018
+ "tool_call_id": event.part.tool_call_id,
1019
+ "args_preview": args_preview,
1020
+ "args_length": len(event.part.args)
1021
+ if event.part.args
1022
+ else 0,
1023
+ "agent_mode": self._current_agent_type.value,
1024
+ },
1025
+ )
1026
+ logfire.warn(
1027
+ "FunctionToolCallEvent received with incomplete JSON args",
1028
+ tool_name=event.part.tool_name,
1029
+ tool_call_id=event.part.tool_call_id,
1030
+ args_preview=args_preview,
1031
+ args_length=len(event.part.args)
1032
+ if event.part.args
1033
+ else 0,
1034
+ agent_mode=self._current_agent_type.value,
1035
+ )
1036
+
1037
+ track_event(
1038
+ "tool_called",
1039
+ {
1040
+ "tool_name": event.part.tool_name,
1041
+ "agent_mode": self._current_agent_type.value
1042
+ if self._current_agent_type
1043
+ else "unknown",
1044
+ "source": source,
1045
+ },
1046
+ )
1047
+
449
1048
  existing_call_idx = next(
450
1049
  (
451
1050
  i
@@ -475,13 +1074,26 @@ class AgentManager(Widget):
475
1074
  state.current_response = partial_message
476
1075
  self._post_partial_message(False)
477
1076
  elif isinstance(event, FunctionToolResultEvent):
1077
+ # Track tool completion event
1078
+
1079
+ # Detect source from call stack
1080
+ source = detect_source()
1081
+
1082
+ track_event(
1083
+ "tool_completed",
1084
+ {
1085
+ "tool_name": event.result.tool_name
1086
+ if hasattr(event.result, "tool_name")
1087
+ else "unknown",
1088
+ "agent_mode": self._current_agent_type.value
1089
+ if self._current_agent_type
1090
+ else "unknown",
1091
+ "source": source,
1092
+ },
1093
+ )
1094
+
478
1095
  request_message = ModelRequest(parts=[event.result])
479
1096
  state.messages.append(request_message)
480
- if (
481
- event.result.tool_name == "ask_user"
482
- ): # special handling to ask_user, because deferred tool results mean we missed the user response
483
- self.ui_message_history.append(request_message)
484
- self._post_messages_updated()
485
1097
  ## this is what the user responded with
486
1098
  self._post_partial_message(is_last=False)
487
1099
 
@@ -503,6 +1115,9 @@ class AgentManager(Widget):
503
1115
  self._post_partial_message(True)
504
1116
  state.current_response = None
505
1117
 
1118
+ # Notify UI that streaming has completed
1119
+ self.post_message(AgentStreamingCompleted())
1120
+
506
1121
  def _build_partial_response(
507
1122
  self, parts: list[ModelResponsePart | ToolCallPartDelta]
508
1123
  ) -> ModelResponse | None:
@@ -530,6 +1145,38 @@ class AgentManager(Widget):
530
1145
  )
531
1146
  )
532
1147
 
1148
+ def _create_file_operation_hint(
1149
+ self, file_operations: list[FileOperation]
1150
+ ) -> str | None:
1151
+ """Create a hint message for file operations.
1152
+
1153
+ Args:
1154
+ file_operations: List of file operations to create a hint for
1155
+
1156
+ Returns:
1157
+ Hint message string or None if no operations
1158
+ """
1159
+ if not file_operations:
1160
+ return None
1161
+
1162
+ tracker = FileOperationTracker(operations=file_operations)
1163
+ display_path = tracker.get_display_path()
1164
+
1165
+ if not display_path:
1166
+ return None
1167
+
1168
+ path_obj = Path(display_path)
1169
+
1170
+ if len(file_operations) == 1:
1171
+ return f"📝 Modified: `{display_path}`"
1172
+ else:
1173
+ num_files = len({op.file_path for op in file_operations})
1174
+ if path_obj.is_dir():
1175
+ return f"📁 Modified {num_files} files in: `{display_path}`"
1176
+ else:
1177
+ # Common path is a file, show parent directory
1178
+ return f"📁 Modified {num_files} files in: `{path_obj.parent}`"
1179
+
533
1180
  def _post_messages_updated(
534
1181
  self, file_operations: list[FileOperation] | None = None
535
1182
  ) -> None:
@@ -588,6 +1235,65 @@ class AgentManager(Widget):
588
1235
  filtered_messages.append(msg)
589
1236
  return filtered_messages
590
1237
 
1238
+ def get_usage_hint(self) -> str | None:
1239
+ return self.deps.usage_manager.build_usage_hint()
1240
+
1241
+ async def get_context_hint(self) -> str | None:
1242
+ """Get conversation context analysis as a formatted hint.
1243
+
1244
+ Returns:
1245
+ Markdown-formatted string with context composition statistics, or None if unavailable
1246
+ """
1247
+ analysis = await self.get_context_analysis()
1248
+ if analysis:
1249
+ return ContextFormatter.format_markdown(analysis)
1250
+ return None
1251
+
1252
+ async def get_context_analysis(self) -> ContextAnalysis | None:
1253
+ """Get conversation context analysis as structured data.
1254
+
1255
+ Returns:
1256
+ ContextAnalysis object with token usage data, or None if unavailable
1257
+ """
1258
+
1259
+ try:
1260
+ analyzer = ContextAnalyzer(self.deps.llm_model)
1261
+ return await analyzer.analyze_conversation(
1262
+ self.message_history, self.ui_message_history
1263
+ )
1264
+ except Exception as e:
1265
+ logger.error(f"Failed to generate context analysis: {e}", exc_info=True)
1266
+ return None
1267
+
1268
+ async def _track_context_analysis(
1269
+ self,
1270
+ compaction_occurred: bool = False,
1271
+ messages_before_compaction: int | None = None,
1272
+ ) -> None:
1273
+ """Track context composition telemetry to PostHog.
1274
+
1275
+ Args:
1276
+ compaction_occurred: Whether compaction was applied
1277
+ messages_before_compaction: Message count before compaction, if it occurred
1278
+ """
1279
+ try:
1280
+ analyzer = ContextAnalyzer(self.deps.llm_model)
1281
+ analysis = await analyzer.analyze_conversation(
1282
+ self.message_history, self.ui_message_history
1283
+ )
1284
+
1285
+ # Create telemetry model from analysis
1286
+ telemetry = ContextCompositionTelemetry.from_analysis(
1287
+ analysis,
1288
+ compaction_occurred=compaction_occurred,
1289
+ messages_before_compaction=messages_before_compaction,
1290
+ )
1291
+
1292
+ # Send to PostHog using model_dump() for dict conversion
1293
+ track_event("agent_context_composition", telemetry.model_dump())
1294
+ except Exception as e:
1295
+ logger.warning(f"Failed to track context analysis: {e}")
1296
+
591
1297
  def get_conversation_state(self) -> "ConversationState":
592
1298
  """Get the current conversation state.
593
1299
 
@@ -635,6 +1341,9 @@ class AgentManager(Widget):
635
1341
  __all__ = [
636
1342
  "AgentManager",
637
1343
  "AgentType",
1344
+ "ClarifyingQuestionsMessage",
1345
+ "CompactionCompletedMessage",
1346
+ "CompactionStartedMessage",
638
1347
  "MessageHistoryUpdated",
639
1348
  "PartialResponseMessage",
640
1349
  ]