shotgun-sh 0.2.29.dev2__py3-none-any.whl → 0.6.1.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.
Potentially problematic release.
This version of shotgun-sh might be problematic. Click here for more details.
- shotgun/agents/agent_manager.py +497 -30
- shotgun/agents/cancellation.py +103 -0
- shotgun/agents/common.py +90 -77
- shotgun/agents/config/README.md +0 -1
- shotgun/agents/config/manager.py +52 -8
- shotgun/agents/config/models.py +48 -45
- shotgun/agents/config/provider.py +44 -29
- shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
- shotgun/agents/conversation/history/token_counting/base.py +51 -9
- shotgun/agents/export.py +12 -13
- shotgun/agents/file_read.py +176 -0
- shotgun/agents/messages.py +15 -3
- shotgun/agents/models.py +90 -2
- shotgun/agents/plan.py +12 -13
- shotgun/agents/research.py +13 -10
- shotgun/agents/router/__init__.py +47 -0
- shotgun/agents/router/models.py +384 -0
- shotgun/agents/router/router.py +185 -0
- shotgun/agents/router/tools/__init__.py +18 -0
- shotgun/agents/router/tools/delegation_tools.py +557 -0
- shotgun/agents/router/tools/plan_tools.py +403 -0
- shotgun/agents/runner.py +17 -2
- shotgun/agents/specify.py +12 -13
- shotgun/agents/tasks.py +12 -13
- shotgun/agents/tools/__init__.py +8 -0
- shotgun/agents/tools/codebase/directory_lister.py +27 -39
- shotgun/agents/tools/codebase/file_read.py +26 -35
- shotgun/agents/tools/codebase/query_graph.py +9 -0
- shotgun/agents/tools/codebase/retrieve_code.py +9 -0
- shotgun/agents/tools/file_management.py +81 -3
- shotgun/agents/tools/file_read_tools/__init__.py +7 -0
- shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
- shotgun/agents/tools/markdown_tools/__init__.py +62 -0
- shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
- shotgun/agents/tools/markdown_tools/models.py +86 -0
- shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
- shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
- shotgun/agents/tools/markdown_tools/utils.py +453 -0
- shotgun/agents/tools/registry.py +41 -0
- shotgun/agents/tools/web_search/__init__.py +1 -2
- shotgun/agents/tools/web_search/gemini.py +1 -3
- shotgun/agents/tools/web_search/openai.py +42 -23
- shotgun/attachments/__init__.py +41 -0
- shotgun/attachments/errors.py +60 -0
- shotgun/attachments/models.py +107 -0
- shotgun/attachments/parser.py +257 -0
- shotgun/attachments/processor.py +193 -0
- shotgun/cli/clear.py +2 -2
- shotgun/cli/codebase/commands.py +181 -65
- shotgun/cli/compact.py +2 -2
- shotgun/cli/context.py +2 -2
- shotgun/cli/run.py +90 -0
- shotgun/cli/spec/backup.py +2 -1
- shotgun/cli/spec/commands.py +2 -0
- shotgun/cli/spec/models.py +18 -0
- shotgun/cli/spec/pull_service.py +122 -68
- shotgun/codebase/__init__.py +2 -0
- shotgun/codebase/benchmarks/__init__.py +35 -0
- shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
- shotgun/codebase/benchmarks/exporters.py +119 -0
- shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
- shotgun/codebase/benchmarks/formatters/base.py +34 -0
- shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
- shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
- shotgun/codebase/benchmarks/models.py +129 -0
- shotgun/codebase/core/__init__.py +4 -0
- shotgun/codebase/core/call_resolution.py +91 -0
- shotgun/codebase/core/change_detector.py +11 -6
- shotgun/codebase/core/errors.py +159 -0
- shotgun/codebase/core/extractors/__init__.py +23 -0
- shotgun/codebase/core/extractors/base.py +138 -0
- shotgun/codebase/core/extractors/factory.py +63 -0
- shotgun/codebase/core/extractors/go/__init__.py +7 -0
- shotgun/codebase/core/extractors/go/extractor.py +122 -0
- shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
- shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
- shotgun/codebase/core/extractors/protocol.py +109 -0
- shotgun/codebase/core/extractors/python/__init__.py +7 -0
- shotgun/codebase/core/extractors/python/extractor.py +141 -0
- shotgun/codebase/core/extractors/rust/__init__.py +7 -0
- shotgun/codebase/core/extractors/rust/extractor.py +139 -0
- shotgun/codebase/core/extractors/types.py +15 -0
- shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
- shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
- shotgun/codebase/core/gitignore.py +252 -0
- shotgun/codebase/core/ingestor.py +644 -354
- shotgun/codebase/core/kuzu_compat.py +119 -0
- shotgun/codebase/core/language_config.py +239 -0
- shotgun/codebase/core/manager.py +256 -46
- shotgun/codebase/core/metrics_collector.py +310 -0
- shotgun/codebase/core/metrics_types.py +347 -0
- shotgun/codebase/core/parallel_executor.py +424 -0
- shotgun/codebase/core/work_distributor.py +254 -0
- shotgun/codebase/core/worker.py +768 -0
- shotgun/codebase/indexing_state.py +86 -0
- shotgun/codebase/models.py +94 -0
- shotgun/codebase/service.py +13 -0
- shotgun/exceptions.py +1 -1
- shotgun/main.py +2 -10
- shotgun/prompts/agents/export.j2 +2 -0
- shotgun/prompts/agents/file_read.j2 +48 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +20 -28
- shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
- shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
- shotgun/prompts/agents/partials/router_delegation_mode.j2 +35 -0
- shotgun/prompts/agents/plan.j2 +43 -1
- shotgun/prompts/agents/research.j2 +75 -20
- shotgun/prompts/agents/router.j2 +713 -0
- shotgun/prompts/agents/specify.j2 +94 -4
- shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
- shotgun/prompts/agents/state/system_state.j2 +24 -15
- shotgun/prompts/agents/tasks.j2 +77 -23
- shotgun/settings.py +44 -0
- shotgun/shotgun_web/shared_specs/upload_pipeline.py +38 -0
- shotgun/tui/app.py +90 -23
- shotgun/tui/commands/__init__.py +9 -1
- shotgun/tui/components/attachment_bar.py +87 -0
- shotgun/tui/components/mode_indicator.py +120 -25
- shotgun/tui/components/prompt_input.py +23 -28
- shotgun/tui/components/status_bar.py +5 -4
- shotgun/tui/dependencies.py +58 -8
- shotgun/tui/protocols.py +37 -0
- shotgun/tui/screens/chat/chat.tcss +24 -1
- shotgun/tui/screens/chat/chat_screen.py +1374 -211
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
- shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
- shotgun/tui/screens/chat_screen/command_providers.py +0 -97
- shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
- shotgun/tui/screens/chat_screen/history/chat_history.py +49 -6
- shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
- shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
- shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
- shotgun/tui/screens/chat_screen/messages.py +219 -0
- shotgun/tui/screens/database_locked_dialog.py +219 -0
- shotgun/tui/screens/database_timeout_dialog.py +158 -0
- shotgun/tui/screens/kuzu_error_dialog.py +135 -0
- shotgun/tui/screens/model_picker.py +14 -9
- shotgun/tui/screens/models.py +11 -0
- shotgun/tui/screens/shotgun_auth.py +50 -0
- shotgun/tui/screens/spec_pull.py +2 -0
- shotgun/tui/state/processing_state.py +19 -0
- 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/tui/widgets/widget_coordinator.py +18 -0
- shotgun/utils/file_system_utils.py +4 -1
- {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/METADATA +88 -34
- shotgun_sh-0.6.1.dev1.dist-info/RECORD +292 -0
- shotgun/cli/export.py +0 -81
- shotgun/cli/plan.py +0 -73
- shotgun/cli/research.py +0 -93
- shotgun/cli/specify.py +0 -70
- shotgun/cli/tasks.py +0 -78
- shotgun/tui/screens/onboarding.py +0 -580
- shotgun_sh-0.2.29.dev2.dist-info/RECORD +0 -229
- {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/licenses/LICENSE +0 -0
shotgun/agents/agent_manager.py
CHANGED
|
@@ -19,8 +19,10 @@ from tenacity import (
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
20
|
from shotgun.agents.conversation import ConversationState
|
|
21
21
|
|
|
22
|
+
import base64
|
|
23
|
+
|
|
22
24
|
from pydantic_ai import (
|
|
23
|
-
|
|
25
|
+
BinaryContent,
|
|
24
26
|
RunContext,
|
|
25
27
|
UsageLimits,
|
|
26
28
|
)
|
|
@@ -38,13 +40,16 @@ from pydantic_ai.messages import (
|
|
|
38
40
|
PartDeltaEvent,
|
|
39
41
|
PartStartEvent,
|
|
40
42
|
SystemPromptPart,
|
|
43
|
+
TextPartDelta,
|
|
41
44
|
ToolCallPart,
|
|
42
45
|
ToolCallPartDelta,
|
|
46
|
+
UserContent,
|
|
43
47
|
UserPromptPart,
|
|
44
48
|
)
|
|
45
49
|
from textual.message import Message
|
|
46
50
|
from textual.widget import Widget
|
|
47
51
|
|
|
52
|
+
from shotgun.agents.cancellation import CancellableStreamIterator
|
|
48
53
|
from shotgun.agents.common import add_system_prompt_message, add_system_status_message
|
|
49
54
|
from shotgun.agents.config.models import (
|
|
50
55
|
KeyProvider,
|
|
@@ -61,19 +66,25 @@ from shotgun.agents.context_analyzer import (
|
|
|
61
66
|
from shotgun.agents.models import (
|
|
62
67
|
AgentResponse,
|
|
63
68
|
AgentType,
|
|
69
|
+
AnyAgent,
|
|
64
70
|
FileOperation,
|
|
65
71
|
FileOperationTracker,
|
|
72
|
+
RouterAgent,
|
|
73
|
+
ShotgunAgent,
|
|
66
74
|
)
|
|
75
|
+
from shotgun.attachments import FileAttachment
|
|
67
76
|
from shotgun.posthog_telemetry import track_event
|
|
68
77
|
from shotgun.tui.screens.chat_screen.hint_message import HintMessage
|
|
69
78
|
from shotgun.utils.source_detection import detect_source
|
|
70
79
|
|
|
71
80
|
from .conversation.history.compaction import apply_persistent_compaction
|
|
72
81
|
from .export import create_export_agent
|
|
73
|
-
from .messages import AgentSystemPrompt
|
|
82
|
+
from .messages import AgentSystemPrompt, InternalPromptPart
|
|
74
83
|
from .models import AgentDeps, AgentRuntimeOptions
|
|
75
84
|
from .plan import create_plan_agent
|
|
76
85
|
from .research import create_research_agent
|
|
86
|
+
from .router import create_router_agent
|
|
87
|
+
from .router.models import RouterDeps, RouterMode
|
|
77
88
|
from .specify import create_specify_agent
|
|
78
89
|
from .tasks import create_tasks_agent
|
|
79
90
|
|
|
@@ -166,6 +177,29 @@ class ClarifyingQuestionsMessage(Message):
|
|
|
166
177
|
self.response_text = response_text
|
|
167
178
|
|
|
168
179
|
|
|
180
|
+
class FileRequestPendingMessage(Message):
|
|
181
|
+
"""Event posted when agent requests files to be loaded.
|
|
182
|
+
|
|
183
|
+
This triggers the TUI to load the requested files and resume
|
|
184
|
+
the agent with the file contents in the next prompt.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __init__(
|
|
188
|
+
self,
|
|
189
|
+
file_paths: list[str],
|
|
190
|
+
response_text: str,
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Initialize the file request pending message.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
file_paths: List of file paths the agent wants to read
|
|
196
|
+
response_text: The agent's response text before requesting files
|
|
197
|
+
"""
|
|
198
|
+
super().__init__()
|
|
199
|
+
self.file_paths = file_paths
|
|
200
|
+
self.response_text = response_text
|
|
201
|
+
|
|
202
|
+
|
|
169
203
|
class CompactionStartedMessage(Message):
|
|
170
204
|
"""Event posted when conversation compaction starts."""
|
|
171
205
|
|
|
@@ -174,6 +208,67 @@ class CompactionCompletedMessage(Message):
|
|
|
174
208
|
"""Event posted when conversation compaction completes."""
|
|
175
209
|
|
|
176
210
|
|
|
211
|
+
class ToolExecutionStartedMessage(Message):
|
|
212
|
+
"""Event posted when a tool starts executing.
|
|
213
|
+
|
|
214
|
+
This allows the UI to update the spinner text to provide feedback
|
|
215
|
+
during long-running tool executions.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def __init__(self, spinner_text: str = "Processing...") -> None:
|
|
219
|
+
"""Initialize the tool execution started message.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
spinner_text: The spinner message to display
|
|
223
|
+
"""
|
|
224
|
+
super().__init__()
|
|
225
|
+
self.spinner_text = spinner_text
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class ToolStreamingProgressMessage(Message):
|
|
229
|
+
"""Event posted during tool call streaming to show progress.
|
|
230
|
+
|
|
231
|
+
This provides visual feedback while tool arguments are streaming,
|
|
232
|
+
especially useful for long-running writes like file content.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
def __init__(self, streamed_tokens: int, spinner_text: str) -> None:
|
|
236
|
+
"""Initialize the tool streaming progress message.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
streamed_tokens: Approximate number of tokens streamed so far
|
|
240
|
+
spinner_text: The current spinner message to preserve
|
|
241
|
+
"""
|
|
242
|
+
super().__init__()
|
|
243
|
+
self.streamed_tokens = streamed_tokens
|
|
244
|
+
self.spinner_text = spinner_text
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# Fun spinner messages to show during tool execution
|
|
248
|
+
SPINNER_MESSAGES = [
|
|
249
|
+
"Pontificating...",
|
|
250
|
+
"Ruminating...",
|
|
251
|
+
"Cogitating...",
|
|
252
|
+
"Deliberating...",
|
|
253
|
+
"Contemplating...",
|
|
254
|
+
"Reticulating splines...",
|
|
255
|
+
"Consulting the oracle...",
|
|
256
|
+
"Gathering thoughts...",
|
|
257
|
+
"Processing neurons...",
|
|
258
|
+
"Summoning wisdom...",
|
|
259
|
+
"Brewing ideas...",
|
|
260
|
+
"Polishing pixels...",
|
|
261
|
+
"Herding electrons...",
|
|
262
|
+
"Warming up the flux capacitor...",
|
|
263
|
+
"Consulting ancient tomes...",
|
|
264
|
+
"Channeling the muses...",
|
|
265
|
+
"Percolating possibilities...",
|
|
266
|
+
"Untangling complexity...",
|
|
267
|
+
"Shuffling priorities...",
|
|
268
|
+
"Aligning the stars...",
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
|
|
177
272
|
class AgentStreamingStarted(Message):
|
|
178
273
|
"""Event posted when agent starts streaming responses."""
|
|
179
274
|
|
|
@@ -210,6 +305,11 @@ class _PartialStreamState:
|
|
|
210
305
|
|
|
211
306
|
messages: list[ModelRequest | ModelResponse] = field(default_factory=list)
|
|
212
307
|
current_response: ModelResponse | None = None
|
|
308
|
+
# Token counting for tool call streaming progress
|
|
309
|
+
streamed_tokens: int = 0
|
|
310
|
+
current_spinner_text: str = "Processing..."
|
|
311
|
+
# Track last reported tokens to throttle UI updates
|
|
312
|
+
last_reported_tokens: int = 0
|
|
213
313
|
|
|
214
314
|
|
|
215
315
|
class AgentManager(Widget):
|
|
@@ -245,16 +345,18 @@ class AgentManager(Widget):
|
|
|
245
345
|
)
|
|
246
346
|
|
|
247
347
|
# Lazy initialization - agents created on first access
|
|
248
|
-
self._research_agent:
|
|
348
|
+
self._research_agent: ShotgunAgent | None = None
|
|
249
349
|
self._research_deps: AgentDeps | None = None
|
|
250
|
-
self._plan_agent:
|
|
350
|
+
self._plan_agent: ShotgunAgent | None = None
|
|
251
351
|
self._plan_deps: AgentDeps | None = None
|
|
252
|
-
self._tasks_agent:
|
|
352
|
+
self._tasks_agent: ShotgunAgent | None = None
|
|
253
353
|
self._tasks_deps: AgentDeps | None = None
|
|
254
|
-
self._specify_agent:
|
|
354
|
+
self._specify_agent: ShotgunAgent | None = None
|
|
255
355
|
self._specify_deps: AgentDeps | None = None
|
|
256
|
-
self._export_agent:
|
|
356
|
+
self._export_agent: ShotgunAgent | None = None
|
|
257
357
|
self._export_deps: AgentDeps | None = None
|
|
358
|
+
self._router_agent: RouterAgent | None = None
|
|
359
|
+
self._router_deps: RouterDeps | None = None
|
|
258
360
|
self._agents_initialized = False
|
|
259
361
|
|
|
260
362
|
# Track current active agent
|
|
@@ -270,6 +372,10 @@ class AgentManager(Widget):
|
|
|
270
372
|
self._qa_questions: list[str] | None = None
|
|
271
373
|
self._qa_mode_active: bool = False
|
|
272
374
|
|
|
375
|
+
# File request state for structured output file loading
|
|
376
|
+
self._file_request_pending: bool = False
|
|
377
|
+
self._pending_file_requests: list[str] = []
|
|
378
|
+
|
|
273
379
|
async def _ensure_agents_initialized(self) -> None:
|
|
274
380
|
"""Ensure all agents are initialized (lazy initialization)."""
|
|
275
381
|
if self._agents_initialized:
|
|
@@ -291,10 +397,13 @@ class AgentManager(Widget):
|
|
|
291
397
|
self._export_agent, self._export_deps = await create_export_agent(
|
|
292
398
|
agent_runtime_options=self._agent_runtime_options
|
|
293
399
|
)
|
|
400
|
+
self._router_agent, self._router_deps = await create_router_agent(
|
|
401
|
+
agent_runtime_options=self._agent_runtime_options
|
|
402
|
+
)
|
|
294
403
|
self._agents_initialized = True
|
|
295
404
|
|
|
296
405
|
@property
|
|
297
|
-
def research_agent(self) ->
|
|
406
|
+
def research_agent(self) -> ShotgunAgent:
|
|
298
407
|
"""Get research agent (must call _ensure_agents_initialized first)."""
|
|
299
408
|
if self._research_agent is None:
|
|
300
409
|
raise RuntimeError(
|
|
@@ -312,7 +421,7 @@ class AgentManager(Widget):
|
|
|
312
421
|
return self._research_deps
|
|
313
422
|
|
|
314
423
|
@property
|
|
315
|
-
def plan_agent(self) ->
|
|
424
|
+
def plan_agent(self) -> ShotgunAgent:
|
|
316
425
|
"""Get plan agent (must call _ensure_agents_initialized first)."""
|
|
317
426
|
if self._plan_agent is None:
|
|
318
427
|
raise RuntimeError(
|
|
@@ -330,7 +439,7 @@ class AgentManager(Widget):
|
|
|
330
439
|
return self._plan_deps
|
|
331
440
|
|
|
332
441
|
@property
|
|
333
|
-
def tasks_agent(self) ->
|
|
442
|
+
def tasks_agent(self) -> ShotgunAgent:
|
|
334
443
|
"""Get tasks agent (must call _ensure_agents_initialized first)."""
|
|
335
444
|
if self._tasks_agent is None:
|
|
336
445
|
raise RuntimeError(
|
|
@@ -348,7 +457,7 @@ class AgentManager(Widget):
|
|
|
348
457
|
return self._tasks_deps
|
|
349
458
|
|
|
350
459
|
@property
|
|
351
|
-
def specify_agent(self) ->
|
|
460
|
+
def specify_agent(self) -> ShotgunAgent:
|
|
352
461
|
"""Get specify agent (must call _ensure_agents_initialized first)."""
|
|
353
462
|
if self._specify_agent is None:
|
|
354
463
|
raise RuntimeError(
|
|
@@ -366,7 +475,7 @@ class AgentManager(Widget):
|
|
|
366
475
|
return self._specify_deps
|
|
367
476
|
|
|
368
477
|
@property
|
|
369
|
-
def export_agent(self) ->
|
|
478
|
+
def export_agent(self) -> ShotgunAgent:
|
|
370
479
|
"""Get export agent (must call _ensure_agents_initialized first)."""
|
|
371
480
|
if self._export_agent is None:
|
|
372
481
|
raise RuntimeError(
|
|
@@ -384,29 +493,114 @@ class AgentManager(Widget):
|
|
|
384
493
|
return self._export_deps
|
|
385
494
|
|
|
386
495
|
@property
|
|
387
|
-
def
|
|
496
|
+
def router_agent(self) -> RouterAgent:
|
|
497
|
+
"""Get router agent (must call _ensure_agents_initialized first)."""
|
|
498
|
+
if self._router_agent is None:
|
|
499
|
+
raise RuntimeError(
|
|
500
|
+
"Agents not initialized. Call _ensure_agents_initialized() first."
|
|
501
|
+
)
|
|
502
|
+
return self._router_agent
|
|
503
|
+
|
|
504
|
+
@property
|
|
505
|
+
def router_deps(self) -> RouterDeps:
|
|
506
|
+
"""Get router deps (must call _ensure_agents_initialized first)."""
|
|
507
|
+
if self._router_deps is None:
|
|
508
|
+
raise RuntimeError(
|
|
509
|
+
"Agents not initialized. Call _ensure_agents_initialized() first."
|
|
510
|
+
)
|
|
511
|
+
return self._router_deps
|
|
512
|
+
|
|
513
|
+
@property
|
|
514
|
+
def current_agent(self) -> AnyAgent:
|
|
388
515
|
"""Get the currently active agent.
|
|
389
516
|
|
|
390
517
|
Returns:
|
|
391
|
-
The currently selected agent instance.
|
|
518
|
+
The currently selected agent instance (ShotgunAgent or RouterAgent).
|
|
392
519
|
"""
|
|
393
520
|
return self._get_agent(self._current_agent_type)
|
|
394
521
|
|
|
395
|
-
|
|
522
|
+
@property
|
|
523
|
+
def file_request_pending(self) -> bool:
|
|
524
|
+
"""Check if there's a pending file request."""
|
|
525
|
+
return self._file_request_pending
|
|
526
|
+
|
|
527
|
+
@property
|
|
528
|
+
def pending_file_requests(self) -> list[str]:
|
|
529
|
+
"""Get the list of pending file requests."""
|
|
530
|
+
return self._pending_file_requests
|
|
531
|
+
|
|
532
|
+
def process_file_requests(self) -> list[tuple[str, BinaryContent]]:
|
|
533
|
+
"""Process pending file requests and return loaded content.
|
|
534
|
+
|
|
535
|
+
This method is called by the TUI after FileRequestPendingMessage is received.
|
|
536
|
+
It loads the requested files as BinaryContent and clears the pending state.
|
|
537
|
+
|
|
538
|
+
Returns:
|
|
539
|
+
List of (file_path, BinaryContent) tuples for files that were successfully loaded.
|
|
540
|
+
"""
|
|
541
|
+
if not self._file_request_pending:
|
|
542
|
+
return []
|
|
543
|
+
|
|
544
|
+
# MIME type mapping for supported file types
|
|
545
|
+
mime_types: dict[str, str] = {
|
|
546
|
+
".pdf": "application/pdf",
|
|
547
|
+
".png": "image/png",
|
|
548
|
+
".jpg": "image/jpeg",
|
|
549
|
+
".jpeg": "image/jpeg",
|
|
550
|
+
".gif": "image/gif",
|
|
551
|
+
".webp": "image/webp",
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
loaded_files: list[tuple[str, BinaryContent]] = []
|
|
555
|
+
for file_path_str in self._pending_file_requests:
|
|
556
|
+
try:
|
|
557
|
+
path = Path(file_path_str).expanduser().resolve()
|
|
558
|
+
if not path.exists():
|
|
559
|
+
logger.warning(f"Requested file not found: {path}")
|
|
560
|
+
continue
|
|
561
|
+
|
|
562
|
+
# Get MIME type
|
|
563
|
+
suffix = path.suffix.lower()
|
|
564
|
+
mime_type = mime_types.get(suffix)
|
|
565
|
+
if mime_type is None:
|
|
566
|
+
logger.warning(f"Unsupported file type: {suffix} for {path}")
|
|
567
|
+
continue
|
|
568
|
+
|
|
569
|
+
# Read file and create BinaryContent
|
|
570
|
+
data = path.read_bytes()
|
|
571
|
+
loaded_files.append(
|
|
572
|
+
(str(path), BinaryContent(data=data, media_type=mime_type))
|
|
573
|
+
)
|
|
574
|
+
logger.debug(f"Loaded file: {path} ({len(data)} bytes)")
|
|
575
|
+
|
|
576
|
+
except Exception as e:
|
|
577
|
+
logger.error(f"Error loading file {file_path_str}: {e}")
|
|
578
|
+
|
|
579
|
+
# Clear pending state
|
|
580
|
+
self._file_request_pending = False
|
|
581
|
+
self._pending_file_requests = []
|
|
582
|
+
|
|
583
|
+
logger.info(
|
|
584
|
+
f"Loaded {len(loaded_files)} of {len(self._pending_file_requests)} requested files"
|
|
585
|
+
)
|
|
586
|
+
return loaded_files
|
|
587
|
+
|
|
588
|
+
def _get_agent(self, agent_type: AgentType) -> AnyAgent:
|
|
396
589
|
"""Get agent by type.
|
|
397
590
|
|
|
398
591
|
Args:
|
|
399
592
|
agent_type: The type of agent to retrieve.
|
|
400
593
|
|
|
401
594
|
Returns:
|
|
402
|
-
The requested agent instance.
|
|
595
|
+
The requested agent instance (ShotgunAgent or RouterAgent).
|
|
403
596
|
"""
|
|
404
|
-
agent_map = {
|
|
597
|
+
agent_map: dict[AgentType, AnyAgent] = {
|
|
405
598
|
AgentType.RESEARCH: self.research_agent,
|
|
406
599
|
AgentType.PLAN: self.plan_agent,
|
|
407
600
|
AgentType.TASKS: self.tasks_agent,
|
|
408
601
|
AgentType.SPECIFY: self.specify_agent,
|
|
409
602
|
AgentType.EXPORT: self.export_agent,
|
|
603
|
+
AgentType.ROUTER: self.router_agent,
|
|
410
604
|
}
|
|
411
605
|
return agent_map[agent_type]
|
|
412
606
|
|
|
@@ -419,12 +613,13 @@ class AgentManager(Widget):
|
|
|
419
613
|
Returns:
|
|
420
614
|
The agent-specific dependencies.
|
|
421
615
|
"""
|
|
422
|
-
deps_map = {
|
|
616
|
+
deps_map: dict[AgentType, AgentDeps] = {
|
|
423
617
|
AgentType.RESEARCH: self.research_deps,
|
|
424
618
|
AgentType.PLAN: self.plan_deps,
|
|
425
619
|
AgentType.TASKS: self.tasks_deps,
|
|
426
620
|
AgentType.SPECIFY: self.specify_deps,
|
|
427
621
|
AgentType.EXPORT: self.export_deps,
|
|
622
|
+
AgentType.ROUTER: self.router_deps,
|
|
428
623
|
}
|
|
429
624
|
return deps_map[agent_type]
|
|
430
625
|
|
|
@@ -433,6 +628,10 @@ class AgentManager(Widget):
|
|
|
433
628
|
|
|
434
629
|
This preserves the agent's system_prompt_fn while using shared runtime state.
|
|
435
630
|
|
|
631
|
+
For Router agent, returns the shared deps directly (not a copy) because
|
|
632
|
+
Router state (pending_approval, current_plan, etc.) must be shared with
|
|
633
|
+
the TUI for features like plan approval widgets.
|
|
634
|
+
|
|
436
635
|
Args:
|
|
437
636
|
agent_type: The type of agent to create merged deps for.
|
|
438
637
|
|
|
@@ -445,8 +644,14 @@ class AgentManager(Widget):
|
|
|
445
644
|
if self.deps is None:
|
|
446
645
|
raise ValueError("Shared deps is None - this should not happen")
|
|
447
646
|
|
|
448
|
-
#
|
|
449
|
-
#
|
|
647
|
+
# For Router, use shared deps directly so state mutations are visible to TUI
|
|
648
|
+
# (e.g., pending_approval, current_plan need to be seen by ChatScreen)
|
|
649
|
+
if agent_type == AgentType.ROUTER:
|
|
650
|
+
# Update system_prompt_fn on shared deps in place
|
|
651
|
+
self.deps.system_prompt_fn = agent_deps.system_prompt_fn
|
|
652
|
+
return self.deps
|
|
653
|
+
|
|
654
|
+
# For other agents, create a copy with agent-specific system_prompt_fn
|
|
450
655
|
merged_deps = self.deps.model_copy(
|
|
451
656
|
update={"system_prompt_fn": agent_deps.system_prompt_fn}
|
|
452
657
|
)
|
|
@@ -478,8 +683,8 @@ class AgentManager(Widget):
|
|
|
478
683
|
)
|
|
479
684
|
async def _run_agent_with_retry(
|
|
480
685
|
self,
|
|
481
|
-
agent:
|
|
482
|
-
prompt: str | None,
|
|
686
|
+
agent: AnyAgent,
|
|
687
|
+
prompt: str | Sequence[UserContent] | None,
|
|
483
688
|
deps: AgentDeps,
|
|
484
689
|
usage_limits: UsageLimits | None,
|
|
485
690
|
message_history: list[ModelMessage],
|
|
@@ -489,9 +694,10 @@ class AgentManager(Widget):
|
|
|
489
694
|
"""Run agent with automatic retry on transient errors.
|
|
490
695
|
|
|
491
696
|
Args:
|
|
492
|
-
agent: The agent to run.
|
|
493
|
-
prompt: Optional prompt to send to the agent.
|
|
494
|
-
|
|
697
|
+
agent: The agent to run (ShotgunAgent or RouterAgent).
|
|
698
|
+
prompt: Optional prompt to send to the agent. Can be a string,
|
|
699
|
+
a sequence of UserContent (for multimodal), or None.
|
|
700
|
+
deps: Agent dependencies (AgentDeps or RouterDeps).
|
|
495
701
|
usage_limits: Optional usage limits.
|
|
496
702
|
message_history: Message history to provide to agent.
|
|
497
703
|
event_stream_handler: Event handler for streaming.
|
|
@@ -502,8 +708,16 @@ class AgentManager(Widget):
|
|
|
502
708
|
|
|
503
709
|
Raises:
|
|
504
710
|
Various exceptions if all retries fail.
|
|
711
|
+
|
|
712
|
+
Note:
|
|
713
|
+
Type safety for agent/deps pairing is maintained by AgentManager's
|
|
714
|
+
_get_agent_deps which ensures the correct deps type is used for each
|
|
715
|
+
agent type. The cast is needed because Agent is contravariant in deps.
|
|
505
716
|
"""
|
|
506
|
-
|
|
717
|
+
# Cast needed because Agent is contravariant in deps type parameter.
|
|
718
|
+
# The agent/deps pairing is ensured by _get_agent_deps returning the
|
|
719
|
+
# correct deps type for each agent type.
|
|
720
|
+
return await cast(ShotgunAgent, agent).run(
|
|
507
721
|
prompt,
|
|
508
722
|
deps=deps,
|
|
509
723
|
usage_limits=usage_limits,
|
|
@@ -516,6 +730,8 @@ class AgentManager(Widget):
|
|
|
516
730
|
self,
|
|
517
731
|
prompt: str | None = None,
|
|
518
732
|
*,
|
|
733
|
+
attachment: FileAttachment | None = None,
|
|
734
|
+
file_contents: list[tuple[str, BinaryContent]] | None = None,
|
|
519
735
|
deps: AgentDeps | None = None,
|
|
520
736
|
usage_limits: UsageLimits | None = None,
|
|
521
737
|
**kwargs: Any,
|
|
@@ -527,6 +743,9 @@ class AgentManager(Widget):
|
|
|
527
743
|
|
|
528
744
|
Args:
|
|
529
745
|
prompt: Optional prompt to send to the agent.
|
|
746
|
+
attachment: Optional file attachment to include as multimodal content.
|
|
747
|
+
file_contents: Optional list of (file_path, BinaryContent) tuples to include
|
|
748
|
+
as multimodal content. Used when resuming after file_requests.
|
|
530
749
|
deps: Optional dependencies override (defaults to manager's deps).
|
|
531
750
|
usage_limits: Optional usage limits for the agent run.
|
|
532
751
|
**kwargs: Additional keyword arguments to pass to the agent.
|
|
@@ -560,6 +779,11 @@ class AgentManager(Widget):
|
|
|
560
779
|
|
|
561
780
|
deps.agent_mode = self._current_agent_type
|
|
562
781
|
|
|
782
|
+
# For router agent, set up the parent stream handler so sub-agents can stream
|
|
783
|
+
if self._current_agent_type == AgentType.ROUTER:
|
|
784
|
+
if isinstance(deps, RouterDeps):
|
|
785
|
+
deps.parent_stream_handler = self._handle_event_stream # type: ignore[assignment]
|
|
786
|
+
|
|
563
787
|
# Filter out system prompts from other agent types
|
|
564
788
|
from pydantic_ai.messages import ModelRequestPart
|
|
565
789
|
|
|
@@ -634,7 +858,7 @@ class AgentManager(Widget):
|
|
|
634
858
|
"**Options:**\n"
|
|
635
859
|
"- Get a [Shotgun Account](https://shotgun.sh) - streaming works out of the box\n"
|
|
636
860
|
"- Complete [Biometric Verification](https://platform.openai.com/settings/organization/general) with OpenAI, then:\n"
|
|
637
|
-
" 1. Press
|
|
861
|
+
" 1. Press `/` → Open Provider Setup\n"
|
|
638
862
|
" 2. Select OpenAI → Clear key\n"
|
|
639
863
|
" 3. Re-add your OpenAI API key\n\n"
|
|
640
864
|
"Continuing without streaming (responses will appear all at once)."
|
|
@@ -650,13 +874,46 @@ class AgentManager(Widget):
|
|
|
650
874
|
{
|
|
651
875
|
"has_prompt": prompt is not None,
|
|
652
876
|
"model_name": model_name,
|
|
877
|
+
"has_attachment": attachment is not None,
|
|
653
878
|
},
|
|
654
879
|
)
|
|
655
880
|
|
|
881
|
+
# Construct multimodal prompt if attachment or file_contents is provided
|
|
882
|
+
user_prompt: str | Sequence[UserContent] | None = prompt
|
|
883
|
+
|
|
884
|
+
if file_contents:
|
|
885
|
+
# File contents from file_requests - construct multimodal prompt with files
|
|
886
|
+
content_parts: list[UserContent] = [
|
|
887
|
+
prompt or "Here are the files you requested:"
|
|
888
|
+
]
|
|
889
|
+
for file_path, binary in file_contents:
|
|
890
|
+
content_parts.append(f"\n\n--- File: {file_path} ---")
|
|
891
|
+
content_parts.append(binary)
|
|
892
|
+
user_prompt = content_parts
|
|
893
|
+
logger.debug(
|
|
894
|
+
"Constructed multimodal prompt with requested files",
|
|
895
|
+
extra={"num_files": len(file_contents)},
|
|
896
|
+
)
|
|
897
|
+
elif attachment and attachment.content_base64:
|
|
898
|
+
# Use BinaryContent which is supported by all providers (OpenAI, Anthropic, Google)
|
|
899
|
+
binary_data = base64.b64decode(attachment.content_base64)
|
|
900
|
+
binary_content = BinaryContent(
|
|
901
|
+
data=binary_data,
|
|
902
|
+
media_type=attachment.mime_type,
|
|
903
|
+
)
|
|
904
|
+
user_prompt = [prompt or "", binary_content]
|
|
905
|
+
logger.debug(
|
|
906
|
+
"Constructed multimodal prompt with attachment",
|
|
907
|
+
extra={
|
|
908
|
+
"attachment_type": attachment.file_type.value,
|
|
909
|
+
"attachment_size": attachment.file_size_bytes,
|
|
910
|
+
},
|
|
911
|
+
)
|
|
912
|
+
|
|
656
913
|
try:
|
|
657
914
|
result: AgentRunResult[AgentResponse] = await self._run_agent_with_retry(
|
|
658
915
|
agent=self.current_agent,
|
|
659
|
-
prompt=
|
|
916
|
+
prompt=user_prompt,
|
|
660
917
|
deps=deps,
|
|
661
918
|
usage_limits=usage_limits,
|
|
662
919
|
message_history=message_history,
|
|
@@ -742,17 +999,38 @@ class AgentManager(Widget):
|
|
|
742
999
|
)
|
|
743
1000
|
|
|
744
1001
|
# Deduplicate: skip user prompts that are already in original_messages
|
|
1002
|
+
# Note: We compare content only, not timestamps, since UserPromptPart
|
|
1003
|
+
# has a timestamp field that differs between instances
|
|
1004
|
+
def get_user_prompt_text(
|
|
1005
|
+
request: ModelRequest,
|
|
1006
|
+
) -> str | None:
|
|
1007
|
+
"""Extract just the text content from a ModelRequest for deduplication.
|
|
1008
|
+
|
|
1009
|
+
When content is multimodal (list with text + binary), extract just the text.
|
|
1010
|
+
This ensures text-only and multimodal versions of the same prompt match.
|
|
1011
|
+
"""
|
|
1012
|
+
for part in request.parts:
|
|
1013
|
+
if isinstance(part, UserPromptPart):
|
|
1014
|
+
content = part.content
|
|
1015
|
+
if isinstance(content, str):
|
|
1016
|
+
return content
|
|
1017
|
+
elif isinstance(content, list):
|
|
1018
|
+
# Multimodal content - extract text strings only
|
|
1019
|
+
text_parts = [item for item in content if isinstance(item, str)]
|
|
1020
|
+
return text_parts[0] if text_parts else None
|
|
1021
|
+
return None
|
|
1022
|
+
|
|
745
1023
|
deduplicated_new_messages = []
|
|
746
1024
|
for msg in new_messages:
|
|
747
1025
|
# Check if this is a user prompt that's already in original_messages
|
|
748
1026
|
if isinstance(msg, ModelRequest) and any(
|
|
749
1027
|
isinstance(part, UserPromptPart) for part in msg.parts
|
|
750
1028
|
):
|
|
1029
|
+
msg_text = get_user_prompt_text(msg)
|
|
751
1030
|
# Check if an identical user prompt is already in original_messages
|
|
752
1031
|
already_exists = any(
|
|
753
1032
|
isinstance(existing, ModelRequest)
|
|
754
|
-
and
|
|
755
|
-
and existing.parts == msg.parts
|
|
1033
|
+
and get_user_prompt_text(existing) == msg_text
|
|
756
1034
|
for existing in original_messages[
|
|
757
1035
|
-5:
|
|
758
1036
|
] # Check last 5 messages for efficiency
|
|
@@ -762,6 +1040,13 @@ class AgentManager(Widget):
|
|
|
762
1040
|
|
|
763
1041
|
deduplicated_new_messages.append(msg)
|
|
764
1042
|
|
|
1043
|
+
# Mark file resume prompts as internal (hidden from UI)
|
|
1044
|
+
# When file_contents is provided, the prompt is system-generated, not user input
|
|
1045
|
+
if file_contents:
|
|
1046
|
+
deduplicated_new_messages = self._mark_as_internal_prompts(
|
|
1047
|
+
deduplicated_new_messages
|
|
1048
|
+
)
|
|
1049
|
+
|
|
765
1050
|
self.ui_message_history = original_messages + deduplicated_new_messages
|
|
766
1051
|
|
|
767
1052
|
# Get file operations early so we can use them for contextual messages
|
|
@@ -776,6 +1061,48 @@ class AgentManager(Widget):
|
|
|
776
1061
|
},
|
|
777
1062
|
)
|
|
778
1063
|
|
|
1064
|
+
# Check if there are file requests (takes priority over clarifying questions)
|
|
1065
|
+
# But ignore file_requests if we just provided file_contents (prevents infinite loops)
|
|
1066
|
+
if agent_response.file_requests and not file_contents:
|
|
1067
|
+
logger.info(
|
|
1068
|
+
f"Agent requested {len(agent_response.file_requests)} files to be loaded"
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
# Set pending state
|
|
1072
|
+
self._file_request_pending = True
|
|
1073
|
+
self._pending_file_requests = agent_response.file_requests
|
|
1074
|
+
|
|
1075
|
+
# Add agent's response as hint if present
|
|
1076
|
+
if agent_response.response:
|
|
1077
|
+
self.ui_message_history.append(
|
|
1078
|
+
HintMessage(message=agent_response.response)
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
# Add file loading indicator
|
|
1082
|
+
files_list = "\n".join(f"- `{p}`" for p in agent_response.file_requests)
|
|
1083
|
+
self.ui_message_history.append(
|
|
1084
|
+
HintMessage(message=f"📁 Loading requested files:\n{files_list}")
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
# Post UI update with hint messages
|
|
1088
|
+
self._post_messages_updated([])
|
|
1089
|
+
|
|
1090
|
+
# Post event to TUI to load files and resume
|
|
1091
|
+
self.post_message(
|
|
1092
|
+
FileRequestPendingMessage(
|
|
1093
|
+
file_paths=agent_response.file_requests,
|
|
1094
|
+
response_text=agent_response.response,
|
|
1095
|
+
)
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
return result
|
|
1099
|
+
elif agent_response.file_requests and file_contents:
|
|
1100
|
+
# We just provided files, ignore any new file_requests to prevent loops
|
|
1101
|
+
logger.debug(
|
|
1102
|
+
"Ignoring file_requests (files were just provided): %s",
|
|
1103
|
+
agent_response.file_requests,
|
|
1104
|
+
)
|
|
1105
|
+
|
|
779
1106
|
# Check if there are clarifying questions
|
|
780
1107
|
if agent_response.clarifying_questions:
|
|
781
1108
|
logger.info(
|
|
@@ -799,11 +1126,16 @@ class AgentManager(Widget):
|
|
|
799
1126
|
self.ui_message_history.append(
|
|
800
1127
|
HintMessage(message=f"💡 {agent_response.clarifying_questions[0]}")
|
|
801
1128
|
)
|
|
1129
|
+
# Add plan hint for Drafting mode (Planning mode uses PlanPanelWidget)
|
|
1130
|
+
self._maybe_add_plan_hint_drafting_mode(deps)
|
|
802
1131
|
else:
|
|
803
1132
|
# Multiple questions (2+) - enter Q&A mode
|
|
804
1133
|
self._qa_questions = agent_response.clarifying_questions
|
|
805
1134
|
self._qa_mode_active = True
|
|
806
1135
|
|
|
1136
|
+
# In Drafting mode, show plan BEFORE Q&A questions (without "Shall I continue?")
|
|
1137
|
+
self._maybe_add_plan_hint_drafting_mode(deps, in_qa_mode=True)
|
|
1138
|
+
|
|
807
1139
|
# Show intro with list, then first question
|
|
808
1140
|
questions_list_with_intro = (
|
|
809
1141
|
f"I have {len(agent_response.clarifying_questions)} questions:\n\n"
|
|
@@ -865,6 +1197,9 @@ class AgentManager(Widget):
|
|
|
865
1197
|
HintMessage(message="✅ Task completed")
|
|
866
1198
|
)
|
|
867
1199
|
|
|
1200
|
+
# Add plan hint for Drafting mode (Planning mode uses PlanPanelWidget)
|
|
1201
|
+
self._maybe_add_plan_hint_drafting_mode(deps)
|
|
1202
|
+
|
|
868
1203
|
# Post UI update immediately so user sees the response without delay
|
|
869
1204
|
# (file operations will be posted after compaction to avoid duplicates)
|
|
870
1205
|
logger.debug("Posting immediate UI update with hint message")
|
|
@@ -963,6 +1298,11 @@ class AgentManager(Widget):
|
|
|
963
1298
|
else:
|
|
964
1299
|
partial_parts = []
|
|
965
1300
|
|
|
1301
|
+
# Wrap stream with cancellable iterator for responsive ESC handling
|
|
1302
|
+
deps = _ctx.deps
|
|
1303
|
+
if deps.cancellation_event:
|
|
1304
|
+
stream = CancellableStreamIterator(stream, deps.cancellation_event)
|
|
1305
|
+
|
|
966
1306
|
async for event in stream:
|
|
967
1307
|
try:
|
|
968
1308
|
if isinstance(event, PartStartEvent):
|
|
@@ -992,6 +1332,44 @@ class AgentManager(Widget):
|
|
|
992
1332
|
)
|
|
993
1333
|
continue
|
|
994
1334
|
|
|
1335
|
+
# Count tokens from the delta for progress indication
|
|
1336
|
+
delta_len = 0
|
|
1337
|
+
is_tool_call_delta = False
|
|
1338
|
+
if isinstance(event.delta, ToolCallPartDelta):
|
|
1339
|
+
is_tool_call_delta = True
|
|
1340
|
+
# args_delta can be str or dict depending on provider
|
|
1341
|
+
args_delta = event.delta.args_delta
|
|
1342
|
+
if isinstance(args_delta, str):
|
|
1343
|
+
delta_len = len(args_delta)
|
|
1344
|
+
elif isinstance(args_delta, dict):
|
|
1345
|
+
# For dict deltas, estimate from JSON representation
|
|
1346
|
+
delta_len = len(json.dumps(args_delta))
|
|
1347
|
+
# Pick a spinner message when tool streaming starts
|
|
1348
|
+
if state.current_spinner_text == "Processing...":
|
|
1349
|
+
import random
|
|
1350
|
+
|
|
1351
|
+
state.current_spinner_text = random.choice( # noqa: S311
|
|
1352
|
+
SPINNER_MESSAGES
|
|
1353
|
+
)
|
|
1354
|
+
elif isinstance(event.delta, TextPartDelta):
|
|
1355
|
+
delta_len = len(event.delta.content_delta)
|
|
1356
|
+
|
|
1357
|
+
if delta_len > 0:
|
|
1358
|
+
# Approximate tokens: len / 4 is a rough estimate
|
|
1359
|
+
state.streamed_tokens += delta_len // 4 + 1
|
|
1360
|
+
# Send progress update for tool call streaming
|
|
1361
|
+
# Throttle updates to every ~75 tokens to avoid flooding UI
|
|
1362
|
+
if is_tool_call_delta and (
|
|
1363
|
+
state.streamed_tokens - state.last_reported_tokens >= 75
|
|
1364
|
+
):
|
|
1365
|
+
state.last_reported_tokens = state.streamed_tokens
|
|
1366
|
+
self.post_message(
|
|
1367
|
+
ToolStreamingProgressMessage(
|
|
1368
|
+
state.streamed_tokens,
|
|
1369
|
+
state.current_spinner_text,
|
|
1370
|
+
)
|
|
1371
|
+
)
|
|
1372
|
+
|
|
995
1373
|
try:
|
|
996
1374
|
updated_part = event.delta.apply(
|
|
997
1375
|
cast(ModelResponsePart, partial_parts[index])
|
|
@@ -1087,6 +1465,17 @@ class AgentManager(Widget):
|
|
|
1087
1465
|
if partial_message is not None:
|
|
1088
1466
|
state.current_response = partial_message
|
|
1089
1467
|
self._post_partial_message(False)
|
|
1468
|
+
|
|
1469
|
+
# Notify UI that a tool is about to execute
|
|
1470
|
+
# This updates the spinner with a fun message during tool execution
|
|
1471
|
+
# Pick a random spinner message and store it for progress updates
|
|
1472
|
+
import random
|
|
1473
|
+
|
|
1474
|
+
spinner_text = random.choice(SPINNER_MESSAGES) # noqa: S311
|
|
1475
|
+
state.current_spinner_text = spinner_text
|
|
1476
|
+
state.streamed_tokens = 0 # Reset token count for new tool
|
|
1477
|
+
self.post_message(ToolExecutionStartedMessage(spinner_text))
|
|
1478
|
+
|
|
1090
1479
|
elif isinstance(event, FunctionToolResultEvent):
|
|
1091
1480
|
# Track tool completion event
|
|
1092
1481
|
|
|
@@ -1191,6 +1580,47 @@ class AgentManager(Widget):
|
|
|
1191
1580
|
# Common path is a file, show parent directory
|
|
1192
1581
|
return f"📁 Modified {num_files} files in: `{path_obj.parent}`"
|
|
1193
1582
|
|
|
1583
|
+
def _maybe_add_plan_hint_drafting_mode(
|
|
1584
|
+
self, deps: AgentDeps, in_qa_mode: bool = False
|
|
1585
|
+
) -> None:
|
|
1586
|
+
"""Add execution plan hint for router agent in Drafting mode only.
|
|
1587
|
+
|
|
1588
|
+
In Drafting mode, there's no PlanPanelWidget, so we show the plan
|
|
1589
|
+
in the chat history with a "Shall I continue?" prompt (unless in Q&A mode).
|
|
1590
|
+
|
|
1591
|
+
In Planning mode, the PlanPanelWidget handles plan display.
|
|
1592
|
+
|
|
1593
|
+
Args:
|
|
1594
|
+
deps: Agent dependencies (may be RouterDeps for router agent)
|
|
1595
|
+
in_qa_mode: If True, skip the "Shall I continue?" prompt since user
|
|
1596
|
+
needs to answer Q&A questions first.
|
|
1597
|
+
"""
|
|
1598
|
+
if self._current_agent_type != AgentType.ROUTER:
|
|
1599
|
+
return
|
|
1600
|
+
|
|
1601
|
+
if not isinstance(deps, RouterDeps):
|
|
1602
|
+
return
|
|
1603
|
+
|
|
1604
|
+
# Only show plan hints in Drafting mode
|
|
1605
|
+
# Planning mode uses PlanPanelWidget instead
|
|
1606
|
+
if deps.router_mode != RouterMode.DRAFTING:
|
|
1607
|
+
return
|
|
1608
|
+
|
|
1609
|
+
if deps.current_plan is None:
|
|
1610
|
+
return
|
|
1611
|
+
|
|
1612
|
+
plan_display = deps.current_plan.format_for_display()
|
|
1613
|
+
|
|
1614
|
+
# In drafting mode, if plan is not complete and NOT in Q&A mode,
|
|
1615
|
+
# prompt user to continue
|
|
1616
|
+
if not deps.current_plan.is_complete() and not in_qa_mode:
|
|
1617
|
+
plan_display += "\n\n**Shall I continue?**"
|
|
1618
|
+
|
|
1619
|
+
logger.debug("Adding plan hint to UI history (Drafting mode)")
|
|
1620
|
+
self.ui_message_history.append(
|
|
1621
|
+
HintMessage(message=f"**Current Plan**\n\n{plan_display}")
|
|
1622
|
+
)
|
|
1623
|
+
|
|
1194
1624
|
def _post_messages_updated(
|
|
1195
1625
|
self, file_operations: list[FileOperation] | None = None
|
|
1196
1626
|
) -> None:
|
|
@@ -1203,6 +1633,43 @@ class AgentManager(Widget):
|
|
|
1203
1633
|
)
|
|
1204
1634
|
)
|
|
1205
1635
|
|
|
1636
|
+
def _mark_as_internal_prompts(
|
|
1637
|
+
self,
|
|
1638
|
+
messages: list[ModelRequest | ModelResponse | HintMessage],
|
|
1639
|
+
) -> list[ModelRequest | ModelResponse | HintMessage]:
|
|
1640
|
+
"""Mark UserPromptPart as InternalPromptPart for system-generated prompts.
|
|
1641
|
+
|
|
1642
|
+
Used when file_contents is provided - the resume prompt is system-generated,
|
|
1643
|
+
not actual user input, and should be hidden from the UI.
|
|
1644
|
+
|
|
1645
|
+
Args:
|
|
1646
|
+
messages: List of messages that may contain user prompts to mark as internal
|
|
1647
|
+
|
|
1648
|
+
Returns:
|
|
1649
|
+
List of messages with UserPromptPart converted to InternalPromptPart
|
|
1650
|
+
"""
|
|
1651
|
+
result: list[ModelRequest | ModelResponse | HintMessage] = []
|
|
1652
|
+
for msg in messages:
|
|
1653
|
+
if isinstance(msg, ModelRequest):
|
|
1654
|
+
new_parts: list[ModelRequestPart] = []
|
|
1655
|
+
for part in msg.parts:
|
|
1656
|
+
if isinstance(part, UserPromptPart) and not isinstance(
|
|
1657
|
+
part, InternalPromptPart
|
|
1658
|
+
):
|
|
1659
|
+
# Convert to InternalPromptPart
|
|
1660
|
+
new_parts.append(
|
|
1661
|
+
InternalPromptPart(
|
|
1662
|
+
content=part.content,
|
|
1663
|
+
timestamp=part.timestamp,
|
|
1664
|
+
)
|
|
1665
|
+
)
|
|
1666
|
+
else:
|
|
1667
|
+
new_parts.append(part)
|
|
1668
|
+
result.append(ModelRequest(parts=new_parts))
|
|
1669
|
+
else:
|
|
1670
|
+
result.append(msg)
|
|
1671
|
+
return result
|
|
1672
|
+
|
|
1206
1673
|
def _filter_system_prompts(
|
|
1207
1674
|
self, messages: list[ModelMessage | HintMessage]
|
|
1208
1675
|
) -> list[ModelMessage | HintMessage]:
|