shotgun-sh 0.3.3.dev1__py3-none-any.whl → 0.6.2__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.
Files changed (159) hide show
  1. shotgun/agents/agent_manager.py +497 -30
  2. shotgun/agents/cancellation.py +103 -0
  3. shotgun/agents/common.py +90 -77
  4. shotgun/agents/config/README.md +0 -1
  5. shotgun/agents/config/manager.py +52 -8
  6. shotgun/agents/config/models.py +21 -27
  7. shotgun/agents/config/provider.py +44 -27
  8. shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
  9. shotgun/agents/conversation/history/token_counting/base.py +51 -9
  10. shotgun/agents/export.py +12 -13
  11. shotgun/agents/file_read.py +176 -0
  12. shotgun/agents/messages.py +15 -3
  13. shotgun/agents/models.py +90 -2
  14. shotgun/agents/plan.py +12 -13
  15. shotgun/agents/research.py +13 -10
  16. shotgun/agents/router/__init__.py +47 -0
  17. shotgun/agents/router/models.py +384 -0
  18. shotgun/agents/router/router.py +185 -0
  19. shotgun/agents/router/tools/__init__.py +18 -0
  20. shotgun/agents/router/tools/delegation_tools.py +557 -0
  21. shotgun/agents/router/tools/plan_tools.py +403 -0
  22. shotgun/agents/runner.py +17 -2
  23. shotgun/agents/specify.py +12 -13
  24. shotgun/agents/tasks.py +12 -13
  25. shotgun/agents/tools/__init__.py +8 -0
  26. shotgun/agents/tools/codebase/directory_lister.py +27 -39
  27. shotgun/agents/tools/codebase/file_read.py +26 -35
  28. shotgun/agents/tools/codebase/query_graph.py +9 -0
  29. shotgun/agents/tools/codebase/retrieve_code.py +9 -0
  30. shotgun/agents/tools/file_management.py +81 -3
  31. shotgun/agents/tools/file_read_tools/__init__.py +7 -0
  32. shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
  33. shotgun/agents/tools/markdown_tools/__init__.py +62 -0
  34. shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
  35. shotgun/agents/tools/markdown_tools/models.py +86 -0
  36. shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
  37. shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
  38. shotgun/agents/tools/markdown_tools/utils.py +453 -0
  39. shotgun/agents/tools/registry.py +46 -6
  40. shotgun/agents/tools/web_search/__init__.py +1 -2
  41. shotgun/agents/tools/web_search/gemini.py +1 -3
  42. shotgun/agents/tools/web_search/openai.py +42 -23
  43. shotgun/attachments/__init__.py +41 -0
  44. shotgun/attachments/errors.py +60 -0
  45. shotgun/attachments/models.py +107 -0
  46. shotgun/attachments/parser.py +257 -0
  47. shotgun/attachments/processor.py +193 -0
  48. shotgun/build_constants.py +4 -7
  49. shotgun/cli/clear.py +2 -2
  50. shotgun/cli/codebase/commands.py +181 -65
  51. shotgun/cli/compact.py +2 -2
  52. shotgun/cli/context.py +2 -2
  53. shotgun/cli/error_handler.py +2 -2
  54. shotgun/cli/run.py +90 -0
  55. shotgun/cli/spec/backup.py +2 -1
  56. shotgun/codebase/__init__.py +2 -0
  57. shotgun/codebase/benchmarks/__init__.py +35 -0
  58. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  59. shotgun/codebase/benchmarks/exporters.py +119 -0
  60. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  61. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  62. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  63. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  64. shotgun/codebase/benchmarks/models.py +129 -0
  65. shotgun/codebase/core/__init__.py +4 -0
  66. shotgun/codebase/core/call_resolution.py +91 -0
  67. shotgun/codebase/core/change_detector.py +11 -6
  68. shotgun/codebase/core/errors.py +159 -0
  69. shotgun/codebase/core/extractors/__init__.py +23 -0
  70. shotgun/codebase/core/extractors/base.py +138 -0
  71. shotgun/codebase/core/extractors/factory.py +63 -0
  72. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  73. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  74. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  75. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  76. shotgun/codebase/core/extractors/protocol.py +109 -0
  77. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  78. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  79. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  80. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  81. shotgun/codebase/core/extractors/types.py +15 -0
  82. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  83. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  84. shotgun/codebase/core/gitignore.py +252 -0
  85. shotgun/codebase/core/ingestor.py +644 -354
  86. shotgun/codebase/core/kuzu_compat.py +119 -0
  87. shotgun/codebase/core/language_config.py +239 -0
  88. shotgun/codebase/core/manager.py +256 -46
  89. shotgun/codebase/core/metrics_collector.py +310 -0
  90. shotgun/codebase/core/metrics_types.py +347 -0
  91. shotgun/codebase/core/parallel_executor.py +424 -0
  92. shotgun/codebase/core/work_distributor.py +254 -0
  93. shotgun/codebase/core/worker.py +768 -0
  94. shotgun/codebase/indexing_state.py +86 -0
  95. shotgun/codebase/models.py +94 -0
  96. shotgun/codebase/service.py +13 -0
  97. shotgun/exceptions.py +9 -9
  98. shotgun/main.py +3 -16
  99. shotgun/posthog_telemetry.py +165 -24
  100. shotgun/prompts/agents/export.j2 +2 -0
  101. shotgun/prompts/agents/file_read.j2 +48 -0
  102. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +19 -52
  103. shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
  104. shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
  105. shotgun/prompts/agents/partials/router_delegation_mode.j2 +35 -0
  106. shotgun/prompts/agents/plan.j2 +38 -12
  107. shotgun/prompts/agents/research.j2 +70 -31
  108. shotgun/prompts/agents/router.j2 +713 -0
  109. shotgun/prompts/agents/specify.j2 +53 -16
  110. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  111. shotgun/prompts/agents/state/system_state.j2 +24 -13
  112. shotgun/prompts/agents/tasks.j2 +72 -34
  113. shotgun/settings.py +49 -10
  114. shotgun/tui/app.py +154 -24
  115. shotgun/tui/commands/__init__.py +9 -1
  116. shotgun/tui/components/attachment_bar.py +87 -0
  117. shotgun/tui/components/mode_indicator.py +120 -25
  118. shotgun/tui/components/prompt_input.py +25 -28
  119. shotgun/tui/components/status_bar.py +14 -7
  120. shotgun/tui/dependencies.py +58 -8
  121. shotgun/tui/protocols.py +55 -0
  122. shotgun/tui/screens/chat/chat.tcss +24 -1
  123. shotgun/tui/screens/chat/chat_screen.py +1376 -213
  124. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  125. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  126. shotgun/tui/screens/chat_screen/command_providers.py +0 -97
  127. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  128. shotgun/tui/screens/chat_screen/history/chat_history.py +58 -6
  129. shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
  130. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  131. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  132. shotgun/tui/screens/chat_screen/messages.py +219 -0
  133. shotgun/tui/screens/database_locked_dialog.py +219 -0
  134. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  135. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  136. shotgun/tui/screens/model_picker.py +1 -3
  137. shotgun/tui/screens/models.py +11 -0
  138. shotgun/tui/state/processing_state.py +19 -0
  139. shotgun/tui/utils/mode_progress.py +20 -86
  140. shotgun/tui/widgets/__init__.py +2 -1
  141. shotgun/tui/widgets/approval_widget.py +152 -0
  142. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  143. shotgun/tui/widgets/plan_panel.py +129 -0
  144. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  145. shotgun/tui/widgets/widget_coordinator.py +18 -0
  146. shotgun/utils/file_system_utils.py +4 -1
  147. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/METADATA +88 -35
  148. shotgun_sh-0.6.2.dist-info/RECORD +291 -0
  149. shotgun/cli/export.py +0 -81
  150. shotgun/cli/plan.py +0 -73
  151. shotgun/cli/research.py +0 -93
  152. shotgun/cli/specify.py +0 -70
  153. shotgun/cli/tasks.py +0 -78
  154. shotgun/sentry_telemetry.py +0 -232
  155. shotgun/tui/screens/onboarding.py +0 -580
  156. shotgun_sh-0.3.3.dev1.dist-info/RECORD +0 -229
  157. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/WHEEL +0 -0
  158. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/entry_points.txt +0 -0
  159. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.6.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,103 @@
