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.
- shotgun/agents/agent_manager.py +191 -23
- shotgun/agents/common.py +78 -77
- shotgun/agents/config/manager.py +42 -1
- shotgun/agents/config/models.py +16 -0
- shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
- shotgun/agents/export.py +12 -13
- shotgun/agents/models.py +66 -1
- shotgun/agents/plan.py +12 -13
- shotgun/agents/research.py +13 -10
- shotgun/agents/router/__init__.py +47 -0
- shotgun/agents/router/models.py +376 -0
- shotgun/agents/router/router.py +185 -0
- shotgun/agents/router/tools/__init__.py +18 -0
- shotgun/agents/router/tools/delegation_tools.py +503 -0
- shotgun/agents/router/tools/plan_tools.py +322 -0
- shotgun/agents/specify.py +12 -13
- shotgun/agents/tasks.py +12 -13
- shotgun/agents/tools/file_management.py +49 -1
- shotgun/agents/tools/registry.py +2 -0
- shotgun/agents/tools/web_search/__init__.py +1 -2
- shotgun/agents/tools/web_search/gemini.py +1 -3
- shotgun/codebase/core/change_detector.py +1 -1
- shotgun/codebase/core/ingestor.py +1 -1
- shotgun/codebase/core/manager.py +1 -1
- shotgun/prompts/agents/export.j2 +2 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -10
- shotgun/prompts/agents/partials/router_delegation_mode.j2 +36 -0
- shotgun/prompts/agents/plan.j2 +24 -12
- shotgun/prompts/agents/research.j2 +70 -31
- shotgun/prompts/agents/router.j2 +440 -0
- shotgun/prompts/agents/specify.j2 +39 -16
- shotgun/prompts/agents/state/system_state.j2 +15 -6
- shotgun/prompts/agents/tasks.j2 +58 -34
- shotgun/tui/app.py +5 -6
- shotgun/tui/components/mode_indicator.py +120 -25
- shotgun/tui/components/status_bar.py +2 -2
- shotgun/tui/dependencies.py +64 -9
- shotgun/tui/protocols.py +37 -0
- shotgun/tui/screens/chat/chat.tcss +9 -1
- shotgun/tui/screens/chat/chat_screen.py +643 -11
- shotgun/tui/screens/chat_screen/command_providers.py +0 -87
- shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
- shotgun/tui/screens/chat_screen/history/chat_history.py +12 -0
- shotgun/tui/screens/chat_screen/history/formatters.py +53 -15
- shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
- shotgun/tui/screens/chat_screen/messages.py +219 -0
- shotgun/tui/screens/onboarding.py +30 -26
- shotgun/tui/utils/mode_progress.py +20 -86
- shotgun/tui/widgets/__init__.py +2 -1
- shotgun/tui/widgets/approval_widget.py +152 -0
- shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
- shotgun/tui/widgets/plan_panel.py +129 -0
- shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/METADATA +3 -3
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/RECORD +58 -45
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.3.3.dev1.dist-info → shotgun_sh-0.4.0.dev1.dist-info}/licenses/LICENSE +0 -0
shotgun/agents/agent_manager.py
CHANGED
|
@@ -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:
|
|
319
|
+
self._research_agent: ShotgunAgent | None = None
|
|
249
320
|
self._research_deps: AgentDeps | None = None
|
|
250
|
-
self._plan_agent:
|
|
321
|
+
self._plan_agent: ShotgunAgent | None = None
|
|
251
322
|
self._plan_deps: AgentDeps | None = None
|
|
252
|
-
self._tasks_agent:
|
|
323
|
+
self._tasks_agent: ShotgunAgent | None = None
|
|
253
324
|
self._tasks_deps: AgentDeps | None = None
|
|
254
|
-
self._specify_agent:
|
|
325
|
+
self._specify_agent: ShotgunAgent | None = None
|
|
255
326
|
self._specify_deps: AgentDeps | None = None
|
|
256
|
-
self._export_agent:
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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
|
|
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) ->
|
|
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
|
-
#
|
|
449
|
-
#
|
|
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:
|
|
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
|
-
|
|
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
|
|
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[
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
#
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
462
|
-
|
|
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:
|
|
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
|