Skip to content

Commit 8d53fd9

Browse files
authored
Merge branch 'main' into jd/architecture-slice-45
2 parents e084a04 + 0f846e5 commit 8d53fd9

55 files changed

Lines changed: 2369 additions & 950 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ name: CI
1010
#
1111
# Notes: CPU-only torch everywhere (no CUDA stack); test shards run HF_HUB_OFFLINE.
1212
# Multi-version (3.10/3.11/3.13) coverage on main is a planned follow-up.
13+
# Windows wheel (win_amd64) built separately — builds the Rust ext just like the
14+
# Linux wheel, then uploads as a separate artifact for downstream consumption.
1315

1416
on:
1517
push:
@@ -118,6 +120,31 @@ jobs:
118120
path: dist/*.whl
119121
retention-days: 1
120122

123+
build-wheel-windows:
124+
needs: changes
125+
if: needs.changes.outputs.code == 'true'
126+
runs-on: windows-latest
127+
timeout-minutes: 45
128+
steps:
129+
- uses: actions/checkout@v6
130+
- uses: actions/setup-python@v6
131+
with:
132+
python-version: ${{ env.PY_VERSION }}
133+
- uses: dtolnay/rust-toolchain@stable
134+
- uses: Swatinem/rust-cache@v2
135+
with:
136+
workspaces: ". -> target"
137+
- name: Build wheel (fast CI cargo profile)
138+
shell: bash
139+
run: |
140+
python -m pip install --upgrade pip maturin
141+
maturin build --profile ci --out dist --interpreter "python${{ env.PY_VERSION }}"
142+
- uses: actions/upload-artifact@v7
143+
with:
144+
name: headroom-wheel-windows
145+
path: dist/*.whl
146+
retention-days: 1
147+
121148
prefetch-model:
122149
needs: changes
123150
if: needs.changes.outputs.code == 'true'

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3232

3333
### Bug Fixes
3434

35+
* **proxy/openai:** stop PRE_SEND from reintroducing `tools: []` after the direct #728 fix. The OpenAI request handler now mirrors the existing `tools or _original_tools is not None` body-write guard during PRE_SEND write-back, so providers that reject empty tool arrays no longer see a tools field when the client omitted it, while explicit client `tools: []` remains preserved ([#1983](https://github.com/headroomlabs-ai/headroom/issues/1983)).
3536
* **proxy/openai:** keep the exact Responses function name `terminal` resident during OpenAI tool-search deferral so cache-mode optimization stops forwarding `terminal.terminal` and triggering the reserved-namespace 400 on Codex Responses ([#1946](https://github.com/headroomlabs-ai/headroom/issues/1946)).
3637
* **proxy/openai:** thread the savings-profile kwargs into the live `/v1/chat/completions` compression path. The chat handler called `openai_pipeline.apply()` without `proxy_pipeline_kwargs(config)`, so `HEADROOM_SAVINGS_PROFILE=agent-90` (and the individual `compress_user_messages`/`target_ratio`/`min_tokens_to_compress`/... knobs) were silently dropped — OpenAI-compatible clients like OpenCode kept protecting user messages and missed the configured profile. Both the token-mode and non-token chat branches now pass the profile kwargs, matching `handlers/anthropic.py` and the dedicated OpenAI compress endpoint ([#1534](https://github.com/headroomlabs-ai/headroom/issues/1534)).
3738
* **proxy:** forward Codex Desktop `/v1/responses` posts byte-faithfully so they stop returning upstream `400 {"detail":"Bad Request"}`. `handle_openai_responses` decoded the inbound body to inspect it but always re-serialized a canonical body on the way out, and it never stripped the inbound `content-encoding` header — so a `content-encoding: zstd` Codex Desktop request was forwarded as already-decoded JSON still advertising `zstd`, and the upstream ChatGPT Codex endpoint rejected it. The handler now keeps the original decoded bytes and forwards them verbatim whenever nothing (compression or memory injection) mutated the request, and drops the stale `content-encoding` header, mirroring the byte-faithful passthrough the chat and Anthropic paths already use ([#1542](https://github.com/headroomlabs-ai/headroom/issues/1542)).
@@ -40,8 +41,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4041
* **install:** stop leaking a file descriptor on every `headroom install start`. `start_detached_agent()` opened the agent log file and handed it to `subprocess.Popen` but never closed the parent's copy, so each call leaked one fd (and pinned the log file open against rotation). The parent now closes its copy in a `try/finally` once the child has inherited it — the close also runs if `Popen` raises ([#1554](https://github.com/headroomlabs-ai/headroom/issues/1554)).
4142
* **memory/sync:** stop the Codex AGENTS.md sync adapter from erasing previously-synced memories on every export. `sync_export` hands each adapter only the *delta* (memories the agent lacks), but `CodexAdapter.write_memories` rebuilt its whole managed section from just that delta — so each sync overwrote the section with only the new items, thrashing the file between disjoint subsets and never accumulating. It now merges the delta into the facts already present (deduped), matching the additive contract the ClaudeCode adapter already follows.
4243
* **memory/sync:** stop the Claude Code sync adapter from clobbering distinct memories that share a first line. `write_memories` derived each file name from the first line of the content only (`headroom_{slug}.md`), so two different DB memories whose first lines slugify identically wrote to the same file and the second silently overwrote the first — and because the loser never landed on disk, the next sync re-exported it, ping-ponging the pair forever. When the slug is already taken by a *different* memory (distinct `headroom_id`) the file name is now disambiguated with a content-hash suffix; an update to the same memory still rewrites its slug file in place, so existing file names are unchanged.
44+
* **transforms/code:** stop raising `ValueError` on common language hints and fence tags. `CodeAwareCompressor.compress()` built the language with `CodeLanguage(language.lower())`, which only accepts the exact enum values (`python`/`javascript`/`typescript`/…). A markdown ` ```js ` / ` ```ts ` / ` ```py ` fence tag (or any caller passing an alias) raised `ValueError` — crashing direct callers, and inside the content router the error was swallowed so those blocks silently skipped code-aware compression. A new `coerce_language` helper maps the common aliases to their canonical language and returns `UNKNOWN` (never raises) for unrecognized tags, falling back to content-based detection.
4345
* **cli/proxy:** honor `HEADROOM_MIN_TOKENS=0` / `HEADROOM_MAX_ITEMS=0`. The Click `proxy` command built these with `_get_env_int_optional(name) or 500`/`or 50`, so an explicit `0` — a legitimate value (`min_tokens_to_crush=0` means "crush every item") — was treated as falsy and silently replaced with the default. The `headroom proxy` argparse path already preserved `0` via `_get_env_int`, so the two entry points disagreed. The Click path now uses the same None-checking helper.
4446
* **proxy:** strip the inbound `Content-Encoding`/`Transfer-Encoding` request headers on the Anthropic `/v1/messages` and OpenAI `/v1/chat/completions` paths before forwarding upstream. `read_request_json_with_bytes` already decompresses the inbound body (zstd/gzip/deflate/br), so the bytes forwarded upstream are plain JSON — but these two handlers left the original `content-encoding` header in place, so a client (or an edge proxy like a Cloudflare Worker) that sent a compressed body got its request rejected with upstream HTTP 400 because the provider tried to decompress already-decoded JSON. The `/v1/responses` handler already carried this fix (#1542); it is now applied to the messages and chat paths too.
47+
* **models:** fix the model registry's prefix fallback silently returning the wrong context window. `ModelRegistry.get` accepted any registered name as a `str.startswith` prefix and returned the *first* match, so `gpt-4-32k-0613` resolved to `gpt-4` (8192) instead of `gpt-4-32k` (32768), and unregistered ids like `gpt-4.1`/`gpt-4.5` inherited `gpt-4`'s 8192-token window — making the proxy think a nearly-empty context was almost full and compress far too aggressively. The fallback now requires the registered name to end at a version boundary in the query (so `gpt-4.1` no longer matches `gpt-4`) and picks the longest qualifying name (so `gpt-4-32k-0613``gpt-4-32k`).
4548
* **proxy:** include the system prompt, tools, and the response-shaping request fields in the SemanticCache key. `_compute_key` hashed only `{model, messages}`, so two non-streaming requests with identical messages but a different top-level `system` prompt, tool set, sampling config, or output-shaping field collided on one key and the second caller was served the first's cached response — generated under different request semantics, in the default config (`cache_enabled` defaults on). The key now folds the request fields that shape generation — `temperature`/`top_p`/`top_k`/`max_tokens`/`stop`, plus OpenAI `tool_choice`/`response_format`/`parallel_tool_calls`/`seed`/`presence_penalty`/`frequency_penalty`/`logit_bias`/`n`/`logprobs`/`top_logprobs`/`reasoning_effort`/`verbosity`/`modalities` and Anthropic `thinking`/`tool_choice`/`output_config` — canonicalizing `system`/`tools` so a moved `cache_control` breakpoint does not fragment it, and the handlers snapshot the fields once at the cache read and reuse them at write so a body mutated by the pipeline cannot diverge the key. Non-streaming path only.
4649
* **learn (verbosity):** `--verbosity --apply --all` now aggregates the savings baseline across every project instead of overwriting it per project (last-project-wins), which previously left the output shaper with a tiny, unrepresentative baseline. The applied verbosity level comes from the project with the most samples ([#1288](https://github.com/headroomlabs-ai/headroom/pull/1288)).
4750
* **proxy/anthropic:** restore token-mode compression on continued Claude Code turns with a frozen prefix and deferred CCR tool injection. Token mode now runs request-side compression even when the client did not pre-register `headroom_retrieve`, relying on the existing marker-triggered injection override to keep emitted CCR markers redeemable ([#1487](https://github.com/headroomlabs-ai/headroom/issues/1487)).

headroom/cache/compression_feedback.py

Lines changed: 30 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
from dataclasses import dataclass, field
3434
from typing import TYPE_CHECKING, Any
3535

36+
from .compression_strategy_outcomes import CompressionStrategyOutcomes
37+
3638
if TYPE_CHECKING:
3739
from .compression_store import CompressionStore, RetrievalEvent
3840

@@ -94,28 +96,33 @@ def search_rate(self) -> float:
9496

9597
def strategy_retrieval_rate(self, strategy: str) -> float:
9698
"""Get retrieval rate for a specific compression strategy."""
97-
compressions = self.strategy_compressions.get(strategy, 0)
98-
if compressions == 0:
99-
return 0.0
100-
retrievals = self.strategy_retrievals.get(strategy, 0)
101-
return retrievals / compressions
99+
return self.strategy_outcomes.retrieval_rate(strategy)
102100

103101
def best_strategy(self) -> str | None:
104102
"""Find the strategy with lowest retrieval rate (most successful)."""
105-
if not self.strategy_compressions:
106-
return None
103+
return self.strategy_outcomes.best_strategy()
107104

108-
best = None
109-
best_rate = 1.0
105+
@property
106+
def strategy_outcomes(self) -> CompressionStrategyOutcomes:
107+
"""Strategy outcome view backed by this pattern's public counters."""
108+
return CompressionStrategyOutcomes(
109+
compressions=self.strategy_compressions,
110+
retrievals=self.strategy_retrievals,
111+
)
110112

111-
for strategy in self.strategy_compressions:
112-
rate = self.strategy_retrieval_rate(strategy)
113-
# Only consider strategies with enough samples
114-
if self.strategy_compressions[strategy] >= 3 and rate < best_rate:
115-
best_rate = rate
116-
best = strategy
113+
def record_strategy_compression(self, strategy: str) -> None:
114+
"""Record strategy compression outcome."""
115+
outcomes = self.strategy_outcomes
116+
outcomes.record_compression(strategy)
117+
self.strategy_compressions = outcomes.compressions
118+
self.strategy_retrievals = outcomes.retrievals
117119

118-
return best
120+
def record_strategy_retrieval(self, strategy: str) -> None:
121+
"""Record strategy retrieval outcome."""
122+
outcomes = self.strategy_outcomes
123+
outcomes.record_retrieval(strategy)
124+
self.strategy_compressions = outcomes.compressions
125+
self.strategy_retrievals = outcomes.retrievals
119126

120127

121128
@dataclass
@@ -235,15 +242,7 @@ def record_compression(
235242

236243
# Track strategy usage
237244
if strategy:
238-
pattern.strategy_compressions[strategy] = (
239-
pattern.strategy_compressions.get(strategy, 0) + 1
240-
)
241-
242-
# CRITICAL FIX: When truncating strategy dicts, keep them in sync
243-
# to prevent desync between compressions and retrievals.
244-
# Both dicts must have the same keys for accurate retrieval rate calculation.
245-
if len(pattern.strategy_compressions) > 50:
246-
self._truncate_strategy_dicts(pattern)
245+
pattern.record_strategy_compression(strategy)
247246

248247
# Track signature hash for TOIN correlation
249248
if tool_signature_hash:
@@ -291,14 +290,7 @@ def record_retrieval(
291290

292291
# Track strategy retrievals (for success rate calculation)
293292
if strategy:
294-
pattern.strategy_retrievals[strategy] = (
295-
pattern.strategy_retrievals.get(strategy, 0) + 1
296-
)
297-
298-
# CRITICAL FIX: When truncating strategy dicts, keep them in sync
299-
# to prevent desync between compressions and retrievals.
300-
if len(pattern.strategy_retrievals) > 50:
301-
self._truncate_strategy_dicts(pattern)
293+
pattern.record_strategy_retrieval(strategy)
302294

303295
# Track query patterns
304296
if event.query:
@@ -318,40 +310,11 @@ def record_retrieval(
318310
self._extract_field_hints(pattern, event.query)
319311

320312
def _truncate_strategy_dicts(self, pattern: LocalToolPattern) -> None:
321-
"""Truncate strategy_compressions and strategy_retrievals in sync.
322-
323-
CRITICAL FIX: Both dicts must have the same keys for accurate retrieval
324-
rate calculation. When truncating, we keep the union of top strategies
325-
from both dicts, then truncate both to the same key set.
326-
"""
327-
# Get top 40 strategies from each dict (using 40 to allow union to stay under 50)
328-
top_compressions = {
329-
k
330-
for k, _ in sorted(
331-
pattern.strategy_compressions.items(),
332-
key=lambda x: x[1],
333-
reverse=True,
334-
)[:40]
335-
}
336-
top_retrievals = {
337-
k
338-
for k, _ in sorted(
339-
pattern.strategy_retrievals.items(),
340-
key=lambda x: x[1],
341-
reverse=True,
342-
)[:40]
343-
}
344-
345-
# Keep union of top strategies from both
346-
keys_to_keep = top_compressions | top_retrievals
347-
348-
# Truncate both dicts to same keys
349-
pattern.strategy_compressions = {
350-
k: v for k, v in pattern.strategy_compressions.items() if k in keys_to_keep
351-
}
352-
pattern.strategy_retrievals = {
353-
k: v for k, v in pattern.strategy_retrievals.items() if k in keys_to_keep
354-
}
313+
"""Truncate strategy counters using the shared strategy outcome domain."""
314+
outcomes = pattern.strategy_outcomes
315+
outcomes.prune()
316+
pattern.strategy_compressions = outcomes.compressions
317+
pattern.strategy_retrievals = outcomes.retrievals
355318

356319
def _extract_field_hints(self, pattern: LocalToolPattern, query: str) -> None:
357320
"""Extract potential field names from search queries.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Strategy outcome accounting for local compression feedback."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass, field
6+
7+
8+
@dataclass
9+
class CompressionStrategyOutcomes:
10+
"""Track compression and retrieval outcomes by compression strategy."""
11+
12+
compressions: dict[str, int] = field(default_factory=dict)
13+
retrievals: dict[str, int] = field(default_factory=dict)
14+
max_strategies: int = 50
15+
top_strategies_per_counter: int = 40
16+
minimum_samples_for_recommendation: int = 3
17+
18+
def record_compression(self, strategy: str) -> None:
19+
"""Record one compression for a strategy."""
20+
self.compressions[strategy] = self.compressions.get(strategy, 0) + 1
21+
self.prune()
22+
23+
def record_retrieval(self, strategy: str) -> None:
24+
"""Record one retrieval for a strategy."""
25+
self.retrievals[strategy] = self.retrievals.get(strategy, 0) + 1
26+
self.prune()
27+
28+
def retrieval_rate(self, strategy: str) -> float:
29+
"""Return the retrievals-per-compression rate for one strategy."""
30+
compressions = self.compressions.get(strategy, 0)
31+
if compressions == 0:
32+
return 0.0
33+
return self.retrievals.get(strategy, 0) / compressions
34+
35+
def best_strategy(self) -> str | None:
36+
"""Return the sampled strategy with the lowest retrieval rate."""
37+
best = None
38+
best_rate = 1.0
39+
40+
for strategy, compression_count in self.compressions.items():
41+
if compression_count < self.minimum_samples_for_recommendation:
42+
continue
43+
44+
rate = self.retrieval_rate(strategy)
45+
if rate < best_rate:
46+
best = strategy
47+
best_rate = rate
48+
49+
return best
50+
51+
def prune(self) -> None:
52+
"""Bound counters while preserving the highest-signal strategies."""
53+
if (
54+
len(self.compressions) <= self.max_strategies
55+
and len(self.retrievals) <= self.max_strategies
56+
):
57+
return
58+
59+
keys_to_keep = self._keys_to_keep()
60+
self.compressions = {
61+
strategy: count
62+
for strategy, count in self.compressions.items()
63+
if strategy in keys_to_keep
64+
}
65+
self.retrievals = {
66+
strategy: count
67+
for strategy, count in self.retrievals.items()
68+
if strategy in keys_to_keep
69+
}
70+
71+
def _keys_to_keep(self) -> set[str]:
72+
top_compressions = self._top_keys(self.compressions)
73+
top_retrievals = self._top_keys(self.retrievals)
74+
candidate_keys = top_compressions | top_retrievals
75+
76+
if len(candidate_keys) <= self.max_strategies:
77+
return candidate_keys
78+
79+
ranked_keys = sorted(
80+
candidate_keys,
81+
key=lambda strategy: (
82+
self.compressions.get(strategy, 0) + self.retrievals.get(strategy, 0),
83+
self.compressions.get(strategy, 0),
84+
self.retrievals.get(strategy, 0),
85+
strategy,
86+
),
87+
reverse=True,
88+
)
89+
return set(ranked_keys[: self.max_strategies])
90+
91+
def _top_keys(self, counts: dict[str, int]) -> set[str]:
92+
return {
93+
strategy
94+
for strategy, _ in sorted(
95+
counts.items(),
96+
key=lambda item: (item[1], item[0]),
97+
reverse=True,
98+
)[: self.top_strategies_per_counter]
99+
}

0 commit comments

Comments
 (0)