|
| 1 | +"""Summarizing conversation history management with configurable options.""" |
| 2 | + |
| 3 | +import logging |
| 4 | +from typing import TYPE_CHECKING, List, Optional |
| 5 | + |
| 6 | +from ...types.content import Message |
| 7 | +from ...types.exceptions import ContextWindowOverflowException |
| 8 | +from .conversation_manager import ConversationManager |
| 9 | + |
| 10 | +if TYPE_CHECKING: |
| 11 | + from ..agent import Agent |
| 12 | + |
| 13 | + |
| 14 | +logger = logging.getLogger(__name__) |
| 15 | + |
| 16 | + |
| 17 | +DEFAULT_SUMMARIZATION_PROMPT = """You are a conversation summarizer. Provide a concise summary of the conversation \ |
| 18 | +history. |
| 19 | +
|
| 20 | +Format Requirements: |
| 21 | +- You MUST create a structured and concise summary in bullet-point format. |
| 22 | +- You MUST NOT respond conversationally. |
| 23 | +- You MUST NOT address the user directly. |
| 24 | +
|
| 25 | +Task: |
| 26 | +Your task is to create a structured summary document: |
| 27 | +- It MUST contain bullet points with key topics and questions covered |
| 28 | +- It MUST contain bullet points for all significant tools executed and their results |
| 29 | +- It MUST contain bullet points for any code or technical information shared |
| 30 | +- It MUST contain a section of key insights gained |
| 31 | +- It MUST format the summary in the third person |
| 32 | +
|
| 33 | +Example format: |
| 34 | +
|
| 35 | +## Conversation Summary |
| 36 | +* Topic 1: Key information |
| 37 | +* Topic 2: Key information |
| 38 | +* |
| 39 | +## Tools Executed |
| 40 | +* Tool X: Result Y""" |
| 41 | + |
| 42 | + |
| 43 | +class SummarizingConversationManager(ConversationManager): |
| 44 | + """Extends sliding window manager with optional summarization of older messages. |
| 45 | +
|
| 46 | + This manager provides a configurable option to summarize older context instead of |
| 47 | + simply trimming it, helping preserve important information while staying within |
| 48 | + context limits. |
| 49 | + """ |
| 50 | + |
| 51 | + def __init__( |
| 52 | + self, |
| 53 | + summary_ratio: float = 0.3, |
| 54 | + preserve_recent_messages: int = 10, |
| 55 | + summarization_agent: Optional["Agent"] = None, |
| 56 | + summarization_system_prompt: Optional[str] = None, |
| 57 | + ): |
| 58 | + """Initialize the summarizing conversation manager. |
| 59 | +
|
| 60 | + Args: |
| 61 | + summary_ratio: Ratio of messages to summarize vs keep when context overflow occurs. |
| 62 | + Value between 0.1 and 0.8. Defaults to 0.3 (summarize 30% of oldest messages). |
| 63 | + preserve_recent_messages: Minimum number of recent messages to always keep. |
| 64 | + Defaults to 10 messages. |
| 65 | + summarization_agent: Optional agent to use for summarization instead of the parent agent. |
| 66 | + If provided, this agent can use tools as part of the summarization process. |
| 67 | + summarization_system_prompt: Optional system prompt override for summarization. |
| 68 | + If None, uses the default summarization prompt. |
| 69 | + """ |
| 70 | + self.summary_ratio = max(0.1, min(0.8, summary_ratio)) |
| 71 | + self.preserve_recent_messages = preserve_recent_messages |
| 72 | + self.summarization_agent = summarization_agent |
| 73 | + self.summarization_system_prompt = summarization_system_prompt |
| 74 | + |
| 75 | + def apply_management(self, agent: "Agent") -> None: |
| 76 | + """Apply management strategy to conversation history. |
| 77 | +
|
| 78 | + For the summarizing conversation manager, no proactive management is performed. |
| 79 | + Summarization only occurs when there's a context overflow that triggers reduce_context. |
| 80 | +
|
| 81 | + Args: |
| 82 | + agent: The agent whose conversation history will be managed. |
| 83 | + The agent's messages list is modified in-place. |
| 84 | + """ |
| 85 | + # No proactive management - summarization only happens on context overflow |
| 86 | + pass |
| 87 | + |
| 88 | + def reduce_context(self, agent: "Agent", e: Optional[Exception] = None) -> None: |
| 89 | + """Reduce context using summarization. |
| 90 | +
|
| 91 | + Args: |
| 92 | + agent: The agent whose conversation history will be reduced. |
| 93 | + The agent's messages list is modified in-place. |
| 94 | + e: The exception that triggered the context reduction, if any. |
| 95 | +
|
| 96 | + Raises: |
| 97 | + ContextWindowOverflowException: If the context cannot be summarized. |
| 98 | + """ |
| 99 | + try: |
| 100 | + # Calculate how many messages to summarize |
| 101 | + messages_to_summarize_count = max(1, int(len(agent.messages) * self.summary_ratio)) |
| 102 | + |
| 103 | + # Ensure we don't summarize recent messages |
| 104 | + messages_to_summarize_count = min( |
| 105 | + messages_to_summarize_count, len(agent.messages) - self.preserve_recent_messages |
| 106 | + ) |
| 107 | + |
| 108 | + if messages_to_summarize_count <= 0: |
| 109 | + raise ContextWindowOverflowException("Cannot summarize: insufficient messages for summarization") from e |
| 110 | + |
| 111 | + # Adjust split point to avoid breaking ToolUse/ToolResult pairs |
| 112 | + messages_to_summarize_count = self._adjust_split_point_for_tool_pairs( |
| 113 | + agent.messages, messages_to_summarize_count |
| 114 | + ) |
| 115 | + |
| 116 | + if messages_to_summarize_count <= 0: |
| 117 | + raise ContextWindowOverflowException("Cannot summarize: insufficient messages for summarization") from e |
| 118 | + |
| 119 | + # Extract messages to summarize |
| 120 | + messages_to_summarize = agent.messages[:messages_to_summarize_count] |
| 121 | + remaining_messages = agent.messages[messages_to_summarize_count:] |
| 122 | + |
| 123 | + # Generate summary |
| 124 | + summary_message = self._generate_summary(messages_to_summarize, agent) |
| 125 | + |
| 126 | + # Replace the summarized messages with the summary |
| 127 | + agent.messages[:] = [summary_message] + remaining_messages |
| 128 | + |
| 129 | + except Exception as summarization_error: |
| 130 | + logger.error("Summarization failed: %s", summarization_error) |
| 131 | + raise |
| 132 | + |
| 133 | + def _generate_summary(self, messages: List[Message], agent: "Agent") -> Message: |
| 134 | + """Generate a summary of the provided messages. |
| 135 | +
|
| 136 | + Args: |
| 137 | + messages: The messages to summarize. |
| 138 | + agent: The agent instance to use for summarization. |
| 139 | +
|
| 140 | + Returns: |
| 141 | + A message containing the conversation summary. |
| 142 | +
|
| 143 | + Raises: |
| 144 | + Exception: If summary generation fails. |
| 145 | + """ |
| 146 | + # Choose which agent to use for summarization |
| 147 | + summarization_agent = self.summarization_agent if self.summarization_agent is not None else agent |
| 148 | + |
| 149 | + # Use custom system prompt if provided, otherwise use default |
| 150 | + system_prompt = ( |
| 151 | + self.summarization_system_prompt |
| 152 | + if self.summarization_system_prompt is not None |
| 153 | + else DEFAULT_SUMMARIZATION_PROMPT |
| 154 | + ) |
| 155 | + |
| 156 | + # Save original system prompt and messages to restore later |
| 157 | + original_system_prompt = summarization_agent.system_prompt |
| 158 | + original_messages = summarization_agent.messages.copy() |
| 159 | + |
| 160 | + try: |
| 161 | + # Temporarily set the system prompt and set formatted messages for summarization |
| 162 | + summarization_agent.system_prompt = system_prompt |
| 163 | + summarization_agent.messages = messages |
| 164 | + |
| 165 | + # Use the agent to generate summary with rich content (can use tools if needed) |
| 166 | + result = summarization_agent("Please summarize this conversation.") |
| 167 | + |
| 168 | + return result.message |
| 169 | + |
| 170 | + finally: |
| 171 | + # Restore original agent state |
| 172 | + summarization_agent.system_prompt = original_system_prompt |
| 173 | + summarization_agent.messages = original_messages |
| 174 | + |
| 175 | + def _adjust_split_point_for_tool_pairs(self, messages: List[Message], split_point: int) -> int: |
| 176 | + """Adjust the split point to avoid breaking ToolUse/ToolResult pairs. |
| 177 | +
|
| 178 | + Args: |
| 179 | + messages: The full list of messages. |
| 180 | + split_point: The initially calculated split point. |
| 181 | +
|
| 182 | + Returns: |
| 183 | + The adjusted split point that doesn't break ToolUse/ToolResult pairs. |
| 184 | + """ |
| 185 | + if split_point >= len(messages): |
| 186 | + return split_point |
| 187 | + |
| 188 | + # Check if the message at split_point is a ToolResult |
| 189 | + if split_point < len(messages): |
| 190 | + message_at_split = messages[split_point] |
| 191 | + if self._message_contains_tool_result(message_at_split): |
| 192 | + # Find the corresponding ToolUse by looking backwards |
| 193 | + for i in range(split_point - 1, -1, -1): |
| 194 | + if self._message_contains_tool_use(messages[i]): |
| 195 | + # Move split point to before the ToolUse |
| 196 | + return i |
| 197 | + # If no ToolUse found, move split point back by 1 |
| 198 | + return max(0, split_point - 1) |
| 199 | + |
| 200 | + return split_point |
| 201 | + |
| 202 | + def _message_contains_tool_use(self, message: Message) -> bool: |
| 203 | + """Check if a message contains a ToolUse.""" |
| 204 | + for content in message.get("content", []): |
| 205 | + if "toolUse" in content: |
| 206 | + return True |
| 207 | + return False |
| 208 | + |
| 209 | + def _message_contains_tool_result(self, message: Message) -> bool: |
| 210 | + """Check if a message contains a ToolResult.""" |
| 211 | + for content in message.get("content", []): |
| 212 | + if "toolResult" in content: |
| 213 | + return True |
| 214 | + return False |
0 commit comments