"""Microcompact service — lightweight tool-result clearing for context efficiency. Mirrors the npm ``src/services/compact/microCompact.ts`` module. Provides time-based microcompaction: when a significant gap exists since the last assistant message (indicating cache has expired), old tool results are replaced with a short cleared marker to reduce context size on the next API call. """ from __future__ import annotations import time from dataclasses import dataclass, field from typing import Any from .agent_context_usage import estimate_tokens from .agent_session import AgentMessage # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- TIME_BASED_MC_CLEARED_MESSAGE = '[Old tool result content cleared]' """Replacement content for cleared tool results.""" IMAGE_MAX_TOKEN_SIZE = 2000 """Fixed token estimate for image/document blocks.""" # Tools whose results can be safely cleared during microcompaction. COMPACTABLE_TOOLS: frozenset[str] = frozenset({ 'read_file', 'bash', 'grep_search', 'glob_search', 'web_search', 'web_fetch', 'edit_file', 'write_file', }) DEFAULT_GAP_THRESHOLD_MINUTES = 60.0 """Minimum gap (in minutes) since the last assistant message before time-based microcompact fires. Mirrors the npm default.""" DEFAULT_KEEP_RECENT = 3 """Number of most-recent compactable tool results to always preserve.""" # --------------------------------------------------------------------------- # Result type # --------------------------------------------------------------------------- @dataclass class MicrocompactResult: """Outcome of a microcompact pass.""" messages: list[AgentMessage] cleared_tool_count: int = 0 kept_tool_count: int = 0 estimated_tokens_saved: int = 0 triggered: bool = False gap_minutes: float = 0.0 # --------------------------------------------------------------------------- # Time-based microcompact # --------------------------------------------------------------------------- def _find_last_assistant_timestamp(messages: list[AgentMessage]) -> float | None: """Find the timestamp of the last assistant message. Returns seconds since epoch, or ``None`` if no assistant message has a ``timestamp`` metadata entry. """ for msg in reversed(messages): if msg.role != 'assistant': continue ts = msg.metadata.get('timestamp') if isinstance(ts, (int, float)): return float(ts) if isinstance(ts, str): try: from datetime import datetime, timezone dt = datetime.fromisoformat(ts.replace('Z', '+00:00')) return dt.timestamp() except (ValueError, TypeError): pass # Fall back to message creation time tracked by the session created = msg.metadata.get('created_at') if isinstance(created, (int, float)): return float(created) return None def _collect_compactable_tool_ids( messages: list[AgentMessage], ) -> list[str]: """Collect tool_call_ids for tool results that can be safely cleared. Walks messages in order and returns IDs for tool-result messages whose name is in :data:`COMPACTABLE_TOOLS`. """ ids: list[str] = [] for msg in messages: if msg.role != 'tool': continue if not msg.tool_call_id: continue tool_name = msg.name or msg.metadata.get('tool_name', '') if tool_name in COMPACTABLE_TOOLS: ids.append(msg.tool_call_id) return ids def evaluate_time_based_trigger( messages: list[AgentMessage], *, gap_threshold_minutes: float = DEFAULT_GAP_THRESHOLD_MINUTES, ) -> float | None: """Return the gap in minutes since the last assistant message, or ``None``. Returns ``None`` when the trigger does not fire (gap below threshold, no assistant messages, etc.). """ ts = _find_last_assistant_timestamp(messages) if ts is None: return None gap_seconds = time.time() - ts if gap_seconds < 0: return None gap_minutes = gap_seconds / 60.0 if gap_minutes < gap_threshold_minutes: return None return gap_minutes def microcompact_messages( messages: list[AgentMessage], *, model: str = '', gap_threshold_minutes: float = DEFAULT_GAP_THRESHOLD_MINUTES, keep_recent: int = DEFAULT_KEEP_RECENT, ) -> MicrocompactResult: """Run time-based microcompaction on session messages. When the gap since the last assistant message exceeds *gap_threshold_minutes*, old tool results (beyond the *keep_recent* most recent) are replaced with a short cleared marker. This is useful when the server-side prompt cache has expired and the entire prefix will be rewritten anyway — clearing old tool results shrinks the rewrite payload. Parameters ---------- messages: The session messages to process (not mutated — new list returned). model: Model name for token estimation. gap_threshold_minutes: Minimum idle gap before trigger fires. keep_recent: Number of most-recent compactable tool results to keep. Returns ------- MicrocompactResult A result containing the (possibly modified) message list and diagnostic counters. """ gap_minutes = evaluate_time_based_trigger( messages, gap_threshold_minutes=gap_threshold_minutes, ) if gap_minutes is None: return MicrocompactResult(messages=messages) # Collect compactable tool IDs in order compactable_ids = _collect_compactable_tool_ids(messages) if not compactable_ids: return MicrocompactResult(messages=messages, gap_minutes=gap_minutes) # Keep the most recent `keep_recent` tools untouched keep_count = max(1, keep_recent) if len(compactable_ids) <= keep_count: return MicrocompactResult( messages=messages, kept_tool_count=len(compactable_ids), gap_minutes=gap_minutes, ) clear_ids = set(compactable_ids[:-keep_count]) keep_ids = set(compactable_ids[-keep_count:]) # Build a new message list with cleared tool results new_messages: list[AgentMessage] = [] tokens_saved = 0 cleared_count = 0 for msg in messages: if ( msg.role == 'tool' and msg.tool_call_id and msg.tool_call_id in clear_ids ): original_tokens = estimate_tokens(msg.content, model) replacement_tokens = estimate_tokens(TIME_BASED_MC_CLEARED_MESSAGE, model) tokens_saved += max(original_tokens - replacement_tokens, 0) cleared_count += 1 # Create a new message with cleared content new_msg = AgentMessage( role=msg.role, content=TIME_BASED_MC_CLEARED_MESSAGE, name=msg.name, tool_call_id=msg.tool_call_id, message_id=msg.message_id, metadata={ **msg.metadata, 'microcompact_cleared': True, 'original_token_estimate': original_tokens, }, ) new_messages.append(new_msg) else: new_messages.append(msg) return MicrocompactResult( messages=new_messages, cleared_tool_count=cleared_count, kept_tool_count=len(keep_ids), estimated_tokens_saved=tokens_saved, triggered=cleared_count > 0, gap_minutes=gap_minutes, )