shotgun-sh 0.2.3.dev2__py3-none-any.whl → 0.2.11.dev1__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 (107) hide show
  1. shotgun/agents/agent_manager.py +524 -58
  2. shotgun/agents/common.py +62 -62
  3. shotgun/agents/config/constants.py +0 -6
  4. shotgun/agents/config/manager.py +14 -3
  5. shotgun/agents/config/models.py +16 -0
  6. shotgun/agents/config/provider.py +68 -13
  7. shotgun/agents/context_analyzer/__init__.py +28 -0
  8. shotgun/agents/context_analyzer/analyzer.py +493 -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 +24 -2
  14. shotgun/agents/export.py +4 -5
  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 +32 -10
  19. shotgun/agents/models.py +50 -2
  20. shotgun/agents/plan.py +4 -5
  21. shotgun/agents/research.py +4 -5
  22. shotgun/agents/specify.py +4 -5
  23. shotgun/agents/tasks.py +4 -5
  24. shotgun/agents/tools/__init__.py +0 -2
  25. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  26. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  27. shotgun/agents/tools/codebase/file_read.py +6 -0
  28. shotgun/agents/tools/codebase/query_graph.py +6 -0
  29. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  30. shotgun/agents/tools/file_management.py +71 -9
  31. shotgun/agents/tools/registry.py +217 -0
  32. shotgun/agents/tools/web_search/__init__.py +24 -12
  33. shotgun/agents/tools/web_search/anthropic.py +24 -3
  34. shotgun/agents/tools/web_search/gemini.py +22 -10
  35. shotgun/agents/tools/web_search/openai.py +21 -12
  36. shotgun/api_endpoints.py +7 -3
  37. shotgun/build_constants.py +1 -1
  38. shotgun/cli/clear.py +52 -0
  39. shotgun/cli/compact.py +186 -0
  40. shotgun/cli/context.py +111 -0
  41. shotgun/cli/models.py +1 -0
  42. shotgun/cli/update.py +16 -2
  43. shotgun/codebase/core/manager.py +10 -1
  44. shotgun/llm_proxy/__init__.py +5 -2
  45. shotgun/llm_proxy/clients.py +12 -7
  46. shotgun/logging_config.py +8 -10
  47. shotgun/main.py +70 -10
  48. shotgun/posthog_telemetry.py +9 -3
  49. shotgun/prompts/agents/export.j2 +18 -1
  50. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
  51. shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
  52. shotgun/prompts/agents/plan.j2 +1 -1
  53. shotgun/prompts/agents/research.j2 +1 -1
  54. shotgun/prompts/agents/specify.j2 +270 -3
  55. shotgun/prompts/agents/state/system_state.j2 +4 -0
  56. shotgun/prompts/agents/tasks.j2 +1 -1
  57. shotgun/prompts/loader.py +2 -2
  58. shotgun/prompts/tools/web_search.j2 +14 -0
  59. shotgun/sentry_telemetry.py +4 -15
  60. shotgun/settings.py +238 -0
  61. shotgun/telemetry.py +15 -32
  62. shotgun/tui/app.py +203 -9
  63. shotgun/tui/commands/__init__.py +1 -1
  64. shotgun/tui/components/context_indicator.py +136 -0
  65. shotgun/tui/components/mode_indicator.py +70 -0
  66. shotgun/tui/components/status_bar.py +48 -0
  67. shotgun/tui/containers.py +93 -0
  68. shotgun/tui/dependencies.py +39 -0
  69. shotgun/tui/protocols.py +45 -0
  70. shotgun/tui/screens/chat/__init__.py +5 -0
  71. shotgun/tui/screens/chat/chat.tcss +54 -0
  72. shotgun/tui/screens/chat/chat_screen.py +1110 -0
  73. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  74. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  75. shotgun/tui/screens/chat/help_text.py +39 -0
  76. shotgun/tui/screens/chat/prompt_history.py +48 -0
  77. shotgun/tui/screens/chat.tcss +11 -0
  78. shotgun/tui/screens/chat_screen/command_providers.py +68 -2
  79. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  80. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  81. shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
  82. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  83. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  84. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  85. shotgun/tui/screens/confirmation_dialog.py +151 -0
  86. shotgun/tui/screens/model_picker.py +30 -6
  87. shotgun/tui/screens/pipx_migration.py +153 -0
  88. shotgun/tui/screens/welcome.py +24 -5
  89. shotgun/tui/services/__init__.py +5 -0
  90. shotgun/tui/services/conversation_service.py +182 -0
  91. shotgun/tui/state/__init__.py +7 -0
  92. shotgun/tui/state/processing_state.py +185 -0
  93. shotgun/tui/widgets/__init__.py +5 -0
  94. shotgun/tui/widgets/widget_coordinator.py +247 -0
  95. shotgun/utils/datetime_utils.py +77 -0
  96. shotgun/utils/file_system_utils.py +3 -2
  97. shotgun/utils/update_checker.py +69 -14
  98. shotgun_sh-0.2.11.dev1.dist-info/METADATA +129 -0
  99. shotgun_sh-0.2.11.dev1.dist-info/RECORD +190 -0
  100. {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev1.dist-info}/entry_points.txt +1 -0
  101. {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev1.dist-info}/licenses/LICENSE +1 -1
  102. shotgun/agents/tools/user_interaction.py +0 -37
  103. shotgun/tui/screens/chat.py +0 -804
  104. shotgun/tui/screens/chat_screen/history.py +0 -352
  105. shotgun_sh-0.2.3.dev2.dist-info/METADATA +0 -467
  106. shotgun_sh-0.2.3.dev2.dist-info/RECORD +0 -154
  107. {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev1.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,55 @@ 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
+ @dataclass(frozen=True)
173
+ class ModelConfigUpdated:
174
+ """Data returned when AI model configuration changes.
175
+
176
+ Used as a return value from ModelPickerScreen to communicate model
177
+ selection back to the calling screen.
178
+
179
+ Attributes:
180
+ old_model: Previous model name (None if first selection)
181
+ new_model: New model name
182
+ provider: LLM provider (OpenAI, Anthropic, Google)
183
+ key_provider: Authentication method (BYOK or Shotgun)
184
+ model_config: Complete model configuration
185
+ """
186
+
187
+ old_model: ModelName | None
188
+ new_model: ModelName
189
+ provider: ProviderType
190
+ key_provider: KeyProvider
191
+ model_config: ModelConfig
192
+
193
+
94
194
  @dataclass(slots=True)
95
195
  class _PartialStreamState:
96
196
  """Tracks streamed messages while handling a single agent run."""
@@ -157,8 +257,12 @@ class AgentManager(Widget):
157
257
  self.recently_change_files: list[FileOperation] = []
158
258
  self._stream_state: _PartialStreamState | None = None
159
259
 
260
+ # Q&A mode state for structured output questions
261
+ self._qa_questions: list[str] | None = None
262
+ self._qa_mode_active: bool = False
263
+
160
264
  @property
161
- def current_agent(self) -> Agent[AgentDeps, str | DeferredToolRequests]:
265
+ def current_agent(self) -> Agent[AgentDeps, AgentResponse]:
162
266
  """Get the currently active agent.
163
267
 
164
268
  Returns:
@@ -166,9 +270,7 @@ class AgentManager(Widget):
166
270
  """
167
271
  return self._get_agent(self._current_agent_type)
168
272
 
169
- def _get_agent(
170
- self, agent_type: AgentType
171
- ) -> Agent[AgentDeps, str | DeferredToolRequests]:
273
+ def _get_agent(self, agent_type: AgentType) -> Agent[AgentDeps, AgentResponse]:
172
274
  """Get agent by type.
173
275
 
174
276
  Args:
@@ -245,15 +347,57 @@ class AgentManager(Widget):
245
347
  f"Invalid agent type: {agent_type}. Must be one of: {', '.join(e.value for e in AgentType)}"
246
348
  ) from None
247
349
 
350
+ @retry(
351
+ stop=stop_after_attempt(3),
352
+ wait=wait_exponential(multiplier=1, min=1, max=8),
353
+ retry=retry_if_exception(_is_retryable_error),
354
+ before_sleep=before_sleep_log(logger, logging.WARNING),
355
+ reraise=True,
356
+ )
357
+ async def _run_agent_with_retry(
358
+ self,
359
+ agent: Agent[AgentDeps, AgentResponse],
360
+ prompt: str | None,
361
+ deps: AgentDeps,
362
+ usage_limits: UsageLimits | None,
363
+ message_history: list[ModelMessage],
364
+ event_stream_handler: Any,
365
+ **kwargs: Any,
366
+ ) -> AgentRunResult[AgentResponse]:
367
+ """Run agent with automatic retry on transient errors.
368
+
369
+ Args:
370
+ agent: The agent to run.
371
+ prompt: Optional prompt to send to the agent.
372
+ deps: Agent dependencies.
373
+ usage_limits: Optional usage limits.
374
+ message_history: Message history to provide to agent.
375
+ event_stream_handler: Event handler for streaming.
376
+ **kwargs: Additional keyword arguments.
377
+
378
+ Returns:
379
+ The agent run result.
380
+
381
+ Raises:
382
+ Various exceptions if all retries fail.
383
+ """
384
+ return await agent.run(
385
+ prompt,
386
+ deps=deps,
387
+ usage_limits=usage_limits,
388
+ message_history=message_history,
389
+ event_stream_handler=event_stream_handler,
390
+ **kwargs,
391
+ )
392
+
248
393
  async def run(
249
394
  self,
250
395
  prompt: str | None = None,
251
396
  *,
252
397
  deps: AgentDeps | None = None,
253
398
  usage_limits: UsageLimits | None = None,
254
- deferred_tool_results: DeferredToolResults | None = None,
255
399
  **kwargs: Any,
256
- ) -> AgentRunResult[str | DeferredToolRequests]:
400
+ ) -> AgentRunResult[AgentResponse]:
257
401
  """Run the current agent with automatic message history management.
258
402
 
259
403
  This method wraps the agent's run method, automatically injecting the
@@ -263,7 +407,6 @@ class AgentManager(Widget):
263
407
  prompt: Optional prompt to send to the agent.
264
408
  deps: Optional dependencies override (defaults to manager's deps).
265
409
  usage_limits: Optional usage limits for the agent run.
266
- deferred_tool_results: Optional deferred tool results for continuing a conversation.
267
410
  **kwargs: Additional keyword arguments to pass to the agent.
268
411
 
269
412
  Returns:
@@ -273,15 +416,6 @@ class AgentManager(Widget):
273
416
  # Use merged deps (shared state + agent-specific system prompt) if not provided
274
417
  if deps is None:
275
418
  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
419
 
286
420
  # Ensure deps is not None
287
421
  if deps is None:
@@ -289,13 +423,12 @@ class AgentManager(Widget):
289
423
 
290
424
  # Clear file tracker before each run to track only this run's operations
291
425
  deps.file_tracker.clear()
292
- # preprocess messages; maybe we need to include the user answer in the message history
293
426
 
294
- original_messages = self.ui_message_history.copy()
427
+ # Don't manually add the user prompt - Pydantic AI will include it in result.new_messages()
428
+ # This prevents duplicates and confusion with incremental mounting
295
429
 
296
- if prompt:
297
- self.ui_message_history.append(ModelRequest.user_text_prompt(prompt))
298
- self._post_messages_updated()
430
+ # Save current message history before the run
431
+ original_messages = self.ui_message_history.copy()
299
432
 
300
433
  # Start with persistent message history
301
434
  message_history = self.message_history
@@ -359,52 +492,309 @@ class AgentManager(Widget):
359
492
  model_name = ""
360
493
  if hasattr(deps, "llm_model") and deps.llm_model is not None:
361
494
  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()
495
+
496
+ # Check if it's a Shotgun account
497
+ is_shotgun_account = (
498
+ hasattr(deps, "llm_model")
499
+ and deps.llm_model is not None
500
+ and deps.llm_model.key_provider == KeyProvider.SHOTGUN
364
501
  )
365
502
 
503
+ # Only disable streaming for GPT-5 if NOT a Shotgun account
504
+ # Shotgun accounts support streaming for GPT-5
505
+ is_gpt5_byok = "gpt-5" in model_name.lower() and not is_shotgun_account
506
+
366
507
  # Track message send event
367
508
  event_name = f"message_send_{self._current_agent_type.value}"
368
509
  track_event(
369
510
  event_name,
370
511
  {
371
512
  "has_prompt": prompt is not None,
372
- "has_deferred_results": deferred_tool_results is not None,
373
513
  "model_name": model_name,
374
514
  },
375
515
  )
376
516
 
377
517
  try:
378
- result: AgentRunResult[
379
- str | DeferredToolRequests
380
- ] = await self.current_agent.run(
381
- prompt,
518
+ result: AgentRunResult[AgentResponse] = await self._run_agent_with_retry(
519
+ agent=self.current_agent,
520
+ prompt=prompt,
382
521
  deps=deps,
383
522
  usage_limits=usage_limits,
384
523
  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,
524
+ event_stream_handler=self._handle_event_stream
525
+ if not is_gpt5_byok
526
+ else None,
387
527
  **kwargs,
388
528
  )
529
+ except ValueError as e:
530
+ # Handle truncated/incomplete JSON in tool calls specifically
531
+ error_str = str(e)
532
+ if "EOF while parsing" in error_str or (
533
+ "JSON" in error_str and "parsing" in error_str
534
+ ):
535
+ logger.error(
536
+ "Tool call with truncated/incomplete JSON arguments detected",
537
+ extra={
538
+ "agent_mode": self._current_agent_type.value,
539
+ "model_name": model_name,
540
+ "error": error_str,
541
+ },
542
+ )
543
+ logfire.error(
544
+ "Tool call with truncated JSON arguments",
545
+ agent_mode=self._current_agent_type.value,
546
+ model_name=model_name,
547
+ error=error_str,
548
+ )
549
+ # Add helpful hint message for the user
550
+ self.ui_message_history.append(
551
+ HintMessage(
552
+ message="⚠️ The agent attempted an operation with arguments that were too large (truncated JSON). "
553
+ "Try breaking your request into smaller steps or more focused contracts."
554
+ )
555
+ )
556
+ self._post_messages_updated()
557
+ # Re-raise to maintain error visibility
558
+ raise
559
+ except Exception as e:
560
+ # Log the error with full stack trace to shotgun.log and Logfire
561
+ logger.exception(
562
+ "Agent execution failed",
563
+ extra={
564
+ "agent_mode": self._current_agent_type.value,
565
+ "model_name": model_name,
566
+ "error_type": type(e).__name__,
567
+ },
568
+ )
569
+ logfire.exception(
570
+ "Agent execution failed",
571
+ agent_mode=self._current_agent_type.value,
572
+ model_name=model_name,
573
+ error_type=type(e).__name__,
574
+ )
575
+ # Re-raise to let TUI handle user messaging
576
+ raise
389
577
  finally:
390
578
  self._stream_state = None
391
579
 
392
- self.ui_message_history = original_messages + cast(
580
+ # Agent ALWAYS returns AgentResponse with structured output
581
+ agent_response = result.output
582
+ logger.debug(
583
+ "Agent returned structured AgentResponse",
584
+ extra={
585
+ "has_response": agent_response.response is not None,
586
+ "response_length": len(agent_response.response)
587
+ if agent_response.response
588
+ else 0,
589
+ "response_preview": agent_response.response[:100] + "..."
590
+ if agent_response.response and len(agent_response.response) > 100
591
+ else agent_response.response or "(empty)",
592
+ "has_clarifying_questions": bool(agent_response.clarifying_questions),
593
+ "num_clarifying_questions": len(agent_response.clarifying_questions)
594
+ if agent_response.clarifying_questions
595
+ else 0,
596
+ },
597
+ )
598
+
599
+ # Merge agent's response messages, avoiding duplicates
600
+ # The TUI may have already added the user prompt, so check for it
601
+ new_messages = cast(
393
602
  list[ModelRequest | ModelResponse | HintMessage], result.new_messages()
394
603
  )
395
604
 
605
+ # Deduplicate: skip user prompts that are already in original_messages
606
+ deduplicated_new_messages = []
607
+ for msg in new_messages:
608
+ # Check if this is a user prompt that's already in original_messages
609
+ if isinstance(msg, ModelRequest) and any(
610
+ isinstance(part, UserPromptPart) for part in msg.parts
611
+ ):
612
+ # Check if an identical user prompt is already in original_messages
613
+ already_exists = any(
614
+ isinstance(existing, ModelRequest)
615
+ and any(isinstance(p, UserPromptPart) for p in existing.parts)
616
+ and existing.parts == msg.parts
617
+ for existing in original_messages[
618
+ -5:
619
+ ] # Check last 5 messages for efficiency
620
+ )
621
+ if already_exists:
622
+ continue # Skip this duplicate user prompt
623
+
624
+ deduplicated_new_messages.append(msg)
625
+
626
+ self.ui_message_history = original_messages + deduplicated_new_messages
627
+
628
+ # Get file operations early so we can use them for contextual messages
629
+ file_operations = deps.file_tracker.operations.copy()
630
+ self.recently_change_files = file_operations
631
+
632
+ logger.debug(
633
+ "File operations tracked",
634
+ extra={
635
+ "num_file_operations": len(file_operations),
636
+ "operation_files": [Path(op.file_path).name for op in file_operations],
637
+ },
638
+ )
639
+
640
+ # Check if there are clarifying questions
641
+ if agent_response.clarifying_questions:
642
+ logger.info(
643
+ f"Agent has {len(agent_response.clarifying_questions)} clarifying questions"
644
+ )
645
+
646
+ # Add agent's response first if present
647
+ if agent_response.response:
648
+ self.ui_message_history.append(
649
+ HintMessage(message=agent_response.response)
650
+ )
651
+
652
+ if len(agent_response.clarifying_questions) == 1:
653
+ # Single question - treat as non-blocking suggestion, DON'T enter Q&A mode
654
+ self.ui_message_history.append(
655
+ HintMessage(message=f"💡 {agent_response.clarifying_questions[0]}")
656
+ )
657
+ else:
658
+ # Multiple questions (2+) - enter Q&A mode
659
+ self._qa_questions = agent_response.clarifying_questions
660
+ self._qa_mode_active = True
661
+
662
+ # Show intro with list, then first question
663
+ questions_list_with_intro = (
664
+ f"I have {len(agent_response.clarifying_questions)} questions:\n\n"
665
+ + "\n".join(
666
+ f"{i + 1}. {q}"
667
+ for i, q in enumerate(agent_response.clarifying_questions)
668
+ )
669
+ )
670
+ self.ui_message_history.append(
671
+ HintMessage(message=questions_list_with_intro)
672
+ )
673
+ self.ui_message_history.append(
674
+ HintMessage(
675
+ message=f"**Q1:** {agent_response.clarifying_questions[0]}"
676
+ )
677
+ )
678
+
679
+ # Post event to TUI to update Q&A mode state (only for multiple questions)
680
+ self.post_message(
681
+ ClarifyingQuestionsMessage(
682
+ questions=agent_response.clarifying_questions,
683
+ response_text=agent_response.response,
684
+ )
685
+ )
686
+
687
+ # Post UI update with hint messages and file operations
688
+ logger.debug(
689
+ "Posting UI update for Q&A mode with hint messages and file operations"
690
+ )
691
+ self._post_messages_updated(file_operations)
692
+ else:
693
+ # No clarifying questions - show the response or a default success message
694
+ if agent_response.response and agent_response.response.strip():
695
+ logger.debug(
696
+ "Adding agent response as hint",
697
+ extra={
698
+ "response_preview": agent_response.response[:100] + "..."
699
+ if len(agent_response.response) > 100
700
+ else agent_response.response,
701
+ "has_file_operations": len(file_operations) > 0,
702
+ },
703
+ )
704
+ self.ui_message_history.append(
705
+ HintMessage(message=agent_response.response)
706
+ )
707
+ else:
708
+ # Fallback: response is empty or whitespace
709
+ logger.debug(
710
+ "Agent response was empty, using fallback completion message",
711
+ extra={"has_file_operations": len(file_operations) > 0},
712
+ )
713
+ # Show contextual message based on whether files were modified
714
+ if file_operations:
715
+ self.ui_message_history.append(
716
+ HintMessage(
717
+ message="✅ Task completed - files have been modified"
718
+ )
719
+ )
720
+ else:
721
+ self.ui_message_history.append(
722
+ HintMessage(message="✅ Task completed")
723
+ )
724
+
725
+ # Post UI update immediately so user sees the response without delay
726
+ logger.debug(
727
+ "Posting immediate UI update with hint message and file operations"
728
+ )
729
+ self._post_messages_updated(file_operations)
730
+
396
731
  # Apply compaction to persistent message history to prevent cascading growth
397
732
  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
733
+ messages_before_compaction = len(all_messages)
734
+ compaction_occurred = False
735
+
736
+ try:
737
+ logger.debug(
738
+ "Starting message history compaction",
739
+ extra={"message_count": len(all_messages)},
740
+ )
741
+ # Notify UI that compaction is starting
742
+ self.post_message(CompactionStartedMessage())
743
+
744
+ self.message_history = await apply_persistent_compaction(all_messages, deps)
745
+
746
+ # Track if compaction actually modified the history
747
+ compaction_occurred = len(self.message_history) != len(all_messages)
748
+
749
+ # Notify UI that compaction is complete
750
+ self.post_message(CompactionCompletedMessage())
751
+
752
+ logger.debug(
753
+ "Completed message history compaction",
754
+ extra={
755
+ "original_count": len(all_messages),
756
+ "compacted_count": len(self.message_history),
757
+ },
758
+ )
759
+ except Exception as e:
760
+ # If compaction fails, log full error with stack trace and use uncompacted messages
761
+ logger.error(
762
+ "Failed to compact message history - using uncompacted messages",
763
+ exc_info=True,
764
+ extra={
765
+ "error": str(e),
766
+ "message_count": len(all_messages),
767
+ "agent_mode": self._current_agent_type.value,
768
+ },
769
+ )
770
+ # Fallback: use uncompacted messages to prevent data loss
771
+ self.message_history = all_messages
772
+
773
+ # Track context composition telemetry
774
+ await self._track_context_analysis(
775
+ compaction_occurred=compaction_occurred,
776
+ messages_before_compaction=messages_before_compaction
777
+ if compaction_occurred
778
+ else None,
402
779
  )
403
780
 
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
781
+ usage = result.usage()
782
+ if hasattr(deps, "llm_model") and deps.llm_model is not None:
783
+ deps.usage_manager.add_usage(
784
+ usage, model_name=deps.llm_model.name, provider=deps.llm_model.provider
785
+ )
786
+ else:
787
+ logger.warning(
788
+ "llm_model is None, skipping usage tracking",
789
+ extra={"agent_mode": self._current_agent_type.value},
790
+ )
407
791
 
792
+ # Post final UI update after compaction completes
793
+ # This ensures widgets that depend on message_history (like context indicator)
794
+ # receive the updated history after compaction
795
+ logger.debug(
796
+ "Posting final UI update after compaction with updated message_history"
797
+ )
408
798
  self._post_messages_updated(file_operations)
409
799
 
410
800
  return result
@@ -480,6 +870,39 @@ class AgentManager(Widget):
480
870
  # Detect source from call stack
481
871
  source = detect_source()
482
872
 
873
+ # Log if tool call has incomplete args (for debugging truncated JSON)
874
+ if isinstance(event.part.args, str):
875
+ try:
876
+ json.loads(event.part.args)
877
+ except (json.JSONDecodeError, ValueError):
878
+ args_preview = (
879
+ event.part.args[:100] + "..."
880
+ if len(event.part.args) > 100
881
+ else event.part.args
882
+ )
883
+ logger.warning(
884
+ "FunctionToolCallEvent received with incomplete JSON args",
885
+ extra={
886
+ "tool_name": event.part.tool_name,
887
+ "tool_call_id": event.part.tool_call_id,
888
+ "args_preview": args_preview,
889
+ "args_length": len(event.part.args)
890
+ if event.part.args
891
+ else 0,
892
+ "agent_mode": self._current_agent_type.value,
893
+ },
894
+ )
895
+ logfire.warn(
896
+ "FunctionToolCallEvent received with incomplete JSON args",
897
+ tool_name=event.part.tool_name,
898
+ tool_call_id=event.part.tool_call_id,
899
+ args_preview=args_preview,
900
+ args_length=len(event.part.args)
901
+ if event.part.args
902
+ else 0,
903
+ agent_mode=self._current_agent_type.value,
904
+ )
905
+
483
906
  track_event(
484
907
  "tool_called",
485
908
  {
@@ -649,6 +1072,62 @@ class AgentManager(Widget):
649
1072
  def get_usage_hint(self) -> str | None:
650
1073
  return self.deps.usage_manager.build_usage_hint()
651
1074
 
1075
+ async def get_context_hint(self) -> str | None:
1076
+ """Get conversation context analysis as a formatted hint.
1077
+
1078
+ Returns:
1079
+ Markdown-formatted string with context composition statistics, or None if unavailable
1080
+ """
1081
+ analysis = await self.get_context_analysis()
1082
+ if analysis:
1083
+ return ContextFormatter.format_markdown(analysis)
1084
+ return None
1085
+
1086
+ async def get_context_analysis(self) -> ContextAnalysis | None:
1087
+ """Get conversation context analysis as structured data.
1088
+
1089
+ Returns:
1090
+ ContextAnalysis object with token usage data, or None if unavailable
1091
+ """
1092
+
1093
+ try:
1094
+ analyzer = ContextAnalyzer(self.deps.llm_model)
1095
+ return await analyzer.analyze_conversation(
1096
+ self.message_history, self.ui_message_history
1097
+ )
1098
+ except Exception as e:
1099
+ logger.error(f"Failed to generate context analysis: {e}", exc_info=True)
1100
+ return None
1101
+
1102
+ async def _track_context_analysis(
1103
+ self,
1104
+ compaction_occurred: bool = False,
1105
+ messages_before_compaction: int | None = None,
1106
+ ) -> None:
1107
+ """Track context composition telemetry to PostHog.
1108
+
1109
+ Args:
1110
+ compaction_occurred: Whether compaction was applied
1111
+ messages_before_compaction: Message count before compaction, if it occurred
1112
+ """
1113
+ try:
1114
+ analyzer = ContextAnalyzer(self.deps.llm_model)
1115
+ analysis = await analyzer.analyze_conversation(
1116
+ self.message_history, self.ui_message_history
1117
+ )
1118
+
1119
+ # Create telemetry model from analysis
1120
+ telemetry = ContextCompositionTelemetry.from_analysis(
1121
+ analysis,
1122
+ compaction_occurred=compaction_occurred,
1123
+ messages_before_compaction=messages_before_compaction,
1124
+ )
1125
+
1126
+ # Send to PostHog using model_dump() for dict conversion
1127
+ track_event("agent_context_composition", telemetry.model_dump())
1128
+ except Exception as e:
1129
+ logger.warning(f"Failed to track context analysis: {e}")
1130
+
652
1131
  def get_conversation_state(self) -> "ConversationState":
653
1132
  """Get the current conversation state.
654
1133
 
@@ -691,27 +1170,14 @@ class AgentManager(Widget):
691
1170
  self.ui_message_history.append(message)
692
1171
  self._post_messages_updated()
693
1172
 
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
1173
 
711
1174
  # Re-export AgentType for backward compatibility
712
1175
  __all__ = [
713
1176
  "AgentManager",
714
1177
  "AgentType",
1178
+ "ClarifyingQuestionsMessage",
1179
+ "CompactionCompletedMessage",
1180
+ "CompactionStartedMessage",
715
1181
  "MessageHistoryUpdated",
716
1182
  "PartialResponseMessage",
717
1183
  ]