shotgun-sh 0.2.6.dev1__py3-none-any.whl → 0.2.17__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. shotgun/agents/agent_manager.py +694 -73
  2. shotgun/agents/common.py +69 -70
  3. shotgun/agents/config/constants.py +0 -6
  4. shotgun/agents/config/manager.py +70 -35
  5. shotgun/agents/config/models.py +41 -1
  6. shotgun/agents/config/provider.py +33 -5
  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 +113 -5
  18. shotgun/agents/history/token_counting/anthropic.py +39 -3
  19. shotgun/agents/history/token_counting/base.py +14 -3
  20. shotgun/agents/history/token_counting/openai.py +11 -1
  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 +8 -8
  38. shotgun/agents/tools/web_search/anthropic.py +8 -2
  39. shotgun/agents/tools/web_search/gemini.py +7 -1
  40. shotgun/agents/tools/web_search/openai.py +7 -1
  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 +3 -3
  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/exceptions.py +32 -0
  63. shotgun/logging_config.py +18 -27
  64. shotgun/main.py +73 -11
  65. shotgun/posthog_telemetry.py +37 -28
  66. shotgun/prompts/agents/export.j2 +18 -1
  67. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
  68. shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
  69. shotgun/prompts/agents/plan.j2 +1 -1
  70. shotgun/prompts/agents/research.j2 +1 -1
  71. shotgun/prompts/agents/specify.j2 +270 -3
  72. shotgun/prompts/agents/tasks.j2 +1 -1
  73. shotgun/sentry_telemetry.py +163 -16
  74. shotgun/settings.py +238 -0
  75. shotgun/telemetry.py +18 -33
  76. shotgun/tui/app.py +243 -43
  77. shotgun/tui/commands/__init__.py +1 -1
  78. shotgun/tui/components/context_indicator.py +179 -0
  79. shotgun/tui/components/mode_indicator.py +70 -0
  80. shotgun/tui/components/status_bar.py +48 -0
  81. shotgun/tui/containers.py +91 -0
  82. shotgun/tui/dependencies.py +39 -0
  83. shotgun/tui/protocols.py +45 -0
  84. shotgun/tui/screens/chat/__init__.py +5 -0
  85. shotgun/tui/screens/chat/chat.tcss +54 -0
  86. shotgun/tui/screens/chat/chat_screen.py +1254 -0
  87. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  88. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  89. shotgun/tui/screens/chat/help_text.py +40 -0
  90. shotgun/tui/screens/chat/prompt_history.py +48 -0
  91. shotgun/tui/screens/chat.tcss +11 -0
  92. shotgun/tui/screens/chat_screen/command_providers.py +78 -2
  93. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  94. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  95. shotgun/tui/screens/chat_screen/history/chat_history.py +115 -0
  96. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  97. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  98. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  99. shotgun/tui/screens/confirmation_dialog.py +151 -0
  100. shotgun/tui/screens/feedback.py +4 -4
  101. shotgun/tui/screens/github_issue.py +102 -0
  102. shotgun/tui/screens/model_picker.py +49 -24
  103. shotgun/tui/screens/onboarding.py +431 -0
  104. shotgun/tui/screens/pipx_migration.py +153 -0
  105. shotgun/tui/screens/provider_config.py +50 -27
  106. shotgun/tui/screens/shotgun_auth.py +2 -2
  107. shotgun/tui/screens/welcome.py +23 -12
  108. shotgun/tui/services/__init__.py +5 -0
  109. shotgun/tui/services/conversation_service.py +184 -0
  110. shotgun/tui/state/__init__.py +7 -0
  111. shotgun/tui/state/processing_state.py +185 -0
  112. shotgun/tui/utils/mode_progress.py +14 -7
  113. shotgun/tui/widgets/__init__.py +5 -0
  114. shotgun/tui/widgets/widget_coordinator.py +263 -0
  115. shotgun/utils/file_system_utils.py +22 -2
  116. shotgun/utils/marketing.py +110 -0
  117. shotgun/utils/update_checker.py +69 -14
  118. shotgun_sh-0.2.17.dist-info/METADATA +465 -0
  119. shotgun_sh-0.2.17.dist-info/RECORD +194 -0
  120. {shotgun_sh-0.2.6.dev1.dist-info → shotgun_sh-0.2.17.dist-info}/entry_points.txt +1 -0
  121. {shotgun_sh-0.2.6.dev1.dist-info → shotgun_sh-0.2.17.dist-info}/licenses/LICENSE +1 -1
  122. shotgun/agents/tools/user_interaction.py +0 -37
  123. shotgun/tui/screens/chat.py +0 -804
  124. shotgun/tui/screens/chat_screen/history.py +0 -401
  125. shotgun_sh-0.2.6.dev1.dist-info/METADATA +0 -467
  126. shotgun_sh-0.2.6.dev1.dist-info/RECORD +0 -156
  127. {shotgun_sh-0.2.6.dev1.dist-info → shotgun_sh-0.2.17.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,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.config.models import KeyProvider
40
- 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
+ )
41
67
  from shotgun.posthog_telemetry import track_event