1
+ """Cancellation utilities for agent execution.
2
+
3
+ This module provides utilities for responsive cancellation of agent operations,
4
+ particularly for handling ESC key presses during LLM streaming.
5
+ """
6
+
7
+ import asyncio
8
+ from collections.abc import AsyncIterable, AsyncIterator
9
+ from typing import TypeVar
10
+
11
+ from shotgun.logging_config import get_logger
12
+
13
+ logger = get_logger(__name__)
14
+
15
+ T = TypeVar("T")
16
+
17
+ CANCELLATION_CHECK_INTERVAL = 0.5 # Check every 500ms
18
+ CANCELLATION_MESSAGE = "Operation cancelled by user"
19
+
20
+
21
+ class CancellableStreamIterator(AsyncIterator[T]):
22
+ """Wraps an async iterable to check for cancellation periodically.
23
+
24
+ This allows ESC cancellation to be responsive even when the underlying
25
+ stream (LLM chunks) is slow to produce events. Instead of blocking
26
+ indefinitely on the next chunk, we timeout periodically and check
27
+ if cancellation was requested.
28
+
29
+ Example:
30
+ ```python
31
+ cancellation_event = asyncio.Event()
32
+
33
+ async def process_stream(stream):
34
+ wrapped = CancellableStreamIterator(stream, cancellation_event)
35
+ async for event in wrapped:
36
+ process(event)
37
+
38
+ # In another task, set the event to cancel:
39
+ cancellation_event.set()
40
+ ```
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ stream: AsyncIterable[T],
46
+ cancellation_event: asyncio.Event | None = None,
47
+ check_interval: float = CANCELLATION_CHECK_INTERVAL,
48
+ ) -> None:
49
+ """Initialize the cancellable stream iterator.
50
+
51
+ Args:
52
+ stream: The underlying async iterable to wrap
53
+ cancellation_event: Event that signals cancellation when set
54
+ check_interval: How often to check for cancellation (seconds)
55
+ """
56
+ self._stream = stream
57
+ self._iterator: AsyncIterator[T] | None = None
58
+ self._cancellation_event = cancellation_event
59
+ self._check_interval = check_interval
60
+ self._pending_task: asyncio.Task[T] | None = None
61
+
62
+ def __aiter__(self) -> AsyncIterator[T]:
63
+ return self
64
+
65
+ async def __anext__(self) -> T:
66
+ if self._iterator is None:
67
+ self._iterator = self._stream.__aiter__()
68
+
69
+ # Create a task for the next item if we don't have one pending
70
+ if self._pending_task is None:
71
+ # Capture iterator reference for the coroutine
72
+ iterator = self._iterator
73
+
74
+ async def get_next() -> T:
75
+ return await iterator.__anext__()
76
+
77
+ self._pending_task = asyncio.create_task(get_next())
78
+
79
+ while True:
80
+ # Check if cancellation was requested
81
+ if self._cancellation_event and self._cancellation_event.is_set():
82
+ logger.debug("Cancellation detected in stream iterator")
83
+ # Cancel the pending task and raise
84
+ self._pending_task.cancel()
85
+ self._pending_task = None
86
+ raise asyncio.CancelledError(CANCELLATION_MESSAGE)
87
+
88
+ # Wait for the task with a short timeout
89
+ # Using asyncio.wait instead of wait_for to avoid cancelling the task on timeout
90
+ done, _ = await asyncio.wait(
91
+ [self._pending_task],
92
+ timeout=self._check_interval,
93
+ return_when=asyncio.FIRST_COMPLETED,
94
+ )
95
+
96
+ if done:
97
+ # Task completed - get result and clear pending task
98
+ task = done.pop()
99
+ self._pending_task = None
100
+ # Re-raise StopAsyncIteration or return the result
101
+ return task.result()
102
+
103
+ # Task not done yet, loop and check cancellation again
shotgun/agents/common.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Common utilities for agent creation and management."""
2
2
 
