| 
 | 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 | +    """Implements a summarizing window manager.  | 
 | 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 | +        if summarization_agent is not None and summarization_system_prompt is not None:  | 
 | 71 | +            raise ValueError(  | 
 | 72 | +                "Cannot provide both summarization_agent and summarization_system_prompt. "  | 
 | 73 | +                "Agents come with their own system prompt."  | 
 | 74 | +            )  | 
 | 75 | + | 
 | 76 | +        self.summary_ratio = max(0.1, min(0.8, summary_ratio))  | 
 | 77 | +        self.preserve_recent_messages = preserve_recent_messages  | 
 | 78 | +        self.summarization_agent = summarization_agent  | 
 | 79 | +        self.summarization_system_prompt = summarization_system_prompt  | 
 | 80 | + | 
 | 81 | +    def apply_management(self, agent: "Agent") -> None:  | 
 | 82 | +        """Apply management strategy to conversation history.  | 
 | 83 | +
  | 
 | 84 | +        For the summarizing conversation manager, no proactive management is performed.  | 
 | 85 | +        Summarization only occurs when there's a context overflow that triggers reduce_context.  | 
 | 86 | +
  | 
 | 87 | +        Args:  | 
 | 88 | +            agent: The agent whose conversation history will be managed.  | 
 | 89 | +                The agent's messages list is modified in-place.  | 
 | 90 | +        """  | 
 | 91 | +        # No proactive management - summarization only happens on context overflow  | 
 | 92 | +        pass  | 
 | 93 | + | 
 | 94 | +    def reduce_context(self, agent: "Agent", e: Optional[Exception] = None) -> None:  | 
 | 95 | +        """Reduce context using summarization.  | 
 | 96 | +
  | 
 | 97 | +        Args:  | 
 | 98 | +            agent: The agent whose conversation history will be reduced.  | 
 | 99 | +                The agent's messages list is modified in-place.  | 
 | 100 | +            e: The exception that triggered the context reduction, if any.  | 
 | 101 | +
  | 
 | 102 | +        Raises:  | 
 | 103 | +            ContextWindowOverflowException: If the context cannot be summarized.  | 
 | 104 | +        """  | 
 | 105 | +        try:  | 
 | 106 | +            # Calculate how many messages to summarize  | 
 | 107 | +            messages_to_summarize_count = max(1, int(len(agent.messages) * self.summary_ratio))  | 
 | 108 | + | 
 | 109 | +            # Ensure we don't summarize recent messages  | 
 | 110 | +            messages_to_summarize_count = min(  | 
 | 111 | +                messages_to_summarize_count, len(agent.messages) - self.preserve_recent_messages  | 
 | 112 | +            )  | 
 | 113 | + | 
 | 114 | +            if messages_to_summarize_count <= 0:  | 
 | 115 | +                raise ContextWindowOverflowException("Cannot summarize: insufficient messages for summarization")  | 
 | 116 | + | 
 | 117 | +            # Adjust split point to avoid breaking ToolUse/ToolResult pairs  | 
 | 118 | +            messages_to_summarize_count = self._adjust_split_point_for_tool_pairs(  | 
 | 119 | +                agent.messages, messages_to_summarize_count  | 
 | 120 | +            )  | 
 | 121 | + | 
 | 122 | +            if messages_to_summarize_count <= 0:  | 
 | 123 | +                raise ContextWindowOverflowException("Cannot summarize: insufficient messages for summarization")  | 
 | 124 | + | 
 | 125 | +            # Extract messages to summarize  | 
 | 126 | +            messages_to_summarize = agent.messages[:messages_to_summarize_count]  | 
 | 127 | +            remaining_messages = agent.messages[messages_to_summarize_count:]  | 
 | 128 | + | 
 | 129 | +            # Generate summary  | 
 | 130 | +            summary_message = self._generate_summary(messages_to_summarize, agent)  | 
 | 131 | + | 
 | 132 | +            # Replace the summarized messages with the summary  | 
 | 133 | +            agent.messages[:] = [summary_message] + remaining_messages  | 
 | 134 | + | 
 | 135 | +        except Exception as summarization_error:  | 
 | 136 | +            logger.error("Summarization failed: %s", summarization_error)  | 
 | 137 | +            raise summarization_error from e  | 
 | 138 | + | 
 | 139 | +    def _generate_summary(self, messages: List[Message], agent: "Agent") -> Message:  | 
 | 140 | +        """Generate a summary of the provided messages.  | 
 | 141 | +
  | 
 | 142 | +        Args:  | 
 | 143 | +            messages: The messages to summarize.  | 
 | 144 | +            agent: The agent instance to use for summarization.  | 
 | 145 | +
  | 
 | 146 | +        Returns:  | 
 | 147 | +            A message containing the conversation summary.  | 
 | 148 | +
  | 
 | 149 | +        Raises:  | 
 | 150 | +            Exception: If summary generation fails.  | 
 | 151 | +        """  | 
 | 152 | +        # Choose which agent to use for summarization  | 
 | 153 | +        summarization_agent = self.summarization_agent if self.summarization_agent is not None else agent  | 
 | 154 | + | 
 | 155 | +        # Save original system prompt and messages to restore later  | 
 | 156 | +        original_system_prompt = summarization_agent.system_prompt  | 
 | 157 | +        original_messages = summarization_agent.messages.copy()  | 
 | 158 | + | 
 | 159 | +        try:  | 
 | 160 | +            # Only override system prompt if no agent was provided during initialization  | 
 | 161 | +            if self.summarization_agent is None:  | 
 | 162 | +                # Use custom system prompt if provided, otherwise use default  | 
 | 163 | +                system_prompt = (  | 
 | 164 | +                    self.summarization_system_prompt  | 
 | 165 | +                    if self.summarization_system_prompt is not None  | 
 | 166 | +                    else DEFAULT_SUMMARIZATION_PROMPT  | 
 | 167 | +                )  | 
 | 168 | +                # Temporarily set the system prompt for summarization  | 
 | 169 | +                summarization_agent.system_prompt = system_prompt  | 
 | 170 | +            summarization_agent.messages = messages  | 
 | 171 | + | 
 | 172 | +            # Use the agent to generate summary with rich content (can use tools if needed)  | 
 | 173 | +            result = summarization_agent("Please summarize this conversation.")  | 
 | 174 | + | 
 | 175 | +            return result.message  | 
 | 176 | + | 
 | 177 | +        finally:  | 
 | 178 | +            # Restore original agent state  | 
 | 179 | +            summarization_agent.system_prompt = original_system_prompt  | 
 | 180 | +            summarization_agent.messages = original_messages  | 
 | 181 | + | 
 | 182 | +    def _adjust_split_point_for_tool_pairs(self, messages: List[Message], split_point: int) -> int:  | 
 | 183 | +        """Adjust the split point to avoid breaking ToolUse/ToolResult pairs.  | 
 | 184 | +
  | 
 | 185 | +        Uses the same logic as SlidingWindowConversationManager for consistency.  | 
 | 186 | +
  | 
 | 187 | +        Args:  | 
 | 188 | +            messages: The full list of messages.  | 
 | 189 | +            split_point: The initially calculated split point.  | 
 | 190 | +
  | 
 | 191 | +        Returns:  | 
 | 192 | +            The adjusted split point that doesn't break ToolUse/ToolResult pairs.  | 
 | 193 | +
  | 
 | 194 | +        Raises:  | 
 | 195 | +            ContextWindowOverflowException: If no valid split point can be found.  | 
 | 196 | +        """  | 
 | 197 | +        if split_point > len(messages):  | 
 | 198 | +            raise ContextWindowOverflowException("Split point exceeds message array length")  | 
 | 199 | + | 
 | 200 | +        if split_point == len(messages):  | 
 | 201 | +            return split_point  | 
 | 202 | + | 
 | 203 | +        # Find the next valid split_point  | 
 | 204 | +        while split_point < len(messages):  | 
 | 205 | +            if (  | 
 | 206 | +                # Oldest message cannot be a toolResult because it needs a toolUse preceding it  | 
 | 207 | +                any("toolResult" in content for content in messages[split_point]["content"])  | 
 | 208 | +                or (  | 
 | 209 | +                    # Oldest message can be a toolUse only if a toolResult immediately follows it.  | 
 | 210 | +                    any("toolUse" in content for content in messages[split_point]["content"])  | 
 | 211 | +                    and split_point + 1 < len(messages)  | 
 | 212 | +                    and not any("toolResult" in content for content in messages[split_point + 1]["content"])  | 
 | 213 | +                )  | 
 | 214 | +            ):  | 
 | 215 | +                split_point += 1  | 
 | 216 | +            else:  | 
 | 217 | +                break  | 
 | 218 | +        else:  | 
 | 219 | +            # If we didn't find a valid split_point, then we throw  | 
 | 220 | +            raise ContextWindowOverflowException("Unable to trim conversation context!")  | 
 | 221 | + | 
 | 222 | +        return split_point  | 
0 commit comments