shotgun-sh 0.2.3.dev2__py3-none-any.whl → 0.2.11.dev5__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 (132) hide show
  1. shotgun/agents/agent_manager.py +664 -75
  2. shotgun/agents/common.py +76 -70
  3. shotgun/agents/config/constants.py +0 -6
  4. shotgun/agents/config/manager.py +78 -36
  5. shotgun/agents/config/models.py +41 -1
  6. shotgun/agents/config/provider.py +70 -15
  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 +9 -4
  16. shotgun/agents/history/context_extraction.py +93 -6
  17. shotgun/agents/history/history_processors.py +14 -2
  18. shotgun/agents/history/token_counting/anthropic.py +49 -11
  19. shotgun/agents/history/token_counting/base.py +14 -3
  20. shotgun/agents/history/token_counting/openai.py +8 -0
  21. shotgun/agents/history/token_counting/sentencepiece_counter.py +8 -0
  22. shotgun/agents/history/token_counting/tokenizer_cache.py +3 -1
  23. shotgun/agents/history/token_counting/utils.py +0 -3
  24. shotgun/agents/models.py +50 -2
  25. shotgun/agents/plan.py +6 -7
  26. shotgun/agents/research.py +7 -8
  27. shotgun/agents/specify.py +6 -7
  28. shotgun/agents/tasks.py +6 -7
  29. shotgun/agents/tools/__init__.py +0 -2
  30. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  31. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  32. shotgun/agents/tools/codebase/file_read.py +11 -2
  33. shotgun/agents/tools/codebase/query_graph.py +6 -0
  34. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  35. shotgun/agents/tools/file_management.py +82 -16
  36. shotgun/agents/tools/registry.py +217 -0
  37. shotgun/agents/tools/web_search/__init__.py +30 -18
  38. shotgun/agents/tools/web_search/anthropic.py +26 -5
  39. shotgun/agents/tools/web_search/gemini.py +23 -11
  40. shotgun/agents/tools/web_search/openai.py +22 -13
  41. shotgun/agents/tools/web_search/utils.py +2 -2
  42. shotgun/agents/usage_manager.py +16 -11
  43. shotgun/api_endpoints.py +7 -3
  44. shotgun/build_constants.py +1 -1
  45. shotgun/cli/clear.py +53 -0
  46. shotgun/cli/compact.py +186 -0
  47. shotgun/cli/config.py +8 -5
  48. shotgun/cli/context.py +111 -0
  49. shotgun/cli/export.py +1 -1
  50. shotgun/cli/feedback.py +4 -2
  51. shotgun/cli/models.py +1 -0
  52. shotgun/cli/plan.py +1 -1
  53. shotgun/cli/research.py +1 -1
  54. shotgun/cli/specify.py +1 -1
  55. shotgun/cli/tasks.py +1 -1
  56. shotgun/cli/update.py +16 -2
  57. shotgun/codebase/core/change_detector.py +5 -3
  58. shotgun/codebase/core/code_retrieval.py +4 -2
  59. shotgun/codebase/core/ingestor.py +10 -8
  60. shotgun/codebase/core/manager.py +13 -4
  61. shotgun/codebase/core/nl_query.py +1 -1
  62. shotgun/llm_proxy/__init__.py +5 -2
  63. shotgun/llm_proxy/clients.py +12 -7
  64. shotgun/logging_config.py +18 -27
  65. shotgun/main.py +73 -11
  66. shotgun/posthog_telemetry.py +23 -7
  67. shotgun/prompts/agents/export.j2 +18 -1
  68. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
  69. shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
  70. shotgun/prompts/agents/plan.j2 +1 -1
  71. shotgun/prompts/agents/research.j2 +1 -1
  72. shotgun/prompts/agents/specify.j2 +270 -3
  73. shotgun/prompts/agents/state/system_state.j2 +4 -0
  74. shotgun/prompts/agents/tasks.j2 +1 -1
  75. shotgun/prompts/loader.py +2 -2
  76. shotgun/prompts/tools/web_search.j2 +14 -0
  77. shotgun/sentry_telemetry.py +7 -16
  78. shotgun/settings.py +238 -0
  79. shotgun/telemetry.py +18 -33
  80. shotgun/tui/app.py +243 -43
  81. shotgun/tui/commands/__init__.py +1 -1
  82. shotgun/tui/components/context_indicator.py +179 -0
  83. shotgun/tui/components/mode_indicator.py +70 -0
  84. shotgun/tui/components/status_bar.py +48 -0
  85. shotgun/tui/containers.py +91 -0
  86. shotgun/tui/dependencies.py +39 -0
  87. shotgun/tui/protocols.py +45 -0
  88. shotgun/tui/screens/chat/__init__.py +5 -0
  89. shotgun/tui/screens/chat/chat.tcss +54 -0
  90. shotgun/tui/screens/chat/chat_screen.py +1202 -0
  91. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  92. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  93. shotgun/tui/screens/chat/help_text.py +40 -0
  94. shotgun/tui/screens/chat/prompt_history.py +48 -0
  95. shotgun/tui/screens/chat.tcss +11 -0
  96. shotgun/tui/screens/chat_screen/command_providers.py +78 -2
  97. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  98. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  99. shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
  100. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  101. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  102. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  103. shotgun/tui/screens/confirmation_dialog.py +151 -0
  104. shotgun/tui/screens/feedback.py +4 -4
  105. shotgun/tui/screens/github_issue.py +102 -0
  106. shotgun/tui/screens/model_picker.py +49 -24
  107. shotgun/tui/screens/onboarding.py +431 -0
  108. shotgun/tui/screens/pipx_migration.py +153 -0
  109. shotgun/tui/screens/provider_config.py +50 -27
  110. shotgun/tui/screens/shotgun_auth.py +2 -2
  111. shotgun/tui/screens/welcome.py +32 -10
  112. shotgun/tui/services/__init__.py +5 -0
  113. shotgun/tui/services/conversation_service.py +184 -0
  114. shotgun/tui/state/__init__.py +7 -0
  115. shotgun/tui/state/processing_state.py +185 -0
  116. shotgun/tui/utils/mode_progress.py +14 -7
  117. shotgun/tui/widgets/__init__.py +5 -0
  118. shotgun/tui/widgets/widget_coordinator.py +262 -0
  119. shotgun/utils/datetime_utils.py +77 -0
  120. shotgun/utils/file_system_utils.py +22 -2
  121. shotgun/utils/marketing.py +110 -0
  122. shotgun/utils/update_checker.py +69 -14
  123. shotgun_sh-0.2.11.dev5.dist-info/METADATA +130 -0
  124. shotgun_sh-0.2.11.dev5.dist-info/RECORD +193 -0
  125. {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev5.dist-info}/entry_points.txt +1 -0
  126. {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev5.dist-info}/licenses/LICENSE +1 -1
  127. shotgun/agents/tools/user_interaction.py +0 -37
  128. shotgun/tui/screens/chat.py +0 -804
  129. shotgun/tui/screens/chat_screen/history.py +0 -352
  130. shotgun_sh-0.2.3.dev2.dist-info/METADATA +0 -467
  131. shotgun_sh-0.2.3.dev2.dist-info/RECORD +0 -154
  132. {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev5.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,25 @@ 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 AgentResponse, AgentType, FileOperation
40
62
  from shotgun.posthog_telemetry import track_event
41
63
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage
42
64
  from shotgun.utils.source_detection import detect_source
@@ -44,7 +66,7 @@ from shotgun.utils.source_detection import detect_source
44
66
  from .export import create_export_agent
45
67
  from .history.compaction import apply_persistent_compaction
46
68
  from .messages import AgentSystemPrompt
47
- from .models import AgentDeps, AgentRuntimeOptions, UserAnswer
69
+ from .models import AgentDeps, AgentRuntimeOptions
48
70
  from .plan import create_plan_agent
49
71
  from .research import create_research_agent
50
72
  from .specify import create_specify_agent
@@ -53,6 +75,35 @@ from .tasks import create_tasks_agent
53
75
  logger = logging.getLogger(__name__)
54
76
 
55
77
 
78
+ def _is_retryable_error(exception: BaseException) -> bool:
79
+ """Check if exception should trigger a retry.
80
+
81
+ Args:
82
+ exception: The exception to check.
83
+
84
+ Returns:
85
+ True if the exception is a transient error that should be retried.
86
+ """
87
+ # ValueError for truncated/incomplete JSON
88
+ if isinstance(exception, ValueError):
89
+ error_str = str(exception)
90
+ return "EOF while parsing" in error_str or (
91
+ "JSON" in error_str and "parsing" in error_str
92
+ )
93
+
94
+ # API errors (overload, rate limits)
95
+ exception_name = type(exception).__name__
96
+ if "APIStatusError" in exception_name:
97
+ error_str = str(exception)
98
+ return "overload" in error_str.lower() or "rate" in error_str.lower()
99
+
100
+ # Network errors
101
+ if "ConnectionError" in exception_name or "TimeoutError" in exception_name:
102
+ return True
103
+
104
+ return False
105
+
106
+
56
107
  class MessageHistoryUpdated(Message):
57
108
  """Event posted when the message history is updated."""
58
109
 
@@ -91,6 +142,63 @@ class PartialResponseMessage(Message):
91
142
  self.is_last = is_last
92
143
 
93
144
 
145
+ class ClarifyingQuestionsMessage(Message):
146
+ """Event posted when agent returns clarifying questions."""
147
+
148
+ def __init__(
149
+ self,
150
+ questions: list[str],
151
+ response_text: str,
152
+ ) -> None:
153
+ """Initialize the clarifying questions message.
154
+
155
+ Args:
156
+ questions: List of clarifying questions from the agent
157
+ response_text: The agent's response text before asking questions
158
+ """
159
+ super().__init__()
160
+ self.questions = questions
161
+ self.response_text = response_text
162
+
163
+
164
+ class CompactionStartedMessage(Message):
165
+ """Event posted when conversation compaction starts."""
166
+
167
+
168
+ class CompactionCompletedMessage(Message):
169
+ """Event posted when conversation compaction completes."""
170
+
171
+
172
+ class AgentStreamingStarted(Message):
173
+ """Event posted when agent starts streaming responses."""
174
+
175
+
176
+ class AgentStreamingCompleted(Message):
177
+ """Event posted when agent finishes streaming responses."""
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class ModelConfigUpdated:
182
+ """Data returned when AI model configuration changes.
183
+
184
+ Used as a return value from ModelPickerScreen to communicate model
185
+ selection back to the calling screen.
186
+
187
+ Attributes:
188
+ old_model: Previous model name (None if first selection)
189
+ new_model: New model name
190
+ provider: LLM provider (OpenAI, Anthropic, Google)
191
+ key_provider: Authentication method (BYOK or Shotgun)
192
+ model_config: Complete model configuration
193
+ """
194
+
195
+ old_model: ModelName | None
196
+ new_model: ModelName
197
+ provider: ProviderType
198
+ key_provider: KeyProvider
199
+ model_config: ModelConfig
200
+
201
+
94
202
  @dataclass(slots=True)
95
203
  class _PartialStreamState:
96
204
  """Tracks streamed messages while handling a single agent run."""
@@ -122,7 +230,7 @@ class AgentManager(Widget):
122
230
  self.deps = deps
123
231
 
124
232
  # Create AgentRuntimeOptions from deps for agent creation
125
- agent_runtime_options = AgentRuntimeOptions(
233
+ self._agent_runtime_options = AgentRuntimeOptions(
126
234
  interactive_mode=self.deps.interactive_mode,
127
235
  working_directory=self.deps.working_directory,
128
236
  is_tui_context=self.deps.is_tui_context,
@@ -131,22 +239,18 @@ class AgentManager(Widget):
131
239
  tasks=self.deps.tasks,
132
240
  )
133
241
 
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
- )
242
+ # Lazy initialization - agents created on first access
243
+ self._research_agent: Agent[AgentDeps, AgentResponse] | None = None
244
+ self._research_deps: AgentDeps | None = None
245
+ self._plan_agent: Agent[AgentDeps, AgentResponse] | None = None
246
+ self._plan_deps: AgentDeps | None = None
247
+ self._tasks_agent: Agent[AgentDeps, AgentResponse] | None = None
248
+ self._tasks_deps: AgentDeps | None = None
249
+ self._specify_agent: Agent[AgentDeps, AgentResponse] | None = None
250
+ self._specify_deps: AgentDeps | None = None
251
+ self._export_agent: Agent[AgentDeps, AgentResponse] | None = None
252
+ self._export_deps: AgentDeps | None = None
253
+ self._agents_initialized = False
150
254
 
151
255
  # Track current active agent
152
256
  self._current_agent_type: AgentType = initial_type
@@ -157,8 +261,125 @@ class AgentManager(Widget):
157
261
  self.recently_change_files: list[FileOperation] = []
158
262
  self._stream_state: _PartialStreamState | None = None
159
263
 
264
+ # Q&A mode state for structured output questions
265
+ self._qa_questions: list[str] | None = None
266
+ self._qa_mode_active: bool = False
267
+
268
+ async def _ensure_agents_initialized(self) -> None:
269
+ """Ensure all agents are initialized (lazy initialization)."""
270
+ if self._agents_initialized:
271
+ return
272
+
273
+ # Initialize all agents asynchronously
274
+ self._research_agent, self._research_deps = await create_research_agent(
275
+ agent_runtime_options=self._agent_runtime_options
276
+ )
277
+ self._plan_agent, self._plan_deps = await create_plan_agent(
278
+ agent_runtime_options=self._agent_runtime_options
279
+ )
280
+ self._tasks_agent, self._tasks_deps = await create_tasks_agent(
281
+ agent_runtime_options=self._agent_runtime_options
282
+ )
283
+ self._specify_agent, self._specify_deps = await create_specify_agent(
284
+ agent_runtime_options=self._agent_runtime_options
285
+ )
286
+ self._export_agent, self._export_deps = await create_export_agent(
287
+ agent_runtime_options=self._agent_runtime_options
288
+ )
289
+ self._agents_initialized = True
290
+
291
+ @property
292
+ def research_agent(self) -> Agent[AgentDeps, AgentResponse]:
293
+ """Get research agent (must call _ensure_agents_initialized first)."""
294
+ if self._research_agent is None:
295
+ raise RuntimeError(
296
+ "Agents not initialized. Call _ensure_agents_initialized() first."
297
+ )
298
+ return self._research_agent
299
+
300
+ @property
301
+ def research_deps(self) -> AgentDeps:
302
+ """Get research deps (must call _ensure_agents_initialized first)."""
303
+ if self._research_deps is None:
304
+ raise RuntimeError(
305
+ "Agents not initialized. Call _ensure_agents_initialized() first."
306
+ )
307
+ return self._research_deps
308
+
309
+ @property
310
+ def plan_agent(self) -> Agent[AgentDeps, AgentResponse]:
311
+ """Get plan agent (must call _ensure_agents_initialized first)."""
312
+ if self._plan_agent is None:
313
+ raise RuntimeError(
314
+ "Agents not initialized. Call _ensure_agents_initialized() first."
315
+ )
316
+ return self._plan_agent
317
+
318
+ @property
319
+ def plan_deps(self) -> AgentDeps:
320
+ """Get plan deps (must call _ensure_agents_initialized first)."""
321
+ if self._plan_deps is None:
322
+ raise RuntimeError(
323
+ "Agents not initialized. Call _ensure_agents_initialized() first."
324
+ )
325
+ return self._plan_deps
326
+
327
+ @property
328
+ def tasks_agent(self) -> Agent[AgentDeps, AgentResponse]:
329
+ """Get tasks agent (must call _ensure_agents_initialized first)."""
330
+ if self._tasks_agent is None:
331
+ raise RuntimeError(
332
+ "Agents not initialized. Call _ensure_agents_initialized() first."
333
+ )
334
+ return self._tasks_agent
335
+
336
+ @property
337
+ def tasks_deps(self) -> AgentDeps:
338
+ """Get tasks deps (must call _ensure_agents_initialized first)."""
339
+ if self._tasks_deps is None:
340
+ raise RuntimeError(
341
+ "Agents not initialized. Call _ensure_agents_initialized() first."
342
+ )
343
+ return self._tasks_deps
344
+
345
+ @property
346
+ def specify_agent(self) -> Agent[AgentDeps, AgentResponse]:
347
+ """Get specify agent (must call _ensure_agents_initialized first)."""
348
+ if self._specify_agent is None:
349
+ raise RuntimeError(
350
+ "Agents not initialized. Call _ensure_agents_initialized() first."
351
+ )
352
+ return self._specify_agent
353
+
354
+ @property
355
+ def specify_deps(self) -> AgentDeps:
356
+ """Get specify deps (must call _ensure_agents_initialized first)."""
357
+ if self._specify_deps is None:
358
+ raise RuntimeError(
359
+ "Agents not initialized. Call _ensure_agents_initialized() first."
360
+ )
361
+ return self._specify_deps
362
+
363
+ @property
364
+ def export_agent(self) -> Agent[AgentDeps, AgentResponse]:
365
+ """Get export agent (must call _ensure_agents_initialized first)."""
366
+ if self._export_agent is None:
367
+ raise RuntimeError(
368
+ "Agents not initialized. Call _ensure_agents_initialized() first."
369
+ )
370
+ return self._export_agent
371
+
372
+ @property
373
+ def export_deps(self) -> AgentDeps:
374
+ """Get export deps (must call _ensure_agents_initialized first)."""
375
+ if self._export_deps is None:
376
+ raise RuntimeError(
377
+ "Agents not initialized. Call _ensure_agents_initialized() first."
378
+ )
379
+ return self._export_deps
380
+
160
381
  @property
161
- def current_agent(self) -> Agent[AgentDeps, str | DeferredToolRequests]:
382
+ def current_agent(self) -> Agent[AgentDeps, AgentResponse]:
162
383
  """Get the currently active agent.
163
384
 
164
385
  Returns:
@@ -166,9 +387,7 @@ class AgentManager(Widget):
166
387
  """
167
388
  return self._get_agent(self._current_agent_type)
168
389
 
169
- def _get_agent(
170
- self, agent_type: AgentType
171
- ) -> Agent[AgentDeps, str | DeferredToolRequests]:
390
+ def _get_agent(self, agent_type: AgentType) -> Agent[AgentDeps, AgentResponse]:
172
391
  """Get agent by type.
173
392
 
174
393
  Args:
@@ -245,15 +464,57 @@ class AgentManager(Widget):
245
464
  f"Invalid agent type: {agent_type}. Must be one of: {', '.join(e.value for e in AgentType)}"
246
465
  ) from None
247
466
 
467
+ @retry(
468
+ stop=stop_after_attempt(3),
469
+ wait=wait_exponential(multiplier=1, min=1, max=8),
470
+ retry=retry_if_exception(_is_retryable_error),
471
+ before_sleep=before_sleep_log(logger, logging.WARNING),
472
+ reraise=True,
473
+ )
474
+ async def _run_agent_with_retry(
475
+ self,
476
+ agent: Agent[AgentDeps, AgentResponse],
477
+ prompt: str | None,
478
+ deps: AgentDeps,
479
+ usage_limits: UsageLimits | None,
480
+ message_history: list[ModelMessage],
481
+ event_stream_handler: Any,
482
+ **kwargs: Any,
483
+ ) -> AgentRunResult[AgentResponse]:
484
+ """Run agent with automatic retry on transient errors.
485
+
486
+ Args:
487
+ agent: The agent to run.
488
+ prompt: Optional prompt to send to the agent.
489
+ deps: Agent dependencies.
490
+ usage_limits: Optional usage limits.
491
+ message_history: Message history to provide to agent.
492
+ event_stream_handler: Event handler for streaming.
493
+ **kwargs: Additional keyword arguments.
494
+
495
+ Returns:
496
+ The agent run result.
497
+
498
+ Raises:
499
+ Various exceptions if all retries fail.
500
+ """
501
+ return await agent.run(
502
+ prompt,
503
+ deps=deps,
504
+ usage_limits=usage_limits,
505
+ message_history=message_history,
506
+ event_stream_handler=event_stream_handler,
507
+ **kwargs,
508
+ )
509
+
248
510
  async def run(
249
511
  self,
250
512
  prompt: str | None = None,
251
513
  *,
252
514
  deps: AgentDeps | None = None,
253
515
  usage_limits: UsageLimits | None = None,
254
- deferred_tool_results: DeferredToolResults | None = None,
255
516
  **kwargs: Any,
256
- ) -> AgentRunResult[str | DeferredToolRequests]:
517
+ ) -> AgentRunResult[AgentResponse]:
257
518
  """Run the current agent with automatic message history management.
258
519
 
259
520
  This method wraps the agent's run method, automatically injecting the
@@ -263,25 +524,18 @@ class AgentManager(Widget):
263
524
  prompt: Optional prompt to send to the agent.
264
525
  deps: Optional dependencies override (defaults to manager's deps).
265
526
  usage_limits: Optional usage limits for the agent run.
266
- deferred_tool_results: Optional deferred tool results for continuing a conversation.
267
527
  **kwargs: Additional keyword arguments to pass to the agent.
268
528
 
269
529
  Returns:
270
530
  The agent run result.
271
531
  """
532
+ # Ensure agents are initialized before running
533
+ await self._ensure_agents_initialized()
534
+
272
535
  logger.info(f"Running agent {self._current_agent_type.value}")
273
536
  # Use merged deps (shared state + agent-specific system prompt) if not provided
274
537
  if deps is None:
275
538
  deps = self._create_merged_deps(self._current_agent_type)
276
- ask_user_part = self.get_unanswered_ask_user_part()
277
- if ask_user_part and prompt:
278
- if not deferred_tool_results:
279
- deferred_tool_results = DeferredToolResults()
280
- deferred_tool_results.calls[ask_user_part.tool_call_id] = UserAnswer(
281
- answer=prompt,
282
- tool_call_id=ask_user_part.tool_call_id,
283
- )
284
- prompt = None
285
539
 
286
540
  # Ensure deps is not None
287
541
  if deps is None:
@@ -289,13 +543,12 @@ class AgentManager(Widget):
289
543
 
290
544
  # Clear file tracker before each run to track only this run's operations
291
545
  deps.file_tracker.clear()
292
- # preprocess messages; maybe we need to include the user answer in the message history
293
546
 
294
- original_messages = self.ui_message_history.copy()
547
+ # Don't manually add the user prompt - Pydantic AI will include it in result.new_messages()
548
+ # This prevents duplicates and confusion with incremental mounting
295
549
 
296
- if prompt:
297
- self.ui_message_history.append(ModelRequest.user_text_prompt(prompt))
298
- self._post_messages_updated()
550
+ # Save current message history before the run
551
+ original_messages = self.ui_message_history.copy()
299
552
 
300
553
  # Start with persistent message history
301
554
  message_history = self.message_history
@@ -359,52 +612,306 @@ class AgentManager(Widget):
359
612
  model_name = ""
360
613
  if hasattr(deps, "llm_model") and deps.llm_model is not None:
361
614
  model_name = deps.llm_model.name
362
- is_gpt5 = ( # streaming is likely not supported for gpt5. It varies between keys.
363
- "gpt-5" in model_name.lower()
615
+
616
+ # Check if it's a Shotgun account
617
+ is_shotgun_account = (
618
+ hasattr(deps, "llm_model")
619
+ and deps.llm_model is not None
620
+ and deps.llm_model.key_provider == KeyProvider.SHOTGUN
364
621
  )
365
622
 
623
+ # Only disable streaming for GPT-5 if NOT a Shotgun account
624
+ # Shotgun accounts support streaming for GPT-5
625
+ is_gpt5_byok = "gpt-5" in model_name.lower() and not is_shotgun_account
626
+
366
627
  # Track message send event
367
628
  event_name = f"message_send_{self._current_agent_type.value}"
368
629
  track_event(
369
630
  event_name,
370
631
  {
371
632
  "has_prompt": prompt is not None,
372
- "has_deferred_results": deferred_tool_results is not None,
373
633
  "model_name": model_name,
374
634
  },
375
635
  )
376
636
 
377
637
  try:
378
- result: AgentRunResult[
379
- str | DeferredToolRequests
380
- ] = await self.current_agent.run(
381
- prompt,
638
+ result: AgentRunResult[AgentResponse] = await self._run_agent_with_retry(
639
+ agent=self.current_agent,
640
+ prompt=prompt,
382
641
  deps=deps,
383
642
  usage_limits=usage_limits,
384
643
  message_history=message_history,
385
- deferred_tool_results=deferred_tool_results,
386
- event_stream_handler=self._handle_event_stream if not is_gpt5 else None,
644
+ event_stream_handler=self._handle_event_stream
645
+ if not is_gpt5_byok
646
+ else None,
387
647
  **kwargs,
388
648
  )
649
+ except ValueError as e:
650
+ # Handle truncated/incomplete JSON in tool calls specifically
651
+ error_str = str(e)
652
+ if "EOF while parsing" in error_str or (
653
+ "JSON" in error_str and "parsing" in error_str
654
+ ):
655
+ logger.error(
656
+ "Tool call with truncated/incomplete JSON arguments detected",
657
+ extra={
658
+ "agent_mode": self._current_agent_type.value,
659
+ "model_name": model_name,
660
+ "error": error_str,
661
+ },
662
+ )
663
+ logfire.error(
664
+ "Tool call with truncated JSON arguments",
665
+ agent_mode=self._current_agent_type.value,
666
+ model_name=model_name,
667
+ error=error_str,
668
+ )
669
+ # Add helpful hint message for the user
670
+ self.ui_message_history.append(
671
+ HintMessage(
672
+ message="⚠️ The agent attempted an operation with arguments that were too large (truncated JSON). "
673
+ "Try breaking your request into smaller steps or more focused contracts."
674
+ )
675
+ )
676
+ self._post_messages_updated()
677
+ # Re-raise to maintain error visibility
678
+ raise
679
+ except Exception as e:
680
+ # Log the error with full stack trace to shotgun.log and Logfire
681
+ logger.exception(
682
+ "Agent execution failed",
683
+ extra={
684
+ "agent_mode": self._current_agent_type.value,
685
+ "model_name": model_name,
686
+ "error_type": type(e).__name__,
687
+ },
688
+ )
689
+ logfire.exception(
690
+ "Agent execution failed",
691
+ agent_mode=self._current_agent_type.value,
692
+ model_name=model_name,
693
+ error_type=type(e).__name__,
694
+ )
695
+ # Re-raise to let TUI handle user messaging
696
+ raise
389
697
  finally:
390
698
  self._stream_state = None
391
699
 
392
- self.ui_message_history = original_messages + cast(
700
+ # Agent ALWAYS returns AgentResponse with structured output
701
+ agent_response = result.output
702
+ logger.debug(
703
+ "Agent returned structured AgentResponse",
704
+ extra={
705
+ "has_response": agent_response.response is not None,
706
+ "response_length": len(agent_response.response)
707
+ if agent_response.response
708
+ else 0,
709
+ "response_preview": agent_response.response[:100] + "..."
710
+ if agent_response.response and len(agent_response.response) > 100
711
+ else agent_response.response or "(empty)",
712
+ "has_clarifying_questions": bool(agent_response.clarifying_questions),
713
+ "num_clarifying_questions": len(agent_response.clarifying_questions)
714
+ if agent_response.clarifying_questions
715
+ else 0,
716
+ },
717
+ )
718
+
719
+ # Merge agent's response messages, avoiding duplicates
720
+ # The TUI may have already added the user prompt, so check for it
721
+ new_messages = cast(
393
722
  list[ModelRequest | ModelResponse | HintMessage], result.new_messages()
394
723
  )
395
724
 
725
+ # Deduplicate: skip user prompts that are already in original_messages
726
+ deduplicated_new_messages = []
727
+ for msg in new_messages:
728
+ # Check if this is a user prompt that's already in original_messages
729
+ if isinstance(msg, ModelRequest) and any(
730
+ isinstance(part, UserPromptPart) for part in msg.parts
731
+ ):
732
+ # Check if an identical user prompt is already in original_messages
733
+ already_exists = any(
734
+ isinstance(existing, ModelRequest)
735
+ and any(isinstance(p, UserPromptPart) for p in existing.parts)
736
+ and existing.parts == msg.parts
737
+ for existing in original_messages[
738
+ -5:
739
+ ] # Check last 5 messages for efficiency
740
+ )
741
+ if already_exists:
742
+ continue # Skip this duplicate user prompt
743
+
744
+ deduplicated_new_messages.append(msg)
745
+
746
+ self.ui_message_history = original_messages + deduplicated_new_messages
747
+
748
+ # Get file operations early so we can use them for contextual messages
749
+ file_operations = deps.file_tracker.operations.copy()
750
+ self.recently_change_files = file_operations
751
+
752
+ logger.debug(
753
+ "File operations tracked",
754
+ extra={
755
+ "num_file_operations": len(file_operations),
756
+ "operation_files": [Path(op.file_path).name for op in file_operations],
757
+ },
758
+ )
759
+
760
+ # Check if there are clarifying questions
761
+ if agent_response.clarifying_questions:
762
+ logger.info(
763
+ f"Agent has {len(agent_response.clarifying_questions)} clarifying questions"
764
+ )
765
+
766
+ # Add agent's response first if present
767
+ if agent_response.response:
768
+ self.ui_message_history.append(
769
+ HintMessage(message=agent_response.response)
770
+ )
771
+
772
+ if len(agent_response.clarifying_questions) == 1:
773
+ # Single question - treat as non-blocking suggestion, DON'T enter Q&A mode
774
+ self.ui_message_history.append(
775
+ HintMessage(message=f"💡 {agent_response.clarifying_questions[0]}")
776
+ )
777
+ else:
778
+ # Multiple questions (2+) - enter Q&A mode
779
+ self._qa_questions = agent_response.clarifying_questions
780
+ self._qa_mode_active = True
781
+
782
+ # Show intro with list, then first question
783
+ questions_list_with_intro = (
784
+ f"I have {len(agent_response.clarifying_questions)} questions:\n\n"
785
+ + "\n".join(
786
+ f"{i + 1}. {q}"
787
+ for i, q in enumerate(agent_response.clarifying_questions)
788
+ )
789
+ )
790
+ self.ui_message_history.append(
791
+ HintMessage(message=questions_list_with_intro)
792
+ )
793
+ self.ui_message_history.append(
794
+ HintMessage(
795
+ message=f"**Q1:** {agent_response.clarifying_questions[0]}"
796
+ )
797
+ )
798
+
799
+ # Post event to TUI to update Q&A mode state (only for multiple questions)
800
+ self.post_message(
801
+ ClarifyingQuestionsMessage(
802
+ questions=agent_response.clarifying_questions,
803
+ response_text=agent_response.response,
804
+ )
805
+ )
806
+
807
+ # Post UI update with hint messages (file operations will be posted after compaction)
808
+ logger.debug("Posting UI update for Q&A mode with hint messages")
809
+ self._post_messages_updated([])
810
+ else:
811
+ # No clarifying questions - show the response or a default success message
812
+ if agent_response.response and agent_response.response.strip():
813
+ logger.debug(
814
+ "Adding agent response as hint",
815
+ extra={
816
+ "response_preview": agent_response.response[:100] + "..."
817
+ if len(agent_response.response) > 100
818
+ else agent_response.response,
819
+ "has_file_operations": len(file_operations) > 0,
820
+ },
821
+ )
822
+ self.ui_message_history.append(
823
+ HintMessage(message=agent_response.response)
824
+ )
825
+ else:
826
+ # Fallback: response is empty or whitespace
827
+ logger.debug(
828
+ "Agent response was empty, using fallback completion message",
829
+ extra={"has_file_operations": len(file_operations) > 0},
830
+ )
831
+ # Show contextual message based on whether files were modified
832
+ if file_operations:
833
+ self.ui_message_history.append(
834
+ HintMessage(
835
+ message="✅ Task completed - files have been modified"
836
+ )
837
+ )
838
+ else:
839
+ self.ui_message_history.append(
840
+ HintMessage(message="✅ Task completed")
841
+ )
842
+
843
+ # Post UI update immediately so user sees the response without delay
844
+ # (file operations will be posted after compaction to avoid duplicates)
845
+ logger.debug("Posting immediate UI update with hint message")
846
+ self._post_messages_updated([])
847
+
396
848
  # Apply compaction to persistent message history to prevent cascading growth
397
849
  all_messages = result.all_messages()
398
- self.message_history = await apply_persistent_compaction(all_messages, deps)
399
- usage = result.usage()
400
- deps.usage_manager.add_usage(
401
- usage, model_name=deps.llm_model.name, provider=deps.llm_model.provider
850
+ messages_before_compaction = len(all_messages)
851
+ compaction_occurred = False
852
+
853
+ try:
854
+ logger.debug(
855
+ "Starting message history compaction",
856
+ extra={"message_count": len(all_messages)},
857
+ )
858
+ # Notify UI that compaction is starting
859
+ self.post_message(CompactionStartedMessage())
860
+
861
+ self.message_history = await apply_persistent_compaction(all_messages, deps)
862
+
863
+ # Track if compaction actually modified the history
864
+ compaction_occurred = len(self.message_history) != len(all_messages)
865
+
866
+ # Notify UI that compaction is complete
867
+ self.post_message(CompactionCompletedMessage())
868
+
869
+ logger.debug(
870
+ "Completed message history compaction",
871
+ extra={
872
+ "original_count": len(all_messages),
873
+ "compacted_count": len(self.message_history),
874
+ },
875
+ )
876
+ except Exception as e:
877
+ # If compaction fails, log full error with stack trace and use uncompacted messages
878
+ logger.error(
879
+ "Failed to compact message history - using uncompacted messages",
880
+ exc_info=True,
881
+ extra={
882
+ "error": str(e),
883
+ "message_count": len(all_messages),
884
+ "agent_mode": self._current_agent_type.value,
885
+ },
886
+ )
887
+ # Fallback: use uncompacted messages to prevent data loss
888
+ self.message_history = all_messages
889
+
890
+ # Track context composition telemetry
891
+ await self._track_context_analysis(
892
+ compaction_occurred=compaction_occurred,
893
+ messages_before_compaction=messages_before_compaction
894
+ if compaction_occurred
895
+ else None,
402
896
  )
403
897
 
404
- # Log file operations summary if any files were modified
405
- file_operations = deps.file_tracker.operations.copy()
406
- self.recently_change_files = file_operations
898
+ usage = result.usage()
899
+ if hasattr(deps, "llm_model") and deps.llm_model is not None:
900
+ await deps.usage_manager.add_usage(
901
+ usage, model_name=deps.llm_model.name, provider=deps.llm_model.provider
902
+ )
903
+ else:
904
+ logger.warning(
905
+ "llm_model is None, skipping usage tracking",
906
+ extra={"agent_mode": self._current_agent_type.value},
907
+ )
407
908
 
909
+ # Post final UI update after compaction completes
910
+ # This ensures widgets that depend on message_history (like context indicator)
911
+ # receive the updated history after compaction
912
+ logger.debug(
913
+ "Posting final UI update after compaction with updated message_history"
914
+ )
408
915
  self._post_messages_updated(file_operations)
409
916
 
410
917
  return result
@@ -416,6 +923,9 @@ class AgentManager(Widget):
416
923
  ) -> None:
417
924
  """Process streamed events and forward partial updates to the UI."""
418
925
 
926
+ # Notify UI that streaming has started
927
+ self.post_message(AgentStreamingStarted())
928
+
419
929
  state = self._stream_state
420
930
  if state is None:
421
931
  state = self._stream_state = _PartialStreamState()
@@ -480,6 +990,39 @@ class AgentManager(Widget):
480
990
  # Detect source from call stack
481
991
  source = detect_source()
482
992
 
993
+ # Log if tool call has incomplete args (for debugging truncated JSON)
994
+ if isinstance(event.part.args, str):
995
+ try:
996
+ json.loads(event.part.args)
997
+ except (json.JSONDecodeError, ValueError):
998
+ args_preview = (
999
+ event.part.args[:100] + "..."
1000
+ if len(event.part.args) > 100
1001
+ else event.part.args
1002
+ )
1003
+ logger.warning(
1004
+ "FunctionToolCallEvent received with incomplete JSON args",
1005
+ extra={
1006
+ "tool_name": event.part.tool_name,
1007
+ "tool_call_id": event.part.tool_call_id,
1008
+ "args_preview": args_preview,
1009
+ "args_length": len(event.part.args)
1010
+ if event.part.args
1011
+ else 0,
1012
+ "agent_mode": self._current_agent_type.value,
1013
+ },
1014
+ )
1015
+ logfire.warn(
1016
+ "FunctionToolCallEvent received with incomplete JSON args",
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
+
483
1026
  track_event(
484
1027
  "tool_called",
485
1028
  {
@@ -561,6 +1104,9 @@ class AgentManager(Widget):
561
1104
  self._post_partial_message(True)
562
1105
  state.current_response = None
563
1106
 
1107
+ # Notify UI that streaming has completed
1108
+ self.post_message(AgentStreamingCompleted())
1109
+
564
1110
  def _build_partial_response(
565
1111
  self, parts: list[ModelResponsePart | ToolCallPartDelta]
566
1112
  ) -> ModelResponse | None:
@@ -649,6 +1195,62 @@ class AgentManager(Widget):
649
1195
  def get_usage_hint(self) -> str | None:
650
1196
  return self.deps.usage_manager.build_usage_hint()
651
1197
 
1198
+ async def get_context_hint(self) -> str | None:
1199
+ """Get conversation context analysis as a formatted hint.
1200
+
1201
+ Returns:
1202
+ Markdown-formatted string with context composition statistics, or None if unavailable
1203
+ """
1204
+ analysis = await self.get_context_analysis()
1205
+ if analysis:
1206
+ return ContextFormatter.format_markdown(analysis)
1207
+ return None
1208
+
1209
+ async def get_context_analysis(self) -> ContextAnalysis | None:
1210
+ """Get conversation context analysis as structured data.
1211
+
1212
+ Returns:
1213
+ ContextAnalysis object with token usage data, or None if unavailable
1214
+ """
1215
+
1216
+ try:
1217
+ analyzer = ContextAnalyzer(self.deps.llm_model)
1218
+ return await analyzer.analyze_conversation(
1219
+ self.message_history, self.ui_message_history
1220
+ )
1221
+ except Exception as e:
1222
+ logger.error(f"Failed to generate context analysis: {e}", exc_info=True)
1223
+ return None
1224
+
1225
+ async def _track_context_analysis(
1226
+ self,
1227
+ compaction_occurred: bool = False,
1228
+ messages_before_compaction: int | None = None,
1229
+ ) -> None:
1230
+ """Track context composition telemetry to PostHog.
1231
+
1232
+ Args:
1233
+ compaction_occurred: Whether compaction was applied
1234
+ messages_before_compaction: Message count before compaction, if it occurred
1235
+ """
1236
+ try:
1237
+ analyzer = ContextAnalyzer(self.deps.llm_model)
1238
+ analysis = await analyzer.analyze_conversation(
1239
+ self.message_history, self.ui_message_history
1240
+ )
1241
+
1242
+ # Create telemetry model from analysis
1243
+ telemetry = ContextCompositionTelemetry.from_analysis(
1244
+ analysis,
1245
+ compaction_occurred=compaction_occurred,
1246
+ messages_before_compaction=messages_before_compaction,
1247
+ )
1248
+
1249
+ # Send to PostHog using model_dump() for dict conversion
1250
+ track_event("agent_context_composition", telemetry.model_dump())
1251
+ except Exception as e:
1252
+ logger.warning(f"Failed to track context analysis: {e}")
1253
+
652
1254
  def get_conversation_state(self) -> "ConversationState":
653
1255
  """Get the current conversation state.
654
1256
 
@@ -691,27 +1293,14 @@ class AgentManager(Widget):
691
1293
  self.ui_message_history.append(message)
692
1294
  self._post_messages_updated()
693
1295
 
694
- def get_unanswered_ask_user_part(self) -> ToolCallPart | None:
695
- if not self.message_history:
696
- return None
697
- self.last_response = self.message_history[-1]
698
- ## we're searching for unanswered ask_user parts
699
- found_tool = next(
700
- (
701
- part
702
- for part in self.message_history[-1].parts
703
- if isinstance(part, ToolCallPart) and part.tool_name == "ask_user"
704
- ),
705
- None,
706
- )
707
-
708
- return found_tool
709
-
710
1296
 
711
1297
  # Re-export AgentType for backward compatibility
712
1298
  __all__ = [
713
1299
  "AgentManager",
714
1300
  "AgentType",
1301
+ "ClarifyingQuestionsMessage",
1302
+ "CompactionCompletedMessage",
1303
+ "CompactionStartedMessage",
715
1304
  "MessageHistoryUpdated",
716
1305
  "PartialResponseMessage",
717
1306
  ]