chopratejas commited on
Commit
c8388c1
·
1 Parent(s): a4bb833

fix: harden edge cases, expand tool exclusions, and fix mypy errors

Browse files

- Fix division-by-zero in OpenAI cache, BM25 scorer, BLEU metrics, and smart_crusher
- Add null safety for SQLite memory store JSON fields
- Add thread safety lock for CCR retrieval counter
- Add timeout/error handling for CCR stream collection
- Safer error logging and JSON serialization fallback in proxy server
- Return explicit zero stats on compression failure instead of None defaults
- Guard against missing "messages" key in ASGI and LiteLLM integrations
- Add aclose() for proper httpx client cleanup in ASGI middleware
- Expand DEFAULT_EXCLUDE_TOOLS to include Grep, Write, Edit
- Revert protect_recent_reads_fraction to 0.0 (protect all excluded-tool outputs)
- Fix whitespace waste detection to use tokenizer for both original and normalized
- Fix top-waste-requests sorting by tokens_saved instead of tokens_before
- Fix TOCTOU race in semantic cache _touch() method
- Fix mypy errors: type annotations for ASGI receive/send and proxy error logging

headroom/cache/openai.py CHANGED
@@ -372,7 +372,7 @@ class OpenAICacheOptimizer(BaseCacheOptimizer):
372
 
373
  # Estimate cacheable portion (system + early messages)
374
  # OpenAI caches the longest matching prefix
375
- cacheable_ratio = min(1.0, system_tokens / total_tokens)
376
 
377
  # Check if prefix is stable
378
  current_hash = self._compute_prefix_hash(system_content)
 
372
 
373
  # Estimate cacheable portion (system + early messages)
374
  # OpenAI caches the longest matching prefix
375
+ cacheable_ratio = min(1.0, system_tokens / total_tokens) if total_tokens > 0 else 0.0
376
 
377
  # Check if prefix is stable
378
  current_hash = self._compute_prefix_hash(system_content)
headroom/cache/semantic.py CHANGED
@@ -285,11 +285,13 @@ class SemanticCache:
285
 
286
  def _touch(self, key: str) -> None:
287
  """Update access time and move to end of LRU."""
288
- if key in self._cache:
289
  entry = self._cache.pop(key)
290
- entry.last_accessed = time.time()
291
- entry.access_count += 1
292
- self._cache[key] = entry
 
 
293
 
294
  def _evict_oldest(self) -> None:
295
  """Evict the oldest (least recently used) entry."""
 
285
 
286
  def _touch(self, key: str) -> None:
287
  """Update access time and move to end of LRU."""
288
+ try:
289
  entry = self._cache.pop(key)
290
+ except KeyError:
291
+ return
292
+ entry.last_accessed = time.time()
293
+ entry.access_count += 1
294
+ self._cache[key] = entry
295
 
296
  def _evict_oldest(self) -> None:
297
  """Evict the oldest (least recently used) entry."""
headroom/ccr/response_handler.py CHANGED
@@ -96,6 +96,7 @@ class CCRResponseHandler:
96
  def __init__(self, config: ResponseHandlerConfig | None = None):
97
  self.config = config or ResponseHandlerConfig()
98
  self._retrieval_count = 0
 
99
 
