shotgun-sh 0.1.0__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.

Files changed (130) hide show
  1. shotgun/__init__.py +5 -0
  2. shotgun/agents/__init__.py +1 -0
  3. shotgun/agents/agent_manager.py +651 -0
  4. shotgun/agents/common.py +549 -0
  5. shotgun/agents/config/__init__.py +13 -0
  6. shotgun/agents/config/constants.py +17 -0
  7. shotgun/agents/config/manager.py +294 -0
  8. shotgun/agents/config/models.py +185 -0
  9. shotgun/agents/config/provider.py +206 -0
  10. shotgun/agents/conversation_history.py +106 -0
  11. shotgun/agents/conversation_manager.py +105 -0
  12. shotgun/agents/export.py +96 -0
  13. shotgun/agents/history/__init__.py +5 -0
  14. shotgun/agents/history/compaction.py +85 -0
  15. shotgun/agents/history/constants.py +19 -0
  16. shotgun/agents/history/context_extraction.py +108 -0
  17. shotgun/agents/history/history_building.py +104 -0
  18. shotgun/agents/history/history_processors.py +426 -0
  19. shotgun/agents/history/message_utils.py +84 -0
  20. shotgun/agents/history/token_counting.py +429 -0
  21. shotgun/agents/history/token_estimation.py +138 -0
  22. shotgun/agents/messages.py +35 -0
  23. shotgun/agents/models.py +275 -0
  24. shotgun/agents/plan.py +98 -0
  25. shotgun/agents/research.py +108 -0
  26. shotgun/agents/specify.py +98 -0
  27. shotgun/agents/tasks.py +96 -0
  28. shotgun/agents/tools/__init__.py +34 -0
  29. shotgun/agents/tools/codebase/__init__.py +28 -0
  30. shotgun/agents/tools/codebase/codebase_shell.py +256 -0
  31. shotgun/agents/tools/codebase/directory_lister.py +141 -0
  32. shotgun/agents/tools/codebase/file_read.py +144 -0
  33. shotgun/agents/tools/codebase/models.py +252 -0
  34. shotgun/agents/tools/codebase/query_graph.py +67 -0
  35. shotgun/agents/tools/codebase/retrieve_code.py +81 -0
  36. shotgun/agents/tools/file_management.py +218 -0
  37. shotgun/agents/tools/user_interaction.py +37 -0
  38. shotgun/agents/tools/web_search/__init__.py +60 -0
  39. shotgun/agents/tools/web_search/anthropic.py +144 -0
  40. shotgun/agents/tools/web_search/gemini.py +85 -0
  41. shotgun/agents/tools/web_search/openai.py +98 -0
  42. shotgun/agents/tools/web_search/utils.py +20 -0
  43. shotgun/build_constants.py +20 -0
  44. shotgun/cli/__init__.py +1 -0
  45. shotgun/cli/codebase/__init__.py +5 -0
  46. shotgun/cli/codebase/commands.py +202 -0
  47. shotgun/cli/codebase/models.py +21 -0
  48. shotgun/cli/config.py +275 -0
  49. shotgun/cli/export.py +81 -0
  50. shotgun/cli/models.py +10 -0
  51. shotgun/cli/plan.py +73 -0
  52. shotgun/cli/research.py +85 -0
  53. shotgun/cli/specify.py +69 -0
  54. shotgun/cli/tasks.py +78 -0
  55. shotgun/cli/update.py +152 -0
  56. shotgun/cli/utils.py +25 -0
  57. shotgun/codebase/__init__.py +12 -0
  58. shotgun/codebase/core/__init__.py +46 -0
  59. shotgun/codebase/core/change_detector.py +358 -0
  60. shotgun/codebase/core/code_retrieval.py +243 -0
  61. shotgun/codebase/core/ingestor.py +1497 -0
  62. shotgun/codebase/core/language_config.py +297 -0
  63. shotgun/codebase/core/manager.py +1662 -0
  64. shotgun/codebase/core/nl_query.py +331 -0
  65. shotgun/codebase/core/parser_loader.py +128 -0
  66. shotgun/codebase/models.py +111 -0
  67. shotgun/codebase/service.py +206 -0
  68. shotgun/logging_config.py +227 -0
  69. shotgun/main.py +167 -0
  70. shotgun/posthog_telemetry.py +158 -0
  71. shotgun/prompts/__init__.py +5 -0
  72. shotgun/prompts/agents/__init__.py +1 -0
  73. shotgun/prompts/agents/export.j2 +350 -0
  74. shotgun/prompts/agents/partials/codebase_understanding.j2 +87 -0
  75. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +37 -0
  76. shotgun/prompts/agents/partials/content_formatting.j2 +65 -0
  77. shotgun/prompts/agents/partials/interactive_mode.j2 +26 -0
  78. shotgun/prompts/agents/plan.j2 +144 -0
  79. shotgun/prompts/agents/research.j2 +69 -0
  80. shotgun/prompts/agents/specify.j2 +51 -0
  81. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +19 -0
  82. shotgun/prompts/agents/state/system_state.j2 +31 -0
  83. shotgun/prompts/agents/tasks.j2 +143 -0
  84. shotgun/prompts/codebase/__init__.py +1 -0
  85. shotgun/prompts/codebase/cypher_query_patterns.j2 +223 -0
  86. shotgun/prompts/codebase/cypher_system.j2 +28 -0
  87. shotgun/prompts/codebase/enhanced_query_context.j2 +10 -0
  88. shotgun/prompts/codebase/partials/cypher_rules.j2 +24 -0
  89. shotgun/prompts/codebase/partials/graph_schema.j2 +30 -0
  90. shotgun/prompts/codebase/partials/temporal_context.j2 +21 -0
  91. shotgun/prompts/history/__init__.py +1 -0
  92. shotgun/prompts/history/incremental_summarization.j2 +53 -0
  93. shotgun/prompts/history/summarization.j2 +46 -0
  94. shotgun/prompts/loader.py +140 -0
  95. shotgun/py.typed +0 -0
  96. shotgun/sdk/__init__.py +13 -0
  97. shotgun/sdk/codebase.py +219 -0
  98. shotgun/sdk/exceptions.py +17 -0
  99. shotgun/sdk/models.py +189 -0
  100. shotgun/sdk/services.py +23 -0
  101. shotgun/sentry_telemetry.py +87 -0
  102. shotgun/telemetry.py +93 -0
  103. shotgun/tui/__init__.py +0 -0
  104. shotgun/tui/app.py +116 -0
  105. shotgun/tui/commands/__init__.py +76 -0
  106. shotgun/tui/components/prompt_input.py +69 -0
  107. shotgun/tui/components/spinner.py +86 -0
  108. shotgun/tui/components/splash.py +25 -0
  109. shotgun/tui/components/vertical_tail.py +13 -0
  110. shotgun/tui/screens/chat.py +782 -0
  111. shotgun/tui/screens/chat.tcss +43 -0
  112. shotgun/tui/screens/chat_screen/__init__.py +0 -0
  113. shotgun/tui/screens/chat_screen/command_providers.py +219 -0
  114. shotgun/tui/screens/chat_screen/hint_message.py +40 -0
  115. shotgun/tui/screens/chat_screen/history.py +221 -0
  116. shotgun/tui/screens/directory_setup.py +113 -0
  117. shotgun/tui/screens/provider_config.py +221 -0
  118. shotgun/tui/screens/splash.py +31 -0
  119. shotgun/tui/styles.tcss +10 -0
  120. shotgun/tui/utils/__init__.py +5 -0
  121. shotgun/tui/utils/mode_progress.py +257 -0
  122. shotgun/utils/__init__.py +5 -0
  123. shotgun/utils/env_utils.py +35 -0
  124. shotgun/utils/file_system_utils.py +36 -0
  125. shotgun/utils/update_checker.py +375 -0
  126. shotgun_sh-0.1.0.dist-info/METADATA +466 -0
  127. shotgun_sh-0.1.0.dist-info/RECORD +130 -0
  128. shotgun_sh-0.1.0.dist-info/WHEEL +4 -0
  129. shotgun_sh-0.1.0.dist-info/entry_points.txt +2 -0
  130. shotgun_sh-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,106 @@