3
- from collections.abc import Callable
3
+ from collections.abc import AsyncIterable, Awaitable, Callable
4
4
  from pathlib import Path
5
5
  from typing import Any
6
6
 
@@ -17,7 +17,12 @@ from pydantic_ai.messages import (
17
17
  )
18
18
 
19
19
  from shotgun.agents.config import ProviderType, get_provider_model
20
- from shotgun.agents.models import AgentResponse, AgentType
20
+ from shotgun.agents.models import (
21
+ AgentResponse,
22
+ AgentSystemPromptContext,
23
+ AgentType,
24
+ ShotgunAgent,
25
+ )
21
26
  from shotgun.logging_config import get_logger
22
27
  from shotgun.prompts import PromptLoader
23
28
  from shotgun.sdk.services import get_codebase_service
@@ -33,12 +38,14 @@ from .tools import (
33
38
  codebase_shell,
34
39
  directory_lister,
35
40
  file_read,
41
+ insert_markdown_section,
36
42
  query_graph,
37
43
  read_file,
44
+ remove_markdown_section,
45
+ replace_markdown_section,
38
46
  retrieve_code,
39
47
  write_file,
40
48
  )
41
- from .tools.file_management import AGENT_DIRECTORIES
42
49
 
43
50
  logger = get_logger(__name__)
