Spaces:
Build error
Add adaptive compression sizing and fix cost tracking bugs
Browse files- Fix LiteLLM pricing for claude-opus-4-6 by adding provider prefix
fallback (_resolve_litellm_model) when model name is unrecognized
- Fix cost savings double-counting: remove cache_read/cache_write
tokens from original_cost calculation at all 3 call sites, which
was inflating reported savings ~425x
- Add information saturation engine (adaptive_sizer.py) using Kneedle
algorithm on unique bigram coverage curves to statistically determine
optimal compression K instead of hardcoded thresholds
- Add CompressionProfile with per-tool bias multipliers (conservative,
moderate, aggressive) configurable via --tool-profile CLI flag and
HEADROOM_TOOL_PROFILES env var
- Integrate adaptive K into SmartCrusher, SearchCompressor, and
LogCompressor, threading bias through ContentRouter
- Remove Grep and Bash from DEFAULT_EXCLUDE_TOOLS so their outputs
are now compressed (only Read and Glob excluded)
- headroom/config.py +43 -7
- headroom/proxy/server.py +102 -9
- headroom/transforms/adaptive_sizer.py +289 -0
- headroom/transforms/content_router.py +66 -10
- headroom/transforms/log_compressor.py +21 -8
- headroom/transforms/search_compressor.py +23 -5
- headroom/transforms/smart_crusher.py +47 -13
|
@@ -348,24 +348,60 @@ class AnchorConfig:
|
|
| 348 |
|
| 349 |
|
| 350 |
# Default tools to exclude from compression (local file/code tools)
|
| 351 |
-
#
|
| 352 |
-
#
|
| 353 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
DEFAULT_EXCLUDE_TOOLS: frozenset[str] = frozenset(
|
| 355 |
{
|
| 356 |
"Read",
|
| 357 |
"Glob",
|
| 358 |
-
"Grep",
|
| 359 |
-
"Bash",
|
| 360 |
# Lowercase variants for case-insensitive matching
|
| 361 |
"read",
|
| 362 |
"glob",
|
| 363 |
-
"grep",
|
| 364 |
-
"bash",
|
| 365 |
}
|
| 366 |
)
|
| 367 |
|
| 368 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
@dataclass
|
| 370 |
class SmartCrusherConfig:
|
| 371 |
"""Configuration for smart statistical crusher (DEFAULT).
|
|
|
|
| 348 |
|
| 349 |
|
| 350 |
# Default tools to exclude from compression (local file/code tools)
|
| 351 |
+
# Read: Returns exact file content needed for Edit tool's old_string matching.
|
| 352 |
+
# Compressing would break the edit workflow.
|
| 353 |
+
# Glob: Returns compact file path lists used for navigation. Low token count,
|
| 354 |
+
# not worth compressing.
|
| 355 |
+
# Grep/Bash are NOT excluded - their outputs (search results, build logs,
|
| 356 |
+
# test output) are ideal compression targets for SearchCompressor/LogCompressor,
|
| 357 |
+
# and CCR provides safe retrieval if the LLM needs more detail.
|
| 358 |
DEFAULT_EXCLUDE_TOOLS: frozenset[str] = frozenset(
|
| 359 |
{
|
| 360 |
"Read",
|
| 361 |
"Glob",
|
|
|
|
|
|
|
| 362 |
# Lowercase variants for case-insensitive matching
|
| 363 |
"read",
|
| 364 |
"glob",
|
|
|
|
|
|
|
| 365 |
}
|
| 366 |
)
|
| 367 |
|
| 368 |
|
| 369 |
+
@dataclass
|
| 370 |
+
class CompressionProfile:
|
| 371 |
+
"""Per-tool compression bias applied to statistically-determined K.
|
| 372 |
+
|
| 373 |
+
Instead of hardcoding max_items=15, the adaptive sizer computes the optimal K
|
| 374 |
+
via information saturation (Kneedle on unique bigram coverage). This profile
|
| 375 |
+
applies a bias multiplier: >1 keeps more items (conservative), <1 keeps fewer
|
| 376 |
+
(aggressive).
|
| 377 |
+
"""
|
| 378 |
+
|
| 379 |
+
bias: float = 1.0 # 0.7=aggressive, 1.0=moderate, 1.5=conservative
|
| 380 |
+
min_k: int = 3 # Never keep fewer than this
|
| 381 |
+
max_k: int | None = None # Cap (None = no cap, let statistics decide)
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
# Named presets for convenience
|
| 385 |
+
PROFILE_PRESETS: dict[str, CompressionProfile] = {
|
| 386 |
+
"conservative": CompressionProfile(bias=1.5, min_k=5),
|
| 387 |
+
"moderate": CompressionProfile(bias=1.0, min_k=3),
|
| 388 |
+
"aggressive": CompressionProfile(bias=0.7, min_k=3),
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
# Default per-tool profiles: tools not listed here use moderate (bias=1.0)
|
| 392 |
+
DEFAULT_TOOL_PROFILES: dict[str, CompressionProfile] = {
|
| 393 |
+
# Search results: keep more matches for accuracy
|
| 394 |
+
"Grep": PROFILE_PRESETS["conservative"],
|
| 395 |
+
"grep": PROFILE_PRESETS["conservative"],
|
| 396 |
+
# Logs/output: balanced compression
|
| 397 |
+
"Bash": PROFILE_PRESETS["moderate"],
|
| 398 |
+
"bash": PROFILE_PRESETS["moderate"],
|
| 399 |
+
# Web pages are verbose, compress aggressively
|
| 400 |
+
"WebFetch": PROFILE_PRESETS["aggressive"],
|
| 401 |
+
"webfetch": PROFILE_PRESETS["aggressive"],
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
|
| 405 |
@dataclass
|
| 406 |
class SmartCrusherConfig:
|
| 407 |
"""Configuration for smart statistical crusher (DEFAULT).
|
|
@@ -247,6 +247,9 @@ class ProxyConfig:
|
|
| 247 |
# Code-aware compression (ON by default if installed)
|
| 248 |
code_aware_enabled: bool = True # Enable AST-based code compression
|
| 249 |
|
|
|
|
|
|
|
|
|
|
| 250 |
# Smart content routing (routes each message to optimal compressor)
|
| 251 |
smart_routing: bool = True # Use ContentRouter for intelligent compression
|
| 252 |
|
|
@@ -536,6 +539,39 @@ class CostTracker:
|
|
| 536 |
self._total_savings_usd: float = 0
|
| 537 |
self._last_prune_time: datetime = datetime.now()
|
| 538 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
def estimate_cost(
|
| 540 |
self,
|
| 541 |
model: str,
|
|
@@ -558,6 +594,9 @@ class CostTracker:
|
|
| 558 |
return None
|
| 559 |
|
| 560 |
try:
|
|
|
|
|
|
|
|
|
|
| 561 |
# cost_per_token returns (total_input_cost, total_output_cost) for the given token counts
|
| 562 |
# Despite the name, it returns total cost not per-token cost
|
| 563 |
|
|
@@ -570,14 +609,14 @@ class CostTracker:
|
|
| 570 |
|
| 571 |
# Get cost for regular (non-cached) input tokens
|
| 572 |
input_cost, _ = litellm.cost_per_token(
|
| 573 |
-
model=
|
| 574 |
prompt_tokens=regular_input,
|
| 575 |
completion_tokens=0,
|
| 576 |
)
|
| 577 |
|
| 578 |
# Get cost for output tokens
|
| 579 |
_, output_cost = litellm.cost_per_token(
|
| 580 |
-
model=
|
| 581 |
prompt_tokens=0,
|
| 582 |
completion_tokens=output_tokens,
|
| 583 |
)
|
|
@@ -585,7 +624,7 @@ class CostTracker:
|
|
| 585 |
# Get model info for cache pricing
|
| 586 |
model_info: dict[str, Any] = {}
|
| 587 |
try:
|
| 588 |
-
model_info = dict(litellm.get_model_info(
|
| 589 |
except Exception:
|
| 590 |
pass
|
| 591 |
|
|
@@ -598,7 +637,7 @@ class CostTracker:
|
|
| 598 |
else:
|
| 599 |
# Fallback: most providers charge ~10% of input price for cache reads
|
| 600 |
cache_read_full_cost, _ = litellm.cost_per_token(
|
| 601 |
-
model=
|
| 602 |
prompt_tokens=cache_read_tokens,
|
| 603 |
completion_tokens=0,
|
| 604 |
)
|
|
@@ -613,7 +652,7 @@ class CostTracker:
|
|
| 613 |
else:
|
| 614 |
# Fallback: most providers charge ~125% of input price for cache writes
|
| 615 |
cache_write_full_cost, _ = litellm.cost_per_token(
|
| 616 |
-
model=
|
| 617 |
prompt_tokens=cache_write_tokens,
|
| 618 |
completion_tokens=0,
|
| 619 |
)
|
|
@@ -999,6 +1038,7 @@ class HeadroomProxy:
|
|
| 999 |
router_config = ContentRouterConfig(
|
| 1000 |
enable_llmlingua=config.llmlingua_enabled,
|
| 1001 |
enable_code_aware=config.code_aware_enabled,
|
|
|
|
| 1002 |
)
|
| 1003 |
transforms = [
|
| 1004 |
CacheAligner(CacheAlignerConfig(enabled=True)),
|
|
@@ -2023,12 +2063,13 @@ class HeadroomProxy:
|
|
| 2023 |
cache_read_tokens=cache_read_tokens,
|
| 2024 |
cache_write_tokens=cache_write_tokens,
|
| 2025 |
)
|
|
|
|
|
|
|
|
|
|
| 2026 |
original_cost = self.cost_tracker.estimate_cost(
|
| 2027 |
model,
|
| 2028 |
original_tokens,
|
| 2029 |
output_tokens,
|
| 2030 |
-
cache_read_tokens=cache_read_tokens,
|
| 2031 |
-
cache_write_tokens=cache_write_tokens,
|
| 2032 |
)
|
| 2033 |
if cost_usd and original_cost:
|
| 2034 |
savings_usd = original_cost - cost_usd
|
|
@@ -4128,11 +4169,12 @@ class HeadroomProxy:
|
|
| 4128 |
output_tokens,
|
| 4129 |
cache_read_tokens=cache_read_tokens,
|
| 4130 |
)
|
|
|
|
|
|
|
| 4131 |
original_cost = self.cost_tracker.estimate_cost(
|
| 4132 |
model,
|
| 4133 |
original_tokens,
|
| 4134 |
output_tokens,
|
| 4135 |
-
cache_read_tokens=cache_read_tokens,
|
| 4136 |
)
|
| 4137 |
if cost_usd and original_cost:
|
| 4138 |
savings_usd = original_cost - cost_usd
|
|
@@ -5183,11 +5225,12 @@ class HeadroomProxy:
|
|
| 5183 |
output_tokens,
|
| 5184 |
cache_read_tokens=cache_read_tokens,
|
| 5185 |
)
|
|
|
|
|
|
|
| 5186 |
original_cost = self.cost_tracker.estimate_cost(
|
| 5187 |
model,
|
| 5188 |
original_tokens,
|
| 5189 |
output_tokens,
|
| 5190 |
-
cache_read_tokens=cache_read_tokens,
|
| 5191 |
)
|
| 5192 |
if cost_usd and original_cost:
|
| 5193 |
savings_usd = original_cost - cost_usd
|
|
@@ -6627,6 +6670,45 @@ def _get_env_str(name: str, default: str) -> str:
|
|
| 6627 |
return os.environ.get(name, default)
|
| 6628 |
|
| 6629 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6630 |
if __name__ == "__main__":
|
| 6631 |
parser = argparse.ArgumentParser(description="Headroom Proxy Server")
|
| 6632 |
|
|
@@ -6690,6 +6772,13 @@ if __name__ == "__main__":
|
|
| 6690 |
parser.add_argument("--no-optimize", action="store_true", help="Disable optimization")
|
| 6691 |
parser.add_argument("--min-tokens", type=int, default=500, help="Min tokens to crush")
|
| 6692 |
parser.add_argument("--max-items", type=int, default=50, help="Max items after crush")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6693 |
|
| 6694 |
# Caching
|
| 6695 |
parser.add_argument("--no-cache", action="store_true", help="Disable caching")
|
|
@@ -6783,6 +6872,9 @@ if __name__ == "__main__":
|
|
| 6783 |
if hasattr(args, "openrouter_api_key") and args.openrouter_api_key:
|
| 6784 |
os.environ["OPENROUTER_API_KEY"] = args.openrouter_api_key
|
| 6785 |
|
|
|
|
|
|
|
|
|
|
| 6786 |
config = ProxyConfig(
|
| 6787 |
host=_get_env_str("HEADROOM_HOST", args.host),
|
| 6788 |
port=_get_env_int("HEADROOM_PORT", args.port),
|
|
@@ -6814,6 +6906,7 @@ if __name__ == "__main__":
|
|
| 6814 |
max_connections=_get_env_int("HEADROOM_MAX_CONNECTIONS", args.max_connections),
|
| 6815 |
max_keepalive_connections=_get_env_int("HEADROOM_MAX_KEEPALIVE", args.max_keepalive),
|
| 6816 |
http2=not args.no_http2 and _get_env_bool("HEADROOM_HTTP2", True),
|
|
|
|
| 6817 |
)
|
| 6818 |
|
| 6819 |
# Get worker and concurrency settings
|
|
|
|
| 247 |
# Code-aware compression (ON by default if installed)
|
| 248 |
code_aware_enabled: bool = True # Enable AST-based code compression
|
| 249 |
|
| 250 |
+
# Per-tool compression profiles (parsed from CLI/env)
|
| 251 |
+
tool_profiles: dict[str, Any] | None = None
|
| 252 |
+
|
| 253 |
# Smart content routing (routes each message to optimal compressor)
|
| 254 |
smart_routing: bool = True # Use ContentRouter for intelligent compression
|
| 255 |
|
|
|
|
| 539 |
self._total_savings_usd: float = 0
|
| 540 |
self._last_prune_time: datetime = datetime.now()
|
| 541 |
|
| 542 |
+
@staticmethod
|
| 543 |
+
def _resolve_litellm_model(model: str) -> str:
|
| 544 |
+
"""Resolve model name to one LiteLLM recognizes, adding provider prefix if needed."""
|
| 545 |
+
if not LITELLM_AVAILABLE:
|
| 546 |
+
return model
|
| 547 |
+
|
| 548 |
+
# Try as-is first
|
| 549 |
+
try:
|
| 550 |
+
litellm.cost_per_token(model=model, prompt_tokens=1, completion_tokens=0)
|
| 551 |
+
return model
|
| 552 |
+
except Exception:
|
| 553 |
+
pass
|
| 554 |
+
|
| 555 |
+
# Try with provider prefix
|
| 556 |
+
prefixes = {
|
| 557 |
+
"claude-": "anthropic/",
|
| 558 |
+
"gpt-": "openai/",
|
| 559 |
+
"o1-": "openai/",
|
| 560 |
+
"o3-": "openai/",
|
| 561 |
+
"o4-": "openai/",
|
| 562 |
+
"gemini-": "google/",
|
| 563 |
+
}
|
| 564 |
+
for pattern, prefix in prefixes.items():
|
| 565 |
+
if model.startswith(pattern):
|
| 566 |
+
prefixed = f"{prefix}{model}"
|
| 567 |
+
try:
|
| 568 |
+
litellm.cost_per_token(model=prefixed, prompt_tokens=1, completion_tokens=0)
|
| 569 |
+
return prefixed
|
| 570 |
+
except Exception:
|
| 571 |
+
break
|
| 572 |
+
|
| 573 |
+
return model
|
| 574 |
+
|
| 575 |
def estimate_cost(
|
| 576 |
self,
|
| 577 |
model: str,
|
|
|
|
| 594 |
return None
|
| 595 |
|
| 596 |
try:
|
| 597 |
+
# Resolve model name (adds provider prefix if needed, e.g. claude-opus-4-6 → anthropic/claude-opus-4-6)
|
| 598 |
+
resolved_model = self._resolve_litellm_model(model)
|
| 599 |
+
|
| 600 |
# cost_per_token returns (total_input_cost, total_output_cost) for the given token counts
|
| 601 |
# Despite the name, it returns total cost not per-token cost
|
| 602 |
|
|
|
|
| 609 |
|
| 610 |
# Get cost for regular (non-cached) input tokens
|
| 611 |
input_cost, _ = litellm.cost_per_token(
|
| 612 |
+
model=resolved_model,
|
| 613 |
prompt_tokens=regular_input,
|
| 614 |
completion_tokens=0,
|
| 615 |
)
|
| 616 |
|
| 617 |
# Get cost for output tokens
|
| 618 |
_, output_cost = litellm.cost_per_token(
|
| 619 |
+
model=resolved_model,
|
| 620 |
prompt_tokens=0,
|
| 621 |
completion_tokens=output_tokens,
|
| 622 |
)
|
|
|
|
| 624 |
# Get model info for cache pricing
|
| 625 |
model_info: dict[str, Any] = {}
|
| 626 |
try:
|
| 627 |
+
model_info = dict(litellm.get_model_info(resolved_model))
|
| 628 |
except Exception:
|
| 629 |
pass
|
| 630 |
|
|
|
|
| 637 |
else:
|
| 638 |
# Fallback: most providers charge ~10% of input price for cache reads
|
| 639 |
cache_read_full_cost, _ = litellm.cost_per_token(
|
| 640 |
+
model=resolved_model,
|
| 641 |
prompt_tokens=cache_read_tokens,
|
| 642 |
completion_tokens=0,
|
| 643 |
)
|
|
|
|
| 652 |
else:
|
| 653 |
# Fallback: most providers charge ~125% of input price for cache writes
|
| 654 |
cache_write_full_cost, _ = litellm.cost_per_token(
|
| 655 |
+
model=resolved_model,
|
| 656 |
prompt_tokens=cache_write_tokens,
|
| 657 |
completion_tokens=0,
|
| 658 |
)
|
|
|
|
| 1038 |
router_config = ContentRouterConfig(
|
| 1039 |
enable_llmlingua=config.llmlingua_enabled,
|
| 1040 |
enable_code_aware=config.code_aware_enabled,
|
| 1041 |
+
tool_profiles=config.tool_profiles,
|
| 1042 |
)
|
| 1043 |
transforms = [
|
| 1044 |
CacheAligner(CacheAlignerConfig(enabled=True)),
|
|
|
|
| 2063 |
cache_read_tokens=cache_read_tokens,
|
| 2064 |
cache_write_tokens=cache_write_tokens,
|
| 2065 |
)
|
| 2066 |
+
# original_cost: what it would have cost without compression
|
| 2067 |
+
# Use only original_tokens at regular input rate — no cache params,
|
| 2068 |
+
# since caching is orthogonal to compression savings
|
| 2069 |
original_cost = self.cost_tracker.estimate_cost(
|
| 2070 |
model,
|
| 2071 |
original_tokens,
|
| 2072 |
output_tokens,
|
|
|
|
|
|
|
| 2073 |
)
|
| 2074 |
if cost_usd and original_cost:
|
| 2075 |
savings_usd = original_cost - cost_usd
|
|
|
|
| 4169 |
output_tokens,
|
| 4170 |
cache_read_tokens=cache_read_tokens,
|
| 4171 |
)
|
| 4172 |
+
# original_cost: what it would have cost without compression
|
| 4173 |
+
# No cache params — caching is orthogonal to compression savings
|
| 4174 |
original_cost = self.cost_tracker.estimate_cost(
|
| 4175 |
model,
|
| 4176 |
original_tokens,
|
| 4177 |
output_tokens,
|
|
|
|
| 4178 |
)
|
| 4179 |
if cost_usd and original_cost:
|
| 4180 |
savings_usd = original_cost - cost_usd
|
|
|
|
| 5225 |
output_tokens,
|
| 5226 |
cache_read_tokens=cache_read_tokens,
|
| 5227 |
)
|
| 5228 |
+
# original_cost: what it would have cost without compression
|
| 5229 |
+
# No cache params — caching is orthogonal to compression savings
|
| 5230 |
original_cost = self.cost_tracker.estimate_cost(
|
| 5231 |
model,
|
| 5232 |
original_tokens,
|
| 5233 |
output_tokens,
|
|
|
|
| 5234 |
)
|
| 5235 |
if cost_usd and original_cost:
|
| 5236 |
savings_usd = original_cost - cost_usd
|
|
|
|
| 6670 |
return os.environ.get(name, default)
|
| 6671 |
|
| 6672 |
|
| 6673 |
+
def _parse_tool_profiles(cli_profiles: list[str]) -> dict[str, Any]:
|
| 6674 |
+
"""Parse tool profiles from CLI args and HEADROOM_TOOL_PROFILES env var.
|
| 6675 |
+
|
| 6676 |
+
Format: ToolName:level (e.g., Grep:conservative, Bash:moderate)
|
| 6677 |
+
Env var format: comma-separated (e.g., "Grep:conservative,Bash:moderate")
|
| 6678 |
+
|
| 6679 |
+
Returns:
|
| 6680 |
+
Dict mapping tool names to CompressionProfile instances.
|
| 6681 |
+
"""
|
| 6682 |
+
from headroom.config import PROFILE_PRESETS, CompressionProfile
|
| 6683 |
+
|
| 6684 |
+
profiles: dict[str, CompressionProfile] = {}
|
| 6685 |
+
raw_entries: list[str] = list(cli_profiles)
|
| 6686 |
+
|
| 6687 |
+
# Also check env var
|
| 6688 |
+
env_val = os.environ.get("HEADROOM_TOOL_PROFILES", "")
|
| 6689 |
+
if env_val:
|
| 6690 |
+
raw_entries.extend(e.strip() for e in env_val.split(",") if e.strip())
|
| 6691 |
+
|
| 6692 |
+
for entry in raw_entries:
|
| 6693 |
+
if ":" not in entry:
|
| 6694 |
+
logger.warning("Invalid tool profile format (expected ToolName:level): %s", entry)
|
| 6695 |
+
continue
|
| 6696 |
+
tool_name, level = entry.split(":", 1)
|
| 6697 |
+
tool_name = tool_name.strip()
|
| 6698 |
+
level = level.strip().lower()
|
| 6699 |
+
|
| 6700 |
+
if level in PROFILE_PRESETS:
|
| 6701 |
+
profiles[tool_name] = PROFILE_PRESETS[level]
|
| 6702 |
+
else:
|
| 6703 |
+
logger.warning(
|
| 6704 |
+
"Unknown profile level '%s' for tool '%s'. Use: conservative, moderate, aggressive",
|
| 6705 |
+
level,
|
| 6706 |
+
tool_name,
|
| 6707 |
+
)
|
| 6708 |
+
|
| 6709 |
+
return profiles
|
| 6710 |
+
|
| 6711 |
+
|
| 6712 |
if __name__ == "__main__":
|
| 6713 |
parser = argparse.ArgumentParser(description="Headroom Proxy Server")
|
| 6714 |
|
|
|
|
| 6772 |
parser.add_argument("--no-optimize", action="store_true", help="Disable optimization")
|
| 6773 |
parser.add_argument("--min-tokens", type=int, default=500, help="Min tokens to crush")
|
| 6774 |
parser.add_argument("--max-items", type=int, default=50, help="Max items after crush")
|
| 6775 |
+
parser.add_argument(
|
| 6776 |
+
"--tool-profile",
|
| 6777 |
+
action="append",
|
| 6778 |
+
default=[],
|
| 6779 |
+
help="Per-tool compression profile: ToolName:level (e.g., Grep:conservative, Bash:moderate, WebFetch:aggressive). "
|
| 6780 |
+
"Can be specified multiple times. Also settable via HEADROOM_TOOL_PROFILES env var.",
|
| 6781 |
+
)
|
| 6782 |
|
| 6783 |
# Caching
|
| 6784 |
parser.add_argument("--no-cache", action="store_true", help="Disable caching")
|
|
|
|
| 6872 |
if hasattr(args, "openrouter_api_key") and args.openrouter_api_key:
|
| 6873 |
os.environ["OPENROUTER_API_KEY"] = args.openrouter_api_key
|
| 6874 |
|
| 6875 |
+
# Parse per-tool compression profiles from CLI and env var
|
| 6876 |
+
tool_profiles = _parse_tool_profiles(args.tool_profile)
|
| 6877 |
+
|
| 6878 |
config = ProxyConfig(
|
| 6879 |
host=_get_env_str("HEADROOM_HOST", args.host),
|
| 6880 |
port=_get_env_int("HEADROOM_PORT", args.port),
|
|
|
|
| 6906 |
max_connections=_get_env_int("HEADROOM_MAX_CONNECTIONS", args.max_connections),
|
| 6907 |
max_keepalive_connections=_get_env_int("HEADROOM_MAX_KEEPALIVE", args.max_keepalive),
|
| 6908 |
http2=not args.no_http2 and _get_env_bool("HEADROOM_HTTP2", True),
|
| 6909 |
+
tool_profiles=tool_profiles if tool_profiles else None,
|
| 6910 |
)
|
| 6911 |
|
| 6912 |
# Get worker and concurrency settings
|
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adaptive compression sizing via information saturation detection.
|
| 2 |
+
|
| 3 |
+
Instead of hardcoded max_items/max_matches, this module statistically determines
|
| 4 |
+
how many items to keep by finding the "knee point" — where adding more items
|
| 5 |
+
stops providing meaningful new information.
|
| 6 |
+
|
| 7 |
+
Algorithm: Track unique bigrams as items are added in importance order. Build a
|
| 8 |
+
cumulative coverage curve. Find the knee (Kneedle algorithm) where marginal
|
| 9 |
+
information gain drops sharply. That's the optimal K.
|
| 10 |
+
|
| 11 |
+
Per-tool profiles apply a bias multiplier on the statistically-determined K:
|
| 12 |
+
- conservative (bias=1.5): keep 50% more than mathematically needed
|
| 13 |
+
- moderate (bias=1.0): trust the statistics
|
| 14 |
+
- aggressive (bias=0.7): compress harder
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import hashlib
|
| 20 |
+
import logging
|
| 21 |
+
import zlib
|
| 22 |
+
from collections.abc import Sequence
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def compute_optimal_k(
|
| 28 |
+
items: Sequence[str],
|
| 29 |
+
bias: float = 1.0,
|
| 30 |
+
min_k: int = 3,
|
| 31 |
+
max_k: int | None = None,
|
| 32 |
+
) -> int:
|
| 33 |
+
"""Compute the optimal number of items to keep using information saturation.
|
| 34 |
+
|
| 35 |
+
Three-tier decision system:
|
| 36 |
+
Tier 1 (fast path): trivial cases, near-duplicate detection
|
| 37 |
+
Tier 2 (standard): Kneedle on unique bigram coverage curve
|
| 38 |
+
Tier 3 (validation): zlib compression ratio sanity check
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
items: Sequence of string representations of items (in importance order).
|
| 42 |
+
bias: Multiplier on the knee point. >1 = keep more, <1 = keep fewer.
|
| 43 |
+
min_k: Never return fewer than this.
|
| 44 |
+
max_k: Never return more than this (None = no cap).
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
Optimal number of items to keep.
|
| 48 |
+
"""
|
| 49 |
+
n = len(items)
|
| 50 |
+
effective_max = max_k if max_k is not None else n
|
| 51 |
+
|
| 52 |
+
# Tier 1: Fast path
|
| 53 |
+
if n <= 8:
|
| 54 |
+
return n
|
| 55 |
+
|
| 56 |
+
# Check for near-total redundancy
|
| 57 |
+
unique_count = count_unique_simhash(items)
|
| 58 |
+
if unique_count <= 3:
|
| 59 |
+
k = max(min_k, unique_count)
|
| 60 |
+
return min(k, effective_max)
|
| 61 |
+
|
| 62 |
+
# Tier 2: Kneedle on unique bigram coverage
|
| 63 |
+
curve = compute_unique_bigram_curve(items)
|
| 64 |
+
knee = find_knee(curve)
|
| 65 |
+
|
| 66 |
+
if knee is None:
|
| 67 |
+
# No clear knee — content is uniformly diverse, keep ~30% as heuristic
|
| 68 |
+
knee = max(min_k, int(n * 0.3))
|
| 69 |
+
|
| 70 |
+
# Apply bias multiplier
|
| 71 |
+
k = max(min_k, int(knee * bias))
|
| 72 |
+
k = min(k, effective_max)
|
| 73 |
+
|
| 74 |
+
# Tier 3: Validate with zlib compression ratio
|
| 75 |
+
k = _validate_with_zlib(items, k, effective_max)
|
| 76 |
+
|
| 77 |
+
k = max(min_k, min(k, effective_max))
|
| 78 |
+
|
| 79 |
+
logger.debug(
|
| 80 |
+
"adaptive_sizer: n=%d unique=%d knee=%s bias=%.1f → k=%d",
|
| 81 |
+
n,
|
| 82 |
+
unique_count,
|
| 83 |
+
knee,
|
| 84 |
+
bias,
|
| 85 |
+
k,
|
| 86 |
+
)
|
| 87 |
+
return k
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def find_knee(curve: list[int]) -> int | None:
|
| 91 |
+
"""Find the knee point in a monotonically increasing curve.
|
| 92 |
+
|
| 93 |
+
Uses the Kneedle algorithm: normalize to [0,1], compute the difference
|
| 94 |
+
from the y=x diagonal, return the index of maximum difference.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
curve: List of cumulative values (e.g., unique bigram counts).
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
Index of the knee point, or None if no clear knee exists.
|
| 101 |
+
"""
|
| 102 |
+
n = len(curve)
|
| 103 |
+
if n < 3:
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
# Normalize x and y to [0, 1]
|
| 107 |
+
x_min, x_max = 0, n - 1
|
| 108 |
+
y_min, y_max = curve[0], curve[-1]
|
| 109 |
+
|
| 110 |
+
if y_max == y_min:
|
| 111 |
+
# Flat curve — all items are identical
|
| 112 |
+
return 1
|
| 113 |
+
|
| 114 |
+
x_range = x_max - x_min
|
| 115 |
+
y_range = y_max - y_min
|
| 116 |
+
|
| 117 |
+
# Compute difference from the diagonal (y = x in normalized space)
|
| 118 |
+
max_diff = -1.0
|
| 119 |
+
knee_idx = None
|
| 120 |
+
|
| 121 |
+
for i in range(n):
|
| 122 |
+
x_norm = (i - x_min) / x_range
|
| 123 |
+
y_norm = (curve[i] - y_min) / y_range
|
| 124 |
+
diff = y_norm - x_norm # For concave curves, knee is where this is maximized
|
| 125 |
+
if diff > max_diff:
|
| 126 |
+
max_diff = diff
|
| 127 |
+
knee_idx = i
|
| 128 |
+
|
| 129 |
+
# Require a meaningful deviation from diagonal
|
| 130 |
+
if max_diff < 0.05:
|
| 131 |
+
return None
|
| 132 |
+
|
| 133 |
+
# Knee is at knee_idx, but we want to include items up to and including the knee
|
| 134 |
+
# Add 1 because we're converting from 0-indexed to count
|
| 135 |
+
return knee_idx + 1 if knee_idx is not None else None
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def compute_unique_bigram_curve(items: Sequence[str]) -> list[int]:
|
| 139 |
+
"""Build cumulative unique bigram coverage curve.
|
| 140 |
+
|
| 141 |
+
For each item (in order), extracts word-level bigrams, adds them to a
|
| 142 |
+
running set, and records the total unique count.
|
| 143 |
+
|
| 144 |
+
Args:
|
| 145 |
+
items: Sequence of string items in importance order.
|
| 146 |
+
|
| 147 |
+
Returns:
|
| 148 |
+
List where curve[k] = number of unique bigrams after seeing items[0:k+1].
|
| 149 |
+
"""
|
| 150 |
+
seen_bigrams: set[tuple[str, str]] = set()
|
| 151 |
+
curve: list[int] = []
|
| 152 |
+
|
| 153 |
+
for item in items:
|
| 154 |
+
words = item.lower().split()
|
| 155 |
+
if len(words) < 2:
|
| 156 |
+
# For single-word items, use the word itself as a "unigram bigram"
|
| 157 |
+
seen_bigrams.add((words[0] if words else "", ""))
|
| 158 |
+
else:
|
| 159 |
+
for j in range(len(words) - 1):
|
| 160 |
+
seen_bigrams.add((words[j], words[j + 1]))
|
| 161 |
+
curve.append(len(seen_bigrams))
|
| 162 |
+
|
| 163 |
+
return curve
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _simhash(text: str) -> int:
|
| 167 |
+
"""Compute a 64-bit SimHash fingerprint for a text string.
|
| 168 |
+
|
| 169 |
+
Uses character 4-grams hashed to 64-bit values, then aggregates
|
| 170 |
+
via weighted bit voting.
|
| 171 |
+
|
| 172 |
+
Args:
|
| 173 |
+
text: Input text.
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
64-bit integer fingerprint.
|
| 177 |
+
"""
|
| 178 |
+
v = [0] * 64
|
| 179 |
+
text_lower = text.lower()
|
| 180 |
+
|
| 181 |
+
# Character 4-grams
|
| 182 |
+
for i in range(max(1, len(text_lower) - 3)):
|
| 183 |
+
gram = text_lower[i : i + 4]
|
| 184 |
+
h = int(hashlib.md5(gram.encode(), usedforsecurity=False).hexdigest()[:16], 16)
|
| 185 |
+
for j in range(64):
|
| 186 |
+
if h & (1 << j):
|
| 187 |
+
v[j] += 1
|
| 188 |
+
else:
|
| 189 |
+
v[j] -= 1
|
| 190 |
+
|
| 191 |
+
fingerprint = 0
|
| 192 |
+
for j in range(64):
|
| 193 |
+
if v[j] > 0:
|
| 194 |
+
fingerprint |= 1 << j
|
| 195 |
+
return fingerprint
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _hamming_distance(a: int, b: int) -> int:
|
| 199 |
+
"""Count differing bits between two 64-bit integers."""
|
| 200 |
+
return bin(a ^ b).count("1")
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def count_unique_simhash(items: Sequence[str], threshold: int = 3) -> int:
|
| 204 |
+
"""Count items with distinct content using SimHash.
|
| 205 |
+
|
| 206 |
+
Groups items by SimHash fingerprint similarity (Hamming distance <= threshold).
|
| 207 |
+
Returns the number of distinct groups.
|
| 208 |
+
|
| 209 |
+
Args:
|
| 210 |
+
items: Sequence of string items.
|
| 211 |
+
threshold: Max Hamming distance to consider items as duplicates.
|
| 212 |
+
|
| 213 |
+
Returns:
|
| 214 |
+
Number of unique content groups.
|
| 215 |
+
"""
|
| 216 |
+
if not items:
|
| 217 |
+
return 0
|
| 218 |
+
|
| 219 |
+
# Compute fingerprints
|
| 220 |
+
fingerprints = [_simhash(item) for item in items]
|
| 221 |
+
|
| 222 |
+
# Greedy clustering: assign each item to the first matching cluster
|
| 223 |
+
clusters: list[int] = [] # Representative fingerprint per cluster
|
| 224 |
+
for fp in fingerprints:
|
| 225 |
+
matched = False
|
| 226 |
+
for rep in clusters:
|
| 227 |
+
if _hamming_distance(fp, rep) <= threshold:
|
| 228 |
+
matched = True
|
| 229 |
+
break
|
| 230 |
+
if not matched:
|
| 231 |
+
clusters.append(fp)
|
| 232 |
+
|
| 233 |
+
return len(clusters)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def _validate_with_zlib(
|
| 237 |
+
items: Sequence[str],
|
| 238 |
+
k: int,
|
| 239 |
+
max_k: int,
|
| 240 |
+
tolerance: float = 0.15,
|
| 241 |
+
) -> int:
|
| 242 |
+
"""Validate K using zlib compression ratio comparison.
|
| 243 |
+
|
| 244 |
+
If the compression ratio of the selected subset differs significantly
|
| 245 |
+
from the full set, increase K.
|
| 246 |
+
|
| 247 |
+
Args:
|
| 248 |
+
items: All items.
|
| 249 |
+
k: Currently proposed K.
|
| 250 |
+
max_k: Maximum allowed K.
|
| 251 |
+
tolerance: Max allowed ratio difference (default 15%).
|
| 252 |
+
|
| 253 |
+
Returns:
|
| 254 |
+
Adjusted K (may be increased if validation fails).
|
| 255 |
+
"""
|
| 256 |
+
if k >= len(items) or k >= max_k:
|
| 257 |
+
return k
|
| 258 |
+
|
| 259 |
+
full_text = "\n".join(items).encode()
|
| 260 |
+
subset_text = "\n".join(items[:k]).encode()
|
| 261 |
+
|
| 262 |
+
# Skip validation for very small content (zlib overhead dominates)
|
| 263 |
+
if len(full_text) < 200:
|
| 264 |
+
return k
|
| 265 |
+
|
| 266 |
+
full_compressed = len(zlib.compress(full_text, level=1))
|
| 267 |
+
subset_compressed = len(zlib.compress(subset_text, level=1))
|
| 268 |
+
|
| 269 |
+
full_ratio = full_compressed / len(full_text) if full_text else 1.0
|
| 270 |
+
subset_ratio = subset_compressed / len(subset_text) if subset_text else 1.0
|
| 271 |
+
|
| 272 |
+
# If subset compresses much better than full, it's missing diverse content
|
| 273 |
+
# A lower ratio means more redundancy. If subset ratio is much lower,
|
| 274 |
+
# it means the subset is more redundant than the full set — we're missing info.
|
| 275 |
+
ratio_diff = abs(full_ratio - subset_ratio)
|
| 276 |
+
|
| 277 |
+
if ratio_diff > tolerance:
|
| 278 |
+
# Increase K by 20% to capture more diversity
|
| 279 |
+
adjusted_k = min(int(k * 1.2), max_k)
|
| 280 |
+
logger.debug(
|
| 281 |
+
"zlib validation: ratio_diff=%.3f > %.3f, adjusting k=%d → %d",
|
| 282 |
+
ratio_diff,
|
| 283 |
+
tolerance,
|
| 284 |
+
k,
|
| 285 |
+
adjusted_k,
|
| 286 |
+
)
|
| 287 |
+
return adjusted_k
|
| 288 |
+
|
| 289 |
+
return k
|
|
@@ -292,6 +292,10 @@ class ContentRouterConfig:
|
|
| 292 |
# Set to None to use DEFAULT_EXCLUDE_TOOLS, or provide custom set
|
| 293 |
exclude_tools: set[str] | None = None
|
| 294 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
|
| 296 |
# Patterns for detecting mixed content
|
| 297 |
_CODE_FENCE_PATTERN = re.compile(r"^```(\w*)\s*$", re.MULTILINE)
|
|
@@ -588,6 +592,7 @@ class ContentRouter(Transform):
|
|
| 588 |
content: str,
|
| 589 |
context: str = "",
|
| 590 |
question: str | None = None,
|
|
|
|
| 591 |
) -> RouterCompressionResult:
|
| 592 |
"""Compress content using optimal strategy based on content detection.
|
| 593 |
|
|
@@ -596,6 +601,7 @@ class ContentRouter(Transform):
|
|
| 596 |
context: Optional context for relevance-aware compression.
|
| 597 |
question: Optional question for QA-aware compression. When provided,
|
| 598 |
tokens relevant to answering this question are preserved.
|
|
|
|
| 599 |
|
| 600 |
Returns:
|
| 601 |
RouterCompressionResult with compressed content and routing metadata.
|
|
@@ -612,9 +618,9 @@ class ContentRouter(Transform):
|
|
| 612 |
strategy = self._determine_strategy(content)
|
| 613 |
|
| 614 |
if strategy == CompressionStrategy.MIXED:
|
| 615 |
-
return self._compress_mixed(content, context, question)
|
| 616 |
else:
|
| 617 |
-
return self._compress_pure(content, strategy, context, question)
|
| 618 |
|
| 619 |
def _determine_strategy(self, content: str) -> CompressionStrategy:
|
| 620 |
"""Determine the compression strategy from content analysis.
|
|
@@ -668,6 +674,7 @@ class ContentRouter(Transform):
|
|
| 668 |
content: str,
|
| 669 |
context: str,
|
| 670 |
question: str | None = None,
|
|
|
|
| 671 |
) -> RouterCompressionResult:
|
| 672 |
"""Compress mixed content by splitting and routing sections.
|
| 673 |
|
|
@@ -675,6 +682,7 @@ class ContentRouter(Transform):
|
|
| 675 |
content: Mixed content to compress.
|
| 676 |
context: User context for relevance.
|
| 677 |
question: Optional question for QA-aware compression.
|
|
|
|
| 678 |
|
| 679 |
Returns:
|
| 680 |
RouterCompressionResult with reassembled content.
|
|
@@ -698,7 +706,12 @@ class ContentRouter(Transform):
|
|
| 698 |
# Compress section
|
| 699 |
original_tokens = len(section.content.split())
|
| 700 |
compressed_content, compressed_tokens = self._apply_strategy_to_content(
|
| 701 |
-
section.content,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 702 |
)
|
| 703 |
|
| 704 |
# Preserve code fence markers
|
|
@@ -730,6 +743,7 @@ class ContentRouter(Transform):
|
|
| 730 |
strategy: CompressionStrategy,
|
| 731 |
context: str,
|
| 732 |
question: str | None = None,
|
|
|
|
| 733 |
) -> RouterCompressionResult:
|
| 734 |
"""Compress pure (non-mixed) content.
|
| 735 |
|
|
@@ -738,6 +752,7 @@ class ContentRouter(Transform):
|
|
| 738 |
strategy: Selected strategy.
|
| 739 |
context: User context.
|
| 740 |
question: Optional question for QA-aware compression.
|
|
|
|
| 741 |
|
| 742 |
Returns:
|
| 743 |
RouterCompressionResult.
|
|
@@ -745,7 +760,7 @@ class ContentRouter(Transform):
|
|
| 745 |
original_tokens = len(content.split())
|
| 746 |
|
| 747 |
compressed, compressed_tokens = self._apply_strategy_to_content(
|
| 748 |
-
content, strategy, context, question=question
|
| 749 |
)
|
| 750 |
|
| 751 |
return RouterCompressionResult(
|
|
@@ -769,6 +784,7 @@ class ContentRouter(Transform):
|
|
| 769 |
context: str,
|
| 770 |
language: str | None = None,
|
| 771 |
question: str | None = None,
|
|
|
|
| 772 |
) -> tuple[str, int]:
|
| 773 |
"""Apply a compression strategy to content.
|
| 774 |
|
|
@@ -778,6 +794,7 @@ class ContentRouter(Transform):
|
|
| 778 |
context: User context.
|
| 779 |
language: Language hint for code.
|
| 780 |
question: Optional question for QA-aware compression.
|
|
|
|
| 781 |
|
| 782 |
Returns:
|
| 783 |
Tuple of (compressed_content, compressed_token_count).
|
|
@@ -804,14 +821,14 @@ class ContentRouter(Transform):
|
|
| 804 |
if self.config.enable_smart_crusher:
|
| 805 |
crusher = self._get_smart_crusher()
|
| 806 |
if crusher:
|
| 807 |
-
result = crusher.crush(content, query=context)
|
| 808 |
return result.compressed, len(result.compressed.split())
|
| 809 |
|
| 810 |
elif strategy == CompressionStrategy.SEARCH:
|
| 811 |
if self.config.enable_search_compressor:
|
| 812 |
compressor = self._get_search_compressor()
|
| 813 |
if compressor:
|
| 814 |
-
result = compressor.compress(content, context=context)
|
| 815 |
compressed, compressed_tokens = (
|
| 816 |
result.compressed,
|
| 817 |
len(result.compressed.split()),
|
|
@@ -821,7 +838,7 @@ class ContentRouter(Transform):
|
|
| 821 |
if self.config.enable_log_compressor:
|
| 822 |
compressor = self._get_log_compressor()
|
| 823 |
if compressor:
|
| 824 |
-
result = compressor.compress(content)
|
| 825 |
compressed, compressed_tokens = (
|
| 826 |
result.compressed,
|
| 827 |
result.compressed_line_count,
|
|
@@ -1184,11 +1201,17 @@ class ContentRouter(Transform):
|
|
| 1184 |
for i, message in enumerate(messages):
|
| 1185 |
role = message.get("role", "")
|
| 1186 |
content = message.get("content", "")
|
|
|
|
| 1187 |
|
| 1188 |
# Handle list content (Anthropic format with content blocks)
|
| 1189 |
if isinstance(content, list):
|
| 1190 |
transformed_message = self._process_content_blocks(
|
| 1191 |
-
message,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1192 |
)
|
| 1193 |
transformed_messages.append(transformed_message)
|
| 1194 |
continue
|
|
@@ -1205,6 +1228,9 @@ class ContentRouter(Transform):
|
|
| 1205 |
transformed_messages.append(message)
|
| 1206 |
transforms_applied.append("router:excluded:tool")
|
| 1207 |
continue
|
|
|
|
|
|
|
|
|
|
| 1208 |
|
| 1209 |
# Protection 1: Never compress user messages
|
| 1210 |
if self.config.skip_user_messages and role == "user":
|
|
@@ -1239,7 +1265,9 @@ class ContentRouter(Transform):
|
|
| 1239 |
continue
|
| 1240 |
|
| 1241 |
# Route and compress based on content detection
|
| 1242 |
-
|
|
|
|
|
|
|
| 1243 |
|
| 1244 |
if result.compression_ratio < 0.9:
|
| 1245 |
transformed_messages.append({**message, "content": result.compressed})
|
|
@@ -1261,6 +1289,27 @@ class ContentRouter(Transform):
|
|
| 1261 |
warnings=warnings,
|
| 1262 |
)
|
| 1263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1264 |
def _process_content_blocks(
|
| 1265 |
self,
|
| 1266 |
message: dict[str, Any],
|
|
@@ -1268,6 +1317,7 @@ class ContentRouter(Transform):
|
|
| 1268 |
context: str,
|
| 1269 |
transforms_applied: list[str],
|
| 1270 |
excluded_tool_ids: set[str],
|
|
|
|
| 1271 |
) -> dict[str, Any]:
|
| 1272 |
"""Process content blocks (Anthropic format) for tool_result compression.
|
| 1273 |
|
|
@@ -1279,6 +1329,8 @@ class ContentRouter(Transform):
|
|
| 1279 |
content_blocks: List of content blocks.
|
| 1280 |
context: Context for compression.
|
| 1281 |
transforms_applied: List to append transform names to.
|
|
|
|
|
|
|
| 1282 |
|
| 1283 |
Returns:
|
| 1284 |
Transformed message with compressed content blocks.
|
|
@@ -1302,12 +1354,16 @@ class ContentRouter(Transform):
|
|
| 1302 |
transforms_applied.append("router:excluded:tool")
|
| 1303 |
continue
|
| 1304 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1305 |
tool_content = block.get("content", "")
|
| 1306 |
|
| 1307 |
# Only process string content
|
| 1308 |
if isinstance(tool_content, str) and len(tool_content) > 500:
|
| 1309 |
# Compress using content detection (will auto-detect JSON arrays, etc.)
|
| 1310 |
-
result = self.compress(tool_content, context=context)
|
| 1311 |
if result.compression_ratio < 0.9:
|
| 1312 |
new_blocks.append({**block, "content": result.compressed})
|
| 1313 |
transforms_applied.append(
|
|
|
|
| 292 |
# Set to None to use DEFAULT_EXCLUDE_TOOLS, or provide custom set
|
| 293 |
exclude_tools: set[str] | None = None
|
| 294 |
|
| 295 |
+
# Per-tool compression profiles (tool_name → CompressionProfile)
|
| 296 |
+
# Set to None to use DEFAULT_TOOL_PROFILES from config
|
| 297 |
+
tool_profiles: dict[str, Any] | None = None
|
| 298 |
+
|
| 299 |
|
| 300 |
# Patterns for detecting mixed content
|
| 301 |
_CODE_FENCE_PATTERN = re.compile(r"^```(\w*)\s*$", re.MULTILINE)
|
|
|
|
| 592 |
content: str,
|
| 593 |
context: str = "",
|
| 594 |
question: str | None = None,
|
| 595 |
+
bias: float = 1.0,
|
| 596 |
) -> RouterCompressionResult:
|
| 597 |
"""Compress content using optimal strategy based on content detection.
|
| 598 |
|
|
|
|
| 601 |
context: Optional context for relevance-aware compression.
|
| 602 |
question: Optional question for QA-aware compression. When provided,
|
| 603 |
tokens relevant to answering this question are preserved.
|
| 604 |
+
bias: Compression bias multiplier (>1 = keep more, <1 = keep fewer).
|
| 605 |
|
| 606 |
Returns:
|
| 607 |
RouterCompressionResult with compressed content and routing metadata.
|
|
|
|
| 618 |
strategy = self._determine_strategy(content)
|
| 619 |
|
| 620 |
if strategy == CompressionStrategy.MIXED:
|
| 621 |
+
return self._compress_mixed(content, context, question, bias=bias)
|
| 622 |
else:
|
| 623 |
+
return self._compress_pure(content, strategy, context, question, bias=bias)
|
| 624 |
|
| 625 |
def _determine_strategy(self, content: str) -> CompressionStrategy:
|
| 626 |
"""Determine the compression strategy from content analysis.
|
|
|
|
| 674 |
content: str,
|
| 675 |
context: str,
|
| 676 |
question: str | None = None,
|
| 677 |
+
bias: float = 1.0,
|
| 678 |
) -> RouterCompressionResult:
|
| 679 |
"""Compress mixed content by splitting and routing sections.
|
| 680 |
|
|
|
|
| 682 |
content: Mixed content to compress.
|
| 683 |
context: User context for relevance.
|
| 684 |
question: Optional question for QA-aware compression.
|
| 685 |
+
bias: Compression bias multiplier.
|
| 686 |
|
| 687 |
Returns:
|
| 688 |
RouterCompressionResult with reassembled content.
|
|
|
|
| 706 |
# Compress section
|
| 707 |
original_tokens = len(section.content.split())
|
| 708 |
compressed_content, compressed_tokens = self._apply_strategy_to_content(
|
| 709 |
+
section.content,
|
| 710 |
+
strategy,
|
| 711 |
+
context,
|
| 712 |
+
section.language,
|
| 713 |
+
question,
|
| 714 |
+
bias=bias,
|
| 715 |
)
|
| 716 |
|
| 717 |
# Preserve code fence markers
|
|
|
|
| 743 |
strategy: CompressionStrategy,
|
| 744 |
context: str,
|
| 745 |
question: str | None = None,
|
| 746 |
+
bias: float = 1.0,
|
| 747 |
) -> RouterCompressionResult:
|
| 748 |
"""Compress pure (non-mixed) content.
|
| 749 |
|
|
|
|
| 752 |
strategy: Selected strategy.
|
| 753 |
context: User context.
|
| 754 |
question: Optional question for QA-aware compression.
|
| 755 |
+
bias: Compression bias multiplier.
|
| 756 |
|
| 757 |
Returns:
|
| 758 |
RouterCompressionResult.
|
|
|
|
| 760 |
original_tokens = len(content.split())
|
| 761 |
|
| 762 |
compressed, compressed_tokens = self._apply_strategy_to_content(
|
| 763 |
+
content, strategy, context, question=question, bias=bias
|
| 764 |
)
|
| 765 |
|
| 766 |
return RouterCompressionResult(
|
|
|
|
| 784 |
context: str,
|
| 785 |
language: str | None = None,
|
| 786 |
question: str | None = None,
|
| 787 |
+
bias: float = 1.0,
|
| 788 |
) -> tuple[str, int]:
|
| 789 |
"""Apply a compression strategy to content.
|
| 790 |
|
|
|
|
| 794 |
context: User context.
|
| 795 |
language: Language hint for code.
|
| 796 |
question: Optional question for QA-aware compression.
|
| 797 |
+
bias: Compression bias multiplier (>1 = keep more, <1 = keep fewer).
|
| 798 |
|
| 799 |
Returns:
|
| 800 |
Tuple of (compressed_content, compressed_token_count).
|
|
|
|
| 821 |
if self.config.enable_smart_crusher:
|
| 822 |
crusher = self._get_smart_crusher()
|
| 823 |
if crusher:
|
| 824 |
+
result = crusher.crush(content, query=context, bias=bias)
|
| 825 |
return result.compressed, len(result.compressed.split())
|
| 826 |
|
| 827 |
elif strategy == CompressionStrategy.SEARCH:
|
| 828 |
if self.config.enable_search_compressor:
|
| 829 |
compressor = self._get_search_compressor()
|
| 830 |
if compressor:
|
| 831 |
+
result = compressor.compress(content, context=context, bias=bias)
|
| 832 |
compressed, compressed_tokens = (
|
| 833 |
result.compressed,
|
| 834 |
len(result.compressed.split()),
|
|
|
|
| 838 |
if self.config.enable_log_compressor:
|
| 839 |
compressor = self._get_log_compressor()
|
| 840 |
if compressor:
|
| 841 |
+
result = compressor.compress(content, bias=bias)
|
| 842 |
compressed, compressed_tokens = (
|
| 843 |
result.compressed,
|
| 844 |
result.compressed_line_count,
|
|
|
|
| 1201 |
for i, message in enumerate(messages):
|
| 1202 |
role = message.get("role", "")
|
| 1203 |
content = message.get("content", "")
|
| 1204 |
+
bias = 1.0 # Default bias, may be overridden for tool messages
|
| 1205 |
|
| 1206 |
# Handle list content (Anthropic format with content blocks)
|
| 1207 |
if isinstance(content, list):
|
| 1208 |
transformed_message = self._process_content_blocks(
|
| 1209 |
+
message,
|
| 1210 |
+
content,
|
| 1211 |
+
context,
|
| 1212 |
+
transforms_applied,
|
| 1213 |
+
excluded_tool_ids,
|
| 1214 |
+
tool_name_map=tool_name_map,
|
| 1215 |
)
|
| 1216 |
transformed_messages.append(transformed_message)
|
| 1217 |
continue
|
|
|
|
| 1228 |
transformed_messages.append(message)
|
| 1229 |
transforms_applied.append("router:excluded:tool")
|
| 1230 |
continue
|
| 1231 |
+
# Look up tool-specific compression bias for OpenAI tool messages
|
| 1232 |
+
tool_name = tool_name_map.get(tool_call_id, "")
|
| 1233 |
+
bias = self._get_tool_bias(tool_name) if tool_name else 1.0
|
| 1234 |
|
| 1235 |
# Protection 1: Never compress user messages
|
| 1236 |
if self.config.skip_user_messages and role == "user":
|
|
|
|
| 1265 |
continue
|
| 1266 |
|
| 1267 |
# Route and compress based on content detection
|
| 1268 |
+
# Use tool-specific bias for tool messages, default 1.0 for others
|
| 1269 |
+
msg_bias = bias if role == "tool" else 1.0
|
| 1270 |
+
result = self.compress(content, context=context, bias=msg_bias)
|
| 1271 |
|
| 1272 |
if result.compression_ratio < 0.9:
|
| 1273 |
transformed_messages.append({**message, "content": result.compressed})
|
|
|
|
| 1289 |
warnings=warnings,
|
| 1290 |
)
|
| 1291 |
|
| 1292 |
+
def _get_tool_bias(self, tool_name: str) -> float:
|
| 1293 |
+
"""Look up compression bias for a tool name.
|
| 1294 |
+
|
| 1295 |
+
Checks user-configured profiles first, then DEFAULT_TOOL_PROFILES.
|
| 1296 |
+
Returns 1.0 (moderate) if no profile is configured.
|
| 1297 |
+
"""
|
| 1298 |
+
from ..config import DEFAULT_TOOL_PROFILES
|
| 1299 |
+
|
| 1300 |
+
# Check user-configured profiles
|
| 1301 |
+
if self.config.tool_profiles:
|
| 1302 |
+
profile = self.config.tool_profiles.get(tool_name)
|
| 1303 |
+
if profile:
|
| 1304 |
+
return float(profile.bias)
|
| 1305 |
+
|
| 1306 |
+
# Check default profiles
|
| 1307 |
+
profile = DEFAULT_TOOL_PROFILES.get(tool_name)
|
| 1308 |
+
if profile:
|
| 1309 |
+
return profile.bias
|
| 1310 |
+
|
| 1311 |
+
return 1.0 # Default: moderate
|
| 1312 |
+
|
| 1313 |
def _process_content_blocks(
|
| 1314 |
self,
|
| 1315 |
message: dict[str, Any],
|
|
|
|
| 1317 |
context: str,
|
| 1318 |
transforms_applied: list[str],
|
| 1319 |
excluded_tool_ids: set[str],
|
| 1320 |
+
tool_name_map: dict[str, str] | None = None,
|
| 1321 |
) -> dict[str, Any]:
|
| 1322 |
"""Process content blocks (Anthropic format) for tool_result compression.
|
| 1323 |
|
|
|
|
| 1329 |
content_blocks: List of content blocks.
|
| 1330 |
context: Context for compression.
|
| 1331 |
transforms_applied: List to append transform names to.
|
| 1332 |
+
excluded_tool_ids: Tool IDs to skip compression for.
|
| 1333 |
+
tool_name_map: Mapping from tool_call_id to tool_name for profile lookup.
|
| 1334 |
|
| 1335 |
Returns:
|
| 1336 |
Transformed message with compressed content blocks.
|
|
|
|
| 1354 |
transforms_applied.append("router:excluded:tool")
|
| 1355 |
continue
|
| 1356 |
|
| 1357 |
+
# Look up tool-specific compression bias
|
| 1358 |
+
tool_name = (tool_name_map or {}).get(tool_use_id, "")
|
| 1359 |
+
bias = self._get_tool_bias(tool_name) if tool_name else 1.0
|
| 1360 |
+
|
| 1361 |
tool_content = block.get("content", "")
|
| 1362 |
|
| 1363 |
# Only process string content
|
| 1364 |
if isinstance(tool_content, str) and len(tool_content) > 500:
|
| 1365 |
# Compress using content detection (will auto-detect JSON arrays, etc.)
|
| 1366 |
+
result = self.compress(tool_content, context=context, bias=bias)
|
| 1367 |
if result.compression_ratio < 0.9:
|
| 1368 |
new_blocks.append({**block, "content": result.compressed})
|
| 1369 |
transforms_applied.append(
|
|
@@ -176,12 +176,13 @@ class LogCompressor:
|
|
| 176 |
"""
|
| 177 |
self.config = config or LogCompressorConfig()
|
| 178 |
|
| 179 |
-
def compress(self, content: str, context: str = "") -> LogCompressionResult:
|
| 180 |
"""Compress log output.
|
| 181 |
|
| 182 |
Args:
|
| 183 |
content: Raw log output.
|
| 184 |
context: User query context (unused for now).
|
|
|
|
| 185 |
|
| 186 |
Returns:
|
| 187 |
LogCompressionResult with compressed output and metadata.
|
|
@@ -204,8 +205,8 @@ class LogCompressor:
|
|
| 204 |
# Parse and categorize lines
|
| 205 |
log_lines = self._parse_lines(lines)
|
| 206 |
|
| 207 |
-
# Select important lines
|
| 208 |
-
selected = self._select_lines(log_lines)
|
| 209 |
|
| 210 |
# Format output with summaries
|
| 211 |
compressed, stats = self._format_output(selected, log_lines)
|
|
@@ -319,10 +320,22 @@ class LogCompressor:
|
|
| 319 |
|
| 320 |
return min(1.0, score)
|
| 321 |
|
| 322 |
-
def _select_lines(self, log_lines: list[LogLine]) -> list[LogLine]:
|
| 323 |
-
"""Select important lines to keep."""
|
|
|
|
|
|
|
| 324 |
selected: list[LogLine] = []
|
| 325 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
# Group by category
|
| 327 |
errors: list[LogLine] = []
|
| 328 |
fails: list[LogLine] = []
|
|
@@ -381,11 +394,11 @@ class LogCompressor:
|
|
| 381 |
# Sort by line number and dedupe
|
| 382 |
selected = sorted(set(selected), key=lambda x: x.line_number)
|
| 383 |
|
| 384 |
-
#
|
| 385 |
-
if len(selected) >
|
| 386 |
# Keep most important lines
|
| 387 |
selected = sorted(selected, key=lambda x: x.score, reverse=True)
|
| 388 |
-
selected = selected[:
|
| 389 |
selected = sorted(selected, key=lambda x: x.line_number)
|
| 390 |
|
| 391 |
return selected
|
|
|
|
| 176 |
"""
|
| 177 |
self.config = config or LogCompressorConfig()
|
| 178 |
|
| 179 |
+
def compress(self, content: str, context: str = "", bias: float = 1.0) -> LogCompressionResult:
|
| 180 |
"""Compress log output.
|
| 181 |
|
| 182 |
Args:
|
| 183 |
content: Raw log output.
|
| 184 |
context: User query context (unused for now).
|
| 185 |
+
bias: Compression bias multiplier (>1 = keep more, <1 = keep fewer).
|
| 186 |
|
| 187 |
Returns:
|
| 188 |
LogCompressionResult with compressed output and metadata.
|
|
|
|
| 205 |
# Parse and categorize lines
|
| 206 |
log_lines = self._parse_lines(lines)
|
| 207 |
|
| 208 |
+
# Select important lines (with adaptive sizing)
|
| 209 |
+
selected = self._select_lines(log_lines, bias=bias)
|
| 210 |
|
| 211 |
# Format output with summaries
|
| 212 |
compressed, stats = self._format_output(selected, log_lines)
|
|
|
|
| 320 |
|
| 321 |
return min(1.0, score)
|
| 322 |
|
| 323 |
+
def _select_lines(self, log_lines: list[LogLine], bias: float = 1.0) -> list[LogLine]:
|
| 324 |
+
"""Select important lines to keep using adaptive sizing."""
|
| 325 |
+
from .adaptive_sizer import compute_optimal_k
|
| 326 |
+
|
| 327 |
selected: list[LogLine] = []
|
| 328 |
|
| 329 |
+
# Compute adaptive line budget from the FULL log output
|
| 330 |
+
# This determines "how many lines of value exist" across the entire log
|
| 331 |
+
all_line_strings = [line.content for line in log_lines]
|
| 332 |
+
adaptive_max = compute_optimal_k(
|
| 333 |
+
all_line_strings,
|
| 334 |
+
bias=bias,
|
| 335 |
+
min_k=10,
|
| 336 |
+
max_k=self.config.max_total_lines,
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
# Group by category
|
| 340 |
errors: list[LogLine] = []
|
| 341 |
fails: list[LogLine] = []
|
|
|
|
| 394 |
# Sort by line number and dedupe
|
| 395 |
selected = sorted(set(selected), key=lambda x: x.line_number)
|
| 396 |
|
| 397 |
+
# Apply adaptive line limit
|
| 398 |
+
if len(selected) > adaptive_max:
|
| 399 |
# Keep most important lines
|
| 400 |
selected = sorted(selected, key=lambda x: x.score, reverse=True)
|
| 401 |
+
selected = selected[:adaptive_max]
|
| 402 |
selected = sorted(selected, key=lambda x: x.line_number)
|
| 403 |
|
| 404 |
return selected
|
|
@@ -106,12 +106,14 @@ class SearchCompressor:
|
|
| 106 |
self,
|
| 107 |
content: str,
|
| 108 |
context: str = "",
|
|
|
|
| 109 |
) -> SearchCompressionResult:
|
| 110 |
"""Compress search results.
|
| 111 |
|
| 112 |
Args:
|
| 113 |
content: Raw grep/ripgrep output.
|
| 114 |
context: User query context for relevance scoring.
|
|
|
|
| 115 |
|
| 116 |
Returns:
|
| 117 |
SearchCompressionResult with compressed output and metadata.
|
|
@@ -135,8 +137,8 @@ class SearchCompressor:
|
|
| 135 |
# Score matches by relevance
|
| 136 |
self._score_matches(file_matches, context)
|
| 137 |
|
| 138 |
-
# Select top matches per file
|
| 139 |
-
selected = self._select_matches(file_matches)
|
| 140 |
|
| 141 |
# Format compressed output
|
| 142 |
compressed, summaries = self._format_output(selected, file_matches)
|
|
@@ -235,8 +237,11 @@ class SearchCompressor:
|
|
| 235 |
def _select_matches(
|
| 236 |
self,
|
| 237 |
file_matches: dict[str, FileMatches],
|
|
|
|
| 238 |
) -> dict[str, FileMatches]:
|
| 239 |
-
"""Select top matches per file and globally."""
|
|
|
|
|
|
|
| 240 |
selected: dict[str, FileMatches] = {}
|
| 241 |
|
| 242 |
# Sort files by total match score (highest first)
|
|
@@ -249,9 +254,22 @@ class SearchCompressor:
|
|
| 249 |
# Limit number of files
|
| 250 |
sorted_files = sorted_files[: self.config.max_files]
|
| 251 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
total_selected = 0
|
| 253 |
for file_path, fm in sorted_files:
|
| 254 |
-
if total_selected >=
|
| 255 |
break
|
| 256 |
|
| 257 |
# Sort matches by score
|
|
@@ -261,7 +279,7 @@ class SearchCompressor:
|
|
| 261 |
file_selected: list[SearchMatch] = []
|
| 262 |
remaining_slots = min(
|
| 263 |
self.config.max_matches_per_file,
|
| 264 |
-
|
| 265 |
)
|
| 266 |
|
| 267 |
# Always include first and last if configured
|
|
|
|
| 106 |
self,
|
| 107 |
content: str,
|
| 108 |
context: str = "",
|
| 109 |
+
bias: float = 1.0,
|
| 110 |
) -> SearchCompressionResult:
|
| 111 |
"""Compress search results.
|
| 112 |
|
| 113 |
Args:
|
| 114 |
content: Raw grep/ripgrep output.
|
| 115 |
context: User query context for relevance scoring.
|
| 116 |
+
bias: Compression bias multiplier (>1 = keep more, <1 = keep fewer).
|
| 117 |
|
| 118 |
Returns:
|
| 119 |
SearchCompressionResult with compressed output and metadata.
|
|
|
|
| 137 |
# Score matches by relevance
|
| 138 |
self._score_matches(file_matches, context)
|
| 139 |
|
| 140 |
+
# Select top matches per file (with adaptive sizing)
|
| 141 |
+
selected = self._select_matches(file_matches, bias=bias)
|
| 142 |
|
| 143 |
# Format compressed output
|
| 144 |
compressed, summaries = self._format_output(selected, file_matches)
|
|
|
|
| 237 |
def _select_matches(
|
| 238 |
self,
|
| 239 |
file_matches: dict[str, FileMatches],
|
| 240 |
+
bias: float = 1.0,
|
| 241 |
) -> dict[str, FileMatches]:
|
| 242 |
+
"""Select top matches per file and globally using adaptive sizing."""
|
| 243 |
+
from .adaptive_sizer import compute_optimal_k
|
| 244 |
+
|
| 245 |
selected: dict[str, FileMatches] = {}
|
| 246 |
|
| 247 |
# Sort files by total match score (highest first)
|
|
|
|
| 254 |
# Limit number of files
|
| 255 |
sorted_files = sorted_files[: self.config.max_files]
|
| 256 |
|
| 257 |
+
# Compute adaptive total matches limit
|
| 258 |
+
all_match_strings = [
|
| 259 |
+
f"{file_path}:{m.line_number}:{m.content}"
|
| 260 |
+
for file_path, fm in sorted_files
|
| 261 |
+
for m in fm.matches
|
| 262 |
+
]
|
| 263 |
+
adaptive_total = compute_optimal_k(
|
| 264 |
+
all_match_strings,
|
| 265 |
+
bias=bias,
|
| 266 |
+
min_k=5,
|
| 267 |
+
max_k=self.config.max_total_matches,
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
total_selected = 0
|
| 271 |
for file_path, fm in sorted_files:
|
| 272 |
+
if total_selected >= adaptive_total:
|
| 273 |
break
|
| 274 |
|
| 275 |
# Sort matches by score
|
|
|
|
| 279 |
file_selected: list[SearchMatch] = []
|
| 280 |
remaining_slots = min(
|
| 281 |
self.config.max_matches_per_file,
|
| 282 |
+
adaptive_total - total_selected,
|
| 283 |
)
|
| 284 |
|
| 285 |
# Always include first and last if configured
|
|
@@ -1440,7 +1440,7 @@ class SmartCrusher(Transform):
|
|
| 1440 |
CompressionStrategy.SMART_SAMPLE: AnchorDataPattern.GENERIC,
|
| 1441 |
}.get(strategy, AnchorDataPattern.GENERIC)
|
| 1442 |
|
| 1443 |
-
def crush(self, content: str, query: str = "") -> CrushResult:
|
| 1444 |
"""Crush content string directly (for use by ContentRouter).
|
| 1445 |
|
| 1446 |
This is a simplified interface for compressing a single content string,
|
|
@@ -1449,12 +1449,13 @@ class SmartCrusher(Transform):
|
|
| 1449 |
Args:
|
| 1450 |
content: JSON string content to compress.
|
| 1451 |
query: Query context for relevance-based compression.
|
|
|
|
| 1452 |
|
| 1453 |
Returns:
|
| 1454 |
CrushResult with compressed content and metadata.
|
| 1455 |
"""
|
| 1456 |
compressed, was_modified, analysis_info = self._smart_crush_content(
|
| 1457 |
-
content, query_context=query
|
| 1458 |
)
|
| 1459 |
return CrushResult(
|
| 1460 |
compressed=compressed,
|
|
@@ -2088,7 +2089,11 @@ class SmartCrusher(Transform):
|
|
| 2088 |
return " ".join(context_parts)
|
| 2089 |
|
| 2090 |
def _smart_crush_content(
|
| 2091 |
-
self,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2092 |
) -> tuple[str, bool, str]:
|
| 2093 |
"""
|
| 2094 |
Apply smart crushing to content.
|
|
@@ -2100,6 +2105,7 @@ class SmartCrusher(Transform):
|
|
| 2100 |
content: Content to crush (JSON or plain text).
|
| 2101 |
query_context: Context string from user messages for relevance scoring.
|
| 2102 |
tool_name: Name of the tool that produced this output.
|
|
|
|
| 2103 |
|
| 2104 |
Returns:
|
| 2105 |
Tuple of (crushed_content, was_modified, analysis_info).
|
|
@@ -2113,7 +2119,7 @@ class SmartCrusher(Transform):
|
|
| 2113 |
|
| 2114 |
# Recursively process and crush arrays
|
| 2115 |
crushed, info, ccr_markers = self._process_value(
|
| 2116 |
-
parsed, query_context=query_context, tool_name=tool_name
|
| 2117 |
)
|
| 2118 |
|
| 2119 |
result = safe_json_dumps(crushed, indent=None)
|
|
@@ -2132,7 +2138,12 @@ class SmartCrusher(Transform):
|
|
| 2132 |
return result, was_modified, info
|
| 2133 |
|
| 2134 |
def _process_value(
|
| 2135 |
-
self,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2136 |
) -> tuple[Any, str, list[tuple[str, int, int]]]:
|
| 2137 |
"""Recursively process a value, crushing arrays where appropriate.
|
| 2138 |
|
|
@@ -2148,7 +2159,9 @@ class SmartCrusher(Transform):
|
|
| 2148 |
# Must have enough items AND all items must be dicts (not mixed types)
|
| 2149 |
all_dicts = value and all(isinstance(item, dict) for item in value)
|
| 2150 |
if len(value) >= self.config.min_items_to_analyze and all_dicts:
|
| 2151 |
-
crushed, strategy, ccr_hash = self._crush_array(
|
|
|
|
|
|
|
| 2152 |
info_parts.append(f"{strategy}({len(value)}->{len(crushed)})")
|
| 2153 |
|
| 2154 |
# Track CCR marker for later injection
|
|
@@ -2161,7 +2174,7 @@ class SmartCrusher(Transform):
|
|
| 2161 |
processed = []
|
| 2162 |
for item in value:
|
| 2163 |
p_item, p_info, p_markers = self._process_value(
|
| 2164 |
-
item, depth + 1, query_context, tool_name
|
| 2165 |
)
|
| 2166 |
processed.append(p_item)
|
| 2167 |
if p_info:
|
|
@@ -2174,7 +2187,7 @@ class SmartCrusher(Transform):
|
|
| 2174 |
processed_dict: dict[str, Any] = {}
|
| 2175 |
for k, v in value.items():
|
| 2176 |
p_val, p_info, p_markers = self._process_value(
|
| 2177 |
-
v, depth + 1, query_context, tool_name
|
| 2178 |
)
|
| 2179 |
processed_dict[k] = p_val
|
| 2180 |
if p_info:
|
|
@@ -2186,7 +2199,11 @@ class SmartCrusher(Transform):
|
|
| 2186 |
return value, "", []
|
| 2187 |
|
| 2188 |
def _crush_array(
|
| 2189 |
-
self,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2190 |
) -> tuple[list, str, str | None]:
|
| 2191 |
"""Crush an array using statistical analysis and relevance scoring.
|
| 2192 |
|
|
@@ -2199,17 +2216,34 @@ class SmartCrusher(Transform):
|
|
| 2199 |
Feedback-aware: Uses learned patterns to adjust compression aggressiveness.
|
| 2200 |
High retrieval rate for a tool → compress less aggressively.
|
| 2201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2202 |
Returns:
|
| 2203 |
Tuple of (crushed_items, strategy_info, ccr_hash).
|
| 2204 |
ccr_hash is the hash for retrieval if CCR is enabled, None otherwise.
|
| 2205 |
"""
|
| 2206 |
-
# BOUNDARY CHECK:
|
| 2207 |
-
|
| 2208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2209 |
|
| 2210 |
# Get feedback hints if enabled
|
| 2211 |
# THREAD-SAFETY: Use a local effective_max_items instead of mutating shared config
|
| 2212 |
-
effective_max_items =
|
| 2213 |
hints_applied = False
|
| 2214 |
toin_hint_applied = False
|
| 2215 |
|
|
|
|
| 1440 |
CompressionStrategy.SMART_SAMPLE: AnchorDataPattern.GENERIC,
|
| 1441 |
}.get(strategy, AnchorDataPattern.GENERIC)
|
| 1442 |
|
| 1443 |
+
def crush(self, content: str, query: str = "", bias: float = 1.0) -> CrushResult:
|
| 1444 |
"""Crush content string directly (for use by ContentRouter).
|
| 1445 |
|
| 1446 |
This is a simplified interface for compressing a single content string,
|
|
|
|
| 1449 |
Args:
|
| 1450 |
content: JSON string content to compress.
|
| 1451 |
query: Query context for relevance-based compression.
|
| 1452 |
+
bias: Compression bias multiplier (>1 = keep more, <1 = keep fewer).
|
| 1453 |
|
| 1454 |
Returns:
|
| 1455 |
CrushResult with compressed content and metadata.
|
| 1456 |
"""
|
| 1457 |
compressed, was_modified, analysis_info = self._smart_crush_content(
|
| 1458 |
+
content, query_context=query, bias=bias
|
| 1459 |
)
|
| 1460 |
return CrushResult(
|
| 1461 |
compressed=compressed,
|
|
|
|
| 2089 |
return " ".join(context_parts)
|
| 2090 |
|
| 2091 |
def _smart_crush_content(
|
| 2092 |
+
self,
|
| 2093 |
+
content: str,
|
| 2094 |
+
query_context: str = "",
|
| 2095 |
+
tool_name: str | None = None,
|
| 2096 |
+
bias: float = 1.0,
|
| 2097 |
) -> tuple[str, bool, str]:
|
| 2098 |
"""
|
| 2099 |
Apply smart crushing to content.
|
|
|
|
| 2105 |
content: Content to crush (JSON or plain text).
|
| 2106 |
query_context: Context string from user messages for relevance scoring.
|
| 2107 |
tool_name: Name of the tool that produced this output.
|
| 2108 |
+
bias: Compression bias multiplier (>1 = keep more, <1 = keep fewer).
|
| 2109 |
|
| 2110 |
Returns:
|
| 2111 |
Tuple of (crushed_content, was_modified, analysis_info).
|
|
|
|
| 2119 |
|
| 2120 |
# Recursively process and crush arrays
|
| 2121 |
crushed, info, ccr_markers = self._process_value(
|
| 2122 |
+
parsed, query_context=query_context, tool_name=tool_name, bias=bias
|
| 2123 |
)
|
| 2124 |
|
| 2125 |
result = safe_json_dumps(crushed, indent=None)
|
|
|
|
| 2138 |
return result, was_modified, info
|
| 2139 |
|
| 2140 |
def _process_value(
|
| 2141 |
+
self,
|
| 2142 |
+
value: Any,
|
| 2143 |
+
depth: int = 0,
|
| 2144 |
+
query_context: str = "",
|
| 2145 |
+
tool_name: str | None = None,
|
| 2146 |
+
bias: float = 1.0,
|
| 2147 |
) -> tuple[Any, str, list[tuple[str, int, int]]]:
|
| 2148 |
"""Recursively process a value, crushing arrays where appropriate.
|
| 2149 |
|
|
|
|
| 2159 |
# Must have enough items AND all items must be dicts (not mixed types)
|
| 2160 |
all_dicts = value and all(isinstance(item, dict) for item in value)
|
| 2161 |
if len(value) >= self.config.min_items_to_analyze and all_dicts:
|
| 2162 |
+
crushed, strategy, ccr_hash = self._crush_array(
|
| 2163 |
+
value, query_context, tool_name, bias=bias
|
| 2164 |
+
)
|
| 2165 |
info_parts.append(f"{strategy}({len(value)}->{len(crushed)})")
|
| 2166 |
|
| 2167 |
# Track CCR marker for later injection
|
|
|
|
| 2174 |
processed = []
|
| 2175 |
for item in value:
|
| 2176 |
p_item, p_info, p_markers = self._process_value(
|
| 2177 |
+
item, depth + 1, query_context, tool_name, bias=bias
|
| 2178 |
)
|
| 2179 |
processed.append(p_item)
|
| 2180 |
if p_info:
|
|
|
|
| 2187 |
processed_dict: dict[str, Any] = {}
|
| 2188 |
for k, v in value.items():
|
| 2189 |
p_val, p_info, p_markers = self._process_value(
|
| 2190 |
+
v, depth + 1, query_context, tool_name, bias=bias
|
| 2191 |
)
|
| 2192 |
processed_dict[k] = p_val
|
| 2193 |
if p_info:
|
|
|
|
| 2199 |
return value, "", []
|
| 2200 |
|
| 2201 |
def _crush_array(
|
| 2202 |
+
self,
|
| 2203 |
+
items: list[dict],
|
| 2204 |
+
query_context: str = "",
|
| 2205 |
+
tool_name: str | None = None,
|
| 2206 |
+
bias: float = 1.0,
|
| 2207 |
) -> tuple[list, str, str | None]:
|
| 2208 |
"""Crush an array using statistical analysis and relevance scoring.
|
| 2209 |
|
|
|
|
| 2216 |
Feedback-aware: Uses learned patterns to adjust compression aggressiveness.
|
| 2217 |
High retrieval rate for a tool → compress less aggressively.
|
| 2218 |
|
| 2219 |
+
Args:
|
| 2220 |
+
items: List of dict items to compress.
|
| 2221 |
+
query_context: Context string from user messages for relevance scoring.
|
| 2222 |
+
tool_name: Name of the tool that produced this output.
|
| 2223 |
+
bias: Compression bias multiplier (>1 = keep more, <1 = keep fewer).
|
| 2224 |
+
|
| 2225 |
Returns:
|
| 2226 |
Tuple of (crushed_items, strategy_info, ccr_hash).
|
| 2227 |
ccr_hash is the hash for retrieval if CCR is enabled, None otherwise.
|
| 2228 |
"""
|
| 2229 |
+
# BOUNDARY CHECK: Use adaptive sizing instead of hardcoded limit
|
| 2230 |
+
# compute_optimal_k handles trivial cases (n <= 8 → keep all)
|
| 2231 |
+
from .adaptive_sizer import compute_optimal_k
|
| 2232 |
+
|
| 2233 |
+
item_strings = [json.dumps(item, default=str) for item in items]
|
| 2234 |
+
adaptive_k = compute_optimal_k(
|
| 2235 |
+
item_strings,
|
| 2236 |
+
bias=bias,
|
| 2237 |
+
min_k=3,
|
| 2238 |
+
max_k=self.config.max_items_after_crush if self.config.max_items_after_crush else None,
|
| 2239 |
+
)
|
| 2240 |
+
|
| 2241 |
+
if len(items) <= adaptive_k:
|
| 2242 |
+
return items, "none:adaptive_at_limit", None
|
| 2243 |
|
| 2244 |
# Get feedback hints if enabled
|
| 2245 |
# THREAD-SAFETY: Use a local effective_max_items instead of mutating shared config
|
| 2246 |
+
effective_max_items = adaptive_k
|
| 2247 |
hints_applied = False
|
| 2248 |
toin_hint_applied = False
|
| 2249 |
|