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.
- shotgun/agents/agent_manager.py +524 -58
- shotgun/agents/common.py +62 -62
- shotgun/agents/config/constants.py +0 -6
- shotgun/agents/config/manager.py +14 -3
- shotgun/agents/config/models.py +16 -0
- shotgun/agents/config/provider.py +68 -13
- shotgun/agents/context_analyzer/__init__.py +28 -0
- shotgun/agents/context_analyzer/analyzer.py +493 -0
- shotgun/agents/context_analyzer/constants.py +9 -0
- shotgun/agents/context_analyzer/formatter.py +115 -0
- shotgun/agents/context_analyzer/models.py +212 -0
- shotgun/agents/conversation_history.py +125 -2
- shotgun/agents/conversation_manager.py +24 -2
- shotgun/agents/export.py +4 -5
- shotgun/agents/history/compaction.py +9 -4
- shotgun/agents/history/context_extraction.py +93 -6
- shotgun/agents/history/history_processors.py +14 -2
- shotgun/agents/history/token_counting/anthropic.py +32 -10
- shotgun/agents/models.py +50 -2
- shotgun/agents/plan.py +4 -5
- shotgun/agents/research.py +4 -5
- shotgun/agents/specify.py +4 -5
- shotgun/agents/tasks.py +4 -5
- shotgun/agents/tools/__init__.py +0 -2
- shotgun/agents/tools/codebase/codebase_shell.py +6 -0
- shotgun/agents/tools/codebase/directory_lister.py +6 -0
- shotgun/agents/tools/codebase/file_read.py +6 -0
- shotgun/agents/tools/codebase/query_graph.py +6 -0
- shotgun/agents/tools/codebase/retrieve_code.py +6 -0
- shotgun/agents/tools/file_management.py +71 -9
- shotgun/agents/tools/registry.py +217 -0
- shotgun/agents/tools/web_search/__init__.py +24 -12
- shotgun/agents/tools/web_search/anthropic.py +24 -3
- shotgun/agents/tools/web_search/gemini.py +22 -10
- shotgun/agents/tools/web_search/openai.py +21 -12
- shotgun/api_endpoints.py +7 -3
- shotgun/build_constants.py +1 -1
- shotgun/cli/clear.py +52 -0
- shotgun/cli/compact.py +186 -0
- shotgun/cli/context.py +111 -0
- shotgun/cli/models.py +1 -0
- shotgun/cli/update.py +16 -2
- shotgun/codebase/core/manager.py +10 -1
- shotgun/llm_proxy/__init__.py +5 -2
- shotgun/llm_proxy/clients.py +12 -7
- shotgun/logging_config.py +8 -10
- shotgun/main.py +70 -10
- shotgun/posthog_telemetry.py +9 -3
- shotgun/prompts/agents/export.j2 +18 -1
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
- shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
- shotgun/prompts/agents/plan.j2 +1 -1
- shotgun/prompts/agents/research.j2 +1 -1
- shotgun/prompts/agents/specify.j2 +270 -3
- shotgun/prompts/agents/state/system_state.j2 +4 -0
- shotgun/prompts/agents/tasks.j2 +1 -1
- shotgun/prompts/loader.py +2 -2
- shotgun/prompts/tools/web_search.j2 +14 -0
- shotgun/sentry_telemetry.py +4 -15
- shotgun/settings.py +238 -0
- shotgun/telemetry.py +15 -32
- shotgun/tui/app.py +203 -9
- shotgun/tui/commands/__init__.py +1 -1
- shotgun/tui/components/context_indicator.py +136 -0
- shotgun/tui/components/mode_indicator.py +70 -0
- shotgun/tui/components/status_bar.py +48 -0
- shotgun/tui/containers.py +93 -0
- shotgun/tui/dependencies.py +39 -0
- shotgun/tui/protocols.py +45 -0
- shotgun/tui/screens/chat/__init__.py +5 -0
- shotgun/tui/screens/chat/chat.tcss +54 -0
- shotgun/tui/screens/chat/chat_screen.py +1110 -0
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
- shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
- shotgun/tui/screens/chat/help_text.py +39 -0
- shotgun/tui/screens/chat/prompt_history.py +48 -0
- shotgun/tui/screens/chat.tcss +11 -0
- shotgun/tui/screens/chat_screen/command_providers.py +68 -2
- shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
- shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
- shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
- shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
- shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
- shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
- shotgun/tui/screens/confirmation_dialog.py +151 -0
- shotgun/tui/screens/model_picker.py +30 -6
- shotgun/tui/screens/pipx_migration.py +153 -0
- shotgun/tui/screens/welcome.py +24 -5
- shotgun/tui/services/__init__.py +5 -0
- shotgun/tui/services/conversation_service.py +182 -0
- shotgun/tui/state/__init__.py +7 -0
- shotgun/tui/state/processing_state.py +185 -0
- shotgun/tui/widgets/__init__.py +5 -0
- shotgun/tui/widgets/widget_coordinator.py +247 -0
- shotgun/utils/datetime_utils.py +77 -0
- shotgun/utils/file_system_utils.py +3 -2
- shotgun/utils/update_checker.py +69 -14
- shotgun_sh-0.2.11.dev1.dist-info/METADATA +129 -0
- shotgun_sh-0.2.11.dev1.dist-info/RECORD +190 -0
- {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev1.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev1.dist-info}/licenses/LICENSE +1 -1
- shotgun/agents/tools/user_interaction.py +0 -37
- shotgun/tui/screens/chat.py +0 -804
- shotgun/tui/screens/chat_screen/history.py +0 -352
- shotgun_sh-0.2.3.dev2.dist-info/METADATA +0 -467
- shotgun_sh-0.2.3.dev2.dist-info/RECORD +0 -154
- {shotgun_sh-0.2.3.dev2.dist-info → shotgun_sh-0.2.11.dev1.dist-info}/WHEEL +0 -0
shotgun/agents/agent_manager.py
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
]
|