chopratejas commited on
Commit
10d6c83
·
1 Parent(s): 42383c1

feat(router): adaptive compression with Read lifecycle and context-pressure scaling

Browse files

Enable 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.

headroom/cli/proxy.py CHANGED
@@ -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,
headroom/config.py CHANGED
@@ -388,7 +388,7 @@ class ReadLifecycleConfig:
388
  outputs still bypass ContentRouter compression.
389
  """
390
 
391
- enabled: bool = False # Opt-in; default preserves backward-compatible behavior
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)
headroom/proxy/server.py CHANGED
@@ -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)),
headroom/transforms/content_router.py CHANGED
@@ -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
- transformed_messages.append(message)
1250
- transforms_applied.append("router:excluded:tool")
1251
- continue
 
 
 
 
 
 
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 < 0.9:
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
- new_blocks.append(block)
1379
- transforms_applied.append("router:excluded:tool")
1380
- continue
 
 
 
 
 
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 < 0.9:
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)
tests/test_memory/test_hierarchical.py CHANGED
@@ -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()
tests/test_transforms/test_read_lifecycle.py CHANGED
@@ -152,9 +152,9 @@ SMALL_CONTENT = "tiny" # Below min_size_bytes
152
  class TestReadLifecycleDisabled:
153
  """Verify backward compatibility when disabled."""
154
 
155
- def test_disabled_by_default(self):
156
- """Default config: no changes to messages."""
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."""