shotgun-sh 0.1.0.dev25__py3-none-any.whl → 0.1.0.dev27__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 +67 -16
- shotgun/agents/common.py +116 -30
- shotgun/agents/conversation_history.py +53 -3
- shotgun/agents/history/history_processors.py +41 -25
- shotgun/agents/history/message_utils.py +39 -1
- shotgun/agents/messages.py +35 -0
- shotgun/agents/models.py +17 -0
- shotgun/agents/tools/file_management.py +18 -14
- shotgun/prompts/agents/export.j2 +283 -39
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +1 -1
- shotgun/prompts/agents/plan.j2 +102 -31
- shotgun/prompts/agents/state/system_state.j2 +7 -7
- shotgun/tui/screens/chat.py +10 -14
- shotgun/tui/screens/chat_screen/hint_message.py +40 -0
- shotgun/tui/screens/chat_screen/history.py +6 -2
- {shotgun_sh-0.1.0.dev25.dist-info → shotgun_sh-0.1.0.dev27.dist-info}/METADATA +1 -1
- {shotgun_sh-0.1.0.dev25.dist-info → shotgun_sh-0.1.0.dev27.dist-info}/RECORD +20 -18
- {shotgun_sh-0.1.0.dev25.dist-info → shotgun_sh-0.1.0.dev27.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.1.0.dev25.dist-info → shotgun_sh-0.1.0.dev27.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.1.0.dev25.dist-info → shotgun_sh-0.1.0.dev27.dist-info}/licenses/LICENSE +0 -0
shotgun/agents/agent_manager.py
CHANGED
|
@@ -37,9 +37,11 @@ from textual.widget import Widget
|
|
|
37
37
|
|
|
38
38
|
from shotgun.agents.common import add_system_prompt_message, add_system_status_message
|
|
39
39
|
from shotgun.agents.models import AgentType, FileOperation
|
|
40
|
+
from shotgun.tui.screens.chat_screen.hint_message import HintMessage
|
|
40
41
|
|
|
41
42
|
from .export import create_export_agent
|
|
42
43
|
from .history.compaction import apply_persistent_compaction
|
|
44
|
+
from .messages import AgentSystemPrompt
|
|
43
45
|
from .models import AgentDeps, AgentRuntimeOptions
|
|
44
46
|
from .plan import create_plan_agent
|
|
45
47
|
from .research import create_research_agent
|
|
@@ -54,7 +56,7 @@ class MessageHistoryUpdated(Message):
|
|
|
54
56
|
|
|
55
57
|
def __init__(
|
|
56
58
|
self,
|
|
57
|
-
messages: list[ModelMessage],
|
|
59
|
+
messages: list[ModelMessage | HintMessage],
|
|
58
60
|
agent_type: AgentType,
|
|
59
61
|
file_operations: list[FileOperation] | None = None,
|
|
60
62
|
) -> None:
|
|
@@ -143,7 +145,7 @@ class AgentManager(Widget):
|
|
|
143
145
|
self._current_agent_type: AgentType = initial_type
|
|
144
146
|
|
|
145
147
|
# Maintain shared message history
|
|
146
|
-
self.ui_message_history: list[ModelMessage] = []
|
|
148
|
+
self.ui_message_history: list[ModelMessage | HintMessage] = []
|
|
147
149
|
self.message_history: list[ModelMessage] = []
|
|
148
150
|
self.recently_change_files: list[FileOperation] = []
|
|
149
151
|
self._stream_state: _PartialStreamState | None = None
|
|
@@ -277,15 +279,51 @@ class AgentManager(Widget):
|
|
|
277
279
|
|
|
278
280
|
deps.agent_mode = self._current_agent_type
|
|
279
281
|
|
|
282
|
+
# Filter out system prompts from other agent types
|
|
283
|
+
from pydantic_ai.messages import ModelRequestPart
|
|
284
|
+
|
|
285
|
+
filtered_history: list[ModelMessage] = []
|
|
286
|
+
for message in message_history:
|
|
287
|
+
# Keep all non-ModelRequest messages as-is
|
|
288
|
+
if not isinstance(message, ModelRequest):
|
|
289
|
+
filtered_history.append(message)
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
# Filter out AgentSystemPrompts from other agent types
|
|
293
|
+
filtered_parts: list[ModelRequestPart] = []
|
|
294
|
+
for part in message.parts:
|
|
295
|
+
# Keep non-AgentSystemPrompt parts
|
|
296
|
+
if not isinstance(part, AgentSystemPrompt):
|
|
297
|
+
filtered_parts.append(part)
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
# Only keep system prompts from the same agent type
|
|
301
|
+
if part.agent_mode == deps.agent_mode:
|
|
302
|
+
filtered_parts.append(part)
|
|
303
|
+
|
|
304
|
+
# Only add the message if it has parts remaining
|
|
305
|
+
if filtered_parts:
|
|
306
|
+
filtered_history.append(ModelRequest(parts=filtered_parts))
|
|
307
|
+
|
|
308
|
+
message_history = filtered_history
|
|
309
|
+
|
|
280
310
|
# Add a system status message so the agent knows whats going on
|
|
281
311
|
message_history = await add_system_status_message(deps, message_history)
|
|
282
312
|
|
|
283
|
-
# Check if the message history already has a system prompt
|
|
284
|
-
has_system_prompt =
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
313
|
+
# Check if the message history already has a system prompt from the same agent type
|
|
314
|
+
has_system_prompt = False
|
|
315
|
+
for message in message_history:
|
|
316
|
+
if not isinstance(message, ModelRequest):
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
for part in message.parts:
|
|
320
|
+
if not isinstance(part, AgentSystemPrompt):
|
|
321
|
+
continue
|
|
322
|
+
|
|
323
|
+
# Check if it's from the same agent type
|
|
324
|
+
if part.agent_mode == deps.agent_mode:
|
|
325
|
+
has_system_prompt = True
|
|
326
|
+
break
|
|
289
327
|
|
|
290
328
|
# Always ensure we have a system prompt for the agent
|
|
291
329
|
# (compaction may remove it from persistent history, but agent needs it)
|
|
@@ -461,8 +499,8 @@ class AgentManager(Widget):
|
|
|
461
499
|
)
|
|
462
500
|
|
|
463
501
|
def _filter_system_prompts(
|
|
464
|
-
self, messages: list[ModelMessage]
|
|
465
|
-
) -> list[ModelMessage]:
|
|
502
|
+
self, messages: list[ModelMessage | HintMessage]
|
|
503
|
+
) -> list[ModelMessage | HintMessage]:
|
|
466
504
|
"""Filter out system prompts from messages for UI display.
|
|
467
505
|
|
|
468
506
|
Args:
|
|
@@ -471,10 +509,12 @@ class AgentManager(Widget):
|
|
|
471
509
|
Returns:
|
|
472
510
|
List of messages without system prompt parts
|
|
473
511
|
"""
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
filtered_messages: list[ModelMessage] = []
|
|
512
|
+
filtered_messages: list[ModelMessage | HintMessage] = []
|
|
477
513
|
for msg in messages:
|
|
514
|
+
if isinstance(msg, HintMessage):
|
|
515
|
+
filtered_messages.append(msg)
|
|
516
|
+
continue
|
|
517
|
+
|
|
478
518
|
parts: Sequence[ModelRequestPart] | Sequence[ModelResponsePart] | None = (
|
|
479
519
|
msg.parts if hasattr(msg, "parts") else None
|
|
480
520
|
)
|
|
@@ -514,6 +554,7 @@ class AgentManager(Widget):
|
|
|
514
554
|
|
|
515
555
|
return ConversationState(
|
|
516
556
|
agent_messages=self.message_history.copy(),
|
|
557
|
+
ui_messages=self.ui_message_history.copy(),
|
|
517
558
|
agent_type=self._current_agent_type.value,
|
|
518
559
|
)
|
|
519
560
|
|
|
@@ -524,10 +565,16 @@ class AgentManager(Widget):
|
|
|
524
565
|
state: ConversationState object to restore
|
|
525
566
|
"""
|
|
526
567
|
# Restore message history for agents (includes system prompts)
|
|
527
|
-
|
|
568
|
+
non_hint_messages = [
|
|
569
|
+
msg for msg in state.agent_messages if not isinstance(msg, HintMessage)
|
|
570
|
+
]
|
|
571
|
+
self.message_history = non_hint_messages
|
|
528
572
|
|
|
529
|
-
# Filter out system prompts for UI display
|
|
530
|
-
|
|
573
|
+
# Filter out system prompts for UI display while keeping hints
|
|
574
|
+
ui_source = state.ui_messages or cast(
|
|
575
|
+
list[ModelMessage | HintMessage], state.agent_messages
|
|
576
|
+
)
|
|
577
|
+
self.ui_message_history = self._filter_system_prompts(ui_source)
|
|
531
578
|
|
|
532
579
|
# Restore agent type
|
|
533
580
|
self._current_agent_type = AgentType(state.agent_type)
|
|
@@ -535,6 +582,10 @@ class AgentManager(Widget):
|
|
|
535
582
|
# Notify listeners about the restored messages
|
|
536
583
|
self._post_messages_updated()
|
|
537
584
|
|
|
585
|
+
def add_hint_message(self, message: HintMessage) -> None:
|
|
586
|
+
self.ui_message_history.append(message)
|
|
587
|
+
self._post_messages_updated()
|
|
588
|
+
|
|
538
589
|
|
|
539
590
|
# Re-export AgentType for backward compatibility
|
|
540
591
|
__all__ = [
|
shotgun/agents/common.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
from collections.abc import Callable
|
|
5
|
+
from pathlib import Path
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
from pydantic_ai import (
|
|
@@ -15,7 +16,6 @@ from pydantic_ai.agent import AgentRunResult
|
|
|
15
16
|
from pydantic_ai.messages import (
|
|
16
17
|
ModelMessage,
|
|
17
18
|
ModelRequest,
|
|
18
|
-
SystemPromptPart,
|
|
19
19
|
)
|
|
20
20
|
|
|
21
21
|
from shotgun.agents.config import ProviderType, get_config_manager, get_provider_model
|
|
@@ -28,7 +28,8 @@ from shotgun.utils.file_system_utils import get_shotgun_base_path
|
|
|
28
28
|
|
|
29
29
|
from .history import token_limit_compactor
|
|
30
30
|
from .history.compaction import apply_persistent_compaction
|
|
31
|
-
from .
|
|
31
|
+
from .messages import AgentSystemPrompt, SystemStatusPrompt
|
|
32
|
+
from .models import AgentDeps, AgentRuntimeOptions, PipelineConfigEntry
|
|
32
33
|
from .tools import (
|
|
33
34
|
append_file,
|
|
34
35
|
ask_user,
|
|
@@ -84,7 +85,7 @@ async def add_system_status_message(
|
|
|
84
85
|
message_history.append(
|
|
85
86
|
ModelRequest(
|
|
86
87
|
parts=[
|
|
87
|
-
|
|
88
|
+
SystemStatusPrompt(content=system_state),
|
|
88
89
|
]
|
|
89
90
|
)
|
|
90
91
|
)
|
|
@@ -197,33 +198,24 @@ def create_base_agent(
|
|
|
197
198
|
return agent, deps
|
|
198
199
|
|
|
199
200
|
|
|
200
|
-
def
|
|
201
|
-
|
|
201
|
+
def _extract_file_toc_content(
|
|
202
|
+
file_path: Path, max_depth: int | None = None, max_chars: int = 500
|
|
203
|
+
) -> str | None:
|
|
204
|
+
"""Extract TOC from a single file with depth and character limits.
|
|
202
205
|
|
|
203
206
|
Args:
|
|
204
|
-
|
|
207
|
+
file_path: Path to the markdown file
|
|
208
|
+
max_depth: Maximum heading depth (1=#, 2=##, None=all)
|
|
209
|
+
max_chars: Maximum characters for the TOC
|
|
205
210
|
|
|
206
211
|
Returns:
|
|
207
|
-
Formatted TOC string
|
|
212
|
+
Formatted TOC string or None if file doesn't exist
|
|
208
213
|
"""
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
not agent_mode
|
|
212
|
-
or agent_mode == AgentType.EXPORT
|
|
213
|
-
or agent_mode not in AGENT_DIRECTORIES
|
|
214
|
-
):
|
|
215
|
-
return None
|
|
216
|
-
|
|
217
|
-
base_path = get_shotgun_base_path()
|
|
218
|
-
md_file = AGENT_DIRECTORIES[agent_mode]
|
|
219
|
-
md_path = base_path / md_file
|
|
220
|
-
|
|
221
|
-
# Check if the markdown file exists
|
|
222
|
-
if not md_path.exists():
|
|
214
|
+
if not file_path.exists():
|
|
223
215
|
return None
|
|
224
216
|
|
|
225
217
|
try:
|
|
226
|
-
content =
|
|
218
|
+
content = file_path.read_text(encoding="utf-8")
|
|
227
219
|
lines = content.split("\n")
|
|
228
220
|
|
|
229
221
|
# Extract headings
|
|
@@ -239,6 +231,10 @@ def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
|
|
|
239
231
|
else:
|
|
240
232
|
break
|
|
241
233
|
|
|
234
|
+
# Skip if exceeds max_depth
|
|
235
|
+
if max_depth and level > max_depth:
|
|
236
|
+
continue
|
|
237
|
+
|
|
242
238
|
# Get the heading text (remove the # symbols and clean up)
|
|
243
239
|
heading_text = stripped[level:].strip()
|
|
244
240
|
if heading_text:
|
|
@@ -246,21 +242,109 @@ def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
|
|
|
246
242
|
indent = " " * (level - 1)
|
|
247
243
|
toc_lines.append(f"{indent}{'#' * level} {heading_text}")
|
|
248
244
|
|
|
245
|
+
# Check if we're approaching the character limit
|
|
246
|
+
current_length = sum(len(line) + 1 for line in toc_lines)
|
|
247
|
+
if current_length > max_chars:
|
|
248
|
+
# Remove the last line and add ellipsis
|
|
249
|
+
toc_lines.pop()
|
|
250
|
+
if toc_lines:
|
|
251
|
+
toc_lines.append(" ...")
|
|
252
|
+
break
|
|
253
|
+
|
|
249
254
|
if not toc_lines:
|
|
250
255
|
return None
|
|
251
256
|
|
|
252
|
-
|
|
253
|
-
toc = "\n".join(toc_lines)
|
|
254
|
-
if len(toc) > 2000:
|
|
255
|
-
toc = toc[:1997] + "..."
|
|
256
|
-
|
|
257
|
-
return toc
|
|
257
|
+
return "\n".join(toc_lines)
|
|
258
258
|
|
|
259
259
|
except Exception as e:
|
|
260
|
-
logger.debug(f"Failed to extract TOC from {
|
|
260
|
+
logger.debug(f"Failed to extract TOC from {file_path}: {e}")
|
|
261
261
|
return None
|
|
262
262
|
|
|
263
263
|
|
|
264
|
+
def extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
|
|
265
|
+
"""Extract TOCs from current and prior agents' files in the pipeline.
|
|
266
|
+
|
|
267
|
+
Shows full TOC of agent's own file and high-level summaries of prior agents'
|
|
268
|
+
files to maintain context awareness while keeping context window tight.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
agent_mode: The agent mode to extract TOC for
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Formatted multi-file TOC string or None if not applicable
|
|
275
|
+
"""
|
|
276
|
+
# Skip if no mode
|
|
277
|
+
if not agent_mode:
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
# Define pipeline order and dependencies
|
|
281
|
+
pipeline_config: dict[AgentType, PipelineConfigEntry] = {
|
|
282
|
+
AgentType.RESEARCH: PipelineConfigEntry(
|
|
283
|
+
own_file="research.md",
|
|
284
|
+
prior_files=[], # First in pipeline
|
|
285
|
+
),
|
|
286
|
+
AgentType.SPECIFY: PipelineConfigEntry(
|
|
287
|
+
own_file="specification.md",
|
|
288
|
+
prior_files=["research.md"],
|
|
289
|
+
),
|
|
290
|
+
AgentType.PLAN: PipelineConfigEntry(
|
|
291
|
+
own_file="plan.md",
|
|
292
|
+
prior_files=["research.md", "specification.md"],
|
|
293
|
+
),
|
|
294
|
+
AgentType.TASKS: PipelineConfigEntry(
|
|
295
|
+
own_file="tasks.md",
|
|
296
|
+
prior_files=["research.md", "specification.md", "plan.md"],
|
|
297
|
+
),
|
|
298
|
+
AgentType.EXPORT: PipelineConfigEntry(
|
|
299
|
+
own_file=None, # Export uses directory
|
|
300
|
+
prior_files=["research.md", "specification.md", "plan.md", "tasks.md"],
|
|
301
|
+
),
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
# Get configuration for current agent
|
|
305
|
+
if agent_mode not in pipeline_config:
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
config = pipeline_config[agent_mode]
|
|
309
|
+
base_path = get_shotgun_base_path()
|
|
310
|
+
toc_sections: list[str] = []
|
|
311
|
+
|
|
312
|
+
# Extract TOCs from prior files (high-level only)
|
|
313
|
+
for prior_file in config.prior_files:
|
|
314
|
+
file_path = base_path / prior_file
|
|
315
|
+
# Only show # and ## headings from prior files, max 500 chars each
|
|
316
|
+
prior_toc = _extract_file_toc_content(file_path, max_depth=2, max_chars=500)
|
|
317
|
+
if prior_toc:
|
|
318
|
+
# Add section with XML tags
|
|
319
|
+
toc_sections.append(
|
|
320
|
+
f'<TABLE_OF_CONTENTS file_name="{prior_file}">\n{prior_toc}\n</TABLE_OF_CONTENTS>'
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
# Extract TOC from own file (full detail)
|
|
324
|
+
if config.own_file:
|
|
325
|
+
own_path = base_path / config.own_file
|
|
326
|
+
own_toc = _extract_file_toc_content(own_path, max_depth=None, max_chars=2000)
|
|
327
|
+
if own_toc:
|
|
328
|
+
# Put own file TOC at the beginning with XML tags
|
|
329
|
+
toc_sections.insert(
|
|
330
|
+
0,
|
|
331
|
+
f'<TABLE_OF_CONTENTS file_name="{config.own_file}">\n{own_toc}\n</TABLE_OF_CONTENTS>',
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Combine all sections
|
|
335
|
+
if not toc_sections:
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
combined_toc = "\n\n".join(toc_sections)
|
|
339
|
+
|
|
340
|
+
# Final truncation if needed (should rarely happen with our limits)
|
|
341
|
+
max_total = 3500 # Conservative total limit
|
|
342
|
+
if len(combined_toc) > max_total:
|
|
343
|
+
combined_toc = combined_toc[: max_total - 3] + "..."
|
|
344
|
+
|
|
345
|
+
return combined_toc
|
|
346
|
+
|
|
347
|
+
|
|
264
348
|
def get_agent_existing_files(agent_mode: AgentType | None = None) -> list[str]:
|
|
265
349
|
"""Get list of existing files for the given agent mode.
|
|
266
350
|
|
|
@@ -399,7 +483,9 @@ async def add_system_prompt_message(
|
|
|
399
483
|
|
|
400
484
|
# Create system message and prepend to message history
|
|
401
485
|
system_message = ModelRequest(
|
|
402
|
-
parts=[
|
|
486
|
+
parts=[
|
|
487
|
+
AgentSystemPrompt(content=system_prompt_content, agent_mode=deps.agent_mode)
|
|
488
|
+
]
|
|
403
489
|
)
|
|
404
490
|
message_history.insert(0, system_message)
|
|
405
491
|
logger.debug("✅ System prompt prepended as first message")
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Models and utilities for persisting TUI conversation history."""
|
|
2
2
|
|
|
3
3
|
from datetime import datetime
|
|
4
|
-
from typing import Any
|
|
4
|
+
from typing import Any, cast
|
|
5
5
|
|
|
6
6
|
from pydantic import BaseModel, ConfigDict, Field
|
|
7
7
|
from pydantic_ai.messages import (
|
|
@@ -10,11 +10,16 @@ from pydantic_ai.messages import (
|
|
|
10
10
|
)
|
|
11
11
|
from pydantic_core import to_jsonable_python
|
|
12
12
|
|
|
13
|
+
from shotgun.tui.screens.chat_screen.hint_message import HintMessage
|
|
14
|
+
|
|
15
|
+
SerializedMessage = dict[str, Any]
|
|
16
|
+
|
|
13
17
|
|
|
14
18
|
class ConversationState(BaseModel):
|
|
15
19
|
"""Represents the complete state of a conversation in memory."""
|
|
16
20
|
|
|
17
21
|
agent_messages: list[ModelMessage]
|
|
22
|
+
ui_messages: list[ModelMessage | HintMessage] = Field(default_factory=list)
|
|
18
23
|
agent_type: str # Will store AgentType.value
|
|
19
24
|
|
|
20
25
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
@@ -24,9 +29,12 @@ class ConversationHistory(BaseModel):
|
|
|
24
29
|
"""Persistent conversation history for TUI sessions."""
|
|
25
30
|
|
|
26
31
|
version: int = 1
|
|
27
|
-
agent_history: list[
|
|
32
|
+
agent_history: list[SerializedMessage] = Field(
|
|
33
|
+
default_factory=list
|
|
34
|
+
) # Stores serialized ModelMessage objects
|
|
35
|
+
ui_history: list[SerializedMessage] = Field(
|
|
28
36
|
default_factory=list
|
|
29
|
-
) #
|
|
37
|
+
) # Stores serialized ModelMessage and HintMessage objects
|
|
30
38
|
last_agent_model: str = "research"
|
|
31
39
|
updated_at: datetime = Field(default_factory=datetime.now)
|
|
32
40
|
|
|
@@ -43,6 +51,25 @@ class ConversationHistory(BaseModel):
|
|
|
43
51
|
messages, fallback=lambda x: str(x), exclude_none=True
|
|
44
52
|
)
|
|
45
53
|
|
|
54
|
+
def set_ui_messages(self, messages: list[ModelMessage | HintMessage]) -> None:
|
|
55
|
+
"""Set ui_history from a list of UI messages."""
|
|
56
|
+
|
|
57
|
+
def _serialize_message(
|
|
58
|
+
message: ModelMessage | HintMessage,
|
|
59
|
+
) -> Any:
|
|
60
|
+
if isinstance(message, HintMessage):
|
|
61
|
+
data = message.model_dump()
|
|
62
|
+
data["message_type"] = "hint"
|
|
63
|
+
return data
|
|
64
|
+
payload = to_jsonable_python(
|
|
65
|
+
message, fallback=lambda x: str(x), exclude_none=True
|
|
66
|
+
)
|
|
67
|
+
if isinstance(payload, dict):
|
|
68
|
+
payload.setdefault("message_type", "model")
|
|
69
|
+
return payload
|
|
70
|
+
|
|
71
|
+
self.ui_history = [_serialize_message(msg) for msg in messages]
|
|
72
|
+
|
|
46
73
|
def get_agent_messages(self) -> list[ModelMessage]:
|
|
47
74
|
"""Get agent_history as a list of ModelMessage objects.
|
|
48
75
|
|
|
@@ -54,3 +81,26 @@ class ConversationHistory(BaseModel):
|
|
|
54
81
|
|
|
55
82
|
# Deserialize from JSON format back to ModelMessage objects
|
|
56
83
|
return ModelMessagesTypeAdapter.validate_python(self.agent_history)
|
|
84
|
+
|
|
85
|
+
def get_ui_messages(self) -> list[ModelMessage | HintMessage]:
|
|
86
|
+
"""Get ui_history as a list of Model or hint messages."""
|
|
87
|
+
|
|
88
|
+
if not self.ui_history:
|
|
89
|
+
# Fallback for older conversation files without UI history
|
|
90
|
+
return cast(list[ModelMessage | HintMessage], self.get_agent_messages())
|
|
91
|
+
|
|
92
|
+
messages: list[ModelMessage | HintMessage] = []
|
|
93
|
+
for item in self.ui_history:
|
|
94
|
+
message_type = item.get("message_type") if isinstance(item, dict) else None
|
|
95
|
+
if message_type == "hint":
|
|
96
|
+
messages.append(HintMessage.model_validate(item))
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
# Backwards compatibility: data may not include the type marker
|
|
100
|
+
payload = item
|
|
101
|
+
if isinstance(payload, dict):
|
|
102
|
+
payload = {k: v for k, v in payload.items() if k != "message_type"}
|
|
103
|
+
deserialized = ModelMessagesTypeAdapter.validate_python([payload])
|
|
104
|
+
messages.append(deserialized[0])
|
|
105
|
+
|
|
106
|
+
return messages
|
|
@@ -6,12 +6,12 @@ from pydantic_ai.messages import (
|
|
|
6
6
|
ModelMessage,
|
|
7
7
|
ModelRequest,
|
|
8
8
|
ModelResponse,
|
|
9
|
-
SystemPromptPart,
|
|
10
9
|
TextPart,
|
|
11
10
|
UserPromptPart,
|
|
12
11
|
)
|
|
13
12
|
|
|
14
13
|
from shotgun.agents.config.models import shotgun_model_request
|
|
14
|
+
from shotgun.agents.messages import AgentSystemPrompt, SystemStatusPrompt
|
|
15
15
|
from shotgun.agents.models import AgentDeps
|
|
16
16
|
from shotgun.logging_config import get_logger
|
|
17
17
|
from shotgun.prompts import PromptLoader
|
|
@@ -20,8 +20,9 @@ from .constants import SUMMARY_MARKER, TOKEN_LIMIT_RATIO
|
|
|
20
20
|
from .context_extraction import extract_context_from_messages
|
|
21
21
|
from .history_building import ensure_ends_with_model_request
|
|
22
22
|
from .message_utils import (
|
|
23
|
+
get_agent_system_prompt,
|
|
23
24
|
get_first_user_request,
|
|
24
|
-
|
|
25
|
+
get_latest_system_status,
|
|
25
26
|
)
|
|
26
27
|
from .token_estimation import (
|
|
27
28
|
calculate_max_summarization_tokens as _calculate_max_summarization_tokens,
|
|
@@ -274,31 +275,39 @@ async def token_limit_compactor(
|
|
|
274
275
|
new_summary_part = create_marked_summary_part(summary_response)
|
|
275
276
|
|
|
276
277
|
# Extract essential context from messages before the last summary (if any)
|
|
277
|
-
|
|
278
|
+
agent_prompt = ""
|
|
279
|
+
system_status = ""
|
|
278
280
|
first_user_prompt = ""
|
|
279
281
|
if last_summary_index > 0:
|
|
280
|
-
# Get system and first user from original conversation
|
|
281
|
-
|
|
282
|
+
# Get agent system prompt and first user from original conversation
|
|
283
|
+
agent_prompt = get_agent_system_prompt(messages[:last_summary_index]) or ""
|
|
282
284
|
first_user_prompt = (
|
|
283
285
|
get_first_user_request(messages[:last_summary_index]) or ""
|
|
284
286
|
)
|
|
285
287
|
|
|
288
|
+
# Get the latest system status from all messages
|
|
289
|
+
system_status = get_latest_system_status(messages) or ""
|
|
290
|
+
|
|
286
291
|
# Create the updated summary message
|
|
287
292
|
updated_summary_message = ModelResponse(parts=[new_summary_part])
|
|
288
293
|
|
|
289
294
|
# Build final compacted history with CLEAN structure
|
|
290
295
|
compacted_messages: list[ModelMessage] = []
|
|
291
296
|
|
|
292
|
-
#
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
)
|
|
297
|
+
# Build parts for the initial request
|
|
298
|
+
from pydantic_ai.messages import ModelRequestPart
|
|
299
|
+
|
|
300
|
+
parts: list[ModelRequestPart] = []
|
|
301
|
+
if agent_prompt:
|
|
302
|
+
parts.append(AgentSystemPrompt(content=agent_prompt))
|
|
303
|
+
if system_status:
|
|
304
|
+
parts.append(SystemStatusPrompt(content=system_status))
|
|
305
|
+
if first_user_prompt:
|
|
306
|
+
parts.append(UserPromptPart(content=first_user_prompt))
|
|
307
|
+
|
|
308
|
+
# Only add if we have at least one part
|
|
309
|
+
if parts:
|
|
310
|
+
compacted_messages.append(ModelRequest(parts=parts))
|
|
302
311
|
|
|
303
312
|
# Add the summary
|
|
304
313
|
compacted_messages.append(updated_summary_message)
|
|
@@ -390,19 +399,26 @@ async def _full_compaction(
|
|
|
390
399
|
marked_summary_part = create_marked_summary_part(summary_response)
|
|
391
400
|
|
|
392
401
|
# Build compacted history structure
|
|
393
|
-
|
|
402
|
+
agent_prompt = get_agent_system_prompt(messages) or ""
|
|
403
|
+
system_status = get_latest_system_status(messages) or ""
|
|
394
404
|
user_prompt = get_first_user_request(messages) or ""
|
|
395
405
|
|
|
406
|
+
# Build parts for the initial request
|
|
407
|
+
from pydantic_ai.messages import ModelRequestPart
|
|
408
|
+
|
|
409
|
+
parts: list[ModelRequestPart] = []
|
|
410
|
+
if agent_prompt:
|
|
411
|
+
parts.append(AgentSystemPrompt(content=agent_prompt))
|
|
412
|
+
if system_status:
|
|
413
|
+
parts.append(SystemStatusPrompt(content=system_status))
|
|
414
|
+
if user_prompt:
|
|
415
|
+
parts.append(UserPromptPart(content=user_prompt))
|
|
416
|
+
|
|
396
417
|
# Create base structure
|
|
397
|
-
compacted_messages: list[ModelMessage] = [
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
UserPromptPart(content=user_prompt),
|
|
402
|
-
]
|
|
403
|
-
),
|
|
404
|
-
ModelResponse(parts=[marked_summary_part]),
|
|
405
|
-
]
|
|
418
|
+
compacted_messages: list[ModelMessage] = []
|
|
419
|
+
if parts:
|
|
420
|
+
compacted_messages.append(ModelRequest(parts=parts))
|
|
421
|
+
compacted_messages.append(ModelResponse(parts=[marked_summary_part]))
|
|
406
422
|
|
|
407
423
|
# Ensure history ends with ModelRequest for PydanticAI compatibility
|
|
408
424
|
compacted_messages = ensure_ends_with_model_request(compacted_messages, messages)
|
|
@@ -7,6 +7,8 @@ from pydantic_ai.messages import (
|
|
|
7
7
|
UserPromptPart,
|
|
8
8
|
)
|
|
9
9
|
|
|
10
|
+
from shotgun.agents.messages import AgentSystemPrompt, SystemStatusPrompt
|
|
11
|
+
|
|
10
12
|
|
|
11
13
|
def get_first_user_request(messages: list[ModelMessage]) -> str | None:
|
|
12
14
|
"""Extract first user request content from messages."""
|
|
@@ -37,10 +39,46 @@ def get_user_content_from_request(request: ModelRequest) -> str | None:
|
|
|
37
39
|
|
|
38
40
|
|
|
39
41
|
def get_system_prompt(messages: list[ModelMessage]) -> str | None:
|
|
40
|
-
"""Extract system prompt from messages."""
|
|
42
|
+
"""Extract system prompt from messages (any SystemPromptPart)."""
|
|
41
43
|
for msg in messages:
|
|
42
44
|
if isinstance(msg, ModelRequest):
|
|
43
45
|
for part in msg.parts:
|
|
44
46
|
if isinstance(part, SystemPromptPart):
|
|
45
47
|
return part.content
|
|
46
48
|
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_agent_system_prompt(messages: list[ModelMessage]) -> str | None:
|
|
52
|
+
"""Extract the main agent system prompt from messages.
|
|
53
|
+
|
|
54
|
+
Prioritizes AgentSystemPrompt but falls back to generic SystemPromptPart
|
|
55
|
+
if no AgentSystemPrompt is found.
|
|
56
|
+
"""
|
|
57
|
+
# First try to find AgentSystemPrompt
|
|
58
|
+
for msg in messages:
|
|
59
|
+
if isinstance(msg, ModelRequest):
|
|
60
|
+
for part in msg.parts:
|
|
61
|
+
if isinstance(part, AgentSystemPrompt):
|
|
62
|
+
return part.content
|
|
63
|
+
|
|
64
|
+
# Fall back to any SystemPromptPart (excluding SystemStatusPrompt)
|
|
65
|
+
for msg in messages:
|
|
66
|
+
if isinstance(msg, ModelRequest):
|
|
67
|
+
for part in msg.parts:
|
|
68
|
+
if isinstance(part, SystemPromptPart) and not isinstance(
|
|
69
|
+
part, SystemStatusPrompt
|
|
70
|
+
):
|
|
71
|
+
return part.content
|
|
72
|
+
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_latest_system_status(messages: list[ModelMessage]) -> str | None:
|
|
77
|
+
"""Extract the most recent system status prompt from messages."""
|
|
78
|
+
# Iterate in reverse to find the most recent status
|
|
79
|
+
for msg in reversed(messages):
|
|
80
|
+
if isinstance(msg, ModelRequest):
|
|
81
|
+
for part in msg.parts:
|
|
82
|
+
if isinstance(part, SystemStatusPrompt):
|
|
83
|
+
return part.content
|
|
84
|
+
return None
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Custom message types for Shotgun agents.
|
|
2
|
+
|
|
3
|
+
This module defines specialized SystemPromptPart subclasses to distinguish
|
|
4
|
+
between different types of system prompts in the agent pipeline.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from pydantic_ai.messages import SystemPromptPart
|
|
10
|
+
|
|
11
|
+
from shotgun.agents.models import AgentType
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class AgentSystemPrompt(SystemPromptPart):
|
|
16
|
+
"""System prompt containing the main agent instructions.
|
|
17
|
+
|
|
18
|
+
This is the primary system prompt that defines the agent's role,
|
|
19
|
+
capabilities, and behavior. It should be preserved during compaction.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
prompt_type: str = "agent"
|
|
23
|
+
agent_mode: AgentType | None = field(default=None)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SystemStatusPrompt(SystemPromptPart):
|
|
28
|
+
"""System prompt containing current system status information.
|
|
29
|
+
|
|
30
|
+
This includes table of contents, available files, and other contextual
|
|
31
|
+
information about the current state. Only the most recent status should
|
|
32
|
+
be preserved during compaction.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
prompt_type: str = "status"
|