shotgun-sh 0.1.14__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 (143) hide show
  1. shotgun/agents/agent_manager.py +715 -75
  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 +10 -5
  16. shotgun/agents/history/context_extraction.py +93 -6
  17. shotgun/agents/history/history_processors.py +129 -12
  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/compact.py +186 -0
  49. shotgun/cli/config.py +41 -67
  50. shotgun/cli/context.py +111 -0
  51. shotgun/cli/export.py +1 -1
  52. shotgun/cli/feedback.py +50 -0
  53. shotgun/cli/models.py +3 -2
  54. shotgun/cli/plan.py +1 -1
  55. shotgun/cli/research.py +1 -1
  56. shotgun/cli/specify.py +1 -1
  57. shotgun/cli/tasks.py +1 -1
  58. shotgun/cli/update.py +16 -2
  59. shotgun/codebase/core/change_detector.py +5 -3
  60. shotgun/codebase/core/code_retrieval.py +4 -2
  61. shotgun/codebase/core/ingestor.py +57 -16
  62. shotgun/codebase/core/manager.py +20 -7
  63. shotgun/codebase/core/nl_query.py +1 -1
  64. shotgun/codebase/models.py +4 -4
  65. shotgun/exceptions.py +32 -0
  66. shotgun/llm_proxy/__init__.py +19 -0
  67. shotgun/llm_proxy/clients.py +44 -0
  68. shotgun/llm_proxy/constants.py +15 -0
  69. shotgun/logging_config.py +18 -27
  70. shotgun/main.py +91 -12
  71. shotgun/posthog_telemetry.py +81 -10
  72. shotgun/prompts/agents/export.j2 +18 -1
  73. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
  74. shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
  75. shotgun/prompts/agents/plan.j2 +1 -1
  76. shotgun/prompts/agents/research.j2 +1 -1
  77. shotgun/prompts/agents/specify.j2 +270 -3
  78. shotgun/prompts/agents/state/system_state.j2 +4 -0
  79. shotgun/prompts/agents/tasks.j2 +1 -1
  80. shotgun/prompts/loader.py +2 -2
  81. shotgun/prompts/tools/web_search.j2 +14 -0
  82. shotgun/sentry_telemetry.py +27 -18
  83. shotgun/settings.py +238 -0
  84. shotgun/shotgun_web/__init__.py +19 -0
  85. shotgun/shotgun_web/client.py +138 -0
  86. shotgun/shotgun_web/constants.py +21 -0
  87. shotgun/shotgun_web/models.py +47 -0
  88. shotgun/telemetry.py +24 -36
  89. shotgun/tui/app.py +251 -23
  90. shotgun/tui/commands/__init__.py +1 -1
  91. shotgun/tui/components/context_indicator.py +179 -0
  92. shotgun/tui/components/mode_indicator.py +70 -0
  93. shotgun/tui/components/status_bar.py +48 -0
  94. shotgun/tui/containers.py +91 -0
  95. shotgun/tui/dependencies.py +39 -0
  96. shotgun/tui/protocols.py +45 -0
  97. shotgun/tui/screens/chat/__init__.py +5 -0
  98. shotgun/tui/screens/chat/chat.tcss +54 -0
  99. shotgun/tui/screens/chat/chat_screen.py +1234 -0
  100. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  101. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  102. shotgun/tui/screens/chat/help_text.py +40 -0
  103. shotgun/tui/screens/chat/prompt_history.py +48 -0
  104. shotgun/tui/screens/chat.tcss +11 -0
  105. shotgun/tui/screens/chat_screen/command_providers.py +226 -11
  106. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  107. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  108. shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
  109. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  110. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  111. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  112. shotgun/tui/screens/confirmation_dialog.py +151 -0
  113. shotgun/tui/screens/feedback.py +193 -0
  114. shotgun/tui/screens/github_issue.py +102 -0
  115. shotgun/tui/screens/model_picker.py +352 -0
  116. shotgun/tui/screens/onboarding.py +431 -0
  117. shotgun/tui/screens/pipx_migration.py +153 -0
  118. shotgun/tui/screens/provider_config.py +156 -39
  119. shotgun/tui/screens/shotgun_auth.py +295 -0
  120. shotgun/tui/screens/welcome.py +198 -0
  121. shotgun/tui/services/__init__.py +5 -0
  122. shotgun/tui/services/conversation_service.py +184 -0
  123. shotgun/tui/state/__init__.py +7 -0
  124. shotgun/tui/state/processing_state.py +185 -0
  125. shotgun/tui/utils/mode_progress.py +14 -7
  126. shotgun/tui/widgets/__init__.py +5 -0
  127. shotgun/tui/widgets/widget_coordinator.py +262 -0
  128. shotgun/utils/datetime_utils.py +77 -0
  129. shotgun/utils/env_utils.py +13 -0
  130. shotgun/utils/file_system_utils.py +22 -2
  131. shotgun/utils/marketing.py +110 -0
  132. shotgun/utils/update_checker.py +69 -14
  133. shotgun_sh-0.2.11.dist-info/METADATA +130 -0
  134. shotgun_sh-0.2.11.dist-info/RECORD +194 -0
  135. {shotgun_sh-0.1.14.dist-info → shotgun_sh-0.2.11.dist-info}/entry_points.txt +1 -0
  136. {shotgun_sh-0.1.14.dist-info → shotgun_sh-0.2.11.dist-info}/licenses/LICENSE +1 -1
  137. shotgun/agents/history/token_counting.py +0 -429
  138. shotgun/agents/tools/user_interaction.py +0 -37
  139. shotgun/tui/screens/chat.py +0 -797
  140. shotgun/tui/screens/chat_screen/history.py +0 -350
  141. shotgun_sh-0.1.14.dist-info/METADATA +0 -466
  142. shotgun_sh-0.1.14.dist-info/RECORD +0 -133
  143. {shotgun_sh-0.1.14.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,12 +40,30 @@ 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
+ )
40
67
  from shotgun.posthog_telemetry import track_event
