shotgun-sh 0.2.8.dev2__py3-none-any.whl → 0.2.17__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shotgun/agents/agent_manager.py +354 -46
- shotgun/agents/common.py +14 -8
- shotgun/agents/config/constants.py +0 -6
- shotgun/agents/config/manager.py +66 -35
- shotgun/agents/config/models.py +41 -1
- shotgun/agents/config/provider.py +33 -5
- 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 +2 -0
- shotgun/agents/conversation_manager.py +35 -19
- shotgun/agents/export.py +2 -2
- shotgun/agents/history/compaction.py +9 -4
- shotgun/agents/history/history_processors.py +113 -5
- shotgun/agents/history/token_counting/anthropic.py +17 -1
- shotgun/agents/history/token_counting/base.py +14 -3
- shotgun/agents/history/token_counting/openai.py +11 -1
- shotgun/agents/history/token_counting/sentencepiece_counter.py +8 -0
- shotgun/agents/history/token_counting/tokenizer_cache.py +3 -1
- shotgun/agents/history/token_counting/utils.py +0 -3
- shotgun/agents/plan.py +2 -2
- shotgun/agents/research.py +3 -3
- shotgun/agents/specify.py +2 -2
- shotgun/agents/tasks.py +2 -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 +27 -7
- shotgun/agents/tools/registry.py +217 -0
- shotgun/agents/tools/web_search/__init__.py +8 -8
- shotgun/agents/tools/web_search/anthropic.py +8 -2
- shotgun/agents/tools/web_search/gemini.py +7 -1
- shotgun/agents/tools/web_search/openai.py +7 -1
- shotgun/agents/tools/web_search/utils.py +2 -2
- shotgun/agents/usage_manager.py +16 -11
- shotgun/api_endpoints.py +7 -3
- shotgun/build_constants.py +3 -3
- shotgun/cli/clear.py +53 -0
- shotgun/cli/compact.py +186 -0
- shotgun/cli/config.py +8 -5
- shotgun/cli/context.py +111 -0
- shotgun/cli/export.py +1 -1
- shotgun/cli/feedback.py +4 -2
- shotgun/cli/models.py +1 -0
- 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 +16 -2
- shotgun/codebase/core/change_detector.py +5 -3
- shotgun/codebase/core/code_retrieval.py +4 -2
- shotgun/codebase/core/ingestor.py +10 -8
- shotgun/codebase/core/manager.py +13 -4
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/exceptions.py +32 -0
- shotgun/logging_config.py +18 -27
- shotgun/main.py +73 -11
- shotgun/posthog_telemetry.py +37 -28
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +3 -2
- shotgun/sentry_telemetry.py +163 -16
- shotgun/settings.py +238 -0
- shotgun/telemetry.py +10 -33
- shotgun/tui/app.py +243 -43
- 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/containers.py +91 -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 +1254 -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 +78 -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 +115 -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 +4 -4
- shotgun/tui/screens/github_issue.py +102 -0
- shotgun/tui/screens/model_picker.py +49 -24
- shotgun/tui/screens/onboarding.py +431 -0
- shotgun/tui/screens/pipx_migration.py +153 -0
- shotgun/tui/screens/provider_config.py +50 -27
- shotgun/tui/screens/shotgun_auth.py +2 -2
- shotgun/tui/screens/welcome.py +14 -11
- 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 +263 -0
- shotgun/utils/file_system_utils.py +22 -2
- shotgun/utils/marketing.py +110 -0
- shotgun/utils/update_checker.py +69 -14
- shotgun_sh-0.2.17.dist-info/METADATA +465 -0
- shotgun_sh-0.2.17.dist-info/RECORD +194 -0
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.2.17.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.2.17.dist-info}/licenses/LICENSE +1 -1
- shotgun/tui/screens/chat.py +0 -996
- shotgun/tui/screens/chat_screen/history.py +0 -335
- shotgun_sh-0.2.8.dev2.dist-info/METADATA +0 -126
- shotgun_sh-0.2.8.dev2.dist-info/RECORD +0 -155
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.2.17.dist-info}/WHEEL +0 -0
shotgun/agents/agent_manager.py
CHANGED
|
@@ -40,13 +40,30 @@ from pydantic_ai.messages import (
|
|
|
40
40
|
SystemPromptPart,
|
|
41
41
|
ToolCallPart,
|
|
42
42
|
ToolCallPartDelta,
|
|
43
|
+
UserPromptPart,
|
|
43
44
|
)
|
|
44
45
|
from textual.message import Message
|
|
45
46
|
from textual.widget import Widget
|
|
46
47
|
|
|
47
48
|
from shotgun.agents.common import add_system_prompt_message, add_system_status_message
|
|
48
|
-
from shotgun.agents.config.models import
|
|
49
|
-
|
|
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
|
+
)
|
|
50
67
|
from shotgun.posthog_telemetry import track_event
|
|
51
68
|
from shotgun.tui.screens.chat_screen.hint_message import HintMessage
|
|
52
69
|
from shotgun.utils.source_detection import detect_source
|
|
@@ -149,6 +166,44 @@ class ClarifyingQuestionsMessage(Message):
|
|
|
149
166
|
self.response_text = response_text
|
|
150
167
|
|
|
151
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
|
+
|
|
152
207
|
@dataclass(slots=True)
|
|
153
208
|
class _PartialStreamState:
|
|
154
209
|
"""Tracks streamed messages while handling a single agent run."""
|
|
@@ -180,7 +235,7 @@ class AgentManager(Widget):
|
|
|
180
235
|
self.deps = deps
|
|
181
236
|
|
|
182
237
|
# Create AgentRuntimeOptions from deps for agent creation
|
|
183
|
-
|
|
238
|
+
self._agent_runtime_options = AgentRuntimeOptions(
|
|
184
239
|
interactive_mode=self.deps.interactive_mode,
|
|
185
240
|
working_directory=self.deps.working_directory,
|
|
186
241
|
is_tui_context=self.deps.is_tui_context,
|
|
@@ -189,22 +244,18 @@ class AgentManager(Widget):
|
|
|
189
244
|
tasks=self.deps.tasks,
|
|
190
245
|
)
|
|
191
246
|
|
|
192
|
-
#
|
|
193
|
-
self.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
self.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
self.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
self.
|
|
203
|
-
|
|
204
|
-
)
|
|
205
|
-
self.export_agent, self.export_deps = create_export_agent(
|
|
206
|
-
agent_runtime_options=agent_runtime_options
|
|
207
|
-
)
|
|
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
|
|
208
259
|
|
|
209
260
|
# Track current active agent
|
|
210
261
|
self._current_agent_type: AgentType = initial_type
|
|
@@ -219,6 +270,119 @@ class AgentManager(Widget):
|
|
|
219
270
|
self._qa_questions: list[str] | None = None
|
|
220
271
|
self._qa_mode_active: bool = False
|
|
221
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
|
+
|
|
368
|
+
@property
|
|
369
|
+
def export_agent(self) -> Agent[AgentDeps, AgentResponse]:
|
|
370
|
+
"""Get export agent (must call _ensure_agents_initialized first)."""
|
|
371
|
+
if self._export_agent is None:
|
|
372
|
+
raise RuntimeError(
|
|
373
|
+
"Agents not initialized. Call _ensure_agents_initialized() first."
|
|
374
|
+
)
|
|
375
|
+
return self._export_agent
|
|
376
|
+
|
|
377
|
+
@property
|
|
378
|
+
def export_deps(self) -> AgentDeps:
|
|
379
|
+
"""Get export deps (must call _ensure_agents_initialized first)."""
|
|
380
|
+
if self._export_deps is None:
|
|
381
|
+
raise RuntimeError(
|
|
382
|
+
"Agents not initialized. Call _ensure_agents_initialized() first."
|
|
383
|
+
)
|
|
384
|
+
return self._export_deps
|
|
385
|
+
|
|
222
386
|
@property
|
|
223
387
|
def current_agent(self) -> Agent[AgentDeps, AgentResponse]:
|
|
224
388
|
"""Get the currently active agent.
|
|
@@ -370,6 +534,9 @@ class AgentManager(Widget):
|
|
|
370
534
|
Returns:
|
|
371
535
|
The agent run result.
|
|
372
536
|
"""
|
|
537
|
+
# Ensure agents are initialized before running
|
|
538
|
+
await self._ensure_agents_initialized()
|
|
539
|
+
|
|
373
540
|
logger.info(f"Running agent {self._current_agent_type.value}")
|
|
374
541
|
# Use merged deps (shared state + agent-specific system prompt) if not provided
|
|
375
542
|
if deps is None:
|
|
@@ -382,19 +549,11 @@ class AgentManager(Widget):
|
|
|
382
549
|
# Clear file tracker before each run to track only this run's operations
|
|
383
550
|
deps.file_tracker.clear()
|
|
384
551
|
|
|
385
|
-
#
|
|
386
|
-
|
|
387
|
-
user_request = ModelRequest.user_text_prompt(prompt)
|
|
388
|
-
self.ui_message_history.append(user_request)
|
|
389
|
-
|
|
390
|
-
# Always post update before run to show user message (or current state if no prompt)
|
|
391
|
-
self._post_messages_updated()
|
|
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
|
|
392
554
|
|
|
393
|
-
# Save history
|
|
394
|
-
|
|
395
|
-
original_messages = (
|
|
396
|
-
self.ui_message_history[:-1] if prompt else self.ui_message_history.copy()
|
|
397
|
-
)
|
|
555
|
+
# Save current message history before the run
|
|
556
|
+
original_messages = self.ui_message_history.copy()
|
|
398
557
|
|
|
399
558
|
# Start with persistent message history
|
|
400
559
|
message_history = self.message_history
|
|
@@ -562,11 +721,35 @@ class AgentManager(Widget):
|
|
|
562
721
|
},
|
|
563
722
|
)
|
|
564
723
|
|
|
565
|
-
#
|
|
566
|
-
|
|
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(
|
|
567
727
|
list[ModelRequest | ModelResponse | HintMessage], result.new_messages()
|
|
568
728
|
)
|
|
569
729
|
|
|
730
|
+
# Deduplicate: skip user prompts that are already in original_messages
|
|
731
|
+
deduplicated_new_messages = []
|
|
732
|
+
for msg in new_messages:
|
|
733
|
+
# Check if this is a user prompt that's already in original_messages
|
|
734
|
+
if isinstance(msg, ModelRequest) and any(
|
|
735
|
+
isinstance(part, UserPromptPart) for part in msg.parts
|
|
736
|
+
):
|
|
737
|
+
# Check if an identical user prompt is already in original_messages
|
|
738
|
+
already_exists = any(
|
|
739
|
+
isinstance(existing, ModelRequest)
|
|
740
|
+
and any(isinstance(p, UserPromptPart) for p in existing.parts)
|
|
741
|
+
and existing.parts == msg.parts
|
|
742
|
+
for existing in original_messages[
|
|
743
|
+
-5:
|
|
744
|
+
] # Check last 5 messages for efficiency
|
|
745
|
+
)
|
|
746
|
+
if already_exists:
|
|
747
|
+
continue # Skip this duplicate user prompt
|
|
748
|
+
|
|
749
|
+
deduplicated_new_messages.append(msg)
|
|
750
|
+
|
|
751
|
+
self.ui_message_history = original_messages + deduplicated_new_messages
|
|
752
|
+
|
|
570
753
|
# Get file operations early so we can use them for contextual messages
|
|
571
754
|
file_operations = deps.file_tracker.operations.copy()
|
|
572
755
|
self.recently_change_files = file_operations
|
|
@@ -591,6 +774,12 @@ class AgentManager(Widget):
|
|
|
591
774
|
HintMessage(message=agent_response.response)
|
|
592
775
|
)
|
|
593
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
|
+
|
|
594
783
|
if len(agent_response.clarifying_questions) == 1:
|
|
595
784
|
# Single question - treat as non-blocking suggestion, DON'T enter Q&A mode
|
|
596
785
|
self.ui_message_history.append(
|
|
@@ -626,11 +815,9 @@ class AgentManager(Widget):
|
|
|
626
815
|
)
|
|
627
816
|
)
|
|
628
817
|
|
|
629
|
-
# Post UI update with hint messages
|
|
630
|
-
logger.debug(
|
|
631
|
-
|
|
632
|
-
)
|
|
633
|
-
self._post_messages_updated(file_operations)
|
|
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([])
|
|
634
821
|
else:
|
|
635
822
|
# No clarifying questions - show the response or a default success message
|
|
636
823
|
if agent_response.response and agent_response.response.strip():
|
|
@@ -665,19 +852,31 @@ class AgentManager(Widget):
|
|
|
665
852
|
)
|
|
666
853
|
|
|
667
854
|
# Post UI update immediately so user sees the response without delay
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
)
|
|
671
|
-
self._post_messages_updated(file_operations)
|
|
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([])
|
|
672
858
|
|
|
673
859
|
# Apply compaction to persistent message history to prevent cascading growth
|
|
674
860
|
all_messages = result.all_messages()
|
|
861
|
+
messages_before_compaction = len(all_messages)
|
|
862
|
+
compaction_occurred = False
|
|
863
|
+
|
|
675
864
|
try:
|
|
676
865
|
logger.debug(
|
|
677
866
|
"Starting message history compaction",
|
|
678
867
|
extra={"message_count": len(all_messages)},
|
|
679
868
|
)
|
|
869
|
+
# Notify UI that compaction is starting
|
|
870
|
+
self.post_message(CompactionStartedMessage())
|
|
871
|
+
|
|
680
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
|
+
|
|
681
880
|
logger.debug(
|
|
682
881
|
"Completed message history compaction",
|
|
683
882
|
extra={
|
|
@@ -699,9 +898,17 @@ class AgentManager(Widget):
|
|
|
699
898
|
# Fallback: use uncompacted messages to prevent data loss
|
|
700
899
|
self.message_history = all_messages
|
|
701
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
|
+
|
|
702
909
|
usage = result.usage()
|
|
703
910
|
if hasattr(deps, "llm_model") and deps.llm_model is not None:
|
|
704
|
-
deps.usage_manager.add_usage(
|
|
911
|
+
await deps.usage_manager.add_usage(
|
|
705
912
|
usage, model_name=deps.llm_model.name, provider=deps.llm_model.provider
|
|
706
913
|
)
|
|
707
914
|
else:
|
|
@@ -710,8 +917,13 @@ class AgentManager(Widget):
|
|
|
710
917
|
extra={"agent_mode": self._current_agent_type.value},
|
|
711
918
|
)
|
|
712
919
|
|
|
713
|
-
#
|
|
714
|
-
#
|
|
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
|
+
)
|
|
926
|
+
self._post_messages_updated(file_operations)
|
|
715
927
|
|
|
716
928
|
return result
|
|
717
929
|
|
|
@@ -722,6 +934,9 @@ class AgentManager(Widget):
|
|
|
722
934
|
) -> None:
|
|
723
935
|
"""Process streamed events and forward partial updates to the UI."""
|
|
724
936
|
|
|
937
|
+
# Notify UI that streaming has started
|
|
938
|
+
self.post_message(AgentStreamingStarted())
|
|
939
|
+
|
|
725
940
|
state = self._stream_state
|
|
726
941
|
if state is None:
|
|
727
942
|
state = self._stream_state = _PartialStreamState()
|
|
@@ -900,6 +1115,9 @@ class AgentManager(Widget):
|
|
|
900
1115
|
self._post_partial_message(True)
|
|
901
1116
|
state.current_response = None
|
|
902
1117
|
|
|
1118
|
+
# Notify UI that streaming has completed
|
|
1119
|
+
self.post_message(AgentStreamingCompleted())
|
|
1120
|
+
|
|
903
1121
|
def _build_partial_response(
|
|
904
1122
|
self, parts: list[ModelResponsePart | ToolCallPartDelta]
|
|
905
1123
|
) -> ModelResponse | None:
|
|
@@ -927,6 +1145,38 @@ class AgentManager(Widget):
|
|
|
927
1145
|
)
|
|
928
1146
|
)
|
|
929
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
|
+
|
|
930
1180
|
def _post_messages_updated(
|
|
931
1181
|
self, file_operations: list[FileOperation] | None = None
|
|
932
1182
|
) -> None:
|
|
@@ -988,6 +1238,62 @@ class AgentManager(Widget):
|
|
|
988
1238
|
def get_usage_hint(self) -> str | None:
|
|
989
1239
|
return self.deps.usage_manager.build_usage_hint()
|
|
990
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
|
+
|
|
991
1297
|
def get_conversation_state(self) -> "ConversationState":
|
|
992
1298
|
"""Get the current conversation state.
|
|
993
1299
|
|
|
@@ -1035,7 +1341,9 @@ class AgentManager(Widget):
|
|
|
1035
1341
|
__all__ = [
|
|
1036
1342
|
"AgentManager",
|
|
1037
1343
|
"AgentType",
|
|
1344
|
+
"ClarifyingQuestionsMessage",
|
|
1345
|
+
"CompactionCompletedMessage",
|
|
1346
|
+
"CompactionStartedMessage",
|
|
1038
1347
|
"MessageHistoryUpdated",
|
|
1039
1348
|
"PartialResponseMessage",
|
|
1040
|
-
"ClarifyingQuestionsMessage",
|
|
1041
1349
|
]
|
shotgun/agents/common.py
CHANGED
|
@@ -4,6 +4,7 @@ from collections.abc import Callable
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
+
import aiofiles
|
|
7
8
|
from pydantic_ai import (
|
|
8
9
|
Agent,
|
|
9
10
|
RunContext,
|
|
@@ -68,7 +69,7 @@ async def add_system_status_message(
|
|
|
68
69
|
existing_files = get_agent_existing_files(deps.agent_mode)
|
|
69
70
|
|
|
70
71
|
# Extract table of contents from the agent's markdown file
|
|
71
|
-
markdown_toc = extract_markdown_toc(deps.agent_mode)
|
|
72
|
+
markdown_toc = await extract_markdown_toc(deps.agent_mode)
|
|
72
73
|
|
|
73
74
|
# Get current datetime with timezone information
|
|
74
75
|
dt_context = get_datetime_context()
|
|
@@ -94,7 +95,7 @@ async def add_system_status_message(
|
|
|
94
95
|
return message_history
|
|
95
96
|
|
|
96
97
|
|
|
97
|
-
def create_base_agent(
|
|
98
|
+
async def create_base_agent(
|
|
98
99
|
system_prompt_fn: Callable[[RunContext[AgentDeps]], str],
|
|
99
100
|
agent_runtime_options: AgentRuntimeOptions,
|
|
100
101
|
load_codebase_understanding_tools: bool = True,
|
|
@@ -119,7 +120,7 @@ def create_base_agent(
|
|
|
119
120
|
|
|
120
121
|
# Get configured model or fall back to first available provider
|
|
121
122
|
try:
|
|
122
|
-
model_config = get_provider_model(provider)
|
|
123
|
+
model_config = await get_provider_model(provider)
|
|
123
124
|
provider_name = model_config.provider
|
|
124
125
|
logger.debug(
|
|
125
126
|
"🤖 Creating agent with configured %s model: %s",
|
|
@@ -194,7 +195,7 @@ def create_base_agent(
|
|
|
194
195
|
return agent, deps
|
|
195
196
|
|
|
196
197
|
|
|
197
|
-
def _extract_file_toc_content(
|
|
198
|
+
async def _extract_file_toc_content(
|
|
198
199
|
file_path: Path, max_depth: int | None = None, max_chars: int = 500
|
|
199
200
|
) -> str | None:
|
|
200
201
|
"""Extract TOC from a single file with depth and character limits.
|
|
@@ -211,7 +212,8 @@ def _extract_file_toc_content(
|
|
|
211
212
|
return None
|
|
212
213
|
|
|
213
214
|
try:
|
|
214
|
-
|
|
215
|
+
async with aiofiles.open(file_path, encoding="utf-8") as f:
|
|
216
|
+
content = await f.read()
|
|
215
217
|
lines = content.split("\n")
|
|
216
218
|
|
|
217
219
|
# Extract headings
|
|
@@ -257,7 +259,7 @@ def _extract_file_toc_content(
|
|
|
257
259
|
return None
|
|
258
260
|
|
|
259
261
|
|
|
260
|
-
def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
|
|
262
|
+
async def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
|
|
261
263
|
"""Extract TOCs from current and prior agents' files in the pipeline.
|
|
262
264
|
|
|
263
265
|
Shows full TOC of agent's own file and high-level summaries of prior agents'
|
|
@@ -309,7 +311,9 @@ def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
|
|
|
309
311
|
for prior_file in config.prior_files:
|
|
310
312
|
file_path = base_path / prior_file
|
|
311
313
|
# Only show # and ## headings from prior files, max 500 chars each
|
|
312
|
-
prior_toc = _extract_file_toc_content(
|
|
314
|
+
prior_toc = await _extract_file_toc_content(
|
|
315
|
+
file_path, max_depth=2, max_chars=500
|
|
316
|
+
)
|
|
313
317
|
if prior_toc:
|
|
314
318
|
# Add section with XML tags
|
|
315
319
|
toc_sections.append(
|
|
@@ -321,7 +325,9 @@ def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
|
|
|
321
325
|
# Extract TOC from own file (full detail)
|
|
322
326
|
if config.own_file:
|
|
323
327
|
own_path = base_path / config.own_file
|
|
324
|
-
own_toc = _extract_file_toc_content(
|
|
328
|
+
own_toc = await _extract_file_toc_content(
|
|
329
|
+
own_path, max_depth=None, max_chars=2000
|
|
330
|
+
)
|
|
325
331
|
if own_toc:
|
|
326
332
|
# Put own file TOC at the beginning with XML tags
|
|
327
333
|
toc_sections.insert(
|
|
@@ -24,11 +24,5 @@ ANTHROPIC_PROVIDER = ConfigSection.ANTHROPIC.value
|
|
|
24
24
|
GOOGLE_PROVIDER = ConfigSection.GOOGLE.value
|
|
25
25
|
SHOTGUN_PROVIDER = ConfigSection.SHOTGUN.value
|
|
26
26
|
|
|
27
|
-
# Environment variable names
|
|
28
|
-
OPENAI_API_KEY_ENV = "OPENAI_API_KEY"
|
|
29
|
-
ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY"
|
|
30
|
-
GEMINI_API_KEY_ENV = "GEMINI_API_KEY"
|
|
31
|
-
SHOTGUN_API_KEY_ENV = "SHOTGUN_API_KEY"
|
|
32
|
-
|
|
33
27
|
# Token limits
|
|
34
28
|
MEDIUM_TEXT_8K_TOKENS = 8192 # Default max_tokens for web search requests
|