42
68
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage
43
69
  from shotgun.utils.source_detection import detect_source
@@ -45,7 +71,7 @@ from shotgun.utils.source_detection import detect_source
45
71
  from .export import create_export_agent
46
72
  from .history.compaction import apply_persistent_compaction
47
73
  from .messages import AgentSystemPrompt
48
- from .models import AgentDeps, AgentRuntimeOptions, UserAnswer
74
+ from .models import AgentDeps, AgentRuntimeOptions
49
75
  from .plan import create_plan_agent
50
76
  from .research import create_research_agent
51
77
  from .specify import create_specify_agent
@@ -54,6 +80,35 @@ from .tasks import create_tasks_agent
54
80
  logger = logging.getLogger(__name__)
55
81
 
56
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
+
57
112
  class MessageHistoryUpdated(Message):
58
113
  """Event posted when the message history is updated."""
59
114
 
@@ -92,6 +147,63 @@ class PartialResponseMessage(Message):
92
147
  self.is_last = is_last
93
148
 
94
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
+
95
207
  @dataclass(slots=True)
96
208
  class _PartialStreamState:
97
209
  """Tracks streamed messages while handling a single agent run."""
@@ -123,7 +235,7 @@ class AgentManager(Widget):
123
235
  self.deps = deps
124
236
 
125
237
  # Create AgentRuntimeOptions from deps for agent creation
126
- agent_runtime_options = AgentRuntimeOptions(
238
+ self._agent_runtime_options = AgentRuntimeOptions(
127
239
  interactive_mode=self.deps.interactive_mode,
128
240
  working_directory=self.deps.working_directory,
129
241
  is_tui_context=self.deps.is_tui_context,
@@ -132,22 +244,18 @@ class AgentManager(Widget):
132
244
  tasks=self.deps.tasks,
133
245
  )
134
246
 
135
- # Initialize all agents and store their specific deps
136
- self.research_agent, self.research_deps = create_research_agent(
137
- agent_runtime_options=agent_runtime_options
138
- )
139
- self.plan_agent, self.plan_deps = create_plan_agent(
140
- agent_runtime_options=agent_runtime_options
141
- )
142
- self.tasks_agent, self.tasks_deps = create_tasks_agent(
143
- agent_runtime_options=agent_runtime_options
144
- )
145
- self.specify_agent, self.specify_deps = create_specify_agent(
146
- agent_runtime_options=agent_runtime_options
147
- )
148
- self.export_agent, self.export_deps = create_export_agent(
149
- agent_runtime_options=agent_runtime_options
150
- )
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
151
259
 
152
260
  # Track current active agent
153
261
  self._current_agent_type: AgentType = initial_type
@@ -158,8 +266,125 @@ class AgentManager(Widget):
158
266
  self.recently_change_files: list[FileOperation] = []
159
267
  self._stream_state: _PartialStreamState | None = None
160
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
+
161
296
  @property
162
- def current_agent(self) -> Agent[AgentDeps, str | DeferredToolRequests]:
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
+
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]:
163
388
  """Get the currently active agent.
164
389
 
165
390
  Returns:
@@ -167,9 +392,7 @@ class AgentManager(Widget):
167
392
  """