41
68
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage
42
69
  from shotgun.utils.source_detection import detect_source
@@ -44,7 +71,7 @@ from shotgun.utils.source_detection import detect_source
44
71
  from .export import create_export_agent
45
72
  from .history.compaction import apply_persistent_compaction
46
73
  from .messages import AgentSystemPrompt
47
- from .models import AgentDeps, AgentRuntimeOptions, UserAnswer
74
+ from .models import AgentDeps, AgentRuntimeOptions
48
75
  from .plan import create_plan_agent
49
76
  from .research import create_research_agent
50
77
  from .specify import create_specify_agent
@@ -53,6 +80,35 @@ from .tasks import create_tasks_agent
53
80
  logger = logging.getLogger(__name__)
54
81
 
55
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
+
56
112
  class MessageHistoryUpdated(Message):
57
113
  """Event posted when the message history is updated."""
58
114
 
@@ -91,6 +147,63 @@ class PartialResponseMessage(Message):
91
147
  self.is_last = is_last
92
148
 
93
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
+
94
207
  @dataclass(slots=True)
95
208
  class _PartialStreamState:
96
209
  """Tracks streamed messages while handling a single agent run."""
@@ -115,14 +228,14 @@ class AgentManager(Widget):
115
228
  super().__init__()
116
229
  self.display = False
117
230
 
231
+ if deps is None:
232
+ raise ValueError("AgentDeps must be provided to AgentManager")
233
+
118
234
  # Use provided deps or create default with interactive mode
119
235
  self.deps = deps
120
236
 
121
- if self.deps is None:
122
- raise ValueError("AgentDeps must be provided to AgentManager")
123
-
124
237
  # Create AgentRuntimeOptions from deps for agent creation
125
- agent_runtime_options = AgentRuntimeOptions(
238
+ self._agent_runtime_options = AgentRuntimeOptions(
126
239
  interactive_mode=self.deps.interactive_mode,
127
240
  working_directory=self.deps.working_directory,
128
241
  is_tui_context=self.deps.is_tui_context,
@@ -131,22 +244,18 @@ class AgentManager(Widget):
131
244
  tasks=self.deps.tasks,
132
245
  )
133
246
 
134
- # Initialize all agents and store their specific deps
135
- self.research_agent, self.research_deps = create_research_agent(
136
- agent_runtime_options=agent_runtime_options
137
- )
138
- self.plan_agent, self.plan_deps = create_plan_agent(
139
- agent_runtime_options=agent_runtime_options
140
- )
141
- self.tasks_agent, self.tasks_deps = create_tasks_agent(
142
- agent_runtime_options=agent_runtime_options
143
- )
144
- self.specify_agent, self.specify_deps = create_specify_agent(
145
- agent_runtime_options=agent_runtime_options
146
- )
147
- self.export_agent, self.export_deps = create_export_agent(
148
- agent_runtime_options=agent_runtime_options
149
- )
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
150
259
 
151
260
  # Track current active agent
152
261
  self._current_agent_type: AgentType = initial_type
@@ -157,8 +266,125 @@ class AgentManager(Widget):
157
266
  self.recently_change_files: list[FileOperation] = []
158
267
  self._stream_state: _PartialStreamState | None = None
159
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
+
160
332
  @property
161
- def current_agent(self) -> Agent[AgentDeps, str | DeferredToolRequests]:
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
+
368
+ @property
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]:
162
388
  """Get the currently active agent.
163
389
 
164
390
  Returns:
@@ -166,9 +392,7 @@ class AgentManager(Widget):
166
392
  """