44
51
 
@@ -65,6 +72,11 @@ async def add_system_status_message(
65
72
  await deps.codebase_service.list_graphs_for_directory()
66
73
  )
67
74
 
75
+ # Get graphs currently being indexed
76
+ indexing_graph_ids: set[str] = set()
77
+ if deps.codebase_service:
78
+ indexing_graph_ids = deps.codebase_service.indexing.get_active_ids()
79
+
68
80
  # Get existing files for the agent
69
81
  existing_files = get_agent_existing_files(deps.agent_mode)
70
82
 
@@ -74,15 +86,31 @@ async def add_system_status_message(
74
86
  # Get current datetime with timezone information
75
87
  dt_context = get_datetime_context()
76
88
 
89
+ # Get execution plan and pending approval state if this is the Router agent
90
+ execution_plan = None
91
+ pending_approval = False
92
+ if deps.agent_mode == AgentType.ROUTER:
93
+ # Import here to avoid circular imports
94
+ from shotgun.agents.router.models import RouterDeps
95
+
96
+ if isinstance(deps, RouterDeps):
97
+ if deps.current_plan is not None:
98
+ execution_plan = deps.current_plan.format_for_display()
99
+ # Check if plan is pending approval (multi-step plan in Planning mode)
100
+ pending_approval = deps.pending_approval is not None
101
+
77
102
  system_state = prompt_loader.render(
78
103
  "agents/state/system_state.j2",
79
104
  codebase_understanding_graphs=codebase_understanding_graphs,
105
+ indexing_graph_ids=indexing_graph_ids,
80
106
  is_tui_context=deps.is_tui_context,
81
107
  existing_files=existing_files,
82
108
  markdown_toc=markdown_toc,
83
109
  current_datetime=dt_context.datetime_formatted,
84
110
  timezone_name=dt_context.timezone_name,
85
111
  utc_offset=dt_context.utc_offset,
112
+ execution_plan=execution_plan,
113
+ pending_approval=pending_approval,
86
114
  )
87
115
 
88
116
  message_history.append(
@@ -102,7 +130,7 @@ async def create_base_agent(
102
130
  additional_tools: list[Any] | None = None,
103
131
  provider: ProviderType | None = None,
104
132
  agent_mode: AgentType | None = None,
105
- ) -> tuple[Agent[AgentDeps, AgentResponse], AgentDeps]:
133
+ ) -> tuple[ShotgunAgent, AgentDeps]:
106
134
  """Create a base agent with common configuration.
107
135
 
108
136
  Args:
@@ -179,6 +207,9 @@ async def create_base_agent(
179
207
  agent.tool(write_file)
180
208
  agent.tool(append_file)
181
209
  agent.tool(read_file)
210
+ agent.tool(replace_markdown_section)
211
+ agent.tool(insert_markdown_section)
212
+ agent.tool(remove_markdown_section)
182
213
 
183
214
  # Register codebase understanding tools (conditional)
184
215
  if load_codebase_understanding_tools:
@@ -352,86 +383,33 @@ async def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
352
383
 
353
384
 
354
385
  def get_agent_existing_files(agent_mode: AgentType | None = None) -> list[str]:
355
- """Get list of existing files for the given agent mode.
386
+ """Get list of all existing files in .shotgun directory.
387
+
388
+ All agents can read any file in .shotgun/, so we list all files regardless
389
+ of agent mode. This includes user-added files that agents should be aware of.
356
390
 
357
391
  Args:
358
- agent_mode: The agent mode to check files for. If None, lists all files.
392
+ agent_mode: Unused, kept for backwards compatibility.
359
393
 
360
394
  Returns:
361
395
  List of existing file paths relative to .shotgun directory
362
396
  """
363
397
  base_path = get_shotgun_base_path()
364
- existing_files = []
365
-
366
- # If no agent mode, list all files in base path and first level subdirectories
367
- if agent_mode is None:
368
- # List files in the root .shotgun directory
369
- for item in base_path.iterdir():
370
- if item.is_file():
371
- existing_files.append(item.name)
372
- elif item.is_dir():
373
- # List files in first-level subdirectories
374
- for subitem in item.iterdir():
375
- if subitem.is_file():
376
- relative_path = subitem.relative_to(base_path)
377
- existing_files.append(str(relative_path))
398
+ existing_files: list[str] = []
399
+
400
+ if not base_path.exists():
378
401
  return existing_files
379
402
 
380
- # Handle specific agent modes
381
- if agent_mode not in AGENT_DIRECTORIES:
382
- return []
383
-
384
- if agent_mode == AgentType.EXPORT:
385
- # For export agent, list all files in exports directory
386
- exports_dir = base_path / "exports"
387
- if exports_dir.exists():
388
- for file_path in exports_dir.rglob("*"):
389
- if file_path.is_file():
390
- relative_path = file_path.relative_to(base_path)
403
+ # List all files in .shotgun directory and subdirectories
404
+ for item in base_path.iterdir():
405
+ if item.is_file():
406
+ existing_files.append(item.name)
407
+ elif item.is_dir():
408
+ # List files in subdirectories (one level deep to avoid too much noise)
409
+ for subitem in item.iterdir():
410
+ if subitem.is_file():
411
+ relative_path = subitem.relative_to(base_path)
391
412
  existing_files.append(str(relative_path))
392
- else:
393
- # For other agents, check files/directories they have access to
394
- allowed_paths_raw = AGENT_DIRECTORIES[agent_mode]
395
-
396
- # Convert single Path/string to list of Paths for uniform handling
397
- if isinstance(allowed_paths_raw, str):
398
- # Special case: "*" means export agent (shouldn't reach here but handle it)
399
- allowed_paths = (
400
- [Path(allowed_paths_raw)] if allowed_paths_raw != "*" else []
401
- )
402
- elif isinstance(allowed_paths_raw, Path):
403
- allowed_paths = [allowed_paths_raw]
404
- else:
405
- # Already a list
406
- allowed_paths = allowed_paths_raw
407
-
408
- # Check each allowed path
409
- for allowed_path in allowed_paths:
410
- allowed_str = str(allowed_path)
411
-
412
- # Check if it's a directory (no .md suffix)
413
- if not allowed_path.suffix or not allowed_str.endswith(".md"):
414
- # It's a directory - list all files within it
415
- dir_path = base_path / allowed_str
416
- if dir_path.exists() and dir_path.is_dir():
417
- for file_path in dir_path.rglob("*"):
418
- if file_path.is_file():
419
- relative_path = file_path.relative_to(base_path)
420
- existing_files.append(str(relative_path))
421
- else:
422
- # It's a file - check if it exists
423
- file_path = base_path / allowed_str
424
- if file_path.exists():
425
- existing_files.append(allowed_str)
426
-
427
- # Also check for associated directory (e.g., research/ for research.md)
428
- base_name = allowed_str.replace(".md", "")
429
- dir_path = base_path / base_name
430
- if dir_path.exists() and dir_path.is_dir():
431
- for file_path in dir_path.rglob("*"):
432
- if file_path.is_file():
433
- relative_path = file_path.relative_to(base_path)
434
- existing_files.append(str(relative_path))
435
413
 
436
414
  return existing_files
437
415
 
@@ -458,10 +436,24 @@ def build_agent_system_prompt(
458
436
  logger.debug("🔧 Building research agent system prompt...")
459
437
  logger.debug("Interactive mode: %s", ctx.deps.interactive_mode)
460
438
 
461
- result = prompt_loader.render(
462
- f"agents/{agent_type}.j2",
439
+ # Build template context using Pydantic model for type safety and testability
440
+ # Import here to avoid circular imports (same pattern as add_system_status_message)
441
+ from shotgun.agents.router.models import RouterDeps
442
+
443
+ router_mode = None
444
+ if isinstance(ctx.deps, RouterDeps):
445
+ router_mode = ctx.deps.router_mode.value
446
+
447
+ template_context = AgentSystemPromptContext(
463
448
  interactive_mode=ctx.deps.interactive_mode,
464
449
  mode=agent_type,
450
+ sub_agent_context=ctx.deps.sub_agent_context,
451
+ router_mode=router_mode,
452
+ )
453
+
454
+ result = prompt_loader.render(
455
+ f"agents/{agent_type}.j2",
456
+ **template_context.model_dump(),
465
457
  )
466
458
 
467
459
  if agent_type == "research":
@@ -525,13 +517,33 @@ async def add_system_prompt_message(
525
517
  return message_history
526
518
 
527
519
 
520
+ EventStreamHandler = Callable[
521
+ [RunContext[AgentDeps], AsyncIterable[Any]], Awaitable[None]
522
+ ]
523
+
524
+
528
525
  async def run_agent(
529
- agent: Agent[AgentDeps, AgentResponse],
526
+ agent: ShotgunAgent,
530
527
  prompt: str,
531
528
  deps: AgentDeps,
532
529
  message_history: list[ModelMessage] | None = None,
533
530
  usage_limits: UsageLimits | None = None,
531
+ event_stream_handler: EventStreamHandler | None = None,
534
532
  ) -> AgentRunResult[AgentResponse]:
533
+ """Run an agent with optional streaming support.
534
+
535
+ Args:
536
+ agent: The agent to run.
537
+ prompt: The prompt to send to the agent.
538
+ deps: Agent dependencies.
539
+ message_history: Optional message history to continue from.
540
+ usage_limits: Optional usage limits for the run.
541
+ event_stream_handler: Optional callback for streaming events.
542
+ When provided, enables real-time streaming of agent responses.
543
+
544
+ Returns:
545
+ The agent run result.
546
+ """
535
547
  # Clear file tracker for new run
536
548
  deps.file_tracker.clear()
537
549
  logger.debug("🔧 Cleared file tracker for new agent run")
@@ -544,6 +556,7 @@ async def run_agent(
544
556
  deps=deps,
545
557
  usage_limits=usage_limits,
546
558
  message_history=message_history,
559
+ event_stream_handler=event_stream_handler,
547
560
  )
548
561
 
549
562
  # Log file operations summary if any files were modified
@@ -41,7 +41,6 @@ This directory contains the configuration management system for Shotgun, includi
41
41
  - **Title**: "feat: add config migration for streaming capability field (v4->v5)"
42
42
  - **Key Changes**:
43
43
  - Added `supports_streaming` field to OpenAI config
44
- - Added `shown_onboarding_popup` timestamp field
45
44
  - Added `supabase_jwt` to Shotgun Account config
46
45
 
47
46
  ## Migration System
@@ -51,7 +51,7 @@ class ConfigMigrationError(Exception):
51
51
  ProviderConfig = OpenAIConfig | AnthropicConfig | GoogleConfig | ShotgunAccountConfig
52
52
 
53
53
  # Current config version
54
- CURRENT_CONFIG_VERSION = 5
54
+ CURRENT_CONFIG_VERSION = 6
55
55
 
56
56
  # Backup directory name
57
57
  BACKUP_DIR_NAME = "backup"
@@ -183,6 +183,26 @@ def _migrate_v4_to_v5(data: dict[str, Any]) -> dict[str, Any]:
183
183
  return data
184
184
 
185
185
 
186
+ def _migrate_v5_to_v6(data: dict[str, Any]) -> dict[str, Any]:
187
+ """Migrate config from version 5 to version 6.
188
+
189
+ Changes:
190
+ - Add 'router_mode' field with default 'planning'
191
+
192
+ Args:
193
+ data: Config data dict at version 5
194
+
195
+ Returns:
196
+ Modified config data dict at version 6
197
+ """
198
+ if "router_mode" not in data:
199
+ data["router_mode"] = "planning"
200
+ logger.info("Migrated config v5->v6: added router_mode field")
201
+
202
+ data["config_version"] = 6
203
+ return data
204
+
205
+
186
206
  def _apply_migrations(data: dict[str, Any]) -> dict[str, Any]:
187
207
  """Apply all necessary migrations to bring config to current version.
188
208
 
@@ -203,6 +223,7 @@ def _apply_migrations(data: dict[str, Any]) -> dict[str, Any]:
203
223
  2: _migrate_v2_to_v3,
204
224
  3: _migrate_v3_to_v4,
205
225
  4: _migrate_v4_to_v5,
226
+ 5: _migrate_v5_to_v6,
206
227
  }
207
228
 
208
229
  # Apply migrations sequentially
@@ -356,9 +377,9 @@ class ConfigManager:
356
377
 
357
378
  # Find default model for this provider
358
379
  provider_models = {
359
- ProviderType.OPENAI: ModelName.GPT_5_1,
360
- ProviderType.ANTHROPIC: ModelName.CLAUDE_HAIKU_4_5,
361
- ProviderType.GOOGLE: ModelName.GEMINI_2_5_PRO,
380
+ ProviderType.OPENAI: ModelName.GPT_5_2,
381
+ ProviderType.ANTHROPIC: ModelName.CLAUDE_SONNET_4_5,
382
+ ProviderType.GOOGLE: ModelName.GEMINI_3_PRO_PREVIEW,
362
383
  }
363
384
 
364
385
  if provider in provider_models:
@@ -500,15 +521,18 @@ class ConfigManager:
500
521
  if provider_enum is None:
501
522
  raise RuntimeError("Provider enum should not be None for LLM providers")
502
523
  other_providers = [p for p in ProviderType if p != provider_enum]
503
- has_other_keys = any(self.has_provider_key(p) for p in other_providers)
524
+ has_other_keys = any(
525
+ self._provider_has_api_key(self._get_provider_config(config, p))
526
+ for p in other_providers
527
+ )
504
528
  if not has_other_keys:
505
529
  # Set selected_model to this provider's default model
506
530
  from .models import ModelName
507
531
 
508
532
  provider_models = {
509
- ProviderType.OPENAI: ModelName.GPT_5_1,
510
- ProviderType.ANTHROPIC: ModelName.CLAUDE_HAIKU_4_5,
511
- ProviderType.GOOGLE: ModelName.GEMINI_2_5_PRO,
533
+ ProviderType.OPENAI: ModelName.GPT_5_2,
534
+ ProviderType.ANTHROPIC: ModelName.CLAUDE_SONNET_4_5,
535
+ ProviderType.GOOGLE: ModelName.GEMINI_3_PRO_PREVIEW,
512
536
  }
513
537
  if provider_enum in provider_models:
514
538
  config.selected_model = provider_models[provider_enum]
@@ -772,6 +796,26 @@ class ConfigManager:
772
796
  await self.save(config)
773
797
  logger.info("Updated Shotgun Account configuration")
774
798
 
799
+ async def get_router_mode(self) -> str:
800
+ """Get the saved router mode.
801
+
802
+ Returns:
803
+ The router mode string ('planning' or 'drafting')
804
+ """
805
+ config = await self.load()
806
+ return config.router_mode
807
+
808
+ async def set_router_mode(self, mode: str) -> None:
809
+ """Save the router mode.
810
+
811
+ Args:
812
+ mode: Router mode to save ('planning' or 'drafting')
813
+ """
814
+ config = await self.load()
815
+ config.router_mode = mode
816
+ await self.save(config)
817
+ logger.debug("Router mode saved: %s", mode)
818
+
775
819
 
776
820
  # Global singleton instance
777
821
  _config_manager_instance: ConfigManager | None = None
@@ -26,10 +26,8 @@ class ModelName(StrEnum):
26
26
  """Available AI model names."""
27
27
 
28
28
  GPT_5_1 = "gpt-5.1"
29
- GPT_5_1_CODEX = "gpt-5.1-codex"
30
- GPT_5_1_CODEX_MINI = "gpt-5.1-codex-mini"
29
+ GPT_5_2 = "gpt-5.2"
31
30
  CLAUDE_OPUS_4_5 = "claude-opus-4-5"
32
- CLAUDE_SONNET_4 = "claude-sonnet-4"
33
31
  CLAUDE_SONNET_4_5 = "claude-sonnet-4-5"
34
32
  CLAUDE_HAIKU_4_5 = "claude-haiku-4-5"
35
33
  GEMINI_2_5_PRO = "gemini-2.5-pro"
@@ -110,21 +108,13 @@ MODEL_SPECS: dict[ModelName, ModelSpec] = {
110
108
  litellm_proxy_model_name="openai/gpt-5.1",
111
109
  short_name="GPT-5.1",
112
110
  ),
113
- ModelName.GPT_5_1_CODEX: ModelSpec(
114
- name=ModelName.GPT_5_1_CODEX,
111
+ ModelName.GPT_5_2: ModelSpec(
112
+ name=ModelName.GPT_5_2,
115
113
  provider=ProviderType.OPENAI,
116
114
  max_input_tokens=272_000,
117
115
  max_output_tokens=128_000,
118
- litellm_proxy_model_name="openai/gpt-5.1-codex",
119
- short_name="GPT-5.1 Codex",
120
- ),
121
- ModelName.GPT_5_1_CODEX_MINI: ModelSpec(
122
- name=ModelName.GPT_5_1_CODEX_MINI,
123
- provider=ProviderType.OPENAI,
124
- max_input_tokens=272_000,
125
- max_output_tokens=128_000,
126
- litellm_proxy_model_name="openai/gpt-5.1-codex-mini",
127
- short_name="GPT-5.1 Codex Mini",
116
+ litellm_proxy_model_name="openai/gpt-5.2",
117
+ short_name="GPT-5.2",
128
118
  ),
129
119
  ModelName.CLAUDE_SONNET_4_5: ModelSpec(
130
120
  name=ModelName.CLAUDE_SONNET_4_5,
@@ -166,14 +156,6 @@ MODEL_SPECS: dict[ModelName, ModelSpec] = {
166
156
  litellm_proxy_model_name="anthropic/claude-opus-4-5",
167
157
  short_name="Opus 4.5",
168
158
  ),
169
- ModelName.CLAUDE_SONNET_4: ModelSpec(
170
- name=ModelName.CLAUDE_SONNET_4,
171
- provider=ProviderType.ANTHROPIC,
172
- max_input_tokens=200_000,
173
- max_output_tokens=64_000,
174
- litellm_proxy_model_name="anthropic/claude-sonnet-4",
175
- short_name="Sonnet 4",
176
- ),
177
159
  ModelName.GEMINI_2_5_FLASH_LITE: ModelSpec(
178
160
  name=ModelName.GEMINI_2_5_FLASH_LITE,
179
161
  provider=ProviderType.GOOGLE,
@@ -226,6 +208,18 @@ class ShotgunAccountConfig(BaseModel):
226
208
  default=None, description="Default workspace ID for shared specs"
227
209
  )
228
210
 
211
+ @property
212
+ def has_valid_account(self) -> bool:
213
+ """Check if the user has a valid Shotgun Account configured.
214
+
215
+ Returns:
216
+ True if api_key is set and non-empty, False otherwise
217
+ """
218
+ if self.api_key is None:
219
+ return False
220
+ value = self.api_key.get_secret_value()
221
+ return bool(value and value.strip())
222
+
229
223
 
230
224
  class MarketingMessageRecord(BaseModel):
231
225
  """Record of when a marketing message was shown to the user."""
@@ -261,10 +255,6 @@ class ShotgunConfig(BaseModel):
261
255
  default=False,
262
256
  description="Whether the welcome screen has been shown to the user",
263
257
  )
264
- shown_onboarding_popup: datetime | None = Field(
265
- default=None,
266
- description="Timestamp when the onboarding popup was shown to the user (ISO8601 format)",
267
- )
268
258
  marketing: MarketingConfig = Field(
269
259
  default_factory=MarketingConfig,
270
260
  description="Marketing messages configuration and tracking",
@@ -277,3 +267,7 @@ class ShotgunConfig(BaseModel):
277
267
  default=None,
278
268
  description="Path to the backup file created when migration failed",
279
269
  )
270
+ router_mode: str = Field(
271
+ default="planning",
272
+ description="Router execution mode: 'planning' or 'drafting'",
273
+ )