shotgun-sh 0.1.9__py3-none-any.whl → 0.2.11__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of shotgun-sh might be problematic. Click here for more details.
- shotgun/agents/agent_manager.py +761 -52
- shotgun/agents/common.py +80 -75
- shotgun/agents/config/constants.py +21 -10
- shotgun/agents/config/manager.py +322 -97
- shotgun/agents/config/models.py +114 -84
- shotgun/agents/config/provider.py +232 -88
- shotgun/agents/context_analyzer/__init__.py +28 -0
- shotgun/agents/context_analyzer/analyzer.py +471 -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 +57 -19
- shotgun/agents/export.py +6 -7
- shotgun/agents/history/compaction.py +23 -3
- shotgun/agents/history/context_extraction.py +93 -6
- shotgun/agents/history/history_processors.py +179 -11
- shotgun/agents/history/token_counting/__init__.py +31 -0
- shotgun/agents/history/token_counting/anthropic.py +127 -0
- shotgun/agents/history/token_counting/base.py +78 -0
- shotgun/agents/history/token_counting/openai.py +90 -0
- shotgun/agents/history/token_counting/sentencepiece_counter.py +127 -0
- shotgun/agents/history/token_counting/tokenizer_cache.py +92 -0
- shotgun/agents/history/token_counting/utils.py +144 -0
- shotgun/agents/history/token_estimation.py +12 -12
- shotgun/agents/llm.py +62 -0
- shotgun/agents/models.py +59 -4
- shotgun/agents/plan.py +6 -7
- shotgun/agents/research.py +7 -8
- shotgun/agents/specify.py +6 -7
- shotgun/agents/tasks.py +6 -7
- 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 +11 -2
- shotgun/agents/tools/codebase/query_graph.py +6 -0
- shotgun/agents/tools/codebase/retrieve_code.py +6 -0
- shotgun/agents/tools/file_management.py +82 -16
- shotgun/agents/tools/registry.py +217 -0
- shotgun/agents/tools/web_search/__init__.py +55 -16
- shotgun/agents/tools/web_search/anthropic.py +76 -51
- shotgun/agents/tools/web_search/gemini.py +50 -27
- shotgun/agents/tools/web_search/openai.py +26 -17
- shotgun/agents/tools/web_search/utils.py +2 -2
- shotgun/agents/usage_manager.py +164 -0
- shotgun/api_endpoints.py +15 -0
- shotgun/cli/clear.py +53 -0
- shotgun/cli/codebase/commands.py +71 -2
- shotgun/cli/compact.py +186 -0
- shotgun/cli/config.py +41 -67
- shotgun/cli/context.py +111 -0
- shotgun/cli/export.py +1 -1
- shotgun/cli/feedback.py +50 -0
- shotgun/cli/models.py +3 -2
- shotgun/cli/plan.py +1 -1
- shotgun/cli/research.py +1 -1
- shotgun/cli/specify.py +1 -1
- shotgun/cli/tasks.py +1 -1
- shotgun/cli/update.py +18 -5
- shotgun/codebase/core/change_detector.py +5 -3
- shotgun/codebase/core/code_retrieval.py +4 -2
- shotgun/codebase/core/ingestor.py +169 -19
- shotgun/codebase/core/manager.py +177 -13
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/codebase/models.py +28 -3
- shotgun/codebase/service.py +14 -2
- shotgun/exceptions.py +32 -0
- shotgun/llm_proxy/__init__.py +19 -0
- shotgun/llm_proxy/clients.py +44 -0
- shotgun/llm_proxy/constants.py +15 -0
- shotgun/logging_config.py +18 -27
- shotgun/main.py +91 -4
- shotgun/posthog_telemetry.py +87 -40
- 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/codebase/partials/cypher_rules.j2 +13 -0
- shotgun/prompts/loader.py +2 -2
- shotgun/prompts/tools/web_search.j2 +14 -0
- shotgun/sdk/codebase.py +60 -2
- shotgun/sentry_telemetry.py +28 -21
- shotgun/settings.py +238 -0
- shotgun/shotgun_web/__init__.py +19 -0
- shotgun/shotgun_web/client.py +138 -0
- shotgun/shotgun_web/constants.py +21 -0
- shotgun/shotgun_web/models.py +47 -0
- shotgun/telemetry.py +24 -36
- shotgun/tui/app.py +275 -23
- shotgun/tui/commands/__init__.py +1 -1
- shotgun/tui/components/context_indicator.py +179 -0
- shotgun/tui/components/mode_indicator.py +70 -0
- shotgun/tui/components/status_bar.py +48 -0
- shotgun/tui/components/vertical_tail.py +6 -0
- shotgun/tui/containers.py +91 -0
- shotgun/tui/dependencies.py +39 -0
- shotgun/tui/filtered_codebase_service.py +46 -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 +1234 -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 +40 -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 +226 -11
- 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/feedback.py +193 -0
- shotgun/tui/screens/github_issue.py +102 -0
- shotgun/tui/screens/model_picker.py +352 -0
- shotgun/tui/screens/onboarding.py +431 -0
- shotgun/tui/screens/pipx_migration.py +153 -0
- shotgun/tui/screens/provider_config.py +156 -39
- shotgun/tui/screens/shotgun_auth.py +295 -0
- shotgun/tui/screens/welcome.py +198 -0
- shotgun/tui/services/__init__.py +5 -0
- shotgun/tui/services/conversation_service.py +184 -0
- shotgun/tui/state/__init__.py +7 -0
- shotgun/tui/state/processing_state.py +185 -0
- shotgun/tui/utils/mode_progress.py +14 -7
- shotgun/tui/widgets/__init__.py +5 -0
- shotgun/tui/widgets/widget_coordinator.py +262 -0
- shotgun/utils/datetime_utils.py +77 -0
- shotgun/utils/env_utils.py +13 -0
- shotgun/utils/file_system_utils.py +22 -2
- shotgun/utils/marketing.py +110 -0
- shotgun/utils/source_detection.py +16 -0
- shotgun/utils/update_checker.py +73 -21
- shotgun_sh-0.2.11.dist-info/METADATA +130 -0
- shotgun_sh-0.2.11.dist-info/RECORD +194 -0
- {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/licenses/LICENSE +1 -1
- shotgun/agents/history/token_counting.py +0 -429
- shotgun/agents/tools/user_interaction.py +0 -37
- shotgun/tui/screens/chat.py +0 -818
- shotgun/tui/screens/chat_screen/history.py +0 -222
- shotgun_sh-0.1.9.dist-info/METADATA +0 -466
- shotgun_sh-0.1.9.dist-info/RECORD +0 -131
- {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.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,13 +40,33 @@ 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 (
|
|
62
|
+
AgentResponse,
|
|
63
|
+
AgentType,
|
|
64
|
+
FileOperation,
|
|
65
|
+
FileOperationTracker,
|
|
66
|
+
)
|
|
67
|
+
from shotgun.posthog_telemetry import track_event
|
|
40
68
|
from shotgun.tui.screens.chat_screen.hint_message import HintMessage
|
|
69
|
+
from shotgun.utils.source_detection import detect_source
|
|
41
70
|
|
|
42
71
|
from .export import create_export_agent
|
|
43
72
|
from .history.compaction import apply_persistent_compaction
|
|
@@ -51,6 +80,35 @@ from .tasks import create_tasks_agent
|
|
|
51
80
|
logger = logging.getLogger(__name__)
|
|
52
81
|
|
|
53
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
|
+
|
|
54
112
|
class MessageHistoryUpdated(Message):
|
|
55
113
|
"""Event posted when the message history is updated."""
|
|
56
114
|
|
|
@@ -89,6 +147,63 @@ class PartialResponseMessage(Message):
|
|
|
89
147
|
self.is_last = is_last
|
|
90
148
|
|
|
91
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
|
+
|
|
92
207
|
@dataclass(slots=True)
|
|
93
208
|
class _PartialStreamState:
|
|
94
209
|
"""Tracks streamed messages while handling a single agent run."""
|
|
@@ -113,14 +228,14 @@ class AgentManager(Widget):
|
|
|
113
228
|
super().__init__()
|
|
114
229
|
self.display = False
|
|
115
230
|
|
|
231
|
+
if deps is None:
|
|
232
|
+
raise ValueError("AgentDeps must be provided to AgentManager")
|
|
233
|
+
|
|
116
234
|
# Use provided deps or create default with interactive mode
|
|
117
235
|
self.deps = deps
|
|
118
236
|
|
|
119
|
-
if self.deps is None:
|
|
120
|
-
raise ValueError("AgentDeps must be provided to AgentManager")
|
|
121
|
-
|
|
122
237
|
# Create AgentRuntimeOptions from deps for agent creation
|
|
123
|
-
|
|
238
|
+
self._agent_runtime_options = AgentRuntimeOptions(
|
|
124
239
|
interactive_mode=self.deps.interactive_mode,
|
|
125
240
|
working_directory=self.deps.working_directory,
|
|
126
241
|
is_tui_context=self.deps.is_tui_context,
|
|
@@ -129,22 +244,18 @@ class AgentManager(Widget):
|
|
|
129
244
|
tasks=self.deps.tasks,
|
|
130
245
|
)
|
|
131
246
|
|
|
132
|
-
#
|
|
133
|
-
self.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
self.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
self.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
self.
|
|
143
|
-
|
|
144
|
-
)
|
|
145
|
-
self.export_agent, self.export_deps = create_export_agent(
|
|
146
|
-
agent_runtime_options=agent_runtime_options
|
|
147
|
-
)
|
|
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
|
|
148
259
|
|
|
149
260
|
# Track current active agent
|
|
150
261
|
self._current_agent_type: AgentType = initial_type
|
|
@@ -155,8 +266,125 @@ class AgentManager(Widget):
|
|
|
155
266
|
self.recently_change_files: list[FileOperation] = []
|
|
156
267
|
self._stream_state: _PartialStreamState | None = None
|
|
157
268
|
|
|
269
|
+
# Q&A mode state for structured output questions
|
|
270
|
+
self._qa_questions: list[str] | None = None
|
|
271
|
+
self._qa_mode_active: bool = False
|
|
272
|
+
|
|
273
|
+
async def _ensure_agents_initialized(self) -> None:
|
|
274
|
+
"""Ensure all agents are initialized (lazy initialization)."""
|
|
275
|
+
if self._agents_initialized:
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
# Initialize all agents asynchronously
|
|
279
|
+
self._research_agent, self._research_deps = await create_research_agent(
|
|
280
|
+
agent_runtime_options=self._agent_runtime_options
|
|
281
|
+
)
|
|
282
|
+
self._plan_agent, self._plan_deps = await create_plan_agent(
|
|
283
|
+
agent_runtime_options=self._agent_runtime_options
|
|
284
|
+
)
|
|
285
|
+
self._tasks_agent, self._tasks_deps = await create_tasks_agent(
|
|
286
|
+
agent_runtime_options=self._agent_runtime_options
|
|
287
|
+
)
|
|
288
|
+
self._specify_agent, self._specify_deps = await create_specify_agent(
|
|
289
|
+
agent_runtime_options=self._agent_runtime_options
|
|
290
|
+
)
|
|
291
|
+
self._export_agent, self._export_deps = await create_export_agent(
|
|
292
|
+
agent_runtime_options=self._agent_runtime_options
|
|
293
|
+
)
|
|
294
|
+
self._agents_initialized = True
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def research_agent(self) -> Agent[AgentDeps, AgentResponse]:
|
|
298
|
+
"""Get research agent (must call _ensure_agents_initialized first)."""
|
|
299
|
+
if self._research_agent is None:
|
|
300
|
+
raise RuntimeError(
|
|
301
|
+
"Agents not initialized. Call _ensure_agents_initialized() first."
|
|
302
|
+
)
|
|
303
|
+
return self._research_agent
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def research_deps(self) -> AgentDeps:
|
|
307
|
+
"""Get research deps (must call _ensure_agents_initialized first)."""
|
|
308
|
+
if self._research_deps is None:
|
|
309
|
+
raise RuntimeError(
|
|
310
|
+
"Agents not initialized. Call _ensure_agents_initialized() first."
|
|
311
|
+
)
|
|
312
|
+
return self._research_deps
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def plan_agent(self) -> Agent[AgentDeps, AgentResponse]:
|
|
316
|
+
"""Get plan agent (must call _ensure_agents_initialized first)."""
|
|
317
|
+
if self._plan_agent is None:
|
|
318
|
+
raise RuntimeError(
|
|
319
|
+
"Agents not initialized. Call _ensure_agents_initialized() first."
|
|
320
|
+
)
|
|
321
|
+
return self._plan_agent
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def plan_deps(self) -> AgentDeps:
|
|
325
|
+
"""Get plan deps (must call _ensure_agents_initialized first)."""
|
|
326
|
+
if self._plan_deps is None:
|
|
327
|
+
raise RuntimeError(
|
|
328
|
+
"Agents not initialized. Call _ensure_agents_initialized() first."
|
|
329
|
+
)
|
|
330
|
+
return self._plan_deps
|
|
331
|
+
|
|
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
|
+
|
|
158
368
|
@property
|
|
159
|
-
def
|
|
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]:
|
|
160
388
|
"""Get the currently active agent.
|
|
161
389
|
|
|
162
390
|
Returns:
|
|
@@ -164,9 +392,7 @@ class AgentManager(Widget):
|
|
|
164
392
|
"""
|
|
165
393
|
return self._get_agent(self._current_agent_type)
|
|
166
394
|
|
|
167
|
-
def _get_agent(
|
|
168
|
-
self, agent_type: AgentType
|
|
169
|
-
) -> Agent[AgentDeps, str | DeferredToolRequests]:
|
|
395
|
+
def _get_agent(self, agent_type: AgentType) -> Agent[AgentDeps, AgentResponse]:
|
|
170
396
|
"""Get agent by type.
|
|
171
397
|
|
|
172
398
|
Args:
|
|
@@ -243,15 +469,57 @@ class AgentManager(Widget):
|
|
|
243
469
|
f"Invalid agent type: {agent_type}. Must be one of: {', '.join(e.value for e in AgentType)}"
|
|
244
470
|
) from None
|
|
245
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
|
+
|
|
246
515
|
async def run(
|
|
247
516
|
self,
|
|
248
517
|
prompt: str | None = None,
|
|
249
518
|
*,
|
|
250
519
|
deps: AgentDeps | None = None,
|
|
251
520
|
usage_limits: UsageLimits | None = None,
|
|
252
|
-
deferred_tool_results: DeferredToolResults | None = None,
|
|
253
521
|
**kwargs: Any,
|
|
254
|
-
) -> AgentRunResult[
|
|
522
|
+
) -> AgentRunResult[AgentResponse]:
|
|
255
523
|
"""Run the current agent with automatic message history management.
|
|
256
524
|
|
|
257
525
|
This method wraps the agent's run method, automatically injecting the
|
|
@@ -261,12 +529,15 @@ class AgentManager(Widget):
|
|
|
261
529
|
prompt: Optional prompt to send to the agent.
|
|
262
530
|
deps: Optional dependencies override (defaults to manager's deps).
|
|
263
531
|
usage_limits: Optional usage limits for the agent run.
|
|
264
|
-
deferred_tool_results: Optional deferred tool results for continuing a conversation.
|
|
265
532
|
**kwargs: Additional keyword arguments to pass to the agent.
|
|
266
533
|
|
|
267
534
|
Returns:
|
|
268
535
|
The agent run result.
|
|
269
536
|
"""
|
|
537
|
+
# Ensure agents are initialized before running
|
|
538
|
+
await self._ensure_agents_initialized()
|
|
539
|
+
|
|
540
|
+
logger.info(f"Running agent {self._current_agent_type.value}")
|
|
270
541
|
# Use merged deps (shared state + agent-specific system prompt) if not provided
|
|
271
542
|
if deps is None:
|
|
272
543
|
deps = self._create_merged_deps(self._current_agent_type)
|
|
@@ -277,11 +548,12 @@ class AgentManager(Widget):
|
|
|
277
548
|
|
|
278
549
|
# Clear file tracker before each run to track only this run's operations
|
|
279
550
|
deps.file_tracker.clear()
|
|
280
|
-
original_messages = self.ui_message_history.copy()
|
|
281
551
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
554
|
+
|
|
555
|
+
# Save current message history before the run
|
|
556
|
+
original_messages = self.ui_message_history.copy()
|
|
285
557
|
|
|
286
558
|
# Start with persistent message history
|
|
287
559
|
message_history = self.message_history
|
|
@@ -345,37 +617,312 @@ class AgentManager(Widget):
|
|
|
345
617
|
model_name = ""
|
|
346
618
|
if hasattr(deps, "llm_model") and deps.llm_model is not None:
|
|
347
619
|
model_name = deps.llm_model.name
|
|
348
|
-
|
|
349
|
-
|
|
620
|
+
|
|
621
|
+
# Check if it's a Shotgun account
|
|
622
|
+
is_shotgun_account = (
|
|
623
|
+
hasattr(deps, "llm_model")
|
|
624
|
+
and deps.llm_model is not None
|
|
625
|
+
and deps.llm_model.key_provider == KeyProvider.SHOTGUN
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
# Only disable streaming for GPT-5 if NOT a Shotgun account
|
|
629
|
+
# Shotgun accounts support streaming for GPT-5
|
|
630
|
+
is_gpt5_byok = "gpt-5" in model_name.lower() and not is_shotgun_account
|
|
631
|
+
|
|
632
|
+
# Track message send event
|
|
633
|
+
event_name = f"message_send_{self._current_agent_type.value}"
|
|
634
|
+
track_event(
|
|
635
|
+
event_name,
|
|
636
|
+
{
|
|
637
|
+
"has_prompt": prompt is not None,
|
|
638
|
+
"model_name": model_name,
|
|
639
|
+
},
|
|
350
640
|
)
|
|
351
641
|
|
|
352
642
|
try:
|
|
353
|
-
result: AgentRunResult[
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
prompt,
|
|
643
|
+
result: AgentRunResult[AgentResponse] = await self._run_agent_with_retry(
|
|
644
|
+
agent=self.current_agent,
|
|
645
|
+
prompt=prompt,
|
|
357
646
|
deps=deps,
|
|
358
647
|
usage_limits=usage_limits,
|
|
359
648
|
message_history=message_history,
|
|
360
|
-
|
|
361
|
-
|
|
649
|
+
event_stream_handler=self._handle_event_stream
|
|
650
|
+
if not is_gpt5_byok
|
|
651
|
+
else None,
|
|
362
652
|
**kwargs,
|
|
363
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
|
|
364
702
|
finally:
|
|
365
703
|
self._stream_state = None
|
|
366
704
|
|
|
367
|
-
|
|
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(
|
|
368
727
|
list[ModelRequest | ModelResponse | HintMessage], result.new_messages()
|
|
369
728
|
)
|
|
370
729
|
|
|
371
|
-
#
|
|
372
|
-
|
|
373
|
-
|
|
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)
|
|
374
750
|
|
|
375
|
-
|
|
751
|
+
self.ui_message_history = original_messages + deduplicated_new_messages
|
|
752
|
+
|
|
753
|
+
# Get file operations early so we can use them for contextual messages
|
|
376
754
|
file_operations = deps.file_tracker.operations.copy()
|
|
377
755
|
self.recently_change_files = file_operations
|
|
378
756
|
|
|
757
|
+
logger.debug(
|
|
758
|
+
"File operations tracked",
|
|
759
|
+
extra={
|
|
760
|
+
"num_file_operations": len(file_operations),
|
|
761
|
+
"operation_files": [Path(op.file_path).name for op in file_operations],
|
|
762
|
+
},
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
# Check if there are clarifying questions
|
|
766
|
+
if agent_response.clarifying_questions:
|
|
767
|
+
logger.info(
|
|
768
|
+
f"Agent has {len(agent_response.clarifying_questions)} clarifying questions"
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
# Add agent's response first if present
|
|
772
|
+
if agent_response.response:
|
|
773
|
+
self.ui_message_history.append(
|
|
774
|
+
HintMessage(message=agent_response.response)
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
# Add file operation hints before questions (so they appear first in UI)
|
|
778
|
+
if file_operations:
|
|
779
|
+
file_hint = self._create_file_operation_hint(file_operations)
|
|
780
|
+
if file_hint:
|
|
781
|
+
self.ui_message_history.append(HintMessage(message=file_hint))
|
|
782
|
+
|
|
783
|
+
if len(agent_response.clarifying_questions) == 1:
|
|
784
|
+
# Single question - treat as non-blocking suggestion, DON'T enter Q&A mode
|
|
785
|
+
self.ui_message_history.append(
|
|
786
|
+
HintMessage(message=f"💡 {agent_response.clarifying_questions[0]}")
|
|
787
|
+
)
|
|
788
|
+
else:
|
|
789
|
+
# Multiple questions (2+) - enter Q&A mode
|
|
790
|
+
self._qa_questions = agent_response.clarifying_questions
|
|
791
|
+
self._qa_mode_active = True
|
|
792
|
+
|
|
793
|
+
# Show intro with list, then first question
|
|
794
|
+
questions_list_with_intro = (
|
|
795
|
+
f"I have {len(agent_response.clarifying_questions)} questions:\n\n"
|
|
796
|
+
+ "\n".join(
|
|
797
|
+
f"{i + 1}. {q}"
|
|
798
|
+
for i, q in enumerate(agent_response.clarifying_questions)
|
|
799
|
+
)
|
|
800
|
+
)
|
|
801
|
+
self.ui_message_history.append(
|
|
802
|
+
HintMessage(message=questions_list_with_intro)
|
|
803
|
+
)
|
|
804
|
+
self.ui_message_history.append(
|
|
805
|
+
HintMessage(
|
|
806
|
+
message=f"**Q1:** {agent_response.clarifying_questions[0]}"
|
|
807
|
+
)
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
# Post event to TUI to update Q&A mode state (only for multiple questions)
|
|
811
|
+
self.post_message(
|
|
812
|
+
ClarifyingQuestionsMessage(
|
|
813
|
+
questions=agent_response.clarifying_questions,
|
|
814
|
+
response_text=agent_response.response,
|
|
815
|
+
)
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
# Post UI update with hint messages (file operations will be posted after compaction)
|
|
819
|
+
logger.debug("Posting UI update for Q&A mode with hint messages")
|
|
820
|
+
self._post_messages_updated([])
|
|
821
|
+
else:
|
|
822
|
+
# No clarifying questions - show the response or a default success message
|
|
823
|
+
if agent_response.response and agent_response.response.strip():
|
|
824
|
+
logger.debug(
|
|
825
|
+
"Adding agent response as hint",
|
|
826
|
+
extra={
|
|
827
|
+
"response_preview": agent_response.response[:100] + "..."
|
|
828
|
+
if len(agent_response.response) > 100
|
|
829
|
+
else agent_response.response,
|
|
830
|
+
"has_file_operations": len(file_operations) > 0,
|
|
831
|
+
},
|
|
832
|
+
)
|
|
833
|
+
self.ui_message_history.append(
|
|
834
|
+
HintMessage(message=agent_response.response)
|
|
835
|
+
)
|
|
836
|
+
else:
|
|
837
|
+
# Fallback: response is empty or whitespace
|
|
838
|
+
logger.debug(
|
|
839
|
+
"Agent response was empty, using fallback completion message",
|
|
840
|
+
extra={"has_file_operations": len(file_operations) > 0},
|
|
841
|
+
)
|
|
842
|
+
# Show contextual message based on whether files were modified
|
|
843
|
+
if file_operations:
|
|
844
|
+
self.ui_message_history.append(
|
|
845
|
+
HintMessage(
|
|
846
|
+
message="✅ Task completed - files have been modified"
|
|
847
|
+
)
|
|
848
|
+
)
|
|
849
|
+
else:
|
|
850
|
+
self.ui_message_history.append(
|
|
851
|
+
HintMessage(message="✅ Task completed")
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
# Post UI update immediately so user sees the response without delay
|
|
855
|
+
# (file operations will be posted after compaction to avoid duplicates)
|
|
856
|
+
logger.debug("Posting immediate UI update with hint message")
|
|
857
|
+
self._post_messages_updated([])
|
|
858
|
+
|
|
859
|
+
# Apply compaction to persistent message history to prevent cascading growth
|
|
860
|
+
all_messages = result.all_messages()
|
|
861
|
+
messages_before_compaction = len(all_messages)
|
|
862
|
+
compaction_occurred = False
|
|
863
|
+
|
|
864
|
+
try:
|
|
865
|
+
logger.debug(
|
|
866
|
+
"Starting message history compaction",
|
|
867
|
+
extra={"message_count": len(all_messages)},
|
|
868
|
+
)
|
|
869
|
+
# Notify UI that compaction is starting
|
|
870
|
+
self.post_message(CompactionStartedMessage())
|
|
871
|
+
|
|
872
|
+
self.message_history = await apply_persistent_compaction(all_messages, deps)
|
|
873
|
+
|
|
874
|
+
# Track if compaction actually modified the history
|
|
875
|
+
compaction_occurred = len(self.message_history) != len(all_messages)
|
|
876
|
+
|
|
877
|
+
# Notify UI that compaction is complete
|
|
878
|
+
self.post_message(CompactionCompletedMessage())
|
|
879
|
+
|
|
880
|
+
logger.debug(
|
|
881
|
+
"Completed message history compaction",
|
|
882
|
+
extra={
|
|
883
|
+
"original_count": len(all_messages),
|
|
884
|
+
"compacted_count": len(self.message_history),
|
|
885
|
+
},
|
|
886
|
+
)
|
|
887
|
+
except Exception as e:
|
|
888
|
+
# If compaction fails, log full error with stack trace and use uncompacted messages
|
|
889
|
+
logger.error(
|
|
890
|
+
"Failed to compact message history - using uncompacted messages",
|
|
891
|
+
exc_info=True,
|
|
892
|
+
extra={
|
|
893
|
+
"error": str(e),
|
|
894
|
+
"message_count": len(all_messages),
|
|
895
|
+
"agent_mode": self._current_agent_type.value,
|
|
896
|
+
},
|
|
897
|
+
)
|
|
898
|
+
# Fallback: use uncompacted messages to prevent data loss
|
|
899
|
+
self.message_history = all_messages
|
|
900
|
+
|
|
901
|
+
# Track context composition telemetry
|
|
902
|
+
await self._track_context_analysis(
|
|
903
|
+
compaction_occurred=compaction_occurred,
|
|
904
|
+
messages_before_compaction=messages_before_compaction
|
|
905
|
+
if compaction_occurred
|
|
906
|
+
else None,
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
usage = result.usage()
|
|
910
|
+
if hasattr(deps, "llm_model") and deps.llm_model is not None:
|
|
911
|
+
await deps.usage_manager.add_usage(
|
|
912
|
+
usage, model_name=deps.llm_model.name, provider=deps.llm_model.provider
|
|
913
|
+
)
|
|
914
|
+
else:
|
|
915
|
+
logger.warning(
|
|
916
|
+
"llm_model is None, skipping usage tracking",
|
|
917
|
+
extra={"agent_mode": self._current_agent_type.value},
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
# Post final UI update after compaction completes
|
|
921
|
+
# This ensures widgets that depend on message_history (like context indicator)
|
|
922
|
+
# receive the updated history after compaction
|
|
923
|
+
logger.debug(
|
|
924
|
+
"Posting final UI update after compaction with updated message_history"
|
|
925
|
+
)
|
|
379
926
|
self._post_messages_updated(file_operations)
|
|
380
927
|
|
|
381
928
|
return result
|
|
@@ -387,6 +934,9 @@ class AgentManager(Widget):
|
|
|
387
934
|
) -> None:
|
|
388
935
|
"""Process streamed events and forward partial updates to the UI."""
|
|
389
936
|
|
|
937
|
+
# Notify UI that streaming has started
|
|
938
|
+
self.post_message(AgentStreamingStarted())
|
|
939
|
+
|
|
390
940
|
state = self._stream_state
|
|
391
941
|
if state is None:
|
|
392
942
|
state = self._stream_state = _PartialStreamState()
|
|
@@ -446,6 +996,55 @@ class AgentManager(Widget):
|
|
|
446
996
|
self._post_partial_message(False)
|
|
447
997
|
|
|
448
998
|
elif isinstance(event, FunctionToolCallEvent):
|
|
999
|
+
# Track tool call event
|
|
1000
|
+
|
|
1001
|
+
# Detect source from call stack
|
|
1002
|
+
source = detect_source()
|
|
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
|
+
|
|
1037
|
+
track_event(
|
|
1038
|
+
"tool_called",
|
|
1039
|
+
{
|
|
1040
|
+
"tool_name": event.part.tool_name,
|
|
1041
|
+
"agent_mode": self._current_agent_type.value
|
|
1042
|
+
if self._current_agent_type
|
|
1043
|
+
else "unknown",
|
|
1044
|
+
"source": source,
|
|
1045
|
+
},
|
|
1046
|
+
)
|
|
1047
|
+
|
|
449
1048
|
existing_call_idx = next(
|
|
450
1049
|
(
|
|
451
1050
|
i
|
|
@@ -475,13 +1074,26 @@ class AgentManager(Widget):
|
|
|
475
1074
|
state.current_response = partial_message
|
|
476
1075
|
self._post_partial_message(False)
|
|
477
1076
|
elif isinstance(event, FunctionToolResultEvent):
|
|
1077
|
+
# Track tool completion event
|
|
1078
|
+
|
|
1079
|
+
# Detect source from call stack
|
|
1080
|
+
source = detect_source()
|
|
1081
|
+
|
|
1082
|
+
track_event(
|
|
1083
|
+
"tool_completed",
|
|
1084
|
+
{
|
|
1085
|
+
"tool_name": event.result.tool_name
|
|
1086
|
+
if hasattr(event.result, "tool_name")
|
|
1087
|
+
else "unknown",
|
|
1088
|
+
"agent_mode": self._current_agent_type.value
|
|
1089
|
+
if self._current_agent_type
|
|
1090
|
+
else "unknown",
|
|
1091
|
+
"source": source,
|
|
1092
|
+
},
|
|
1093
|
+
)
|
|
1094
|
+
|
|
478
1095
|
request_message = ModelRequest(parts=[event.result])
|
|
479
1096
|
state.messages.append(request_message)
|
|
480
|
-
if (
|
|
481
|
-
event.result.tool_name == "ask_user"
|
|
482
|
-
): # special handling to ask_user, because deferred tool results mean we missed the user response
|
|
483
|
-
self.ui_message_history.append(request_message)
|
|
484
|
-
self._post_messages_updated()
|
|
485
1097
|
## this is what the user responded with
|
|
486
1098
|
self._post_partial_message(is_last=False)
|
|
487
1099
|
|
|
@@ -503,6 +1115,9 @@ class AgentManager(Widget):
|
|
|
503
1115
|
self._post_partial_message(True)
|
|
504
1116
|
state.current_response = None
|
|
505
1117
|
|
|
1118
|
+
# Notify UI that streaming has completed
|
|
1119
|
+
self.post_message(AgentStreamingCompleted())
|
|
1120
|
+
|
|
506
1121
|
def _build_partial_response(
|
|
507
1122
|
self, parts: list[ModelResponsePart | ToolCallPartDelta]
|
|
508
1123
|
) -> ModelResponse | None:
|
|
@@ -530,6 +1145,38 @@ class AgentManager(Widget):
|
|
|
530
1145
|
)
|
|
531
1146
|
)
|
|
532
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
|
+
|
|
533
1180
|
def _post_messages_updated(
|
|
534
1181
|
self, file_operations: list[FileOperation] | None = None
|
|
535
1182
|
) -> None:
|
|
@@ -588,6 +1235,65 @@ class AgentManager(Widget):
|
|
|
588
1235
|
filtered_messages.append(msg)
|
|
589
1236
|
return filtered_messages
|
|
590
1237
|
|
|
1238
|
+
def get_usage_hint(self) -> str | None:
|
|
1239
|
+
return self.deps.usage_manager.build_usage_hint()
|
|
1240
|
+
|
|
1241
|
+
async def get_context_hint(self) -> str | None:
|
|
1242
|
+
"""Get conversation context analysis as a formatted hint.
|
|
1243
|
+
|
|
1244
|
+
Returns:
|
|
1245
|
+
Markdown-formatted string with context composition statistics, or None if unavailable
|
|
1246
|
+
"""
|
|
1247
|
+
analysis = await self.get_context_analysis()
|
|
1248
|
+
if analysis:
|
|
1249
|
+
return ContextFormatter.format_markdown(analysis)
|
|
1250
|
+
return None
|
|
1251
|
+
|
|
1252
|
+
async def get_context_analysis(self) -> ContextAnalysis | None:
|
|
1253
|
+
"""Get conversation context analysis as structured data.
|
|
1254
|
+
|
|
1255
|
+
Returns:
|
|
1256
|
+
ContextAnalysis object with token usage data, or None if unavailable
|
|
1257
|
+
"""
|
|
1258
|
+
|
|
1259
|
+
try:
|
|
1260
|
+
analyzer = ContextAnalyzer(self.deps.llm_model)
|
|
1261
|
+
return await analyzer.analyze_conversation(
|
|
1262
|
+
self.message_history, self.ui_message_history
|
|
1263
|
+
)
|
|
1264
|
+
except Exception as e:
|
|
1265
|
+
logger.error(f"Failed to generate context analysis: {e}", exc_info=True)
|
|
1266
|
+
return None
|
|
1267
|
+
|
|
1268
|
+
async def _track_context_analysis(
|
|
1269
|
+
self,
|
|
1270
|
+
compaction_occurred: bool = False,
|
|
1271
|
+
messages_before_compaction: int | None = None,
|
|
1272
|
+
) -> None:
|
|
1273
|
+
"""Track context composition telemetry to PostHog.
|
|
1274
|
+
|
|
1275
|
+
Args:
|
|
1276
|
+
compaction_occurred: Whether compaction was applied
|
|
1277
|
+
messages_before_compaction: Message count before compaction, if it occurred
|
|
1278
|
+
"""
|
|
1279
|
+
try:
|
|
1280
|
+
analyzer = ContextAnalyzer(self.deps.llm_model)
|
|
1281
|
+
analysis = await analyzer.analyze_conversation(
|
|
1282
|
+
self.message_history, self.ui_message_history
|
|
1283
|
+
)
|
|
1284
|
+
|
|
1285
|
+
# Create telemetry model from analysis
|
|
1286
|
+
telemetry = ContextCompositionTelemetry.from_analysis(
|
|
1287
|
+
analysis,
|
|
1288
|
+
compaction_occurred=compaction_occurred,
|
|
1289
|
+
messages_before_compaction=messages_before_compaction,
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
# Send to PostHog using model_dump() for dict conversion
|
|
1293
|
+
track_event("agent_context_composition", telemetry.model_dump())
|
|
1294
|
+
except Exception as e:
|
|
1295
|
+
logger.warning(f"Failed to track context analysis: {e}")
|
|
1296
|
+
|
|
591
1297
|
def get_conversation_state(self) -> "ConversationState":
|
|
592
1298
|
"""Get the current conversation state.
|
|
593
1299
|
|
|
@@ -635,6 +1341,9 @@ class AgentManager(Widget):
|
|
|
635
1341
|
__all__ = [
|
|
636
1342
|
"AgentManager",
|
|
637
1343
|
"AgentType",
|
|
1344
|
+
"ClarifyingQuestionsMessage",
|
|
1345
|
+
"CompactionCompletedMessage",
|
|
1346
|
+
"CompactionStartedMessage",
|
|
638
1347
|
"MessageHistoryUpdated",
|
|
639
1348
|
"PartialResponseMessage",
|
|
640
1349
|
]
|