100
  def has_ccr_tool_calls(
101
  self,
@@ -432,7 +433,8 @@ class CCRResponseHandler:
432
  break
433
 
434
  rounds += 1
435
- self._retrieval_count += len(ccr_calls)
 
436
 
437
  logger.info(f"CCR: Handling {len(ccr_calls)} retrieval(s) in round {rounds}")
438
 
@@ -625,9 +627,16 @@ class StreamingCCRHandler:
625
  if self.buffer.detected_ccr:
626
  logger.info("CCR: Detected tool call in stream, switching to buffered mode")
627
 
628
- # Collect rest of stream
629
- async for chunk in stream_iterator:
630
- self.buffer.add_chunk(chunk)
 
 
 
 
 
 
 
631
 
632
  # Parse the complete response
633
  try:
 
96
  def __init__(self, config: ResponseHandlerConfig | None = None):
97
  self.config = config or ResponseHandlerConfig()
98
  self._retrieval_count = 0
99
+ self._retrieval_count_lock = __import__("threading").Lock()
100
 
101
  def has_ccr_tool_calls(
102
  self,
 
433
  break
434
 
435
  rounds += 1
436
+ with self._retrieval_count_lock:
437
+ self._retrieval_count += len(ccr_calls)
438
 
439
  logger.info(f"CCR: Handling {len(ccr_calls)} retrieval(s) in round {rounds}")
440
 
 
627
  if self.buffer.detected_ccr:
628
  logger.info("CCR: Detected tool call in stream, switching to buffered mode")
629
 
630
+ # Collect rest of stream with timeout to prevent indefinite blocking
631
+ import asyncio
632
+
633
+ try:
634
+ async for chunk in stream_iterator:
635
+ self.buffer.add_chunk(chunk)
636
+ except asyncio.TimeoutError:
637
+ logger.warning("CCR: Timed out collecting rest of stream")
638
+ except Exception as e:
639
+ logger.error(f"CCR: Error collecting rest of stream: {e}")
640
 
641
  # Parse the complete response
642
  try:
headroom/compress.py CHANGED
@@ -163,7 +163,13 @@ def compress(
163
 
164
  except Exception as e:
165
  logger.warning("Compression failed, returning original messages: %s", e)
166
- return CompressResult(messages=messages)
 
 
 
 
 
 
167
 
168
 
169
  def _get_pipeline() -> Any:
 
163
 
164
  except Exception as e:
165
  logger.warning("Compression failed, returning original messages: %s", e)
166
+ return CompressResult(
167
+ messages=messages,
168
+ tokens_before=0,
169
+ tokens_after=0,
170
+ tokens_saved=0,
171
+ compression_ratio=0.0,
172
+ )
173
 
174
 
175
  def _get_pipeline() -> Any:
headroom/config.py CHANGED
@@ -352,16 +352,23 @@ class AnchorConfig:
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
 
 
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
+ # Tool outputs that are reference data and must NOT be compressed.
356
+ # Read/Glob/Grep contain exact file contents/search results the agent needs for edits.
357
+ # Write/Edit record what changes were made compressing them causes duplicate/conflicting edits.
358
+ # Bash is NOT excluded — its outputs (build logs, test output) are ideal compression targets.
359
  DEFAULT_EXCLUDE_TOOLS: frozenset[str] = frozenset(
360
  {
361
  "Read",
362
  "Glob",
363
+ "Grep",
364
+ "Write",
365
+ "Edit",
366
  # Lowercase variants for case-insensitive matching
367
  "read",
368
  "glob",
369
+ "grep",
370
+ "write",
371
+ "edit",
372
  }
373
  )
374
 
headroom/evals/metrics.py CHANGED
@@ -103,7 +103,8 @@ def compute_bleu(response_a: str, response_b: str, max_n: int = 4) -> float:
103
  # Geometric mean of precisions
104
  import math
105
 
106
- log_sum = sum(math.log(p) for p in precisions if p > 0) / len(precisions)
 
107
  return math.exp(log_sum)
108
 
109
 
 
103
  # Geometric mean of precisions
104
  import math
105
 
106
+ nonzero_precisions = [p for p in precisions if p > 0]
107
+ log_sum = sum(math.log(p) for p in nonzero_precisions) / len(nonzero_precisions)
108
  return math.exp(log_sum)
109
 
110
 
headroom/integrations/asgi.py CHANGED
@@ -29,6 +29,7 @@ from __future__ import annotations
29
  import json
30
  import logging
31
  import os
 
32
  from typing import Any
33
 
34
  from starlette.types import ASGIApp, Receive, Scope, Send
@@ -87,6 +88,12 @@ class CompressionMiddleware:
87
  """Whether cloud compression is enabled."""
88
  return self._api_key is not None
89
 
 
 
 
 
 
 
90
  async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
91
  if scope["type"] != "http":
92
  await self.app(scope, receive, send)
@@ -103,8 +110,8 @@ class CompressionMiddleware:
103
  # Buffer the request body
104
  body_chunks: list[bytes] = []
105
 
106
- async def buffering_receive() -> dict[str, Any]:
107
- message: dict[str, Any] = await receive()
108
  if message["type"] == "http.request":
109
  chunk = message.get("body", b"")
110
  if chunk:
@@ -135,7 +142,7 @@ class CompressionMiddleware:
135
  else:
136
  result = self._local_compress(messages, model)
137
 
138
- if result and result.get("tokens_saved", 0) > 0:
139
  body_json["messages"] = result["messages"]
140
  full_body = json.dumps(body_json).encode("utf-8")
141
  tokens_saved = result["tokens_saved"]
@@ -157,16 +164,16 @@ class CompressionMiddleware:
157
  # Create a new receive that returns the (possibly modified) body
158
  body_sent = False
159
 
160
- async def modified_receive() -> dict[str, Any]:
161
  nonlocal body_sent
162
  if not body_sent:
163
  body_sent = True
164
  return {"type": "http.request", "body": full_body, "more_body": False}
165
- result: dict[str, Any] = await receive()
166
  return result
167
 
168
  # Wrap send to inject compression headers
169
- async def metrics_send(message: dict[str, Any]) -> None:
170
  if message["type"] == "http.response.start" and tokens_saved > 0:
171
  headers = list(message.get("headers", []))
172
  headers.append((b"x-headroom-compressed", b"true"))
 
29
  import json
30
  import logging
31
  import os
32
+ from collections.abc import MutableMapping
33
  from typing import Any
34
 
35
  from starlette.types import ASGIApp, Receive, Scope, Send
 
88
  """Whether cloud compression is enabled."""
89
  return self._api_key is not None
90
 
91
+ async def aclose(self) -> None:
92
+ """Close the underlying httpx.AsyncClient, if one was created."""
93
+ if self._client is not None:
94
+ await self._client.aclose()
95
+ self._client = None
96
+
97
  async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
98
  if scope["type"] != "http":
99
  await self.app(scope, receive, send)
 
110
  # Buffer the request body
111
  body_chunks: list[bytes] = []
112
 
113
+ async def buffering_receive() -> MutableMapping[str, Any]:
114
+ message = await receive()
115
  if message["type"] == "http.request":
116
  chunk = message.get("body", b"")
117
  if chunk:
 
142
  else:
143
  result = self._local_compress(messages, model)
144
 
145
+ if result and result.get("tokens_saved", 0) > 0 and "messages" in result:
146
  body_json["messages"] = result["messages"]
147
  full_body = json.dumps(body_json).encode("utf-8")
148
  tokens_saved = result["tokens_saved"]
 
164
  # Create a new receive that returns the (possibly modified) body
165
  body_sent = False
166
 
167
+ async def modified_receive() -> MutableMapping[str, Any]:
168
  nonlocal body_sent
169
  if not body_sent:
170
  body_sent = True
171
  return {"type": "http.request", "body": full_body, "more_body": False}
172
+ result = await receive()
173
  return result
174
 
175
  # Wrap send to inject compression headers
176
+ async def metrics_send(message: MutableMapping[str, Any]) -> None:
177
  if message["type"] == "http.response.start" and tokens_saved > 0:
178
  headers = list(message.get("headers", []))
179
  headers.append((b"x-headroom-compressed", b"true"))
headroom/integrations/litellm_callback.py CHANGED
@@ -103,7 +103,7 @@ class HeadroomCallback:
103
  else:
104
  result = self._local_compress(messages, model)
105
 
106
- if result and result.get("tokens_saved", 0) > 0:
107
  data["messages"] = result["messages"]
108
  self._total_saved += result["tokens_saved"]
109
  logger.info(
 
103
  else:
104
  result = self._local_compress(messages, model)
105
 
106
+ if result and result.get("tokens_saved", 0) > 0 and "messages" in result:
107
  data["messages"] = result["messages"]
108
  self._total_saved += result["tokens_saved"]
109
  logger.info(
headroom/memory/adapters/sqlite.py CHANGED
@@ -217,14 +217,14 @@ class SQLiteMemoryStore:
217
  supersedes=row["supersedes"],
218
  superseded_by=row["superseded_by"],
219
  promoted_from=row["promoted_from"],
220
- promotion_chain=json.loads(row["promotion_chain"]),
221
  access_count=row["access_count"],
222
  last_accessed=datetime.fromisoformat(row["last_accessed"])
223
  if row["last_accessed"]
224
  else None,
225
- entity_refs=json.loads(row["entity_refs"]),
226
  embedding=self._deserialize_embedding(row["embedding"]),
227
- metadata=json.loads(row["metadata"]),
228
  )
229
 
230
  async def save(self, memory: Memory) -> None:
 
217
  supersedes=row["supersedes"],
218
  superseded_by=row["superseded_by"],
219
  promoted_from=row["promoted_from"],
220
+ promotion_chain=json.loads(row["promotion_chain"]) if row["promotion_chain"] else [],
221
  access_count=row["access_count"],
222
  last_accessed=datetime.fromisoformat(row["last_accessed"])
223
  if row["last_accessed"]
224
  else None,
225
+ entity_refs=json.loads(row["entity_refs"]) if row["entity_refs"] else [],
226
  embedding=self._deserialize_embedding(row["embedding"]),
227
+ metadata=json.loads(row["metadata"]) if row["metadata"] else {},
228
  )
229
 
230
  async def save(self, memory: Memory) -> None:
headroom/parser.py CHANGED
@@ -67,9 +67,10 @@ def detect_waste_signals(text: str, tokenizer: Tokenizer) -> WasteSignals:
67
  # Excessive whitespace
68
  ws_matches = WHITESPACE_PATTERN.findall(text)
69
  if ws_matches:
70
- # Count tokens that could be saved by normalizing
71
  ws_text = "".join(ws_matches)
72
- signals.whitespace_tokens = max(0, tokenizer.count_text(ws_text) - len(ws_matches))
 
73
 
74
  # Large JSON blocks
75
  json_matches = JSON_BLOCK_PATTERN.findall(text)
 
67
  # Excessive whitespace
68
  ws_matches = WHITESPACE_PATTERN.findall(text)
69
  if ws_matches:
70
+ # Count tokens that could be saved by normalizing whitespace to single spaces
71
  ws_text = "".join(ws_matches)
72
+ normalized_text = " ".join(ws_matches)
73
+ signals.whitespace_tokens = max(0, tokenizer.count_text(ws_text) - tokenizer.count_text(normalized_text))
74
 
75
  # Large JSON blocks
76
  json_matches = JSON_BLOCK_PATTERN.findall(text)
headroom/proxy/server.py CHANGED
@@ -2049,9 +2049,14 @@ class HeadroomProxy:
2049
  logger.info("CCR: Parsed JSON successfully")
2050
  return result
2051
  except Exception as e:
 
 
 
 
 
2052
  logger.error(
2053
  f"CCR: API call failed: {e}, "
2054
- f"response headers: {dict(cont_response.headers) if 'cont_response' in dir() else 'N/A'}"
2055
  )
2056
  raise
2057
 
@@ -2072,9 +2077,14 @@ class HeadroomProxy:
2072
  for k, v in response.headers.items()
2073
  if k.lower() not in ("content-encoding", "content-length")
2074
  }
 
 
 
 
 
2075
  response = httpx.Response(
2076
  status_code=200,
2077
- content=json.dumps(final_resp_json).encode(),
2078
  headers=ccr_response_headers,
2079
  )
2080
  logger.info(f"[{request_id}] CCR: Retrieval handled successfully")
 
2049
  logger.info("CCR: Parsed JSON successfully")
2050
  return result
2051
  except Exception as e:
2052
+ resp_headers: str | dict[str, str] = "N/A"
2053
+ try:
2054
+ resp_headers = dict(cont_response.headers)
2055
+ except Exception:
2056
+ pass
2057
  logger.error(
2058
  f"CCR: API call failed: {e}, "
2059
+ f"response headers: {resp_headers}"
2060
  )
2061
  raise
2062
 
 
2077
  for k, v in response.headers.items()
2078
  if k.lower() not in ("content-encoding", "content-length")
2079
  }
2080
+ try:
2081
+ ccr_content = json.dumps(final_resp_json).encode()
2082
+ except (TypeError, ValueError) as json_err:
2083
+ logger.warning(f"[{request_id}] CCR: JSON serialization failed: {json_err}")
2084
+ ccr_content = json.dumps(resp_json).encode()
2085
  response = httpx.Response(
2086
  status_code=200,
2087
+ content=ccr_content,
2088
  headers=ccr_response_headers,
2089
  )
2090
  logger.info(f"[{request_id}] CCR: Retrieval handled successfully")
headroom/relevance/bm25.py CHANGED
@@ -134,7 +134,7 @@ class BM25Scorer(RelevanceScorer):
134
  return 0.0, []
135
 
136
  doc_len = len(doc_tokens)
137
- avgdl = avg_doc_len or doc_len
138
 
139
  doc_freq = Counter(doc_tokens)
140
  query_freq = Counter(query_tokens)
 
134
  return 0.0, []
135
 
136
  doc_len = len(doc_tokens)
137
+ avgdl = avg_doc_len or doc_len or 1
138
 
139
  doc_freq = Counter(doc_tokens)
140
  query_freq = Counter(query_tokens)
headroom/reporting/generator.py CHANGED
@@ -471,7 +471,7 @@ def _get_top_waste_requests(
471
  )
472
 
473
  # Sort by tokens saved (waste potential)
474
- requests.sort(key=lambda x: x["tokens_before"], reverse=True)
475
 
476
  return requests[:limit]
477
 
 
471
  )
