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.

@@ -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 = any(
285
- hasattr(msg, "parts")
286
- and any(isinstance(part, SystemPromptPart) for part in msg.parts)
287
- for msg in message_history
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
- from pydantic_ai.messages import SystemPromptPart
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
- self.message_history = state.agent_messages.copy()
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
- self.ui_message_history = self._filter_system_prompts(state.agent_messages)
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 .models import AgentDeps, AgentRuntimeOptions
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
- SystemPromptPart(content=system_state),
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 extract_markdown_toc(agent_mode: AgentType | None) -> str | None:
201
- """Extract table of contents from agent's markdown file.
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
- agent_mode: The agent mode to extract TOC for
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 (up to 2000 chars) or None if not applicable
212
+ Formatted TOC string or None if file doesn't exist
208
213
  """
209
- # Skip for EXPORT mode or no mode
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 = md_path.read_text(encoding="utf-8")
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
- # Join and truncate to 2000 characters
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 {md_file}: {e}")
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=[SystemPromptPart(content=system_prompt_content)]
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[dict[str, Any]] = Field(
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
- ) # Will store serialized ModelMessage objects
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
- get_system_prompt,
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
- system_prompt = ""
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
- system_prompt = get_system_prompt(messages[:last_summary_index]) or ""
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
- # Only add system/user context if it exists and is meaningful
293
- if system_prompt or first_user_prompt:
294
- compacted_messages.append(
295
- ModelRequest(
296
- parts=[
297
- SystemPromptPart(content=system_prompt),
298
- UserPromptPart(content=first_user_prompt),
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
- system_prompt = get_system_prompt(messages) or ""
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
- ModelRequest(
399
- parts=[
400
- SystemPromptPart(content=system_prompt),
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"