168
393
  return self._get_agent(self._current_agent_type)
169
394
 
170
- def _get_agent(
171
- self, agent_type: AgentType
172
- ) -> Agent[AgentDeps, str | DeferredToolRequests]:
395
+ def _get_agent(self, agent_type: AgentType) -> Agent[AgentDeps, AgentResponse]:
173
396
  """Get agent by type.
174
397
 
175
398
  Args:
@@ -246,15 +469,57 @@ class AgentManager(Widget):
246
469
  f"Invalid agent type: {agent_type}. Must be one of: {', '.join(e.value for e in AgentType)}"
247
470
  ) from None
248
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
+
249
515
  async def run(
250
516
  self,
251
517
  prompt: str | None = None,
252
518
  *,
253
519
  deps: AgentDeps | None = None,
254
520
  usage_limits: UsageLimits | None = None,
255
- deferred_tool_results: DeferredToolResults | None = None,
256
521
  **kwargs: Any,
257
- ) -> AgentRunResult[str | DeferredToolRequests]:
522
+ ) -> AgentRunResult[AgentResponse]:
258
523
  """Run the current agent with automatic message history management.
259
524
 
260
525
  This method wraps the agent's run method, automatically injecting the
@@ -264,25 +529,18 @@ class AgentManager(Widget):
264
529
  prompt: Optional prompt to send to the agent.
