shotgun-sh 0.3.3.dev1__py3-none-any.whl → 0.4.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. shotgun/agents/agent_manager.py +191 -23
  2. shotgun/agents/common.py +78 -77
  3. shotgun/agents/config/manager.py +42 -1
  4. shotgun/agents/config/models.py +16 -0
  5. shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
  6. shotgun/agents/export.py +12 -13
  7. shotgun/agents/models.py +66 -1
  8. shotgun/agents/plan.py +12 -13
  9. shotgun/agents/research.py +13 -10
  10. shotgun/agents/router/__init__.py +47 -0
  11. shotgun/agents/router/models.py +376 -0
  12. shotgun/agents/router/router.py +185 -0
  13. shotgun/agents/router/tools/__init__.py +18 -0
  14. shotgun/agents/router/tools/delegation_tools.py +503 -0
  15. shotgun/agents/router/tools/plan_tools.py +322 -0
  16. shotgun/agents/specify.py +12 -13
  17. shotgun/agents/tasks.py +12 -13
  18. shotgun/agents/tools/file_management.py +49 -1
  19. shotgun/agents/tools/registry.py +2 -0
  20. shotgun/agents/tools/web_search/__init__.py +1 -2
  21. shotgun/agents/tools/web_search/gemini.py +1 -3
  22. shotgun/codebase/core/change_detector.py +1 -1
  23. shotgun/codebase/core/ingestor.py +1 -1
  24. shotgun/codebase/core/manager.py +1 -1
  25. shotgun/prompts/agents/export.j2 +2 -0
  26. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -10
  27. shotgun/prompts/agents/partials/router_delegation_mode.j2 +36 -0
  28. shotgun/prompts/agents/plan.j2 +24 -12
  29. shotgun/prompts/agents/research.j2 +70 -31
  30. shotgun/prompts/agents/router.j2 +440 -0
  31. shotgun/prompts/agents/specify.j2 +39 -16
  32. shotgun/prompts/agents/state/system_state.j2 +15 -6
  33. shotgun/prompts/agents/tasks.j2 +58 -34
  34. shotgun/tui/app.py +5 -6
  35. shotgun/tui/components/mode_indicator.py +120 -25
  36. shotgun/tui/components/status_bar.py +2 -2
  37. shotgun/tui/dependencies.py +64 -9
  38. shotgun/tui/protocols.py +37 -0
  39. shotgun/tui/screens/chat/chat.tcss +9 -1
  40. shotgun/tui/screens/chat/chat_screen.py +643 -11
  41. shotgun/tui/screens/chat_screen/command_providers.py +0 -87
  42. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  43. shotgun/tui/screens/chat_screen/history/chat_history.py +12 -0
  44. shotgun/tui/screens/chat_screen/history/formatters.py +53 -15
  45. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  46. shotgun/tui/screens/chat_screen/messages.py +219 -0
  47. shotgun/tui/screens/onboarding.py +30 -26
  48. shotgun/tui/utils/mode_progress.py +20 -86
  49. shotgun/tui/widgets/__init__.py +2 -1
  50. shotgun/tui/widgets/approval_widget.py +152 -0
  51. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  52. shotgun/tui/widgets/plan_panel.py +129 -0
  53. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  54. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/METADATA +3 -3
  55. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/RECORD +58 -45
  56. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/WHEEL +0 -0
  57. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/entry_points.txt +0 -0
  58. {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/licenses/LICENSE +0 -0
@@ -20,7 +20,6 @@ if TYPE_CHECKING:
20
20
  from shotgun.agents.conversation import ConversationState
21
21
 
22
22
  from pydantic_ai import (
23
- Agent,
24
23
  RunContext,
25
24
  UsageLimits,
26
25
  )
@@ -38,6 +37,7 @@ from pydantic_ai.messages import (
38
37
  PartDeltaEvent,
39
38
  PartStartEvent,
40
39
  SystemPromptPart,
40
+ TextPartDelta,
41
41
  ToolCallPart,
42
42
  ToolCallPartDelta,
43
43
  UserPromptPart,
@@ -61,8 +61,11 @@ from shotgun.agents.context_analyzer import (
61
61
  from shotgun.agents.models import (
62
62
  AgentResponse,
63
63
  AgentType,
64
+ AnyAgent,
64
65
  FileOperation,
65
66
  FileOperationTracker,
67
+ RouterAgent,
68
+ ShotgunAgent,
66
69
  )
67
70
  from shotgun.posthog_telemetry import track_event
68
71
  from shotgun.tui.screens.chat_screen.hint_message import HintMessage
@@ -74,6 +77,8 @@ from .messages import AgentSystemPrompt
74
77
  from .models import AgentDeps, AgentRuntimeOptions
75
78
  from .plan import create_plan_agent
76
79
  from .research import create_research_agent
80
+ from .router import create_router_agent
81
+ from .router.models import RouterDeps
77
82
  from .specify import create_specify_agent
78
83
  from .tasks import create_tasks_agent
79
84
 
@@ -174,6 +179,67 @@ class CompactionCompletedMessage(Message):
174
179
  """Event posted when conversation compaction completes."""
175
180
 
176
181
 
182
+ class ToolExecutionStartedMessage(Message):
183
+ """Event posted when a tool starts executing.
184
+
185
+ This allows the UI to update the spinner text to provide feedback
186
+ during long-running tool executions.
187
+ """
188
+
189
+ def __init__(self, spinner_text: str = "Processing...") -> None:
190
+ """Initialize the tool execution started message.
191
+
192
+ Args:
193
+ spinner_text: The spinner message to display
194
+ """
195
+ super().__init__()
196
+ self.spinner_text = spinner_text
197
+
198
+
199
+ class ToolStreamingProgressMessage(Message):
200
+ """Event posted during tool call streaming to show progress.
201
+
202
+ This provides visual feedback while tool arguments are streaming,
203
+ especially useful for long-running writes like file content.
204
+ """
205
+
206
+ def __init__(self, streamed_tokens: int, spinner_text: str) -> None:
207
+ """Initialize the tool streaming progress message.
208
+
209
+ Args:
210
+ streamed_tokens: Approximate number of tokens streamed so far
211
+ spinner_text: The current spinner message to preserve
212
+ """
213
+ super().__init__()
214
+ self.streamed_tokens = streamed_tokens
215
+ self.spinner_text = spinner_text
216
+
217
+
218
+ # Fun spinner messages to show during tool execution
219
+ SPINNER_MESSAGES = [
220
+ "Pontificating...",
221
+ "Ruminating...",
222
+ "Cogitating...",
223
+ "Deliberating...",
224
+ "Contemplating...",
225
+ "Reticulating splines...",
226
+ "Consulting the oracle...",
227
+ "Gathering thoughts...",
228
+ "Processing neurons...",
229
+ "Summoning wisdom...",
230
+ "Brewing ideas...",
231
+ "Polishing pixels...",
232
+ "Herding electrons...",
233
+ "Warming up the flux capacitor...",
234
+ "Consulting ancient tomes...",
235
+ "Channeling the muses...",
236
+ "Percolating possibilities...",
237
+ "Untangling complexity...",
238
+ "Shuffling priorities...",
239
+ "Aligning the stars...",
240
+ ]
241
+
242
+
177
243
  class AgentStreamingStarted(Message):
178
244
  """Event posted when agent starts streaming responses."""
179
245
 
@@ -210,6 +276,11 @@ class _PartialStreamState:
210
276
 
211
277
  messages: list[ModelRequest | ModelResponse] = field(default_factory=list)
212
278
  current_response: ModelResponse | None = None
279
+ # Token counting for tool call streaming progress
280
+ streamed_tokens: int = 0
281
+ current_spinner_text: str = "Processing..."
282
+ # Track last reported tokens to throttle UI updates
283
+ last_reported_tokens: int = 0
213
284
 
214
285
 
215
286
  class AgentManager(Widget):
@@ -245,16 +316,18 @@ class AgentManager(Widget):
245
316
  )
246
317
 
247
318
  # Lazy initialization - agents created on first access
248
- self._research_agent: Agent[AgentDeps, AgentResponse] | None = None
319
+ self._research_agent: ShotgunAgent | None = None
249
320
  self._research_deps: AgentDeps | None = None
250
- self._plan_agent: Agent[AgentDeps, AgentResponse] | None = None
321
+ self._plan_agent: ShotgunAgent | None = None
251
322
  self._plan_deps: AgentDeps | None = None
252
- self._tasks_agent: Agent[AgentDeps, AgentResponse] | None = None
323
+ self._tasks_agent: ShotgunAgent | None = None
253
324
  self._tasks_deps: AgentDeps | None = None
254
- self._specify_agent: Agent[AgentDeps, AgentResponse] | None = None
325
+ self._specify_agent: ShotgunAgent | None = None
255
326
  self._specify_deps: AgentDeps | None = None
256
- self._export_agent: Agent[AgentDeps, AgentResponse] | None = None
327
+ self._export_agent: ShotgunAgent | None = None
257
328
  self._export_deps: AgentDeps | None = None
329
+ self._router_agent: RouterAgent | None = None
330
+ self._router_deps: RouterDeps | None = None
258
331
  self._agents_initialized = False
259
332
 
260
333
  # Track current active agent
@@ -291,10 +364,13 @@ class AgentManager(Widget):
291
364
  self._export_agent, self._export_deps = await create_export_agent(
292
365
  agent_runtime_options=self._agent_runtime_options
293
366
  )
367
+ self._router_agent, self._router_deps = await create_router_agent(
368
+ agent_runtime_options=self._agent_runtime_options
369
+ )
294
370
  self._agents_initialized = True
295
371
 
296
372
  @property
297
- def research_agent(self) -> Agent[AgentDeps, AgentResponse]:
373
+ def research_agent(self) -> ShotgunAgent:
298
374
  """Get research agent (must call _ensure_agents_initialized first)."""
299
375
  if self._research_agent is None:
300
376
  raise RuntimeError(
@@ -312,7 +388,7 @@ class AgentManager(Widget):
312
388
  return self._research_deps
313
389
 
314
390
  @property
315
- def plan_agent(self) -> Agent[AgentDeps, AgentResponse]:
391
+ def plan_agent(self) -> ShotgunAgent:
316
392
  """Get plan agent (must call _ensure_agents_initialized first)."""
317
393
  if self._plan_agent is None:
318
394
  raise RuntimeError(
@@ -330,7 +406,7 @@ class AgentManager(Widget):
330
406
  return self._plan_deps
331
407
 
332
408
  @property
333
- def tasks_agent(self) -> Agent[AgentDeps, AgentResponse]:
409
+ def tasks_agent(self) -> ShotgunAgent:
334
410
  """Get tasks agent (must call _ensure_agents_initialized first)."""
335
411
  if self._tasks_agent is None:
336
412
  raise RuntimeError(
@@ -348,7 +424,7 @@ class AgentManager(Widget):
348
424
  return self._tasks_deps
349
425
 
350
426
  @property
351
- def specify_agent(self) -> Agent[AgentDeps, AgentResponse]:
427
+ def specify_agent(self) -> ShotgunAgent:
352
428
  """Get specify agent (must call _ensure_agents_initialized first)."""
353
429
  if self._specify_agent is None:
354
430
  raise RuntimeError(
@@ -366,7 +442,7 @@ class AgentManager(Widget):
366
442
  return self._specify_deps
367
443
 
368
444
  @property
369
- def export_agent(self) -> Agent[AgentDeps, AgentResponse]:
445
+ def export_agent(self) -> ShotgunAgent:
370
446
  """Get export agent (must call _ensure_agents_initialized first)."""
371
447
  if self._export_agent is None:
372
448
  raise RuntimeError(
@@ -384,29 +460,48 @@ class AgentManager(Widget):
384
460
  return self._export_deps
385
461
 
386
462
  @property
387
- def current_agent(self) -> Agent[AgentDeps, AgentResponse]:
463
+ def router_agent(self) -> RouterAgent:
464
+ """Get router agent (must call _ensure_agents_initialized first)."""
465
+ if self._router_agent is None:
466
+ raise RuntimeError(
467
+ "Agents not initialized. Call _ensure_agents_initialized() first."
468
+ )
469
+ return self._router_agent
470
+
471
+ @property
472
+ def router_deps(self) -> RouterDeps:
473
+ """Get router deps (must call _ensure_agents_initialized first)."""
474
+ if self._router_deps is None:
475
+ raise RuntimeError(
476
+ "Agents not initialized. Call _ensure_agents_initialized() first."
477
+ )
478
+ return self._router_deps
479
+
480
+ @property
481
+ def current_agent(self) -> AnyAgent:
388
482
  """Get the currently active agent.
389
483
 
390
484
  Returns:
391
- The currently selected agent instance.
485
+ The currently selected agent instance (ShotgunAgent or RouterAgent).
392
486
  """
393
487
  return self._get_agent(self._current_agent_type)
394
488
 
395
- def _get_agent(self, agent_type: AgentType) -> Agent[AgentDeps, AgentResponse]:
489
+ def _get_agent(self, agent_type: AgentType) -> AnyAgent:
396
490
  """Get agent by type.
397
491
 
398
492
  Args:
399
493
  agent_type: The type of agent to retrieve.
400
494
 
401
495
  Returns:
402
- The requested agent instance.
496
+ The requested agent instance (ShotgunAgent or RouterAgent).
403
497
  """
404
- agent_map = {
498
+ agent_map: dict[AgentType, AnyAgent] = {
405
499
  AgentType.RESEARCH: self.research_agent,
406
500
  AgentType.PLAN: self.plan_agent,
407
501
  AgentType.TASKS: self.tasks_agent,
408
502
  AgentType.SPECIFY: self.specify_agent,
409
503
  AgentType.EXPORT: self.export_agent,
504
+ AgentType.ROUTER: self.router_agent,
410
505
  }
411
506
  return agent_map[agent_type]
412
507
 
@@ -419,12 +514,13 @@ class AgentManager(Widget):
419
514
  Returns:
420
515
  The agent-specific dependencies.
421
516
  """
422
- deps_map = {
517
+ deps_map: dict[AgentType, AgentDeps] = {
423
518
  AgentType.RESEARCH: self.research_deps,
424
519
  AgentType.PLAN: self.plan_deps,
425
520
  AgentType.TASKS: self.tasks_deps,
426
521
  AgentType.SPECIFY: self.specify_deps,
427
522
  AgentType.EXPORT: self.export_deps,
523
+ AgentType.ROUTER: self.router_deps,
428
524
  }
429
525
  return deps_map[agent_type]
430
526
 
@@ -433,6 +529,10 @@ class AgentManager(Widget):
433
529
 
434
530
  This preserves the agent's system_prompt_fn while using shared runtime state.
435
531
 
532
+ For Router agent, returns the shared deps directly (not a copy) because
533
+ Router state (pending_approval, current_plan, etc.) must be shared with
534
+ the TUI for features like plan approval widgets.
535
+
436
536
  Args:
437
537
  agent_type: The type of agent to create merged deps for.
438
538
 
@@ -445,8 +545,14 @@ class AgentManager(Widget):
445
545
  if self.deps is None:
446
546
  raise ValueError("Shared deps is None - this should not happen")
447
547
 
448
- # Create new deps with shared runtime state but agent's system_prompt_fn
449
- # Use a copy of the shared deps and update the system_prompt_fn
548
+ # For Router, use shared deps directly so state mutations are visible to TUI
549
+ # (e.g., pending_approval, current_plan need to be seen by ChatScreen)
550
+ if agent_type == AgentType.ROUTER:
551
+ # Update system_prompt_fn on shared deps in place
552
+ self.deps.system_prompt_fn = agent_deps.system_prompt_fn
553
+ return self.deps
554
+
555
+ # For other agents, create a copy with agent-specific system_prompt_fn
450
556
  merged_deps = self.deps.model_copy(
451
557
  update={"system_prompt_fn": agent_deps.system_prompt_fn}
452
558
  )
@@ -478,7 +584,7 @@ class AgentManager(Widget):
478
584
  )
479
585
  async def _run_agent_with_retry(
480
586
  self,
481
- agent: Agent[AgentDeps, AgentResponse],
587
+ agent: AnyAgent,
482
588
  prompt: str | None,
483
589
  deps: AgentDeps,
484
590
  usage_limits: UsageLimits | None,
@@ -489,9 +595,9 @@ class AgentManager(Widget):
489
595
  """Run agent with automatic retry on transient errors.
490
596
 
491
597
  Args:
492
- agent: The agent to run.
598
+ agent: The agent to run (ShotgunAgent or RouterAgent).
493
599
  prompt: Optional prompt to send to the agent.
494
- deps: Agent dependencies.
600
+ deps: Agent dependencies (AgentDeps or RouterDeps).
495
601
  usage_limits: Optional usage limits.
496
602
  message_history: Message history to provide to agent.
497
603
  event_stream_handler: Event handler for streaming.
@@ -502,8 +608,16 @@ class AgentManager(Widget):
502
608
 
503
609
  Raises:
504
610
  Various exceptions if all retries fail.
611
+
612
+ Note:
613
+ Type safety for agent/deps pairing is maintained by AgentManager's
614
+ _get_agent_deps which ensures the correct deps type is used for each
615
+ agent type. The cast is needed because Agent is contravariant in deps.
505
616
  """
506
- return await agent.run(
617
+ # Cast needed because Agent is contravariant in deps type parameter.
618
+ # The agent/deps pairing is ensured by _get_agent_deps returning the
619
+ # correct deps type for each agent type.
620
+ return await cast(ShotgunAgent, agent).run(
507
621
  prompt,
508
622
  deps=deps,
509
623
  usage_limits=usage_limits,
@@ -560,6 +674,11 @@ class AgentManager(Widget):
560
674
 
561
675
  deps.agent_mode = self._current_agent_type
562
676
 
677
+ # For router agent, set up the parent stream handler so sub-agents can stream
678
+ if self._current_agent_type == AgentType.ROUTER:
679
+ if isinstance(deps, RouterDeps):
680
+ deps.parent_stream_handler = self._handle_event_stream # type: ignore[assignment]
681
+
563
682
  # Filter out system prompts from other agent types
564
683
  from pydantic_ai.messages import ModelRequestPart
565
684
 
@@ -992,6 +1111,44 @@ class AgentManager(Widget):
992
1111
  )
993
1112
  continue
994
1113
 
1114
+ # Count tokens from the delta for progress indication
1115
+ delta_len = 0
1116
+ is_tool_call_delta = False
1117
+ if isinstance(event.delta, ToolCallPartDelta):
1118
+ is_tool_call_delta = True
1119
+ # args_delta can be str or dict depending on provider
1120
+ args_delta = event.delta.args_delta
1121
+ if isinstance(args_delta, str):
1122
+ delta_len = len(args_delta)
1123
+ elif isinstance(args_delta, dict):
1124
+ # For dict deltas, estimate from JSON representation
1125
+ delta_len = len(json.dumps(args_delta))
1126
+ # Pick a spinner message when tool streaming starts
1127
+ if state.current_spinner_text == "Processing...":
1128
+ import random
1129
+
1130
+ state.current_spinner_text = random.choice( # noqa: S311
1131
+ SPINNER_MESSAGES
1132
+ )
1133
+ elif isinstance(event.delta, TextPartDelta):
1134
+ delta_len = len(event.delta.content_delta)
1135
+
1136
+ if delta_len > 0:
1137
+ # Approximate tokens: len / 4 is a rough estimate
1138
+ state.streamed_tokens += delta_len // 4 + 1
1139
+ # Send progress update for tool call streaming
1140
+ # Throttle updates to every ~75 tokens to avoid flooding UI
1141
+ if is_tool_call_delta and (
1142
+ state.streamed_tokens - state.last_reported_tokens >= 75
1143
+ ):
1144
+ state.last_reported_tokens = state.streamed_tokens
1145
+ self.post_message(
1146
+ ToolStreamingProgressMessage(
1147
+ state.streamed_tokens,
1148
+ state.current_spinner_text,
1149
+ )
1150
+ )
1151
+
995
1152
  try:
996
1153
  updated_part = event.delta.apply(
997
1154
  cast(ModelResponsePart, partial_parts[index])
@@ -1087,6 +1244,17 @@ class AgentManager(Widget):
1087
1244
  if partial_message is not None:
1088
1245
  state.current_response = partial_message
1089
1246
  self._post_partial_message(False)
1247
+
1248
+ # Notify UI that a tool is about to execute
1249
+ # This updates the spinner with a fun message during tool execution
1250
+ # Pick a random spinner message and store it for progress updates
1251
+ import random
1252
+
1253
+ spinner_text = random.choice(SPINNER_MESSAGES) # noqa: S311
1254
+ state.current_spinner_text = spinner_text
1255
+ state.streamed_tokens = 0 # Reset token count for new tool
1256
+ self.post_message(ToolExecutionStartedMessage(spinner_text))
1257
+
1090
1258
  elif isinstance(event, FunctionToolResultEvent):
1091
1259
  # Track tool completion event
1092
1260
 
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
@@ -38,7 +43,6 @@ from .tools import (
38
43
  retrieve_code,
39
44
  write_file,
40
45
  )
41
- from .tools.file_management import AGENT_DIRECTORIES
42
46
 
43
47
  logger = get_logger(__name__)
44
48
 
@@ -74,6 +78,19 @@ async def add_system_status_message(
74
78
  # Get current datetime with timezone information
75
79
  dt_context = get_datetime_context()
76
80
 
81
+ # Get execution plan and pending approval state if this is the Router agent
82
+ execution_plan = None
83
+ pending_approval = False
84
+ if deps.agent_mode == AgentType.ROUTER:
85
+ # Import here to avoid circular imports
86
+ from shotgun.agents.router.models import RouterDeps
87
+
88
+ if isinstance(deps, RouterDeps):
89
+ if deps.current_plan is not None:
90
+ execution_plan = deps.current_plan.format_for_display()
91
+ # Check if plan is pending approval (multi-step plan in Planning mode)
92
+ pending_approval = deps.pending_approval is not None
93
+
77
94
  system_state = prompt_loader.render(
78
95
  "agents/state/system_state.j2",
79
96
  codebase_understanding_graphs=codebase_understanding_graphs,
@@ -83,6 +100,8 @@ async def add_system_status_message(
83
100
  current_datetime=dt_context.datetime_formatted,
84
101
  timezone_name=dt_context.timezone_name,
85
102
  utc_offset=dt_context.utc_offset,
103
+ execution_plan=execution_plan,
104
+ pending_approval=pending_approval,
86
105
  )
87
106
 
88
107
  message_history.append(
@@ -102,7 +121,7 @@ async def create_base_agent(
102
121
  additional_tools: list[Any] | None = None,
103
122
  provider: ProviderType | None = None,
104
123
  agent_mode: AgentType | None = None,
105
- ) -> tuple[Agent[AgentDeps, AgentResponse], AgentDeps]:
124
+ ) -> tuple[ShotgunAgent, AgentDeps]:
106
125
  """Create a base agent with common configuration.
107
126
 
108
127
  Args:
@@ -352,86 +371,33 @@ async def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
352
371
 
353
372
 
354
373
  def get_agent_existing_files(agent_mode: AgentType | None = None) -> list[str]:
355
- """Get list of existing files for the given agent mode.
374
+ """Get list of all existing files in .shotgun directory.
375
+
376
+ All agents can read any file in .shotgun/, so we list all files regardless
377
+ of agent mode. This includes user-added files that agents should be aware of.
356
378
 
357
379
  Args:
358
- agent_mode: The agent mode to check files for. If None, lists all files.
380
+ agent_mode: Unused, kept for backwards compatibility.
359
381
 
360
382
  Returns:
361
383
  List of existing file paths relative to .shotgun directory
362
384
  """
363
385
  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))
386
+ existing_files: list[str] = []
387
+
388
+ if not base_path.exists():
378
389
  return existing_files
379
390
 
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)
391
+ # List all files in .shotgun directory and subdirectories
392
+ for item in base_path.iterdir():
393
+ if item.is_file():
394
+ existing_files.append(item.name)
395
+ elif item.is_dir():
396
+ # List files in subdirectories (one level deep to avoid too much noise)
397
+ for subitem in item.iterdir():
398
+ if subitem.is_file():
399
+ relative_path = subitem.relative_to(base_path)
391
400
  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
401
 
436
402
  return existing_files
437
403
 
@@ -458,10 +424,24 @@ def build_agent_system_prompt(
458
424
  logger.debug("🔧 Building research agent system prompt...")
459
425
  logger.debug("Interactive mode: %s", ctx.deps.interactive_mode)
460
426
 
461
- result = prompt_loader.render(
462
- f"agents/{agent_type}.j2",
427
+ # Build template context using Pydantic model for type safety and testability
428
+ # Import here to avoid circular imports (same pattern as add_system_status_message)
429
+ from shotgun.agents.router.models import RouterDeps
430
+
431
+ router_mode = None
432
+ if isinstance(ctx.deps, RouterDeps):
433
+ router_mode = ctx.deps.router_mode.value
434
+
435
+ template_context = AgentSystemPromptContext(
463
436
  interactive_mode=ctx.deps.interactive_mode,
464
437
  mode=agent_type,
438
+ sub_agent_context=ctx.deps.sub_agent_context,
439
+ router_mode=router_mode,
440
+ )
441
+
442
+ result = prompt_loader.render(
443
+ f"agents/{agent_type}.j2",
444
+ **template_context.model_dump(),
465
445
  )
466
446
 
467
447
  if agent_type == "research":
@@ -525,13 +505,33 @@ async def add_system_prompt_message(
525
505
  return message_history
526
506
 
527
507
 
508
+ EventStreamHandler = Callable[
509
+ [RunContext[AgentDeps], AsyncIterable[Any]], Awaitable[None]
510
+ ]
511
+
512
+
528
513
  async def run_agent(
529
- agent: Agent[AgentDeps, AgentResponse],
514
+ agent: ShotgunAgent,
530
515
  prompt: str,
531
516
  deps: AgentDeps,
532
517
  message_history: list[ModelMessage] | None = None,
533
518
  usage_limits: UsageLimits | None = None,
519
+ event_stream_handler: EventStreamHandler | None = None,
534
520
  ) -> AgentRunResult[AgentResponse]:
521
+ """Run an agent with optional streaming support.
522
+
523
+ Args:
524
+ agent: The agent to run.
525
+ prompt: The prompt to send to the agent.
526
+ deps: Agent dependencies.
527
+ message_history: Optional message history to continue from.
528
+ usage_limits: Optional usage limits for the run.
529
+ event_stream_handler: Optional callback for streaming events.
530
+ When provided, enables real-time streaming of agent responses.
531
+
532
+ Returns:
533
+ The agent run result.
534
+ """
535
535
  # Clear file tracker for new run
536
536
  deps.file_tracker.clear()
537
537
  logger.debug("🔧 Cleared file tracker for new agent run")
@@ -544,6 +544,7 @@ async def run_agent(
544
544
  deps=deps,
545
545
  usage_limits=usage_limits,
546
546
  message_history=message_history,
547
+ event_stream_handler=event_stream_handler,
547
548
  )
548
549
 
549
550
  # Log file operations summary if any files were modified