167
393
  return self._get_agent(self._current_agent_type)
168
394
 
169
- def _get_agent(
170
- self, agent_type: AgentType
171
- ) -> Agent[AgentDeps, str | DeferredToolRequests]:
395
+ def _get_agent(self, agent_type: AgentType) -> Agent[AgentDeps, AgentResponse]:
172
396
  """Get agent by type.
173
397
 
174
398
  Args:
@@ -245,15 +469,57 @@ class AgentManager(Widget):
245
469
  f"Invalid agent type: {agent_type}. Must be one of: {', '.join(e.value for e in AgentType)}"
246
470
  ) from None
247
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
+
248
515
  async def run(
249
516
  self,
250
517
  prompt: str | None = None,
251
518
  *,
252
519
  deps: AgentDeps | None = None,
253
520
  usage_limits: UsageLimits | None = None,
254
- deferred_tool_results: DeferredToolResults | None = None,
255
521
  **kwargs: Any,
256
- ) -> AgentRunResult[str | DeferredToolRequests]:
522
+ ) -> AgentRunResult[AgentResponse]:
257
523
  """Run the current agent with automatic message history management.
258
524
 
259
525
  This method wraps the agent's run method, automatically injecting the
@@ -263,24 +529,18 @@ class AgentManager(Widget):
263
529
  prompt: Optional prompt to send to the agent.
264
530
  deps: Optional dependencies override (defaults to manager's deps).
265
531
  usage_limits: Optional usage limits for the agent run.
266
- deferred_tool_results: Optional deferred tool results for continuing a conversation.
267
532
  **kwargs: Additional keyword arguments to pass to the agent.
268
533
 
269
534
  Returns:
270
535
  The agent run result.
271
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}")
272
541
  # Use merged deps (shared state + agent-specific system prompt) if not provided
273
542
  if deps is None:
274
543
  deps = self._create_merged_deps(self._current_agent_type)
275
- ask_user_part = self.get_unanswered_ask_user_part()
276
- if ask_user_part and prompt:
277
- if not deferred_tool_results:
278
- deferred_tool_results = DeferredToolResults()
279
- deferred_tool_results.calls[ask_user_part.tool_call_id] = UserAnswer(
280
- answer=prompt,
281
- tool_call_id=ask_user_part.tool_call_id,
282
- )
283
- prompt = None
284
544
 
285
545
  # Ensure deps is not None
286
546
  if deps is None:
@@ -288,13 +548,12 @@ class AgentManager(Widget):
288
548
 
289
549
  # Clear file tracker before each run to track only this run's operations
290
550
  deps.file_tracker.clear()
291
- # preprocess messages; maybe we need to include the user answer in the message history
292
551
 
293
- original_messages = self.ui_message_history.copy()
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
294
554
 
295
- if prompt:
296
- self.ui_message_history.append(ModelRequest.user_text_prompt(prompt))
297
- self._post_messages_updated()
555
+ # Save current message history before the run
556
+ original_messages = self.ui_message_history.copy()
298
557
 
299
558
  # Start with persistent message history
300
559
  message_history = self.message_history
@@ -358,48 +617,312 @@ class AgentManager(Widget):
358
617
  model_name = ""
359
618
  if hasattr(deps, "llm_model") and deps.llm_model is not None:
360
619
  model_name = deps.llm_model.name
361
- is_gpt5 = ( # streaming is likely not supported for gpt5. It varies between keys.
362
- "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
363
626
  )
364
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
+
365
632
  # Track message send event
366
633
  event_name = f"message_send_{self._current_agent_type.value}"
