Files
zk-data-agent/src/microcompact.py
T
2026-04-15 02:29:22 +02:00

237 lines
7.5 KiB
Python

"""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,
)