shotgun-sh 0.2.11__py3-none-any.whl → 0.2.11.dev2__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 +28 -194
- shotgun/agents/common.py +8 -14
- shotgun/agents/config/manager.py +33 -64
- shotgun/agents/config/models.py +1 -25
- shotgun/agents/config/provider.py +2 -2
- shotgun/agents/context_analyzer/analyzer.py +24 -2
- shotgun/agents/conversation_manager.py +19 -35
- shotgun/agents/export.py +2 -2
- shotgun/agents/history/history_processors.py +3 -99
- shotgun/agents/history/token_counting/anthropic.py +1 -17
- shotgun/agents/history/token_counting/base.py +3 -14
- shotgun/agents/history/token_counting/openai.py +1 -11
- shotgun/agents/history/token_counting/sentencepiece_counter.py +0 -8
- shotgun/agents/history/token_counting/tokenizer_cache.py +1 -3
- shotgun/agents/history/token_counting/utils.py +3 -0
- 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/file_read.py +2 -5
- shotgun/agents/tools/file_management.py +7 -11
- shotgun/agents/tools/web_search/__init__.py +8 -8
- shotgun/agents/tools/web_search/anthropic.py +2 -2
- shotgun/agents/tools/web_search/gemini.py +1 -1
- shotgun/agents/tools/web_search/openai.py +1 -1
- shotgun/agents/tools/web_search/utils.py +2 -2
- shotgun/agents/usage_manager.py +11 -16
- shotgun/build_constants.py +2 -2
- shotgun/cli/clear.py +1 -2
- shotgun/cli/compact.py +3 -3
- shotgun/cli/config.py +5 -8
- shotgun/cli/context.py +2 -2
- shotgun/cli/export.py +1 -1
- shotgun/cli/feedback.py +2 -4
- 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/codebase/core/change_detector.py +3 -5
- shotgun/codebase/core/code_retrieval.py +2 -4
- shotgun/codebase/core/ingestor.py +8 -10
- shotgun/codebase/core/manager.py +3 -3
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/logging_config.py +17 -10
- shotgun/main.py +1 -3
- shotgun/posthog_telemetry.py +4 -14
- shotgun/sentry_telemetry.py +2 -22
- shotgun/telemetry.py +1 -3
- shotgun/tui/app.py +65 -71
- shotgun/tui/components/context_indicator.py +0 -43
- shotgun/tui/containers.py +17 -15
- shotgun/tui/dependencies.py +2 -2
- shotgun/tui/screens/chat/chat_screen.py +40 -164
- shotgun/tui/screens/chat/help_text.py +15 -16
- shotgun/tui/screens/chat_screen/command_providers.py +0 -10
- shotgun/tui/screens/feedback.py +4 -4
- shotgun/tui/screens/model_picker.py +20 -21
- shotgun/tui/screens/provider_config.py +27 -50
- shotgun/tui/screens/shotgun_auth.py +2 -2
- shotgun/tui/screens/welcome.py +11 -14
- shotgun/tui/services/conversation_service.py +14 -16
- shotgun/tui/utils/mode_progress.py +7 -14
- shotgun/tui/widgets/widget_coordinator.py +0 -15
- shotgun/utils/file_system_utils.py +0 -19
- {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/METADATA +1 -2
- {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/RECORD +69 -73
- shotgun/exceptions.py +0 -32
- shotgun/tui/screens/github_issue.py +0 -102
- shotgun/tui/screens/onboarding.py +0 -431
- shotgun/utils/marketing.py +0 -110
- {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.11.dist-info → shotgun_sh-0.2.11.dev2.dist-info}/licenses/LICENSE +0 -0
shotgun/agents/agent_manager.py
CHANGED
|
@@ -58,12 +58,7 @@ from shotgun.agents.context_analyzer import (
|
|
|
58
58
|
ContextCompositionTelemetry,
|
|
59
59
|
ContextFormatter,
|
|
60
60
|
)
|
|
61
|
-
from shotgun.agents.models import
|
|
62
|
-
AgentResponse,
|
|
63
|
-
AgentType,
|
|
64
|
-
FileOperation,
|
|
65
|
-
FileOperationTracker,
|
|
66
|
-
)
|
|
61
|
+
from shotgun.agents.models import AgentResponse, AgentType, FileOperation
|
|
67
62
|
from shotgun.posthog_telemetry import track_event
|
|
68
63
|
from shotgun.tui.screens.chat_screen.hint_message import HintMessage
|
|
69
64
|
from shotgun.utils.source_detection import detect_source
|
|
@@ -174,14 +169,6 @@ class CompactionCompletedMessage(Message):
|
|
|
174
169
|
"""Event posted when conversation compaction completes."""
|
|
175
170
|
|
|
176
171
|
|
|
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
172
|
@dataclass(frozen=True)
|
|
186
173
|
class ModelConfigUpdated:
|
|
187
174
|
"""Data returned when AI model configuration changes.
|
|
@@ -235,7 +222,7 @@ class AgentManager(Widget):
|
|
|
235
222
|
self.deps = deps
|
|
236
223
|
|
|
237
224
|
# Create AgentRuntimeOptions from deps for agent creation
|
|
238
|
-
|
|
225
|
+
agent_runtime_options = AgentRuntimeOptions(
|
|
239
226
|
interactive_mode=self.deps.interactive_mode,
|
|
240
227
|
working_directory=self.deps.working_directory,
|
|
241
228
|
is_tui_context=self.deps.is_tui_context,
|
|
@@ -244,18 +231,22 @@ class AgentManager(Widget):
|
|
|
244
231
|
tasks=self.deps.tasks,
|
|
245
232
|
)
|
|
246
233
|
|
|
247
|
-
#
|
|
248
|
-
self.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
self.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
self.
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
self.
|
|
258
|
-
|
|
234
|
+
# Initialize all agents and store their specific deps
|
|
235
|
+
self.research_agent, self.research_deps = create_research_agent(
|
|
236
|
+
agent_runtime_options=agent_runtime_options
|
|
237
|
+
)
|
|
238
|
+
self.plan_agent, self.plan_deps = create_plan_agent(
|
|
239
|
+
agent_runtime_options=agent_runtime_options
|
|
240
|
+
)
|
|
241
|
+
self.tasks_agent, self.tasks_deps = create_tasks_agent(
|
|
242
|
+
agent_runtime_options=agent_runtime_options
|
|
243
|
+
)
|
|
244
|
+
self.specify_agent, self.specify_deps = create_specify_agent(
|
|
245
|
+
agent_runtime_options=agent_runtime_options
|
|
246
|
+
)
|
|
247
|
+
self.export_agent, self.export_deps = create_export_agent(
|
|
248
|
+
agent_runtime_options=agent_runtime_options
|
|
249
|
+
)
|
|
259
250
|
|
|
260
251
|
# Track current active agent
|
|
261
252
|
self._current_agent_type: AgentType = initial_type
|
|
@@ -270,119 +261,6 @@ class AgentManager(Widget):
|
|
|
270
261
|
self._qa_questions: list[str] | None = None
|
|
271
262
|
self._qa_mode_active: bool = False
|
|
272
263
|
|
|
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
|
-
|
|
386
264
|
@property
|
|
387
265
|
def current_agent(self) -> Agent[AgentDeps, AgentResponse]:
|
|
388
266
|
"""Get the currently active agent.
|
|
@@ -534,9 +412,6 @@ class AgentManager(Widget):
|
|
|
534
412
|
Returns:
|
|
535
413
|
The agent run result.
|
|
536
414
|
"""
|
|
537
|
-
# Ensure agents are initialized before running
|
|
538
|
-
await self._ensure_agents_initialized()
|
|
539
|
-
|
|
540
415
|
logger.info(f"Running agent {self._current_agent_type.value}")
|
|
541
416
|
# Use merged deps (shared state + agent-specific system prompt) if not provided
|
|
542
417
|
if deps is None:
|
|
@@ -774,12 +649,6 @@ class AgentManager(Widget):
|
|
|
774
649
|
HintMessage(message=agent_response.response)
|
|
775
650
|
)
|
|
776
651
|
|
|
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
652
|
if len(agent_response.clarifying_questions) == 1:
|
|
784
653
|
# Single question - treat as non-blocking suggestion, DON'T enter Q&A mode
|
|
785
654
|
self.ui_message_history.append(
|
|
@@ -815,9 +684,11 @@ class AgentManager(Widget):
|
|
|
815
684
|
)
|
|
816
685
|
)
|
|
817
686
|
|
|
818
|
-
# Post UI update with hint messages
|
|
819
|
-
logger.debug(
|
|
820
|
-
|
|
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)
|
|
821
692
|
else:
|
|
822
693
|
# No clarifying questions - show the response or a default success message
|
|
823
694
|
if agent_response.response and agent_response.response.strip():
|
|
@@ -852,9 +723,10 @@ class AgentManager(Widget):
|
|
|
852
723
|
)
|
|
853
724
|
|
|
854
725
|
# Post UI update immediately so user sees the response without delay
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
726
|
+
logger.debug(
|
|
727
|
+
"Posting immediate UI update with hint message and file operations"
|
|
728
|
+
)
|
|
729
|
+
self._post_messages_updated(file_operations)
|
|
858
730
|
|
|
859
731
|
# Apply compaction to persistent message history to prevent cascading growth
|
|
860
732
|
all_messages = result.all_messages()
|
|
@@ -908,7 +780,7 @@ class AgentManager(Widget):
|
|
|
908
780
|
|
|
909
781
|
usage = result.usage()
|
|
910
782
|
if hasattr(deps, "llm_model") and deps.llm_model is not None:
|
|
911
|
-
|
|
783
|
+
deps.usage_manager.add_usage(
|
|
912
784
|
usage, model_name=deps.llm_model.name, provider=deps.llm_model.provider
|
|
913
785
|
)
|
|
914
786
|
else:
|
|
@@ -934,9 +806,6 @@ class AgentManager(Widget):
|
|
|
934
806
|
) -> None:
|
|
935
807
|
"""Process streamed events and forward partial updates to the UI."""
|
|
936
808
|
|
|
937
|
-
# Notify UI that streaming has started
|
|
938
|
-
self.post_message(AgentStreamingStarted())
|
|
939
|
-
|
|
940
809
|
state = self._stream_state
|
|
941
810
|
if state is None:
|
|
942
811
|
state = self._stream_state = _PartialStreamState()
|
|
@@ -1115,9 +984,6 @@ class AgentManager(Widget):
|
|
|
1115
984
|
self._post_partial_message(True)
|
|
1116
985
|
state.current_response = None
|
|
1117
986
|
|
|
1118
|
-
# Notify UI that streaming has completed
|
|
1119
|
-
self.post_message(AgentStreamingCompleted())
|
|
1120
|
-
|
|
1121
987
|
def _build_partial_response(
|
|
1122
988
|
self, parts: list[ModelResponsePart | ToolCallPartDelta]
|
|
1123
989
|
) -> ModelResponse | None:
|
|
@@ -1145,38 +1011,6 @@ class AgentManager(Widget):
|
|
|
1145
1011
|
)
|
|
1146
1012
|
)
|
|
1147
1013
|
|
|
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
|
-
|
|
1180
1014
|
def _post_messages_updated(
|
|
1181
1015
|
self, file_operations: list[FileOperation] | None = None
|
|
1182
1016
|
) -> None:
|
shotgun/agents/common.py
CHANGED
|
@@ -4,7 +4,6 @@ from collections.abc import Callable
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
-
import aiofiles
|
|
8
7
|
from pydantic_ai import (
|
|
9
8
|
Agent,
|
|
10
9
|
RunContext,
|
|
@@ -69,7 +68,7 @@ async def add_system_status_message(
|
|
|
69
68
|
existing_files = get_agent_existing_files(deps.agent_mode)
|
|
70
69
|
|
|
71
70
|
# Extract table of contents from the agent's markdown file
|
|
72
|
-
markdown_toc =
|
|
71
|
+
markdown_toc = extract_markdown_toc(deps.agent_mode)
|
|
73
72
|
|
|
74
73
|
# Get current datetime with timezone information
|
|
75
74
|
dt_context = get_datetime_context()
|
|
@@ -95,7 +94,7 @@ async def add_system_status_message(
|
|
|
95
94
|
return message_history
|
|
96
95
|
|
|
97
96
|
|
|
98
|
-
|
|
97
|
+
def create_base_agent(
|
|
99
98
|
system_prompt_fn: Callable[[RunContext[AgentDeps]], str],
|
|
100
99
|
agent_runtime_options: AgentRuntimeOptions,
|
|
101
100
|
load_codebase_understanding_tools: bool = True,
|
|
@@ -120,7 +119,7 @@ async def create_base_agent(
|
|
|
120
119
|
|
|
121
120
|
# Get configured model or fall back to first available provider
|
|
122
121
|
try:
|
|
123
|
-
model_config =
|
|
122
|
+
model_config = get_provider_model(provider)
|
|
124
123
|
provider_name = model_config.provider
|
|
125
124
|
logger.debug(
|
|
126
125
|
"🤖 Creating agent with configured %s model: %s",
|
|
@@ -195,7 +194,7 @@ async def create_base_agent(
|
|
|
195
194
|
return agent, deps
|
|
196
195
|
|
|
197
196
|
|
|
198
|
-
|
|
197
|
+
def _extract_file_toc_content(
|
|
199
198
|
file_path: Path, max_depth: int | None = None, max_chars: int = 500
|
|
200
199
|
) -> str | None:
|
|
201
200
|
"""Extract TOC from a single file with depth and character limits.
|
|
@@ -212,8 +211,7 @@ async def _extract_file_toc_content(
|
|
|
212
211
|
return None
|
|
213
212
|
|
|
214
213
|
try:
|
|
215
|
-
|
|
216
|
-
content = await f.read()
|
|
214
|
+
content = file_path.read_text(encoding="utf-8")
|
|
217
215
|
lines = content.split("\n")
|
|
218
216
|
|
|
219
217
|
# Extract headings
|
|
@@ -259,7 +257,7 @@ async def _extract_file_toc_content(
|
|
|
259
257
|
return None
|
|
260
258
|
|
|
261
259
|
|
|
262
|
-
|
|
260
|
+
def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
|
|
263
261
|
"""Extract TOCs from current and prior agents' files in the pipeline.
|
|
264
262
|
|
|
265
263
|
Shows full TOC of agent's own file and high-level summaries of prior agents'
|
|
@@ -311,9 +309,7 @@ async def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
|
|
|
311
309
|
for prior_file in config.prior_files:
|
|
312
310
|
file_path = base_path / prior_file
|
|
313
311
|
# Only show # and ## headings from prior files, max 500 chars each
|
|
314
|
-
prior_toc =
|
|
315
|
-
file_path, max_depth=2, max_chars=500
|
|
316
|
-
)
|
|
312
|
+
prior_toc = _extract_file_toc_content(file_path, max_depth=2, max_chars=500)
|
|
317
313
|
if prior_toc:
|
|
318
314
|
# Add section with XML tags
|
|
319
315
|
toc_sections.append(
|
|
@@ -325,9 +321,7 @@ async def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
|
|
|
325
321
|
# Extract TOC from own file (full detail)
|
|
326
322
|
if config.own_file:
|
|
327
323
|
own_path = base_path / config.own_file
|
|
328
|
-
own_toc =
|
|
329
|
-
own_path, max_depth=None, max_chars=2000
|
|
330
|
-
)
|
|
324
|
+
own_toc = _extract_file_toc_content(own_path, max_depth=None, max_chars=2000)
|
|
331
325
|
if own_toc:
|
|
332
326
|
# Put own file TOC at the beginning with XML tags
|
|
333
327
|
toc_sections.insert(
|
shotgun/agents/config/manager.py
CHANGED
|
@@ -5,8 +5,6 @@ import uuid
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
|
-
import aiofiles
|
|
9
|
-
import aiofiles.os
|
|
10
8
|
from pydantic import SecretStr
|
|
11
9
|
|
|
12
10
|
from shotgun.logging_config import get_logger
|
|
@@ -50,7 +48,7 @@ class ConfigManager:
|
|
|
50
48
|
|
|
51
49
|
self._config: ShotgunConfig | None = None
|
|
52
50
|
|
|
53
|
-
|
|
51
|
+
def load(self, force_reload: bool = True) -> ShotgunConfig:
|
|
54
52
|
"""Load configuration from file.
|
|
55
53
|
|
|
56
54
|
Args:
|
|
@@ -62,19 +60,18 @@ class ConfigManager:
|
|
|
62
60
|
if self._config is not None and not force_reload:
|
|
63
61
|
return self._config
|
|
64
62
|
|
|
65
|
-
if not
|
|
63
|
+
if not self.config_path.exists():
|
|
66
64
|
logger.info(
|
|
67
65
|
"Configuration file not found, creating new config at: %s",
|
|
68
66
|
self.config_path,
|
|
69
67
|
)
|
|
70
68
|
# Create new config with generated shotgun_instance_id
|
|
71
|
-
self._config =
|
|
69
|
+
self._config = self.initialize()
|
|
72
70
|
return self._config
|
|
73
71
|
|
|
74
72
|
try:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
data = json.loads(content)
|
|
73
|
+
with open(self.config_path, encoding="utf-8") as f:
|
|
74
|
+
data = json.load(f)
|
|
78
75
|
|
|
79
76
|
# Migration: Rename user_id to shotgun_instance_id (config v2 -> v3)
|
|
80
77
|
if "user_id" in data and SHOTGUN_INSTANCE_ID_FIELD not in data:
|
|
@@ -104,12 +101,6 @@ class ConfigManager:
|
|
|
104
101
|
"Existing BYOK user detected: set shown_welcome_screen=False to show welcome screen"
|
|
105
102
|
)
|
|
106
103
|
|
|
107
|
-
# Migration: Add marketing config for v3 -> v4
|
|
108
|
-
if "marketing" not in data:
|
|
109
|
-
data["marketing"] = {"messages": {}}
|
|
110
|
-
data["config_version"] = 4
|
|
111
|
-
logger.info("Migrated config v3->v4: added marketing configuration")
|
|
112
|
-
|
|
113
104
|
# Convert plain text secrets to SecretStr objects
|
|
114
105
|
self._convert_secrets_to_secretstr(data)
|
|
115
106
|
|
|
@@ -126,7 +117,7 @@ class ConfigManager:
|
|
|
126
117
|
|
|
127
118
|
if self._config.selected_model in MODEL_SPECS:
|
|
128
119
|
spec = MODEL_SPECS[self._config.selected_model]
|
|
129
|
-
if not
|
|
120
|
+
if not self.has_provider_key(spec.provider):
|
|
130
121
|
logger.info(
|
|
131
122
|
"Selected model %s provider has no API key, finding available model",
|
|
132
123
|
self._config.selected_model.value,
|
|
@@ -144,7 +135,7 @@ class ConfigManager:
|
|
|
144
135
|
# If no selected_model or it was invalid, find first available model
|
|
145
136
|
if not self._config.selected_model:
|
|
146
137
|
for provider in ProviderType:
|
|
147
|
-
if
|
|
138
|
+
if self.has_provider_key(provider):
|
|
148
139
|
# Set to that provider's default model
|
|
149
140
|
from .models import MODEL_SPECS, ModelName
|
|
150
141
|
|
|
@@ -165,7 +156,7 @@ class ConfigManager:
|
|
|
165
156
|
break
|
|
166
157
|
|
|
167
158
|
if should_save:
|
|
168
|
-
|
|
159
|
+
self.save(self._config)
|
|
169
160
|
|
|
170
161
|
return self._config
|
|
171
162
|
|
|
@@ -174,10 +165,10 @@ class ConfigManager:
|
|
|
174
165
|
"Failed to load configuration from %s: %s", self.config_path, e
|
|
175
166
|
)
|
|
176
167
|
logger.info("Creating new configuration with generated shotgun_instance_id")
|
|
177
|
-
self._config =
|
|
168
|
+
self._config = self.initialize()
|
|
178
169
|
return self._config
|
|
179
170
|
|
|
180
|
-
|
|
171
|
+
def save(self, config: ShotgunConfig | None = None) -> None:
|
|
181
172
|
"""Save configuration to file.
|
|
182
173
|
|
|
183
174
|
Args:
|
|
@@ -193,17 +184,15 @@ class ConfigManager:
|
|
|
193
184
|
)
|
|
194
185
|
|
|
195
186
|
# Ensure directory exists
|
|
196
|
-
|
|
187
|
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
197
188
|
|
|
198
189
|
try:
|
|
199
190
|
# Convert SecretStr to plain text for JSON serialization
|
|
200
191
|
data = config.model_dump()
|
|
201
192
|
self._convert_secretstr_to_plain(data)
|
|
202
|
-
self._convert_datetime_to_isoformat(data)
|
|
203
193
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
await f.write(json_content)
|
|
194
|
+
with open(self.config_path, "w", encoding="utf-8") as f:
|
|
195
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
207
196
|
|
|
208
197
|
logger.debug("Configuration saved to %s", self.config_path)
|
|
209
198
|
self._config = config
|
|
@@ -212,16 +201,14 @@ class ConfigManager:
|
|
|
212
201
|
logger.error("Failed to save configuration to %s: %s", self.config_path, e)
|
|
213
202
|
raise
|
|
214
203
|
|
|
215
|
-
|
|
216
|
-
self, provider: ProviderType | str, **kwargs: Any
|
|
217
|
-
) -> None:
|
|
204
|
+
def update_provider(self, provider: ProviderType | str, **kwargs: Any) -> None:
|
|
218
205
|
"""Update provider configuration.
|
|
219
206
|
|
|
220
207
|
Args:
|
|
221
208
|
provider: Provider to update
|
|
222
209
|
**kwargs: Configuration fields to update (only api_key supported)
|
|
223
210
|
"""
|
|
224
|
-
config =
|
|
211
|
+
config = self.load()
|
|
225
212
|
|
|
226
213
|
# Get provider config and check if it's shotgun
|
|
227
214
|
provider_config, is_shotgun = self._get_provider_config_and_type(
|
|
@@ -266,11 +253,11 @@ class ConfigManager:
|
|
|
266
253
|
# This prevents the welcome screen from showing again after user has made their choice
|
|
267
254
|
config.shown_welcome_screen = True
|
|
268
255
|
|
|
269
|
-
|
|
256
|
+
self.save(config)
|
|
270
257
|
|
|
271
|
-
|
|
258
|
+
def clear_provider_key(self, provider: ProviderType | str) -> None:
|
|
272
259
|
"""Remove the API key for the given provider (LLM provider or shotgun)."""
|
|
273
|
-
config =
|
|
260
|
+
config = self.load()
|
|
274
261
|
|
|
275
262
|
# Get provider config (shotgun or LLM provider)
|
|
276
263
|
provider_config, is_shotgun = self._get_provider_config_and_type(
|
|
@@ -283,34 +270,34 @@ class ConfigManager:
|
|
|
283
270
|
if is_shotgun and isinstance(provider_config, ShotgunAccountConfig):
|
|
284
271
|
provider_config.supabase_jwt = None
|
|
285
272
|
|
|
286
|
-
|
|
273
|
+
self.save(config)
|
|
287
274
|
|
|
288
|
-
|
|
275
|
+
def update_selected_model(self, model_name: "ModelName") -> None:
|
|
289
276
|
"""Update the selected model.
|
|
290
277
|
|
|
291
278
|
Args:
|
|
292
279
|
model_name: Model to select
|
|
293
280
|
"""
|
|
294
|
-
config =
|
|
281
|
+
config = self.load()
|
|
295
282
|
config.selected_model = model_name
|
|
296
|
-
|
|
283
|
+
self.save(config)
|
|
297
284
|
|
|
298
|
-
|
|
285
|
+
def has_provider_key(self, provider: ProviderType | str) -> bool:
|
|
299
286
|
"""Check if the given provider has a non-empty API key configured.
|
|
300
287
|
|
|
301
288
|
This checks only the configuration file.
|
|
302
289
|
"""
|
|
303
290
|
# Use force_reload=False to avoid infinite loop when called from load()
|
|
304
|
-
config =
|
|
291
|
+
config = self.load(force_reload=False)
|
|
305
292
|
provider_enum = self._ensure_provider_enum(provider)
|
|
306
293
|
provider_config = self._get_provider_config(config, provider_enum)
|
|
307
294
|
|
|
308
295
|
return self._provider_has_api_key(provider_config)
|
|
309
296
|
|
|
310
|
-
|
|
297
|
+
def has_any_provider_key(self) -> bool:
|
|
311
298
|
"""Determine whether any provider has a configured API key."""
|
|
312
299
|
# Use force_reload=False to avoid infinite loop when called from load()
|
|
313
|
-
config =
|
|
300
|
+
config = self.load(force_reload=False)
|
|
314
301
|
# Check LLM provider keys (BYOK)
|
|
315
302
|
has_llm_key = any(
|
|
316
303
|
self._provider_has_api_key(self._get_provider_config(config, provider))
|
|
@@ -324,7 +311,7 @@ class ConfigManager:
|
|
|
324
311
|
has_shotgun_key = self._provider_has_api_key(config.shotgun)
|
|
325
312
|
return has_llm_key or has_shotgun_key
|
|
326
313
|
|
|
327
|
-
|
|
314
|
+
def initialize(self) -> ShotgunConfig:
|
|
328
315
|
"""Initialize configuration with defaults and save to file.
|
|
329
316
|
|
|
330
317
|
Returns:
|
|
@@ -334,7 +321,7 @@ class ConfigManager:
|
|
|
334
321
|
config = ShotgunConfig(
|
|
335
322
|
shotgun_instance_id=str(uuid.uuid4()),
|
|
336
323
|
)
|
|
337
|
-
|
|
324
|
+
self.save(config)
|
|
338
325
|
logger.info(
|
|
339
326
|
"Configuration initialized at %s with shotgun_instance_id: %s",
|
|
340
327
|
self.config_path,
|
|
@@ -390,24 +377,6 @@ class ConfigManager:
|
|
|
390
377
|
SUPABASE_JWT_FIELD
|
|
391
378
|
].get_secret_value()
|
|
392
379
|
|
|
393
|
-
def _convert_datetime_to_isoformat(self, data: dict[str, Any]) -> None:
|
|
394
|
-
"""Convert datetime objects in data to ISO8601 format strings for JSON serialization."""
|
|
395
|
-
from datetime import datetime
|
|
396
|
-
|
|
397
|
-
def convert_dict(d: dict[str, Any]) -> None:
|
|
398
|
-
"""Recursively convert datetime objects in a dict."""
|
|
399
|
-
for key, value in d.items():
|
|
400
|
-
if isinstance(value, datetime):
|
|
401
|
-
d[key] = value.isoformat()
|
|
402
|
-
elif isinstance(value, dict):
|
|
403
|
-
convert_dict(value)
|
|
404
|
-
elif isinstance(value, list):
|
|
405
|
-
for item in value:
|
|
406
|
-
if isinstance(item, dict):
|
|
407
|
-
convert_dict(item)
|
|
408
|
-
|
|
409
|
-
convert_dict(data)
|
|
410
|
-
|
|
411
380
|
def _ensure_provider_enum(self, provider: ProviderType | str) -> ProviderType:
|
|
412
381
|
"""Normalize provider values to ProviderType enum."""
|
|
413
382
|
return (
|
|
@@ -471,16 +440,16 @@ class ConfigManager:
|
|
|
471
440
|
provider_enum = self._ensure_provider_enum(provider)
|
|
472
441
|
return (self._get_provider_config(config, provider_enum), False)
|
|
473
442
|
|
|
474
|
-
|
|
443
|
+
def get_shotgun_instance_id(self) -> str:
|
|
475
444
|
"""Get the shotgun instance ID from configuration.
|
|
476
445
|
|
|
477
446
|
Returns:
|
|
478
447
|
The unique shotgun instance ID string
|
|
479
448
|
"""
|
|
480
|
-
config =
|
|
449
|
+
config = self.load()
|
|
481
450
|
return config.shotgun_instance_id
|
|
482
451
|
|
|
483
|
-
|
|
452
|
+
def update_shotgun_account(
|
|
484
453
|
self, api_key: str | None = None, supabase_jwt: str | None = None
|
|
485
454
|
) -> None:
|
|
486
455
|
"""Update Shotgun Account configuration.
|
|
@@ -489,7 +458,7 @@ class ConfigManager:
|
|
|
489
458
|
api_key: LiteLLM proxy API key (optional)
|
|
490
459
|
supabase_jwt: Supabase authentication JWT (optional)
|
|
491
460
|
"""
|
|
492
|
-
config =
|
|
461
|
+
config = self.load()
|
|
493
462
|
|
|
494
463
|
if api_key is not None:
|
|
495
464
|
config.shotgun.api_key = SecretStr(api_key) if api_key else None
|
|
@@ -499,7 +468,7 @@ class ConfigManager:
|
|
|
499
468
|
SecretStr(supabase_jwt) if supabase_jwt else None
|
|
500
469
|
)
|
|
501
470
|
|
|
502
|
-
|
|
471
|
+
self.save(config)
|
|
503
472
|
logger.info("Updated Shotgun Account configuration")
|
|
504
473
|
|
|
505
474
|
|