265
530
  deps: Optional dependencies override (defaults to manager's deps).
266
531
  usage_limits: Optional usage limits for the agent run.
267
- deferred_tool_results: Optional deferred tool results for continuing a conversation.
268
532
  **kwargs: Additional keyword arguments to pass to the agent.
269
533
 
270
534
  Returns:
271
535
  The agent run result.
272
536
  """
537
+ # Ensure agents are initialized before running
538
+ await self._ensure_agents_initialized()
539
+
273
540
  logger.info(f"Running agent {self._current_agent_type.value}")
274
541
  # Use merged deps (shared state + agent-specific system prompt) if not provided
275
542
  if deps is None:
276
543
  deps = self._create_merged_deps(self._current_agent_type)
277
- ask_user_part = self.get_unanswered_ask_user_part()
278
- if ask_user_part and prompt:
279
- if not deferred_tool_results:
280
- deferred_tool_results = DeferredToolResults()
281
- deferred_tool_results.calls[ask_user_part.tool_call_id] = UserAnswer(
282
- answer=prompt,
283
- tool_call_id=ask_user_part.tool_call_id,
284
- )
285
- prompt = None
286
544
 
287
545
  # Ensure deps is not None
288
546
  if deps is None:
@@ -290,13 +548,12 @@ class AgentManager(Widget):
290
548
 
291
549
  # Clear file tracker before each run to track only this run's operations
292
550
  deps.file_tracker.clear()
293
- # preprocess messages; maybe we need to include the user answer in the message history
294
551
 
295
- 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
296
554
 
297
- if prompt:
298
- self.ui_message_history.append(ModelRequest.user_text_prompt(prompt))
299
- self._post_messages_updated()
555
+ # Save current message history before the run
556
+ original_messages = self.ui_message_history.copy()
300
557
 
301
558
  # Start with persistent message history
302
559
  message_history = self.message_history
@@ -378,44 +635,294 @@ class AgentManager(Widget):
378
635
  event_name,
379
636
  {
380
637
  "has_prompt": prompt is not None,
381
- "has_deferred_results": deferred_tool_results is not None,
382
638
  "model_name": model_name,
383
639
  },
384
640
  )
385
641
 
386
642
  try:
387
- result: AgentRunResult[
388
- str | DeferredToolRequests
389
- ] = await self.current_agent.run(
390
- prompt,
643
+ result: AgentRunResult[AgentResponse] = await self._run_agent_with_retry(
644
+ agent=self.current_agent,
645
+ prompt=prompt,
391
646
  deps=deps,
392
647
  usage_limits=usage_limits,
393
648
  message_history=message_history,
394
- deferred_tool_results=deferred_tool_results,
395
649
  event_stream_handler=self._handle_event_stream
396
650
  if not is_gpt5_byok
397
651
  else None,
398
652
  **kwargs,
399
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
400
702
  finally:
401
703
  self._stream_state = None
402
704
 
403
- 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(
404
727
  list[ModelRequest | ModelResponse | HintMessage], result.new_messages()
405
728
  )
406
729
 
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
752
+
753
+ # Get file operations early so we can use them for contextual messages
754
+ file_operations = deps.file_tracker.operations.copy()
755
+ self.recently_change_files = file_operations
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
+
407
859
  # Apply compaction to persistent message history to prevent cascading growth
408
860
  all_messages = result.all_messages()
409
- self.message_history = await apply_persistent_compaction(all_messages, deps)
410
- usage = result.usage()
411
- deps.usage_manager.add_usage(
412
- usage, model_name=deps.llm_model.name, provider=deps.llm_model.provider
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,
413
907
  )
414
908
 
415
- # Log file operations summary if any files were modified
416
- file_operations = deps.file_tracker.operations.copy()
417
- self.recently_change_files = file_operations
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
+ )
418
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
+ )
419
926
  self._post_messages_updated(file_operations)
420
927
 
421
928
  return result
@@ -427,6 +934,9 @@ class AgentManager(Widget):
427
934
  ) -> None:
428
935
  """Process streamed events and forward partial updates to the UI."""
429
936
 
937
+ # Notify UI that streaming has started
938
+ self.post_message(AgentStreamingStarted())
939
+
430
940
  state = self._stream_state
431
941
  if state is None:
432
942
  state = self._stream_state = _PartialStreamState()
@@ -491,6 +1001,39 @@ class AgentManager(Widget):
491
1001
  # Detect source from call stack
492
1002
  source = detect_source()
493
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
+
494
1037
  track_event(
495
1038
  "tool_called",
496
1039
  {
@@ -572,6 +1115,9 @@ class AgentManager(Widget):
572
1115
  self._post_partial_message(True)
573
1116
  state.current_response = None
574
1117
 
1118
+ # Notify UI that streaming has completed
1119
+ self.post_message(AgentStreamingCompleted())
1120
+
575
1121
  def _build_partial_response(
576
1122
  self, parts: list[ModelResponsePart | ToolCallPartDelta]
577
1123
  ) -> ModelResponse | None:
@@ -599,6 +1145,38 @@ class AgentManager(Widget):
599
1145
  )
600
1146
  )
601
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
+
602
1180
  def _post_messages_updated(
603
1181
  self, file_operations: list[FileOperation] | None = None
604
1182
  ) -> None:
@@ -660,6 +1238,62 @@ class AgentManager(Widget):
660
1238
  def get_usage_hint(self) -> str | None:
661
1239
  return self.deps.usage_manager.build_usage_hint()
662
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
+
663
1297
  def get_conversation_state(self) -> "ConversationState":
664
1298
  """Get the current conversation state.
665
1299
 
@@ -702,27 +1336,14 @@ class AgentManager(Widget):
702
1336
  self.ui_message_history.append(message)
703
1337
  self._post_messages_updated()
704
1338
 
705
- def get_unanswered_ask_user_part(self) -> ToolCallPart | None:
706
- if not self.message_history:
707
- return None
708
- self.last_response = self.message_history[-1]
709
- ## we're searching for unanswered ask_user parts
710
- found_tool = next(
711
- (
712
- part
713
- for part in self.message_history[-1].parts
714
- if isinstance(part, ToolCallPart) and part.tool_name == "ask_user"
715
- ),
716
- None,
717
- )
718
-
719
- return found_tool
720
-
721
1339
 
722
1340
  # Re-export AgentType for backward compatibility
723
1341
  __all__ = [
724
1342
  "AgentManager",
725
1343
  "AgentType",
1344
+ "ClarifyingQuestionsMessage",
1345
+ "CompactionCompletedMessage",
1346
+ "CompactionStartedMessage",
726
1347
  "MessageHistoryUpdated",
727
1348
  "PartialResponseMessage",
728
1349
  ]