367
634
  track_event(
368
635
  event_name,
369
636
  {
370
637
  "has_prompt": prompt is not None,
371
- "has_deferred_results": deferred_tool_results is not None,
372
638
  "model_name": model_name,
373
639
  },
374
640
  )
375
641
 
376
642
  try:
377
- result: AgentRunResult[
378
- str | DeferredToolRequests
379
- ] = await self.current_agent.run(
380
- prompt,
643
+ result: AgentRunResult[AgentResponse] = await self._run_agent_with_retry(
644
+ agent=self.current_agent,
645
+ prompt=prompt,
381
646
  deps=deps,
382
647
  usage_limits=usage_limits,
383
648
  message_history=message_history,
384
- deferred_tool_results=deferred_tool_results,
385
- 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,
386
652
  **kwargs,
387
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
388
702
  finally:
389
703
  self._stream_state = None
390
704
 
391
- 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(
392
727
  list[ModelRequest | ModelResponse | HintMessage], result.new_messages()
393
728
  )
394
729
 
395
- # Apply compaction to persistent message history to prevent cascading growth
396
- all_messages = result.all_messages()
397
- 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)
750
+
751
+ self.ui_message_history = original_messages + deduplicated_new_messages
398
752
 
399
- # Log file operations summary if any files were modified
753
+ # Get file operations early so we can use them for contextual messages
400
754
  file_operations = deps.file_tracker.operations.copy()
401
755
  self.recently_change_files = file_operations
402
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
+ )
403
926
  self._post_messages_updated(file_operations)
404
927
 
405
928
  return result
@@ -411,6 +934,9 @@ class AgentManager(Widget):
411
934
  ) -> None:
412
935
  """Process streamed events and forward partial updates to the UI."""
413
936
 
937
+ # Notify UI that streaming has started
938
+ self.post_message(AgentStreamingStarted())
939
+
414
940
  state = self._stream_state
415
941
  if state is None:
416
942
  state = self._stream_state = _PartialStreamState()
@@ -475,6 +1001,39 @@ class AgentManager(Widget):
475
1001
  # Detect source from call stack
476
1002
  source = detect_source()
477
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
+
478
1037
  track_event(
479
1038
  "tool_called",
480
1039
  {
@@ -556,6 +1115,9 @@ class AgentManager(Widget):
556
1115
  self._post_partial_message(True)
557
1116
  state.current_response = None
558
1117
 
1118
+ # Notify UI that streaming has completed
1119
+ self.post_message(AgentStreamingCompleted())
1120
+
559
1121
  def _build_partial_response(
560
1122
  self, parts: list[ModelResponsePart | ToolCallPartDelta]
561
1123
  ) -> ModelResponse | None:
@@ -583,6 +1145,38 @@ class AgentManager(Widget):
583
1145
  )
584
1146
  )
585
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
+
586
1180
  def _post_messages_updated(
587
1181
  self, file_operations: list[FileOperation] | None = None
588
1182
  ) -> None:
@@ -641,6 +1235,65 @@ class AgentManager(Widget):
641
1235
  filtered_messages.append(msg)
642
1236
  return filtered_messages
643
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
+
644
1297
  def get_conversation_state(self) -> "ConversationState":
645
1298
  """Get the current conversation state.
646
1299
 
@@ -683,27 +1336,14 @@ class AgentManager(Widget):
683
1336
  self.ui_message_history.append(message)
684
1337
  self._post_messages_updated()
685
1338
 
686
- def get_unanswered_ask_user_part(self) -> ToolCallPart | None:
687
- if not self.message_history:
688
- return None
689
- self.last_response = self.message_history[-1]
690
- ## we're searching for unanswered ask_user parts
691
- found_tool = next(
692
- (
693
- part
694
- for part in self.message_history[-1].parts
695
- if isinstance(part, ToolCallPart) and part.tool_name == "ask_user"
696
- ),
697
- None,
698
- )
699
-
700
- return found_tool
701
-
702
1339
 
703
1340
  # Re-export AgentType for backward compatibility
704
1341
  __all__ = [
705
1342
  "AgentManager",
706
1343
  "AgentType",
1344
+ "ClarifyingQuestionsMessage",
1345
+ "CompactionCompletedMessage",
1346
+ "CompactionStartedMessage",
707
1347
  "MessageHistoryUpdated",
708
1348
  "PartialResponseMessage",
709
1349
  ]