1
+ """Models and utilities for persisting TUI conversation history."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any, cast
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+ from pydantic_ai.messages import (
8
+ ModelMessage,
9
+ ModelMessagesTypeAdapter,
10
+ )
11
+ from pydantic_core import to_jsonable_python
12
+
13
+ from shotgun.tui.screens.chat_screen.hint_message import HintMessage
14
+
15
+ SerializedMessage = dict[str, Any]
16
+
17
+
18
+ class ConversationState(BaseModel):
19
+ """Represents the complete state of a conversation in memory."""
20
+
21
+ agent_messages: list[ModelMessage]
22
+ ui_messages: list[ModelMessage | HintMessage] = Field(default_factory=list)
23
+ agent_type: str # Will store AgentType.value
24
+
25
+ model_config = ConfigDict(arbitrary_types_allowed=True)
26
+
27
+
28
+ class ConversationHistory(BaseModel):
29
+ """Persistent conversation history for TUI sessions."""
30
+
31
+ version: int = 1
32
+ agent_history: list[SerializedMessage] = Field(
33
+ default_factory=list
34
+ ) # Stores serialized ModelMessage objects
35
+ ui_history: list[SerializedMessage] = Field(
36
+ default_factory=list
37
+ ) # Stores serialized ModelMessage and HintMessage objects
38
+ last_agent_model: str = "research"
39
+ updated_at: datetime = Field(default_factory=datetime.now)
40
+
41
+ model_config = ConfigDict(arbitrary_types_allowed=True)
42
+
43
+ def set_agent_messages(self, messages: list[ModelMessage]) -> None:
44
+ """Set agent_history from a list of ModelMessage objects.
45
+
46
+ Args:
47
+ messages: List of ModelMessage objects to serialize and store
48
+ """
49
+ # Serialize ModelMessage list to JSON-serializable format
50
+ self.agent_history = to_jsonable_python(
51
+ messages, fallback=lambda x: str(x), exclude_none=True
52
+ )
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
+
73
+ def get_agent_messages(self) -> list[ModelMessage]:
74
+ """Get agent_history as a list of ModelMessage objects.
75
+
76
+ Returns:
77
+ List of deserialized ModelMessage objects
78
+ """
79
+ if not self.agent_history:
80
+ return []
81
+
82
+ # Deserialize from JSON format back to ModelMessage objects
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
@@ -0,0 +1,105 @@
1
+ """Manager for handling conversation persistence operations."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from shotgun.logging_config import get_logger
7
+ from shotgun.utils import get_shotgun_home
8
+
9
+ from .conversation_history import ConversationHistory
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ class ConversationManager:
15
+ """Handles saving and loading conversation history."""
16
+
17
+ def __init__(self, conversation_path: Path | None = None):
18
+ """Initialize ConversationManager.
19
+
20
+ Args:
21
+ conversation_path: Path to conversation file.
22
+ If None, uses default ~/.shotgun-sh/conversation.json
23
+ """
24
+ if conversation_path is None:
25
+ self.conversation_path = get_shotgun_home() / "conversation.json"
26
+ else:
27
+ self.conversation_path = conversation_path
28
+
29
+ def save(self, conversation: ConversationHistory) -> None:
30
+ """Save conversation history to file.
31
+
32
+ Args:
33
+ conversation: ConversationHistory to save
34
+ """
35
+ # Ensure directory exists
36
+ self.conversation_path.parent.mkdir(parents=True, exist_ok=True)
37
+
38
+ try:
39
+ # Update timestamp
40
+ from datetime import datetime
41
+
42
+ conversation.updated_at = datetime.now()
43
+
44
+ # Serialize to JSON using Pydantic's model_dump
45
+ data = conversation.model_dump(mode="json")
46
+
47
+ with open(self.conversation_path, "w", encoding="utf-8") as f:
48
+ json.dump(data, f, indent=2, ensure_ascii=False)
49
+
50
+ logger.debug("Conversation saved to %s", self.conversation_path)
51
+
52
+ except Exception as e:
53
+ logger.error(
54
+ "Failed to save conversation to %s: %s", self.conversation_path, e
55
+ )
56
+ # Don't raise - we don't want to interrupt the user's session
57
+
58
+ def load(self) -> ConversationHistory | None:
59
+ """Load conversation history from file.
60
+
61
+ Returns:
62
+ ConversationHistory if file exists and is valid, None otherwise
63
+ """
64
+ if not self.conversation_path.exists():
65
+ logger.debug("No conversation history found at %s", self.conversation_path)
66
+ return None
67
+
68
+ try:
69
+ with open(self.conversation_path, encoding="utf-8") as f:
70
+ data = json.load(f)
71
+
72
+ conversation = ConversationHistory.model_validate(data)
73
+ logger.debug(
74
+ "Conversation loaded from %s with %d agent messages",
75
+ self.conversation_path,
76
+ len(conversation.agent_history),
77
+ )
78
+ return conversation
79
+
80
+ except Exception as e:
81
+ logger.error(
82
+ "Failed to load conversation from %s: %s", self.conversation_path, e
83
+ )
84
+ return None
85
+
86
+ def clear(self) -> None:
87
+ """Delete the conversation history file."""
88
+ if self.conversation_path.exists():
89
+ try:
90
+ self.conversation_path.unlink()
91
+ logger.debug(
92
+ "Conversation history cleared at %s", self.conversation_path
93
+ )
94
+ except Exception as e:
95
+ logger.error(
96
+ "Failed to clear conversation at %s: %s", self.conversation_path, e
97
+ )
98
+
99
+ def exists(self) -> bool:
100
+ """Check if a conversation history file exists.
101
+
102
+ Returns:
103
+ True if conversation file exists, False otherwise
104
+ """
105
+ return self.conversation_path.exists()
@@ -0,0 +1,96 @@
1
+ """Export agent factory and functions using Pydantic AI with file-based memory."""
2
+
3
+ from functools import partial
4
+
5
+ from pydantic_ai import (
6
+ Agent,
7
+ DeferredToolRequests,
8
+ )
9
+ from pydantic_ai.agent import AgentRunResult
10
+ from pydantic_ai.messages import ModelMessage
11
+
12
+ from shotgun.agents.config import ProviderType
13
+ from shotgun.logging_config import get_logger
14
+
15
+ from .common import (
16
+ add_system_status_message,
17
+ build_agent_system_prompt,
18
+ create_base_agent,
19
+ create_usage_limits,
20
+ run_agent,
21
+ )
22
+ from .models import AgentDeps, AgentRuntimeOptions, AgentType
23
+
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ def create_export_agent(
28
+ agent_runtime_options: AgentRuntimeOptions, provider: ProviderType | None = None
29
+ ) -> tuple[Agent[AgentDeps, str | DeferredToolRequests], AgentDeps]:
30
+ """Create an export agent with file management capabilities.
31
+
32
+ Args:
33
+ agent_runtime_options: Agent runtime options for the agent
34
+ provider: Optional provider override. If None, uses configured default
35
+
36
+ Returns:
37
+ Tuple of (Configured Pydantic AI agent for export management, Agent dependencies)
38
+ """
39
+ logger.debug("Initializing export agent")
40
+ # Use partial to create system prompt function for export agent
41
+ system_prompt_fn = partial(build_agent_system_prompt, "export")
42
+
43
+ agent, deps = create_base_agent(
44
+ system_prompt_fn,
45
+ agent_runtime_options,
46
+ provider=provider,
47
+ agent_mode=AgentType.EXPORT,
48
+ )
49
+ return agent, deps
50
+
51
+
52
+ async def run_export_agent(
53
+ agent: Agent[AgentDeps, str | DeferredToolRequests],
54
+ instruction: str,
55
+ deps: AgentDeps,
56
+ message_history: list[ModelMessage] | None = None,
57
+ ) -> AgentRunResult[str | DeferredToolRequests]:
58
+ """Export artifacts based on the given instruction.
59
+
60
+ Args:
61
+ agent: The configured export agent
62
+ instruction: The export instruction
63
+ deps: Agent dependencies
64
+ message_history: Optional message history for conversation continuity
65
+
66
+ Returns:
67
+ AgentRunResult containing the export process output
68
+ """
69
+ logger.debug("📤 Starting export for instruction: %s", instruction)
70
+
71
+ message_history = await add_system_status_message(deps, message_history)
72
+
73
+ # Let the agent use its tools to read existing artifacts and export them
74
+ full_prompt = f"Export artifacts or findings based on: {instruction}"
75
+
76
+ try:
77
+ # Create usage limits for responsible API usage
78
+ usage_limits = create_usage_limits()
79
+
80
+ result = await run_agent(
81
+ agent=agent,
82
+ prompt=full_prompt,
83
+ deps=deps,
84
+ message_history=message_history,
85
+ usage_limits=usage_limits,
86
+ )
87
+
88
+ logger.debug("✅ Export completed successfully")
89
+ return result
90
+
91
+ except Exception as e:
92
+ import traceback
93
+
94
+ logger.error("Full traceback:\n%s", traceback.format_exc())
95
+ logger.error("❌ Export failed: %s", str(e))
96
+ raise
@@ -0,0 +1,5 @@
1
+ """History management utilities for Shotgun agents."""
2
+
3
+ from .history_processors import token_limit_compactor
4
+
5
+ __all__ = ["token_limit_compactor"]
@@ -0,0 +1,85 @@
1
+ """Conversation compaction utilities."""
2
+
3
+ from pydantic_ai.messages import ModelMessage
4
+ from pydantic_ai.usage import RequestUsage
5
+
6
+ from shotgun.agents.models import AgentDeps
7
+ from shotgun.logging_config import get_logger
8
+
9
+ from .token_estimation import estimate_tokens_from_messages
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ async def apply_persistent_compaction(
15
+ messages: list[ModelMessage], deps: AgentDeps
16
+ ) -> list[ModelMessage]:
17
+ """Apply compaction to message history for persistent storage.
18
+
19
+ This ensures that compacted history is actually used as the conversation baseline,
20
+ preventing cascading compaction issues across both CLI and TUI usage patterns.
21
+
22
+ Args:
23
+ messages: Full message history from agent run
24
+ deps: Agent dependencies containing model config
25
+
26
+ Returns:
27
+ Compacted message history that should be stored as conversation state
28
+ """
29
+ from .history_processors import token_limit_compactor
30
+
31
+ try:
32
+ # Count actual token usage using shared utility
33
+ estimated_tokens = estimate_tokens_from_messages(messages, deps.llm_model)
34
+
35
+ # Create minimal usage info for compaction check
36
+ usage = RequestUsage(
37
+ input_tokens=estimated_tokens,
38
+ output_tokens=0,
39
+ )
40
+
41
+ # Create a minimal context object for compaction
42
+ class MockContext:
43
+ def __init__(self, deps: AgentDeps, usage: RequestUsage | None):
44
+ self.deps = deps
45
+ self.usage = usage
46
+
47
+ ctx = MockContext(deps, usage)
48
+ compacted_messages = await token_limit_compactor(ctx, messages)
49
+
50
+ # Log the result for monitoring
51
+ original_size = len(messages)
52
+ compacted_size = len(compacted_messages)
53
+
54
+ if compacted_size < original_size:
55
+ reduction_pct = ((original_size - compacted_size) / original_size) * 100
56
+ logger.debug(
57
+ f"Persistent compaction applied: {original_size} → {compacted_size} messages "
58
+ f"({reduction_pct:.1f}% reduction)"
59
+ )
60
+ else:
61
+ logger.debug(
62
+ f"No persistent compaction needed: {original_size} messages unchanged"
63
+ )
64
+
65
+ return compacted_messages
66
+
67
+ except Exception as e:
68
+ # If compaction fails, return original messages
69
+ # This ensures the system remains functional even if compaction has issues
70
+ logger.warning(f"Persistent compaction failed, using original history: {e}")
71
+ return messages
72
+
73
+
74
+ def should_apply_persistent_compaction(deps: AgentDeps) -> bool:
75
+ """Check if persistent compaction should be applied.
76
+
77
+ Args:
78
+ deps: Agent dependencies
79
+
80
+ Returns:
81
+ True if persistent compaction should be applied
82
+ """
83
+ # For now, always apply persistent compaction
84
+ # Future: Add configuration option in deps or environment variable
85
+ return True
@@ -0,0 +1,19 @@
1
+ """Constants for history processing and compaction."""
2
+
3
+ from enum import Enum
4
+
5
+ # Summary marker for compacted history
6
+ SUMMARY_MARKER = "📌 COMPACTED_HISTORY:"
7
+
8
+ # Token calculation constants
9
+ INPUT_BUFFER_TOKENS = 500
10
+ MIN_SUMMARY_TOKENS = 100
11
+ TOKEN_LIMIT_RATIO = 0.8
12
+
13
+
14
+ class SummaryType(Enum):
15
+ """Types of summarization requests for logging."""
16
+
17
+ INCREMENTAL = "INCREMENTAL"
18
+ FULL = "FULL"
19
+ CONTEXT_EXTRACTION = "CONTEXT_EXTRACTION"
@@ -0,0 +1,108 @@
1
+ """Context extraction utilities for history processing."""
2
+
3
+ from pydantic_ai.messages import (
4
+ BuiltinToolCallPart,
5
+ BuiltinToolReturnPart,
6
+ ModelMessage,
7
+ ModelRequest,
8
+ ModelResponse,
9
+ ModelResponsePart,
10
+ RetryPromptPart,
11
+ SystemPromptPart,
12
+ TextPart,
13
+ ThinkingPart,
14
+ ToolCallPart,
15
+ ToolReturnPart,
16
+ UserPromptPart,
17
+ )
18
+
19
+
20
+ def extract_context_from_messages(messages: list[ModelMessage]) -> str:
21
+ """Extract context from a list of messages for summarization."""
22
+ context = ""
23
+ for msg in messages:
24
+ if isinstance(msg, ModelResponse | ModelRequest):
25
+ for part in msg.parts:
26
+ message_content = extract_context_from_part(part)
27
+ if message_content:
28
+ context += message_content + "\n"
29
+ return context
30
+
31
+
32
+ def extract_context_from_message_range(
33
+ messages: list[ModelMessage],
34
+ start_index: int,
35
+ end_index: int | None = None,
36
+ ) -> str:
37
+ """Extract context from a specific range of messages."""
38
+ if end_index is None:
39
+ end_index = len(messages)
40
+
41
+ message_slice = messages[start_index:end_index]
42
+ return extract_context_from_messages(message_slice)
43
+
44
+
45
+ def has_meaningful_content(messages: list[ModelMessage]) -> bool:
46
+ """Check if messages contain meaningful content worth summarizing.
47
+
48
+ Only ModelResponse messages are considered meaningful for summarization.
49
+ User requests alone don't need summarization.
50
+ """
51
+ for msg in messages:
52
+ if isinstance(msg, ModelResponse):
53
+ for part in msg.parts:
54
+ if extract_context_from_part(part):
55
+ return True
56
+ return False
57
+
58
+
59
+ def extract_context_from_part(
60
+ message_part: (
61
+ SystemPromptPart
62
+ | UserPromptPart
63
+ | ToolReturnPart
64
+ | RetryPromptPart
65
+ | ModelResponsePart
66
+ ),
67
+ ) -> str:
68
+ """Extract context from a single message part."""
69
+ if isinstance(message_part, SystemPromptPart):
70
+ return "" # Exclude system prompts from summary
71
+
72
+ elif isinstance(message_part, UserPromptPart):
73
+ if isinstance(message_part.content, str):
74
+ return f"<USER_PROMPT>\n{message_part.content}\n</USER_PROMPT>"
75
+ return ""
76
+
77
+ elif isinstance(message_part, ToolReturnPart):
78
+ return f"<TOOL_RETURN>\n{str(message_part.content)}\n</TOOL_RETURN>"
79
+
80
+ elif isinstance(message_part, RetryPromptPart):
81
+ if isinstance(message_part.content, str):
82
+ return f"<RETRY_PROMPT>\n{message_part.content}\n</RETRY_PROMPT>"
83
+ return ""
84
+
85
+ # Handle ModelResponsePart types
86
+ elif isinstance(message_part, TextPart):
87
+ return f"<ASSISTANT_TEXT>\n{message_part.content}\n</ASSISTANT_TEXT>"
88
+
89
+ elif isinstance(message_part, ToolCallPart):
90
+ if isinstance(message_part.args, dict):
91
+ args_str = ", ".join(f"{k}={repr(v)}" for k, v in message_part.args.items())
92
+ tool_call_str = f"{message_part.tool_name}({args_str})"
93
+ else:
94
+ tool_call_str = f"{message_part.tool_name}({message_part.args})"
95
+ return f"<TOOL_CALL>\n{tool_call_str}\n</TOOL_CALL>"
96
+
97
+ elif isinstance(message_part, BuiltinToolCallPart):
98
+ return f"<BUILTIN_TOOL_CALL>\n{message_part.tool_name}\n</BUILTIN_TOOL_CALL>"
99
+
100
+ elif isinstance(message_part, BuiltinToolReturnPart):
101
+ return (
102
+ f"<BUILTIN_TOOL_RETURN>\n{message_part.tool_name}\n</BUILTIN_TOOL_RETURN>"
103
+ )
104
+
105
+ elif isinstance(message_part, ThinkingPart):
106
+ return f"<THINKING>\n{message_part.content}\n</THINKING>"
107
+
108
+ return ""
@@ -0,0 +1,104 @@
1
+ """Functions for building compacted message history."""
2
+
3
+ from pydantic_ai.messages import (
4
+ ModelMessage,
5
+ ModelRequest,
6
+ ModelRequestPart,
7
+ ModelResponse,
8
+ SystemPromptPart,
9
+ TextPart,
10
+ UserPromptPart,
11
+ )
12
+
13
+ from .message_utils import (
14
+ get_first_user_request,
15
+ get_last_user_request,
16
+ get_system_prompt,
17
+ get_user_content_from_request,
18
+ )
19
+
20
+
21
+ def build_clean_compacted_history(
22
+ summary_part: TextPart,
23
+ all_messages: list[ModelMessage],
24
+ last_summary_index: int | None = None,
25
+ ) -> list[ModelMessage]:
26
+ """Build a clean compacted history without preserving old verbose content.
27
+
28
+ Args:
29
+ summary_part: The marked summary part to include
30
+ all_messages: Original message history
31
+ last_summary_index: Index of the last summary (if any)
32
+
33
+ Returns:
34
+ Clean compacted message history
35
+ """
36
+ # Extract essential context from pre-summary messages (if any)
37
+ system_prompt = ""
38
+ first_user_prompt = ""
39
+
40
+ if last_summary_index is not None and last_summary_index > 0:
41
+ # Get system and first user from original conversation
42
+ pre_summary_messages = all_messages[:last_summary_index]
43
+ system_prompt = get_system_prompt(pre_summary_messages) or ""
44
+ first_user_prompt = get_first_user_request(pre_summary_messages) or ""
45
+
46
+ # Build the base structure
47
+ compacted_messages: list[ModelMessage] = []
48
+
49
+ # Add system/user context if it exists and is meaningful
50
+ if system_prompt or first_user_prompt:
51
+ compacted_messages.append(
52
+ _create_base_request(system_prompt, first_user_prompt)
53
+ )
54
+
55
+ # Add the summary
56
+ summary_message = ModelResponse(parts=[summary_part])
57
+ compacted_messages.append(summary_message)
58
+
59
+ # Ensure proper ending
60
+ return ensure_ends_with_model_request(compacted_messages, all_messages)
61
+
62
+
63
+ def ensure_ends_with_model_request(
64
+ compacted_messages: list[ModelMessage],
65
+ original_messages: list[ModelMessage],
66
+ ) -> list[ModelMessage]:
67
+ """Ensure the message history ends with ModelRequest for PydanticAI compatibility."""
68
+ last_user_request = get_last_user_request(original_messages)
69
+
70
+ if not last_user_request:
71
+ return compacted_messages
72
+
73
+ # Check if we need to add the last request or restructure
74
+ if compacted_messages and isinstance(compacted_messages[0], ModelRequest):
75
+ first_request = compacted_messages[0]
76
+ last_user_content = get_user_content_from_request(last_user_request)
77
+ first_user_content = get_user_content_from_request(first_request)
78
+
79
+ if last_user_content != first_user_content:
80
+ # Different messages - append the last request
81
+ compacted_messages.append(last_user_request)
82
+ else:
83
+ # Same message - restructure to end with ModelRequest
84
+ if len(compacted_messages) >= 2:
85
+ summary_message = compacted_messages[1] # The summary
86
+ compacted_messages = [summary_message, first_request]
87
+ else:
88
+ # No first request, just add the last one
89
+ compacted_messages.append(last_user_request)
90
+
91
+ return compacted_messages
92
+
93
+
94
+ def _create_base_request(system_prompt: str, user_prompt: str) -> ModelRequest:
95
+ """Create the base ModelRequest with system and user prompts."""
96
+ parts: list[ModelRequestPart] = []
97
+
98
+ if system_prompt:
99
+ parts.append(SystemPromptPart(content=system_prompt))
100
+
101
+ if user_prompt:
102
+ parts.append(UserPromptPart(content=user_prompt))
103
+
104
+ return ModelRequest(parts=parts)