Spaces:
Build error
feat(router): adaptive compression with Read lifecycle and context-pressure scaling
Browse filesEnable ReadLifecycle by default so stale/superseded Read outputs are
automatically replaced with compact CCR markers — these are provably safe
to compress (file was edited or re-read).
Replace static compression thresholds with adaptive parameters that scale
with conversation length and context pressure:
- protect_recent_reads_fraction: protects the most-recent 50% of messages
from Read exclusion. Old Reads beyond this window become compressible,
preventing the "28 excluded Read/Glob, 0 tokens saved" problem.
- min_ratio_relaxed / min_ratio_aggressive: compression acceptance
threshold interpolates linearly with context pressure (tokens / model
limit). Low pressure → 0.85 (picky), high pressure → 0.65 (accept
anything helpful). Eliminates the fixed 0.9 gate that was rejecting
20+ messages per request.
Also adds --no-read-lifecycle CLI flag, and fixes a missing
pytest.importorskip guard for sentence-transformers in memory tests.
|
@@ -29,6 +29,12 @@ from .main import main
|
|
| 29 |
)
|
| 30 |
# Code-aware compression (ON by default if installed)
|
| 31 |
@click.option("--no-code-aware", is_flag=True, help="Disable AST-based code compression")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
# Intelligent Context Management (ON by default)
|
| 33 |
@click.option(
|
| 34 |
"--no-intelligent-context",
|
|
@@ -110,6 +116,7 @@ def proxy(
|
|
| 110 |
llmlingua_device: str,
|
| 111 |
llmlingua_rate: float,
|
| 112 |
no_code_aware: bool,
|
|
|
|
| 113 |
no_intelligent_context: bool,
|
| 114 |
no_intelligent_scoring: bool,
|
| 115 |
no_compress_first: bool,
|
|
@@ -162,6 +169,8 @@ def proxy(
|
|
| 162 |
llmlingua_target_rate=llmlingua_rate,
|
| 163 |
# Code-aware: ON by default (use --no-code-aware to disable)
|
| 164 |
code_aware_enabled=not no_code_aware,
|
|
|
|
|
|
|
| 165 |
# Intelligent Context: ON by default (use --no-intelligent-context to disable)
|
| 166 |
intelligent_context=not no_intelligent_context,
|
| 167 |
intelligent_context_scoring=not no_intelligent_scoring,
|
|
|
|
| 29 |
)
|
| 30 |
# Code-aware compression (ON by default if installed)
|
| 31 |
@click.option("--no-code-aware", is_flag=True, help="Disable AST-based code compression")
|
| 32 |
+
# Read lifecycle (ON by default: compresses stale/superseded Read outputs)
|
| 33 |
+
@click.option(
|
| 34 |
+
"--no-read-lifecycle",
|
| 35 |
+
is_flag=True,
|
| 36 |
+
help="Disable Read lifecycle management (stale/superseded Read compression)",
|
| 37 |
+
)
|
| 38 |
# Intelligent Context Management (ON by default)
|
| 39 |
@click.option(
|
| 40 |
"--no-intelligent-context",
|
|
|
|
| 116 |
llmlingua_device: str,
|
| 117 |
llmlingua_rate: float,
|
| 118 |
no_code_aware: bool,
|
| 119 |
+
no_read_lifecycle: bool,
|
| 120 |
no_intelligent_context: bool,
|
| 121 |
no_intelligent_scoring: bool,
|
| 122 |
no_compress_first: bool,
|
|
|
|
| 169 |
llmlingua_target_rate=llmlingua_rate,
|
| 170 |
# Code-aware: ON by default (use --no-code-aware to disable)
|
| 171 |
code_aware_enabled=not no_code_aware,
|
| 172 |
+
# Read lifecycle: ON by default (use --no-read-lifecycle to disable)
|
| 173 |
+
read_lifecycle=not no_read_lifecycle,
|
| 174 |
# Intelligent Context: ON by default (use --no-intelligent-context to disable)
|
| 175 |
intelligent_context=not no_intelligent_context,
|
| 176 |
intelligent_context_scoring=not no_intelligent_scoring,
|
|
@@ -388,7 +388,7 @@ class ReadLifecycleConfig:
|
|
| 388 |
outputs still bypass ContentRouter compression.
|
| 389 |
"""
|
| 390 |
|
| 391 |
-
enabled: bool =
|
| 392 |
compress_stale: bool = True # Replace Reads of files that were later edited
|
| 393 |
compress_superseded: bool = True # Replace Reads of files that were later re-Read
|
| 394 |
min_size_bytes: int = 512 # Skip tiny Read outputs (not worth the overhead)
|
|
|
|
| 388 |
outputs still bypass ContentRouter compression.
|
| 389 |
"""
|
| 390 |
|
| 391 |
+
enabled: bool = True # On by default: stale/superseded Reads are provably safe to compress
|
| 392 |
compress_stale: bool = True # Replace Reads of files that were later edited
|
| 393 |
compress_superseded: bool = True # Replace Reads of files that were later re-Read
|
| 394 |
min_size_bytes: int = 512 # Skip tiny Read outputs (not worth the overhead)
|
|
@@ -79,6 +79,7 @@ from headroom.config import (
|
|
| 79 |
CacheAlignerConfig,
|
| 80 |
CCRConfig,
|
| 81 |
IntelligentContextConfig,
|
|
|
|
| 82 |
RollingWindowConfig,
|
| 83 |
SmartCrusherConfig,
|
| 84 |
)
|
|
@@ -252,6 +253,9 @@ class ProxyConfig:
|
|
| 252 |
# Per-tool compression profiles (parsed from CLI/env)
|
| 253 |
tool_profiles: dict[str, Any] | None = None
|
| 254 |
|
|
|
|
|
|
|
|
|
|
| 255 |
# Smart content routing (routes each message to optimal compressor)
|
| 256 |
smart_routing: bool = True # Use ContentRouter for intelligent compression
|
| 257 |
|
|
@@ -1069,6 +1073,7 @@ class HeadroomProxy:
|
|
| 1069 |
enable_llmlingua=config.llmlingua_enabled,
|
| 1070 |
enable_code_aware=config.code_aware_enabled,
|
| 1071 |
tool_profiles=config.tool_profiles,
|
|
|
|
| 1072 |
)
|
| 1073 |
transforms = [
|
| 1074 |
CacheAligner(CacheAlignerConfig(enabled=True)),
|
|
|
|
| 79 |
CacheAlignerConfig,
|
| 80 |
CCRConfig,
|
| 81 |
IntelligentContextConfig,
|
| 82 |
+
ReadLifecycleConfig,
|
| 83 |
RollingWindowConfig,
|
| 84 |
SmartCrusherConfig,
|
| 85 |
)
|
|
|
|
| 253 |
# Per-tool compression profiles (parsed from CLI/env)
|
| 254 |
tool_profiles: dict[str, Any] | None = None
|
| 255 |
|
| 256 |
+
# Read lifecycle management (compress stale/superseded Read outputs)
|
| 257 |
+
read_lifecycle: bool = True # ON by default: stale/superseded are provably safe
|
| 258 |
+
|
| 259 |
# Smart content routing (routes each message to optimal compressor)
|
| 260 |
smart_routing: bool = True # Use ContentRouter for intelligent compression
|
| 261 |
|
|
|
|
| 1073 |
enable_llmlingua=config.llmlingua_enabled,
|
| 1074 |
enable_code_aware=config.code_aware_enabled,
|
| 1075 |
tool_profiles=config.tool_profiles,
|
| 1076 |
+
read_lifecycle=ReadLifecycleConfig(enabled=config.read_lifecycle),
|
| 1077 |
)
|
| 1078 |
transforms = [
|
| 1079 |
CacheAligner(CacheAlignerConfig(enabled=True)),
|
|
@@ -284,6 +284,19 @@ class ContentRouterConfig:
|
|
| 284 |
protect_recent_code: int = 4 # Don't compress CODE in last N messages (0 = disabled)
|
| 285 |
protect_analysis_context: bool = True # Detect "analyze/review" intent, protect code
|
| 286 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
# CCR (Compress-Cache-Retrieve) settings for SmartCrusher
|
| 288 |
ccr_enabled: bool = True # Enable CCR marker injection for reversible compression
|
| 289 |
ccr_inject_marker: bool = True # Add retrieval markers to compressed content
|
|
@@ -1208,22 +1221,78 @@ class ContentRouter(Transform):
|
|
| 1208 |
tool_id for tool_id, name in tool_name_map.items() if name in exclude_tools
|
| 1209 |
}
|
| 1210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1211 |
transformed_messages: list[dict[str, Any]] = []
|
| 1212 |
transforms_applied: list[str] = []
|
| 1213 |
warnings: list[str] = []
|
| 1214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1215 |
# Check for analysis intent in the most recent user message
|
| 1216 |
analysis_intent = False
|
| 1217 |
if self.config.protect_analysis_context:
|
| 1218 |
analysis_intent = self._detect_analysis_intent(messages)
|
| 1219 |
|
| 1220 |
-
num_messages = len(messages)
|
| 1221 |
-
|
| 1222 |
for i, message in enumerate(messages):
|
| 1223 |
role = message.get("role", "")
|
| 1224 |
content = message.get("content", "")
|
| 1225 |
bias = 1.0 # Default bias, may be overridden for tool messages
|
| 1226 |
|
|
|
|
|
|
|
| 1227 |
# Handle list content (Anthropic format with content blocks)
|
| 1228 |
if isinstance(content, list):
|
| 1229 |
transformed_message = self._process_content_blocks(
|
|
@@ -1233,22 +1302,37 @@ class ContentRouter(Transform):
|
|
| 1233 |
transforms_applied,
|
| 1234 |
excluded_tool_ids,
|
| 1235 |
tool_name_map=tool_name_map,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1236 |
)
|
| 1237 |
transformed_messages.append(transformed_message)
|
|
|
|
| 1238 |
continue
|
| 1239 |
|
| 1240 |
# Skip non-string content (other types)
|
| 1241 |
if not isinstance(content, str):
|
| 1242 |
transformed_messages.append(message)
|
|
|
|
| 1243 |
continue
|
| 1244 |
|
| 1245 |
# Skip OpenAI-style tool messages for excluded tools
|
|
|
|
|
|
|
| 1246 |
if role == "tool":
|
| 1247 |
tool_call_id = message.get("tool_call_id", "")
|
| 1248 |
if tool_call_id in excluded_tool_ids:
|
| 1249 |
-
|
| 1250 |
-
|
| 1251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1252 |
# Look up tool-specific compression bias for OpenAI tool messages
|
| 1253 |
tool_name = tool_name_map.get(tool_call_id, "")
|
| 1254 |
bias = self._get_tool_bias(tool_name) if tool_name else 1.0
|
|
@@ -1257,11 +1341,13 @@ class ContentRouter(Transform):
|
|
| 1257 |
if self.config.skip_user_messages and role == "user":
|
| 1258 |
transformed_messages.append(message)
|
| 1259 |
transforms_applied.append("router:protected:user_message")
|
|
|
|
| 1260 |
continue
|
| 1261 |
|
| 1262 |
if not content or len(content.split()) < 50:
|
| 1263 |
# Skip small content
|
| 1264 |
transformed_messages.append(message)
|
|
|
|
| 1265 |
continue
|
| 1266 |
|
| 1267 |
# Detect content type for protection decisions
|
|
@@ -1277,12 +1363,14 @@ class ContentRouter(Transform):
|
|
| 1277 |
):
|
| 1278 |
transformed_messages.append(message)
|
| 1279 |
transforms_applied.append("router:protected:recent_code")
|
|
|
|
| 1280 |
continue
|
| 1281 |
|
| 1282 |
# Protection 3: Don't compress CODE when analysis intent detected
|
| 1283 |
if analysis_intent and is_code:
|
| 1284 |
transformed_messages.append(message)
|
| 1285 |
transforms_applied.append("router:protected:analysis_context")
|
|
|
|
| 1286 |
continue
|
| 1287 |
|
| 1288 |
# Route and compress based on content detection
|
|
@@ -1292,18 +1380,49 @@ class ContentRouter(Transform):
|
|
| 1292 |
msg_bias *= hook_biases[i]
|
| 1293 |
result = self.compress(content, context=context, bias=msg_bias)
|
| 1294 |
|
| 1295 |
-
if result.compression_ratio <
|
| 1296 |
transformed_messages.append({**message, "content": result.compressed})
|
| 1297 |
transforms_applied.append(
|
| 1298 |
f"router:{result.strategy_used.value}:{result.compression_ratio:.2f}"
|
| 1299 |
)
|
|
|
|
|
|
|
|
|
|
| 1300 |
else:
|
| 1301 |
transformed_messages.append(message)
|
|
|
|
| 1302 |
|
| 1303 |
tokens_after = sum(
|
| 1304 |
tokenizer.count_text(str(m.get("content", ""))) for m in transformed_messages
|
| 1305 |
)
|
| 1306 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1307 |
all_transforms = lifecycle_transforms + transforms_applied
|
| 1308 |
return TransformResult(
|
| 1309 |
messages=transformed_messages,
|
|
@@ -1343,6 +1462,11 @@ class ContentRouter(Transform):
|
|
| 1343 |
transforms_applied: list[str],
|
| 1344 |
excluded_tool_ids: set[str],
|
| 1345 |
tool_name_map: dict[str, str] | None = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1346 |
) -> dict[str, Any]:
|
| 1347 |
"""Process content blocks (Anthropic format) for tool_result compression.
|
| 1348 |
|
|
@@ -1356,6 +1480,11 @@ class ContentRouter(Transform):
|
|
| 1356 |
transforms_applied: List to append transform names to.
|
| 1357 |
excluded_tool_ids: Tool IDs to skip compression for.
|
| 1358 |
tool_name_map: Mapping from tool_call_id to tool_name for profile lookup.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1359 |
|
| 1360 |
Returns:
|
| 1361 |
Transformed message with compressed content blocks.
|
|
@@ -1375,9 +1504,14 @@ class ContentRouter(Transform):
|
|
| 1375 |
# Check if tool is excluded from compression
|
| 1376 |
tool_use_id = block.get("tool_use_id", "")
|
| 1377 |
if tool_use_id in excluded_tool_ids:
|
| 1378 |
-
|
| 1379 |
-
|
| 1380 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1381 |
|
| 1382 |
# Look up tool-specific compression bias
|
| 1383 |
tool_name = (tool_name_map or {}).get(tool_use_id, "")
|
|
@@ -1389,13 +1523,23 @@ class ContentRouter(Transform):
|
|
| 1389 |
if isinstance(tool_content, str) and len(tool_content) > 500:
|
| 1390 |
# Compress using content detection (will auto-detect JSON arrays, etc.)
|
| 1391 |
result = self.compress(tool_content, context=context, bias=bias)
|
| 1392 |
-
if result.compression_ratio <
|
| 1393 |
new_blocks.append({**block, "content": result.compressed})
|
| 1394 |
transforms_applied.append(
|
| 1395 |
f"router:tool_result:{result.strategy_used.value}"
|
| 1396 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1397 |
any_compressed = True
|
| 1398 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1399 |
|
| 1400 |
# Keep block unchanged
|
| 1401 |
new_blocks.append(block)
|
|
|
|
| 284 |
protect_recent_code: int = 4 # Don't compress CODE in last N messages (0 = disabled)
|
| 285 |
protect_analysis_context: bool = True # Detect "analyze/review" intent, protect code
|
| 286 |
|
| 287 |
+
# Adaptive Read protection: fraction of total messages to protect from
|
| 288 |
+
# compression. At 10 msgs, protects ~5 Reads. At 100 msgs, protects ~10.
|
| 289 |
+
# Old Reads beyond this window become compressible even though they are
|
| 290 |
+
# in DEFAULT_EXCLUDE_TOOLS. 0.0 = always exclude all (old behavior).
|
| 291 |
+
protect_recent_reads_fraction: float = 0.5 # protect the most-recent 50% of messages
|
| 292 |
+
|
| 293 |
+
# Adaptive compression ratio: scales with context pressure.
|
| 294 |
+
# At low pressure (<30% full), use the relaxed threshold (reject marginal).
|
| 295 |
+
# At high pressure (>80% full), use the aggressive threshold (accept anything helpful).
|
| 296 |
+
# Linearly interpolates between the two.
|
| 297 |
+
min_ratio_relaxed: float = 0.85 # when context is mostly empty
|
| 298 |
+
min_ratio_aggressive: float = 0.65 # when context is nearly full
|
| 299 |
+
|
| 300 |
# CCR (Compress-Cache-Retrieve) settings for SmartCrusher
|
| 301 |
ccr_enabled: bool = True # Enable CCR marker injection for reversible compression
|
| 302 |
ccr_inject_marker: bool = True # Add retrieval markers to compressed content
|
|
|
|
| 1221 |
tool_id for tool_id, name in tool_name_map.items() if name in exclude_tools
|
| 1222 |
}
|
| 1223 |
|
| 1224 |
+
# --- Adaptive parameters based on context pressure ---
|
| 1225 |
+
num_messages = len(messages)
|
| 1226 |
+
model_limit = kwargs.get("model_limit", 0)
|
| 1227 |
+
|
| 1228 |
+
# Adaptive Read protection: protect a fraction of recent messages
|
| 1229 |
+
if self.config.protect_recent_reads_fraction > 0:
|
| 1230 |
+
# Scale: at 10 msgs protect 5, at 50 msgs protect 25, at 200 msgs protect 100
|
| 1231 |
+
# But cap at a reasonable floor so very short convos still protect everything
|
| 1232 |
+
read_protection_window = max(
|
| 1233 |
+
4, # always protect at least last 4 messages
|
| 1234 |
+
int(num_messages * self.config.protect_recent_reads_fraction),
|
| 1235 |
+
)
|
| 1236 |
+
else:
|
| 1237 |
+
read_protection_window = num_messages # 0.0 = protect all (old behavior)
|
| 1238 |
+
|
| 1239 |
+
# Adaptive compression ratio: scale with context pressure
|
| 1240 |
+
if model_limit > 0:
|
| 1241 |
+
context_pressure = min(1.0, tokens_before / model_limit)
|
| 1242 |
+
else:
|
| 1243 |
+
context_pressure = 0.5 # default: moderate
|
| 1244 |
+
|
| 1245 |
+
# Linear interpolation between relaxed and aggressive thresholds
|
| 1246 |
+
# pressure 0.0 → relaxed, pressure 1.0 → aggressive
|
| 1247 |
+
min_ratio = (
|
| 1248 |
+
self.config.min_ratio_relaxed
|
| 1249 |
+
+ (self.config.min_ratio_aggressive - self.config.min_ratio_relaxed) * context_pressure
|
| 1250 |
+
)
|
| 1251 |
+
# Clamp to [aggressive, relaxed] range
|
| 1252 |
+
min_ratio = max(
|
| 1253 |
+
self.config.min_ratio_aggressive,
|
| 1254 |
+
min(self.config.min_ratio_relaxed, min_ratio),
|
| 1255 |
+
)
|
| 1256 |
+
|
| 1257 |
+
if context_pressure > 0.3:
|
| 1258 |
+
logger.debug(
|
| 1259 |
+
"content_router adaptive: pressure=%.2f, min_ratio=%.2f, "
|
| 1260 |
+
"read_protect_window=%d/%d msgs",
|
| 1261 |
+
context_pressure,
|
| 1262 |
+
min_ratio,
|
| 1263 |
+
read_protection_window,
|
| 1264 |
+
num_messages,
|
| 1265 |
+
)
|
| 1266 |
+
|
| 1267 |
transformed_messages: list[dict[str, Any]] = []
|
| 1268 |
transforms_applied: list[str] = []
|
| 1269 |
warnings: list[str] = []
|
| 1270 |
|
| 1271 |
+
# Routing reason counters for summary logging
|
| 1272 |
+
route_counts: dict[str, int] = {
|
| 1273 |
+
"excluded_tool": 0,
|
| 1274 |
+
"user_msg": 0,
|
| 1275 |
+
"small": 0,
|
| 1276 |
+
"recent_code": 0,
|
| 1277 |
+
"analysis_ctx": 0,
|
| 1278 |
+
"ratio_too_high": 0,
|
| 1279 |
+
"non_string": 0,
|
| 1280 |
+
"content_blocks": 0,
|
| 1281 |
+
}
|
| 1282 |
+
compressed_details: list[str] = [] # e.g. ["code_aware:0.72", "llmlingua:0.65"]
|
| 1283 |
+
|
| 1284 |
# Check for analysis intent in the most recent user message
|
| 1285 |
analysis_intent = False
|
| 1286 |
if self.config.protect_analysis_context:
|
| 1287 |
analysis_intent = self._detect_analysis_intent(messages)
|
| 1288 |
|
|
|
|
|
|
|
| 1289 |
for i, message in enumerate(messages):
|
| 1290 |
role = message.get("role", "")
|
| 1291 |
content = message.get("content", "")
|
| 1292 |
bias = 1.0 # Default bias, may be overridden for tool messages
|
| 1293 |
|
| 1294 |
+
messages_from_end = num_messages - i
|
| 1295 |
+
|
| 1296 |
# Handle list content (Anthropic format with content blocks)
|
| 1297 |
if isinstance(content, list):
|
| 1298 |
transformed_message = self._process_content_blocks(
|
|
|
|
| 1302 |
transforms_applied,
|
| 1303 |
excluded_tool_ids,
|
| 1304 |
tool_name_map=tool_name_map,
|
| 1305 |
+
route_counts=route_counts,
|
| 1306 |
+
compressed_details=compressed_details,
|
| 1307 |
+
min_ratio=min_ratio,
|
| 1308 |
+
read_protection_window=read_protection_window,
|
| 1309 |
+
messages_from_end=messages_from_end,
|
| 1310 |
)
|
| 1311 |
transformed_messages.append(transformed_message)
|
| 1312 |
+
route_counts["content_blocks"] += 1
|
| 1313 |
continue
|
| 1314 |
|
| 1315 |
# Skip non-string content (other types)
|
| 1316 |
if not isinstance(content, str):
|
| 1317 |
transformed_messages.append(message)
|
| 1318 |
+
route_counts["non_string"] += 1
|
| 1319 |
continue
|
| 1320 |
|
| 1321 |
# Skip OpenAI-style tool messages for excluded tools
|
| 1322 |
+
# BUT: allow compression of old excluded-tool outputs beyond the
|
| 1323 |
+
# adaptive protection window (age-based decay).
|
| 1324 |
if role == "tool":
|
| 1325 |
tool_call_id = message.get("tool_call_id", "")
|
| 1326 |
if tool_call_id in excluded_tool_ids:
|
| 1327 |
+
if messages_from_end <= read_protection_window:
|
| 1328 |
+
# Recent — protect as before
|
| 1329 |
+
transformed_messages.append(message)
|
| 1330 |
+
transforms_applied.append("router:excluded:tool")
|
| 1331 |
+
route_counts["excluded_tool"] += 1
|
| 1332 |
+
continue
|
| 1333 |
+
# Old excluded-tool output — fall through to compression
|
| 1334 |
+
# (the LLM is unlikely to need exact content from this far back,
|
| 1335 |
+
# and CCR provides retrieval if it does)
|
| 1336 |
# Look up tool-specific compression bias for OpenAI tool messages
|
| 1337 |
tool_name = tool_name_map.get(tool_call_id, "")
|
| 1338 |
bias = self._get_tool_bias(tool_name) if tool_name else 1.0
|
|
|
|
| 1341 |
if self.config.skip_user_messages and role == "user":
|
| 1342 |
transformed_messages.append(message)
|
| 1343 |
transforms_applied.append("router:protected:user_message")
|
| 1344 |
+
route_counts["user_msg"] += 1
|
| 1345 |
continue
|
| 1346 |
|
| 1347 |
if not content or len(content.split()) < 50:
|
| 1348 |
# Skip small content
|
| 1349 |
transformed_messages.append(message)
|
| 1350 |
+
route_counts["small"] += 1
|
| 1351 |
continue
|
| 1352 |
|
| 1353 |
# Detect content type for protection decisions
|
|
|
|
| 1363 |
):
|
| 1364 |
transformed_messages.append(message)
|
| 1365 |
transforms_applied.append("router:protected:recent_code")
|
| 1366 |
+
route_counts["recent_code"] += 1
|
| 1367 |
continue
|
| 1368 |
|
| 1369 |
# Protection 3: Don't compress CODE when analysis intent detected
|
| 1370 |
if analysis_intent and is_code:
|
| 1371 |
transformed_messages.append(message)
|
| 1372 |
transforms_applied.append("router:protected:analysis_context")
|
| 1373 |
+
route_counts["analysis_ctx"] += 1
|
| 1374 |
continue
|
| 1375 |
|
| 1376 |
# Route and compress based on content detection
|
|
|
|
| 1380 |
msg_bias *= hook_biases[i]
|
| 1381 |
result = self.compress(content, context=context, bias=msg_bias)
|
| 1382 |
|
| 1383 |
+
if result.compression_ratio < min_ratio:
|
| 1384 |
transformed_messages.append({**message, "content": result.compressed})
|
| 1385 |
transforms_applied.append(
|
| 1386 |
f"router:{result.strategy_used.value}:{result.compression_ratio:.2f}"
|
| 1387 |
)
|
| 1388 |
+
compressed_details.append(
|
| 1389 |
+
f"{result.strategy_used.value}:{result.compression_ratio:.2f}"
|
| 1390 |
+
)
|
| 1391 |
else:
|
| 1392 |
transformed_messages.append(message)
|
| 1393 |
+
route_counts["ratio_too_high"] += 1
|
| 1394 |
|
| 1395 |
tokens_after = sum(
|
| 1396 |
tokenizer.count_text(str(m.get("content", ""))) for m in transformed_messages
|
| 1397 |
)
|
| 1398 |
|
| 1399 |
+
# Log routing summary
|
| 1400 |
+
parts = []
|
| 1401 |
+
if compressed_details:
|
| 1402 |
+
parts.append(f"{len(compressed_details)} compressed ({', '.join(compressed_details)})")
|
| 1403 |
+
if route_counts["excluded_tool"]:
|
| 1404 |
+
parts.append(f"{route_counts['excluded_tool']} excluded (Read/Glob)")
|
| 1405 |
+
if route_counts["user_msg"]:
|
| 1406 |
+
parts.append(f"{route_counts['user_msg']} skipped (user)")
|
| 1407 |
+
if route_counts["small"]:
|
| 1408 |
+
parts.append(f"{route_counts['small']} skipped (<50 words)")
|
| 1409 |
+
if route_counts["recent_code"]:
|
| 1410 |
+
parts.append(f"{route_counts['recent_code']} protected (recent code)")
|
| 1411 |
+
if route_counts["analysis_ctx"]:
|
| 1412 |
+
parts.append(f"{route_counts['analysis_ctx']} protected (analysis ctx)")
|
| 1413 |
+
if route_counts["ratio_too_high"]:
|
| 1414 |
+
parts.append(f"{route_counts['ratio_too_high']} unchanged (ratio>={min_ratio:.2f})")
|
| 1415 |
+
if route_counts["content_blocks"]:
|
| 1416 |
+
parts.append(f"{route_counts['content_blocks']} content-block msgs")
|
| 1417 |
+
if route_counts["non_string"]:
|
| 1418 |
+
parts.append(f"{route_counts['non_string']} non-string")
|
| 1419 |
+
if parts:
|
| 1420 |
+
logger.info(
|
| 1421 |
+
"content_router: %d msgs — %s",
|
| 1422 |
+
num_messages,
|
| 1423 |
+
", ".join(parts),
|
| 1424 |
+
)
|
| 1425 |
+
|
| 1426 |
all_transforms = lifecycle_transforms + transforms_applied
|
| 1427 |
return TransformResult(
|
| 1428 |
messages=transformed_messages,
|
|
|
|
| 1462 |
transforms_applied: list[str],
|
| 1463 |
excluded_tool_ids: set[str],
|
| 1464 |
tool_name_map: dict[str, str] | None = None,
|
| 1465 |
+
route_counts: dict[str, int] | None = None,
|
| 1466 |
+
compressed_details: list[str] | None = None,
|
| 1467 |
+
min_ratio: float = 0.85,
|
| 1468 |
+
read_protection_window: int = 8,
|
| 1469 |
+
messages_from_end: int = 0,
|
| 1470 |
) -> dict[str, Any]:
|
| 1471 |
"""Process content blocks (Anthropic format) for tool_result compression.
|
| 1472 |
|
|
|
|
| 1480 |
transforms_applied: List to append transform names to.
|
| 1481 |
excluded_tool_ids: Tool IDs to skip compression for.
|
| 1482 |
tool_name_map: Mapping from tool_call_id to tool_name for profile lookup.
|
| 1483 |
+
route_counts: Optional routing reason counters to update.
|
| 1484 |
+
compressed_details: Optional list to append compression details to.
|
| 1485 |
+
min_ratio: Adaptive compression ratio threshold.
|
| 1486 |
+
read_protection_window: Messages from end within which excluded tools are protected.
|
| 1487 |
+
messages_from_end: How far this message is from the end of the conversation.
|
| 1488 |
|
| 1489 |
Returns:
|
| 1490 |
Transformed message with compressed content blocks.
|
|
|
|
| 1504 |
# Check if tool is excluded from compression
|
| 1505 |
tool_use_id = block.get("tool_use_id", "")
|
| 1506 |
if tool_use_id in excluded_tool_ids:
|
| 1507 |
+
if messages_from_end <= read_protection_window:
|
| 1508 |
+
# Recent — protect as before
|
| 1509 |
+
new_blocks.append(block)
|
| 1510 |
+
transforms_applied.append("router:excluded:tool")
|
| 1511 |
+
if route_counts is not None:
|
| 1512 |
+
route_counts["excluded_tool"] += 1
|
| 1513 |
+
continue
|
| 1514 |
+
# Old excluded-tool output — fall through to compression
|
| 1515 |
|
| 1516 |
# Look up tool-specific compression bias
|
| 1517 |
tool_name = (tool_name_map or {}).get(tool_use_id, "")
|
|
|
|
| 1523 |
if isinstance(tool_content, str) and len(tool_content) > 500:
|
| 1524 |
# Compress using content detection (will auto-detect JSON arrays, etc.)
|
| 1525 |
result = self.compress(tool_content, context=context, bias=bias)
|
| 1526 |
+
if result.compression_ratio < min_ratio:
|
| 1527 |
new_blocks.append({**block, "content": result.compressed})
|
| 1528 |
transforms_applied.append(
|
| 1529 |
f"router:tool_result:{result.strategy_used.value}"
|
| 1530 |
)
|
| 1531 |
+
if compressed_details is not None:
|
| 1532 |
+
compressed_details.append(
|
| 1533 |
+
f"tool:{result.strategy_used.value}:{result.compression_ratio:.2f}"
|
| 1534 |
+
)
|
| 1535 |
any_compressed = True
|
| 1536 |
continue
|
| 1537 |
+
else:
|
| 1538 |
+
if route_counts is not None:
|
| 1539 |
+
route_counts["ratio_too_high"] += 1
|
| 1540 |
+
else:
|
| 1541 |
+
if route_counts is not None:
|
| 1542 |
+
route_counts["small"] += 1
|
| 1543 |
|
| 1544 |
# Keep block unchanged
|
| 1545 |
new_blocks.append(block)
|
|
@@ -754,6 +754,7 @@ class TestLocalEmbedder:
|
|
| 754 |
@pytest.fixture
|
| 755 |
def embedder(self):
|
| 756 |
"""Create a local embedder for testing."""
|
|
|
|
| 757 |
from headroom.memory.adapters.embedders import LocalEmbedder
|
| 758 |
|
| 759 |
return LocalEmbedder()
|
|
|
|
| 754 |
@pytest.fixture
|
| 755 |
def embedder(self):
|
| 756 |
"""Create a local embedder for testing."""
|
| 757 |
+
pytest.importorskip("sentence_transformers", reason="sentence-transformers not installed")
|
| 758 |
from headroom.memory.adapters.embedders import LocalEmbedder
|
| 759 |
|
| 760 |
return LocalEmbedder()
|
|
@@ -152,9 +152,9 @@ SMALL_CONTENT = "tiny" # Below min_size_bytes
|
|
| 152 |
class TestReadLifecycleDisabled:
|
| 153 |
"""Verify backward compatibility when disabled."""
|
| 154 |
|
| 155 |
-
def
|
| 156 |
-
"""
|
| 157 |
-
config = ReadLifecycleConfig()
|
| 158 |
assert config.enabled is False
|
| 159 |
|
| 160 |
mgr = ReadLifecycleManager(config)
|
|
@@ -168,6 +168,11 @@ class TestReadLifecycleDisabled:
|
|
| 168 |
assert result.reads_total == 0
|
| 169 |
assert result.transforms_applied == []
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
class TestStaleDetection:
|
| 173 |
"""Read outputs become stale when the file is subsequently edited."""
|
|
|
|
| 152 |
class TestReadLifecycleDisabled:
|
| 153 |
"""Verify backward compatibility when disabled."""
|
| 154 |
|
| 155 |
+
def test_disabled_when_explicitly_off(self):
|
| 156 |
+
"""Explicitly disabled config: no changes to messages."""
|
| 157 |
+
config = ReadLifecycleConfig(enabled=False)
|
| 158 |
assert config.enabled is False
|
| 159 |
|
| 160 |
mgr = ReadLifecycleManager(config)
|
|
|
|
| 168 |
assert result.reads_total == 0
|
| 169 |
assert result.transforms_applied == []
|
| 170 |
|
| 171 |
+
def test_enabled_by_default(self):
|
| 172 |
+
"""Default config has lifecycle enabled."""
|
| 173 |
+
config = ReadLifecycleConfig()
|
| 174 |
+
assert config.enabled is True
|
| 175 |
+
|
| 176 |
|
| 177 |
class TestStaleDetection:
|
| 178 |
"""Read outputs become stale when the file is subsequently edited."""
|