"""Conversation compaction service. Mirrors the npm ``src/services/compact/compact.ts`` and ``src/services/compact/prompt.ts`` modules. Provides: - The 9-section summarisation prompt (``get_compact_prompt``). - XML-tag formatting/stripping (``format_compact_summary``). - The post-compact user summary message builder (``get_compact_user_summary_message``). - The core ``compact_conversation`` entry point that an ``/compact`` slash command or auto-compact subsystem can call. - PTL retry loop: drops oldest API-round groups when the compact request itself hits prompt-too-long (up to ``MAX_PTL_RETRIES``). - Circuit-breaker tracking for consecutive failures. """ from __future__ import annotations import re from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any from .agent_context_usage import estimate_tokens from .agent_types import UsageStats from .agent_session import AgentMessage if TYPE_CHECKING: from .agent_runtime import LocalCodingAgent # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- AUTOCOMPACT_BUFFER_TOKENS = 13_000 """How many tokens to reserve below the effective context window before auto-compact fires (same as the npm ``AUTOCOMPACT_BUFFER_TOKENS``).""" ERROR_NOT_ENOUGH_MESSAGES = 'Not enough messages to compact.' ERROR_INCOMPLETE_RESPONSE = ( 'The summary response was incomplete. ' 'The conversation was not compacted.' ) ERROR_USER_ABORT = 'Compaction canceled.' ERROR_PROMPT_TOO_LONG = ( 'The compact request itself was too long even after retry truncation.' ) MAX_COMPACT_FAILURES = 3 """Circuit-breaker – stop retrying auto-compact after this many consecutive failures (mirrors the npm implementation).""" MAX_PTL_RETRIES = 3 """Maximum number of prompt-too-long retry attempts during compaction.""" PTL_RETRY_MARKER = '[compact_ptl_retry_marker]' """Synthetic user message prepended when dropping the first API-round group leaves an assistant message at position 0.""" # --------------------------------------------------------------------------- # Prompt construction (npm ``src/services/compact/prompt.ts``) # --------------------------------------------------------------------------- _NO_TOOLS_PREAMBLE = """\ CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. - Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool. - You already have all the context you need in the conversation above. - Tool calls will be REJECTED and will waste your only turn — you will fail the task. - Your entire response must be plain text: an block followed by a block. """ _DETAILED_ANALYSIS_INSTRUCTION = """\ Before providing your final summary, wrap your analysis in tags to \ organize your thoughts and ensure you've covered all necessary points. In your \ analysis process: 1. Chronologically analyze each message and section of the conversation. \ For each section thoroughly identify: - The user's explicit requests and intents - Your approach to addressing the user's requests - Key decisions, technical concepts and code patterns - Specific details like: - file names - full code snippets - function signatures - file edits - Errors that you ran into and how you fixed them - Pay special attention to specific user feedback that you received, \ especially if the user told you to do something differently. 2. Double-check for technical accuracy and completeness, addressing each \ required element thoroughly.""" _BASE_COMPACT_PROMPT = f"""\ Your task is to create a detailed summary of the conversation so far, paying \ close attention to the user's explicit requests and your previous actions. This summary should be thorough in capturing technical details, code patterns, \ and architectural decisions that would be essential for continuing development \ work without losing context. {_DETAILED_ANALYSIS_INSTRUCTION} Your summary should include the following sections: 1. Primary Request and Intent: Capture all of the user's explicit requests \ and intents in detail 2. Key Technical Concepts: List all important technical concepts, technologies, \ and frameworks discussed. 3. Files and Code Sections: Enumerate specific files and code sections examined, \ modified, or created. Pay special attention to the most recent messages and \ include full code snippets where applicable and include a summary of why this \ file read or edit is important. 4. Errors and fixes: List all errors that you ran into, and how you fixed them. \ Pay special attention to specific user feedback that you received, especially if \ the user told you to do something differently. 5. Problem Solving: Document problems solved and any ongoing troubleshooting \ efforts. 6. All user messages: List ALL user messages that are not tool results. These \ are critical for understanding the users' feedback and changing intent. 7. Pending Tasks: Outline any pending tasks that you have explicitly been asked \ to work on. 8. Current Work: Describe in detail precisely what was being worked on \ immediately before this summary request, paying special attention to the most \ recent messages from both user and assistant. Include file names and code \ snippets where applicable. 9. Optional Next Step: List the next step that you will take that is related to \ the most recent work you were doing. IMPORTANT: ensure that this step is \ DIRECTLY in line with the user's most recent explicit requests, and the task \ you were working on immediately before this summary request. If your last task \ was concluded, then only list next steps if they are explicitly in line with the \ users request. Do not start on tangential requests or really old requests that \ were already completed without confirming with the user first. If there is a next step, include direct quotes from the \ most recent conversation showing exactly what task you were working on and where \ you left off. This should be verbatim to ensure there's no drift in task \ interpretation. Here's an example of how your output should be structured: [Your thought process, ensuring all points are covered thoroughly and accurately] 1. Primary Request and Intent: [Detailed description] 2. Key Technical Concepts: - [Concept 1] - [Concept 2] - [...] 3. Files and Code Sections: - [File Name 1] - [Summary of why this file is important] - [Summary of the changes made to this file, if any] - [Important Code Snippet] - [File Name 2] - [Important Code Snippet] - [...] 4. Errors and fixes: - [Detailed description of error 1]: - [How you fixed the error] - [User feedback on the error if any] - [...] 5. Problem Solving: [Description of solved problems and ongoing troubleshooting] 6. All user messages: - [Detailed non tool use user message] - [...] 7. Pending Tasks: - [Task 1] - [Task 2] - [...] 8. Current Work: [Precise description of current work] 9. Optional Next Step: [Optional Next step to take] Please provide your summary based on the conversation so far, following this \ structure and ensuring precision and thoroughness in your response. There may be additional summarization instructions provided in the included \ context. If so, remember to follow these instructions when creating the above \ summary. Examples of instructions include: ## Compact Instructions When summarizing the conversation focus on typescript code changes and also \ remember the mistakes you made and how you fixed them. # Summary instructions When you are using compact - please focus on test output and code changes. \ Include file reads verbatim. """ _NO_TOOLS_TRAILER = ( '\n\nREMINDER: Do NOT call any tools. Respond with plain text only — ' 'an block followed by a block. ' 'Tool calls will be rejected and you will fail the task.' ) def get_compact_prompt(custom_instructions: str | None = None) -> str: """Build the full compact prompt, optionally appending user instructions.""" prompt = _NO_TOOLS_PREAMBLE + _BASE_COMPACT_PROMPT if custom_instructions and custom_instructions.strip(): prompt += f'\n\nAdditional Instructions:\n{custom_instructions}' prompt += _NO_TOOLS_TRAILER return prompt # --------------------------------------------------------------------------- # Summary formatting # --------------------------------------------------------------------------- def format_compact_summary(summary: str) -> str: """Strip the ```` scratchpad and unwrap ```` tags. Mirrors the npm ``formatCompactSummary`` helper. """ formatted = re.sub(r'[\s\S]*?', '', summary) match = re.search(r'([\s\S]*?)', formatted) if match: content = match.group(1).strip() formatted = re.sub( r'[\s\S]*?', f'Summary:\n{content}', formatted, ) # Collapse runs of blank lines. formatted = re.sub(r'\n\n+', '\n\n', formatted) return formatted.strip() def get_compact_user_summary_message( summary: str, *, suppress_follow_up: bool = False, transcript_path: str | None = None, ) -> str: """Build the user-facing summary that replaces compacted messages. Mirrors the npm ``getCompactUserSummaryMessage`` helper. """ formatted = format_compact_summary(summary) base = ( 'This session is being continued from a previous conversation that ' 'ran out of context. The summary below covers the earlier portion ' f'of the conversation.\n\n{formatted}' ) if transcript_path: base += ( '\n\nIf you need specific details from before compaction ' '(like exact code snippets, error messages, or content you ' 'generated), read the full transcript at: ' f'{transcript_path}' ) if suppress_follow_up: base += ( '\nContinue the conversation from where it left off without ' 'asking the user any further questions. Resume directly — do ' 'not acknowledge the summary, do not recap what was happening, ' 'do not preface with "I\'ll continue" or similar. Pick up the ' 'last task as if the break never happened.' ) return base # --------------------------------------------------------------------------- # API-round grouping (mirrors npm groupMessagesByApiRound) # --------------------------------------------------------------------------- def group_messages_by_api_round( messages: list[AgentMessage], ) -> list[list[AgentMessage]]: """Group messages at API-round boundaries. A new group starts when an assistant message with a different ``message_id`` than the previous assistant message is encountered. """ groups: list[list[AgentMessage]] = [] current: list[AgentMessage] = [] last_assistant_id: str | None = None for msg in messages: if ( msg.role == 'assistant' and msg.message_id != last_assistant_id and current ): groups.append(current) current = [msg] else: current.append(msg) if msg.role == 'assistant': last_assistant_id = msg.message_id if current: groups.append(current) return groups def truncate_head_for_ptl_retry( messages: list[AgentMessage], model: str = '', token_gap: int | None = None, ) -> list[AgentMessage] | None: """Drop oldest API-round groups to free space for the compact request. If *token_gap* is provided, drops enough groups to cover that many tokens. Otherwise falls back to dropping ~20% of groups. Returns ``None`` if nothing can be dropped without emptying the list. """ # Strip a previous PTL_RETRY_MARKER to prevent stalling working = list(messages) if ( working and working[0].role == 'user' and working[0].content == PTL_RETRY_MARKER ): working = working[1:] groups = group_messages_by_api_round(working) if len(groups) < 2: return None if token_gap is not None and token_gap > 0: accumulated = 0 drop_count = 0 for group in groups: group_tokens = sum(estimate_tokens(m.content, model) for m in group) accumulated += group_tokens drop_count += 1 if accumulated >= token_gap: break else: # Fallback: drop ~20% of groups drop_count = max(1, len(groups) // 5) drop_count = min(drop_count, len(groups) - 1) if drop_count < 1: return None remaining: list[AgentMessage] = [] for group in groups[drop_count:]: remaining.extend(group) if not remaining: return None # If the first remaining message is an assistant message, prepend a # synthetic user marker so the API contract is satisfied. if remaining[0].role == 'assistant': marker = AgentMessage( role='user', content=PTL_RETRY_MARKER, message_id='ptl_retry_marker', metadata={'kind': 'ptl_retry_marker', 'is_meta': True}, ) remaining = [marker] + remaining return remaining # --------------------------------------------------------------------------- # Compaction result # --------------------------------------------------------------------------- @dataclass class CompactionResult: """Outcome of a ``compact_conversation`` call.""" boundary_message: AgentMessage summary_messages: list[AgentMessage] = field(default_factory=list) messages_to_keep: list[AgentMessage] = field(default_factory=list) pre_compact_token_count: int = 0 post_compact_token_count: int = 0 true_post_compact_token_count: int = 0 summary_text: str = '' usage: UsageStats = field(default_factory=UsageStats) error: str | None = None ptl_retries: int = 0 # --------------------------------------------------------------------------- # Core compaction logic # --------------------------------------------------------------------------- def _is_prompt_too_long_response(content: str) -> bool: """Check if a model response indicates a prompt-too-long error. Some models embed the error in the response text rather than raising. """ lower = content.lower() return ( 'prompt is too long' in lower or 'prompt_too_long' in lower or 'context_length_exceeded' in lower ) def _call_compact_model( agent: 'LocalCodingAgent', api_messages: list[dict[str, Any]], ) -> tuple[str | None, 'UsageStats', str | None]: """Call the model for compaction, returning (content, usage, error). Returns (None, usage, error_string) on failure. """ try: turn = agent.client.complete(api_messages, tools=[]) except Exception as exc: error_str = str(exc) if 'prompt' in error_str.lower() and 'long' in error_str.lower(): return None, UsageStats(), 'prompt_too_long' return None, UsageStats(), error_str raw = turn.content or '' if not raw.strip(): return None, turn.usage, 'empty_response' if _is_prompt_too_long_response(raw): return None, turn.usage, 'prompt_too_long' return raw, turn.usage, None def compact_conversation( agent: 'LocalCodingAgent', custom_instructions: str | None = None, ) -> CompactionResult: """Perform conversation compaction. Tries session-memory-based compaction first (free, no API call), then falls back to LLM-backed compaction. 1. If no custom instructions, try session memory compact. 2. Otherwise, build the compact prompt (9-section template). 3. Collect the session messages to summarise. 4. Send them + the compact prompt to the model. 5. On prompt-too-long, retry by dropping oldest API-round groups (up to ``MAX_PTL_RETRIES`` attempts). 6. Parse ```` from the response. 7. Replace session messages with: boundary marker → summary user message → preserved tail. Returns a :class:`CompactionResult` with diagnostics. """ session = agent.last_session if session is None or len(session.messages) == 0: return CompactionResult( boundary_message=_build_boundary('No session to compact.'), error=ERROR_NOT_ENOUGH_MESSAGES, ) # --- Try session-memory-based compact first (no API call) --- if custom_instructions is None: from .session_memory_compact import try_session_memory_compaction last_summarized_id = getattr(agent, '_last_summarized_message_id', None) sm_result = try_session_memory_compaction( messages=list(session.messages), model=agent.model_config.model, last_summarized_message_id=last_summarized_id, ) if sm_result is not None: # Apply the session-memory compaction to the session prefix_count = 0 for msg in session.messages: if msg.metadata.get('kind') == 'compact_boundary': prefix_count += 1 else: break session.messages = ( session.messages[:prefix_count] + [sm_result.boundary_message] + sm_result.summary_messages + sm_result.messages_to_keep ) # Reset the summarized ID agent._last_summarized_message_id = None return sm_result # ---- Determine which messages to compact vs preserve ---- preserve_count = max( getattr(agent.runtime_config, 'compact_preserve_messages', 4), 1 ) prefix_count = 0 for msg in session.messages: if msg.metadata.get('kind') == 'compact_boundary': prefix_count += 1 else: break total = len(session.messages) tail_count = min(preserve_count, max(total - prefix_count, 0)) compact_end = total - tail_count if compact_end <= prefix_count: return CompactionResult( boundary_message=_build_boundary('Not enough messages after prefix.'), error=ERROR_NOT_ENOUGH_MESSAGES, ) candidates = list(session.messages[prefix_count:compact_end]) preserved_tail = list(session.messages[compact_end:]) if not candidates: return CompactionResult( boundary_message=_build_boundary('Nothing to compact.'), error=ERROR_NOT_ENOUGH_MESSAGES, ) # ---- Estimate pre-compact token count ---- model = agent.model_config.model pre_tokens = sum(estimate_tokens(m.content, model) for m in session.messages) # ---- Build the compact request ---- compact_prompt = get_compact_prompt(custom_instructions) # ---- PTL retry loop ---- messages_to_summarize = candidates ptl_retries = 0 total_usage = UsageStats() raw_summary: str | None = None for attempt in range(MAX_PTL_RETRIES + 1): api_messages: list[dict[str, Any]] = [] for part in session.system_prompt_parts: if part.strip(): api_messages.append({'role': 'system', 'content': part}) for msg in messages_to_summarize: api_messages.append(msg.to_openai_message()) api_messages.append({'role': 'user', 'content': compact_prompt}) content, usage, error = _call_compact_model(agent, api_messages) total_usage = total_usage + usage if error != 'prompt_too_long': raw_summary = content break # PTL error — try truncating oldest API-round groups ptl_retries += 1 if attempt >= MAX_PTL_RETRIES: return CompactionResult( boundary_message=_build_boundary( f'Compact request was too long after {ptl_retries} retries.' ), error=ERROR_PROMPT_TOO_LONG, usage=total_usage, ptl_retries=ptl_retries, ) truncated = truncate_head_for_ptl_retry( messages_to_summarize, model=model, ) if truncated is None: return CompactionResult( boundary_message=_build_boundary( 'Cannot truncate further for compact retry.' ), error=ERROR_PROMPT_TOO_LONG, usage=total_usage, ptl_retries=ptl_retries, ) messages_to_summarize = truncated if raw_summary is None: error_msg = error or ERROR_INCOMPLETE_RESPONSE return CompactionResult( boundary_message=_build_boundary(f'Compaction failed: {error_msg}'), error=error_msg, usage=total_usage, ptl_retries=ptl_retries, ) # ---- Format the summary ---- summary_text = format_compact_summary(raw_summary) user_summary_content = get_compact_user_summary_message(raw_summary) # ---- Build post-compact messages ---- boundary = _build_boundary( f'Earlier conversation ({len(candidates)} messages, ~{pre_tokens} tokens) ' f'was compacted.', ) summary_msg = AgentMessage( role='user', content=user_summary_content, message_id='compact_summary', metadata={'kind': 'compact_summary', 'is_compact_summary': True}, ) # Replace session messages in-place session.messages = ( session.messages[:prefix_count] + [boundary, summary_msg] + preserved_tail ) # ---- Post-compact token estimate ---- post_tokens = sum(estimate_tokens(m.content, model) for m in session.messages) return CompactionResult( boundary_message=boundary, summary_messages=[summary_msg], messages_to_keep=preserved_tail, pre_compact_token_count=pre_tokens, post_compact_token_count=post_tokens, true_post_compact_token_count=sum( estimate_tokens(m.content, model) for m in [boundary, summary_msg] + preserved_tail ), summary_text=summary_text, usage=total_usage, ptl_retries=ptl_retries, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _build_boundary(note: str) -> AgentMessage: """Create a compact-boundary system message.""" return AgentMessage( role='user', content=f'\n{note}\n', message_id='compact_boundary', metadata={'kind': 'compact_boundary'}, )