472
 
473
  # Sort by tokens saved (waste potential)
474
+ requests.sort(key=lambda x: x["tokens_saved"], reverse=True)
475
 
476
  return requests[:limit]
477
 
headroom/transforms/content_router.py CHANGED
@@ -288,7 +288,7 @@ class ContentRouterConfig:
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).
 
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.0 # 0.0 = protect ALL excluded-tool outputs (safest for coding agents)
292
 
293
  # Adaptive compression ratio: scales with context pressure.
294
  # At low pressure (<30% full), use the relaxed threshold (reject marginal).
headroom/transforms/smart_crusher.py CHANGED
@@ -368,6 +368,10 @@ def _detect_sequential_pattern(values: list[Any], check_order: bool = True) -> b
368
  if len(nums) < 5:
369
  return False
370
 
 
 
 
 
371
  # Check if sorted values form a near-sequence
372
  sorted_nums = sorted(nums)
373
  diffs = [sorted_nums[i + 1] - sorted_nums[i] for i in range(len(sorted_nums) - 1)]
@@ -500,12 +504,13 @@ def _detect_score_field_statistically(stats: FieldStats, items: list[dict]) -> t
500
  values_in_order.append(float(val))
501
  if len(values_in_order) >= 5:
502
  # Check for descending sort
 
503
  descending_count = sum(
504
  1
505
- for i in range(len(values_in_order) - 1)
506
  if values_in_order[i] >= values_in_order[i + 1]
507
  )
508
- if descending_count / (len(values_in_order) - 1) > 0.7:
509
  confidence += 0.3
510
 
511
  # Score fields often have floating point values
 
368
  if len(nums) < 5:
369
  return False
370
 
371
+ # Need at least 2 elements for pairwise comparison
372
+ if len(nums) < 2:
373
+ return False
374
+
375
  # Check if sorted values form a near-sequence
376
  sorted_nums = sorted(nums)
377
  diffs = [sorted_nums[i + 1] - sorted_nums[i] for i in range(len(sorted_nums) - 1)]
 
504
  values_in_order.append(float(val))
505
  if len(values_in_order) >= 5:
506
  # Check for descending sort
507
+ num_pairs = len(values_in_order) - 1
508
  descending_count = sum(
509
  1
510
+ for i in range(num_pairs)
511
  if values_in_order[i] >= values_in_order[i + 1]
512
  )
513
+ if num_pairs > 0 and descending_count / num_pairs > 0.7:
514
  confidence += 0.3
515
 
516
  # Score fields often have floating point values