From 57bf720d5c60496f9bce2529b850641e72ce99d2 Mon Sep 17 00:00:00 2001 From: Tejas Chopra Date: Mon, 27 Jul 2026 20:52:18 -0700 Subject: [PATCH 001/215] feat(router): route embedded & nested JSON through the compressor dispatch (#2623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `ContentRouter` only compressed JSON when the **whole** `tool_result` block was a single JSON value. JSON embedded inside larger output (`gh api` dumps, MCP tool results, `curl | jq` tails, log lines ending in a JSON blob) was invisible to the JSON compressors — and in practice that embedded shape is the large majority of JSON an agent actually sees. This adds a structural routing step: find balanced JSON spans at **any offset** in a block and route each one through the router's **existing, unchanged** `_apply_strategy_to_content`, splicing the result back with the surrounding bytes kept exact. Because each span takes the same dispatch path a whole-block JSON already takes, SmartCrusher/CodeCompressor register their `<>` retrieval markers exactly as before — CCR is hash-keyed, so it is location-agnostic and unaffected by nesting. Closes # ## Type of Change - [x] New feature (non-breaking change that adds functionality) ## Changes Made - New `headroom/transforms/recursive_json.py` — `route_embedded_json()`: deterministic balanced-span scan + splice; skips spans already carrying a `< tuple[str, int, list[str]]: """Apply a compression strategy to content. @@ -2827,6 +2828,37 @@ class ContentRouter(Transform): log]``). Log readers use this to see *how* we got to the final compressor without parsing decision_reason strings. """ + # ── STRUCTURAL (embedded) JSON routing ─────────────────────────────── + # Before anything else: if this block is not a single JSON value but + # CONTAINS balanced JSON span(s), route each span through this very + # dispatch and splice the result back (surrounding bytes kept exact). + # This is how nested/embedded JSON reaches the JSON compressors at all — + # today's linear splitter never sees it. Each span goes through the + # UNCHANGED path, so SmartCrusher/CodeCompressor register their + # `<>` markers exactly as for a whole-block JSON (CCR is hash- + # keyed → location-agnostic). `_allow_embedded=False` on the recursive + # call is a one-shot re-entrancy guard (NOT a depth cap). Deterministic + + # benefit-gated (no size/min thresholds) → prefix-cache- and CCR-store- + # stable, and a strict no-op when the block has no embedded JSON. + if _allow_embedded: + from headroom.transforms.recursive_json import route_embedded_json + + def _dispatch_span(span: str) -> str | None: + strat = self._strategy_from_detection_type(_detect_content(span).content_type) + text, _t, _c = self._apply_strategy_to_content( + span, + strat, + context, + question=question, + bias=bias, + _allow_embedded=False, + ) + return text if text != span else None + + routed = route_embedded_json(content, _dispatch_span, tok=_estimate_tokens) + if routed is not None: + return routed, _estimate_tokens(routed), ["embedded_json"] + # Track original tokens for TOIN recording original_tokens = _estimate_tokens(content) compressed: str | None = None diff --git a/headroom/transforms/recursive_json.py b/headroom/transforms/recursive_json.py new file mode 100644 index 000000000..e6d7b1665 --- /dev/null +++ b/headroom/transforms/recursive_json.py @@ -0,0 +1,162 @@ +"""Structural (recursive) JSON routing for the ContentRouter. + +Today the router is *linear*: it splits a block into textual sections and picks +one strategy per section. It never looks *inside* a structure, so JSON embedded +in a larger payload (a ``gh api`` dump, an MCP result, a ``curl | jq`` tail) is +invisible to the JSON compressors — even though, in practice, that embedded shape +is the overwhelming majority of JSON the agent ever sees. + +This module adds the missing structural step: find balanced JSON spans at any +offset in a block and route each one through the router's *existing* dispatch, +splicing the result back in place with the surrounding bytes kept exact. + +Why this is CCR-safe by construction +------------------------------------- +Each span is handed to the router's own ``_apply_strategy_to_content`` — the same +code path a whole-block JSON already takes — so SmartCrusher / CodeCompressor +register their ``<>`` retrieval markers exactly as they do today. CCR +is hash-keyed and therefore location-agnostic: a marker resolves whether it sits +at the top of a block or nested inside one. This module never touches the CCR +store; it only relocates where the dispatch is invoked. + +Safety invariants (no thresholds — outcome-gated only): + * A span that already contains a ``< int | None: + """Index just past the balanced JSON container opening at ``start`` (honoring + string/escape rules), or ``None`` if it never balances.""" + stack: list[str] = [] + in_str = esc = False + for j in range(start, len(text)): + ch = text[j] + if in_str: + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == '"': + in_str = False + continue + if ch == '"': + in_str = True + elif ch in _OPEN: + stack.append(ch) + elif ch in _CLOSE: + if not stack or stack[-1] != _PAIR[ch]: + return None + stack.pop() + if not stack: + return j + 1 + return None + + +def _spans(text: str) -> list[tuple[int, int]]: + """Deterministic list of ``(start, end)`` for top-level balanced JSON spans. + Nested spans are not returned separately — the dispatch handles depth.""" + out: list[tuple[int, int]] = [] + i, n = 0, len(text) + while i < n: + if text[i] in _OPEN: + end = _match_span(text, i) + if end is not None: + out.append((i, end)) + i = end + continue + i += 1 + return out + + +def _has_routable_json(span: str) -> bool: + """True if ``span`` parses and contains an array of objects somewhere — the + shape the JSON compressors actually act on. Cheap structural check, no size + threshold.""" + try: + v = json.loads(span) + except (ValueError, TypeError): + return False + + found = False + + def walk(x: object) -> None: + nonlocal found + if found: + return + if isinstance(x, list): + if len(x) >= 2 and sum(isinstance(e, dict) for e in x) >= 0.8 * len(x): + found = True + return + for e in x: + walk(e) + elif isinstance(x, dict): + for e in x.values(): + walk(e) + + walk(v) + return found + + +def route_embedded_json( + content: str, + dispatch: Dispatch, + *, + tok: Callable[[str], int] | None = None, +) -> str | None: + """Route every embedded JSON span in ``content`` through ``dispatch`` and + splice the results back in place. Returns the rewritten block, or ``None`` + when nothing safe/smaller applied. + + ``content`` that is itself a single JSON value is intentionally skipped — the + caller already routes pure-JSON blocks; this exists for the *embedded* case. + """ + tok = tok or (lambda s: max(1, len(s) // 4)) + spans = _spans(content) + if not spans: + return None + # Whole-block JSON is the caller's job, not ours. + if len(spans) == 1 and spans[0] == (0, len(content.strip())): + return None + + repls: list[tuple[int, int, str]] = [] + for a, b in spans: + chunk = content[a:b] + if "< str | None: + """Fake compressor: returns a shorter deterministic stand-in for any span.""" + try: + v = json.loads(span) + except ValueError: + return None + return f"" if isinstance(v, list) else "" + + +def test_embedded_json_routed_and_surroundings_exact() -> None: + payload = json.dumps([{"id": i, "ok": True} for i in range(6)], separators=(",", ":")) + content = f"Fetched rows from API:\n{payload}\nDone (200 OK)." + out = route_embedded_json(content, _upper_dispatch) + assert out is not None + assert out.startswith("Fetched rows from API:\n") + assert out.endswith("\nDone (200 OK).") + assert "
" in out + + +def test_ccr_marker_span_passed_through() -> None: + # A span already carrying a CCR marker must never be re-routed (R1). + content = 'prefix [{"a":1,"b":2},{"a":3,"b":"<>"}] suffix' + out = route_embedded_json(content, _upper_dispatch) + assert out is None # only span contains a marker → skipped → nothing to do + + +def test_no_json_is_noop() -> None: + assert route_embedded_json("just prose, nothing structured here", _upper_dispatch) is None + + +def test_whole_block_json_is_callers_job() -> None: + # A block that IS a single JSON value is skipped (routed by the caller). + content = json.dumps([{"a": i} for i in range(5)], separators=(",", ":")) + assert route_embedded_json(content, _upper_dispatch) is None + + +def test_benefit_gate_declines_when_not_smaller() -> None: + payload = json.dumps([{"a": i} for i in range(5)], separators=(",", ":")) + content = f"x {payload} y" + # Dispatch that returns something LARGER → must be declined (outcome gate). + assert route_embedded_json(content, lambda s: s + " " * 999) is None + + +def test_deterministic() -> None: + payload = json.dumps([{"k": i} for i in range(8)], separators=(",", ":")) + content = f"a {payload} b {payload} c" + r1 = route_embedded_json(content, _upper_dispatch) + r2 = route_embedded_json(content, _upper_dispatch) + assert r1 == r2 and r1 is not None + assert r1.count("
") == 2 # both embedded spans routed + + +def test_scalar_array_not_routed() -> None: + # array of scalars is not a "routable" JSON shape (no dict rows) + content = "nums: [1,2,3,4,5,6,7,8] done" + assert route_embedded_json(content, _upper_dispatch) is None From 1588f5e04144af5de8398810ea3893c6e623309c Mon Sep 17 00:00:00 2001 From: Devanshi Vyas Date: Tue, 28 Jul 2026 15:33:08 -0700 Subject: [PATCH 002/215] feat: expose configured OTEL meters to integrations (#2519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Expose a small public observability API that lets optional integrations create OpenTelemetry instruments using Headroom's configured meter provider. Without this API, an integration must either rely on observability internals or create a second provider and exporter. `get_otel_meter(name, version)` keeps configuration, export, and shutdown ownership inside Headroom while allowing integration-specific instruments to use their own instrumentation scope. ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Add `HeadroomOtelMetrics.get_meter(name, version)` to obtain a meter from the provider already owned by the Headroom metrics facade. - Add and publicly export `headroom.observability.get_otel_meter(...)`. - Preserve no-op-compatible OpenTelemetry behavior when Headroom-managed metric export is not configured. - Add a focused test proving integration instruments are collected by the same configured provider. - Add no dependencies and make no changes to existing metrics or configuration. ## Testing - [ ] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run pytest tests/test_observability_metrics.py -q collected 6 items tests/test_observability_metrics.py ...... [100%] 6 passed in 4.72s $ uv run ruff check . All checks passed! $ uv run ruff format --check . 1331 files already formatted ``` ## Real Behavior Proof - Environment: macOS (Darwin), Python 3.12.13, Headroom `0.33.0-dev`, OpenTelemetry SDK `1.39.1`, console metric exporter. - Exact command / steps: Run the command below: ```bash uv run python -c 'from headroom.observability import OTelMetricsConfig, configure_otel_metrics, get_otel_meter, shutdown_otel_metrics; configure_otel_metrics(OTelMetricsConfig(enabled=True, exporter="console", service_name="headroom-integration-proof", export_interval_millis=60000)); get_otel_meter("example.integration", "1.0.0").create_counter("example.integration.events").add(3, {"source": "extension-api"}); shutdown_otel_metrics()' ``` - Observed result: Headroom's console exporter emitted `example.integration.events` with value `3`, attribute `source="extension-api"`, instrumentation scope `example.integration` version `1.0.0`, and resource service name `headroom-integration-proof`. This demonstrates that the public accessor participates in Headroom's configured provider and shutdown lifecycle. - Not tested: network OTLP export, the complete repository test suite, `mypy`, or Python versions other than 3.12 in this final validation. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Screenshots (if applicable) N/A — this change has no user-interface surface. --- headroom/observability/__init__.py | 2 ++ headroom/observability/metrics.py | 24 ++++++++++++++++++++++++ tests/test_observability_metrics.py | 23 ++++++++++++++++++++++- 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/headroom/observability/__init__.py b/headroom/observability/__init__.py index 44eea15a5..7c261ba7b 100644 --- a/headroom/observability/__init__.py +++ b/headroom/observability/__init__.py @@ -4,6 +4,7 @@ from .metrics import ( HeadroomOtelMetrics, OTelMetricsConfig, configure_otel_metrics, + get_otel_meter, get_otel_metrics, get_otel_metrics_status, reset_otel_metrics, @@ -25,6 +26,7 @@ __all__ = [ "HeadroomOtelMetrics", "OTelMetricsConfig", "configure_otel_metrics", + "get_otel_meter", "get_otel_metrics", "get_otel_metrics_status", "HeadroomTracer", diff --git a/headroom/observability/metrics.py b/headroom/observability/metrics.py index 67e44672c..7a6fed7c5 100644 --- a/headroom/observability/metrics.py +++ b/headroom/observability/metrics.py @@ -127,6 +127,7 @@ class HeadroomOtelMetrics: """Shared OTEL metrics facade for Headroom operations.""" def __init__(self, meter_provider: Any | None = None): + self._meter_provider = meter_provider if meter_provider is None: self._meter = metrics.get_meter(_SCOPE_NAME, _headroom_version()) else: @@ -312,6 +313,17 @@ class HeadroomOtelMetrics: callbacks=[_cb_overage], ) + def get_meter(self, name: str, version: str | None = None) -> Any: + """Return a meter backed by Headroom's configured metric provider. + + Optional integrations can use this to create their own instruments + without creating a second exporter or meter provider. + """ + + if self._meter_provider is None: + return metrics.get_meter(name, version) + return self._meter_provider.get_meter(name, version) + @staticmethod def _attrs(**attrs: Any) -> dict[str, Any]: filtered: dict[str, Any] = {} @@ -472,6 +484,18 @@ def get_otel_metrics() -> HeadroomOtelMetrics: return _global_metrics +def get_otel_meter(name: str, version: str | None = None) -> Any: + """Return a meter backed by Headroom's configured OTEL metric provider. + + This is intended for optional integrations that need to create their own + instruments while sharing Headroom's configured exporter and lifecycle. + When OTEL metrics are disabled, the returned meter is the standard no-op + compatible meter from the OpenTelemetry API. + """ + + return get_otel_metrics().get_meter(name, version) + + def set_otel_metrics(otel_metrics: HeadroomOtelMetrics) -> HeadroomOtelMetrics: global _global_metrics with _metrics_lock: diff --git a/tests/test_observability_metrics.py b/tests/test_observability_metrics.py index 6b7e09890..5a7297476 100644 --- a/tests/test_observability_metrics.py +++ b/tests/test_observability_metrics.py @@ -9,7 +9,12 @@ import pytest from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.export import InMemoryMetricReader -from headroom.observability import HeadroomOtelMetrics, reset_otel_metrics, set_otel_metrics +from headroom.observability import ( + HeadroomOtelMetrics, + get_otel_meter, + reset_otel_metrics, + set_otel_metrics, +) from headroom.proxy.prometheus_metrics import PrometheusMetrics from headroom.transforms.pipeline import TransformPipeline @@ -126,6 +131,22 @@ def test_headroom_otel_metrics_records_proxy_and_pipeline_metrics() -> None: assert waste_point.value == 12 +def test_get_otel_meter_uses_headrooms_configured_provider() -> None: + reader = InMemoryMetricReader() + provider = MeterProvider(metric_readers=[reader]) + set_otel_metrics(HeadroomOtelMetrics(meter_provider=provider)) + + try: + meter = get_otel_meter("example.integration", "1.0.0") + meter.create_counter("example.integration.events").add(1, {"source": "test"}) + + metric = _collect_metrics(reader)["example.integration.events"] + point = _find_point(metric, source="test") + assert point.value == 1 + finally: + reset_otel_metrics() + + @dataclass class _SpyMetrics: pipeline_calls: list[dict[str, Any]] = field(default_factory=list) From 1d29738818bb40e00847dba46e2f9acce773d3eb Mon Sep 17 00:00:00 2001 From: Fabien Culpo Date: Wed, 29 Jul 2026 18:06:51 +0200 Subject: [PATCH 003/215] fix(proxy): keep core tools and the client's ToolSearch resident for PascalCase clients (#2647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `_TOOL_SEARCH_CORE_TOOLS` is spelled in lowercase, but the membership test compared the raw tool name, so the core-tool exemption never fired for clients that send PascalCase names. For Claude Code (`Bash`, `Read`, `Edit`, `ToolSearch`) **every** tool in the request body was deferred. The damaging part is that Claude Code's own `ToolSearch` was deferred. It is the schema fetcher for tools the client keeps in its local registry and never sends in the body — `TaskCreate`, `TaskUpdate`, `TaskList`, `WebFetch`, `EnterPlanMode`, `Monitor`, `LSP`, `Cron*`, `SendMessage`. Hiding it makes all of them permanently uncallable: advertised to the model in a ``, but no search can return their schemas, because the injected `tool_search_tool_regex` only indexes what is in the request body. Closes #2646 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Compare tool names against the core set case-insensitively in `inject_tool_search_deferral` (`helpers.py`). - Add `"toolsearch"` to `_TOOL_SEARCH_CORE_TOOLS` so a client's own schema-fetch tool is never deferred. - Apply the same case-insensitive comparison to `inject_tool_search_deferral_openai`, which had the identical exact-match bug (including against `_OPENAI_TOOL_SEARCH_RESIDENT_NAMES = {"terminal"}`). - Add 3 tests on the Anthropic path and 1 on the OpenAI path. Both source changes are required: case-folding alone does not help `ToolSearch` (it was not in the set), and adding it alone does not help `Bash`/`Read`/`Edit`. **The token saving is unchanged** — MCP tools are still deferred. This is not a request to disable the feature. Beyond the stranded tools, the old behaviour also meant (a) routine `Bash`/`Read`/`Edit` loops each paid a search round-trip, the exact cost the core set exists to avoid, and (b) zero resident *real* tools remained, silently violating the invariant documented on `inject_tool_search_deferral` — the injected search tool is typed and does not satisfy it — which risks an upstream 400. The existing assertion for that invariant passes today only because its fixture uses lowercase names. ## Testing - [x] Unit tests pass (`pytest`) — the two affected files; see scope note below - [ ] Linting passes (`ruff check .`) — see note - [ ] Type checking passes (`mypy headroom`) — could not run, see note - [x] New tests added for new functionality - [x] Manual testing performed `ruff check .` reports 4 findings repo-wide, **all pre-existing and unrelated** (`plugins/headroom-oauth2/`), confirmed identical on unmodified `main`. Zero findings in the three files this PR touches, and `ruff format --check` is clean on all three. Left unchecked because the repo-wide command does not exit 0. `mypy headroom` could not run in my environment (numpy stubs error out under the resolved Python version before checking begins). Not attempted further — CI should be the authority. ### Test Output ```text $ python -m pytest tests/test_issue_746_tool_search.py tests/test_openai_tool_search_deferral.py -q 65 passed, 1 warning in 0.70s # Baseline on those two files before this PR: 62 (36 + 26). # The 3 new Anthropic tests + 1 new OpenAI test bring it to 65. # Red before the source change (tests written first): tests/test_issue_746_tool_search.py::test_core_tools_match_case_insensitively FAILED AssertionError: Bash assert True is None where {'name': 'Bash', ..., 'defer_loading': True}.get('defer_loading') tests/test_issue_746_tool_search.py::test_client_tool_search_tool_is_never_deferred FAILED AssertionError: assert True is None where {'name': 'ToolSearch', ..., 'defer_loading': True}.get('defer_loading') tests/test_issue_746_tool_search.py::test_resident_real_tool_survives_pascal_case_surface FAILED assert any(not t.get("type") and not t.get("defer_loading") for t in out) assert False 3 failed, 36 deselected $ python -m ruff check headroom/proxy/helpers.py tests/test_issue_746_tool_search.py tests/test_openai_tool_search_deferral.py All checks passed! $ python -m ruff format --check 3 files already formatted ``` ## Real Behavior Proof - Environment: headroom 0.32.1 installed / 0.32.0 source, Python 3.13, macOS 15 (Darwin 25.5.0), Claude Code 2.1.220 with `ENABLE_TOOL_SEARCH=true` and `ANTHROPIC_BASE_URL=http://localhost:8787`, first-party Anthropic upstream, `HEADROOM_TOOL_SEARCH` truthy - Exact command / steps: build a Claude Code tool surface and pass it through the injector — `names = ["Bash","Read","Write","Edit","Glob","Grep","ToolSearch"] + [f"mcp__srv__t{i}" for i in range(12)]`, `tools = [{"name": n, "description": n, "input_schema": {}} for n in names]`, then `inject_tool_search_deferral(tools)` and print which entries carry `defer_loading` - Observed result: before the fix `resident real tools: []` with `ToolSearch deferred: True` (every built-in deferred). After the fix `resident real tools: ['Bash','Edit','Glob','Grep','Read','ToolSearch','Write']` with all 12 `mcp__srv__t*` still deferred, so the saving is retained. This matches a live session: the proxy logged `router:tool_search_deferral:25tools:22182tok ... client=claude-code` and `tool_search_tool_regex` could resolve only `mcp__*` tools — `TaskCreate`/`WebFetch`/`EnterPlanMode` returned no match until `ToolSearch` was recovered by regex-searching for it and then calling `select:TaskCreate,...` - Not tested: the full pytest suite (164 modules fail collection with `ModuleNotFoundError: No module named 'headroom._core'` because my environment imports the package via `PYTHONPATH` without building the Rust extension; identical failure confirmed on unmodified `main`, so it is environmental). `mypy headroom` not runnable here. No end-to-end run against a live upstream through a rebuilt proxy — verification is at the function boundary plus the live-session log evidence above. The OpenAI Responses path is covered by unit test only, not exercised against a real gpt-5.4+ deployment. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Additional Notes - **Documentation**: N/A — no user-facing surface changes; behaviour returns to what the existing comments and docstring already describe. - **"New and existing unit tests pass locally"**: left unchecked deliberately. The tests covering the changed symbols pass (65), but I cannot run the whole suite locally without the compiled `headroom._core`. Not claiming more than I verified. - **Scope**: the OpenAI-path fix rides along because it is the identical three-line comparison bug in the sibling function. Happy to split it into its own PR if you would rather keep this Anthropic-only. - **Deliberately not done**: I did not add a `client != "claude-code"` gate at `handlers/anthropic.py`, even though the feature's own comment block scopes it to non-Claude-Code clients and `client=claude-code` is already known there (it appears in the `transforms=` log line). Gating there would forfeit the ~22k tokens/request currently saved on Claude Code's eagerly-shipped MCP schemas; keeping the meta-tool resident preserves both the saving and reachability. Flagging in case you would prefer to gate as well. - **Adjacent blind spot, out of scope**: `claude_code_tool_search_inactive` already checks both the tools array *and* the `anthropic-beta` header, but the injector's early-return guard checks only the array. That is why a plain-function `ToolSearch` slips past it and the injection runs on a client that is already deferring. Co-authored-by: Fabien Culpo --- headroom/proxy/helpers.py | 26 +++++++++++--- tests/test_issue_746_tool_search.py | 42 +++++++++++++++++++++++ tests/test_openai_tool_search_deferral.py | 14 ++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/headroom/proxy/helpers.py b/headroom/proxy/helpers.py index 562cb3385..f1096acc5 100644 --- a/headroom/proxy/helpers.py +++ b/headroom/proxy/helpers.py @@ -2857,6 +2857,11 @@ _TOOL_SEARCH_CORE_TOOLS = frozenset( "webfetch", "question", "skill", + # A client's own tool-search/schema-fetch tool (Claude Code's ``ToolSearch``). + # It resolves tools the client keeps in its local registry and never puts in + # the request body (TaskCreate, WebFetch, …), so deferring it hides the only + # tool that can load them and they become permanently unreachable. + "toolsearch", } ) _TOOL_SEARCH_DEFAULT_TYPE = "tool_search_tool_regex_20251119" @@ -2900,8 +2905,18 @@ def inject_tool_search_deferral( last_resident_real: dict[str, Any] | None = None resident_has_cache_control = False + # Clients disagree on casing for the same tool: Claude Code sends ``Bash`` / + # ``ToolSearch`` where opencode sends ``bash``. Compare case-insensitively so + # the exemption applies to both — an exact match silently deferred *every* + # tool for PascalCase clients, including their own tool-search tool. + core_lower = {name.lower() for name in core_tools} + for tool in tools: - if not isinstance(tool, dict) or tool.get("type") or tool.get("name") in core_tools: + if ( + not isinstance(tool, dict) + or tool.get("type") + or str(tool.get("name") or "").lower() in core_lower + ): # Non-dict, server/typed tools (web_search, computer, …), and core # tools stay resident and unchanged. out.append(tool) @@ -3011,6 +3026,11 @@ def inject_tool_search_deferral_openai( out: list[Any] = [{"type": _OPENAI_TOOL_SEARCH_TYPE}] deferred = 0 + # Case-insensitive for the same reason as the Anthropic path above: the + # resident-name sets are lowercase, clients are not required to be. + resident_lower = {name.lower() for name in core_tools} | { + name.lower() for name in _OPENAI_TOOL_SEARCH_RESIDENT_NAMES + } for tool in tools: if not isinstance(tool, dict): out.append(tool) @@ -3020,9 +3040,7 @@ def inject_tool_search_deferral_openai( # trained to search namespaces / MCP servers). Everything else — core # coding tools and other hosted tools — stays resident. deferrable = ( - ttype == "function" - and tool.get("name") not in core_tools - and tool.get("name") not in _OPENAI_TOOL_SEARCH_RESIDENT_NAMES + ttype == "function" and str(tool.get("name") or "").lower() not in resident_lower ) or ttype == "mcp" if deferrable and not tool.get("defer_loading"): new_tool = dict(tool) diff --git a/tests/test_issue_746_tool_search.py b/tests/test_issue_746_tool_search.py index b07216073..337447dfa 100644 --- a/tests/test_issue_746_tool_search.py +++ b/tests/test_issue_746_tool_search.py @@ -257,3 +257,45 @@ def test_non_dict_and_typed_tools_stay_resident() -> None: out = inject_tool_search_deferral(tools) typed = [t for t in out if t.get("type") == "web_search_20250305"] assert len(typed) == 1 and typed[0].get("defer_loading") is None + + +# --------------------------------------------------------------------------- +# PascalCase clients (Claude Code). The core-tool exemption is spelled in +# lowercase, so an exact-match comparison never fired for Claude Code: every +# tool was deferred, including Claude Code's own ``ToolSearch``. +# --------------------------------------------------------------------------- + + +def _claude_code_tools() -> list[dict]: + """Claude Code's surface: PascalCase built-ins, its ToolSearch, MCP tools.""" + names = ["Bash", "Read", "Write", "Edit", "Glob", "Grep", "ToolSearch"] + [ + f"mcp__srv__t{i}" for i in range(12) + ] + return [{"name": n, "description": n, "input_schema": {}} for n in names] + + +def test_core_tools_match_case_insensitively() -> None: + # Without a case-insensitive match, routine edit/read/run loops each pay a + # search round-trip — the exact thing _TOOL_SEARCH_CORE_TOOLS exists to avoid. + out = inject_tool_search_deferral(_claude_code_tools()) + by_name = {t.get("name"): t for t in out if "name" in t} + for name in ("Bash", "Read", "Write", "Edit", "Glob", "Grep"): + assert by_name[name].get("defer_loading") is None, name + # MCP tools are still deferred — the token saving is preserved. + assert by_name["mcp__srv__t0"].get("defer_loading") is True + + +def test_client_tool_search_tool_is_never_deferred() -> None: + # ToolSearch is the client's own schema fetcher for tools that never appear + # in the request body (TaskCreate, WebFetch, …). Deferring it hides the only + # tool that can load them, so they become permanently unreachable. + out = inject_tool_search_deferral(_claude_code_tools()) + by_name = {t.get("name"): t for t in out if "name" in t} + assert by_name["ToolSearch"].get("defer_loading") is None + + +def test_resident_real_tool_survives_pascal_case_surface() -> None: + # The injected search tool is typed and does not satisfy the invariant on its + # own; Anthropic 400s when every real tool is deferred. + out = inject_tool_search_deferral(_claude_code_tools()) + assert any(not t.get("type") and not t.get("defer_loading") for t in out) diff --git a/tests/test_openai_tool_search_deferral.py b/tests/test_openai_tool_search_deferral.py index afde1adac..070ddbd60 100644 --- a/tests/test_openai_tool_search_deferral.py +++ b/tests/test_openai_tool_search_deferral.py @@ -143,3 +143,17 @@ def test_noop_when_nothing_deferrable(): def test_noop_for_non_list(): assert inject_tool_search_deferral_openai(None, "gpt-5.5") is None + + +def test_resident_names_match_case_insensitively(): + # The resident-name sets are lowercase; clients are not required to be. An + # exact match deferred every tool for a PascalCase client, including its own + # tool-search tool. Mirrors the Anthropic-side fix. + tools = [_fn(n) for n in ("Bash", "Read", "Edit", "Terminal", "ToolSearch")] + [ + _fn(f"slack_{i}") for i in range(10) + ] + out = inject_tool_search_deferral_openai(tools, "gpt-5.5") + by_name = {t.get("name"): t for t in out if "name" in t} + for name in ("Bash", "Read", "Edit", "Terminal", "ToolSearch"): + assert by_name[name].get("defer_loading") is None, name + assert by_name["slack_0"].get("defer_loading") is True From 22b707fd31d75914e1677290d2a8011727eb74f5 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Wed, 29 Jul 2026 21:44:03 +0530 Subject: [PATCH 004/215] fix(proxy/cost): count Gemini thinking tokens in output usage (#2639) ## Description The Gemini handlers take the response's output-token count straight from `candidatesTokenCount`: ```python output_tokens = _usage_int(usage.get("candidatesTokenCount")) ``` For Gemini 2.5 thinking models that undercounts. Gemini reports `candidatesTokenCount` **sometimes inclusive** of the reasoning tokens (`thoughtsTokenCount`) and **sometimes exclusive** of them. When it is exclusive, the thinking tokens are a separate bucket that is still billed at the output rate, so dropping them makes `output_tokens` (and therefore the output cost that flows through `record_tokens` -> `estimate_cost`) too low. The gap grows with reasoning effort. litellm handles exactly this: it adds `thoughtsTokenCount` to completion tokens unless `promptTokenCount + candidatesTokenCount == totalTokenCount` (its `is_candidate_token_count_inclusive` check). The Headroom handlers had no equivalent. ## Fix Add `gemini_output_tokens(usage_meta)` in `headroom/proxy/token_counting.py`: - No `thoughtsTokenCount` (the common non-2.5 case): return `candidatesTokenCount` unchanged. - `promptTokenCount + candidatesTokenCount == totalTokenCount`: candidates already include thoughts, return `candidatesTokenCount`. - Otherwise: return `candidatesTokenCount + thoughtsTokenCount`. This mirrors litellm's rule and is robust to missing or null fields. Wire it into the native Gemini handler (both the generate and count paths), the streaming usage extractors, and the OpenAI-compatible passthrough usage normalizer, so every Gemini usage path counts output the same way. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature - [ ] Breaking change - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring ## Changes Made - `headroom/proxy/token_counting.py`: add `gemini_output_tokens()`. - `headroom/proxy/handlers/gemini.py`: use it for `output_tokens` on both response paths. - `headroom/proxy/handlers/streaming.py`: use it in the two Gemini streaming usage extractors. - `headroom/proxy/handlers/openai.py`: use it in `_passthrough_usage_from_json` (Gemini-shaped usage). - `tests/test_proxy_handler_helpers.py`: unit test for `gemini_output_tokens` (inclusive / exclusive / no-thinking / empty) and a `_passthrough_usage_from_json` test that thinking tokens land in `output_tokens`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check` / `ruff format --check`) - [x] Type checking passes (`mypy`) - [x] New tests added for the fix - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_proxy_handler_helpers.py -k "gemini_output_tokens or thinking or vertex_usage_metadata" -q 3 passed $ python -m pytest tests/test_proxy_gemini_native_integration.py tests/test_proxy/test_gemini_savings_profile.py tests/test_proxy_handler_helpers.py -q 38 passed, 18 skipped # with the wiring reverted, the passthrough test fails (output_tokens is 200, not 700): $ git stash push headroom/proxy/handlers/openai.py && \ python -m pytest tests/test_proxy_handler_helpers.py -k passthrough_usage_counts_gemini_thinking -q 1 failed $ uvx ruff@0.15.17 check headroom/proxy/token_counting.py headroom/proxy/handlers/gemini.py headroom/proxy/handlers/streaming.py headroom/proxy/handlers/openai.py tests/test_proxy_handler_helpers.py All checks passed! $ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/token_counting.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12, project venv (`uv sync --extra proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv. - Exact command / steps: called `gemini_output_tokens` on an exclusive usage (`prompt=1000, candidates=200, thoughts=500, total=1700`), an inclusive usage (`candidates=700, total=1700`), a no-thinking usage, and `{}`; drove `_passthrough_usage_from_json` with a thinking usage; then reverted the handler wiring and re-ran the passthrough test. - Observed result: exclusive returns 700 (200 visible plus 500 thinking), inclusive returns 700, no-thinking returns the candidates count, empty returns 0; `_passthrough_usage_from_json` reports `output_tokens=700`. With the wiring reverted it reports 200 (the undercount). Verified against litellm's documented rule. - Not tested: a live Gemini 2.5 request end to end (the accounting is verified at the usage-extraction boundary against litellm's reference logic). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable Co-authored-by: JD Davis --- headroom/proxy/handlers/gemini.py | 9 ++++-- headroom/proxy/handlers/openai.py | 3 +- headroom/proxy/handlers/streaming.py | 5 ++-- headroom/proxy/token_counting.py | 30 +++++++++++++++++++ tests/test_proxy_handler_helpers.py | 45 ++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 5 deletions(-) diff --git a/headroom/proxy/handlers/gemini.py b/headroom/proxy/handlers/gemini.py index 0d176d129..199a79fd1 100644 --- a/headroom/proxy/handlers/gemini.py +++ b/headroom/proxy/handlers/gemini.py @@ -22,6 +22,7 @@ from headroom.proxy.auth_mode import classify_client from headroom.proxy.compression_decision import CompressionDecision from headroom.proxy.helpers import COMPRESSION_TIMEOUT_SECONDS, extract_tags from headroom.proxy.outcome import RequestOutcome +from headroom.proxy.token_counting import gemini_output_tokens logger = logging.getLogger("headroom.proxy") @@ -462,7 +463,9 @@ class GeminiHandlerMixin: # output_tokens) would then raise TypeError on the non-error # path. Mirrors the streaming _usage_int guard. total_input_tokens = _usage_int(usage.get("promptTokenCount")) - output_tokens = _usage_int(usage.get("candidatesTokenCount")) + output_tokens = gemini_output_tokens( + usage + ) # includes thinking tokens (2.5-family) cache_read_tokens = _usage_int(usage.get("cachedContentTokenCount")) except (json.JSONDecodeError, ValueError, KeyError, TypeError, AttributeError): pass @@ -714,7 +717,9 @@ class GeminiHandlerMixin: if usage.get("promptTokenCount") is None else usage["promptTokenCount"] ) - output_tokens = _usage_int(usage.get("candidatesTokenCount")) + output_tokens = gemini_output_tokens( + usage + ) # includes thinking tokens (2.5-family) # Gemini returns cachedContentTokenCount for context-cached tokens # These are charged at 10-25% of the input price depending on model cache_read_tokens = _usage_int(usage.get("cachedContentTokenCount")) diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index f380ab4c9..62b46c03e 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -75,6 +75,7 @@ from headroom.proxy.passthrough import ( custom_base_passthrough_telemetry as _custom_base_passthrough_telemetry, ) from headroom.proxy.project_context import classify_project, set_current_project +from headroom.proxy.token_counting import gemini_output_tokens logger = logging.getLogger("headroom.proxy") @@ -331,7 +332,7 @@ def _passthrough_usage_from_json(payload: Any) -> dict[str, int]: if isinstance(usage_meta, dict): return { "input_tokens": _usage_int(usage_meta.get("promptTokenCount")), - "output_tokens": _usage_int(usage_meta.get("candidatesTokenCount")), + "output_tokens": gemini_output_tokens(usage_meta), "cache_read_input_tokens": _usage_int(usage_meta.get("cachedContentTokenCount")), } diff --git a/headroom/proxy/handlers/streaming.py b/headroom/proxy/handlers/streaming.py index 3b864f9dd..7ce93a9fb 100644 --- a/headroom/proxy/handlers/streaming.py +++ b/headroom/proxy/handlers/streaming.py @@ -18,6 +18,7 @@ from headroom.proxy.helpers import ( jitter_delay_ms, retry_after_ms, ) +from headroom.proxy.token_counting import gemini_output_tokens if TYPE_CHECKING: from fastapi.responses import Response, StreamingResponse @@ -224,7 +225,7 @@ class StreamingMixin: usage_meta = data.get("usageMetadata") if usage_meta: usage["input_tokens"] = usage_meta.get("promptTokenCount", 0) - usage["output_tokens"] = usage_meta.get("candidatesTokenCount", 0) + usage["output_tokens"] = gemini_output_tokens(usage_meta) # Gemini also has cachedContentTokenCount for context caching usage["cache_read_input_tokens"] = usage_meta.get( "cachedContentTokenCount", 0 @@ -342,7 +343,7 @@ class StreamingMixin: usage_meta = data.get("usageMetadata") if usage_meta: usage_found["input_tokens"] = usage_meta.get("promptTokenCount", 0) - usage_found["output_tokens"] = usage_meta.get("candidatesTokenCount", 0) + usage_found["output_tokens"] = gemini_output_tokens(usage_meta) usage_found["cache_read_input_tokens"] = usage_meta.get( "cachedContentTokenCount", 0 ) diff --git a/headroom/proxy/token_counting.py b/headroom/proxy/token_counting.py index fb9ca107b..0e5c10017 100644 --- a/headroom/proxy/token_counting.py +++ b/headroom/proxy/token_counting.py @@ -77,3 +77,33 @@ async def count_texts_offloaded(owner: Any, model: Any, texts: Any) -> tuple[Any return await _count_offloaded( owner, model, lambda counter: sum(counter.count_text(text) for text in text_list) ) + + +def gemini_output_tokens(usage_meta: dict[str, Any]) -> int: + """Output-token count for a Gemini ``usageMetadata``, including thinking tokens. + + Gemini reports ``candidatesTokenCount`` sometimes inclusive of the + ``thoughtsTokenCount`` (2.5-family reasoning) and sometimes exclusive of it. + When ``promptTokenCount + candidatesTokenCount != totalTokenCount`` the + thinking tokens are a separate bucket and must be added, or the output cost + (billed at the output rate) is undercounted. Mirrors litellm's + ``is_candidate_token_count_inclusive`` rule. Robust to missing/null fields. + """ + + def _int(value: Any) -> int: + try: + return max(int(value), 0) + except (TypeError, ValueError): + return 0 + + candidates = _int(usage_meta.get("candidatesTokenCount")) + thoughts = _int(usage_meta.get("thoughtsTokenCount")) + if thoughts <= 0: + return candidates + prompt = _int(usage_meta.get("promptTokenCount")) + total = _int(usage_meta.get("totalTokenCount")) + # Inclusive iff prompt + candidates already equals total; otherwise the + # thinking tokens are a separate bucket that belongs in the output count. + if prompt + candidates == total: + return candidates + return candidates + thoughts diff --git a/tests/test_proxy_handler_helpers.py b/tests/test_proxy_handler_helpers.py index bb86076aa..7845396b0 100644 --- a/tests/test_proxy_handler_helpers.py +++ b/tests/test_proxy_handler_helpers.py @@ -381,6 +381,51 @@ def test_passthrough_usage_normalizes_vertex_usage_metadata() -> None: } +def test_gemini_output_tokens_includes_thinking_when_exclusive() -> None: + """Gemini 2.5 thinking: when prompt + candidates != total, thoughtsTokenCount + is a separate output bucket and must be added, or output cost undercounts.""" + from headroom.proxy.token_counting import gemini_output_tokens + + exclusive = { + "promptTokenCount": 1000, + "candidatesTokenCount": 200, + "thoughtsTokenCount": 500, + "totalTokenCount": 1700, + } + assert gemini_output_tokens(exclusive) == 700 # 200 visible + 500 thinking + + # Inclusive: candidatesTokenCount already covers thoughts (prompt+cand==total). + inclusive = { + "promptTokenCount": 1000, + "candidatesTokenCount": 700, + "thoughtsTokenCount": 500, + "totalTokenCount": 1700, + } + assert gemini_output_tokens(inclusive) == 700 + + # No thinking tokens: just the candidates count (common non-2.5 case). + assert gemini_output_tokens({"candidatesTokenCount": 42, "totalTokenCount": 100}) == 42 + # Robust to empty / missing fields. + assert gemini_output_tokens({}) == 0 + + +def test_passthrough_usage_counts_gemini_thinking_tokens() -> None: + """_passthrough_usage_from_json must include thinking tokens in output_tokens.""" + usage = _passthrough_usage_from_json( + { + "usageMetadata": { + "promptTokenCount": 1000, + "candidatesTokenCount": 200, + "thoughtsTokenCount": 500, + "totalTokenCount": 1700, + "cachedContentTokenCount": 100, + } + } + ) + assert usage["output_tokens"] == 700 + assert usage["input_tokens"] == 1000 + + def test_vertex_passthrough_records_usage_metadata_for_dashboard() -> None: handler = object.__new__(HeadroomProxy) handler.http_client = _VertexUsageClient() From e86c6390cec4fc0f932b006b36d5b924511a5b0b Mon Sep 17 00:00:00 2001 From: Zhenjia ZHOU Date: Thu, 30 Jul 2026 00:14:29 +0800 Subject: [PATCH 005/215] fix(rust): port CJK-aware relevance-query matching to CodeCompressor (#2634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The Rust port of `CodeCompressor` (#1154, parity-only) did not carry over the CJK-aware relevance-query matching from `headroom/transforms/code_compressor.py` (`_CONTEXT_DELIMS` / `_CJK_CHARS` / `_query_context_tokens()` / `_symbol_in_context()`, lines 2353-2387, called from lines 987/1009): - Rust tokenized the context with an ASCII-only delimiter class `[\s,;:.()\[\]{}"']+`, so a CJK query (no spaces, CJK punctuation) collapses into a single blob and never isolates an ASCII symbol name. - The substring-fallback guard `chars().count() > 3` had no CJK relaxation, so a short ASCII name glued to CJK text (e.g. `run` in `修复run函数的报错`, `db` in `请保留db相关的逻辑`) could never receive the +3.0 context boost — while Python does boost it. Same `(code, context)` input, different `symbol_scores`. This PR ports the two Python helpers with identical semantics: - `query_context_tokens()` — delimiter class extended with the CJK/full-width punctuation and ideographic space from Python's `_CONTEXT_DELIMS`; returns `(words, lowered, has_cjk)` with CJK detection over U+3000-U+9FFF, U+AC00-U+D7AF, U+FF00-U+FFEF (Python's `_CJK_CHARS`). - `symbol_in_context()` — exact token match, plus the substring fallback gated by `> 3` **characters** (Python `len()`, not bytes), relaxed when the query contains CJK. The call site in `analyze_symbols` now uses these helpers; no other behavior changed. Pure-ASCII query behavior is identical to before (exact token match, `>3`-gated substring fallback), which the tests pin down. Closes #2630 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `crates/headroom-core/src/transforms/code_compressor.rs`: extract `query_context_tokens()` / `symbol_in_context()` free functions mirroring the Python helpers (CJK/full-width delimiter class, CJK detection, CJK-relaxed `>3`-character guard); replace the inline ASCII-only tokenization + guard in `analyze_symbols` with calls to them. - Unit tests mirroring `tests/test_transforms/test_code_compressor_cjk.py` case-for-case, plus a character-vs-byte guard test and an end-to-end `compress_with` test asserting `symbol_scores`. ## Testing - [x] Unit tests pass (`cargo test -p headroom-core` — Rust-only change; Python untouched) - [x] Linting passes (`cargo fmt --check`, `cargo clippy -p headroom-core --all-targets` — no new warnings) - [ ] Type checking passes (`mypy headroom`) — N/A, no Python changes - [x] New tests added for new functionality - [x] Manual testing performed New tests (all in `code_compressor.rs` `mod tests`): - `cjk_query_isolates_wrapped_ascii_symbol` — full-width parens isolate `parse_config` - `cjk_query_matches_short_ascii_name_glued_to_cjk` — `db` (len 2) glued to CJK matches via the relaxed guard - `english_short_name_substring_still_gated` — `db` vs "keep the database helper" must NOT match (ASCII guard unchanged) - `english_exact_token_match_unchanged`, `english_long_name_substring_fallback_unchanged`, `empty_context_matches_nothing` - `guard_counts_chars_not_bytes` — the guard is a character count, matching Python `len()` - `cjk_context_boosts_named_symbol_end_to_end` — full `compress_with` run asserting `symbol_scores` (red on main, green here — see proof) ### Test Output ```text $ cargo test -p headroom-core --lib -- code_compressor::tests test transforms::code_compressor::tests::empty_and_short_passthrough ... ok test transforms::code_compressor::tests::empty_context_matches_nothing ... ok test transforms::code_compressor::tests::estimate_tokens_uses_chars_div_4_min_1 ... ok test transforms::code_compressor::tests::py_round3_matches_cpython ... ok test transforms::code_compressor::tests::py_round_int_is_half_to_even ... ok test transforms::code_compressor::tests::cjk_query_isolates_wrapped_ascii_symbol ... ok test transforms::code_compressor::tests::english_short_name_substring_still_gated ... ok test transforms::code_compressor::tests::cjk_query_matches_short_ascii_name_glued_to_cjk ... ok test transforms::code_compressor::tests::english_exact_token_match_unchanged ... ok test transforms::code_compressor::tests::english_long_name_substring_fallback_unchanged ... ok test transforms::code_compressor::tests::guard_counts_chars_not_bytes ... ok test transforms::code_compressor::tests::cjk_context_boosts_named_symbol_end_to_end ... ok test transforms::code_compressor::tests::detect_language_basic ... ok test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 899 filtered out; finished in 0.05s $ cargo test -p headroom-core # per-binary summaries lib .......................... ok. 911 passed; 0 failed; 1 ignored auth_mode .................... ok. 16 passed; 0 failed cache_control ................ ok. 14 passed; 0 failed ccr_backends ................. ok. 7 passed; 0 failed ccr_roundtrip ................ ok. 15 passed; 0 failed code_compressor_parity ....... ok. 1 passed; 0 failed (recorded byte-parity fixtures) live_zone_ccr ................ ok. 3 passed; 0 failed live_zone_dispatch ........... ok. 6 passed; 0 failed live_zone_thresholds ......... ok. 2 passed; 0 failed live_zone_token_validation ... ok. 3 passed; 0 failed recommendations_loader ....... ok. 4 passed; 0 failed tokenizer_proptest ........... ok. 5 passed; 0 failed doc-tests .................... ok. 1 passed; 0 failed; 2 ignored $ cargo fmt --check # clean $ cargo clippy -p headroom-core --all-targets # no new warnings ``` ## Real Behavior Proof - Environment: macOS (Darwin 25.5.0), repo-pinned Rust toolchain (`rust-toolchain.toml`), branch based on current `main`. - Exact command / steps: the end-to-end test was written first and run against unmodified `main` (red), then after the fix (green). Input: Python source with two signal-symmetric functions `run` and `keep`; context `修复run函数的报错`. The Python reference gives `run` the boost (`_symbol_in_context('run', ...) == True`, `_symbol_in_context('keep', ...) == False`, verified against the live Python implementation), so expected normalized scores are `run = 1.0`, `keep = 0.0`. - Observed result: on unmodified main the end-to-end test fails (`left: 0.5, right: 1.0` — the CJK query `修复run函数的报错` gives `run` no boost, both symbols collapse to 0.5, while Python scores `run=1.0, keep=0.0`); on this branch all 8 new tests pass and the same query boosts `run` to 1.0, matching Python. Full output: Before (unmodified `main` + new test only — Rust gives no boost, both symbols collapse to 0.5): ```text ---- transforms::code_compressor::tests::cjk_context_boosts_named_symbol_end_to_end stdout ---- thread '...cjk_context_boosts_named_symbol_end_to_end' panicked at crates/headroom-core/src/transforms/code_compressor.rs:1890:9: assertion `left == right` failed: run must get the context boost left: 0.5 right: 1.0 test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 904 filtered out ``` After (this branch): the same test passes, including its ASCII control case (`fix the runner` must NOT boost `run` — scores stay 0.5/0.5), proving pure-ASCII behavior is unchanged. The recorded byte-parity fixture suite (`code_compressor_parity`) also still passes. - Not tested: real proxy traffic end-to-end (change is confined to the symbol-scoring context boost inside the Rust compressor; the Python implementation is the behavioral reference and is untouched). `kompress_parity` was not run locally — it is model-gated and my sandbox blocks the model fetch; it is unrelated to this change and CI covers its skip path. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation — N/A (internal behavior fix) - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Additional Notes For background, the Python-side CJK handling comes from the merged CJK sweep work (#2220 and follow-ups); #1154 predates part of it, which is likely how the port missed it. Longer names wrapped in full-width punctuation happened to still match in Rust via the substring fallback, but the token set itself was wrong; this PR restores exact-token semantics for those too. --- .../src/transforms/code_compressor.rs | 183 ++++++++++++++++-- 1 file changed, 164 insertions(+), 19 deletions(-) diff --git a/crates/headroom-core/src/transforms/code_compressor.rs b/crates/headroom-core/src/transforms/code_compressor.rs index c06447b52..11b6a55ad 100644 --- a/crates/headroom-core/src/transforms/code_compressor.rs +++ b/crates/headroom-core/src/transforms/code_compressor.rs @@ -413,6 +413,59 @@ fn get_definition_name(node: Node, code: &str) -> Option { None } +/// Tokenize a relevance query for symbol-name matching (CJK-aware). +/// Mirrors `_query_context_tokens`: returns (word set, lowercased query, +/// has_cjk). Symbol names are ASCII identifiers; CJK relevance queries have +/// no spaces and use CJK/full-width punctuation, so an ASCII-only delimiter +/// class would collapse the whole query into one blob and never isolate an +/// ASCII name the user asked to keep. CJK/full-width punctuation and the +/// ideographic space are therefore delimiters too. +fn query_context_tokens(context: &str) -> (BTreeSet, String, bool) { + if context.is_empty() { + return (BTreeSet::new(), String::new(), false); + } + static DELIMS: std::sync::OnceLock = std::sync::OnceLock::new(); + let delims = DELIMS.get_or_init(|| { + // Same class as Python `_CONTEXT_DELIMS`. + regex::Regex::new(r#"[\s,;:.()\[\]{}"',、;:。.!?()【】「」『』《》〈〉·…— ]+"#) + .unwrap() + }); + static CJK: std::sync::OnceLock = std::sync::OnceLock::new(); + let cjk = CJK.get_or_init(|| { + // Same class as Python `_CJK_CHARS`: + // U+3000-U+9FFF, U+AC00-U+D7AF (Hangul), U+FF00-U+FFEF (full-width). + regex::Regex::new(r"[\u{3000}-\u{9FFF}\u{AC00}-\u{D7AF}\u{FF00}-\u{FFEF}]").unwrap() + }); + let lowered = context.to_lowercase(); + let words: BTreeSet = delims + .split(&lowered) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + let has_cjk = cjk.is_match(&lowered); + (words, lowered, has_cjk) +} + +/// Whether the relevance query names this symbol. Mirrors `_symbol_in_context`: +/// exact token match, or a substring fallback gated by len>3 (in characters, +/// like Python's `len`) for ASCII queries but relaxed for CJK queries — a +/// short ASCII name glued to CJK has no delimiter to isolate it, so the +/// exact match can't fire and the guard would wrongly drop it. +fn symbol_in_context( + name_lower: &str, + words: &BTreeSet, + context_lower: &str, + has_cjk: bool, +) -> bool { + if words.is_empty() || name_lower.is_empty() { + return false; + } + if words.contains(name_lower) { + return true; + } + context_lower.contains(name_lower) && (name_lower.chars().count() > 3 || has_cjk) +} + fn is_public_symbol(name: &str, language: CodeLanguage) -> bool { if name.is_empty() { return false; @@ -1066,18 +1119,8 @@ impl CodeAwareCompressor { ref_counts.insert(qname.clone(), (count - def_count).max(0)); } - // Context words (empty when context is ""). - let context_lower = context.to_lowercase(); - let context_words: BTreeSet = if context.is_empty() { - BTreeSet::new() - } else { - static SPLIT: std::sync::OnceLock = std::sync::OnceLock::new(); - let re = SPLIT.get_or_init(|| regex::Regex::new(r#"[\s,;:.()\[\]{}"']+"#).unwrap()); - re.split(&context_lower) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect() - }; + // Context words (empty when context is ""). Mirrors `_query_context_tokens`. + let (context_words, context_lower, context_has_cjk) = query_context_tokens(context); // Raw importance signals per symbol. let mut raw_signals: Vec<(String, f64)> = Vec::new(); @@ -1106,13 +1149,14 @@ impl CodeAwareCompressor { raw += 1.0; } - if !context_words.is_empty() { - let name_lower = short.to_lowercase(); - if context_words.contains(&name_lower) - || (name_lower.chars().count() > 3 && context_lower.contains(&name_lower)) - { - raw += 3.0; - } + // Context boost: the relevance query named this symbol. + if symbol_in_context( + &short.to_lowercase(), + &context_words, + &context_lower, + context_has_cjk, + ) { + raw += 3.0; } raw_signals.push((qname.clone(), raw)); } @@ -1866,6 +1910,107 @@ mod tests { assert_eq!(lang, CodeLanguage::Unknown); } + // CJK-aware relevance-query matching. Mirrors + // tests/test_transforms/test_code_compressor_cjk.py (Python reference). + + #[test] + fn cjk_query_isolates_wrapped_ascii_symbol() { + // Full-width parens around the name must still tokenize parse_config out. + let (words, lowered, has_cjk) = + query_context_tokens("请重点保留(parse_config)的解析配置"); + assert!(has_cjk); + assert!(words.contains("parse_config")); + assert!(symbol_in_context("parse_config", &words, &lowered, has_cjk)); + } + + #[test] + fn cjk_query_matches_short_ascii_name_glued_to_cjk() { + // 'db' (len 2) glued to CJK has no delimiter to isolate it; the len>3 + // guard is relaxed for CJK so the substring fallback still matches. + let (words, lowered, has_cjk) = query_context_tokens("请保留db相关的逻辑"); + assert!(has_cjk); + assert!(symbol_in_context("db", &words, &lowered, has_cjk)); + } + + #[test] + fn english_short_name_substring_still_gated() { + // ASCII query unchanged: a short name that is only a substring (not a + // token) of an English query must NOT match (avoids spurious boosts). + let (words, lowered, has_cjk) = query_context_tokens("keep the database helper"); + assert!(!has_cjk); + assert!(!symbol_in_context("db", &words, &lowered, has_cjk)); + } + + #[test] + fn english_exact_token_match_unchanged() { + let (words, lowered, has_cjk) = query_context_tokens("keep parse_config and helper"); + assert!(!has_cjk); + assert!(symbol_in_context("parse_config", &words, &lowered, has_cjk)); + assert!(symbol_in_context("helper", &words, &lowered, has_cjk)); + } + + #[test] + fn english_long_name_substring_fallback_unchanged() { + // ASCII path, len>3 substring fallback: 'parse_config' is not a + // standalone token but is a substring of 'parse_configs' -> must match. + let (words, lowered, has_cjk) = query_context_tokens("parse_configs and related helpers"); + assert!(!has_cjk); + assert!(!words.contains("parse_config")); + assert!(symbol_in_context("parse_config", &words, &lowered, has_cjk)); + } + + #[test] + fn empty_context_matches_nothing() { + let (words, lowered, has_cjk) = query_context_tokens(""); + assert!(words.is_empty()); + assert_eq!(lowered, ""); + assert!(!has_cjk); + assert!(!symbol_in_context("foo", &words, &lowered, has_cjk)); + } + + #[test] + fn guard_counts_chars_not_bytes() { + // Python's len() counts characters. A 4-char name that is >3 in chars + // must take the substring fallback on an ASCII query even though a + // byte-length comparison would agree here; conversely a 3-char name + // must not, even when it is many bytes away from any CJK. + let (words, lowered, has_cjk) = query_context_tokens("prefer the runs_fast variant"); + assert!(!has_cjk); + assert!(symbol_in_context("runs", &words, &lowered, has_cjk)); + assert!(!symbol_in_context("run", &words, &lowered, has_cjk)); + } + + /// Symmetric pair of Python functions: identical raw importance signals, + /// so any score difference comes only from the context boost. + const CJK_BOOST_CODE: &str = "import os\n\n\ +def run(config):\n value = config.get(\"alpha\")\n result = value + 1\n total = result * 2\n scaled = total - value\n merged = scaled + result\n print(merged)\n print(scaled)\n print(total)\n return merged\n\n\ +def keep(config):\n value = config.get(\"beta\")\n result = value + 2\n total = result * 3\n scaled = total - value\n merged = scaled + result\n print(merged)\n print(scaled)\n print(total)\n return merged\n"; + + fn score_of(result: &CodeCompressionResult, name: &str) -> f64 { + result + .symbol_scores + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| *v) + .unwrap_or_else(|| panic!("no score for {name}: {:?}", result.symbol_scores)) + } + + #[test] + fn cjk_context_boosts_named_symbol_end_to_end() { + // Python reference: a CJK query with no spaces still boosts the ASCII + // symbol it names ("run" glued to CJK, len 3 <= guard, has_cjk relaxes it). + let c = CodeAwareCompressor::new(CodeCompressorConfig::default()); + let r = c.compress_with(CJK_BOOST_CODE, Some("python"), "修复run函数的报错"); + assert_eq!(score_of(&r, "run"), 1.0, "run must get the context boost"); + assert_eq!(score_of(&r, "keep"), 0.0); + + // ASCII query unchanged: "run" is only a substring of "runner" and the + // len>3 guard is NOT relaxed without CJK -> no boost, symmetric scores. + let r = c.compress_with(CJK_BOOST_CODE, Some("python"), "fix the runner"); + assert_eq!(score_of(&r, "run"), 0.5); + assert_eq!(score_of(&r, "keep"), 0.5); + } + #[test] fn empty_and_short_passthrough() { let c = CodeAwareCompressor::new(CodeCompressorConfig::default()); From e825588bfbc59fa9e86085e23b4a078e9a0038ba Mon Sep 17 00:00:00 2001 From: Zhenjia ZHOU Date: Thu, 30 Jul 2026 00:14:58 +0800 Subject: [PATCH 006/215] fix(ccr): sliding idle-window TTL with max-lifetime ceiling in the Rust core backends (#2604) (#2631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Rust-core counterpart of the CCR mid-session expiry fix. #2604 (and its duplicate #2616) report that the 30-minute wall-clock TTL kills entries in the middle of a normal multi-agent burst: the clock starts at compression time and never refreshes, so an entry the session keeps touching still dies. #2607 fixes this on the Python side by turning the TTL into an idle window that restarts on every successful retrieval, bounded by an absolute max lifetime (8x the idle TTL) — but it explicitly notes the caveat that the Rust core still measures TTL from insertion. This PR closes that gap: all three Rust CCR backends (`InMemoryCcrStore`, `SqliteCcrStore`, `RedisCcrStore`) now use the same sliding idle-window + max-lifetime-ceiling semantics as the Python `CompressionStore`. Scoped to the Rust core only; it deliberately does not touch `DEFAULT_TTL`'s value (1800), which #2607 bumps to 3600 — happy to rebase in lockstep whichever lands first. Refs #2604, #2616. Complements #2607. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `crates/headroom-core/src/ccr/mod.rs`: `DEFAULT_MAX_LIFETIME_MULTIPLIER = 8` + `max_lifetime_for()` helper; documents the idle-window semantics. - `in_memory.rs`: entries track `last_accessed`; a hit refreshes it under the shard write lock (`get_mut`), expiry checks idle window OR max lifetime, and the existing `remove_if` TOCTOU protection now uses the same predicate. New `with_capacity_and_ttls` constructor for independent control of window and ceiling. - `sqlite.rs`: new `last_accessed` column (legacy DBs migrated in place via `ALTER TABLE`, backfilled from `created_at` so old rows keep their original expiry baseline); lazy purge and the lookup honour both bounds; a hit touches the row under the same connection mutex as the read. New `open_with_ttls` constructor. - `redis.rs`: a hit re-arms the key's expiry, capped by a companion `{prefix}:{hash}:born` key whose remaining TTL marks the absolute ceiling; entries written by pre-sliding builds (no born key) are backfilled rather than dropped. - `tests/ccr_backends.rs`: 6 new tests — sliding-window survival and max-lifetime cap for in-memory and SQLite, legacy-schema migration, and a gated Redis sliding test. No public API is broken: existing constructors keep their signatures and derive the ceiling as 8x the idle TTL. ## Testing - [x] Unit tests pass (`cargo test -p headroom-core`) - [x] Linting passes (`cargo clippy -p headroom-core --all-features`, `cargo fmt --check`) - [ ] Type checking passes (`mypy headroom`) — N/A, no Python files touched - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ cargo test -p headroom-core --test ccr_backends test result: ok. 12 passed; 0 failed; 0 ignored (8.12s) $ cargo test -p headroom-core ccr # all ccr-named tests across suites 38 passed, 949 filtered out (13 suites) $ cargo test -p headroom-core --test ccr_roundtrip --test live_zone_ccr 18 passed (2 suites) $ cargo check -p headroom-core --features redis # cfg-gated backend compiles Finished `dev` profile in 25.09s $ cargo clippy -p headroom-core --all-features No issues found ``` ## Real Behavior Proof - Environment: macOS (Darwin 25.5), local checkout at upstream `main` (57bf720d), `cargo test`. - Exact command / steps: dropped a proof test file (`ccr_sliding_ttl_proof.rs`, uses only APIs present on both main and this branch) into `crates/headroom-core/tests/`, ran it against unpatched `main` src, then against this branch. The in-memory case touches an entry every 60ms with a 120ms TTL; the SQLite case touches at t+2s with a 3s TTL and reads again at t+4s — i.e. the issue's "session keeps using the entry" timeline scaled down. - Observed result: on unpatched `main` both proof tests fail (in-memory: "entry vanished on touch #2 despite constant access"; SQLite: "entry expired at t+4s even though the session touched it at t+2s"); on this branch the same tests pass 2/2. Full output: Before (main, wall-clock TTL): ```text ---- proof_in_memory_entry_survives_while_session_keeps_touching_it stdout ---- panicked: entry vanished on touch #2 despite constant access ---- proof_sqlite_entry_survives_while_session_keeps_touching_it stdout ---- panicked: entry expired at t+4s even though the session touched it at t+2s (wall-clock TTL) test result: FAILED. 0 passed; 2 failed ``` After (this branch, sliding idle window): ```text test result: ok. 2 passed; 0 failed (4.01s) ``` - Not tested: the Redis backend against a live Redis (the new `redis_get_refreshes_idle_ttl` test self-skips without `HEADROOM_TEST_REDIS_URL`, same as the existing gated tests; it compiles under `--features redis` and runs in the CI redis matrix). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Additional Notes - Docs: `docs/content/docs/ccr.mdx` TTL wording is being updated by #2607; not duplicated here to avoid conflicting hunks. - The SQLite migration is intentionally in-place and idempotent (`pragma_table_info` check → `ALTER TABLE ADD COLUMN` → backfill), so a proxy restarting onto an existing `ccr.sqlite` keeps its rows. - If #2607 lands first I will rebase; the only expected overlap is the doc comment around `DEFAULT_TTL`. --- .../src/ccr/backends/in_memory.rs | 57 ++++-- .../headroom-core/src/ccr/backends/redis.rs | 85 ++++++++- .../headroom-core/src/ccr/backends/sqlite.rs | 126 ++++++++++--- crates/headroom-core/src/ccr/mod.rs | 14 ++ crates/headroom-core/tests/ccr_backends.rs | 178 ++++++++++++++++++ 5 files changed, 410 insertions(+), 50 deletions(-) diff --git a/crates/headroom-core/src/ccr/backends/in_memory.rs b/crates/headroom-core/src/ccr/backends/in_memory.rs index efb6b5376..d14b04d3d 100644 --- a/crates/headroom-core/src/ccr/backends/in_memory.rs +++ b/crates/headroom-core/src/ccr/backends/in_memory.rs @@ -15,13 +15,16 @@ use std::time::{Duration, Instant}; use dashmap::DashMap; -use crate::ccr::{CcrStore, DEFAULT_CAPACITY, DEFAULT_TTL}; +use crate::ccr::{max_lifetime_for, CcrStore, DEFAULT_CAPACITY, DEFAULT_TTL}; /// In-memory CCR store backed by [`DashMap`] for sharded concurrent /// access. /// -/// - **TTL**: 30 minutes by default. Entries past their TTL are dropped -/// on the next `get` (lazy expiry — no background reaper thread). +/// - **TTL**: 30 minutes by default, treated as an **idle window** — +/// every successful `get` restarts the entry's clock (#2604), bounded +/// by an absolute max lifetime of 8x the idle TTL measured from +/// insertion. Entries past their window are dropped on the next `get` +/// (lazy expiry — no background reaper thread). /// - **Capacity**: 1000 entries by default. When `put` would push us /// past capacity, the oldest entry (per insertion order) is evicted. /// - **Concurrency**: gets and puts on distinct keys do not contend. @@ -36,6 +39,7 @@ pub struct InMemoryCcrStore { /// they actually evict a real entry. order: Mutex>, ttl: Duration, + max_lifetime: Duration, capacity: usize, } @@ -43,19 +47,38 @@ pub struct InMemoryCcrStore { struct Entry { payload: String, inserted: Instant, + last_accessed: Instant, +} + +impl Entry { + /// Expired when idle past `ttl` OR older (since insertion) than + /// `max_lifetime` — the absolute ceiling that keeps constant access + /// from pinning an entry forever. + fn is_expired(&self, ttl: Duration, max_lifetime: Duration) -> bool { + self.last_accessed.elapsed() > ttl || self.inserted.elapsed() > max_lifetime + } } impl InMemoryCcrStore { - /// Default: 1000 entries, 30-minute TTL. + /// Default: 1000 entries, 30-minute idle TTL (8x max lifetime). pub fn new() -> Self { Self::with_capacity_and_ttl(DEFAULT_CAPACITY, DEFAULT_TTL) } + /// `ttl` is the idle window; the absolute max lifetime defaults to + /// 8x that (see [`crate::ccr::DEFAULT_MAX_LIFETIME_MULTIPLIER`]). pub fn with_capacity_and_ttl(capacity: usize, ttl: Duration) -> Self { + Self::with_capacity_and_ttls(capacity, ttl, max_lifetime_for(ttl)) + } + + /// Full-control constructor: idle window and absolute max lifetime + /// specified independently. + pub fn with_capacity_and_ttls(capacity: usize, ttl: Duration, max_lifetime: Duration) -> Self { Self { map: DashMap::with_capacity(capacity), order: Mutex::new(VecDeque::with_capacity(capacity)), ttl, + max_lifetime, capacity, } } @@ -89,8 +112,10 @@ impl CcrStore for InMemoryCcrStore { // in place, leave the order queue alone. Common when the same // tool output flows through multiple times in a session. if let Some(mut existing) = self.map.get_mut(hash) { + let now = Instant::now(); existing.payload = payload.to_string(); - existing.inserted = Instant::now(); + existing.inserted = now; + existing.last_accessed = now; return; } @@ -99,9 +124,11 @@ impl CcrStore for InMemoryCcrStore { if self.map.len() >= self.capacity { self.evict_until_under_capacity(); } + let now = Instant::now(); let entry = Entry { payload: payload.to_string(), - inserted: Instant::now(), + inserted: now, + last_accessed: now, }; let prev = self.map.insert(hash.to_string(), entry); if prev.is_none() { @@ -117,9 +144,12 @@ impl CcrStore for InMemoryCcrStore { } fn get(&self, hash: &str) -> Option { - // Read path: shard read-lock, check TTL, clone payload out. - // No global lock involvement at all — distinct hashes hash to - // distinct shards and never contend. + // Hit path: shard write-lock (get_mut), check the idle window + + // max-lifetime ceiling, refresh `last_accessed`, clone payload + // out. The TTL is a sliding idle window (#2604): every hit + // restarts the clock, so an entry a session keeps touching does + // not expire mid-burst. Distinct hashes hash to distinct shards + // and never contend. // // Lazy expiry uses DashMap's `remove_if` so the check-and-remove // is atomic on the shard. An earlier 2-step (drop read lock, @@ -130,8 +160,9 @@ impl CcrStore for InMemoryCcrStore { // load this manifested as "I just stored it; why is it gone?" // `remove_if` closes the window because the shard write lock // is held across both the predicate evaluation and the removal. - if let Some(entry) = self.map.get(hash) { - if entry.inserted.elapsed() <= self.ttl { + if let Some(mut entry) = self.map.get_mut(hash) { + if !entry.is_expired(self.ttl, self.max_lifetime) { + entry.last_accessed = Instant::now(); return Some(entry.payload.clone()); } } else { @@ -143,7 +174,9 @@ impl CcrStore for InMemoryCcrStore { // and re-fetch its payload. let was_removed = self .map - .remove_if(hash, |_, entry| entry.inserted.elapsed() > self.ttl) + .remove_if(hash, |_, entry| { + entry.is_expired(self.ttl, self.max_lifetime) + }) .is_some(); if was_removed { None diff --git a/crates/headroom-core/src/ccr/backends/redis.rs b/crates/headroom-core/src/ccr/backends/redis.rs index d0070ec39..a05c8b45a 100644 --- a/crates/headroom-core/src/ccr/backends/redis.rs +++ b/crates/headroom-core/src/ccr/backends/redis.rs @@ -10,10 +10,12 @@ //! # Storage model //! //! Each entry maps to a Redis key `ccr:{hash}` containing the original -//! payload bytes, with a `SETEX` TTL applied on every write. Read path -//! is a single `GET`. Redis handles purging via key expiry — no -//! application-side sweep needed (matching the SQLite backend's -//! lazy-purge but at the Redis level). +//! payload bytes, with a `SETEX` TTL applied on every write. The TTL is +//! an **idle window** (#2604): every successful `get` re-arms the key's +//! expiry, bounded by an absolute max lifetime tracked in a companion +//! `ccr:{hash}:born` key whose own expiry marks the ceiling. Redis +//! handles purging via key expiry — no application-side sweep needed +//! (matching the SQLite backend's lazy-purge but at the Redis level). //! //! # Concurrency //! @@ -27,7 +29,7 @@ use redis::Commands; -use crate::ccr::CcrStore; +use crate::ccr::{max_lifetime_for, CcrStore}; /// Key prefix applied to every CCR entry. Configurable per-deployment /// so multiple proxies sharing one Redis don't collide. @@ -38,6 +40,9 @@ pub struct RedisCcrStore { client: redis::Client, key_prefix: String, default_ttl_seconds: u64, + /// Absolute max lifetime (seconds since `put`) that caps the + /// sliding idle window. Defaults to 8x the idle TTL. + max_lifetime_seconds: u64, } impl RedisCcrStore { @@ -59,10 +64,13 @@ impl RedisCcrStore { // signal. let mut conn = client.get_connection()?; let _: String = redis::cmd("PING").query(&mut conn)?; + let max_lifetime_seconds = + max_lifetime_for(std::time::Duration::from_secs(default_ttl_seconds)).as_secs(); Ok(Self { client, key_prefix, default_ttl_seconds, + max_lifetime_seconds, }) } @@ -70,6 +78,12 @@ impl RedisCcrStore { format!("{}:{}", self.key_prefix, hash) } + /// Companion key whose expiry marks the entry's absolute max + /// lifetime; its remaining TTL caps every idle-window re-arm. + fn born_key_for(&self, hash: &str) -> String { + format!("{}:{}:born", self.key_prefix, hash) + } + /// Default TTL (seconds) applied on every `put`. pub fn default_ttl_seconds(&self) -> u64 { self.default_ttl_seconds @@ -102,6 +116,20 @@ impl CcrStore for RedisCcrStore { error = %err, "ccr_redis_put_failed" ); + return; + } + // Companion max-lifetime marker: its remaining TTL caps every + // idle-window re-arm in `get`, so constant access cannot pin an + // entry past `max_lifetime_seconds`. + let born: redis::RedisResult<()> = + conn.set_ex(self.born_key_for(hash), 1_u8, self.max_lifetime_seconds); + if let Err(err) = born { + tracing::warn!( + target = "ccr.redis", + hash = %hash, + error = %err, + "ccr_redis_put_born_failed" + ); } } @@ -120,9 +148,9 @@ impl CcrStore for RedisCcrStore { } }; let bytes: redis::RedisResult>> = conn.get(&key); - match bytes { - Ok(Some(bytes)) => String::from_utf8(bytes).ok(), - Ok(None) => None, + let payload = match bytes { + Ok(Some(bytes)) => String::from_utf8(bytes).ok()?, + Ok(None) => return None, Err(err) => { tracing::warn!( target = "ccr.redis", @@ -130,9 +158,48 @@ impl CcrStore for RedisCcrStore { error = %err, "ccr_redis_get_failed" ); - None + return None; } + }; + + // Sliding idle window (#2604): re-arm the key's expiry on every + // hit, capped by the companion born-key's remaining lifetime. + let born_key = self.born_key_for(hash); + let born_remaining: i64 = conn.ttl(&born_key).unwrap_or(-1); + let remaining = if born_remaining >= 0 { + born_remaining as u64 + } else { + // Legacy entry written by a pre-sliding build (no born key): + // backfill the ceiling from now rather than dropping data. + let backfill: redis::RedisResult<()> = + conn.set_ex(&born_key, 1_u8, self.max_lifetime_seconds); + if let Err(err) = backfill { + tracing::warn!( + target = "ccr.redis", + hash = %hash, + error = %err, + "ccr_redis_born_backfill_failed" + ); + } + self.max_lifetime_seconds + }; + let new_ttl = self.default_ttl_seconds.min(remaining); + if new_ttl == 0 { + // Past the max lifetime: purge rather than serve a pinned + // entry that should have died. + let _: redis::RedisResult<()> = conn.del(&key); + return None; } + let rearm: redis::RedisResult<()> = conn.expire(&key, new_ttl as i64); + if let Err(err) = rearm { + tracing::warn!( + target = "ccr.redis", + hash = %hash, + error = %err, + "ccr_redis_ttl_rearm_failed" + ); + } + Some(payload) } fn len(&self) -> usize { diff --git a/crates/headroom-core/src/ccr/backends/sqlite.rs b/crates/headroom-core/src/ccr/backends/sqlite.rs index 14cc32cc3..906ecfca4 100644 --- a/crates/headroom-core/src/ccr/backends/sqlite.rs +++ b/crates/headroom-core/src/ccr/backends/sqlite.rs @@ -5,16 +5,21 @@ //! //! ```sql //! CREATE TABLE IF NOT EXISTS ccr_entries ( -//! hash TEXT PRIMARY KEY, -//! original BLOB NOT NULL, -//! created_at INTEGER NOT NULL, -- unix-seconds -//! ttl_seconds INTEGER NOT NULL +//! hash TEXT PRIMARY KEY, +//! original BLOB NOT NULL, +//! created_at INTEGER NOT NULL, -- unix-seconds +//! ttl_seconds INTEGER NOT NULL, -- idle window, restarted on get +//! last_accessed INTEGER NOT NULL -- unix-seconds //! ); //! ``` //! -//! On every `get` we lazy-purge stale rows -//! (`WHERE created_at + ttl_seconds <= now`) — no background reaper -//! thread, no cron. +//! The TTL is an **idle window** (#2604): every successful `get` +//! restarts the row's clock via `last_accessed`, bounded by an absolute +//! max lifetime measured from `created_at`. On every `get` we +//! lazy-purge stale rows (`WHERE last_accessed + ttl_seconds <= now OR +//! created_at + max_lifetime <= now`) — no background reaper thread, +//! no cron. DBs created by pre-sliding builds are migrated in place +//! (the `last_accessed` column is added, backfilled from `created_at`). //! //! All hot statements are prepared once on connection setup and reused //! per call (per realignment build constraint #5: performant). Writes @@ -43,14 +48,17 @@ use std::time::{SystemTime, UNIX_EPOCH}; use rusqlite::{params, Connection, OptionalExtension}; -use crate::ccr::CcrStore; +use crate::ccr::{max_lifetime_for, CcrStore}; /// SQLite-backed CCR store. pub struct SqliteCcrStore { conn: Mutex, - /// Default TTL applied on every `put`. Mirrors Python's - /// `compression_store` 30-minute window. + /// Default idle TTL applied on every `put`. Mirrors Python's + /// `compression_store` idle window. default_ttl_seconds: u64, + /// Absolute max lifetime (seconds since `created_at`) that caps the + /// sliding idle window. Defaults to 8x the idle TTL. + max_lifetime_seconds: u64, /// Path the connection was opened against — kept for diagnostics /// and for the proxy-restart simulation test. path: PathBuf, @@ -58,9 +66,24 @@ pub struct SqliteCcrStore { impl SqliteCcrStore { /// Open or create the DB file at `path` and prepare the schema. + /// `default_ttl_seconds` is the idle window; the absolute max + /// lifetime defaults to 8x that (see + /// [`crate::ccr::DEFAULT_MAX_LIFETIME_MULTIPLIER`]). /// Errors surface to the caller (`from_config`); we never silently /// fall back to the in-memory backend (`feedback_no_silent_fallbacks.md`). pub fn open(path: impl AsRef, default_ttl_seconds: u64) -> rusqlite::Result { + let max_lifetime = + max_lifetime_for(std::time::Duration::from_secs(default_ttl_seconds)).as_secs(); + Self::open_with_ttls(path, default_ttl_seconds, max_lifetime) + } + + /// Full-control constructor: idle window and absolute max lifetime + /// specified independently. + pub fn open_with_ttls( + path: impl AsRef, + default_ttl_seconds: u64, + max_lifetime_seconds: u64, + ) -> rusqlite::Result { let path_buf = path.as_ref().to_path_buf(); let conn = Connection::open(&path_buf)?; @@ -73,25 +96,49 @@ impl SqliteCcrStore { conn.execute( "CREATE TABLE IF NOT EXISTS ccr_entries ( - hash TEXT PRIMARY KEY, - original BLOB NOT NULL, - created_at INTEGER NOT NULL, - ttl_seconds INTEGER NOT NULL + hash TEXT PRIMARY KEY, + original BLOB NOT NULL, + created_at INTEGER NOT NULL, + ttl_seconds INTEGER NOT NULL, + last_accessed INTEGER NOT NULL )", [], )?; + Self::migrate_legacy_schema(&conn)?; // No secondary index — the schema is one-row-per-PK and the only // non-PK lookup (the lazy-purge sweep) is a `WHERE` predicate on - // a small table; an index on `created_at + ttl_seconds` would - // cost more than it saves. + // a small table; an index on the expiry expressions would cost + // more than it saves. Ok(Self { conn: Mutex::new(conn), default_ttl_seconds, + max_lifetime_seconds, path: path_buf, }) } + /// DBs created before the sliding-TTL change lack `last_accessed`. + /// Add it in place and backfill from `created_at` so legacy rows + /// keep their original expiry baseline rather than being purged or + /// artificially refreshed. + fn migrate_legacy_schema(conn: &Connection) -> rusqlite::Result<()> { + let has_last_accessed = conn + .prepare("SELECT 1 FROM pragma_table_info('ccr_entries') WHERE name = 'last_accessed'")? + .exists([])?; + if !has_last_accessed { + conn.execute( + "ALTER TABLE ccr_entries ADD COLUMN last_accessed INTEGER NOT NULL DEFAULT 0", + [], + )?; + conn.execute( + "UPDATE ccr_entries SET last_accessed = created_at WHERE last_accessed = 0", + [], + )?; + } + Ok(()) + } + /// Path the connection was opened against. Test helper. pub fn path(&self) -> &Path { &self.path @@ -102,12 +149,15 @@ impl SqliteCcrStore { self.default_ttl_seconds } - /// Drop all expired rows. Lazy — invoked from `get`. Returns the + /// Drop all expired rows: idle past their window, or past the + /// absolute max lifetime. Lazy — invoked from `get`. Returns the /// number of rows purged. - fn purge_expired(conn: &Connection, now: u64) -> rusqlite::Result { + fn purge_expired(&self, conn: &Connection, now: u64) -> rusqlite::Result { let purged = conn.execute( - "DELETE FROM ccr_entries WHERE created_at + ttl_seconds <= ?1", - params![now as i64], + "DELETE FROM ccr_entries + WHERE last_accessed + ttl_seconds <= ?1 + OR created_at + ?2 <= ?1", + params![now as i64, self.max_lifetime_seconds as i64], )?; Ok(purged) } @@ -129,12 +179,13 @@ impl CcrStore for SqliteCcrStore { // Upsert by PK. ON CONFLICT REPLACE matches the in-memory // backend's idempotent re-store semantics. let res = conn.execute( - "INSERT INTO ccr_entries (hash, original, created_at, ttl_seconds) - VALUES (?1, ?2, ?3, ?4) + "INSERT INTO ccr_entries (hash, original, created_at, ttl_seconds, last_accessed) + VALUES (?1, ?2, ?3, ?4, ?3) ON CONFLICT(hash) DO UPDATE SET - original = excluded.original, - created_at = excluded.created_at, - ttl_seconds = excluded.ttl_seconds", + original = excluded.original, + created_at = excluded.created_at, + ttl_seconds = excluded.ttl_seconds, + last_accessed = excluded.last_accessed", params![ hash, payload.as_bytes(), @@ -165,7 +216,7 @@ impl CcrStore for SqliteCcrStore { // Lazy purge sweep, then the real lookup. Both happen under // the same mutex so the row we read is guaranteed not to have // been just-deleted by another caller. - if let Err(err) = Self::purge_expired(&conn, now) { + if let Err(err) = self.purge_expired(&conn, now) { tracing::warn!( target = "ccr.sqlite", error = %err, @@ -176,8 +227,10 @@ impl CcrStore for SqliteCcrStore { let row: Option> = conn .query_row( "SELECT original FROM ccr_entries - WHERE hash = ?1 AND created_at + ttl_seconds > ?2", - params![hash, now as i64], + WHERE hash = ?1 + AND last_accessed + ttl_seconds > ?2 + AND created_at + ?3 > ?2", + params![hash, now as i64, self.max_lifetime_seconds as i64], |r| r.get::<_, Vec>(0), ) .optional() @@ -191,7 +244,22 @@ impl CcrStore for SqliteCcrStore { None }); - row.and_then(|bytes| String::from_utf8(bytes).ok()) + let row = row?; + // Sliding idle window (#2604): a successful hit restarts the + // row's idle clock. Still under the same mutex as the lookup. + if let Err(err) = conn.execute( + "UPDATE ccr_entries SET last_accessed = ?2 WHERE hash = ?1", + params![hash, now as i64], + ) { + tracing::warn!( + target = "ccr.sqlite", + hash = %hash, + error = %err, + "ccr_sqlite_touch_failed" + ); + } + + String::from_utf8(row).ok() } fn len(&self) -> usize { diff --git a/crates/headroom-core/src/ccr/mod.rs b/crates/headroom-core/src/ccr/mod.rs index 2dd3ef28a..d810414ef 100644 --- a/crates/headroom-core/src/ccr/mod.rs +++ b/crates/headroom-core/src/ccr/mod.rs @@ -65,6 +65,20 @@ pub const DEFAULT_CAPACITY: usize = 1000; /// silently converts "lossless with retrieval" into "lossy". pub const DEFAULT_TTL: Duration = Duration::from_secs(1800); +/// The TTL is an **idle window**, not a wall clock: every successful +/// `get` restarts the entry's clock, so an entry a session keeps +/// touching survives a long multi-agent burst (#2604). To keep +/// constant access from pinning an entry forever, an absolute max +/// lifetime of `DEFAULT_MAX_LIFETIME_MULTIPLIER * ttl` (measured from +/// insertion) caps the sliding window. Mirrors the Python +/// `CompressionStore` semantics. +pub const DEFAULT_MAX_LIFETIME_MULTIPLIER: u32 = 8; + +/// Absolute max lifetime for an entry with idle window `idle_ttl`. +pub fn max_lifetime_for(idle_ttl: Duration) -> Duration { + idle_ttl.saturating_mul(DEFAULT_MAX_LIFETIME_MULTIPLIER) +} + /// Compute the canonical CCR key for `payload`. BLAKE3 → first 24 hex /// chars (96 bits — collision-resistant for the bounded LRU population /// the proxy will hold). Centralized here so every call site (live-zone diff --git a/crates/headroom-core/tests/ccr_backends.rs b/crates/headroom-core/tests/ccr_backends.rs index a3ad8f338..5730b0cae 100644 --- a/crates/headroom-core/tests/ccr_backends.rs +++ b/crates/headroom-core/tests/ccr_backends.rs @@ -152,6 +152,157 @@ fn backend_swap_byte_equal_keys() { } } +// ─── Sliding (idle-window) TTL semantics — #2604 ─────────────────────── +// +// The Python `CompressionStore` treats `HEADROOM_CCR_TTL_SECONDS` as an +// idle window that restarts on every successful retrieval, bounded by an +// absolute max lifetime (8x the idle TTL). These tests pin the same +// semantics onto the Rust backends so an entry a session keeps touching +// does not expire mid-burst. + +#[test] +fn in_memory_get_refreshes_idle_ttl() { + let store = InMemoryCcrStore::with_capacity_and_ttl(10, Duration::from_millis(120)); + let hash = compute_key(b"hot entry"); + store.put(&hash, "hot entry"); + // Touch the entry every 60ms for ~4 idle windows' worth of wall + // clock. Wall-clock expiry would kill it at 120ms; a sliding idle + // window keeps it alive because every hit restarts the clock. + for _ in 0..8 { + std::thread::sleep(Duration::from_millis(60)); + assert_eq!( + store.get(&hash).as_deref(), + Some("hot entry"), + "an entry accessed within its idle window must stay alive" + ); + } + // Now go idle past the window: the entry must expire. + std::thread::sleep(Duration::from_millis(200)); + assert_eq!( + store.get(&hash), + None, + "an entry idle past its window must expire" + ); +} + +#[test] +fn in_memory_max_lifetime_caps_sliding_window() { + // Idle TTL 40ms → max lifetime 320ms (8x). Constant access must not + // keep the entry alive forever. + let store = InMemoryCcrStore::with_capacity_and_ttl(10, Duration::from_millis(40)); + let hash = compute_key(b"immortal?"); + store.put(&hash, "immortal?"); + let deadline = std::time::Instant::now() + Duration::from_millis(600); + let mut expired = false; + while std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + if store.get(&hash).is_none() { + expired = true; + break; + } + } + assert!( + expired, + "constant access must not extend an entry past its max lifetime" + ); +} + +#[test] +fn sqlite_get_refreshes_idle_ttl() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("ccr.sqlite"); + // 3-second idle window (unix-second resolution needs whole seconds). + let store = SqliteCcrStore::open(&path, 3).expect("open sqlite store"); + let hash = compute_key(b"sliding sqlite"); + store.put(&hash, "sliding sqlite"); + // t+2s: hit inside the window — restarts the idle clock. + std::thread::sleep(Duration::from_millis(2_000)); + assert_eq!( + store.get(&hash).as_deref(), + Some("sliding sqlite"), + "first access within the idle window must hit" + ); + // t+4s: wall-clock expiry would have purged at t+3s; the refresh at + // t+2s must keep it alive until t+5s. + std::thread::sleep(Duration::from_millis(2_000)); + assert_eq!( + store.get(&hash).as_deref(), + Some("sliding sqlite"), + "an entry accessed within its idle window must stay alive past the wall-clock TTL" + ); + // Go idle past the window. + std::thread::sleep(Duration::from_millis(4_100)); + assert_eq!( + store.get(&hash), + None, + "an entry idle past its window must be purged" + ); +} + +#[test] +fn sqlite_max_lifetime_caps_sliding_window() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("ccr.sqlite"); + // Idle 2s with a 3s ceiling: constant access must not outlive t+3s. + let store = + SqliteCcrStore::open_with_ttls(&path, 2, 3).expect("open sqlite store with ceiling"); + let hash = compute_key(b"capped sqlite"); + store.put(&hash, "capped sqlite"); + std::thread::sleep(Duration::from_millis(1_500)); + assert_eq!( + store.get(&hash).as_deref(), + Some("capped sqlite"), + "entry inside idle window and ceiling must hit" + ); + // Keep touching, but cross the 3s ceiling. + std::thread::sleep(Duration::from_millis(2_600)); + assert_eq!( + store.get(&hash), + None, + "constant access must not extend an entry past its max lifetime" + ); +} + +#[test] +fn sqlite_migrates_legacy_schema_without_last_accessed() { + // A DB created by a pre-sliding-TTL build has no `last_accessed` + // column. Opening it must migrate in place and keep the rows + // retrievable (backfilling last_accessed from created_at). + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("ccr.sqlite"); + let payload = "legacy row"; + let hash = compute_key(payload.as_bytes()); + { + let conn = rusqlite::Connection::open(&path).expect("open raw connection"); + conn.execute( + "CREATE TABLE ccr_entries ( + hash TEXT PRIMARY KEY, + original BLOB NOT NULL, + created_at INTEGER NOT NULL, + ttl_seconds INTEGER NOT NULL + )", + [], + ) + .expect("create legacy schema"); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + conn.execute( + "INSERT INTO ccr_entries (hash, original, created_at, ttl_seconds) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![hash, payload.as_bytes(), now, 300_i64], + ) + .expect("insert legacy row"); + } + let store = SqliteCcrStore::open(&path, 300).expect("open must migrate legacy schema"); + assert_eq!( + store.get(&hash).as_deref(), + Some(payload), + "legacy rows must survive the schema migration" + ); +} + // ─── Redis-feature-gated tests ───────────────────────────────────────── #[cfg(feature = "redis")] @@ -196,4 +347,31 @@ mod redis_tests { store.put(&hash, payload); assert_eq!(store.get(&hash).as_deref(), Some(payload)); } + + #[test] + fn redis_get_refreshes_idle_ttl() { + let Some(url) = redis_url() else { + eprintln!("skipping redis_get_refreshes_idle_ttl: HEADROOM_TEST_REDIS_URL not set"); + return; + }; + // 2-second idle window (Redis EXPIRE has 1s resolution). + let store = RedisCcrStore::open_with_prefix(&url, "ccr_test_sliding".to_string(), 2) + .expect("open redis store"); + let payload = "sliding redis"; + let hash = compute_key(payload.as_bytes()); + store.put(&hash, payload); + // Touch at t+1.5s (inside window) — restarts the idle clock. + std::thread::sleep(Duration::from_millis(1_500)); + assert_eq!(store.get(&hash).as_deref(), Some(payload)); + // t+3s: wall-clock expiry would have fired at t+2s. + std::thread::sleep(Duration::from_millis(1_500)); + assert_eq!( + store.get(&hash).as_deref(), + Some(payload), + "an entry accessed within its idle window must stay alive past the wall-clock TTL" + ); + // Go idle past the window. + std::thread::sleep(Duration::from_millis(3_100)); + assert_eq!(store.get(&hash), None); + } } From e0d2cd0c5a1c3ee813ac225252c9fd8db7c77c12 Mon Sep 17 00:00:00 2001 From: Tejas Chopra Date: Wed, 29 Jul 2026 09:16:41 -0700 Subject: [PATCH 007/215] fix(cache): preserve cache_control ttl when re-anchoring a breakpoint (#2651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `normalize_message_cache_control` deliberately reuses the client's marker verbatim so an explicit `cache_control.ttl` (e.g. `"1h"`) survives breakpoint consolidation instead of silently downgrading to the 5-minute default (#2375). Two other sites also strip a breakpoint and re-place it, and both hardcoded a bare `{"type": "ephemeral"}` — undoing that guarantee. A downgrade is invisible: the request still succeeds, and the cost shows up later as a full prefix re-write on every idle gap past 5 minutes. Measured over 10,409 local Claude Code API requests, cache writes are **6.1% of raw input tokens but 44.8% of the price-weighted input bill** (5m write 1.25x vs read 0.1x), and **89% of those write tokens are re-writes of content cached one request earlier**. Honoring a 1h TTL when the client asks for it is the cheapest thing we can do about that. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/transforms/read_maturation.py` — `relocate_cache_breakpoint` now carries the stripped marker forward when re-anchoring before the held-Read region. This is the one that mattered most: it runs **after** `normalize_message_cache_control` in the Anthropic handler (`anthropic.py:1747` vs `:1642`), so it had the final say — a 1h client with read maturation enabled was being downgraded to 5m. - `headroom/proxy/helpers.py` — `inject_tool_search_deferral` keeps the dropped marker when moving the tools-array breakpoint off a now-deferred tool onto the last resident real tool. - Both fall back to a bare ephemeral only when the client sent no ttl, and neither invents a breakpoint where none existed. - `headroom/transforms/compression_policy.py` — comment only. Notes that `CACHE_WRITE_MULTIPLIER` is hardcoded to the 5m tier (1.25x), so a client already on 1h caching (2.0x) has its mutations gated with a ~40% under-stated write penalty. Harmless while the net-cost gate stays default-off (`HEADROOM_NET_COST_POLICY`); names the plumbing needed if it is ever enabled. Both changed code paths sit behind off-by-default flags (`HEADROOM_READ_MATURATION`, `HEADROOM_TOOL_SEARCH`), so this is a latent-bug fix with **no default behavior change**. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cache_ttl_preserved.py tests/test_read_maturation.py \ tests/test_read_maturation_handler_nobust.py tests/test_cache_control_move_bust.py -q tests/test_cache_ttl_preserved.py ..... [ 12%] tests/test_read_maturation.py ...................... [ 67%] tests/test_read_maturation_handler_nobust.py ... [ 75%] tests/test_cache_control_move_bust.py .......... [100%] ============================= 40 passed in 15.52s ============================== $ ruff check headroom/ tests/ --exclude headroom/dashboard/templates All checks passed! $ mypy headroom Success: no issues found in 509 source files ``` Broader regression sweep over every cache/breakpoint-adjacent suite: ```text $ python -m pytest tests/ -q -k "read_maturation or tool_search or cache_control or prefix_tracker or ttl_preserved" 204 passed, 10145 deselected in 59.95s ``` ## Real Behavior Proof - **Environment:** macOS 25.4.0 (arm64), Python 3.12.6, pytest 9.0.2, branched from `main` at e530de5a. - **Exact command / steps:** verified the new tests actually fail without the fix, rather than passing vacuously: ``` $ git stash push -- headroom/transforms/read_maturation.py headroom/proxy/helpers.py $ python -m pytest tests/test_cache_ttl_preserved.py -q ``` - **Observed result:** exactly the two TTL-preservation tests fail, with the downgrade visible in the assertion: ```text E assert [{'type': 'ephemeral'}] == [{'ttl': '1h'... 'ephemeral'}] E At index 0 diff: {'type': 'ephemeral'} != {'type': 'ephemeral', 'ttl': '1h'} FAILED tests/test_cache_ttl_preserved.py::test_read_maturation_reanchor_keeps_ttl FAILED tests/test_cache_ttl_preserved.py::test_tool_search_deferral_keeps_ttl ========================= 2 failed, 3 passed in 0.56s ========================= ``` The other three pass either way, which is correct: they pin the 5m default and the "don't invent a breakpoint" case. Restored with `git stash pop`; all 5 pass again. - **Not tested:** no live Anthropic request was made with `ttl: "1h"` — both changed paths are behind off-by-default flags, and the corpus I measured contains only 15 requests that ever used 1h TTL, so the 2.0x write multiplier cited above is from Anthropic's price list, not observed traffic. The `compression_policy.py` change is a comment and has no runtime effect. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` Docs: N/A — no user-facing surface changes. The behavior being fixed (an explicit client `cache_control.ttl` is preserved) is what the existing `normalize_message_cache_control` docstring already promises; these two sites were violating it. ## Additional Notes **Scope deliberately kept to the fixes.** An earlier draft also added a `HEADROOM_CACHE_LONGEVITY` flag that paired the existing cold-prefix recompaction with an adaptive 5m→1h TTL upgrade for sessions observed losing a warm prefix. That was dropped: a 1h write costs 2.0x vs 1.25x, so it is a bet that a session idles often enough to repay the premium, and the TTL lever is Anthropic-only (OpenAI/Codex cache automatically with no TTL knob). It carried more side effects than the ~16% it modelled was worth. The recompaction half already exists behind `HEADROOM_COLD_RECOMPACT` and needs no new code. **Follow-up worth considering separately:** the headline compression savings figure is cache-blind — `cost.py:965-976` destructures the cache-write price and discards it (`_cw_price`), and the savings-percent denominator at `cost.py:568-570` includes the write premium while the numerator does not, so a compression-induced cache bust *inflates* reported savings. Given cache writes are ~45% of the effective input bill, that seems worth its own issue. --- headroom/proxy/helpers.py | 12 +++- headroom/transforms/compression_policy.py | 7 +++ headroom/transforms/read_maturation.py | 11 +++- tests/test_cache_ttl_preserved.py | 72 +++++++++++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 tests/test_cache_ttl_preserved.py diff --git a/headroom/proxy/helpers.py b/headroom/proxy/helpers.py index f1096acc5..c926c4fb6 100644 --- a/headroom/proxy/helpers.py +++ b/headroom/proxy/helpers.py @@ -2902,6 +2902,7 @@ def inject_tool_search_deferral( out: list[Any] = [search_tool] deferred = 0 dropped_cache_control = False + dropped_marker: dict[str, Any] | None = None last_resident_real: dict[str, Any] | None = None resident_has_cache_control = False @@ -2928,8 +2929,13 @@ def inject_tool_search_deferral( continue new_tool = dict(tool) new_tool["defer_loading"] = True - if new_tool.pop("cache_control", None) is not None: + _dropped = new_tool.pop("cache_control", None) + if _dropped is not None: dropped_cache_control = True + # Keep the marker itself, not just the fact of it: re-placing a bare + # ephemeral would downgrade a 1h breakpoint to the 5m default. + if isinstance(_dropped, dict): + dropped_marker = _dropped out.append(new_tool) deferred += 1 @@ -2939,7 +2945,9 @@ def inject_tool_search_deferral( # deferred tool and no resident tool carries one, move it to the last # resident real tool (never the search tool, to keep its shape canonical). if dropped_cache_control and not resident_has_cache_control and last_resident_real is not None: - last_resident_real["cache_control"] = {"type": "ephemeral"} + last_resident_real["cache_control"] = ( + dict(dropped_marker) if dropped_marker else {"type": "ephemeral"} + ) return out diff --git a/headroom/transforms/compression_policy.py b/headroom/transforms/compression_policy.py index 626b40bc3..faf54fa60 100644 --- a/headroom/transforms/compression_policy.py +++ b/headroom/transforms/compression_policy.py @@ -58,6 +58,13 @@ _MAX_LOSSY_RATIO_SUBSCRIPTION: float = 0.25 #: Anthropic prompt-cache write multiplier: a ``cache_creation`` token #: costs 1.25x a plain input token (5-minute TTL tier). Input to the #: net-cost mutation formula (#856). Mirrors the Rust ``pub const``. +#: ponytail: hardcoded to the 5m tier. A client on Anthropic's 1h cache +#: (ENABLE_PROMPT_CACHING_1H / cache_control.ttl="1h", which Headroom +#: preserves) writes at 2.0x, so its mutations are gated with a ~40% +#: under-stated write penalty. Harmless while the net-cost gate stays +#: default-off (HEADROOM_NET_COST_POLICY); thread the TTL from +#: cold_prefix.anthropic_cache_ttl_seconds through ContentRouter -> +#: net_mutation_gain if that gate is ever turned on. CACHE_WRITE_MULTIPLIER: float = 1.25 #: Anthropic prompt-cache read multiplier: a ``cache_read`` token costs diff --git a/headroom/transforms/read_maturation.py b/headroom/transforms/read_maturation.py index 7efc653bd..309c369e4 100644 --- a/headroom/transforms/read_maturation.py +++ b/headroom/transforms/read_maturation.py @@ -343,12 +343,18 @@ def relocate_cache_breakpoint( stripped_any = False # 1. Strip breakpoints from the held region [earliest:]. + held_marker: dict[str, Any] | None = None for i in range(earliest, len(out)): msg = out[i] content = msg.get("content") if not isinstance(content, list): continue if any(isinstance(b, dict) and "cache_control" in b for b in content): + for b in content: + # Carry the TTL forward: re-anchoring with a bare ephemeral marker + # would silently downgrade a 1h breakpoint to the 5m default. + if isinstance(b, dict) and isinstance(b.get("cache_control"), dict): + held_marker = b["cache_control"] out[i] = { **msg, "content": [ @@ -371,7 +377,10 @@ def relocate_cache_breakpoint( content = out[i].get("content") if isinstance(content, list) and content and isinstance(content[-1], dict): new_content = list(content) - new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}} + new_content[-1] = { + **new_content[-1], + "cache_control": dict(held_marker) if held_marker else {"type": "ephemeral"}, + } out[i] = {**out[i], "content": new_content} break diff --git a/tests/test_cache_ttl_preserved.py b/tests/test_cache_ttl_preserved.py new file mode 100644 index 000000000..4aeabb4ce --- /dev/null +++ b/tests/test_cache_ttl_preserved.py @@ -0,0 +1,72 @@ +"""Re-anchored cache breakpoints must keep the client's TTL. + +``normalize_message_cache_control`` deliberately preserves an explicit +``cache_control.ttl`` so a client on Anthropic's 1h cache isn't silently +downgraded to the 5-minute default (#2375). Two other sites also strip a +breakpoint and re-place it, and both used to hardcode a bare ephemeral marker — +undoing that guarantee. A downgrade is invisible (the request still succeeds) +and costs a full prefix re-write on every gap past 5 minutes, so it needs a test +rather than a comment. +""" + +from typing import Any + +from headroom.proxy.helpers import inject_tool_search_deferral +from headroom.transforms.read_maturation import relocate_cache_breakpoint + +TTL_1H = {"type": "ephemeral", "ttl": "1h"} + + +def _markers(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [b["cache_control"] for b in blocks if isinstance(b, dict) and "cache_control" in b] + + +def _held(marker: dict[str, Any]) -> list[dict[str, Any]]: + return [ + {"role": "user", "content": [{"type": "text", "text": "keep"}]}, + {"role": "user", "content": [{"type": "text", "text": "held", "cache_control": marker}]}, + ] + + +def test_read_maturation_reanchor_keeps_ttl() -> None: + # Breakpoint sits inside the held-Read region, so it is moved back before it. + out = relocate_cache_breakpoint(_held(TTL_1H), holding_msg_indices=[1]) + assert _markers(out[0]["content"]) == [TTL_1H], "re-anchored breakpoint lost the 1h ttl" + assert _markers(out[1]["content"]) == [], "held region should carry no breakpoint" + + +def test_read_maturation_reanchor_defaults_to_5m() -> None: + out = relocate_cache_breakpoint(_held({"type": "ephemeral"}), holding_msg_indices=[1]) + assert _markers(out[0]["content"]) == [{"type": "ephemeral"}] + + +def _tools(marker: dict[str, Any] | None) -> list[dict[str, Any]]: + # Needs >= _TOOL_SEARCH_MIN_TOOLS (12) to trigger, with one core tool resident + # and the tools-array breakpoint riding on a tool that will be deferred. + tools: list[dict[str, Any]] = [{"name": "read", "description": "core", "input_schema": {}}] + for i in range(12): + t: dict[str, Any] = {"name": f"rare_{i}", "description": "rare", "input_schema": {}} + if marker is not None and i == 11: + t["cache_control"] = marker + tools.append(t) + return tools + + +def _tool_markers(tools: Any) -> list[dict[str, Any]]: + return [t["cache_control"] for t in tools if isinstance(t, dict) and "cache_control" in t] + + +def test_tool_search_deferral_keeps_ttl() -> None: + out = inject_tool_search_deferral(_tools(TTL_1H)) + assert out is not _tools(TTL_1H), "deferral did not apply — fixture no longer triggers it" + assert _tool_markers(out) == [TTL_1H], "tools breakpoint lost the 1h ttl" + + +def test_tool_search_deferral_defaults_to_5m() -> None: + out = inject_tool_search_deferral(_tools({"type": "ephemeral"})) + assert _tool_markers(out) == [{"type": "ephemeral"}] + + +def test_tool_search_deferral_no_breakpoint_adds_none() -> None: + # Nothing was stripped, so nothing should be invented. + assert _tool_markers(inject_tool_search_deferral(_tools(None))) == [] From 2dc7e4ab27b91d0b8056882b1efc6c2ab8cd2565 Mon Sep 17 00:00:00 2001 From: JD Davis Date: Wed, 29 Jul 2026 16:17:25 +0000 Subject: [PATCH 008/215] test: add fluent Headroom harness (#2650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Adds `headroom.testing`, a fluent, contractual test harness for building Headroom scenarios and suites that can be simulated locally, orchestrated, deployed through the proxy, and handed off to `headroom-bench` / `agent-evals` with bench-native manifests. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Add `headroom.testing.Headroom` fluent scenario builder with provider/platform/configuration facets such as `WithBedrock`, `OnAppleSilicon`, `Configure`, `WithCompression`, `WithCCR`, `WithCache`, `WithPrefixFreeze`, `WithReadMaturation`, and `WithMemory`. - Add contractual coverage over the current `HeadroomConfig` and `ProxyConfig` dataclass surfaces, including full JSON-ready proxy deployment payloads. - Add no-key local simulations, scenario/suite orchestration, guarantee evaluation, deployment plans, and a local proxy lifecycle context manager. - Add `headroom-bench` handoff artifacts, including `agent_evals.models.RunManifest`-compatible JSON without taking a runtime dependency on `agent-evals`. - Add demonstration tests for providers, feature facets, manifests, suites, guarantees, deployment payloads, and the no-key simulation path. - Fix unversioned OTEL meter lookup typing so `mypy headroom` remains green on current `main`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text python -m ruff check . All checks passed! python -m mypy headroom headroom\proxy\server.py:1680: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] headroom\proxy\server.py:1691: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs [annotation-unchecked] Success: no issues found in 512 source files python -m pytest tests/test_cli/test_subprocess_utf8_encoding.py tests/test_testing_harness.py -q 24 passed, 1 warning in 4.68s ``` ## Real Behavior Proof - Environment: Windows, Python 3.13.13, branch `feat/headroom-test-harness` rebased on `headroomlabs-ai/main`. - Exact command / steps: built a `Headroom.WithOpenAI().WithCompression(mode="cache", kompress=False).Build()` scenario and entered `scenario.deploy_local(port=19192, timeout_s=20)`. - Observed result: proxy launched, `/readyz` succeeded, handle returned `http://127.0.0.1:19192`, `OPENAI_BASE_URL=http://127.0.0.1:19192/v1`, and context-manager teardown completed. - Exact command / steps: emitted `scenario.agent_evals_manifest(...).to_dict()` and validated it with the current cloned `headroom-bench` `agent_evals.models.RunManifest` pydantic model. - Observed result: validation succeeded with arms `a0_direct`, `a1_passthrough`, and `b_headroom` for provider `openai`. - Not tested: upstream-provider API calls requiring real OpenAI/Anthropic/Bedrock keys; phase-1 validation intentionally stays no-key/local. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Screenshots (if applicable) N/A. ## Additional Notes The pytest warning shown above is the existing OpenAI pricing-data staleness warning from cost estimation. The harness does not call upstream providers during local simulation. --- headroom/observability/metrics.py | 4 + headroom/testing/README.md | 72 ++ headroom/testing/__init__.py | 68 ++ headroom/testing/harness.py | 1552 +++++++++++++++++++++++++++++ tests/test_testing_harness.py | 459 +++++++++ 5 files changed, 2155 insertions(+) create mode 100644 headroom/testing/README.md create mode 100644 headroom/testing/__init__.py create mode 100644 headroom/testing/harness.py create mode 100644 tests/test_testing_harness.py diff --git a/headroom/observability/metrics.py b/headroom/observability/metrics.py index 7a6fed7c5..a2c55ae7b 100644 --- a/headroom/observability/metrics.py +++ b/headroom/observability/metrics.py @@ -321,7 +321,11 @@ class HeadroomOtelMetrics: """ if self._meter_provider is None: + if version is None: + return metrics.get_meter(name) return metrics.get_meter(name, version) + if version is None: + return self._meter_provider.get_meter(name) return self._meter_provider.get_meter(name, version) @staticmethod diff --git a/headroom/testing/README.md b/headroom/testing/README.md new file mode 100644 index 000000000..7b9ee7f99 --- /dev/null +++ b/headroom/testing/README.md @@ -0,0 +1,72 @@ +# Headroom Testing Harness + +`headroom.testing` is the fluent scenario contract for local simulations, +deployment planning, and `headroom-bench` handoff. + +## Scenario + +```python +from headroom.testing import Headroom, ScenarioTask + +scenario = ( + Headroom.WithBedrock(region="us-east-1", profile="bench") + .OnAppleSilicon() + .WithCompression(mode="cache", kompress=False, savings_profile="coding") + .WithCCR(enabled=True, inject_tool=False, inject_marker=True) + .WithMemory(enabled=True, mode="tool", top_k=4) + .Configure(lambda c: setattr(c, "default_mode", "optimize")) + .Build() +) + +task = ScenarioTask( + task_id="smoke", + messages=[{"role": "user", "content": "Summarize this payload."}], +) + +report = scenario.orchestrate([task]) +assert report.passed +``` + +## Suite + +```python +suite = ( + Headroom.Suite("phase-1") + .Add(Headroom.WithOpenAI().named("openai-cache").WithCompression(mode="cache")) + .Add(Headroom.WithBedrock(region="us-east-1").named("bedrock-token").WithCompression(mode="token")) +) + +suite.write_manifest_bundle("headroom-testing-bundle.json", provider="openai", port_start=19000) +suite.write_agent_evals_manifests( + "agent-evals-manifests", + benchmark="mini_swebench", + benchmark_ref="mini@abc123", + provider="openai", +) +``` + +## Contract + +Every scenario builds real `HeadroomConfig` and `ProxyConfig` objects. The harness +exports the complete dataclass constructor surface, including compatibility +`InitVar` fields, through: + +- `scenario.audit_contract()` +- `scenario.deployment_plan()` +- `scenario.bench_manifest_fragment()` +- `scenario.agent_evals_manifest()` +- `suite.manifest_bundle()` +- `suite.agent_evals_manifests()` + +Phase-1 validation is local and no-key: `simulate` and `orchestrate` run the SDK +transform pipeline without calling upstream providers. Live proxy deployment is +available through `scenario.deploy_local(...)`; deployment plans carry the full +proxy config in `HEADROOM_PROXY_CONFIG_JSON`. + +## headroom-bench + +`agent_evals_manifest(...)` emits the same top-level field names as +`agent_evals.models.RunManifest`, including `arms` entries shaped like +`ArmSpec`. The harness keeps this adapter dependency-free: `headroom-ai` can +write bench-native JSON without importing `agent-evals`, while `headroom-bench` +can validate the artifact with its own pydantic model. diff --git a/headroom/testing/__init__.py b/headroom/testing/__init__.py new file mode 100644 index 000000000..b3dbe9a2b --- /dev/null +++ b/headroom/testing/__init__.py @@ -0,0 +1,68 @@ +"""Fluent testing harness for Headroom scenarios. + +The testing package is intentionally small at import time. It builds real +``HeadroomConfig`` and ``ProxyConfig`` instances and exposes the same surface to +bench runners, smoke tests, and local simulations. +""" + +from __future__ import annotations + +from .harness import ( + AgentEvalsManifest, + AgentEvalsPricing, + ArmName, + BenchArm, + BenchManifestFragment, + Configurator, + ContractAudit, + DeploymentHandle, + FieldContract, + Guarantee, + GuaranteeResult, + HarnessScenario, + Headroom, + HeadroomSuite, + LocalProxyDeployment, + PlatformTarget, + ProviderTarget, + ProxyDeploymentPlan, + ScenarioCaseResult, + ScenarioContract, + ScenarioOrchestrator, + ScenarioResult, + ScenarioRunReport, + ScenarioTask, + SuiteManifestBundle, + guarantee_messages_remain_non_empty, + guarantee_tokens_do_not_increase, +) + +__all__ = [ + "AgentEvalsManifest", + "AgentEvalsPricing", + "ArmName", + "BenchArm", + "BenchManifestFragment", + "Configurator", + "ContractAudit", + "DeploymentHandle", + "FieldContract", + "Guarantee", + "GuaranteeResult", + "Headroom", + "HarnessScenario", + "HeadroomSuite", + "LocalProxyDeployment", + "PlatformTarget", + "ProviderTarget", + "ProxyDeploymentPlan", + "ScenarioCaseResult", + "ScenarioContract", + "ScenarioOrchestrator", + "ScenarioResult", + "ScenarioRunReport", + "ScenarioTask", + "SuiteManifestBundle", + "guarantee_messages_remain_non_empty", + "guarantee_tokens_do_not_increase", +] diff --git a/headroom/testing/harness.py b/headroom/testing/harness.py new file mode 100644 index 000000000..1904085c3 --- /dev/null +++ b/headroom/testing/harness.py @@ -0,0 +1,1552 @@ +"""Authoritative Headroom test-harness contract. + +This module gives tests and external benches one fluent API for configuring the +whole Headroom surface area. It does not mirror a hand-maintained subset: +``HeadroomConfig`` and ``ProxyConfig`` are instantiated directly, and their +dataclass fields are exposed through ``ScenarioContract`` so drift is visible to +tests and downstream harnesses. +""" + +from __future__ import annotations + +import dataclasses +import json +import os +import subprocess +import tempfile +import time +import urllib.error +import urllib.request +import uuid +from collections.abc import Callable, Mapping, Sequence +from dataclasses import MISSING, asdict, dataclass, is_dataclass, replace +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path +from typing import Any, Literal, cast + +from headroom._subprocess import run as subprocess_run +from headroom.config import HeadroomConfig, HeadroomMode +from headroom.proxy.models import ProxyConfig + +ConfigCallback = Callable[["Configurator"], None] +ProxyCallback = Callable[[ProxyConfig], None] +SdkCallback = Callable[[HeadroomConfig], None] +Guarantee = Callable[["HarnessScenario", "ScenarioTask", "ScenarioResult"], "GuaranteeResult"] + + +class ProviderTarget(str, Enum): + """Provider/backend targets that Headroom currently exposes through the proxy.""" + + ANTHROPIC = "anthropic" + OPENAI = "openai" + GEMINI = "gemini" + CLOUDCODE = "cloudcode" + VERTEX = "vertex" + BEDROCK = "bedrock" + ANYLLM = "anyllm" + LITELLM = "litellm" + + +class PlatformTarget(str, Enum): + """Execution platform metadata for reproducible test scenarios.""" + + LOCAL = "local" + APPLE_SILICON = "apple_silicon" + LINUX_X86_64 = "linux_x86_64" + CONTAINER = "container" + + +class ArmName(str, Enum): + """Bench arm names used by headroom-bench.""" + + A0_DIRECT = "a0_direct" + A1_PASSTHROUGH = "a1_passthrough" + B_HEADROOM = "b_headroom" + B_ABLATE = "b_ablate" + + +@dataclass(frozen=True) +class FieldContract: + """One dataclass field in the Headroom contract.""" + + owner: Literal["headroom", "proxy"] + name: str + type_repr: str + has_default: bool + default_repr: str | None + + +@dataclass(frozen=True) +class ScenarioContract: + """The auditable configuration surface for a built scenario.""" + + headroom_fields: tuple[FieldContract, ...] + proxy_fields: tuple[FieldContract, ...] + + def field_names(self, owner: Literal["headroom", "proxy"]) -> set[str]: + source = self.headroom_fields if owner == "headroom" else self.proxy_fields + return {field.name for field in source} + + +@dataclass(frozen=True) +class ContractAudit: + """Machine-readable audit of a scenario against current Headroom contracts.""" + + scenario: str + provider: str + platform: str + headroom_fields_total: int + proxy_fields_total: int + headroom_payload_fields: tuple[str, ...] + proxy_payload_fields: tuple[str, ...] + missing_headroom_payload_fields: tuple[str, ...] + missing_proxy_payload_fields: tuple[str, ...] + extra_headroom_payload_fields: tuple[str, ...] + extra_proxy_payload_fields: tuple[str, ...] + notes: tuple[str, ...] = () + + @property + def passed(self) -> bool: + return not ( + self.missing_headroom_payload_fields + or self.missing_proxy_payload_fields + or self.extra_headroom_payload_fields + or self.extra_proxy_payload_fields + ) + + def to_dict(self) -> dict[str, Any]: + return { + "scenario": self.scenario, + "provider": self.provider, + "platform": self.platform, + "passed": self.passed, + "headroom_fields_total": self.headroom_fields_total, + "proxy_fields_total": self.proxy_fields_total, + "headroom_payload_fields": list(self.headroom_payload_fields), + "proxy_payload_fields": list(self.proxy_payload_fields), + "missing_headroom_payload_fields": list(self.missing_headroom_payload_fields), + "missing_proxy_payload_fields": list(self.missing_proxy_payload_fields), + "extra_headroom_payload_fields": list(self.extra_headroom_payload_fields), + "extra_proxy_payload_fields": list(self.extra_proxy_payload_fields), + "notes": list(self.notes), + } + + +@dataclass(frozen=True) +class BenchArm: + """Bench-ready arm specification compatible with headroom-bench's model.""" + + name: ArmName + provider: Literal["anthropic", "openai"] + proxy_mode: Literal["off", "token", "cache"] | None + proxy_flags: tuple[str, ...] + label: str + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name.value, + "provider": self.provider, + "proxy_mode": self.proxy_mode, + "proxy_flags": list(self.proxy_flags), + "label": self.label, + } + + +@dataclass(frozen=True) +class BenchManifestFragment: + """Portable scenario metadata a bench repo can merge into its RunManifest.""" + + harness: str + harness_version: str + provider: str + platform: str + arms: tuple[BenchArm, ...] + proxy_config: dict[str, Any] + headroom_config: dict[str, Any] + env: dict[str, str] + deployment_plan: dict[str, Any] + contract_audit: dict[str, Any] + + def to_dict(self) -> dict[str, Any]: + return { + "harness": self.harness, + "harness_version": self.harness_version, + "provider": self.provider, + "platform": self.platform, + "arms": [arm.to_dict() for arm in self.arms], + "proxy_config": dict(self.proxy_config), + "headroom_config": dict(self.headroom_config), + "env": dict(self.env), + "deployment_plan": dict(self.deployment_plan), + "contract_audit": dict(self.contract_audit), + } + + +@dataclass(frozen=True) +class AgentEvalsPricing: + """Pricing block matching headroom-bench's ``Pricing`` model.""" + + input_usd_per_1m: float = 3.0 + output_usd_per_1m: float = 15.0 + + def to_dict(self) -> dict[str, float]: + return { + "input_usd_per_1m": self.input_usd_per_1m, + "output_usd_per_1m": self.output_usd_per_1m, + } + + +@dataclass(frozen=True) +class AgentEvalsManifest: + """Bench-native manifest shape compatible with ``agent_evals.models.RunManifest``.""" + + experiment_id: str + created_at: datetime + headroom_git_sha: str + agent_evals_git_sha: str + model_snapshot: str + provider: Literal["anthropic", "openai"] + auth_mode: str + benchmark: str + benchmark_ref: str + harness: str + harness_version: str + docker_digests: dict[str, str] + arms: tuple[BenchArm, ...] + k_runs: int + temperature: float + seeds: tuple[int, ...] + alpha: float + margins: dict[str, float] + pricing: AgentEvalsPricing + + def to_dict(self) -> dict[str, Any]: + return { + "experiment_id": self.experiment_id, + "created_at": self.created_at.isoformat(), + "headroom_git_sha": self.headroom_git_sha, + "agent_evals_git_sha": self.agent_evals_git_sha, + "model_snapshot": self.model_snapshot, + "provider": self.provider, + "auth_mode": self.auth_mode, + "benchmark": self.benchmark, + "benchmark_ref": self.benchmark_ref, + "harness": self.harness, + "harness_version": self.harness_version, + "docker_digests": dict(self.docker_digests), + "arms": [arm.to_dict() for arm in self.arms], + "k_runs": self.k_runs, + "temperature": self.temperature, + "seeds": list(self.seeds), + "alpha": self.alpha, + "margins": dict(self.margins), + "pricing": self.pricing.to_dict(), + } + + +@dataclass(frozen=True) +class SuiteManifestBundle: + """JSON-ready bundle for a group of Headroom scenarios.""" + + name: str + harness: str + harness_version: str + scenarios: tuple[dict[str, Any], ...] + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "harness": self.harness, + "harness_version": self.harness_version, + "scenarios": [dict(scenario) for scenario in self.scenarios], + } + + +@dataclass(frozen=True) +class ScenarioResult: + """Dry-run result produced without upstream API keys.""" + + tokens_before: int + tokens_after: int + tokens_saved: int + transforms: tuple[str, ...] + messages: list[dict[str, Any]] + + +@dataclass(frozen=True) +class ScenarioTask: + """One no-key local simulation task for a Headroom scenario.""" + + task_id: str + messages: list[dict[str, Any]] + model: str = "gpt-4o" + provider: Literal["openai", "anthropic"] = "openai" + output_buffer_tokens: int | None = None + metadata: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class GuaranteeResult: + """Verdict for one scenario guarantee.""" + + name: str + passed: bool + detail: str + + def to_dict(self) -> dict[str, Any]: + return {"name": self.name, "passed": self.passed, "detail": self.detail} + + +@dataclass(frozen=True) +class ScenarioCaseResult: + """Result for one scenario x task cell.""" + + scenario: str + task_id: str + result: ScenarioResult + guarantees: tuple[GuaranteeResult, ...] + + @property + def passed(self) -> bool: + return all(guarantee.passed for guarantee in self.guarantees) + + def to_dict(self) -> dict[str, Any]: + return { + "scenario": self.scenario, + "task_id": self.task_id, + "passed": self.passed, + "result": { + "tokens_before": self.result.tokens_before, + "tokens_after": self.result.tokens_after, + "tokens_saved": self.result.tokens_saved, + "transforms": list(self.result.transforms), + "messages": self.result.messages, + }, + "guarantees": [guarantee.to_dict() for guarantee in self.guarantees], + } + + +@dataclass(frozen=True) +class ScenarioRunReport: + """Deterministic report for an orchestrated no-key scenario run.""" + + cases: tuple[ScenarioCaseResult, ...] + + @property + def passed(self) -> bool: + return all(case.passed for case in self.cases) + + @property + def total_tokens_before(self) -> int: + return sum(case.result.tokens_before for case in self.cases) + + @property + def total_tokens_after(self) -> int: + return sum(case.result.tokens_after for case in self.cases) + + @property + def total_tokens_saved(self) -> int: + return self.total_tokens_before - self.total_tokens_after + + def to_dict(self) -> dict[str, Any]: + return { + "passed": self.passed, + "total_cases": len(self.cases), + "total_tokens_before": self.total_tokens_before, + "total_tokens_after": self.total_tokens_after, + "total_tokens_saved": self.total_tokens_saved, + "cases": [case.to_dict() for case in self.cases], + } + + +@dataclass(frozen=True) +class DeploymentHandle: + """Live local proxy metadata.""" + + base_url: str + env: dict[str, str] + command: tuple[str, ...] + process_id: int + + +@dataclass(frozen=True) +class ProxyDeploymentPlan: + """Concrete proxy deployment inputs for local runners and headroom-bench.""" + + command: tuple[str, ...] + env: dict[str, str] + config_payload: dict[str, Any] + config_env_var: str = "HEADROOM_PROXY_CONFIG_JSON" + + def to_dict(self) -> dict[str, Any]: + return { + "command": list(self.command), + "env": dict(self.env), + "config_payload": dict(self.config_payload), + "config_env_var": self.config_env_var, + } + + def validate(self) -> None: + """Fail if the environment does not round-trip the full config payload.""" + + raw = self.env.get(self.config_env_var) + if raw is None: + raise ValueError(f"deployment env missing {self.config_env_var}") + parsed = json.loads(raw) + if parsed != self.config_payload: + raise ValueError(f"{self.config_env_var} does not match config_payload") + + +def _field_contract( + owner: Literal["headroom", "proxy"], cls: type[Any] +) -> tuple[FieldContract, ...]: + if not is_dataclass(cls): + raise TypeError(f"{cls!r} must be a dataclass") + out: list[FieldContract] = [] + for field in cls.__dataclass_fields__.values(): + default: Any = MISSING + if field.default is not MISSING: + default = field.default + elif field.default_factory is not MISSING: # type: ignore[attr-defined] + default = "" + out.append( + FieldContract( + owner=owner, + name=field.name, + type_repr=str(field.type), + has_default=default is not MISSING, + default_repr=None if default is MISSING else repr(default), + ) + ) + return tuple(out) + + +def _public_dataclass_dict(value: Any) -> dict[str, Any]: + raw = asdict(value) + out = {key: _portable_value(val) for key, val in raw.items() if not key.startswith("_")} + for name, field in value.__dataclass_fields__.items(): + if name in out or name.startswith("_"): + continue + default = None if field.default is MISSING else field.default + out[name] = _portable_value(default) + return out + + +def _portable_value(value: Any) -> Any: + if value is None or isinstance(value, str | int | float | bool): + return value + if isinstance(value, Enum): + return value.value + if isinstance(value, Path): + return str(value) + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return _portable_value(asdict(value)) + if isinstance(value, Mapping): + return {str(key): _portable_value(val) for key, val in value.items()} + if isinstance(value, set | frozenset): + return sorted(_portable_value(item) for item in value) + if isinstance(value, tuple | list): + return [_portable_value(item) for item in value] + return repr(value) + + +def _git_sha(repo_path: str | Path) -> str: + try: + proc = subprocess_run( + ["git", "-C", str(repo_path), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return "unknown" + return proc.stdout.strip() or "unknown" + + +def _coerce_headroom_mode(value: str | HeadroomMode) -> HeadroomMode: + if isinstance(value, HeadroomMode): + return value + return HeadroomMode(value) + + +def guarantee_tokens_do_not_increase( + scenario: HarnessScenario, + task: ScenarioTask, + result: ScenarioResult, +) -> GuaranteeResult: + """Guarantee that local optimization never grows the input token count.""" + + passed = result.tokens_after <= result.tokens_before + return GuaranteeResult( + name="tokens_do_not_increase", + passed=passed, + detail=( + f"{scenario.name}/{task.task_id}: " + f"{result.tokens_before} -> {result.tokens_after} tokens" + ), + ) + + +def guarantee_messages_remain_non_empty( + scenario: HarnessScenario, + task: ScenarioTask, + result: ScenarioResult, +) -> GuaranteeResult: + """Guarantee that optimization keeps a non-empty message sequence.""" + + passed = len(result.messages) > 0 + return GuaranteeResult( + name="messages_remain_non_empty", + passed=passed, + detail=f"{scenario.name}/{task.task_id}: {len(result.messages)} messages", + ) + + +DEFAULT_GUARANTEES: tuple[Guarantee, ...] = ( + guarantee_tokens_do_not_increase, + guarantee_messages_remain_non_empty, +) + + +class Configurator: + """Mutation facade used by ``Headroom.configure`` callbacks. + + Unknown fields fail fast. Known lower-level dataclass fields can be written + via ``proxy.`` / ``headroom.``, while common cross-surface + concepts are exposed as concise properties. + """ + + headroom: HeadroomConfig + proxy: ProxyConfig + + def __init__(self, headroom: HeadroomConfig, proxy: ProxyConfig) -> None: + object.__setattr__(self, "headroom", headroom) + object.__setattr__(self, "proxy", proxy) + + @property + def kompress_enabled(self) -> bool: + return not self.proxy.disable_kompress + + @kompress_enabled.setter + def kompress_enabled(self, value: bool) -> None: + self.proxy.disable_kompress = not bool(value) + + @property + def optimize(self) -> bool: + return cast(bool, self.proxy.optimize) + + @optimize.setter + def optimize(self, value: bool) -> None: + self.proxy.optimize = bool(value) + + @property + def mode(self) -> str: + return cast(str, self.proxy.mode) + + @mode.setter + def mode(self, value: Literal["token", "cache"]) -> None: + if value not in {"token", "cache"}: + raise ValueError("mode must be 'token' or 'cache'") + self.proxy.mode = value + + @property + def default_mode(self) -> HeadroomMode: + return self.headroom.default_mode + + @default_mode.setter + def default_mode(self, value: str | HeadroomMode) -> None: + self.headroom.default_mode = _coerce_headroom_mode(value) + + def __setattr__(self, name: str, value: Any) -> None: + if name in {"headroom", "proxy"}: + raise AttributeError(f"{name} is read-only; mutate its fields instead") + descriptor = getattr(type(self), name, None) + if isinstance(descriptor, property) and descriptor.fset is not None: + descriptor.fset(self, value) + return + if hasattr(self.proxy, name): + setattr(self.proxy, name, value) + return + if hasattr(self.headroom, name): + setattr(self.headroom, name, value) + return + raise AttributeError(f"unknown Headroom harness config field: {name}") + + +class Headroom: + """Fluent scenario builder. + + Example: + ``Headroom.with_bedrock(region="us-east-1").on_apple_silicon().configure(...)`` + """ + + HARNESS_VERSION = "1" + + def __init__( + self, + *, + name: str = "headroom-scenario", + provider: ProviderTarget = ProviderTarget.ANTHROPIC, + platform: PlatformTarget = PlatformTarget.LOCAL, + headroom_config: HeadroomConfig | None = None, + proxy_config: ProxyConfig | None = None, + metadata: Mapping[str, Any] | None = None, + ) -> None: + self._name = name + self._provider = provider + self._platform = platform + self._headroom_config = headroom_config or HeadroomConfig() + self._proxy_config = proxy_config or ProxyConfig() + self._metadata = dict(metadata or {}) + + @classmethod + def scenario(cls, name: str = "headroom-scenario") -> Headroom: + return cls(name=name) + + @classmethod + def suite(cls, name: str = "headroom-suite") -> HeadroomSuite: + return HeadroomSuite(name=name) + + @classmethod + def with_bedrock(cls, **config: Any) -> Headroom: + return cls.scenario().with_provider(ProviderTarget.BEDROCK, **config) + + @classmethod + def with_openai(cls, **config: Any) -> Headroom: + return cls.scenario().with_provider(ProviderTarget.OPENAI, **config) + + @classmethod + def with_anthropic(cls, **config: Any) -> Headroom: + return cls.scenario().with_provider(ProviderTarget.ANTHROPIC, **config) + + @classmethod + def with_vertex(cls, **config: Any) -> Headroom: + return cls.scenario().with_provider(ProviderTarget.VERTEX, **config) + + @classmethod + def with_gemini(cls, **config: Any) -> Headroom: + return cls.scenario().with_provider(ProviderTarget.GEMINI, **config) + + @classmethod + def with_cloudcode(cls, **config: Any) -> Headroom: + return cls.scenario().with_provider(ProviderTarget.CLOUDCODE, **config) + + @classmethod + def with_anyllm(cls, **config: Any) -> Headroom: + return cls.scenario().with_provider(ProviderTarget.ANYLLM, **config) + + @classmethod + def with_litellm(cls, **config: Any) -> Headroom: + return cls.scenario().with_provider(ProviderTarget.LITELLM, **config) + + # PascalCase aliases mirror the shape requested by downstream .NET-style examples. + WithBedrock = with_bedrock + WithOpenAI = with_openai + WithAnthropic = with_anthropic + WithVertex = with_vertex + WithGemini = with_gemini + WithCloudCode = with_cloudcode + WithAnyLLM = with_anyllm + WithLiteLLM = with_litellm + + def named(self, name: str) -> Headroom: + self._name = name + return self + + def with_provider(self, target: ProviderTarget | str, **config: Any) -> Headroom: + target = ProviderTarget(target) + self._provider = target + if target is ProviderTarget.BEDROCK: + self._proxy_config.backend = "bedrock" + if "region" in config: + self._proxy_config.bedrock_region = str(config["region"]) + if "profile" in config: + self._proxy_config.bedrock_profile = str(config["profile"]) + if "api_url" in config: + self._proxy_config.bedrock_api_url = str(config["api_url"]) + elif target is ProviderTarget.VERTEX: + self._proxy_config.backend = "litellm-vertex" + if "api_url" in config: + self._proxy_config.vertex_api_url = str(config["api_url"]) + elif target is ProviderTarget.OPENAI: + self._proxy_config.backend = "anthropic" + if "api_url" in config: + self._proxy_config.openai_api_url = str(config["api_url"]) + elif target is ProviderTarget.ANTHROPIC: + self._proxy_config.backend = "anthropic" + if "api_url" in config: + self._proxy_config.anthropic_api_url = str(config["api_url"]) + elif target is ProviderTarget.GEMINI: + if "api_url" in config: + self._proxy_config.gemini_api_url = str(config["api_url"]) + elif target is ProviderTarget.CLOUDCODE: + if "api_url" in config: + self._proxy_config.cloudcode_api_url = str(config["api_url"]) + elif target is ProviderTarget.ANYLLM: + self._proxy_config.backend = "anyllm" + self._proxy_config.anyllm_provider = str(config.get("provider", "openai")) + elif target is ProviderTarget.LITELLM: + litellm_provider = str(config.get("provider", "openai")) + self._proxy_config.backend = f"litellm-{litellm_provider}" + self._metadata.setdefault("provider_config", {}).update(config) + return self + + WithProvider = with_provider + + def on_apple_silicon(self, **config: Any) -> Headroom: + self._platform = PlatformTarget.APPLE_SILICON + self._metadata.setdefault("platform_config", {}).update(config) + return self + + OnAppleSilicon = on_apple_silicon + + def on_platform(self, platform: PlatformTarget | str, **config: Any) -> Headroom: + self._platform = PlatformTarget(platform) + self._metadata.setdefault("platform_config", {}).update(config) + return self + + OnPlatform = on_platform + + def configure(self, callback: ConfigCallback | None = None, **overrides: Any) -> Headroom: + configurator = Configurator(self._headroom_config, self._proxy_config) + if callback is not None: + callback(configurator) + for key, value in overrides.items(): + setattr(configurator, key, value) + return self + + Configure = configure + + def configure_proxy(self, callback: ProxyCallback | None = None, **overrides: Any) -> Headroom: + if callback is not None: + callback(self._proxy_config) + for key, value in overrides.items(): + if not hasattr(self._proxy_config, key): + raise AttributeError(f"unknown ProxyConfig field: {key}") + setattr(self._proxy_config, key, value) + return self + + ConfigureProxy = configure_proxy + + def configure_headroom(self, callback: SdkCallback | None = None, **overrides: Any) -> Headroom: + if callback is not None: + callback(self._headroom_config) + for key, value in overrides.items(): + if not hasattr(self._headroom_config, key): + raise AttributeError(f"unknown HeadroomConfig field: {key}") + setattr(self._headroom_config, key, value) + return self + + ConfigureHeadroom = configure_headroom + + def with_compression( + self, + *, + mode: Literal["token", "cache"] | None = None, + kompress: bool | None = None, + force_kompress_all: bool | None = None, + lossless: bool | None = None, + compressors: set[str] | Sequence[str] | Literal["*"] | None = None, + min_tokens: int | None = None, + max_items: int | None = None, + savings_profile: str | None = None, + ) -> Headroom: + """Configure proxy and SDK compression posture with real config fields.""" + + if mode is not None: + self._proxy_config.mode = mode + if kompress is not None: + self._proxy_config.disable_kompress = not kompress + if force_kompress_all is not None: + self._proxy_config.force_kompress_all = force_kompress_all + if lossless is not None: + self._proxy_config.lossless = lossless + self._headroom_config.smart_crusher.lossless_only = lossless + if compressors is not None: + if compressors == "*": + self._proxy_config.compressors = {"*"} + else: + self._proxy_config.compressors = set(compressors) + if min_tokens is not None: + self._proxy_config.min_tokens_to_crush = min_tokens + self._headroom_config.smart_crusher.min_tokens_to_crush = min_tokens + if max_items is not None: + self._proxy_config.max_items_after_crush = max_items + self._headroom_config.smart_crusher.max_items_after_crush = max_items + if savings_profile is not None: + self._proxy_config.savings_profile = savings_profile + return self + + WithCompression = with_compression + + def with_ccr( + self, + *, + enabled: bool = True, + inject_tool: bool | None = None, + inject_marker: bool | None = None, + handle_responses: bool | None = None, + proactive_expansion: bool | None = None, + max_retrieval_rounds: int | None = None, + ) -> Headroom: + """Configure Compress-Cache-Retrieve across SDK and proxy surfaces.""" + + self._headroom_config.ccr.enabled = enabled + self._proxy_config.ccr_inject_tool = enabled if inject_tool is None else inject_tool + self._proxy_config.ccr_inject_marker = enabled if inject_marker is None else inject_marker + if inject_tool is not None: + self._headroom_config.ccr.inject_tool = inject_tool + if inject_marker is not None: + self._headroom_config.ccr.inject_retrieval_marker = inject_marker + if handle_responses is not None: + self._proxy_config.ccr_handle_responses = handle_responses + if proactive_expansion is not None: + self._proxy_config.ccr_proactive_expansion = proactive_expansion + if max_retrieval_rounds is not None: + self._proxy_config.ccr_max_retrieval_rounds = max_retrieval_rounds + return self + + WithCCR = with_ccr + + def with_cache( + self, + *, + enabled: bool = True, + semantic: bool | None = None, + ttl_seconds: int | None = None, + max_entries: int | None = None, + ) -> Headroom: + """Configure proxy cache and SDK cache optimizer knobs.""" + + self._proxy_config.cache_enabled = enabled + self._headroom_config.cache_optimizer.enabled = enabled + if semantic is not None: + self._headroom_config.cache_optimizer.enable_semantic_cache = semantic + if ttl_seconds is not None: + self._proxy_config.cache_ttl_seconds = ttl_seconds + self._headroom_config.cache_optimizer.semantic_cache_ttl_seconds = ttl_seconds + if max_entries is not None: + self._proxy_config.cache_max_entries = max_entries + self._headroom_config.cache_optimizer.semantic_cache_max_entries = max_entries + return self + + WithCache = with_cache + + def with_prefix_freeze( + self, + *, + enabled: bool = True, + session_ttl_seconds: int | None = None, + ) -> Headroom: + """Configure cache-aware prefix freezing for proxy and SDK paths.""" + + self._proxy_config.prefix_freeze_enabled = enabled + self._headroom_config.prefix_freeze.enabled = enabled + if session_ttl_seconds is not None: + self._proxy_config.prefix_freeze_session_ttl = session_ttl_seconds + self._headroom_config.prefix_freeze.session_ttl_seconds = session_ttl_seconds + return self + + WithPrefixFreeze = with_prefix_freeze + + def with_read_maturation( + self, + *, + enabled: bool = True, + quiesce_turns: int | None = None, + max_hold_turns: int | None = None, + min_size_bytes: int | None = None, + ) -> Headroom: + """Configure activity-based Read maturation on the proxy surface.""" + + self._proxy_config.read_maturation = enabled + read_maturation_metadata = self._metadata.setdefault("read_maturation", {}) + read_maturation_metadata["enabled"] = enabled + if quiesce_turns is not None: + self._proxy_config.read_maturation_quiesce_turns = quiesce_turns + read_maturation_metadata["quiesce_turns"] = quiesce_turns + if max_hold_turns is not None: + self._proxy_config.read_maturation_max_hold_turns = max_hold_turns + read_maturation_metadata["max_hold_turns"] = max_hold_turns + if min_size_bytes is not None: + self._proxy_config.read_maturation_min_size_bytes = min_size_bytes + read_maturation_metadata["min_size_bytes"] = min_size_bytes + return self + + WithReadMaturation = with_read_maturation + + def with_memory( + self, + *, + enabled: bool = True, + backend: Literal["local", "qdrant-neo4j"] | None = None, + mode: Literal["auto_tail", "tool"] | None = None, + top_k: int | None = None, + min_similarity: float | None = None, + inject_tools: bool | None = None, + inject_context: bool | None = None, + storage_mode: Literal["project", "user", "global"] | None = None, + ) -> Headroom: + """Configure the proxy memory subsystem for agent scenarios.""" + + self._proxy_config.memory_enabled = enabled + if backend is not None: + self._proxy_config.memory_backend = backend + if mode is not None: + self._proxy_config.memory_mode = mode + if top_k is not None: + self._proxy_config.memory_top_k = top_k + if min_similarity is not None: + self._proxy_config.memory_min_similarity = min_similarity + if inject_tools is not None: + self._proxy_config.memory_inject_tools = inject_tools + if inject_context is not None: + self._proxy_config.memory_inject_context = inject_context + if storage_mode is not None: + self._proxy_config.memory_storage_mode = storage_mode + return self + + WithMemory = with_memory + + def build(self) -> HarnessScenario: + return HarnessScenario( + name=self._name, + provider=self._provider, + platform=self._platform, + headroom_config=replace(self._headroom_config), + proxy_config=replace(self._proxy_config), + metadata=dict(self._metadata), + ) + + Build = build + Suite = suite + + +class HeadroomSuite: + """Declarative matrix of built scenarios for bench and local no-key runs.""" + + def __init__(self, *, name: str = "headroom-suite") -> None: + self.name = name + self._scenarios: list[HarnessScenario] = [] + + @property + def scenarios(self) -> tuple[HarnessScenario, ...]: + return tuple(self._scenarios) + + def add(self, scenario: HarnessScenario | Headroom) -> HeadroomSuite: + built = scenario.Build() if isinstance(scenario, Headroom) else scenario + if any(existing.name == built.name for existing in self._scenarios): + raise ValueError(f"duplicate scenario name in suite: {built.name}") + self._scenarios.append(built) + return self + + Add = add + + def extend(self, scenarios: Sequence[HarnessScenario | Headroom]) -> HeadroomSuite: + for scenario in scenarios: + self.add(scenario) + return self + + Extend = extend + + def orchestrate( + self, + tasks: Sequence[ScenarioTask], + *, + guarantees: Sequence[Guarantee] = DEFAULT_GUARANTEES, + ) -> ScenarioRunReport: + return ScenarioOrchestrator(self._require_scenarios(), guarantees=guarantees).run(tasks) + + Orchestrate = orchestrate + + def deployment_plans( + self, + *, + port_start: int = 8787, + headroom_cmd: Sequence[str] = ("headroom", "proxy"), + skip_upstream_check: bool = True, + ) -> dict[str, ProxyDeploymentPlan]: + plans: dict[str, ProxyDeploymentPlan] = {} + for offset, scenario in enumerate(self._require_scenarios()): + plans[scenario.name] = scenario.deployment_plan( + port=port_start + offset, + headroom_cmd=headroom_cmd, + skip_upstream_check=skip_upstream_check, + ) + return plans + + DeploymentPlans = deployment_plans + + def manifest_bundle( + self, + *, + provider: Literal["anthropic", "openai"] = "anthropic", + port_start: int = 8787, + ) -> SuiteManifestBundle: + scenarios: list[dict[str, Any]] = [] + for offset, scenario in enumerate(self._require_scenarios()): + fragment = scenario.bench_manifest_fragment(provider=provider).to_dict() + fragment["suite_port"] = port_start + offset + fragment["deployment_plan"] = scenario.deployment_plan( + port=port_start + offset + ).to_dict() + scenarios.append(fragment) + return SuiteManifestBundle( + name=self.name, + harness="headroom.testing", + harness_version=Headroom.HARNESS_VERSION, + scenarios=tuple(scenarios), + ) + + ManifestBundle = manifest_bundle + + def write_manifest_bundle( + self, + path: str | Path, + *, + provider: Literal["anthropic", "openai"] = "anthropic", + port_start: int = 8787, + ) -> Path: + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + payload = self.manifest_bundle(provider=provider, port_start=port_start).to_dict() + target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return target + + WriteManifestBundle = write_manifest_bundle + + def agent_evals_manifests( + self, + *, + benchmark: str, + benchmark_ref: str, + provider: Literal["anthropic", "openai"] = "anthropic", + now: datetime | None = None, + model_snapshot: str = "claude-sonnet-4-6", + headroom_repo_path: str | Path = ".", + agent_evals_repo_path: str | Path = ".", + auth_mode: str = "payg", + temperature: float = 0.0, + k_runs: int = 10, + seeds: Sequence[int] | None = None, + alpha: float = 0.05, + margins: Mapping[str, float] | None = None, + pricing: AgentEvalsPricing | None = None, + docker_digests: Mapping[str, str] | None = None, + ) -> dict[str, AgentEvalsManifest]: + stamp = now or datetime.now(timezone.utc) + return { + scenario.name: scenario.agent_evals_manifest( + benchmark=benchmark, + benchmark_ref=benchmark_ref, + provider=provider, + now=stamp, + model_snapshot=model_snapshot, + headroom_repo_path=headroom_repo_path, + agent_evals_repo_path=agent_evals_repo_path, + auth_mode=auth_mode, + temperature=temperature, + k_runs=k_runs, + seeds=seeds, + alpha=alpha, + margins=margins, + pricing=pricing, + docker_digests=docker_digests, + ) + for scenario in self._require_scenarios() + } + + AgentEvalsManifests = agent_evals_manifests + + def write_agent_evals_manifests( + self, + directory: str | Path, + *, + benchmark: str, + benchmark_ref: str, + provider: Literal["anthropic", "openai"] = "anthropic", + now: datetime | None = None, + ) -> tuple[Path, ...]: + target_dir = Path(directory) + target_dir.mkdir(parents=True, exist_ok=True) + manifests = self.agent_evals_manifests( + benchmark=benchmark, + benchmark_ref=benchmark_ref, + provider=provider, + now=now, + ) + written: list[Path] = [] + for scenario_name, manifest in manifests.items(): + target = target_dir / f"{scenario_name}.agent-evals.json" + target.write_text( + json.dumps(manifest.to_dict(), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + written.append(target) + return tuple(written) + + WriteAgentEvalsManifests = write_agent_evals_manifests + + def _require_scenarios(self) -> tuple[HarnessScenario, ...]: + if not self._scenarios: + raise ValueError("HeadroomSuite requires at least one scenario") + return tuple(self._scenarios) + + +@dataclass(frozen=True) +class HarnessScenario: + """A fully-built, immutable Headroom test scenario.""" + + name: str + provider: ProviderTarget + platform: PlatformTarget + headroom_config: HeadroomConfig + proxy_config: ProxyConfig + metadata: dict[str, Any] + + @property + def contract(self) -> ScenarioContract: + return ScenarioContract( + headroom_fields=_field_contract("headroom", HeadroomConfig), + proxy_fields=_field_contract("proxy", ProxyConfig), + ) + + def env(self, *, base_url: str | None = None) -> dict[str, str]: + env: dict[str, str] = { + "HEADROOM_MODE": self.proxy_config.mode, + "HEADROOM_BACKEND": self.proxy_config.backend, + "HEADROOM_DISABLE_KOMPRESS": "1" if self.proxy_config.disable_kompress else "0", + "HEADROOM_SAVINGS_PROFILE": self.proxy_config.savings_profile or "", + } + if self.proxy_config.bedrock_region: + env["HEADROOM_BEDROCK_REGION"] = self.proxy_config.bedrock_region + if self.proxy_config.bedrock_profile: + env["AWS_PROFILE"] = self.proxy_config.bedrock_profile + if base_url: + if self.provider is ProviderTarget.OPENAI: + openai_url = base_url if base_url.rstrip("/").endswith("/v1") else f"{base_url}/v1" + env["OPENAI_BASE_URL"] = openai_url + env["OPENAI_API_BASE"] = openai_url + elif self.provider is ProviderTarget.ANTHROPIC: + env["ANTHROPIC_BASE_URL"] = base_url + return {key: value for key, value in env.items() if value != ""} + + def proxy_command( + self, + *, + port: int | None = None, + headroom_cmd: Sequence[str] = ("headroom", "proxy"), + extra_flags: Sequence[str] = (), + ) -> tuple[str, ...]: + cmd = list(headroom_cmd) + if port is not None: + cmd += ["--port", str(port)] + if not self.proxy_config.optimize: + cmd.append("--no-optimize") + else: + cmd += ["--mode", self.proxy_config.mode] + if self.proxy_config.backend != "anthropic": + cmd += ["--backend", self.proxy_config.backend] + if self.proxy_config.disable_kompress: + cmd.append("--disable-kompress") + if self.proxy_config.lossless: + cmd.append("--lossless") + if self.proxy_config.bedrock_region: + cmd += ["--bedrock-region", self.proxy_config.bedrock_region] + if self.proxy_config.bedrock_profile: + cmd += ["--bedrock-profile", self.proxy_config.bedrock_profile] + cmd += list(extra_flags) + return tuple(cmd) + + def bench_arms( + self, *, provider: Literal["anthropic", "openai"] = "anthropic" + ) -> tuple[BenchArm, ...]: + headroom_proxy_mode: Literal["off", "token", "cache"] + if self.proxy_config.optimize: + raw_mode = cast(str, self.proxy_config.mode) + if raw_mode not in {"token", "cache"}: + raise ValueError(f"unsupported headroom-bench proxy mode: {raw_mode!r}") + headroom_proxy_mode = cast(Literal["token", "cache"], raw_mode) + else: + headroom_proxy_mode = "off" + return ( + BenchArm( + name=ArmName.A0_DIRECT, + provider=provider, + proxy_mode=None, + proxy_flags=(), + label="Direct provider API", + ), + BenchArm( + name=ArmName.A1_PASSTHROUGH, + provider=provider, + proxy_mode="off", + proxy_flags=(), + label="Headroom proxy passthrough", + ), + BenchArm( + name=ArmName.B_HEADROOM, + provider=provider, + proxy_mode=headroom_proxy_mode, + proxy_flags=self._bench_proxy_flags(), + label="Headroom compression", + ), + ) + + def bench_manifest_fragment( + self, *, provider: Literal["anthropic", "openai"] = "anthropic" + ) -> BenchManifestFragment: + deployment_plan = self.deployment_plan() + contract_audit = self.audit_contract() + return BenchManifestFragment( + harness="headroom.testing", + harness_version=Headroom.HARNESS_VERSION, + provider=self.provider.value, + platform=self.platform.value, + arms=self.bench_arms(provider=provider), + proxy_config=_public_dataclass_dict(self.proxy_config), + headroom_config=_public_dataclass_dict(self.headroom_config), + env=self.env(), + deployment_plan=deployment_plan.to_dict(), + contract_audit=contract_audit.to_dict(), + ) + + def agent_evals_manifest( + self, + *, + benchmark: str, + benchmark_ref: str, + provider: Literal["anthropic", "openai"] = "anthropic", + now: datetime | None = None, + model_snapshot: str = "claude-sonnet-4-6", + headroom_repo_path: str | Path = ".", + agent_evals_repo_path: str | Path = ".", + auth_mode: str = "payg", + temperature: float = 0.0, + k_runs: int = 10, + seeds: Sequence[int] | None = None, + alpha: float = 0.05, + margins: Mapping[str, float] | None = None, + pricing: AgentEvalsPricing | None = None, + docker_digests: Mapping[str, str] | None = None, + ) -> AgentEvalsManifest: + created_at = now or datetime.now(timezone.utc) + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + else: + created_at = created_at.astimezone(timezone.utc) + run_seeds = tuple(seeds) if seeds is not None else tuple(range(k_runs)) + return AgentEvalsManifest( + experiment_id=f"{benchmark}-{self.name}-{created_at:%Y%m%dT%H%M%SZ}", + created_at=created_at, + headroom_git_sha=_git_sha(headroom_repo_path), + agent_evals_git_sha=_git_sha(agent_evals_repo_path), + model_snapshot=model_snapshot, + provider=provider, + auth_mode=auth_mode, + benchmark=benchmark, + benchmark_ref=benchmark_ref, + harness="headroom.testing", + harness_version=Headroom.HARNESS_VERSION, + docker_digests=dict(docker_digests or {}), + arms=self.bench_arms(provider=provider), + k_runs=k_runs, + temperature=temperature, + seeds=run_seeds, + alpha=alpha, + margins=dict(margins or {"ccr": 0.0, "lossy": 2.0}), + pricing=pricing or AgentEvalsPricing(), + ) + + AgentEvalsManifest = agent_evals_manifest + + def write_agent_evals_manifest( + self, + path: str | Path, + *, + benchmark: str, + benchmark_ref: str, + provider: Literal["anthropic", "openai"] = "anthropic", + now: datetime | None = None, + ) -> Path: + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + payload = self.agent_evals_manifest( + benchmark=benchmark, + benchmark_ref=benchmark_ref, + provider=provider, + now=now, + ).to_dict() + target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return target + + WriteAgentEvalsManifest = write_agent_evals_manifest + + def audit_contract(self) -> ContractAudit: + """Audit scenario payloads against the current dataclass contract.""" + + contract = self.contract + headroom_contract = contract.field_names("headroom") + proxy_contract = contract.field_names("proxy") + headroom_payload = set(_public_dataclass_dict(self.headroom_config)) + proxy_payload = set(_public_dataclass_dict(self.proxy_config)) + notes: list[str] = [] + if self.metadata.get("read_maturation"): + notes.append("read_maturation is currently a proxy-only surface") + return ContractAudit( + scenario=self.name, + provider=self.provider.value, + platform=self.platform.value, + headroom_fields_total=len(headroom_contract), + proxy_fields_total=len(proxy_contract), + headroom_payload_fields=tuple(sorted(headroom_payload)), + proxy_payload_fields=tuple(sorted(proxy_payload)), + missing_headroom_payload_fields=tuple(sorted(headroom_contract - headroom_payload)), + missing_proxy_payload_fields=tuple(sorted(proxy_contract - proxy_payload)), + extra_headroom_payload_fields=tuple(sorted(headroom_payload - headroom_contract)), + extra_proxy_payload_fields=tuple(sorted(proxy_payload - proxy_contract)), + notes=tuple(notes), + ) + + def write_manifest_fragment( + self, + path: str | Path, + *, + provider: Literal["anthropic", "openai"] = "anthropic", + ) -> Path: + """Write a JSON manifest fragment that headroom-bench can consume.""" + + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + payload = self.bench_manifest_fragment(provider=provider).to_dict() + target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return target + + def deployment_plan( + self, + *, + port: int | None = None, + headroom_cmd: Sequence[str] = ("headroom", "proxy"), + extra_flags: Sequence[str] = (), + skip_upstream_check: bool = True, + ) -> ProxyDeploymentPlan: + """Build complete command/env/config inputs for a proxy deployment.""" + + command = self.proxy_command(port=port, headroom_cmd=headroom_cmd, extra_flags=extra_flags) + config_payload = _public_dataclass_dict(self.proxy_config) + env = self.env(base_url=f"http://127.0.0.1:{port}" if port is not None else None) + env["HEADROOM_PROXY_CONFIG_JSON"] = json.dumps( + config_payload, + sort_keys=True, + separators=(",", ":"), + ) + if skip_upstream_check: + env["HEADROOM_SKIP_UPSTREAM_CHECK"] = "1" + plan = ProxyDeploymentPlan(command=command, env=env, config_payload=config_payload) + plan.validate() + return plan + + def simulate( + self, + messages: list[dict[str, Any]], + *, + model: str = "gpt-4o", + provider: Literal["openai", "anthropic"] = "openai", + output_buffer_tokens: int | None = None, + ) -> ScenarioResult: + """Run the real Headroom SDK transform pipeline without calling an upstream API.""" + + from headroom.client import HeadroomClient + from headroom.providers.anthropic import AnthropicProvider + from headroom.providers.openai import OpenAIProvider + + provider_obj = ( + AnthropicProvider(warn=False) if provider == "anthropic" else OpenAIProvider() + ) + config = replace(self.headroom_config) + temp_metrics_path = ( + Path(tempfile.gettempdir()) / f"headroom-testing-{uuid.uuid4().hex}.jsonl" + ) + store_url = f"jsonl://{temp_metrics_path}" + config.store_url = store_url + client = HeadroomClient( + original_client=_NoopClient(), + provider=provider_obj, + store_url=store_url, + config=config, + default_mode=self.headroom_config.default_mode.value, + enable_cache_optimizer=False, + ) + result = client.chat.completions.simulate( + model=model, + messages=messages, + headroom_mode=HeadroomMode.OPTIMIZE.value, + headroom_output_buffer_tokens=output_buffer_tokens, + ) + return ScenarioResult( + tokens_before=result.tokens_before, + tokens_after=result.tokens_after, + tokens_saved=result.tokens_saved, + transforms=tuple(result.transforms), + messages=result.messages_optimized, + ) + + def orchestrate( + self, + tasks: Sequence[ScenarioTask], + *, + guarantees: Sequence[Guarantee] = DEFAULT_GUARANTEES, + ) -> ScenarioRunReport: + """Run no-key simulations for this scenario and evaluate guarantees.""" + + return ScenarioOrchestrator([self], guarantees=guarantees).run(tasks) + + def deploy_local( + self, + *, + port: int = 8787, + headroom_cmd: Sequence[str] = ("headroom", "proxy"), + ready_path: str = "/readyz", + timeout_s: float = 30.0, + log_path: str | Path | None = None, + ) -> LocalProxyDeployment: + return LocalProxyDeployment( + self, + port=port, + headroom_cmd=headroom_cmd, + ready_path=ready_path, + timeout_s=timeout_s, + log_path=log_path, + ) + + def _bench_proxy_flags(self) -> tuple[str, ...]: + flags: list[str] = [] + if self.proxy_config.disable_kompress: + flags.append("--disable-kompress") + if self.proxy_config.lossless: + flags.append("--lossless") + return tuple(flags) + + +class LocalProxyDeployment: + """Context manager that launches a configured local Headroom proxy.""" + + def __init__( + self, + scenario: HarnessScenario, + *, + port: int, + headroom_cmd: Sequence[str], + ready_path: str, + timeout_s: float, + log_path: str | Path | None, + ) -> None: + self.scenario = scenario + self.port = port + self.headroom_cmd = tuple(headroom_cmd) + self.ready_path = ready_path + self.timeout_s = timeout_s + self.log_path = Path(log_path) if log_path is not None else None + self._process: subprocess.Popen[bytes] | None = None + self._log_file: Any = None + + def __enter__(self) -> DeploymentHandle: + plan = self.scenario.deployment_plan(port=self.port, headroom_cmd=self.headroom_cmd) + command = plan.command + env = os.environ.copy() + env.update(plan.env) + stdout: Any = subprocess.DEVNULL + if self.log_path is not None: + self.log_path.parent.mkdir(parents=True, exist_ok=True) + self._log_file = self.log_path.open("ab") + stdout = self._log_file + self._process = subprocess.Popen(command, stdout=stdout, stderr=stdout, env=env) + try: + self._wait_ready() + except Exception: + self.__exit__(None, None, None) + raise + return DeploymentHandle( + base_url=f"http://127.0.0.1:{self.port}", + env=plan.env, + command=command, + process_id=self._process.pid, + ) + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + process = self._process + self._process = None + if process is not None and process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + if self._log_file is not None: + self._log_file.close() + self._log_file = None + + def _wait_ready(self) -> None: + assert self._process is not None + url = f"http://127.0.0.1:{self.port}{self.ready_path}" + deadline = time.monotonic() + self.timeout_s + last_error: str | None = None + while time.monotonic() < deadline: + if self._process.poll() is not None: + raise RuntimeError(f"headroom proxy exited with code {self._process.returncode}") + try: + with urllib.request.urlopen(url, timeout=0.5) as response: + if response.status == 200: + return + except (OSError, urllib.error.URLError) as exc: + last_error = str(exc) + time.sleep(0.25) + raise TimeoutError(f"headroom proxy was not ready at {url}: {last_error}") + + +class _NoopClient: + """Minimal original-client object for SDK simulations without upstream I/O.""" + + +class ScenarioOrchestrator: + """Runs built scenarios against local no-key tasks and evaluates guarantees.""" + + def __init__( + self, + scenarios: Sequence[HarnessScenario], + *, + guarantees: Sequence[Guarantee] = DEFAULT_GUARANTEES, + ) -> None: + if not scenarios: + raise ValueError("ScenarioOrchestrator requires at least one scenario") + self._scenarios = tuple(scenarios) + self._guarantees = tuple(guarantees) + + def run(self, tasks: Sequence[ScenarioTask]) -> ScenarioRunReport: + if not tasks: + raise ValueError("ScenarioOrchestrator.run requires at least one task") + + cases: list[ScenarioCaseResult] = [] + for scenario in self._scenarios: + for task in tasks: + result = scenario.simulate( + task.messages, + model=task.model, + provider=task.provider, + output_buffer_tokens=task.output_buffer_tokens, + ) + guarantees = tuple( + guarantee(scenario, task, result) for guarantee in self._guarantees + ) + cases.append( + ScenarioCaseResult( + scenario=scenario.name, + task_id=task.task_id, + result=result, + guarantees=guarantees, + ) + ) + return ScenarioRunReport(cases=tuple(cases)) diff --git a/tests/test_testing_harness.py b/tests/test_testing_harness.py new file mode 100644 index 000000000..89d6c436c --- /dev/null +++ b/tests/test_testing_harness.py @@ -0,0 +1,459 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +from headroom.config import HeadroomConfig +from headroom.proxy.models import ProxyConfig +from headroom.testing import ( + AgentEvalsPricing, + ArmName, + Configurator, + GuaranteeResult, + Headroom, + ProviderTarget, + ScenarioOrchestrator, + ScenarioTask, +) + +AGENT_EVALS_RUN_MANIFEST_FIELDS = { + "experiment_id", + "created_at", + "headroom_git_sha", + "agent_evals_git_sha", + "model_snapshot", + "provider", + "auth_mode", + "benchmark", + "benchmark_ref", + "harness", + "harness_version", + "docker_digests", + "arms", + "k_runs", + "temperature", + "seeds", + "alpha", + "margins", + "pricing", +} + + +def _configure_bedrock_apple(c: Configurator) -> None: + c.kompress_enabled = False + c.mode = "cache" + c.default_mode = "optimize" + + +def test_contract_covers_current_headroom_and_proxy_config_fields() -> None: + scenario = Headroom.scenario("contract").build() + + assert scenario.contract.field_names("headroom") == set(HeadroomConfig.__dataclass_fields__) + assert scenario.contract.field_names("proxy") == set(ProxyConfig.__dataclass_fields__) + + +def test_fluent_builder_configures_real_proxy_and_sdk_configs() -> None: + scenario = ( + Headroom.WithBedrock(region="us-east-1", profile="bench") + .named("bedrock-apple") + .OnAppleSilicon() + .configure(_configure_bedrock_apple) + .configure_proxy(savings_profile="coding", min_tokens_to_crush=10) + .build() + ) + + assert scenario.provider is ProviderTarget.BEDROCK + assert scenario.proxy_config.backend == "bedrock" + assert scenario.proxy_config.bedrock_region == "us-east-1" + assert scenario.proxy_config.bedrock_profile == "bench" + assert scenario.proxy_config.disable_kompress is True + assert scenario.proxy_config.mode == "cache" + assert scenario.headroom_config.default_mode.value == "optimize" + + command = scenario.proxy_command(port=18800) + assert command[:4] == ("headroom", "proxy", "--port", "18800") + assert "--backend" in command + assert "bedrock" in command + assert "--disable-kompress" in command + assert "--savings-profile" not in command + + env = scenario.env() + assert env["HEADROOM_BACKEND"] == "bedrock" + assert env["HEADROOM_DISABLE_KOMPRESS"] == "1" + assert env["HEADROOM_BEDROCK_REGION"] == "us-east-1" + assert env["AWS_PROFILE"] == "bench" + + +def test_unknown_config_field_fails_fast() -> None: + with pytest.raises(AttributeError, match="unknown Headroom harness config field"): + Headroom.scenario().configure(not_a_real_knob=True) + + +def test_bench_manifest_fragment_matches_headroom_bench_arm_shape() -> None: + scenario = ( + Headroom.with_openai() + .configure(mode="cache", kompress_enabled=False) + .configure_proxy(savings_profile="coding") + .build() + ) + + fragment = scenario.bench_manifest_fragment(provider="openai").to_dict() + + assert fragment["harness"] == "headroom.testing" + assert [arm["name"] for arm in fragment["arms"]] == [ + ArmName.A0_DIRECT.value, + ArmName.A1_PASSTHROUGH.value, + ArmName.B_HEADROOM.value, + ] + assert fragment["arms"][0]["proxy_mode"] is None + assert fragment["arms"][1]["proxy_mode"] == "off" + assert fragment["arms"][2]["proxy_mode"] == "cache" + assert fragment["arms"][2]["proxy_flags"] == ["--disable-kompress"] + assert fragment["env"]["HEADROOM_MODE"] == "cache" + assert fragment["env"]["HEADROOM_SAVINGS_PROFILE"] == "coding" + assert fragment["deployment_plan"]["config_env_var"] == "HEADROOM_PROXY_CONFIG_JSON" + assert fragment["contract_audit"]["passed"] is True + json.dumps(fragment) + + +def test_agent_evals_manifest_matches_run_manifest_contract() -> None: + now = datetime(2026, 6, 15, 9, 30, tzinfo=timezone.utc) + scenario = ( + Headroom.with_openai() + .named("openai-cache") + .WithCompression(mode="cache", kompress=False) + .Build() + ) + + manifest = scenario.agent_evals_manifest( + benchmark="mini_swebench", + benchmark_ref="mini@abc123", + provider="openai", + now=now, + model_snapshot="openai/gpt-4o", + headroom_repo_path="/nonexistent-headroom", + agent_evals_repo_path="/nonexistent-agent-evals", + k_runs=3, + pricing=AgentEvalsPricing(input_usd_per_1m=2.5, output_usd_per_1m=10.0), + ) + payload = manifest.to_dict() + + assert set(payload) == AGENT_EVALS_RUN_MANIFEST_FIELDS + assert payload["experiment_id"] == "mini_swebench-openai-cache-20260615T093000Z" + assert payload["created_at"] == "2026-06-15T09:30:00+00:00" + assert payload["headroom_git_sha"] == "unknown" + assert payload["agent_evals_git_sha"] == "unknown" + assert payload["provider"] == "openai" + assert payload["benchmark"] == "mini_swebench" + assert payload["benchmark_ref"] == "mini@abc123" + assert payload["harness"] == "headroom.testing" + assert payload["model_snapshot"] == "openai/gpt-4o" + assert payload["seeds"] == [0, 1, 2] + assert payload["margins"] == {"ccr": 0.0, "lossy": 2.0} + assert payload["pricing"] == {"input_usd_per_1m": 2.5, "output_usd_per_1m": 10.0} + assert [arm["name"] for arm in payload["arms"]] == [ + "a0_direct", + "a1_passthrough", + "b_headroom", + ] + assert payload["arms"][2]["proxy_mode"] == "cache" + assert payload["arms"][2]["proxy_flags"] == ["--disable-kompress"] + json.dumps(payload) + + +def test_sdk_simulation_runs_without_provider_api_keys() -> None: + scenario = Headroom.with_openai().configure(default_mode="optimize").build() + messages = [ + {"role": "system", "content": "You are concise."}, + {"role": "user", "content": "Summarize this small payload."}, + ] + + result = scenario.simulate(messages, model="gpt-4o") + + assert result.tokens_before >= result.tokens_after + assert result.tokens_saved >= 0 + assert result.messages + + +def test_deployment_plan_carries_full_proxy_config_payload_through_env() -> None: + scenario = ( + Headroom.WithBedrock(region="us-east-2", profile="bench") + .Configure(mode="cache", kompress_enabled=False) + .ConfigureProxy(memory_enabled=True, memory_top_k=3, offline=True) + .Build() + ) + + plan = scenario.deployment_plan(port=18888) + payload_from_env = json.loads(plan.env["HEADROOM_PROXY_CONFIG_JSON"]) + + assert plan.command[:4] == ("headroom", "proxy", "--port", "18888") + assert payload_from_env == plan.config_payload + assert plan.env["HEADROOM_SKIP_UPSTREAM_CHECK"] == "1" + assert plan.config_payload["backend"] == "bedrock" + assert plan.config_payload["memory_enabled"] is True + assert plan.config_payload["memory_top_k"] == 3 + assert plan.config_payload["offline"] is True + assert set(plan.config_payload) == set(ProxyConfig.__dataclass_fields__) + + +def test_contract_audit_reports_full_payload_coverage_and_proxy_only_notes() -> None: + scenario = ( + Headroom.WithBedrock(region="us-east-1") + .WithReadMaturation(enabled=True, quiesce_turns=2) + .Build() + ) + + audit = scenario.audit_contract() + + assert audit.passed is True + assert audit.missing_headroom_payload_fields == () + assert audit.missing_proxy_payload_fields == () + assert audit.extra_headroom_payload_fields == () + assert audit.extra_proxy_payload_fields == () + assert audit.headroom_fields_total == len(HeadroomConfig.__dataclass_fields__) + assert audit.proxy_fields_total == len(ProxyConfig.__dataclass_fields__) + assert "read_maturation is currently a proxy-only surface" in audit.notes + json.dumps(audit.to_dict()) + + +def test_write_manifest_fragment_outputs_json_file(tmp_path: Path) -> None: + path = tmp_path / "headroom-manifest-fragment.json" + scenario = ( + Headroom.WithOpenAI(api_url="https://openai.internal") + .WithCompression(mode="cache", kompress=False) + .Build() + ) + + written = scenario.write_manifest_fragment(path, provider="openai") + payload = json.loads(written.read_text(encoding="utf-8")) + + assert written == path + assert payload["harness"] == "headroom.testing" + assert payload["provider"] == "openai" + assert ( + payload["deployment_plan"]["config_payload"]["openai_api_url"] == "https://openai.internal" + ) + assert payload["contract_audit"]["passed"] is True + + +def test_feature_facets_configure_authoritative_scenario_surfaces() -> None: + scenario = ( + Headroom.WithAnthropic(api_url="https://anthropic.internal") + .named("enterprise-feature-matrix") + .WithCompression( + mode="cache", + kompress=False, + lossless=True, + compressors=["smart_crusher", "log", "diff"], + min_tokens=25, + max_items=9, + savings_profile="coding", + ) + .WithCCR( + enabled=True, + inject_tool=False, + inject_marker=True, + handle_responses=True, + proactive_expansion=False, + max_retrieval_rounds=1, + ) + .WithCache(enabled=True, semantic=True, ttl_seconds=120, max_entries=33) + .WithPrefixFreeze(enabled=False, session_ttl_seconds=42) + .WithReadMaturation(enabled=True, quiesce_turns=2, max_hold_turns=8, min_size_bytes=512) + .WithMemory( + enabled=True, + backend="local", + mode="tool", + top_k=4, + min_similarity=0.5, + inject_tools=False, + inject_context=False, + storage_mode="project", + ) + .Build() + ) + + assert scenario.proxy_config.anthropic_api_url == "https://anthropic.internal" + assert scenario.proxy_config.disable_kompress is True + assert scenario.proxy_config.lossless is True + assert scenario.proxy_config.compressors == {"smart_crusher", "log", "diff"} + assert scenario.proxy_config.min_tokens_to_crush == 25 + assert scenario.proxy_config.max_items_after_crush == 9 + assert scenario.headroom_config.smart_crusher.lossless_only is True + assert scenario.headroom_config.smart_crusher.min_tokens_to_crush == 25 + assert scenario.headroom_config.smart_crusher.max_items_after_crush == 9 + assert scenario.proxy_config.ccr_inject_tool is False + assert scenario.proxy_config.ccr_inject_marker is True + assert scenario.proxy_config.ccr_proactive_expansion is False + assert scenario.proxy_config.ccr_max_retrieval_rounds == 1 + assert scenario.headroom_config.ccr.enabled is True + assert scenario.headroom_config.ccr.inject_tool is False + assert scenario.headroom_config.ccr.inject_retrieval_marker is True + assert scenario.proxy_config.cache_ttl_seconds == 120 + assert scenario.proxy_config.cache_max_entries == 33 + assert scenario.headroom_config.cache_optimizer.enable_semantic_cache is True + assert scenario.proxy_config.prefix_freeze_enabled is False + assert scenario.headroom_config.prefix_freeze.enabled is False + assert scenario.proxy_config.read_maturation is True + assert scenario.metadata["read_maturation"]["quiesce_turns"] == 2 + assert scenario.proxy_config.memory_enabled is True + assert scenario.proxy_config.memory_mode == "tool" + assert scenario.proxy_config.memory_top_k == 4 + assert scenario.proxy_config.memory_min_similarity == 0.5 + assert scenario.proxy_config.memory_inject_tools is False + assert scenario.proxy_config.memory_inject_context is False + + payload = scenario.deployment_plan(port=18889).config_payload + assert payload["compressors"] == ["diff", "log", "smart_crusher"] + assert payload["read_maturation"] is True + assert payload["memory_mode"] == "tool" + + +@pytest.mark.parametrize( + ("builder", "expected_provider", "expected_backend"), + [ + ( + lambda: Headroom.WithAnthropic(api_url="https://anthropic.internal"), + "anthropic", + "anthropic", + ), + (lambda: Headroom.WithOpenAI(api_url="https://openai.internal"), "openai", "anthropic"), + (lambda: Headroom.WithGemini(api_url="https://gemini.internal"), "gemini", "anthropic"), + ( + lambda: Headroom.WithCloudCode(api_url="https://cloudcode.internal"), + "cloudcode", + "anthropic", + ), + ( + lambda: Headroom.WithVertex(api_url="https://vertex.internal"), + "vertex", + "litellm-vertex", + ), + (lambda: Headroom.WithBedrock(region="us-east-1"), "bedrock", "bedrock"), + (lambda: Headroom.WithAnyLLM(provider="mistral"), "anyllm", "anyllm"), + (lambda: Headroom.WithLiteLLM(provider="openrouter"), "litellm", "litellm-openrouter"), + ], +) +def test_provider_builders_cover_current_proxy_targets( + builder: object, + expected_provider: str, + expected_backend: str, +) -> None: + scenario = builder().WithCompression(mode="cache").Build() # type: ignore[operator] + plan = scenario.deployment_plan(port=18901) + + assert scenario.provider.value == expected_provider + assert plan.config_payload["backend"] == expected_backend + assert plan.command[:4] == ("headroom", "proxy", "--port", "18901") + assert json.loads(plan.env["HEADROOM_PROXY_CONFIG_JSON"]) == plan.config_payload + + +def test_scenario_orchestrator_runs_multiple_scenarios_and_reports_guarantees() -> None: + passthrough = ( + Headroom.with_openai() + .named("passthrough") + .Configure(optimize=False, default_mode="audit") + .Build() + ) + optimized = ( + Headroom.with_openai() + .named("optimized") + .Configure(mode="cache", default_mode="optimize") + .Build() + ) + task = ScenarioTask( + task_id="tiny-chat", + messages=[ + {"role": "system", "content": "You are concise."}, + {"role": "user", "content": "Summarize this small payload."}, + ], + model="gpt-4o", + ) + + report = ScenarioOrchestrator([passthrough, optimized]).run([task]) + + assert report.passed is True + assert len(report.cases) == 2 + assert report.total_tokens_before >= report.total_tokens_after + payload = report.to_dict() + assert payload["total_cases"] == 2 + assert payload["cases"][0]["guarantees"] + + +def test_orchestrator_surfaces_custom_guarantee_failures() -> None: + scenario = Headroom.with_openai().named("guarded").Build() + task = ScenarioTask( + task_id="expected-failure", + messages=[{"role": "user", "content": "hello"}], + ) + + def always_fail(*_args: object) -> GuaranteeResult: + return GuaranteeResult(name="always_fail", passed=False, detail="demonstration failure") + + report = ScenarioOrchestrator([scenario], guarantees=[always_fail]).run([task]) + + assert report.passed is False + assert report.cases[0].passed is False + assert report.to_dict()["cases"][0]["guarantees"] == [ + {"name": "always_fail", "passed": False, "detail": "demonstration failure"} + ] + + +def test_headroom_suite_orchestrates_matrix_and_assigns_deployment_ports(tmp_path: Path) -> None: + suite = ( + Headroom.Suite("phase-1-matrix") + .Add(Headroom.WithOpenAI().named("openai-cache").WithCompression(mode="cache")) + .Add( + Headroom.WithBedrock(region="us-east-1") + .named("bedrock-token") + .WithCompression(mode="token") + ) + ) + task = ScenarioTask( + task_id="suite-smoke", + messages=[{"role": "user", "content": "hello"}], + ) + + report = suite.Orchestrate([task]) + plans = suite.DeploymentPlans(port_start=19000) + bundle = suite.ManifestBundle(provider="openai", port_start=19000).to_dict() + path = suite.WriteManifestBundle(tmp_path / "suite.json", provider="openai", port_start=19000) + agent_paths = suite.WriteAgentEvalsManifests( + tmp_path / "agent-evals", + benchmark="mini_swebench", + benchmark_ref="mini@abc123", + provider="openai", + now=datetime(2026, 6, 15, 9, 30, tzinfo=timezone.utc), + ) + written = json.loads(path.read_text(encoding="utf-8")) + + assert report.passed is True + assert len(report.cases) == 2 + assert set(plans) == {"openai-cache", "bedrock-token"} + assert plans["openai-cache"].command[:4] == ("headroom", "proxy", "--port", "19000") + assert plans["bedrock-token"].command[:4] == ("headroom", "proxy", "--port", "19001") + assert bundle["name"] == "phase-1-matrix" + assert [scenario["suite_port"] for scenario in bundle["scenarios"]] == [19000, 19001] + assert written == bundle + assert {path.name for path in agent_paths} == { + "openai-cache.agent-evals.json", + "bedrock-token.agent-evals.json", + } + first_agent_payload = json.loads(agent_paths[0].read_text(encoding="utf-8")) + assert set(first_agent_payload) == AGENT_EVALS_RUN_MANIFEST_FIELDS + assert first_agent_payload["provider"] == "openai" + + +def test_headroom_suite_rejects_duplicate_scenario_names() -> None: + suite = Headroom.Suite("duplicates").Add(Headroom.WithOpenAI().named("same")) + + with pytest.raises(ValueError, match="duplicate scenario name"): + suite.Add(Headroom.WithBedrock(region="us-east-1").named("same")) + + +def test_empty_suite_fails_loudly() -> None: + with pytest.raises(ValueError, match="requires at least one scenario"): + Headroom.Suite("empty").DeploymentPlans() From b3f016b866375cfe2ff8518055ab93844e11ec27 Mon Sep 17 00:00:00 2001 From: Robert Schorr <143385203+robert-schorr@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:20:31 +0200 Subject: [PATCH 009/215] fix(mcp): pin mcp dependency to <2.0.0 to prevent server startup crash (#2642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The MCP Python SDK recently released version `2.0.0`, which introduced breaking changes to the high-level server interface (removing `.list_tools()` and `.call_tool()` decorators on `mcp.server.Server`). Because `headroom-ai` specified `mcp>=1.28.1` without an upper bound, installing or upgrading `headroom-ai` pulled in `mcp 2.0.0`. When `headroom mcp serve` was started by an MCP client (such as OpenCode or Claude Code), the server crashed immediately on startup with `AttributeError: 'Server' object has no attribute 'list_tools'`, resulting in the connection closing error (`headroom MCP error -32000: Connection closed`). This PR pins the `mcp` dependency to `<2.0.0` (`mcp>=1.28.1,<2.0.0`) in `pyproject.toml` so compatible 1.x SDK releases (e.g. `1.29.0`) are used. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Pinned `mcp` dependency to `"mcp>=1.28.1,<2.0.0"` under both `proxy` dependencies and the `mcp` extra in `pyproject.toml`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [ ] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ ruff check pyproject.toml headroom/ccr/mcp_server.py All checks passed! $ pytest tests/test_ccr_mcp_server.py tests/test_cli/test_mcp.py tests/test_cli/test_mcp_status.py ============================== 45 passed in 1.03s ============================== ``` ## Real Behavior Proof - Environment: macOS (Darwin arm64), Python 3.14.5, uv - Exact command / steps: Executed uv sync --all-extras and sent stdio JSON-RPC initialize and tools/list requests to .venv/bin/headroom mcp serve. - Observed result: Server initializes cleanly and returns JSON-RPC response {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"experimental":{},"tools":{"listChanged":false}},"serverInfo":{"name":"headroom","version":"1.29.0"}}} with no startup AttributeError or closed pipe errors. - Not tested: N/A ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did not edit CHANGELOG.md — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Screenshots (if applicable) N/A ## Additional Notes Capping mcp<2.0.0 ensures stability with current headroom releases while a future update can adopt MCP SDK 2.x interface changes if desired. --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1a43c32c5..6e1b1ba48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,7 @@ proxy = [ "orjson>=3.9.14; platform_python_implementation != 'PyPy'", "httpx[http2]>=0.24.0", "openai>=2.14.0", # OpenAI API format support - "mcp>=1.28.1", # MCP server (headroom_compress, retrieve, stats) + "mcp>=1.28.1,<2.0.0", # MCP server (headroom_compress, retrieve, stats) "magika>=0.6.0", # ML content detection for ContentRouter "zstandard>=0.20.0", # Decompress zstd request bodies (Codex, etc.) "websockets>=13.0", # WebSocket proxy for /v1/responses (Codex gpt-5.4+) @@ -225,7 +225,7 @@ autogen = [ ] # MCP server for Claude Code integration mcp = [ - "mcp>=1.28.1", + "mcp>=1.28.1,<2.0.0", "httpx>=0.24.0", "starlette>=0.27.0", "uvicorn>=0.23.0,<1.0", From 5383c6bf2f5209ddfe33cb9bf1c36c0b2e431bcd Mon Sep 17 00:00:00 2001 From: Tejas Chopra Date: Wed, 29 Jul 2026 15:12:04 -0700 Subject: [PATCH 010/215] fix(release): sync generated version metadata on the release branch (#2659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The 0.33.0 release PR (#2339) has sat in `changes-requested` since 2026-07-17. Root cause: **release-please only rewrites `pyproject.toml` and its configured `extra-files`**, but other tracked files also carry the version — and `server.json` is asserted byte-for-byte against `render_server_json()`, which derives its version from `pyproject.toml`. So the bump alone fails `tests/test_mcp_registry/test_server_json.py::test_root_server_json_matches_builder` (the `test (2)` shard) on every regenerated release PR. Nothing in the repo regenerated `server.json` at all, so it fell behind every release. Unblocks #2339. ### Why the release *build* passes but the release PR does not `release.yml` already runs `scripts/version-sync.py` immediately before its own `verify-versions.py` gate (lines 145 and 278). That is why `build` and `build-wheels` are green on #2339 despite the drift — it syncs in the workspace, uncommitted. The regular CI test job does **not** sync, so the fix has to be committed to the branch. This also explains why reviewers kept seeing `verify-versions.py` fail locally while CI's build jobs passed: the verifier is never run un-synced inside `release.yml`. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - **`scripts/version-sync.py`**: also write `server.json`. It was the one version-carrying file with no writer anywhere. Values are rewritten in place so key order and formatting keep matching the builder's byte-for-byte output (verified: the file is pure ASCII and round-trips exactly through `json.dumps(..., indent=2) + "\n"`). - **`.github/workflows/release-metadata-sync.yml`** (new): on a push to `release-please--branches--**`, run version-sync → gate on verify-versions → commit if changed. - **Keyed off the branch push** because release-please force-regenerates that branch on every merge to main. That is precisely what wiped the hand-pushed metadata fixes on #2339 (`2a86c8ff`, `d5ea4dc5`) — a push trigger re-heals after every regeneration instead of being lost. - **Uses the same PAT as `release-please.yml`**: a `GITHUB_TOKEN` push does not trigger workflows, so the release PR's checks would never re-run against the synced commit and would stay red. - **Idempotent**: the self-triggered rerun finds no diff and exits before pushing, so the loop terminates after one no-op run. - **Corrected pre-existing drift on `main`**: the agent-hooks plugin manifests, both marketplace manifests, and `.releasemetadata` were stranded at **0.31.0** — never bumped for 0.32.0 either. `verify-versions.py` now passes on `main`. ### Why not more `extra-files` entries That would need ~13 jsonpath entries restating what `version-sync.py` already knows, and a jsonpath that fails to match **fails silently** — the same class of failure this PR removes, discoverable only after a real release PR regenerates. There is also no precedent for nested jsonpath (`$.packages[0].version`, `$.metadata.version`) in the config today; both existing entries are plain `$.version`. Running the script keeps one source of truth, and files added to it later are covered with no change here. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) — no `headroom/` sources touched - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ python -m pytest scripts/tests/ tests/test_release_workflows.py tests/test_mcp_registry/ -q 207 passed in 2.69s $ ruff check scripts/version-sync.py scripts/tests/test_version_sync.py tests/test_release_workflows.py All checks passed! $ ruff format --check 3 files already formatted $ actionlint .github/workflows/release-metadata-sync.yml (clean) ``` New tests: - `test_server_json_version_is_synchronized` — version-sync moves both `server.json` version fields and preserves the other keys. - `test_release_metadata_sync_runs_on_release_please_branch` — asserts the trigger, the sync→verify→commit ordering, the no-op guard, and the PAT. - `test_version_sync_covers_every_file_the_verifier_gates` — guards `version-sync.py` and `verify-versions.py` against drifting apart again, which is the root cause here. ## Real Behavior Proof - **Environment:** macOS (Darwin arm64), Python 3.12, repo venv. - **Exact command / steps:** reproduced the CI failure locally by simulating release-please's partial bump, then applying the fix. **Reproducing the exact `test (2)` failure** — set `pyproject` to 0.33.0 while `server.json` stays at 0.32.0, as release-please leaves it: ```text $ python -m pytest tests/test_mcp_registry/test_server_json.py -q FAILED tests/test_mcp_registry/test_server_json.py::test_root_server_json_matches_builder 1 failed, 3 passed ``` **After `version-sync.py`:** ```text $ python scripts/version-sync.py && python -m pytest tests/test_mcp_registry/test_server_json.py -q 4 passed ``` **Both gates green on a simulated 0.33.0 bump:** ```text $ python scripts/version-sync.py --version 0.33.0 Version synchronized to 0.33.0 $ python scripts/verify-versions.py All versions aligned at 0.33.0 $ python -m pytest tests/test_mcp_registry/test_server_json.py -q 4 passed ``` **Idempotency** (the property the workflow's loop-termination relies on): re-running against an already-synced tree leaves `pyproject.toml`, `server.json`, `openclaw`, and `sdk/typescript` untouched. - **Not tested:** the workflow has not executed on a real release-please branch regeneration — that can only be exercised once this is on `main` and release-please next updates #2339. The PAT push path and the self-trigger no-op are reasoned from `release-please.yml`'s existing token comment and from local idempotency, not observed in CI. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` ## Additional Notes **Context on the v0.32.0 release failure, since it is easy to misread as "images never build".** Every artifact built for v0.32.0 — all 5 wheel platforms including Windows, all 16 Docker builds + 8 manifests + `promote-latest`, npm, and GitHub Packages. Only `publish-pypi` failed (PyPI attestations, already fixed by `f9cbdd6e` / #2405), and `create-release` was skipped because it depends on it. That is why the release looked like it produced nothing. **Separate, approaching blocker — not addressed here.** PyPI is at **9.69 GB of its 10 GB project cap (96.9%)**, leaving ~305 MB against ~68 MB per release, so roughly 4 more releases fit. The `0.21.x` series alone holds **6.58 GB across 31 releases**, from the old every-push-is-a-release era; pruning it would reclaim two thirds of the quota. Worth a separate issue. **`.releasemetadata` is written but never read** by anything outside `version-sync.py` and its test. It is kept in sync here for internal consistency, but it may be a deletion candidate. --- .claude-plugin/marketplace.json | 4 +- .github/plugin/marketplace.json | 4 +- .github/workflows/release-metadata-sync.yml | 87 +++++++++++++++++++ .releasemetadata | 10 +-- .../.claude-plugin/plugin.json | 2 +- .../.github/plugin/plugin.json | 2 +- scripts/tests/test_version_sync.py | 42 +++++++++ scripts/version-sync.py | 23 +++++ tests/test_release_workflows.py | 63 ++++++++++++++ 9 files changed, 226 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/release-metadata-sync.yml diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c8c4240ca..dba745ab1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,14 +5,14 @@ }, "metadata": { "description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.", - "version": "0.31.0" + "version": "0.32.0" }, "plugins": [ { "name": "headroom", "source": "./plugins/headroom-agent-hooks", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", - "version": "0.31.0", + "version": "0.32.0", "author": { "name": "Headroom Contributors", "url": "https://github.com/chopratejas/headroom" diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index c8c4240ca..dba745ab1 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -5,14 +5,14 @@ }, "metadata": { "description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.", - "version": "0.31.0" + "version": "0.32.0" }, "plugins": [ { "name": "headroom", "source": "./plugins/headroom-agent-hooks", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", - "version": "0.31.0", + "version": "0.32.0", "author": { "name": "Headroom Contributors", "url": "https://github.com/chopratejas/headroom" diff --git a/.github/workflows/release-metadata-sync.yml b/.github/workflows/release-metadata-sync.yml new file mode 100644 index 000000000..ef775cf3b --- /dev/null +++ b/.github/workflows/release-metadata-sync.yml @@ -0,0 +1,87 @@ +name: Release Metadata Sync + +# Keep generated version-carrying files in sync on release-please's branch. +# +# Why this exists +# --------------- +# release-please only rewrites `pyproject.toml` plus the `extra-files` listed in +# `.release-please-config.json` (currently the TypeScript SDK and OpenClaw +# package.json). Several other tracked files also carry the version, and +# `server.json` is asserted byte-for-byte against `render_server_json()` — which +# derives its version from `pyproject.toml`. So the moment release-please bumps +# the version, `tests/test_mcp_registry/test_server_json.py:: +# test_root_server_json_matches_builder` fails on the release PR, and the release +# cannot be merged. That is what blocked v0.33.0 (PR #2339). +# +# `release.yml` already runs `scripts/version-sync.py` before its own +# `verify-versions.py` gate, so the release *build* self-heals in the workspace. +# The regular CI test job does not, so the fix has to be committed. +# +# Why a workflow rather than more `extra-files` entries +# ---------------------------------------------------- +# `scripts/version-sync.py` is the single place that knows every version-carrying +# file. Restating that list as per-file jsonpaths would duplicate it, and a +# jsonpath that silently fails to match produces exactly the failure we are trying +# to remove. Running the script instead means files added to it in future are +# covered with no change here. +# +# Why the push trigger +# -------------------- +# release-please regenerates (force-pushes) its branch on every merge to main. +# That is what repeatedly wiped the hand-pushed metadata fixes on #2339. Keying +# off a push to the branch means the sync re-applies after every regeneration +# instead of being lost. + +on: + push: + branches: + - "release-please--branches--**" + +permissions: + contents: write + +concurrency: + # Never cancel: a half-applied sync would leave the release PR inconsistent. + group: release-metadata-sync-${{ github.ref }} + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.ref_name }} + # PAT (not GITHUB_TOKEN) for the same reason release-please.yml uses one: + # a push made with GITHUB_TOKEN does not trigger workflows, so the release + # PR's checks would never re-run against the synced commit and would stay + # red. Falls back to GITHUB_TOKEN, where the sync still lands and a manual + # re-run of the PR's checks picks it up. + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + # version-sync.py is stdlib-only (json/re/tomllib), so no install step. + - name: Sync version-carrying files release-please does not bump + run: python scripts/version-sync.py + + - name: Verify all versions agree + run: python scripts/verify-versions.py + + - name: Commit and push if anything changed + run: | + if git diff --quiet; then + echo "Already in sync — nothing to commit." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "chore: sync generated version metadata" + # This push re-triggers this workflow. version-sync.py is idempotent, so + # the next run finds no diff and exits above without pushing — the loop + # terminates after one no-op run. + git push origin HEAD:"${GITHUB_REF_NAME}" diff --git a/.releasemetadata b/.releasemetadata index 7735c0e48..27d6fdedb 100644 --- a/.releasemetadata +++ b/.releasemetadata @@ -1,9 +1,9 @@ { - "version": "0.31.0", + "version": "0.32.0", "packages": { - "pypi": "0.31.0", - "npm-sdk": "0.31.0", - "npm-openclaw": "0.31.0", - "agent-hooks-plugin": "0.31.0" + "pypi": "0.32.0", + "npm-sdk": "0.32.0", + "npm-openclaw": "0.32.0", + "agent-hooks-plugin": "0.32.0" } } diff --git a/plugins/headroom-agent-hooks/.claude-plugin/plugin.json b/plugins/headroom-agent-hooks/.claude-plugin/plugin.json index d025fb3f6..a3c4e2bec 100644 --- a/plugins/headroom-agent-hooks/.claude-plugin/plugin.json +++ b/plugins/headroom-agent-hooks/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "headroom", - "version": "0.31.0", + "version": "0.32.0", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", "author": { "name": "Headroom Contributors", diff --git a/plugins/headroom-agent-hooks/.github/plugin/plugin.json b/plugins/headroom-agent-hooks/.github/plugin/plugin.json index bcd4f237e..ff7f9b2ce 100644 --- a/plugins/headroom-agent-hooks/.github/plugin/plugin.json +++ b/plugins/headroom-agent-hooks/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "headroom", - "version": "0.31.0", + "version": "0.32.0", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", "author": { "name": "Headroom Contributors", diff --git a/scripts/tests/test_version_sync.py b/scripts/tests/test_version_sync.py index 075453fe0..c25c72e07 100644 --- a/scripts/tests/test_version_sync.py +++ b/scripts/tests/test_version_sync.py @@ -80,6 +80,20 @@ def temp_project(tmp_path: Path) -> dict[str, Path]: typescript_pkg = typescript / "package.json" typescript_pkg.write_text(json.dumps({"name": "test", "version": "0.5.25"})) + # server.json — the MCP registry descriptor. Asserted byte-for-byte against + # render_server_json(), which reads the version from pyproject.toml, so it has + # to move with every bump or the release PR's test job fails. + server_json = root / "server.json" + server_json.write_text( + json.dumps( + { + "name": "io.github.headroomlabs-ai/headroom", + "version": "0.5.25", + "packages": [{"registryType": "pypi", "version": "0.5.25"}], + } + ) + ) + return { "root": root, "pyproject": pyproject, @@ -90,6 +104,7 @@ def temp_project(tmp_path: Path) -> dict[str, Path]: "claude_plugin": claude_plugin, "github_plugin": github_plugin, "typescript_pkg": typescript_pkg, + "server_json": server_json, } @@ -320,3 +335,30 @@ def test_openclaw_headroom_dependency_is_preserved_for_registry_installability( openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text()) assert openclaw_pkg["version"] == "0.28.0" assert openclaw_pkg["dependencies"]["headroom-ai"] == "^0.22.3" + + +def test_server_json_version_is_synchronized(temp_project: dict[str, Path]) -> None: + """server.json must track the bump or the release PR's test job fails. + + ``tests/test_mcp_registry/test_server_json.py::test_root_server_json_matches_builder`` + asserts the tracked file equals ``render_server_json()``, which reads the version + from ``pyproject.toml``. Nothing regenerated server.json, so it fell behind every + release and blocked v0.33.0 (PR #2339). + """ + root = temp_project["root"] + script = Path(__file__).parent.parent / "version-sync.py" + + result = subprocess.run( + [sys.executable, str(script), "--root", str(root), "--version", "0.33.0"], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, f"Script failed: {result.stderr}" + server_json = json.loads(temp_project["server_json"].read_text()) + assert server_json["version"] == "0.33.0" + # The packages[] entry carries its own version and is checked by the builder too. + assert [p["version"] for p in server_json["packages"]] == ["0.33.0"] + # Untouched keys must survive so the file still matches the builder's output. + assert server_json["name"] == "io.github.headroomlabs-ai/headroom" + assert server_json["packages"][0]["registryType"] == "pypi" diff --git a/scripts/version-sync.py b/scripts/version-sync.py index 79fa78fd0..c4bcdf81c 100644 --- a/scripts/version-sync.py +++ b/scripts/version-sync.py @@ -74,6 +74,28 @@ def update_marketplace_manifest(file_path: Path, version: str) -> None: f.write("\n") +def update_server_json(file_path: Path, version: str) -> None: + """Update the MCP registry descriptor's version fields. + + ``server.json`` is asserted byte-for-byte against ``render_server_json()`` + (tests/test_mcp_registry/test_server_json.py), which derives the version from + ``pyproject.toml``. Nothing regenerated this file, so it silently fell behind + every release and failed that test on the release PR. Values are rewritten in + place so key order and formatting keep matching the builder's output. + """ + with open(file_path, encoding="utf-8") as f: + data = json.load(f) + data["version"] = version + packages = data.get("packages") + if isinstance(packages, list): + for package in packages: + if isinstance(package, dict): + package["version"] = version + with open(file_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + + def update_plugin_versions(root: Path, version: str) -> None: """Update marketplace and plugin manifest versions.""" update_marketplace_manifest(root / ".claude-plugin" / "marketplace.json", version) @@ -172,6 +194,7 @@ def main() -> None: update_openclaw_package_json(args.root / "plugins" / "openclaw" / "package.json", version) update_package_json(args.root / "sdk" / "typescript" / "package.json", version) update_plugin_versions(args.root, version) + update_server_json(args.root / "server.json", version) write_release_metadata(args.root, version) print(f"Version synchronized to {version}") diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py index 8933987f7..9acd4faa2 100644 --- a/tests/test_release_workflows.py +++ b/tests/test_release_workflows.py @@ -1219,3 +1219,66 @@ def test_release_please_config_and_manifest_are_present_and_consistent() -> None "release-please must bump plugins/openclaw/package.json so the " "openclaw npm publish stays in sync." ) + + +def test_release_metadata_sync_runs_on_release_please_branch() -> None: + """The release branch must self-heal the versions release-please does not bump. + + release-please rewrites `pyproject.toml` plus its configured `extra-files` only. + `server.json` is asserted byte-for-byte against `render_server_json()`, which + reads the version from `pyproject.toml`, so a bump without a sync fails + `test_root_server_json_matches_builder` on the release PR — that is what blocked + v0.33.0 (#2339). `release.yml` syncs in-workspace before its own gate, but the + regular CI test job does not, so the sync has to be committed to the branch. + """ + content = (ROOT / ".github" / "workflows" / "release-metadata-sync.yml").read_text( + encoding="utf-8" + ) + + # Keyed off a push to the release branch: release-please force-regenerates that + # branch on every merge to main, which is what wiped the hand-pushed fixes. + assert '"release-please--branches--**"' in content + assert "contents: write" in content + + # Sync, then gate on the verifier, then commit — in that order. + sync = content.index("python scripts/version-sync.py") + verify = content.index("python scripts/verify-versions.py", sync) + commit = content.index("git commit", verify) + assert sync < verify < commit + + # Must no-op rather than loop when the branch is already in sync. + assert "git diff --quiet" in content + + # A GITHUB_TOKEN push would not re-trigger the release PR's checks. + assert "RELEASE_PLEASE_TOKEN" in content + + +def test_version_sync_covers_every_file_the_verifier_gates() -> None: + """version-sync.py must write every version location verify-versions.py checks. + + These two scripts drifting apart is the root cause of the stuck release: the + verifier gated files nothing propagated a version to. + """ + sync = (ROOT / "scripts" / "version-sync.py").read_text(encoding="utf-8") + verify = (ROOT / "scripts" / "verify-versions.py").read_text(encoding="utf-8") + + gated = [ + "pyproject.toml", + "plugins/openclaw/package.json", + "sdk/typescript/package.json", + "plugins/headroom-agent-hooks/.claude-plugin/plugin.json", + "plugins/headroom-agent-hooks/.github/plugin/plugin.json", + "marketplace.json", + ] + for path in gated: + assert path in verify, f"{path} unexpectedly no longer gated by verify-versions.py" + + # version-sync builds paths piecewise, so match on the distinctive components. + for fragment in [ + "openclaw", + "typescript", + "headroom-agent-hooks", + "marketplace.json", + "server.json", + ]: + assert fragment in sync, f"version-sync.py no longer propagates a version to {fragment}" From 28aa53dc7ca51e687cc719c3fe160f3be50c6570 Mon Sep 17 00:00:00 2001 From: Tejas Chopra Date: Wed, 29 Jul 2026 15:54:23 -0700 Subject: [PATCH 011/215] chore: release main (#2339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :robot: I have created a release *beep* *boop* ---
0.33.0 ## [0.33.0](https://github.com/headroomlabs-ai/headroom/compare/v0.32.0...v0.33.0) (2026-07-29) ### Features * **lossless:** factor shared directory prefix in the grep search fold ([#2547](https://github.com/headroomlabs-ai/headroom/issues/2547)) ([7dc9a97](https://github.com/headroomlabs-ai/headroom/commit/7dc9a978ca974a2ed264bb585b187dd11e0a04f2)) * **metrics:** record per-extension token savings ([#2371](https://github.com/headroomlabs-ai/headroom/issues/2371)) ([02eb90f](https://github.com/headroomlabs-ai/headroom/commit/02eb90f24318abdfb05438e873c8f2af7023ab91)) * **opencode:** ship the transport plugin in pip installs ([#2601](https://github.com/headroomlabs-ai/headroom/issues/2601)) ([f54f04f](https://github.com/headroomlabs-ai/headroom/commit/f54f04f5bfff9ff9f9ec83b452f580447c06254a)) * **opencode:** support Copilot subscription backend for headroom models ([#2441](https://github.com/headroomlabs-ai/headroom/issues/2441)) ([#2445](https://github.com/headroomlabs-ai/headroom/issues/2445)) ([9089e7f](https://github.com/headroomlabs-ai/headroom/commit/9089e7f7d394b5a474cc99503b0197c0172f4c9c)) * **proxy/hooks:** run fold-only (stream-safe) turn hooks on streaming OpenAI chat ([#2549](https://github.com/headroomlabs-ai/headroom/issues/2549)) ([a6d4921](https://github.com/headroomlabs-ai/headroom/commit/a6d4921e82c1e9fe1a5ca8b90ffd16aa84a698d4)) * **proxy/savings:** aggregate tool-schema savings into Metrics + all reporting sinks ([#2546](https://github.com/headroomlabs-ai/headroom/issues/2546)) ([9f1ffef](https://github.com/headroomlabs-ai/headroom/commit/9f1ffefe83845a3af0ecd8013daa732c3cd56b7c)) * **proxy:** label GitHub Copilot traffic as "copilot" in the outcome… ([#2377](https://github.com/headroomlabs-ai/headroom/issues/2377)) ([d7a8cdb](https://github.com/headroomlabs-ai/headroom/commit/d7a8cdbee1c500be35b87c9da8395087a37ff8b9)) * **proxy:** make /v1/compress usable as a gateway/Kong sidecar ([#2458](https://github.com/headroomlabs-ai/headroom/issues/2458)) ([1329ed7](https://github.com/headroomlabs-ai/headroom/commit/1329ed7f1a8d7a018042ecbe41804b0be971792e)) * **proxy:** model-aware cold-prefix hook — reasoning compaction (Kimi/GLM) + cold recompaction (CC) ([#2555](https://github.com/headroomlabs-ai/headroom/issues/2555)) ([cb8f4b6](https://github.com/headroomlabs-ai/headroom/commit/cb8f4b64367f8b034315db33e451bdbe87af61f2)) * **proxy:** route selected external compressors through the content router ([#2388](https://github.com/headroomlabs-ai/headroom/issues/2388)) ([e3c7964](https://github.com/headroomlabs-ai/headroom/commit/e3c7964038116a8df4675840896712e1aa967c45)) * **proxy:** select built-in compressors via --compressor + registry inventory ([#2373](https://github.com/headroomlabs-ai/headroom/issues/2373)) ([56c7d4a](https://github.com/headroomlabs-ai/headroom/commit/56c7d4a59e67655cd24040ecf729382c81cdec23)) * **rust:** add structured prose offload plumbing ([#334](https://github.com/headroomlabs-ai/headroom/issues/334)) ([#2378](https://github.com/headroomlabs-ai/headroom/issues/2378)) ([9e07785](https://github.com/headroomlabs-ai/headroom/commit/9e0778553fc505edb2c5bc949b7277f9ffdf3bda)) * **rust:** port CodeCompressor AST compressor to Rust (parity-only) ([#1154](https://github.com/headroomlabs-ai/headroom/issues/1154)) ([e530de5](https://github.com/headroomlabs-ai/headroom/commit/e530de5ad22100bcfaa12a463961dcb08d9671c8)) * **rust:** port Kompress ML prose compressor to Rust (parity-only) ([#1153](https://github.com/headroomlabs-ai/headroom/issues/1153)) ([83e27e5](https://github.com/headroomlabs-ai/headroom/commit/83e27e50360753cf472acb99f1de992574fa80ae)) * **telemetry:** record provider cache read/write/uncached tokens per request ([#2450](https://github.com/headroomlabs-ai/headroom/issues/2450)) ([bec4cce](https://github.com/headroomlabs-ai/headroom/commit/bec4cce8a9f5623e63dba0a847719a652b47d5dc)) * **transforms:** add compressed signal + dispatch code_aware/html/diff via registry ([#2400](https://github.com/headroomlabs-ai/headroom/issues/2400)) ([7ebda67](https://github.com/headroomlabs-ai/headroom/commit/7ebda67ef65fe82803c7fb729c509a1451165f26)) * **transforms:** add pluggable compressor registry + headroom.compressor entry point ([#2370](https://github.com/headroomlabs-ai/headroom/issues/2370)) ([a02073e](https://github.com/headroomlabs-ai/headroom/commit/a02073e3327365a0220ba04eeb10039f12d61684)) * **transforms:** dispatch kompress/text via the compressor registry + forward question ([#2411](https://github.com/headroomlabs-ai/headroom/issues/2411)) ([446ec26](https://github.com/headroomlabs-ai/headroom/commit/446ec26003c8f661cec175a69e0ab8be0ae9cdea)) * **transforms:** dispatch smart_crusher via the compressor registry (defer kompress/text ML boundary) ([#2404](https://github.com/headroomlabs-ai/headroom/issues/2404)) ([7c7bf43](https://github.com/headroomlabs-ai/headroom/commit/7c7bf430576541d0fffdb8fc727b76f3dd038f55)) * **transforms:** make built-in compressors real Compressor implementations (adapters) ([#2391](https://github.com/headroomlabs-ai/headroom/issues/2391)) ([981616c](https://github.com/headroomlabs-ai/headroom/commit/981616c60ef04c32b3eb5b51c4f0f4a7ef297ef1)) * **wrap:** boost Serena — symbol-first guidance, wrap-time pre-index, repo-language scoping ([#2425](https://github.com/headroomlabs-ai/headroom/issues/2425)) ([fd0e1a8](https://github.com/headroomlabs-ai/headroom/commit/fd0e1a8afeb60748f65fef8b9197ec95e23b335a)) * **wrap:** default code-memory to Serena (dashboard browser off) behind unified --code-memory ([#2413](https://github.com/headroomlabs-ai/headroom/issues/2413)) ([6e4425a](https://github.com/headroomlabs-ai/headroom/commit/6e4425a6bdb2bfc49e1633a24b9c9e96e705e1ff)) * **wrap:** reduce-at-source — SAFE quiet-CLI env defaults for the launched agent ([#2548](https://github.com/headroomlabs-ai/headroom/issues/2548)) ([c990cfb](https://github.com/headroomlabs-ai/headroom/commit/c990cfb8037e8f355c82eb1cef87f5c4297b612d)) ### Bug Fixes * **backends/litellm:** guard None completion_tokens in usage mapping ([#2322](https://github.com/headroomlabs-ai/headroom/issues/2322)) ([44a174f](https://github.com/headroomlabs-ai/headroom/commit/44a174fef4d514eceed20a767dc87d00cfde0eaa)) * **backends:** don't crash the OpenAI->Anthropic converter on empty choices ([#2484](https://github.com/headroomlabs-ai/headroom/issues/2484)) ([43a7b57](https://github.com/headroomlabs-ai/headroom/commit/43a7b578a1377ad34d8a78ba3bcef1c276db0b4d)) * **cache:** preserve cache_control ttl when re-anchoring a breakpoint ([#2651](https://github.com/headroomlabs-ai/headroom/issues/2651)) ([e0d2cd0](https://github.com/headroomlabs-ai/headroom/commit/e0d2cd0c5a1c3ee813ac225252c9fd8db7c77c12)) * **cache:** preserve client cache_control ttl when consolidating breakpoints ([#2382](https://github.com/headroomlabs-ai/headroom/issues/2382)) ([8906d3a](https://github.com/headroomlabs-ai/headroom/commit/8906d3a6761c097bbc9d92a0b41f8c982afc633b)) * **ccr:** guard empty/malformed OpenAI choices in _extract_assistant_message ([#2389](https://github.com/headroomlabs-ai/headroom/issues/2389)) ([89319fb](https://github.com/headroomlabs-ai/headroom/commit/89319fbcaddb4be2ea11e87858ed3bd0fcf9dca5)) * **ccr:** sliding idle-window TTL with max-lifetime ceiling in the Rust core backends ([#2604](https://github.com/headroomlabs-ai/headroom/issues/2604)) ([#2631](https://github.com/headroomlabs-ai/headroom/issues/2631)) ([e825588](https://github.com/headroomlabs-ai/headroom/commit/e825588bfbc59fa9e86085e23b4a078e9a0038ba)) * **ci:** align Ruff tooling versions ([#2406](https://github.com/headroomlabs-ai/headroom/issues/2406)) ([2bb14d1](https://github.com/headroomlabs-ai/headroom/commit/2bb14d1ab24617971a657b71ead567479021119d)) * **cli:** warn when Headroom proxy URL leaks into the shell after unwrap claude ([#2238](https://github.com/headroomlabs-ai/headroom/issues/2238)) ([#2571](https://github.com/headroomlabs-ai/headroom/issues/2571)) ([904bc67](https://github.com/headroomlabs-ai/headroom/commit/904bc675b35072dc61191963cbe485fa692927d1)) * **codex:** detect keyring-backed ChatGPT auth ([#2478](https://github.com/headroomlabs-ai/headroom/issues/2478)) ([46293f4](https://github.com/headroomlabs-ai/headroom/commit/46293f4daf4d217ab6f8a83f7c571571b79bae0c)) * **compression:** report source-line span in CCR compression marker ([#2597](https://github.com/headroomlabs-ai/headroom/issues/2597)) ([18e1c3c](https://github.com/headroomlabs-ai/headroom/commit/18e1c3c9badc5169466b7f76ae08e0639f4ba104)) * **copilot:** derive GHE credential host from API URL ([#800](https://github.com/headroomlabs-ai/headroom/issues/800)) ([#2511](https://github.com/headroomlabs-ai/headroom/issues/2511)) ([4a8157f](https://github.com/headroomlabs-ai/headroom/commit/4a8157fa0a3f1d07699f1071ceb653f8902f10a4)) * **copilot:** normalize subscription API routing ([#2441](https://github.com/headroomlabs-ai/headroom/issues/2441)) ([#2455](https://github.com/headroomlabs-ai/headroom/issues/2455)) ([2eca5ee](https://github.com/headroomlabs-ai/headroom/commit/2eca5ee1140c9ce0a5fee05e604d3198f7f86026)) * **copilot:** preserve /v1 for the Anthropic /v1/messages endpoint ([#2409](https://github.com/headroomlabs-ai/headroom/issues/2409)) ([#2414](https://github.com/headroomlabs-ai/headroom/issues/2414)) ([c400f90](https://github.com/headroomlabs-ai/headroom/commit/c400f9081052f633e4e64ad70b95a0230dc6fb3d)) * **deps:** bump mcp to 1.28.1 to clear 3 high-severity CVEs ([#2348](https://github.com/headroomlabs-ai/headroom/issues/2348)) ([a90be94](https://github.com/headroomlabs-ai/headroom/commit/a90be94e32c393332d37db4fb439e0c776b89f27)) * **grok:** preserve business-seat auth while routing only inference ([#2514](https://github.com/headroomlabs-ai/headroom/issues/2514)) ([e4076bb](https://github.com/headroomlabs-ai/headroom/commit/e4076bbe99d500982b51444fe37f8f467cd6abe2)) * **image:** reuse image models instead of rebuilding them per request ([#2513](https://github.com/headroomlabs-ai/headroom/issues/2513)) ([#2536](https://github.com/headroomlabs-ai/headroom/issues/2536)) ([2a63ec7](https://github.com/headroomlabs-ai/headroom/commit/2a63ec70b65605dfcff1b0afc292ab0298459f20)) * **install:** carry upstream-routing env overrides into supervised deployments ([#2429](https://github.com/headroomlabs-ai/headroom/issues/2429)) ([170b04a](https://github.com/headroomlabs-ai/headroom/commit/170b04a74d5361cdfac4a6e265f5ea0dfecbd841)) * **install:** default to cache mode, matching `headroom proxy` ([#1893](https://github.com/headroomlabs-ai/headroom/issues/1893) follow-up) ([#2563](https://github.com/headroomlabs-ai/headroom/issues/2563)) ([b121223](https://github.com/headroomlabs-ai/headroom/commit/b121223ec97e95c5a7a4c2c5e06a4655c7328e88)) * **install:** migrate deployments off the retired chopratejas image repo ([#2427](https://github.com/headroomlabs-ai/headroom/issues/2427)) ([17ff13c](https://github.com/headroomlabs-ai/headroom/commit/17ff13ccbe274e831d5d9327740cd6d506ea8c1c)) * **install:** use CREATE_NO_WINDOW instead of DETACHED_PROCESS on Windows ([#2527](https://github.com/headroomlabs-ai/headroom/issues/2527)) ([045f3df](https://github.com/headroomlabs-ai/headroom/commit/045f3dfe6fd9f4e39e4cdd8c0c529a815d925c7e)) * **kompress:** raise the default execution-slot wait ([#2456](https://github.com/headroomlabs-ai/headroom/issues/2456)) ([5bd2266](https://github.com/headroomlabs-ai/headroom/commit/5bd2266f16bb351a7a7334e1c29c598d28187b1d)) * **learn:** detect the active OpenCode database ([#2587](https://github.com/headroomlabs-ai/headroom/issues/2587)) ([f74d874](https://github.com/headroomlabs-ai/headroom/commit/f74d87477701f1f95bd4709c4727f3d3890a4e22)) * **learn:** keep traceback tail in tool-error digest preview ([#2596](https://github.com/headroomlabs-ai/headroom/issues/2596)) ([85e8699](https://github.com/headroomlabs-ai/headroom/commit/85e869945138f06471501046c5725eac119dea58)) * **learn:** treat unreadable candidate paths as absent in project decode ([#2446](https://github.com/headroomlabs-ai/headroom/issues/2446)) ([a09ba6c](https://github.com/headroomlabs-ai/headroom/commit/a09ba6c08723618dba5f282a9beac78c9406edbf)) * **mcp:** pin mcp dependency to <2.0.0 to prevent server startup crash ([#2642](https://github.com/headroomlabs-ai/headroom/issues/2642)) ([b3f016b](https://github.com/headroomlabs-ai/headroom/commit/b3f016b866375cfe2ff8518055ab93844e11ec27)) * **proxy/cost:** count Gemini thinking tokens in output usage ([#2639](https://github.com/headroomlabs-ai/headroom/issues/2639)) ([22b707f](https://github.com/headroomlabs-ai/headroom/commit/22b707fd31d75914e1677290d2a8011727eb74f5)) * **proxy/cost:** record each request's savings exactly once (drop 3 double-counts) ([#2545](https://github.com/headroomlabs-ai/headroom/issues/2545)) ([0845b26](https://github.com/headroomlabs-ai/headroom/commit/0845b26ee61c507487cd8476cfabe8284f59402b)) * **proxy/cost:** warn once per model when pricing lookup fails ([#2504](https://github.com/headroomlabs-ai/headroom/issues/2504)) ([#2535](https://github.com/headroomlabs-ai/headroom/issues/2535)) ([fa47637](https://github.com/headroomlabs-ai/headroom/commit/fa4763761b5912cccde95903f4b9a681b555465b)) * **proxy/gemini:** None-guard token counts from usageMetadata ([#2347](https://github.com/headroomlabs-ai/headroom/issues/2347)) ([f64aac9](https://github.com/headroomlabs-ai/headroom/commit/f64aac9733d5e314f381644eaea62e2c28b6dc65)) * **proxy/gemini:** tolerate malformed parts on the compression path ([#2486](https://github.com/headroomlabs-ai/headroom/issues/2486)) ([07cf547](https://github.com/headroomlabs-ai/headroom/commit/07cf5476072a45bac7dd94386de126234a8049e7)) * **proxy/metrics:** move the savings-ledger append off the event loop ([#2439](https://github.com/headroomlabs-ai/headroom/issues/2439)) ([4aac068](https://github.com/headroomlabs-ai/headroom/commit/4aac068814246db3fa250c48f5c916aa2561d8c8)) * **proxy/openai:** cache under looked-up messages ([#2420](https://github.com/headroomlabs-ai/headroom/issues/2420)) ([7052d52](https://github.com/headroomlabs-ai/headroom/commit/7052d52dcbb2fd97b756c9b60a096cdfeee32c94)) * **proxy/openai:** don't record Codex WS savings without input accounting ([#2493](https://github.com/headroomlabs-ai/headroom/issues/2493)) ([2195ba7](https://github.com/headroomlabs-ai/headroom/commit/2195ba7d917649ba2ac647fdefa661cf598e3028)) * **proxy/openai:** feed chat/completions traffic into the traffic learner ([#2333](https://github.com/headroomlabs-ai/headroom/issues/2333)) ([6cdfd3f](https://github.com/headroomlabs-ai/headroom/commit/6cdfd3f64d2f64d50ed47644126df71872a21050)) * **proxy/openai:** None-guard usage token counts on the chat path ([#2431](https://github.com/headroomlabs-ai/headroom/issues/2431)) ([313c290](https://github.com/headroomlabs-ai/headroom/commit/313c290df96ca58a19ea0f79c67f5b71bb5f4d60)) * **proxy/openai:** replay incremental events in buffered Responses SSE ([#2410](https://github.com/headroomlabs-ai/headroom/issues/2410)) ([#2415](https://github.com/headroomlabs-ai/headroom/issues/2415)) ([0cbc0e8](https://github.com/headroomlabs-ai/headroom/commit/0cbc0e8e5435cd8d743ae537cdbaa70787bfc5b4)) * **proxy/output-shaping:** tolerate a non-string system block text in steering ([#2435](https://github.com/headroomlabs-ai/headroom/issues/2435)) ([3e97671](https://github.com/headroomlabs-ai/headroom/commit/3e976712e717a53ab6aea73120ae6ffacea74250)) * **proxy/perf:** count turn-hook message folds in token accounting ([#2520](https://github.com/headroomlabs-ai/headroom/issues/2520)) ([c371d5a](https://github.com/headroomlabs-ai/headroom/commit/c371d5ad602f5ab93645b2db4673ae2c5e9f0575)) * **proxy/perf:** tokenizer-consistent token accounting + surface tool-schema savings ([#2542](https://github.com/headroomlabs-ai/headroom/issues/2542)) ([1cc53c9](https://github.com/headroomlabs-ai/headroom/commit/1cc53c9c92cd4dffaf048dc806cb8c570bdb86b6)) * **proxy/streaming:** tolerate malformed content in _response_to_sse ([#2481](https://github.com/headroomlabs-ai/headroom/issues/2481)) ([77b26c0](https://github.com/headroomlabs-ai/headroom/commit/77b26c093cfb7b5c71a46d5156cb774a2ae889b1)) * **proxy:** keep buffered CCR streams alive ([#2479](https://github.com/headroomlabs-ai/headroom/issues/2479)) ([a2e42fb](https://github.com/headroomlabs-ai/headroom/commit/a2e42fb877642e7eacfcc77655183244823d969e)) * **proxy:** keep core tools and the client's ToolSearch resident for PascalCase clients ([#2647](https://github.com/headroomlabs-ai/headroom/issues/2647)) ([1d29738](https://github.com/headroomlabs-ai/headroom/commit/1d29738818bb40e00847dba46e2f9acce773d3eb)) * **proxy:** offload OpenAI and Gemini tokenizer counting off the event loop ([#2498](https://github.com/headroomlabs-ai/headroom/issues/2498)) ([806d2e4](https://github.com/headroomlabs-ai/headroom/commit/806d2e468ace012ebfa1a0907a679781b5004c72)) * **proxy:** promote Kompress health after runtime load ([#2402](https://github.com/headroomlabs-ai/headroom/issues/2402)) ([54526bc](https://github.com/headroomlabs-ai/headroom/commit/54526bc8586cdeb248d6257dc497136a21b971c0)) * **proxy:** reassemble server_tool_use.input from streamed partial_json ([#2449](https://github.com/headroomlabs-ai/headroom/issues/2449)) ([8c8fae0](https://github.com/headroomlabs-ai/headroom/commit/8c8fae0d0bca75f7f2561136910e40f716be57ab)) * **proxy:** report deferred Kompress status and promote health from cache ([#2564](https://github.com/headroomlabs-ai/headroom/issues/2564)) ([d50cfab](https://github.com/headroomlabs-ai/headroom/commit/d50cfabedca2c4b7d83751adaa8aa7b317f13c7b)) * **proxy:** skip max_tokens rename for backend-routed openai chat ([#2401](https://github.com/headroomlabs-ai/headroom/issues/2401)) ([d6a1af4](https://github.com/headroomlabs-ai/headroom/commit/d6a1af40d5a18f4440a45e342c2d05fee7a642e3)) * **release:** publish Windows wheel + sdist (disable PyPI attestations, [#112](https://github.com/headroomlabs-ai/headroom/issues/112)) ([#2405](https://github.com/headroomlabs-ai/headroom/issues/2405)) ([f9cbdd6](https://github.com/headroomlabs-ai/headroom/commit/f9cbdd6e390714e037832f78c59d00907a26b612)) * **release:** sync generated version metadata on the release branch ([#2659](https://github.com/headroomlabs-ai/headroom/issues/2659)) ([5383c6b](https://github.com/headroomlabs-ai/headroom/commit/5383c6bf2f5209ddfe33cb9bf1c36c0b2e431bcd)) * **rust:** port CJK-aware relevance-query matching to CodeCompressor ([#2634](https://github.com/headroomlabs-ai/headroom/issues/2634)) ([e86c639](https://github.com/headroomlabs-ai/headroom/commit/e86c6390cec4fc0f932b006b36d5b924511a5b0b)) * **security:** exclude compromised ast-grep-cli 0.44.1 (supply-chain trojan) ([#2342](https://github.com/headroomlabs-ai/headroom/issues/2342)) ([494fb5a](https://github.com/headroomlabs-ai/headroom/commit/494fb5a60e15ae1ce425f79f1432827b42923c73)) * **tokenizers:** price Claude against a real BPE (tiktoken o200k) not a char estimate ([#2543](https://github.com/headroomlabs-ai/headroom/issues/2543)) ([285176b](https://github.com/headroomlabs-ai/headroom/commit/285176be54e1d179676dcf205de44d5893f8efa5)) * **transforms/cross-turn-dedup:** don't renumber-fold zero-padded line prefixes ([#2369](https://github.com/headroomlabs-ai/headroom/issues/2369)) ([f4070c4](https://github.com/headroomlabs-ai/headroom/commit/f4070c44cbd65ecf49f2ae81ad26a95296ef552b)) * **transforms/kompress-remote:** keep compress fail-open on malformed 200 ([#2320](https://github.com/headroomlabs-ai/headroom/issues/2320)) ([b759990](https://github.com/headroomlabs-ai/headroom/commit/b75999017fc060a4617077ef86c21ce3249d0842)) * **wrap:** emit bare dotted keys for Codex --config overrides ([#2383](https://github.com/headroomlabs-ai/headroom/issues/2383)) ([f57e959](https://github.com/headroomlabs-ai/headroom/commit/f57e959a506f87f14143d595cae24a1fd6084f66)) * **wrap:** make RTK opt-in (off by default) across wrap subcommands ([#2344](https://github.com/headroomlabs-ai/headroom/issues/2344)) ([44136ed](https://github.com/headroomlabs-ai/headroom/commit/44136ed0427edff338c5d7979b589f8540c9b967)) * **wrap:** skip Serena project setup outside real project roots ([#2574](https://github.com/headroomlabs-ai/headroom/issues/2574)) ([0994ea0](https://github.com/headroomlabs-ai/headroom/commit/0994ea04c869939946b91cbe52ceaf46740786be)) * **wrap:** stop same-port persistent routing during claude unwrap ([#2340](https://github.com/headroomlabs-ai/headroom/issues/2340)) ([#2350](https://github.com/headroomlabs-ai/headroom/issues/2350)) ([cf5fa64](https://github.com/headroomlabs-ai/headroom/commit/cf5fa644b6e019a3ea31b4f48509a63921055253)) ### Performance Improvements * **content_router:** dedupe content detection ([#2419](https://github.com/headroomlabs-ai/headroom/issues/2419)) ([9b016f2](https://github.com/headroomlabs-ai/headroom/commit/9b016f2b64cb50cd50ab68711ab2abdf7d74c8ec)) ### Dependencies * bump the cargo-minor-patch group with 10 updates ([#2284](https://github.com/headroomlabs-ai/headroom/issues/2284)) ([3266ed7](https://github.com/headroomlabs-ai/headroom/commit/3266ed7641cc92f5cae79b1befeb6bee7c96242e)) * bump the npm-minor-patch group across 3 directories with 7 updates ([#2276](https://github.com/headroomlabs-ai/headroom/issues/2276)) ([961866b](https://github.com/headroomlabs-ai/headroom/commit/961866ba7c277b59ccdd51e784de9547a09198af)) ### Code Refactoring * **transforms:** dispatch simple built-in strategies via the compressor registry ([#2399](https://github.com/headroomlabs-ai/headroom/issues/2399)) ([fc9c63f](https://github.com/headroomlabs-ai/headroom/commit/fc9c63f18c1a8414b62ced8b2dd54ad1fe4d1c14)) * **wrap:** retire tokensave; Serena is the code-memory MCP ([#2499](https://github.com/headroomlabs-ai/headroom/issues/2499)) ([5d23a0a](https://github.com/headroomlabs-ai/headroom/commit/5d23a0aec22dacdbd7bf221dafbb17bcf9f10c63))
--- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .claude-plugin/marketplace.json | 4 +- .github/plugin/marketplace.json | 4 +- .release-please-manifest.json | 2 +- .releasemetadata | 10 +- CHANGELOG.md | 108 ++++++++++++++++++ .../.claude-plugin/plugin.json | 2 +- .../.github/plugin/plugin.json | 2 +- plugins/openclaw/package.json | 2 +- pyproject.toml | 2 +- sdk/typescript/package.json | 2 +- server.json | 4 +- 11 files changed, 125 insertions(+), 17 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index dba745ab1..a40324ce8 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,14 +5,14 @@ }, "metadata": { "description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.", - "version": "0.32.0" + "version": "0.33.0" }, "plugins": [ { "name": "headroom", "source": "./plugins/headroom-agent-hooks", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", - "version": "0.32.0", + "version": "0.33.0", "author": { "name": "Headroom Contributors", "url": "https://github.com/chopratejas/headroom" diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index dba745ab1..a40324ce8 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -5,14 +5,14 @@ }, "metadata": { "description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.", - "version": "0.32.0" + "version": "0.33.0" }, "plugins": [ { "name": "headroom", "source": "./plugins/headroom-agent-hooks", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", - "version": "0.32.0", + "version": "0.33.0", "author": { "name": "Headroom Contributors", "url": "https://github.com/chopratejas/headroom" diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 475822287..53ecb865b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.32.0" + ".": "0.33.0" } diff --git a/.releasemetadata b/.releasemetadata index 27d6fdedb..0c73f80d8 100644 --- a/.releasemetadata +++ b/.releasemetadata @@ -1,9 +1,9 @@ { - "version": "0.32.0", + "version": "0.33.0", "packages": { - "pypi": "0.32.0", - "npm-sdk": "0.32.0", - "npm-openclaw": "0.32.0", - "agent-hooks-plugin": "0.32.0" + "pypi": "0.33.0", + "npm-sdk": "0.33.0", + "npm-openclaw": "0.33.0", + "agent-hooks-plugin": "0.33.0" } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 42ac39738..0cdf17270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -284,6 +284,114 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **code:** fix two `CodeAwareCompressor` AST-reassembly bugs: an exported JS/TS function or class (`export function foo() {`) produced a duplicated `export export` keyword and invalid syntax, because line-based node slicing (used to preserve indentation) pulled in the preceding `export` sibling's text on top of the `export_statement` handler's own prefix reconstruction. Separately, in every supported language, a doc comment immediately above a top-level function, class, or type was detached from its declaration during extraction and re-emitted in a cluster at the end of the compressed output instead of staying attached to what it documents. - * **proxy:** Buffered upstream responses containing a `server_tool_use` (or any other unrecognized Anthropic content block) no longer turn a fully-generated response into an HTTP 502. `StreamingMixin._response_to_sse` raised `ValueError` on unknown block types after the entire upstream generation had already been buffered, so a slow-but-successful response failed and the client retried the whole multi-minute request. Unknown blocks are now emitted verbatim in `content_block_start` (following the existing redacted_thinking` pattern), so `server_tool_use`, `server_tool_result`, `mcp_tool_use`, and future block types round-trip ([#1806](https://github.com/headroomlabs-ai/headroom/issues/1806)). +## [0.33.0](https://github.com/headroomlabs-ai/headroom/compare/v0.32.0...v0.33.0) (2026-07-29) + + +### Features + +* **lossless:** factor shared directory prefix in the grep search fold ([#2547](https://github.com/headroomlabs-ai/headroom/issues/2547)) ([7dc9a97](https://github.com/headroomlabs-ai/headroom/commit/7dc9a978ca974a2ed264bb585b187dd11e0a04f2)) +* **metrics:** record per-extension token savings ([#2371](https://github.com/headroomlabs-ai/headroom/issues/2371)) ([02eb90f](https://github.com/headroomlabs-ai/headroom/commit/02eb90f24318abdfb05438e873c8f2af7023ab91)) +* **opencode:** ship the transport plugin in pip installs ([#2601](https://github.com/headroomlabs-ai/headroom/issues/2601)) ([f54f04f](https://github.com/headroomlabs-ai/headroom/commit/f54f04f5bfff9ff9f9ec83b452f580447c06254a)) +* **opencode:** support Copilot subscription backend for headroom models ([#2441](https://github.com/headroomlabs-ai/headroom/issues/2441)) ([#2445](https://github.com/headroomlabs-ai/headroom/issues/2445)) ([9089e7f](https://github.com/headroomlabs-ai/headroom/commit/9089e7f7d394b5a474cc99503b0197c0172f4c9c)) +* **proxy/hooks:** run fold-only (stream-safe) turn hooks on streaming OpenAI chat ([#2549](https://github.com/headroomlabs-ai/headroom/issues/2549)) ([a6d4921](https://github.com/headroomlabs-ai/headroom/commit/a6d4921e82c1e9fe1a5ca8b90ffd16aa84a698d4)) +* **proxy/savings:** aggregate tool-schema savings into Metrics + all reporting sinks ([#2546](https://github.com/headroomlabs-ai/headroom/issues/2546)) ([9f1ffef](https://github.com/headroomlabs-ai/headroom/commit/9f1ffefe83845a3af0ecd8013daa732c3cd56b7c)) +* **proxy:** label GitHub Copilot traffic as "copilot" in the outcome… ([#2377](https://github.com/headroomlabs-ai/headroom/issues/2377)) ([d7a8cdb](https://github.com/headroomlabs-ai/headroom/commit/d7a8cdbee1c500be35b87c9da8395087a37ff8b9)) +* **proxy:** make /v1/compress usable as a gateway/Kong sidecar ([#2458](https://github.com/headroomlabs-ai/headroom/issues/2458)) ([1329ed7](https://github.com/headroomlabs-ai/headroom/commit/1329ed7f1a8d7a018042ecbe41804b0be971792e)) +* **proxy:** model-aware cold-prefix hook — reasoning compaction (Kimi/GLM) + cold recompaction (CC) ([#2555](https://github.com/headroomlabs-ai/headroom/issues/2555)) ([cb8f4b6](https://github.com/headroomlabs-ai/headroom/commit/cb8f4b64367f8b034315db33e451bdbe87af61f2)) +* **proxy:** route selected external compressors through the content router ([#2388](https://github.com/headroomlabs-ai/headroom/issues/2388)) ([e3c7964](https://github.com/headroomlabs-ai/headroom/commit/e3c7964038116a8df4675840896712e1aa967c45)) +* **proxy:** select built-in compressors via --compressor + registry inventory ([#2373](https://github.com/headroomlabs-ai/headroom/issues/2373)) ([56c7d4a](https://github.com/headroomlabs-ai/headroom/commit/56c7d4a59e67655cd24040ecf729382c81cdec23)) +* **rust:** add structured prose offload plumbing ([#334](https://github.com/headroomlabs-ai/headroom/issues/334)) ([#2378](https://github.com/headroomlabs-ai/headroom/issues/2378)) ([9e07785](https://github.com/headroomlabs-ai/headroom/commit/9e0778553fc505edb2c5bc949b7277f9ffdf3bda)) +* **rust:** port CodeCompressor AST compressor to Rust (parity-only) ([#1154](https://github.com/headroomlabs-ai/headroom/issues/1154)) ([e530de5](https://github.com/headroomlabs-ai/headroom/commit/e530de5ad22100bcfaa12a463961dcb08d9671c8)) +* **rust:** port Kompress ML prose compressor to Rust (parity-only) ([#1153](https://github.com/headroomlabs-ai/headroom/issues/1153)) ([83e27e5](https://github.com/headroomlabs-ai/headroom/commit/83e27e50360753cf472acb99f1de992574fa80ae)) +* **telemetry:** record provider cache read/write/uncached tokens per request ([#2450](https://github.com/headroomlabs-ai/headroom/issues/2450)) ([bec4cce](https://github.com/headroomlabs-ai/headroom/commit/bec4cce8a9f5623e63dba0a847719a652b47d5dc)) +* **transforms:** add compressed signal + dispatch code_aware/html/diff via registry ([#2400](https://github.com/headroomlabs-ai/headroom/issues/2400)) ([7ebda67](https://github.com/headroomlabs-ai/headroom/commit/7ebda67ef65fe82803c7fb729c509a1451165f26)) +* **transforms:** add pluggable compressor registry + headroom.compressor entry point ([#2370](https://github.com/headroomlabs-ai/headroom/issues/2370)) ([a02073e](https://github.com/headroomlabs-ai/headroom/commit/a02073e3327365a0220ba04eeb10039f12d61684)) +* **transforms:** dispatch kompress/text via the compressor registry + forward question ([#2411](https://github.com/headroomlabs-ai/headroom/issues/2411)) ([446ec26](https://github.com/headroomlabs-ai/headroom/commit/446ec26003c8f661cec175a69e0ab8be0ae9cdea)) +* **transforms:** dispatch smart_crusher via the compressor registry (defer kompress/text ML boundary) ([#2404](https://github.com/headroomlabs-ai/headroom/issues/2404)) ([7c7bf43](https://github.com/headroomlabs-ai/headroom/commit/7c7bf430576541d0fffdb8fc727b76f3dd038f55)) +* **transforms:** make built-in compressors real Compressor implementations (adapters) ([#2391](https://github.com/headroomlabs-ai/headroom/issues/2391)) ([981616c](https://github.com/headroomlabs-ai/headroom/commit/981616c60ef04c32b3eb5b51c4f0f4a7ef297ef1)) +* **wrap:** boost Serena — symbol-first guidance, wrap-time pre-index, repo-language scoping ([#2425](https://github.com/headroomlabs-ai/headroom/issues/2425)) ([fd0e1a8](https://github.com/headroomlabs-ai/headroom/commit/fd0e1a8afeb60748f65fef8b9197ec95e23b335a)) +* **wrap:** default code-memory to Serena (dashboard browser off) behind unified --code-memory ([#2413](https://github.com/headroomlabs-ai/headroom/issues/2413)) ([6e4425a](https://github.com/headroomlabs-ai/headroom/commit/6e4425a6bdb2bfc49e1633a24b9c9e96e705e1ff)) +* **wrap:** reduce-at-source — SAFE quiet-CLI env defaults for the launched agent ([#2548](https://github.com/headroomlabs-ai/headroom/issues/2548)) ([c990cfb](https://github.com/headroomlabs-ai/headroom/commit/c990cfb8037e8f355c82eb1cef87f5c4297b612d)) + + +### Bug Fixes + +* **backends/litellm:** guard None completion_tokens in usage mapping ([#2322](https://github.com/headroomlabs-ai/headroom/issues/2322)) ([44a174f](https://github.com/headroomlabs-ai/headroom/commit/44a174fef4d514eceed20a767dc87d00cfde0eaa)) +* **backends:** don't crash the OpenAI->Anthropic converter on empty choices ([#2484](https://github.com/headroomlabs-ai/headroom/issues/2484)) ([43a7b57](https://github.com/headroomlabs-ai/headroom/commit/43a7b578a1377ad34d8a78ba3bcef1c276db0b4d)) +* **cache:** preserve cache_control ttl when re-anchoring a breakpoint ([#2651](https://github.com/headroomlabs-ai/headroom/issues/2651)) ([e0d2cd0](https://github.com/headroomlabs-ai/headroom/commit/e0d2cd0c5a1c3ee813ac225252c9fd8db7c77c12)) +* **cache:** preserve client cache_control ttl when consolidating breakpoints ([#2382](https://github.com/headroomlabs-ai/headroom/issues/2382)) ([8906d3a](https://github.com/headroomlabs-ai/headroom/commit/8906d3a6761c097bbc9d92a0b41f8c982afc633b)) +* **ccr:** guard empty/malformed OpenAI choices in _extract_assistant_message ([#2389](https://github.com/headroomlabs-ai/headroom/issues/2389)) ([89319fb](https://github.com/headroomlabs-ai/headroom/commit/89319fbcaddb4be2ea11e87858ed3bd0fcf9dca5)) +* **ccr:** sliding idle-window TTL with max-lifetime ceiling in the Rust core backends ([#2604](https://github.com/headroomlabs-ai/headroom/issues/2604)) ([#2631](https://github.com/headroomlabs-ai/headroom/issues/2631)) ([e825588](https://github.com/headroomlabs-ai/headroom/commit/e825588bfbc59fa9e86085e23b4a078e9a0038ba)) +* **ci:** align Ruff tooling versions ([#2406](https://github.com/headroomlabs-ai/headroom/issues/2406)) ([2bb14d1](https://github.com/headroomlabs-ai/headroom/commit/2bb14d1ab24617971a657b71ead567479021119d)) +* **cli:** warn when Headroom proxy URL leaks into the shell after unwrap claude ([#2238](https://github.com/headroomlabs-ai/headroom/issues/2238)) ([#2571](https://github.com/headroomlabs-ai/headroom/issues/2571)) ([904bc67](https://github.com/headroomlabs-ai/headroom/commit/904bc675b35072dc61191963cbe485fa692927d1)) +* **codex:** detect keyring-backed ChatGPT auth ([#2478](https://github.com/headroomlabs-ai/headroom/issues/2478)) ([46293f4](https://github.com/headroomlabs-ai/headroom/commit/46293f4daf4d217ab6f8a83f7c571571b79bae0c)) +* **compression:** report source-line span in CCR compression marker ([#2597](https://github.com/headroomlabs-ai/headroom/issues/2597)) ([18e1c3c](https://github.com/headroomlabs-ai/headroom/commit/18e1c3c9badc5169466b7f76ae08e0639f4ba104)) +* **copilot:** derive GHE credential host from API URL ([#800](https://github.com/headroomlabs-ai/headroom/issues/800)) ([#2511](https://github.com/headroomlabs-ai/headroom/issues/2511)) ([4a8157f](https://github.com/headroomlabs-ai/headroom/commit/4a8157fa0a3f1d07699f1071ceb653f8902f10a4)) +* **copilot:** normalize subscription API routing ([#2441](https://github.com/headroomlabs-ai/headroom/issues/2441)) ([#2455](https://github.com/headroomlabs-ai/headroom/issues/2455)) ([2eca5ee](https://github.com/headroomlabs-ai/headroom/commit/2eca5ee1140c9ce0a5fee05e604d3198f7f86026)) +* **copilot:** preserve /v1 for the Anthropic /v1/messages endpoint ([#2409](https://github.com/headroomlabs-ai/headroom/issues/2409)) ([#2414](https://github.com/headroomlabs-ai/headroom/issues/2414)) ([c400f90](https://github.com/headroomlabs-ai/headroom/commit/c400f9081052f633e4e64ad70b95a0230dc6fb3d)) +* **deps:** bump mcp to 1.28.1 to clear 3 high-severity CVEs ([#2348](https://github.com/headroomlabs-ai/headroom/issues/2348)) ([a90be94](https://github.com/headroomlabs-ai/headroom/commit/a90be94e32c393332d37db4fb439e0c776b89f27)) +* **grok:** preserve business-seat auth while routing only inference ([#2514](https://github.com/headroomlabs-ai/headroom/issues/2514)) ([e4076bb](https://github.com/headroomlabs-ai/headroom/commit/e4076bbe99d500982b51444fe37f8f467cd6abe2)) +* **image:** reuse image models instead of rebuilding them per request ([#2513](https://github.com/headroomlabs-ai/headroom/issues/2513)) ([#2536](https://github.com/headroomlabs-ai/headroom/issues/2536)) ([2a63ec7](https://github.com/headroomlabs-ai/headroom/commit/2a63ec70b65605dfcff1b0afc292ab0298459f20)) +* **install:** carry upstream-routing env overrides into supervised deployments ([#2429](https://github.com/headroomlabs-ai/headroom/issues/2429)) ([170b04a](https://github.com/headroomlabs-ai/headroom/commit/170b04a74d5361cdfac4a6e265f5ea0dfecbd841)) +* **install:** default to cache mode, matching `headroom proxy` ([#1893](https://github.com/headroomlabs-ai/headroom/issues/1893) follow-up) ([#2563](https://github.com/headroomlabs-ai/headroom/issues/2563)) ([b121223](https://github.com/headroomlabs-ai/headroom/commit/b121223ec97e95c5a7a4c2c5e06a4655c7328e88)) +* **install:** migrate deployments off the retired chopratejas image repo ([#2427](https://github.com/headroomlabs-ai/headroom/issues/2427)) ([17ff13c](https://github.com/headroomlabs-ai/headroom/commit/17ff13ccbe274e831d5d9327740cd6d506ea8c1c)) +* **install:** use CREATE_NO_WINDOW instead of DETACHED_PROCESS on Windows ([#2527](https://github.com/headroomlabs-ai/headroom/issues/2527)) ([045f3df](https://github.com/headroomlabs-ai/headroom/commit/045f3dfe6fd9f4e39e4cdd8c0c529a815d925c7e)) +* **kompress:** raise the default execution-slot wait ([#2456](https://github.com/headroomlabs-ai/headroom/issues/2456)) ([5bd2266](https://github.com/headroomlabs-ai/headroom/commit/5bd2266f16bb351a7a7334e1c29c598d28187b1d)) +* **learn:** detect the active OpenCode database ([#2587](https://github.com/headroomlabs-ai/headroom/issues/2587)) ([f74d874](https://github.com/headroomlabs-ai/headroom/commit/f74d87477701f1f95bd4709c4727f3d3890a4e22)) +* **learn:** keep traceback tail in tool-error digest preview ([#2596](https://github.com/headroomlabs-ai/headroom/issues/2596)) ([85e8699](https://github.com/headroomlabs-ai/headroom/commit/85e869945138f06471501046c5725eac119dea58)) +* **learn:** treat unreadable candidate paths as absent in project decode ([#2446](https://github.com/headroomlabs-ai/headroom/issues/2446)) ([a09ba6c](https://github.com/headroomlabs-ai/headroom/commit/a09ba6c08723618dba5f282a9beac78c9406edbf)) +* **mcp:** pin mcp dependency to <2.0.0 to prevent server startup crash ([#2642](https://github.com/headroomlabs-ai/headroom/issues/2642)) ([b3f016b](https://github.com/headroomlabs-ai/headroom/commit/b3f016b866375cfe2ff8518055ab93844e11ec27)) +* **proxy/cost:** count Gemini thinking tokens in output usage ([#2639](https://github.com/headroomlabs-ai/headroom/issues/2639)) ([22b707f](https://github.com/headroomlabs-ai/headroom/commit/22b707fd31d75914e1677290d2a8011727eb74f5)) +* **proxy/cost:** record each request's savings exactly once (drop 3 double-counts) ([#2545](https://github.com/headroomlabs-ai/headroom/issues/2545)) ([0845b26](https://github.com/headroomlabs-ai/headroom/commit/0845b26ee61c507487cd8476cfabe8284f59402b)) +* **proxy/cost:** warn once per model when pricing lookup fails ([#2504](https://github.com/headroomlabs-ai/headroom/issues/2504)) ([#2535](https://github.com/headroomlabs-ai/headroom/issues/2535)) ([fa47637](https://github.com/headroomlabs-ai/headroom/commit/fa4763761b5912cccde95903f4b9a681b555465b)) +* **proxy/gemini:** None-guard token counts from usageMetadata ([#2347](https://github.com/headroomlabs-ai/headroom/issues/2347)) ([f64aac9](https://github.com/headroomlabs-ai/headroom/commit/f64aac9733d5e314f381644eaea62e2c28b6dc65)) +* **proxy/gemini:** tolerate malformed parts on the compression path ([#2486](https://github.com/headroomlabs-ai/headroom/issues/2486)) ([07cf547](https://github.com/headroomlabs-ai/headroom/commit/07cf5476072a45bac7dd94386de126234a8049e7)) +* **proxy/metrics:** move the savings-ledger append off the event loop ([#2439](https://github.com/headroomlabs-ai/headroom/issues/2439)) ([4aac068](https://github.com/headroomlabs-ai/headroom/commit/4aac068814246db3fa250c48f5c916aa2561d8c8)) +* **proxy/openai:** cache under looked-up messages ([#2420](https://github.com/headroomlabs-ai/headroom/issues/2420)) ([7052d52](https://github.com/headroomlabs-ai/headroom/commit/7052d52dcbb2fd97b756c9b60a096cdfeee32c94)) +* **proxy/openai:** don't record Codex WS savings without input accounting ([#2493](https://github.com/headroomlabs-ai/headroom/issues/2493)) ([2195ba7](https://github.com/headroomlabs-ai/headroom/commit/2195ba7d917649ba2ac647fdefa661cf598e3028)) +* **proxy/openai:** feed chat/completions traffic into the traffic learner ([#2333](https://github.com/headroomlabs-ai/headroom/issues/2333)) ([6cdfd3f](https://github.com/headroomlabs-ai/headroom/commit/6cdfd3f64d2f64d50ed47644126df71872a21050)) +* **proxy/openai:** None-guard usage token counts on the chat path ([#2431](https://github.com/headroomlabs-ai/headroom/issues/2431)) ([313c290](https://github.com/headroomlabs-ai/headroom/commit/313c290df96ca58a19ea0f79c67f5b71bb5f4d60)) +* **proxy/openai:** replay incremental events in buffered Responses SSE ([#2410](https://github.com/headroomlabs-ai/headroom/issues/2410)) ([#2415](https://github.com/headroomlabs-ai/headroom/issues/2415)) ([0cbc0e8](https://github.com/headroomlabs-ai/headroom/commit/0cbc0e8e5435cd8d743ae537cdbaa70787bfc5b4)) +* **proxy/output-shaping:** tolerate a non-string system block text in steering ([#2435](https://github.com/headroomlabs-ai/headroom/issues/2435)) ([3e97671](https://github.com/headroomlabs-ai/headroom/commit/3e976712e717a53ab6aea73120ae6ffacea74250)) +* **proxy/perf:** count turn-hook message folds in token accounting ([#2520](https://github.com/headroomlabs-ai/headroom/issues/2520)) ([c371d5a](https://github.com/headroomlabs-ai/headroom/commit/c371d5ad602f5ab93645b2db4673ae2c5e9f0575)) +* **proxy/perf:** tokenizer-consistent token accounting + surface tool-schema savings ([#2542](https://github.com/headroomlabs-ai/headroom/issues/2542)) ([1cc53c9](https://github.com/headroomlabs-ai/headroom/commit/1cc53c9c92cd4dffaf048dc806cb8c570bdb86b6)) +* **proxy/streaming:** tolerate malformed content in _response_to_sse ([#2481](https://github.com/headroomlabs-ai/headroom/issues/2481)) ([77b26c0](https://github.com/headroomlabs-ai/headroom/commit/77b26c093cfb7b5c71a46d5156cb774a2ae889b1)) +* **proxy:** keep buffered CCR streams alive ([#2479](https://github.com/headroomlabs-ai/headroom/issues/2479)) ([a2e42fb](https://github.com/headroomlabs-ai/headroom/commit/a2e42fb877642e7eacfcc77655183244823d969e)) +* **proxy:** keep core tools and the client's ToolSearch resident for PascalCase clients ([#2647](https://github.com/headroomlabs-ai/headroom/issues/2647)) ([1d29738](https://github.com/headroomlabs-ai/headroom/commit/1d29738818bb40e00847dba46e2f9acce773d3eb)) +* **proxy:** offload OpenAI and Gemini tokenizer counting off the event loop ([#2498](https://github.com/headroomlabs-ai/headroom/issues/2498)) ([806d2e4](https://github.com/headroomlabs-ai/headroom/commit/806d2e468ace012ebfa1a0907a679781b5004c72)) +* **proxy:** promote Kompress health after runtime load ([#2402](https://github.com/headroomlabs-ai/headroom/issues/2402)) ([54526bc](https://github.com/headroomlabs-ai/headroom/commit/54526bc8586cdeb248d6257dc497136a21b971c0)) +* **proxy:** reassemble server_tool_use.input from streamed partial_json ([#2449](https://github.com/headroomlabs-ai/headroom/issues/2449)) ([8c8fae0](https://github.com/headroomlabs-ai/headroom/commit/8c8fae0d0bca75f7f2561136910e40f716be57ab)) +* **proxy:** report deferred Kompress status and promote health from cache ([#2564](https://github.com/headroomlabs-ai/headroom/issues/2564)) ([d50cfab](https://github.com/headroomlabs-ai/headroom/commit/d50cfabedca2c4b7d83751adaa8aa7b317f13c7b)) +* **proxy:** skip max_tokens rename for backend-routed openai chat ([#2401](https://github.com/headroomlabs-ai/headroom/issues/2401)) ([d6a1af4](https://github.com/headroomlabs-ai/headroom/commit/d6a1af40d5a18f4440a45e342c2d05fee7a642e3)) +* **release:** publish Windows wheel + sdist (disable PyPI attestations, [#112](https://github.com/headroomlabs-ai/headroom/issues/112)) ([#2405](https://github.com/headroomlabs-ai/headroom/issues/2405)) ([f9cbdd6](https://github.com/headroomlabs-ai/headroom/commit/f9cbdd6e390714e037832f78c59d00907a26b612)) +* **release:** sync generated version metadata on the release branch ([#2659](https://github.com/headroomlabs-ai/headroom/issues/2659)) ([5383c6b](https://github.com/headroomlabs-ai/headroom/commit/5383c6bf2f5209ddfe33cb9bf1c36c0b2e431bcd)) +* **rust:** port CJK-aware relevance-query matching to CodeCompressor ([#2634](https://github.com/headroomlabs-ai/headroom/issues/2634)) ([e86c639](https://github.com/headroomlabs-ai/headroom/commit/e86c6390cec4fc0f932b006b36d5b924511a5b0b)) +* **security:** exclude compromised ast-grep-cli 0.44.1 (supply-chain trojan) ([#2342](https://github.com/headroomlabs-ai/headroom/issues/2342)) ([494fb5a](https://github.com/headroomlabs-ai/headroom/commit/494fb5a60e15ae1ce425f79f1432827b42923c73)) +* **tokenizers:** price Claude against a real BPE (tiktoken o200k) not a char estimate ([#2543](https://github.com/headroomlabs-ai/headroom/issues/2543)) ([285176b](https://github.com/headroomlabs-ai/headroom/commit/285176be54e1d179676dcf205de44d5893f8efa5)) +* **transforms/cross-turn-dedup:** don't renumber-fold zero-padded line prefixes ([#2369](https://github.com/headroomlabs-ai/headroom/issues/2369)) ([f4070c4](https://github.com/headroomlabs-ai/headroom/commit/f4070c44cbd65ecf49f2ae81ad26a95296ef552b)) +* **transforms/kompress-remote:** keep compress fail-open on malformed 200 ([#2320](https://github.com/headroomlabs-ai/headroom/issues/2320)) ([b759990](https://github.com/headroomlabs-ai/headroom/commit/b75999017fc060a4617077ef86c21ce3249d0842)) +* **wrap:** emit bare dotted keys for Codex --config overrides ([#2383](https://github.com/headroomlabs-ai/headroom/issues/2383)) ([f57e959](https://github.com/headroomlabs-ai/headroom/commit/f57e959a506f87f14143d595cae24a1fd6084f66)) +* **wrap:** make RTK opt-in (off by default) across wrap subcommands ([#2344](https://github.com/headroomlabs-ai/headroom/issues/2344)) ([44136ed](https://github.com/headroomlabs-ai/headroom/commit/44136ed0427edff338c5d7979b589f8540c9b967)) +* **wrap:** skip Serena project setup outside real project roots ([#2574](https://github.com/headroomlabs-ai/headroom/issues/2574)) ([0994ea0](https://github.com/headroomlabs-ai/headroom/commit/0994ea04c869939946b91cbe52ceaf46740786be)) +* **wrap:** stop same-port persistent routing during claude unwrap ([#2340](https://github.com/headroomlabs-ai/headroom/issues/2340)) ([#2350](https://github.com/headroomlabs-ai/headroom/issues/2350)) ([cf5fa64](https://github.com/headroomlabs-ai/headroom/commit/cf5fa644b6e019a3ea31b4f48509a63921055253)) + + +### Performance Improvements + +* **content_router:** dedupe content detection ([#2419](https://github.com/headroomlabs-ai/headroom/issues/2419)) ([9b016f2](https://github.com/headroomlabs-ai/headroom/commit/9b016f2b64cb50cd50ab68711ab2abdf7d74c8ec)) + + +### Dependencies + +* bump the cargo-minor-patch group with 10 updates ([#2284](https://github.com/headroomlabs-ai/headroom/issues/2284)) ([3266ed7](https://github.com/headroomlabs-ai/headroom/commit/3266ed7641cc92f5cae79b1befeb6bee7c96242e)) +* bump the npm-minor-patch group across 3 directories with 7 updates ([#2276](https://github.com/headroomlabs-ai/headroom/issues/2276)) ([961866b](https://github.com/headroomlabs-ai/headroom/commit/961866ba7c277b59ccdd51e784de9547a09198af)) + + +### Code Refactoring + +* **transforms:** dispatch simple built-in strategies via the compressor registry ([#2399](https://github.com/headroomlabs-ai/headroom/issues/2399)) ([fc9c63f](https://github.com/headroomlabs-ai/headroom/commit/fc9c63f18c1a8414b62ced8b2dd54ad1fe4d1c14)) +* **wrap:** retire tokensave; Serena is the code-memory MCP ([#2499](https://github.com/headroomlabs-ai/headroom/issues/2499)) ([5d23a0a](https://github.com/headroomlabs-ai/headroom/commit/5d23a0aec22dacdbd7bf221dafbb17bcf9f10c63)) + ## [0.32.0](https://github.com/headroomlabs-ai/headroom/compare/v0.31.0...v0.32.0) (2026-07-17) diff --git a/plugins/headroom-agent-hooks/.claude-plugin/plugin.json b/plugins/headroom-agent-hooks/.claude-plugin/plugin.json index a3c4e2bec..ae598339d 100644 --- a/plugins/headroom-agent-hooks/.claude-plugin/plugin.json +++ b/plugins/headroom-agent-hooks/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "headroom", - "version": "0.32.0", + "version": "0.33.0", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", "author": { "name": "Headroom Contributors", diff --git a/plugins/headroom-agent-hooks/.github/plugin/plugin.json b/plugins/headroom-agent-hooks/.github/plugin/plugin.json index ff7f9b2ce..fd8f661f4 100644 --- a/plugins/headroom-agent-hooks/.github/plugin/plugin.json +++ b/plugins/headroom-agent-hooks/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "headroom", - "version": "0.32.0", + "version": "0.33.0", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", "author": { "name": "Headroom Contributors", diff --git a/plugins/openclaw/package.json b/plugins/openclaw/package.json index e87bec054..b9366d63d 100644 --- a/plugins/openclaw/package.json +++ b/plugins/openclaw/package.json @@ -1,6 +1,6 @@ { "name": "headroom-openclaw", - "version": "0.32.0", + "version": "0.33.0", "description": "Headroom context compression plugin for OpenClaw — 70-90% token savings with zero LLM calls", "type": "module", "main": "./dist/index.js", diff --git a/pyproject.toml b/pyproject.toml index 6e1b1ba48..1867f4739 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "headroom-ai" -version = "0.32.0" +version = "0.33.0" description = "The Context Optimization Layer for LLM Applications - Cut costs by 50-90%" readme = "README.md" license = "Apache-2.0" diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 71e9818f2..7f441a7b9 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -1,6 +1,6 @@ { "name": "headroom-ai", - "version": "0.32.0", + "version": "0.33.0", "description": "Compress LLM context. Save tokens. Fit more into every request.", "type": "module", "main": "./dist/index.cjs", diff --git a/server.json b/server.json index 4817edce8..75d3288d5 100644 --- a/server.json +++ b/server.json @@ -9,13 +9,13 @@ "source": "github", "id": "1129940957" }, - "version": "0.32.0", + "version": "0.33.0", "packages": [ { "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "headroom-ai", - "version": "0.32.0", + "version": "0.33.0", "runtimeHint": "uvx", "runtimeArguments": [ { From 759209cff3daa72dd9d47e57568e731d10573d63 Mon Sep 17 00:00:00 2001 From: Tejas Chopra Date: Thu, 30 Jul 2026 20:55:47 -0700 Subject: [PATCH 012/215] fix(wrap/serena): stop creating serena_config.yml, unbricking Serena on fresh installs (#2676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `_ensure_serena_dashboard_disabled()` wrote a one-key bootstrap config (`web_dashboard_open_on_launch: false`) into `~/.serena/serena_config.yml` when the file was absent, assuming Serena fills in any key it omits. Verified against **Serena 1.6.2.dev0** (`serena/config/serena_config.py`), that holds for every field except one. Serena autogenerates its own complete config **only when the path does not exist**: ```python if not os.path.exists(config_file_path): cls._generate_config_file(config_file_path) ``` Once any file is present it validates instead. Every other field falls back to a dataclass default via `get_value_or_default`, but a missing `projects` key is fatal (~line 1064): ``` SerenaConfigError: `projects` key not found in Serena configuration. ``` So Headroom's own bootstrap file killed Serena on **every machine without a pre-existing Serena config**. The MCP server exited during handshake — surfacing as `connection closed: initialize response` on Codex and a bare `MCP error -32000: Connection closed` on OpenCode (#2674) — and `serena project index` failed identically. Headroom now leaves that file to Serena. That is immune to Serena adding required keys later; guessing the schema is what caused the outage. The popup never needed the file anyway: `build_serena_spec` passes `--open-web-dashboard False`, which Serena applies *after* loading the config (`serena/mcp.py:361` — `config.web_dashboard_open_on_launch = open_web_dashboard`), so the flag wins regardless of what is on disk. An **existing** config is still edited in place — dashboard key flipped, `projects: []` backfilled to repair machines an affected version already wrote — preserving a populated `projects` list, other keys and comments. Closes #2674 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made - `_ensure_serena_dashboard_disabled()` never creates `serena_config.yml`; it only edits an existing one, and backfills `projects: []` there to repair already-broken machines. - Dropped `_scope_serena_languages` + `_detect_repo_languages` + `_EXT_TO_SERENA_LANGUAGE` (**−133 lines**). Dead weight: Serena determines languages itself in `ProjectConfig.autogenerate` (`_determine_project_language_servers`) and records them under `language_servers` — `languages`, which Headroom wrote, is a legacy name Serena migrates via `RENAMED_FIELDS`. Serena's generated file uses a block-style list, so our single-line-flow regex never matched it: on any Serena-generated `project.yml` the function was a **verified no-op**. The only case where it acted was creating the file — the same partial-config trap — which also skipped the `project.local.yml` sidecar Serena writes alongside. - **Test isolation:** the MCP install ledger defaults to `~/.headroom/mcp_installs.json`, so any test registering a server wrote into the developer's real ledger (observed adding a live `claude/serena` entry during a local run). `conftest.py` now redirects it per-test. - **Repo config:** `.serena/project.yml` carried a stale `project_name` (`"feature-opencode-wrap"`) and listed only `typescript`, so Serena's symbol index skipped 1331 Python and 194 Rust files for every contributor. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_wrap_code_memory.py tests/test_cli/test_wrap_serena_boost.py \ tests/test_cli/test_serena_migrate.py tests/test_cli/test_serena_disable.py -q 35 passed, 1 skipped in 0.84s $ SERENA_SRC= pytest tests/test_wrap_code_memory.py -q 13 passed in 0.62s # the skipped test runs when a Serena source tree is available $ ruff check headroom/ tests/ --exclude headroom/dashboard/templates All checks passed! $ mypy headroom/cli/wrap.py Success: no issues found in 1 source file ``` New tests. The key one asserts the invariant rather than our own key list, so it stays correct even if Serena adds a required key — a test pinning `projects: []` would keep passing while users broke again: - `test_serena_config_is_never_created_by_headroom` — Headroom must not pre-empt Serena's bootstrap - `test_serena_dashboard_disabled_repairs_config_missing_projects` — heals a config an affected version wrote - `test_serena_dashboard_disabled_preserves_registered_projects` — never clobbers the real registry; comments kept, no duplicate key - `test_serena_dashboard_disabled_is_idempotent` - `test_serena_config_required_keys_match_serena_source` — reads Serena's real source and pins the two facts this fix rests on (bootstrap-only-when-absent, `projects` is the sole fatal omission). Skipped unless `SERENA_SRC` is set; deliberately **not** named `HEADROOM_*` because `conftest.py` scrubs that namespace, which would make it silently always-skip. ## Real Behavior Proof - **Environment:** macOS 15.4 (darwin 25.4.0), Python 3.12.6, Serena 1.6.2.dev0 via `uvx --from git+https://github.com/oraios/serena`, Codex CLI 0.146.0. - **Exact command / steps:** a probe doing a real JSON-RPC `initialize` handshake against the exact command `headroom wrap` registers — i.e. what Codex/OpenCode actually do — in a throwaway `HOME` per case. (A) pre-seeded with the one-line config an affected version wrote; (B) no config; (C) real `headroom wrap codex --prepare-only`, then handshake. - **Observed result:** ```text === A. BROKEN: single-key config (Headroom 0.33.0) === MCP handshake: FAIL — no initialize response (exit=1). stderr tail: File ".../serena/config/serena_config.py", line 1064, in from_config_file raise SerenaConfigError("`projects` key not found in Serena configuration. ...") serena.config.serena_config.SerenaConfigError: `projects` key not found ... config after run: 1 lines, has 'projects': False === B. FIXED: no config, Serena bootstraps it === MCP handshake: PASS — initialize OK — serverInfo.name='Serena' config after run: 213 lines, has 'projects': True === C. FULL FLOW: real `headroom wrap codex` then handshake === Serena: no serena_config.yml yet — letting Serena generate it Serena MCP: registered (restart OpenAI Codex CLI if it was already running) Serena: project pre-indexed (symbol cache warmed) serena_config.yml: 213 lines, written by Serena (correct) MCP handshake: PASS — initialize OK — serverInfo.name='Serena' --- verdict --- A (broken config) started: False <- expected False B (fixed, no config) started: True <- expected True C (after real wrap) started: True <- expected True ``` A second `wrap` in the same HOME flips the dashboard without damage: `true` → `false`, `projects` intact, all 153 comment lines intact. The writer was isolated against a pristine 213-line Serena config: **delta 0 newlines**. - **Not tested:** Windows and Linux (macOS only); Serena versions other than 1.6.2.dev0; the JetBrains language backend. The probe needs network + `uvx` (~2 min) so it is a manual verification tool, not wired into CI. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Additional Notes - **Docs:** N/A — no user-facing docs described the `serena_config.yml` bootstrap or the language scoping. - Also fixes the OpenCode report (#2674). The Codex-side report of the same root cause quotes the `SerenaConfigError` verbatim; OpenCode only surfaces the generic `-32000`, which is why it read as two different bugs. - Users already broken by an affected version are repaired automatically on their next `headroom wrap` — no manual `serena_config.yml` edit needed. - A stacked PR removing the rtk/lean-ctx CLI context tools is based on this branch; this one is deliberately small so it can land first. --- .serena/project.yml | 132 ++++++++----- headroom/cli/wrap.py | 239 ++++++++--------------- tests/conftest.py | 24 +++ tests/test_cli/test_serena_migrate.py | 1 - tests/test_cli/test_wrap_serena_boost.py | 109 +---------- tests/test_wrap_code_memory.py | 100 +++++++++- 6 files changed, 285 insertions(+), 320 deletions(-) diff --git a/.serena/project.yml b/.serena/project.yml index 0e9ac3ead..bc005c5fc 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,38 +1,5 @@ -# the name by which the project can be referenced within Serena -project_name: "feature-opencode-wrap" - - -# list of languages for which language servers are started; choose from: -# al angular ansible bash clojure -# cpp cpp_ccls crystal csharp csharp_omnisharp -# dart elixir elm erlang fortran -# fsharp go groovy haskell haxe -# hlsl html java json julia -# kotlin lean4 lua luau markdown -# matlab msl nix ocaml pascal -# perl php php_phpactor powershell python -# python_jedi python_ty r rego ruby -# ruby_solargraph rust scala scss solidity -# svelte swift systemverilog terraform toml -# typescript typescript_vts vue yaml zig -# (This list may be outdated. For the current list, see values of Language enum here: -# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py -# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) -# Note: -# - For C, use cpp -# - For JavaScript, use typescript -# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) -# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) -# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) -# - For Free Pascal/Lazarus, use pascal -# Special requirements: -# Some languages require additional setup/installations. -# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers -# When using multiple languages, the first language server that supports a given file will be used for that file. -# The first language is the default language and the respective language server will be used as a fallback. -# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. -languages: -- typescript +# the name by which the project can be referenced within Serena/when chatting with the LLM. +project_name: "headroom" # the encoding used by text files in the project # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings @@ -55,23 +22,19 @@ ignore_all_files_in_gitignore: true # advanced configuration option allowing to configure language server-specific options. # Maps the language key to the options. -# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. -# No documentation on options means no options are available. +# The settings are considered only if the project is trusted (see global configuration to define trusted projects). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings ls_specific_settings: {} -# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). -# Paths can be absolute or relative to the project root. -# Each folder is registered as an LSP workspace folder, enabling language servers to discover -# symbols and references across package boundaries. -# Currently supported for: TypeScript. -# Example: -# additional_workspace_folders: -# - ../sibling-package -# - ../shared-lib -additional_workspace_folders: [] - # list of additional paths to ignore in this project. # Same syntax as gitignore, so you can use * and **. +# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases. +# Example: +# ignored_paths: +# - "examples/**" +# - ".worktrees/**" +# - "**/bin/**" +# - "**/obj/**" # Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] @@ -131,3 +94,76 @@ read_only_memory_patterns: [] # Extends the list from the global configuration, merging the two lists. # Example: ["_archive/.*", "_episodes/.*"] ignored_memory_patterns: [] + +# list of additional workspace folder paths for cross-package reference support. +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries, but these folders are not indexed by Serena, +# i.e. the respective symbols will not be found using Serena's symbol search tools. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +ls_additional_workspace_folders: [] + +# list of language servers to start when using the LSP backend; choose from: +# ada al angular ansible bash +# bsl clojure cpp cpp_ccls crystal +# csharp csharp_omnisharp cue dart elixir +# elm erlang fortran fsharp gdscript +# go groovy haskell haxe hlsl +# html java json julia kotlin +# latex lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor php_phpantom powershell +# python python_basedpyright python_jedi python_pyrefly python_ty +# qml r rego ruby ruby_solargraph +# rust scala scss solidity svelte +# swift systemverilog terraform toml typescript +# typescript_vts vue yaml zig +# (This list may be outdated; generated with scripts/print_language_list.py; +# For the current list, see values of the LanguageServerId enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some language servers require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple language servers, the first language server that supports a given file will be used for that file. +# The first language server is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +language_servers: +- python +- rust +- typescript + +# list of workspace folder paths (LSP backend only). +# These folders will be used to build up Serena's symbol index. +# Paths must be within the project root and should thus be relative to the project root. +# Furthermore, the paths should not be filtered by ignore settings. +# Default setting: The entire project root folder (".") is considered. +# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. +# ls_workspace_folders: +# - "./subproject1" +# - "./subproject2" +ls_workspace_folders: +- . + +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180.0 diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 9262e82aa..0afd8ec65 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -1607,38 +1607,79 @@ def _ensure_serena_dashboard_disabled(*, verbose: bool = False) -> None: """Disable Serena's browser dashboard auto-open in ``~/.serena/serena_config.yml``. Serena opens its web dashboard in a browser tab on launch by default - (``web_dashboard_open_on_launch: true``). Since Headroom now registers Serena - as the default code-memory MCP, flip that setting off so wrapped sessions - don't spawn a browser tab. The dashboard backend still runs and stays - reachable at http://localhost:24282/dashboard/. The setting lives in Serena's - own config (authoritative, unlike a startup flag); other keys and comments are - preserved via a targeted line edit rather than a YAML rewrite. + (``web_dashboard_open_on_launch: true``), so flip that off for users who run + Serena outside Headroom. The dashboard backend still runs and stays reachable + at http://localhost:24282/dashboard/. Other keys and comments are preserved + via a targeted line edit rather than a YAML rewrite. + + **Never creates the file.** Verified against Serena 1.6.2.dev0 + (``serena/config/serena_config.py``): Serena autogenerates its own complete + config only when the path does *not* exist (``if not + os.path.exists(config_file_path): cls._generate_config_file(...)``, ~line + 1033). Once any file exists it validates instead of filling gaps, and while + every other field falls back to a dataclass default via + ``get_value_or_default``, a missing ``projects`` key is fatal (~line 1064): + + SerenaConfigError: `projects` key not found in Serena configuration. + + So Headroom writing its own bootstrap file bricked Serena on every machine + without a pre-existing config — the MCP server died mid-handshake ("connection + closed: initialize response" on Codex, bare ``MCP error -32000`` on OpenCode) + and ``serena project index`` failed identically (#2674). Letting Serena + generate the file is immune to Serena adding required keys later; guessing the + schema is what caused the outage. + + Suppressing the popup does not need this file anyway: ``build_serena_spec`` + passes ``--open-web-dashboard False``, which Serena applies *after* loading + the config (``serena/mcp.py:361`` — ``config.web_dashboard_open_on_launch = + open_web_dashboard``), so the flag wins regardless of what is on disk. + + ``projects: []`` is still backfilled into an *existing* file, to repair + configs an affected Headroom version already wrote. """ import re cfg = Path.home() / ".serena" / "serena_config.yml" key = "web_dashboard_open_on_launch" + if not cfg.exists(): + # Let Serena bootstrap its own valid config; the MCP flag handles the popup. + if verbose: + click.echo(" Serena: no serena_config.yml yet — letting Serena generate it") + return try: - if cfg.exists(): - text = cfg.read_text(encoding="utf-8") - pattern = re.compile(rf"^(\s*){re.escape(key)}:\s*\S+\s*$", re.MULTILINE) - if pattern.search(text): - new = pattern.sub(rf"\g<1>{key}: false", text) - else: - new = text.rstrip("\n") + f"\n{key}: false\n" - if new != text: - cfg.write_text(new, encoding="utf-8") - if verbose: - click.echo(" Serena: disabled dashboard browser auto-open (serena_config.yml)") - else: - cfg.parent.mkdir(parents=True, exist_ok=True) - # Serena fills defaults for any keys we omit, so a single-key file is valid. - cfg.write_text(f"{key}: false\n", encoding="utf-8") - if verbose: - click.echo(" Serena: created serena_config.yml with dashboard auto-open off") + text = cfg.read_text(encoding="utf-8") + except OSError as e: + if verbose: + click.echo(f" Serena: could not read serena_config.yml ({e})") + return + + new = text + appended: list[str] = [] + + dashboard = re.compile(rf"^(\s*){re.escape(key)}:\s*\S+\s*$", re.MULTILINE) + if dashboard.search(new): + new = dashboard.sub(rf"\g<1>{key}: false", new) + else: + appended.append(f"{key}: false") + + # Repair a config left by an affected Headroom version (see #2674 above). + if not re.search(r"^\s*projects\s*:", new, re.MULTILINE): + appended.append("projects: []") + + if appended: + body = new.rstrip("\n") + new = (f"{body}\n" if body.strip() else "") + "\n".join(appended) + "\n" + + if new == text: + return + try: + cfg.write_text(new, encoding="utf-8") except OSError as e: if verbose: click.echo(f" Serena: could not update serena_config.yml ({e})") + return + if verbose: + click.echo(" Serena: updated serena_config.yml (dashboard auto-open off)") # Marker-fenced guidance steering the agent toward Serena's symbol tools. @@ -1668,73 +1709,6 @@ symbol view does not answer the question. """ -# Ext → Serena language key. Values match the ``Language`` enum in Serena's -# solidlsp ``ls_config`` (the same keys accepted by ``.serena/project.yml``'s -# ``languages`` list). Only real programming languages are mapped — data/markup -# formats (json/yaml/toml/md/html/css) are intentionally skipped so Serena does -# not spin up language servers that add no symbol-navigation value. -_EXT_TO_SERENA_LANGUAGE: dict[str, str] = { - ".py": "python", - ".pyi": "python", - ".ts": "typescript", - ".tsx": "typescript", - ".mts": "typescript", - ".cts": "typescript", - ".js": "typescript", - ".jsx": "typescript", - ".mjs": "typescript", - ".cjs": "typescript", - ".go": "go", - ".rs": "rust", - ".java": "java", - ".kt": "kotlin", - ".kts": "kotlin", - ".rb": "ruby", - ".erb": "ruby", - ".cs": "csharp", - ".cpp": "cpp", - ".cc": "cpp", - ".cxx": "cpp", - ".c++": "cpp", - ".hpp": "cpp", - ".hh": "cpp", - ".hxx": "cpp", - ".c": "cpp", - ".h": "cpp", - ".php": "php", - ".swift": "swift", - ".dart": "dart", - ".scala": "scala", - ".sbt": "scala", - ".sh": "bash", - ".bash": "bash", - ".lua": "lua", - ".r": "r", - ".pl": "perl", - ".pm": "perl", - ".ex": "elixir", - ".exs": "elixir", - ".clj": "clojure", - ".cljs": "clojure", - ".cljc": "clojure", - ".elm": "elm", - ".tf": "terraform", - ".tfvars": "terraform", - ".zig": "zig", - ".nix": "nix", - ".hs": "haskell", - ".jl": "julia", - ".sol": "solidity", - ".vue": "vue", - ".svelte": "svelte", -} - -# Directories never worth scanning for language detection (VCS, dependencies, -# build output, virtualenvs, caches). Pruned in-place during the walk. -_LANG_SCAN_IGNORE_DIRS = frozenset( - {".git", "node_modules", ".venv", "venv", "dist", "build", "__pycache__"} -) - def _serena_instruction_file(registrar: Any) -> Path: """Resolve the project instruction file the agent reads for guidance. @@ -1775,71 +1749,6 @@ def _inject_serena_instructions(file_path: Path, verbose: bool = False) -> bool: return True -def _detect_repo_languages(root: Path) -> list[str]: - """Detect the Serena languages present under *root* by file extension. - - Returns the mapped Serena language keys ordered by file count (most common - first — Serena treats the first entry as the default/fallback language - server), with ties broken alphabetically for determinism. Dependency, - build, VCS, and cache directories are pruned from the walk. - """ - counts: dict[str, int] = {} - for _dirpath, dirnames, filenames in os.walk(root): - dirnames[:] = [d for d in dirnames if d not in _LANG_SCAN_IGNORE_DIRS] - for filename in filenames: - lang = _EXT_TO_SERENA_LANGUAGE.get(Path(filename).suffix.lower()) - if lang is not None: - counts[lang] = counts.get(lang, 0) + 1 - return [lang for lang, _ in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))] - - -def _scope_serena_languages(*, verbose: bool = False) -> None: - """Pin the repo's languages into ``.serena/project.yml`` (best-effort). - - Scoping the LSP to the languages actually present keeps Serena from - starting unnecessary language servers. Runs before indexing so - ``serena project index`` respects the scope. Writes the ``languages`` key as - a YAML flow list (the format Serena's own project template uses) via a - targeted line edit — mirroring :func:`_ensure_serena_dashboard_disabled` — - and creates a minimal ``project.yml`` (``project_name`` + ``languages``, the - only fields Serena requires) when absent. An existing block-style or - otherwise unexpected ``languages`` entry is left untouched rather than risk - corrupting the file. Non-fatal on any I/O error. - """ - languages = _detect_repo_languages(Path.cwd()) - if not languages: - if verbose: - click.echo(" Serena: no recognized source languages detected — leaving scope unset") - return - - cfg = Path.cwd() / ".serena" / "project.yml" - value = "[" + ", ".join(f'"{lang}"' for lang in languages) + "]" - try: - if cfg.exists(): - text = _read_text(cfg) - # Match only a single-line flow list (the format we and Serena write). - pattern = re.compile(r"^(\s*)languages:\s*\[[^\]\n]*\]\s*$", re.MULTILINE) - if pattern.search(text): - new = pattern.sub(rf"\g<1>languages: {value}", text, count=1) - if new != text: - _write_text(cfg, new) - if verbose: - click.echo(f" Serena: scoped languages to {value} (project.yml)") - elif verbose: - click.echo( - " Serena: project.yml has a custom languages entry — leaving it untouched" - ) - else: - cfg.parent.mkdir(parents=True, exist_ok=True) - project_name = Path.cwd().name or "project" - _write_text(cfg, f'project_name: "{project_name}"\nlanguages: {value}\n') - if verbose: - click.echo(f" Serena: created project.yml scoped to {value}") - except OSError as e: - if verbose: - click.echo(f" Serena: could not scope languages ({e})") - - def _serena_project_skip_reason(root: Path) -> str | None: """Why Serena's per-project setup must not run for *root* (None = proceed). @@ -1963,17 +1872,25 @@ def _setup_serena_mcp( click.echo(line) # Serena is the active engine here (we passed the detect/uvx guards): steer - # the agent toward symbol-level tools, scope the LSP to the repo's - # languages, then warm the symbol cache. Scoping runs before indexing so - # ``serena project index`` respects the scope. Each step is best-effort and - # non-fatal — none of them block the wrap. + # the agent toward symbol-level tools, then warm the symbol cache. Both are + # best-effort and non-fatal — neither blocks the wrap. + # + # Headroom no longer writes ``.serena/project.yml`` language scoping. Serena + # determines the project's languages itself during + # ``ProjectConfig.autogenerate`` (``_determine_project_language_servers``), + # and it records them under ``language_servers`` — ``languages`` is a legacy + # name it migrates via ``RENAMED_FIELDS``. Our scoping therefore no-op'd on + # any Serena-generated project.yml (wrong key, block-style list) and only did + # anything when it created the file itself, which is the same partial-config + # trap as #2674 — and skipped the ``project.local.yml`` sidecar Serena writes + # alongside. Letting Serena own that file removes a hand-maintained ext→ + # language map that duplicated its detection. _inject_serena_instructions(_serena_instruction_file(registrar), verbose=verbose) skip_reason = _serena_project_skip_reason(Path.cwd()) if skip_reason is not None: if verbose: - click.echo(f" Serena: skipping language scope + pre-index ({skip_reason})") + click.echo(f" Serena: skipping pre-index ({skip_reason})") return - _scope_serena_languages(verbose=verbose) _index_serena_project(verbose=verbose) diff --git a/tests/conftest.py b/tests/conftest.py index a9ccee755..38de51499 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,6 +30,30 @@ def _scrub_developer_headroom_env(monkeypatch): monkeypatch.delenv("ANTHROPIC_CUSTOM_HEADERS", raising=False) +# The MCP install ledger defaults to ``~/.headroom/mcp_installs.json``, so any +# test that registers a server (directly or through `wrap`) writes into the +# developer's REAL ledger — observed adding a live `claude/serena` entry during a +# local run. Since the scrub above deletes HEADROOM_WORKSPACE_DIR, the default is +# always the real home. Redirect the ledger per-test instead: every writer +# (`record_install` / `clear_install` / `headroom_installed_matching`) resolves it +# through this module-global, so one patch covers them all. Patched here rather +# than pointing workspace_dir() at a tmp path, which would break the tests that +# assert the default workspace layout. +@pytest.fixture(autouse=True) +def _isolate_mcp_ledger(monkeypatch, tmp_path_factory): + # Same guard as _reset_copilot_routing_flag below: the macos/windows-native- + # wrapper CI jobs install only pytest and drive the installer shell scripts + # via subprocess, so headroom isn't importable and there is no ledger to + # redirect. Skip there instead of erroring at setup. + try: + from headroom.mcp_registry import ledger + except ModuleNotFoundError: + return + + ledger_file = tmp_path_factory.mktemp("mcp-ledger") / "mcp_installs.json" + monkeypatch.setattr(ledger, "ledger_path", lambda: ledger_file) + + # The Copilot "routed to Copilot" flag is a module-global ContextVar that # build_copilot_upstream_url() sets as a side effect. Unit tests that call that # builder directly (or otherwise run in the shared root context) would leave it diff --git a/tests/test_cli/test_serena_migrate.py b/tests/test_cli/test_serena_migrate.py index 947930778..7fe1172ef 100644 --- a/tests/test_cli/test_serena_migrate.py +++ b/tests/test_cli/test_serena_migrate.py @@ -87,7 +87,6 @@ def _isolate_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # real ``uvx`` — neutralise them so these registration-focused tests stay # hermetic (covered directly in test_wrap_serena_boost.py). monkeypatch.setattr(wrap_cli, "_inject_serena_instructions", lambda *a, **k: True) - monkeypatch.setattr(wrap_cli, "_scope_serena_languages", lambda *a, **k: None) monkeypatch.setattr(wrap_cli, "_index_serena_project", lambda *a, **k: None) diff --git a/tests/test_cli/test_wrap_serena_boost.py b/tests/test_cli/test_wrap_serena_boost.py index c5ca1d9c7..8c63c5782 100644 --- a/tests/test_cli/test_wrap_serena_boost.py +++ b/tests/test_cli/test_wrap_serena_boost.py @@ -23,8 +23,8 @@ def _opt_in(monkeypatch: pytest.MonkeyPatch) -> None: """Enable the opt-in gate so injection actually writes. Instruction injection rewrites the user's CLAUDE.md/AGENTS.md, so it is - off by default (mirrors RTK). Tests that exercise the write path must opt - in via ``HEADROOM_SERENA_INSTRUCTIONS``. + off by default (mirrors RTK). Tests that exercise the write path must opt in via + ``HEADROOM_SERENA_INSTRUCTIONS``. """ monkeypatch.setenv("HEADROOM_SERENA_INSTRUCTIONS", "1") @@ -94,111 +94,6 @@ def test_instruction_file_target_per_agent(tmp_path: Path, monkeypatch: pytest.M assert wrap_cli._serena_instruction_file(_Reg("grok")).name == "AGENTS.md" -# --------------------------------------------------------------------------- -# _detect_repo_languages -# --------------------------------------------------------------------------- - - -def test_detect_maps_extensions_to_serena_languages(tmp_path: Path) -> None: - (tmp_path / "app.py").write_text("print(1)\n") - (tmp_path / "web.ts").write_text("export const x = 1\n") - (tmp_path / "main.go").write_text("package main\n") - - assert set(wrap_cli._detect_repo_languages(tmp_path)) == {"python", "typescript", "go"} - - -def test_detect_ignores_deps_and_venv(tmp_path: Path) -> None: - (tmp_path / "app.py").write_text("print(1)\n") - # Languages that appear ONLY inside ignored dirs must not be reported. - (tmp_path / "node_modules").mkdir() - (tmp_path / "node_modules" / "dep.rs").write_text("fn main() {}\n") - (tmp_path / ".venv").mkdir() - (tmp_path / ".venv" / "lib.rb").write_text("puts 1\n") - - detected = set(wrap_cli._detect_repo_languages(tmp_path)) - assert detected == {"python"} - assert "rust" not in detected - assert "ruby" not in detected - - -def test_detect_orders_by_file_count(tmp_path: Path) -> None: - for i in range(3): - (tmp_path / f"m{i}.py").write_text("x = 1\n") - (tmp_path / "main.go").write_text("package main\n") - - ordered = wrap_cli._detect_repo_languages(tmp_path) - assert ordered[0] == "python" # most files → default/fallback language first - - -def test_detect_empty_when_no_source(tmp_path: Path) -> None: - (tmp_path / "README.md").write_text("# hi\n") # markup, not mapped - assert wrap_cli._detect_repo_languages(tmp_path) == [] - - -# --------------------------------------------------------------------------- -# _scope_serena_languages — pins languages into .serena/project.yml -# --------------------------------------------------------------------------- - - -def test_scope_creates_project_yml_when_absent( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.chdir(tmp_path) - (tmp_path / "app.py").write_text("print(1)\n") - - wrap_cli._scope_serena_languages() - - cfg = tmp_path / ".serena" / "project.yml" - assert cfg.exists() - text = cfg.read_text() - assert 'languages: ["python"]' in text - assert "project_name:" in text # required field written too - - -def test_scope_updates_existing_inline_languages( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.chdir(tmp_path) - (tmp_path / "app.py").write_text("print(1)\n") - (tmp_path / "main.go").write_text("package main\n") - cfg = tmp_path / ".serena" / "project.yml" - cfg.parent.mkdir(parents=True) - cfg.write_text('project_name: "demo"\nlanguages: ["python"]\nencoding: "utf-8"\n') - - wrap_cli._scope_serena_languages() - - text = cfg.read_text() - # go + python (one file each → alphabetical tie-break), inline flow list. - assert 'languages: ["go", "python"]' in text - assert 'encoding: "utf-8"' in text # other keys preserved - - -def test_scope_leaves_block_style_untouched( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.chdir(tmp_path) - (tmp_path / "app.py").write_text("print(1)\n") - cfg = tmp_path / ".serena" / "project.yml" - cfg.parent.mkdir(parents=True) - original = 'project_name: "demo"\nlanguages:\n- typescript\n' - cfg.write_text(original) - - wrap_cli._scope_serena_languages() - - # Block-style list is not something our single-line edit can safely touch, - # so it is left exactly as-is rather than corrupted. - assert cfg.read_text() == original - - -def test_scope_noop_when_no_languages(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.chdir(tmp_path) - (tmp_path / "README.md").write_text("# hi\n") - - wrap_cli._scope_serena_languages() - - assert not (tmp_path / ".serena" / "project.yml").exists() - - # --------------------------------------------------------------------------- # _index_serena_project — best-effort, timeout-guarded pre-index # --------------------------------------------------------------------------- diff --git a/tests/test_wrap_code_memory.py b/tests/test_wrap_code_memory.py index 40cf9e2f6..9e2574681 100644 --- a/tests/test_wrap_code_memory.py +++ b/tests/test_wrap_code_memory.py @@ -12,6 +12,7 @@ import os from unittest.mock import patch import click +import pytest from click.testing import CliRunner from headroom.cli import wrap @@ -64,12 +65,105 @@ def test_serena_dashboard_disabled_flips_existing_config(tmp_path, monkeypatch) assert "web_dashboard: true" in text # other keys preserved -def test_serena_dashboard_disabled_creates_config(tmp_path, monkeypatch) -> None: +def test_serena_config_is_never_created_by_headroom(tmp_path, monkeypatch) -> None: + """Headroom must NOT pre-empt Serena's own config bootstrap (#2674). + + This is the exact invariant, and it is the reason the outage happened. + Verified against Serena 1.6.2.dev0 ``serena/config/serena_config.py``: Serena + autogenerates a complete config only when the path does not exist; once any + file is there it validates instead, and a missing ``projects`` key is fatal + (``SerenaConfigError``). Headroom used to write a one-key bootstrap file, + which killed Serena's MCP handshake on every fresh install. + + Asserting "we write nothing" is stronger than asserting which keys we write: + it stays correct even if Serena adds a new required key, whereas a test that + pins our own key list would go on passing while users broke again. + """ monkeypatch.setenv("HOME", str(tmp_path)) + wrap._ensure_serena_dashboard_disabled() + cfg = tmp_path / ".serena" / "serena_config.yml" - assert cfg.exists() - assert "web_dashboard_open_on_launch: false" in cfg.read_text() + assert not cfg.exists(), "Headroom created a config Serena would have generated itself" + + +def test_serena_dashboard_disabled_repairs_config_missing_projects(tmp_path, monkeypatch) -> None: + """Backfill ``projects`` into a config an older Headroom already wrote (#2674). + + Users who ran an affected version have the single-key file on disk, so simply + not creating new bad files would leave them broken forever. + """ + import yaml + + monkeypatch.setenv("HOME", str(tmp_path)) + cfg = tmp_path / ".serena" / "serena_config.yml" + cfg.parent.mkdir(parents=True) + cfg.write_text("web_dashboard_open_on_launch: false\n") + + wrap._ensure_serena_dashboard_disabled() + + parsed = yaml.safe_load(cfg.read_text()) + assert parsed["projects"] == [] + assert parsed["web_dashboard_open_on_launch"] is False + + +def test_serena_dashboard_disabled_preserves_registered_projects(tmp_path, monkeypatch) -> None: + """Never clobber Serena's real project registry — it is user data.""" + import yaml + + monkeypatch.setenv("HOME", str(tmp_path)) + cfg = tmp_path / ".serena" / "serena_config.yml" + cfg.parent.mkdir(parents=True) + cfg.write_text( + "# my serena config\nprojects:\n - /home/me/work/api\n - /home/me/work/web\n" + "web_dashboard_open_on_launch: true\n" + ) + + wrap._ensure_serena_dashboard_disabled() + + text = cfg.read_text() + parsed = yaml.safe_load(text) + assert parsed["projects"] == ["/home/me/work/api", "/home/me/work/web"] + assert parsed["web_dashboard_open_on_launch"] is False + assert "# my serena config" in text # comments preserved + assert text.count("projects:") == 1 # no duplicate key + + +def test_serena_dashboard_disabled_is_idempotent(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + cfg = tmp_path / ".serena" / "serena_config.yml" + cfg.parent.mkdir(parents=True) + cfg.write_text("projects: []\nweb_dashboard_open_on_launch: true\n") + wrap._ensure_serena_dashboard_disabled() + first = cfg.read_text() + wrap._ensure_serena_dashboard_disabled() + assert cfg.read_text() == first + + +def test_serena_config_required_keys_match_serena_source() -> None: + """Pin the assumption this fix rests on, against Serena's real source (#2674). + + Skipped unless a Serena checkout is present. When it is, this proves the claim + the fix depends on — that ``projects`` is the *only* hard-required key and that + Serena bootstraps only a missing file — rather than trusting a bug report. + Set ``SERENA_SRC`` to a Serena source tree to enable it. + """ + import os + import re + from pathlib import Path + + src = os.environ.get("SERENA_SRC", "") + if not src or not (Path(src) / "config" / "serena_config.py").is_file(): + pytest.skip("set SERENA_SRC to a Serena source tree to run this check") + + text = (Path(src) / "config" / "serena_config.py").read_text(encoding="utf-8") + # Serena bootstraps only when the file is absent — so we must not create one. + assert re.search(r"if not os\.path\.exists\(config_file_path\)", text) + # `projects` is the sole fatal omission; everything else has a default. + fatal = re.findall(r"raise SerenaConfigError\((.*?)\)", text, re.DOTALL) + projects_fatal = [f for f in fatal if "projects" in f] + assert projects_fatal, "Serena no longer rejects a missing `projects` key" + assert "get_value_or_default" in text, "Serena's default-filling path changed" def test_invalid_env_raises() -> None: From e0ce4b1d4817e1b352e68e8b316273d863260ba7 Mon Sep 17 00:00:00 2001 From: Tejas Chopra Date: Thu, 30 Jul 2026 22:59:41 -0700 Subject: [PATCH 013/215] fix: remove rtk and lean-ctx CLI context tools (#2677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Removes both third-party CLI context tools — **rtk** and **lean-ctx** — and with them the context-tool selector itself. Headroom no longer downloads, installs or configures either one, and there is no replacement. The previous pass (#2344) gated only three entry points inside `headroom/cli/wrap.py`. That left the feature reachable in practice: | Gap | Effect | |---|---| | `scripts/install.sh:1544`, `install.ps1:1681` | Ran `rtk init --global --auto-patch` from bash/PowerShell, **bypassing the Python gate entirely** — `curl \| sh` still wrote a Claude Code `PreToolUse` hook regardless of `HEADROOM_RTK` | | `wrap.py` `_setup_context_tool_for_agent` | **`wrap openhands` was broken by default**: `rtk_required=True` met a gate returning `None` → `SystemExit(1)`. Invisible because all 8 openhands tests patched `_ensure_rtk_binary` to a fake path | | `proxy/helpers.py`, `subscription/tracker.py` | Proxy shelled out to `rtk gain` from `/stats`, the dashboard and `headroom perf`; the tracker polled it per contribution (`_RTK_WIRING_DEFAULT = "enabled"`) | | No cleanup path | Nothing removed artifacts an earlier default had installed, so a machine that once ran the old default kept rtk in the loop forever (#1669, #1955) | Also worth noting: the rtk binary download had **no SHA or signature verification** — only `rtk --version` as a smoke test. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [x] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [x] Code refactoring (no functional changes) ## Changes Made **Removed** — `headroom/rtk/` and `headroom/lean_ctx/` packages, `headroom/cli/wrap_rtk_metrics.py`, `_selected_context_tool` / `_setup_context_tool_for_agent` / `_VALID_CONTEXT_TOOLS`, the `--rtk` / `--no-rtk` / `--no-project-rtk` / `--keep-rtk` flags across all 18 wrap subcommands, `HEADROOM_RTK*`, the proxy-side `rtk gain` polling, the dashboard CLI-filtering panel (rows + all 8 `cliFiltering*` Alpine getters), `paths.rtk_path()` / `lean_ctx_path()`, the SDK path helpers, `benchmarks/rtk_loop_learn_eval.py`, and the `headroom/rtk/**` CI path filters. **Fails loudly, not silently** — `--context-tool` / `--no-context-tool` / `HEADROOM_CONTEXT_TOOL` are kept solely to error out. They live in shell profiles, aliases and CI jobs, and accepting them as a no-op would read as Headroom having quietly stopped working. The installers reject them too, which matters more than it looks: their arg parsers forward the first unknown flag **and everything after it** to the wrapped tool, so a leftover `--no-rtk` would have silently swallowed a following `--port` and then been ignored downstream. **New `headroom/context_tool_cleanup.py`** — deleting the code cannot help a machine that already ran the old default, since the hooks, binaries and injected guidance are durable on disk. `purge_context_tool_artifacts()` runs once per `wrap`/`unwrap` and removes the registered hook entries, the generated hook scripts, the Headroom-managed `~/.local/bin` symlinks, the vendored `~/.headroom/bin/{rtk,lean-ctx}` binaries, the `lean-ctx` MCP server entry and the marker-fenced instruction blocks. Deliberately conservative: idempotent, **skips** a malformed config rather than overwriting it, and only unlinks a symlink resolving inside Headroom's own bin dir so a user's own build is untouched. It reports on **stderr**, because `wrap/unwrap openclaw --prepare-only` emit machine-readable JSON on stdout as their entire contract. Skipped for `wrap selfheal` (runs from a SessionStart hook; must not race Claude Code's writer for `~/.claude.json`) and for `--help`, which must stay read-only. **Client-config hardening** (discovered while investigating a "corrupted Serena settings file" report) — `wrap.py` reset a settings file to `{}` when an existing file would not parse, then wrote that back. One hand-edited typo or a transient `EACCES`/`EINTR` on a valid file destroyed the user's `permissions`, `env` and `hooks`, on **every `headroom wrap claude`**. It now refuses to write. Separately, `fsutil.write_text` is now atomic (temp file + `fsync` + `os.replace`), fixing all 14 non-atomic client-config writes at once; it follows symlinks rather than replacing them (dotfile managers) and preserves an existing file's mode. **Deliberately kept** — `rtk` stays in the wrapper-peel list in `transforms/content_router.py`. It sits beside `sudo`/`env`/`timeout` as shell-command grammar, so `rtk cat f` is still classified as a file read for anyone running their own rtk install, which the purge intentionally leaves alone. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ ruff check headroom/ tests/ e2e/ --exclude headroom/dashboard/templates All checks passed! $ ruff format --check headroom/ tests/ e2e/ --exclude headroom/dashboard/templates 1255 files already formatted $ mypy headroom/ Success: no issues found in 508 source files $ pytest tests/test_context_tool_cleanup.py -q 11 passed $ pytest tests/test_fsutil.py -q 12 passed $ pytest tests/test_cli/test_wrap_codex.py -q # 89 tests 89 passed in 431.68s $ pytest tests/test_cli/test_wrap_opencode.py -q 39 passed in 257.46s $ pytest tests/test_cli/test_wrap_helpers.py -q 45 passed $ pytest tests/test_paths.py -q 75 passed $ pytest tests/test_cli/test_unwrap_claude.py -q 14 passed $ pytest tests/test_proxy_savings_history.py -q 39 passed $ pytest tests/test_cli/test_wrap_copilot.py -q 27 passed $ pytest tests/test_cli/test_wrap_zcode.py -q 20 passed $ pytest tests/test_subscription_tracker.py -q 9 passed $ pytest tests/test_proxy_dashboard_stats_cache.py -q 5 passed, 1 skipped ``` Repo-wide grep for 14 removed symbols (`headroom.rtk`, `headroom.lean_ctx`, `_ensure_rtk_binary`, `_selected_context_tool`, `_get_context_tool_stats`, `rtk_path`, `lean_ctx_path`, `wrap_rtk_metrics`, `HEADROOM_RTK`, `cli_tokens_avoided`, `tokens_saved_rtk`, …) across `*.py`, `*.ts`, `*.sh`, `*.ps1`, `*.yml`, `*.html`: **zero hits**. Notable test changes: `test_wrap_openhands.py` no longer patches `_ensure_rtk_binary` and asserts `wrap openhands --prepare-only` exits 0 unpatched — the regression that was previously masked. `test_wrap_continue.py` and `test_wrap_hintfile_agents.py` were removed (every test drove RTK instruction injection). A new `test_subscription_tracker.py::test_load_state_written_before_cli_context_tools_were_removed` proves a pre-removal `subscription_state.json` still loads. ## Real Behavior Proof - **Environment:** macOS 15.4 (darwin 25.4.0), Python 3.12.6, Headroom @ this branch, real `~/.headroom` and `~/.claude` on the dev machine. - **Exact command / steps and observed result:** ```text # 1. Retired flag fails loudly instead of silently no-op'ing $ headroom wrap codex --prepare-only --context-tool rtk Error: CLI context tools (rtk, lean-ctx) have been removed from Headroom: they rewrote shell commands through a third-party binary Headroom no longer manages. Drop --context-tool / --no-context-tool and unset HEADROOM_CONTEXT_TOOL; `headroom wrap` uninstalls what they left behind on first run. $ HEADROOM_CONTEXT_TOOL=lean-ctx headroom wrap codex --prepare-only Error: CLI context tools (rtk, lean-ctx) have been removed from Headroom: ... # 2. install.sh rejects the retired flags (extracted parse_wrap_args harness) ['--no-rtk', '--port', '9999'] rc=1 ERROR: CLI context tools ... Drop --no-rtk ['--context-tool=rtk'] rc=1 ERROR: CLI context tools ... Drop --context-tool $ bash -n scripts/install.sh # syntax OK # 3. Purge ran against the real machine, which had all the orphaned artifacts $ python -c "from headroom.context_tool_cleanup import purge_context_tool_artifacts; ..." removed ~/.headroom/bin/lean-ctx (51 MB) removed ~/.headroom/bin/rtk (7.7 MB) removed ~/.local/bin/rtk (symlink into ~/.headroom/bin) removed ~/.claude/hooks/rtk-rewrite.sh removed 8 lean-ctx-* hook scripts # ~/.claude.json afterwards: 90 top-level keys, 19 projects, mcpServers unchanged # → ~59 MB reclaimed, no unrelated key touched # 4. stdout stays machine-readable while the purge reports (planted a fake artifact) $ headroom wrap openclaw --prepare-only --gateway-provider-id codex >out 2>err $ cat out {"enabled":true,"config":{"proxyPort":8787,...}} # parses as JSON $ cat err Retired CLI context tool cleanup: removed /Users/tcms/.headroom/bin/rtk # 5. --help is inert (planted artifact survives), a real run purges $ headroom wrap codex --help → artifact survived: CORRECT $ headroom wrap openclaw --prepare-only → purged: CORRECT # 6. MCP purge dry-run against a copy of the real 82 KB ~/.claude.json top-level keys 90 -> 90; projects 19 -> 19; LOST keys: none all content outside mcpServers byte-identical: True ``` Dashboard rendered via the Playwright test after the panel removal: "Token Savings" shows only `Proxy 0 (0.0%)` / `Of total wire: 36.86%`, and "Token Usage" reads Before Compression → Proxy Removed → After Compression with no "Filtered (this session)" row. Nothing below the removed panel broke. - **Not tested:** Windows and Linux (macOS only) — `install.ps1` is verified by brace-balance and inspection, not executed, since no `pwsh` is available locally. The wrap e2e suite (`e2e/wrap/run.py`) was updated but not run; it needs the Docker e2e image. `serena project index` interaction is exercised in the stacked base PR. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Additional Notes **Stacked on #2676** (`tejas/serena-config-bootstrap`) — please merge that first; this PR's base should then be retargeted to `main`, or it will read as containing that fix too. **Breaking-change migration for users:** - Drop `--rtk`, `--no-rtk`, `--no-project-rtk`, `--keep-rtk`, `--context-tool`, `--no-context-tool` from any alias, script or CI job, and unset `HEADROOM_RTK*` / `HEADROOM_CONTEXT_TOOL`. They now error rather than being ignored, so the failure is immediate and self-explaining. - Previously-installed artifacts are purged automatically on the next `wrap`/`unwrap`; no manual cleanup needed. - `headroom perf --json` no longer carries a `cli_filtering` key, and `/stats` no longer returns a `context_tool` section. **Docs:** `docs/rtk-architecture.md` deleted; RTK/lean-ctx removed from `README.md`, `docs/content/docs/{configuration,opencode,grok-build,docker-install,filesystem-contract}.mdx`, `docs/observability.md` and the matching `wiki/` pages. `REALIGNMENT/09-phase-G-rtk-observability.md` is marked SUPERSEDED rather than deleted, to keep the planning record. **Follow-ups not in scope:** `_emit_wrap_interrupted` was deleted as dead code — its only caller was the `except KeyboardInterrupt` guarding the binary download, so with no download there is nothing slow left to interrupt. --- .github/workflows/ci.yml | 1 - .github/workflows/wrap-e2e.yml | 1 - .github/workflows/wrap-native-e2e.yml | 1 - README.md | 4 +- REALIGNMENT/09-phase-G-rtk-observability.md | 9 + REALIGNMENT/12-decisions-needed.md | 6 +- benchmarks/rtk_loop_learn_eval.py | 287 ---- .../src/observability/metric_names.rs | 10 +- .../src/observability/proxy_metrics.rs | 8 +- docker-compose.yml | 2 - docker/docker-compose.native.yml | 4 - docs/content/docs/configuration.mdx | 19 - docs/content/docs/docker-install.mdx | 5 +- docs/content/docs/filesystem-contract.mdx | 2 - docs/content/docs/grok-build.mdx | 5 +- docs/content/docs/opencode.mdx | 5 +- docs/observability.md | 20 +- docs/rtk-architecture.md | 122 -- e2e/wrap/run.py | 107 +- headroom/audit/codex.py | 22 +- headroom/audit/maturation.py | 2 +- headroom/cli/audit.py | 2 +- headroom/cli/proxy.py | 20 - headroom/cli/wrap.py | 1404 +++-------------- headroom/cli/wrap_rtk_metrics.py | 73 - headroom/context_tool_cleanup.py | 383 +++++ headroom/dashboard/templates/dashboard.html | 64 - headroom/fsutil.py | 41 +- headroom/integrations/strands/__init__.py | 2 +- headroom/integrations/strands/bundle.py | 7 +- headroom/lean_ctx/__init__.py | 46 - headroom/lean_ctx/installer.py | 179 --- headroom/learn/analyzer.py | 4 +- headroom/learn/fixtures.py | 29 +- headroom/learn/loops.py | 21 +- headroom/paths.py | 20 - headroom/perf/analyzer.py | 72 +- headroom/proxy/cost.py | 44 +- headroom/proxy/helpers.py | 555 ------- headroom/proxy/prometheus_metrics.py | 28 - headroom/proxy/server.py | 129 +- headroom/rtk/__init__.py | 48 - headroom/rtk/installer.py | 247 --- headroom/subscription/models.py | 44 +- headroom/subscription/tracker.py | 378 +---- scripts/install.ps1 | 79 +- scripts/install.sh | 113 +- sdk/typescript/src/index.ts | 1 - sdk/typescript/src/paths.ts | 6 - sdk/typescript/test/paths.test.ts | 11 - tests/test_audit_codex.py | 13 +- tests/test_cli/conftest.py | 6 +- tests/test_cli/test_main_help_version.py | 5 +- tests/test_cli/test_unwrap_claude.py | 47 +- tests/test_cli/test_wrap_aider.py | 2 +- tests/test_cli/test_wrap_bridge.py | 213 +-- tests/test_cli/test_wrap_claude_base_url.py | 46 +- .../test_wrap_claude_finally_unbound.py | 2 +- .../test_wrap_claude_vertex_proxy_env.py | 1 - tests/test_cli/test_wrap_codex.py | 414 ++--- tests/test_cli/test_wrap_continue.py | 316 ---- tests/test_cli/test_wrap_copilot.py | 144 +- tests/test_cli/test_wrap_encoding.py | 26 +- tests/test_cli/test_wrap_goose.py | 14 +- tests/test_cli/test_wrap_grok.py | 18 +- tests/test_cli/test_wrap_helpers.py | 219 +-- tests/test_cli/test_wrap_hintfile_agents.py | 188 --- tests/test_cli/test_wrap_omp.py | 42 +- tests/test_cli/test_wrap_openclaude.py | 60 +- tests/test_cli/test_wrap_opencode.py | 287 +--- tests/test_cli/test_wrap_openhands.py | 220 +-- tests/test_cli/test_wrap_rtk_metrics.py | 113 -- tests/test_cli/test_wrap_rtk_on_path.py | 127 -- tests/test_cli/test_wrap_serena_boost.py | 2 +- tests/test_cli/test_wrap_vibe.py | 36 - tests/test_cli/test_wrap_zcode.py | 189 +-- tests/test_context_tool_cleanup.py | 218 +++ tests/test_dashboard_cache_ttl_playwright.py | 2 - ...rd_context_tool_availability_playwright.py | 233 --- tests/test_fsutil.py | 46 + tests/test_lean_ctx_installer.py | 176 --- tests/test_learn/test_loop_weighting.py | 32 +- tests/test_learn/test_rtk_loop_eval.py | 27 - tests/test_paths.py | 32 - tests/test_perf_cli_filtering.py | 51 - tests/test_proxy_dashboard_stats_cache.py | 587 +------ ...test_proxy_openai_responses_integration.py | 3 +- tests/test_proxy_savings_history.py | 119 -- tests/test_proxy_stats_recent_requests.py | 14 +- tests/test_rtk_docker_availability.py | 45 - tests/test_rtk_installer.py | 106 -- tests/test_rtk_session_savings.py | 309 ---- tests/test_subprocess_encoding.py | 31 +- tests/test_subscription_tracker.py | 82 +- tests/test_subscription_tracker_rtk_wired.py | 668 -------- tests/test_telemetry_warning.py | 11 - tests/test_wrap_rtk_opt_in.py | 47 - wiki/cli.md | 6 - wiki/docker-install.md | 2 +- wiki/filesystem-contract.md | 1 - wiki/integration-guide.md | 2 - 101 files changed, 1546 insertions(+), 8746 deletions(-) delete mode 100644 benchmarks/rtk_loop_learn_eval.py delete mode 100644 docs/rtk-architecture.md delete mode 100644 headroom/cli/wrap_rtk_metrics.py create mode 100644 headroom/context_tool_cleanup.py delete mode 100644 headroom/lean_ctx/__init__.py delete mode 100644 headroom/lean_ctx/installer.py delete mode 100644 headroom/rtk/__init__.py delete mode 100644 headroom/rtk/installer.py delete mode 100644 tests/test_cli/test_wrap_continue.py delete mode 100644 tests/test_cli/test_wrap_hintfile_agents.py delete mode 100644 tests/test_cli/test_wrap_rtk_metrics.py delete mode 100644 tests/test_cli/test_wrap_rtk_on_path.py create mode 100644 tests/test_context_tool_cleanup.py delete mode 100644 tests/test_dashboard_context_tool_availability_playwright.py delete mode 100644 tests/test_lean_ctx_installer.py delete mode 100644 tests/test_learn/test_rtk_loop_eval.py delete mode 100644 tests/test_perf_cli_filtering.py delete mode 100644 tests/test_rtk_docker_availability.py delete mode 100644 tests/test_rtk_installer.py delete mode 100644 tests/test_rtk_session_savings.py delete mode 100644 tests/test_subscription_tracker_rtk_wired.py delete mode 100644 tests/test_wrap_rtk_opt_in.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45d5243e8..83cf8f78f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,6 @@ jobs: - 'headroom/cli/**' - 'headroom/install/**' - 'headroom/providers/**' - - 'headroom/rtk/**' - 'crates/**' - '**/*.rs' - 'Cargo.toml' diff --git a/.github/workflows/wrap-e2e.yml b/.github/workflows/wrap-e2e.yml index cc041e092..dae4a147a 100644 --- a/.github/workflows/wrap-e2e.yml +++ b/.github/workflows/wrap-e2e.yml @@ -9,7 +9,6 @@ on: paths: - 'headroom/cli/**' - 'headroom/providers/**' - - 'headroom/rtk/**' - 'crates/**' - 'docker/**' - 'Dockerfile' diff --git a/.github/workflows/wrap-native-e2e.yml b/.github/workflows/wrap-native-e2e.yml index 0e98d780a..3684368a4 100644 --- a/.github/workflows/wrap-native-e2e.yml +++ b/.github/workflows/wrap-native-e2e.yml @@ -16,7 +16,6 @@ on: paths: - "headroom/cli/**" - "headroom/providers/**" - - "headroom/rtk/**" - "tests/test_cli/test_wrap_bridge.py" - ".github/actions/headroom-e2e-setup/**" - ".github/workflows/wrap-native-e2e.yml" diff --git a/README.md b/README.md index da2536151..266c9914f 100644 --- a/README.md +++ b/README.md @@ -533,12 +533,10 @@ Headroom runs **locally**, covers **every** content type, works with every major | | Scope | Deploy | Local | Reversible | |------------------------------------------------------------------------------|------------------------------------------------|------------------------------------|:-----:|:----------:| | **Headroom** | All context — tools, RAG, logs, files, history | Proxy · library · middleware · MCP | Yes | Yes | -| [RTK](https://github.com/rtk-ai/rtk) | CLI command outputs | CLI wrapper | Yes | No | -| [lean-ctx](https://github.com/yvgude/lean-ctx) | Tool output, files, shell, history | Proxy · library · middleware · MCP · CLI | Yes | Yes | | [Compresr](https://compresr.ai), [Token Co.](https://thetokencompany.ai) | Text sent to their API | Hosted API call | No | No | | OpenAI Compaction | Conversation history | Provider-native | No | No | -> **Stack & integrations.** Headroom is the **proxy** — that's what we build and offer, and it compresses everything flowing through it no matter what sits upstream. Our recommended companion is **[Serena](https://github.com/oraios/serena)** (installed by default when you wrap an agent) for semantic code navigation — plus **Ponytail** if you want leaner model output. Everything else is your call: Headroom vendors the third-party [RTK](https://github.com/rtk-ai/rtk) and [lean-ctx](https://github.com/yvgude/lean-ctx) binaries for shell-output rewriting, but we don't own or control either project — swap between them with `HEADROOM_CONTEXT_TOOL`, or turn them off. You're free to attach your own tooling too — code-memory MCP, Graphify, Caveman, or any MCP server — and Headroom compresses downstream of all of it. +> **Stack & integrations.** Headroom is the **proxy** — that's what we build and offer, and it compresses everything flowing through it no matter what sits upstream. Our recommended companion is **[Serena](https://github.com/oraios/serena)** (installed by default when you wrap an agent) for semantic code navigation — plus **Ponytail** if you want leaner model output. Everything else is your call: you're free to attach your own tooling — code-memory MCP, Graphify, Caveman, or any MCP server — and Headroom compresses downstream of all of it. ## Contributing diff --git a/REALIGNMENT/09-phase-G-rtk-observability.md b/REALIGNMENT/09-phase-G-rtk-observability.md index 4b89b7ef7..3c634fb36 100644 --- a/REALIGNMENT/09-phase-G-rtk-observability.md +++ b/REALIGNMENT/09-phase-G-rtk-observability.md @@ -1,5 +1,14 @@ # Phase G — RTK Breadth + Observability +> **SUPERSEDED.** RTK and lean-ctx were removed from Headroom entirely: the +> `headroom/rtk/` and `headroom/lean_ctx/` packages, all `--rtk` / `--context-tool` +> flags, the wrap-side hooks and hint-file injection, and the proxy-side `rtk gain` +> polling are all gone, and `headroom/context_tool_cleanup.py` uninstalls what +> earlier versions left on disk. The RTK-specific plan below is historical; the +> non-RTK observability items (cache-hit rate, compression ratio, token +> validation) were kept. `docs/rtk-architecture.md`, referenced throughout this +> document, was deleted with the feature. + **Goal:** Extend RTK coverage to more wrap-CLI agents; close the dead `tokens_saved_rtk` data plane; add per-invocation RTK metrics; add the cache-hit-rate, compression-ratio, token-validation observability surface that's missing today. **Calendar:** 1 week. diff --git a/REALIGNMENT/12-decisions-needed.md b/REALIGNMENT/12-decisions-needed.md index 757ad3520..7e9dc9d64 100644 --- a/REALIGNMENT/12-decisions-needed.md +++ b/REALIGNMENT/12-decisions-needed.md @@ -92,7 +92,11 @@ No re-scoping needed; revisit after Phase D lands. ## Q9. RTK proxy-side invocation — ever revisit? -**Recommendation:** **No, document the decision in `docs/rtk-architecture.md`** (Phase G PR-G3). The argument: +**Resolved — moot.** RTK was removed from Headroom outright (see +`09-phase-G-rtk-observability.md`), so there is no proxy-side invocation to +revisit. The original recommendation was "no, document the decision in +`docs/rtk-architecture.md`" (that doc was deleted with the feature). The argument +is kept because reasons 1–3 apply to any future shell-output rewriter: 1. Cache hot zone risk: shell-out + buffer per tool result is correctness-fragile. 2. Parallel implementation: `crates/headroom-core/src/transforms/log_compressor.rs` covers post-hoc log/output compression; RTK rewrites *commands* (different value). 3. RTK itself is a third-party binary the team doesn't control; an upstream version change silently busts cache. diff --git a/benchmarks/rtk_loop_learn_eval.py b/benchmarks/rtk_loop_learn_eval.py deleted file mode 100644 index cd36fbf98..000000000 --- a/benchmarks/rtk_loop_learn_eval.py +++ /dev/null @@ -1,287 +0,0 @@ -"""RTK-loop eval — does Headroom Learn catch a loop and write a guardrail that -would prevent it recurring? - -This is the agentic eval for the loop-weighting work. It runs in two phases: - - Phase 1 — TRIGGER + LEARN - Reproduce an RTK re-fetch loop (a grep whose RTK-truncated output forces the - agent to re-run larger-limit variants), run it through ``SessionAnalyzer``, - and SCORE the resulting guardrail: - • produced — a loop guardrail was emitted at all - • ranked_first — it outranks the one-off rules (the weighting works) - • names_command — the rule identifies the command that looped - • prescribes_fix — the rule says how to avoid it (fetch full output once) - • weight_reflects — its savings estimate >= the MEASURED wasted tokens - - Phase 2 — GUARDRAIL HOLDS - Inject that guardrail as a prior learned pattern, then feed a session where - the agent FOLLOWED it (one full-output fetch, no loop). Re-run the analyzer - and assert NO new loop guardrail is produced for that command — i.e. once - the rule exists and is honored, the loop does not re-trigger and Learn does - not need to relearn it. - -Runs deterministically by default (a stubbed analyzer LLM so CI is hermetic). -With ``--real`` it drives the real analyzer LLM and scores the actually-generated -rule, using an API key (ANTHROPIC/OPENAI/GEMINI) or an installed CLI backend. - -Usage: - python benchmarks/rtk_loop_learn_eval.py # deterministic - python benchmarks/rtk_loop_learn_eval.py --real # real LLM (API key) - HEADROOM_LEARN_CLI=claude python benchmarks/rtk_loop_learn_eval.py --real # via CLI -""" - -from __future__ import annotations - -import argparse -import os -import sys -from contextlib import nullcontext -from dataclasses import dataclass, field -from pathlib import Path -from unittest.mock import patch - -# Allow running as a plain script from the repo root. -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from headroom.learn.analyzer import SessionAnalyzer # noqa: E402 -from headroom.learn.fixtures import rtk_refetch_loop_session # noqa: E402 -from headroom.learn.loops import detect_loops # noqa: E402 -from headroom.learn.models import ( # noqa: E402 - ProjectInfo, - SessionData, - ToolCall, -) - -REPETITIONS = 6 - - -# ============================================================================= -# Deterministic LLM stub — stands in for the analyzer's _call_llm in CI. -# It mimics a competent model: emits the loop guardrail (under-estimating its -# savings, so the weighting layer has real work to do) plus a one-off rule the -# model would naively rank higher. In Phase 2 it emits NO loop rule, because a -# non-looping guarded session gives it nothing to relearn. -# ============================================================================= - - -def _stub_llm_phase1(digest: str, model: str) -> dict: - return { - "context_file_rules": [ - { - "section": "Use uv for Python", - "content": "Use `uv run python` instead of `python3`.", - "estimated_tokens_saved": 900, # model rates the one-off high - "evidence_count": 2, - }, - { - "section": "Avoid grep TimeoutError re-fetch loop", - "content": ( - "When searching logs for TimeoutError, capture the full " - "result once (grep into a file and read it) instead of " - "re-running grep with larger `head` limits." - ), - "estimated_tokens_saved": 150, # simulated low estimate (stub value, not a real-model figure) - "evidence_count": 1, - }, - ], - "memory_file_rules": [], - } - - -def _stub_llm_phase2(digest: str, model: str) -> dict: - # Guarded, non-looping session → nothing new to learn about the grep. - return {"context_file_rules": [], "memory_file_rules": []} - - -# ============================================================================= -# Scoring -# ============================================================================= - - -@dataclass -class Scorecard: - checks: dict[str, bool] = field(default_factory=dict) - notes: dict[str, str] = field(default_factory=dict) - - def add(self, name: str, passed: bool, note: str = "") -> None: - self.checks[name] = passed - if note: - self.notes[name] = note - - @property - def passed(self) -> bool: - return all(self.checks.values()) - - def render(self) -> str: - width = max(len(k) for k in self.checks) - lines = [] - for name, ok in self.checks.items(): - mark = "PASS" if ok else "FAIL" - note = f" ({self.notes[name]})" if name in self.notes else "" - lines.append(f" [{mark}] {name.ljust(width)}{note}") - return "\n".join(lines) - - -def _guarded_session() -> SessionData: - """A session where the agent followed the guardrail: one full-output fetch, - no re-fetch loop.""" - return SessionData( - session_id="guarded", - tool_calls=[ - ToolCall( - name="Bash", - tool_call_id="tc_0", - input_data={"command": "grep -rn 'TimeoutError' logs/ > /tmp/hits.txt"}, - output="(wrote 1240 matches to /tmp/hits.txt)", - is_error=False, - msg_index=0, - output_bytes=40, - ), - ToolCall( - name="Read", - tool_call_id="tc_1", - input_data={"file_path": "/tmp/hits.txt"}, - output="logs/app.log:42: TimeoutError ...", - is_error=False, - msg_index=1, - output_bytes=8000, - ), - ], - ) - - -def run_eval(*, use_real_llm: bool) -> Scorecard: - project = ProjectInfo( - name="rtk-loop-eval", - project_path=Path("/tmp/rtk-loop-eval"), - data_path=Path("/tmp/rtk-loop-eval-data"), - ) - card = Scorecard() - - # ---- Phase 1: trigger + learn ----------------------------------------- - loop_session = rtk_refetch_loop_session(repetitions=REPETITIONS) - loops = detect_loops([loop_session]) - measured_waste = loops[0].wasted_tokens if loops else 0 - card.add("loop_detected", bool(loops), f"{len(loops)} loop(s), ~{measured_waste:,} tok wasted") - - analyzer = SessionAnalyzer(model=None if use_real_llm else "stub") - phase1_ctx = ( - nullcontext() - if use_real_llm - else patch("headroom.learn.analyzer._call_llm", _stub_llm_phase1) - ) - with phase1_ctx: - result = analyzer.analyze(project, [loop_session]) - - recs = result.recommendations - loop_recs = [r for r in recs if r.is_loop_guardrail] - card.add("guardrail_produced", bool(loop_recs)) - - top = recs[0] if recs else None - card.add( - "ranked_first", - bool(top and top.is_loop_guardrail), - "" if (top and top.is_loop_guardrail) else "loop rule did not rank #1", - ) - - guardrail = loop_recs[0] if loop_recs else None - text = (guardrail.section + " " + guardrail.content).lower() if guardrail else "" - # The rule must identify the LOOPING COMMAND (grep + its output-limit shape), - # not the incidental search string — a good fix generalizes beyond it. (The - # real-LLM run surfaced this: the model wrote a general "grepping logs / `head - # -N` limits" rule and never echoed "TimeoutError", which an earlier - # literal-match check wrongly failed.) - card.add( - "names_command", - "grep" in text and any(k in text for k in ("head", "log", "limit")), - ) - card.add( - "prescribes_fix", - any(k in text for k in ("full", "once", "into a file", "instead", "limit")), - ) - card.add( - "weight_reflects_waste", - bool(guardrail and guardrail.estimated_tokens_saved >= measured_waste), - "" - if (guardrail and guardrail.estimated_tokens_saved >= measured_waste) - else f"savings {getattr(guardrail, 'estimated_tokens_saved', 0)} < waste {measured_waste}", - ) - - # ---- Phase 2: guardrail holds ----------------------------------------- - # Inject the produced guardrail as a prior pattern via the project's - # context file, then analyze a guarded (non-looping) session. - held = True - note = "" - if guardrail: - ctx_path = Path("/tmp/rtk-loop-eval-CLAUDE.md") - ctx_path.write_text( - "\n" - f"### {guardrail.section}\n{guardrail.content}\n" - "\n", - encoding="utf-8", - ) - project.context_file = ctx_path - phase2_ctx = ( - nullcontext() - if use_real_llm - else patch("headroom.learn.analyzer._call_llm", _stub_llm_phase2) - ) - with phase2_ctx: - held_result = analyzer.analyze(project, [_guarded_session()]) - # No NEW loop guardrail should be needed for the (now-guarded) grep. - new_loop_rules = [ - r - for r in held_result.recommendations - if r.is_loop_guardrail and "grep" in (r.section + r.content).lower() - ] - held = not new_loop_rules - note = "" if held else f"{len(new_loop_rules)} new grep loop rule(s) re-emitted" - else: - held = False - note = "no guardrail from phase 1 to test" - card.add("guardrail_holds", held, note) - - return card - - -def _real_backend_available() -> bool: - """True when the analyzer can reach a real LLM — API key or installed CLI.""" - import shutil - - if any(os.environ.get(k) for k in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY")): - return True - return any(shutil.which(cli) for cli in ("claude", "gemini", "codex")) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--real", - action="store_true", - help="Drive the real analyzer LLM — needs an API key (ANTHROPIC_API_KEY / " - "OPENAI_API_KEY / GEMINI_API_KEY) or an installed CLI backend " - "(claude / gemini / codex; force one with HEADROOM_LEARN_CLI=claude).", - ) - args = parser.parse_args() - - if args.real and not _real_backend_available(): - print( - "--real needs an LLM backend (API key or claude/gemini/codex CLI); " - "falling back to deterministic mode.\n" - ) - args.real = False - - mode = "REAL LLM" if args.real else "deterministic stub" - print(f"RTK-loop eval — mode: {mode}\n") - card = run_eval(use_real_llm=args.real) - print(card.render()) - print() - if card.passed: - print("RESULT: PASS — loop caught, guardrail ranked first, and it holds.") - return 0 - print("RESULT: FAIL — see failed checks above.") - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/crates/headroom-proxy/src/observability/metric_names.rs b/crates/headroom-proxy/src/observability/metric_names.rs index 7708f92f5..07f5772d5 100644 --- a/crates/headroom-proxy/src/observability/metric_names.rs +++ b/crates/headroom-proxy/src/observability/metric_names.rs @@ -98,11 +98,11 @@ pub const METRIC_PROXY_RESPONSE_STATUS_COUNT_TOTAL_HELP: &str = // for `proxy_image_generation_call_log_redacted_total`, // `wrap_rtk_invocations_total`, and `wrap_rtk_tokens_saved_per_session` // were removed because the underlying counters had no production -// emit site on the Rust side. The same metrics are exported by the -// Python proxy (`headroom/proxy/prometheus_metrics.py`) which is the -// natural owner: image redaction is a Python-proxy operation and RTK -// invocation tracking lives in the wrap CLI, both Python-side -// surfaces. See `docs/observability.md`. +// emit site on the Rust side. Image redaction is exported by the +// Python proxy (`headroom/proxy/prometheus_metrics.py`), its natural +// owner. The two `wrap_rtk_*` names are gone for good: the rtk +// integration they measured has been removed from Headroom. +// See `docs/observability.md`. // ---------- shared label keys ---------- diff --git a/crates/headroom-proxy/src/observability/proxy_metrics.rs b/crates/headroom-proxy/src/observability/proxy_metrics.rs index 371b2887f..a93eee396 100644 --- a/crates/headroom-proxy/src/observability/proxy_metrics.rs +++ b/crates/headroom-proxy/src/observability/proxy_metrics.rs @@ -283,10 +283,10 @@ pub fn record_response_status(status: &str, reason: Option<&str>, request_id: &s // Phase G PR-G3 remediation (C3 + C4): the image-redacted counter // and the wrap_rtk_invocations counter were originally registered // here but neither had a production emit site that crossed the -// Python/Rust boundary. Both have moved Python-side -// (`headroom.proxy.request_logger::redactions_total` and -// `headroom.cli.wrap_rtk_metrics::rtk_invocation_counts`) and the -// Python proxy's `/metrics` exporter surfaces them — see +// Python/Rust boundary. The image-redacted counter moved Python-side +// (`headroom.proxy.request_logger::redactions_total`) and the Python +// proxy's `/metrics` exporter surfaces it; the RTK counter is gone +// entirely along with the rtk integration itself — see // `docs/observability.md` for the placement decision. Keeping a // dead Rust counter would (a) violate the "no dead metrics // registered" review finding and (b) mislead Phase H canary diff --git a/docker-compose.yml b/docker-compose.yml index f081de838..47b707b2d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,8 +38,6 @@ services: # if you want to use a custom OpenAI-compatible API endpoint, # uncomment and set the following line with the desired URL # - OPENAI_TARGET_API_URL=https://api.x.ai - # CLI-filtering dashboard figures require the `rtk` binary inside this - # container; it is not installed by this image. See docs/content/docs/docker-install.mdx. ports: - "8787:8787" volumes: diff --git a/docker/docker-compose.native.yml b/docker/docker-compose.native.yml index 0c1fd6c1d..c999023f1 100644 --- a/docker/docker-compose.native.yml +++ b/docker/docker-compose.native.yml @@ -13,8 +13,6 @@ services: # the Docker bind-mount source and is intentionally different. HEADROOM_WORKSPACE_DIR: /tmp/headroom-home/.headroom HEADROOM_CONFIG_DIR: /tmp/headroom-home/.headroom/config - # CLI-filtering dashboard figures require the `rtk` binary inside this - # container; it is not installed by this image. See docs/content/docs/docker-install.mdx. volumes: - ${HEADROOM_WORKSPACE:-.}:/workspace - ${HEADROOM_HOST_HOME:?set HEADROOM_HOST_HOME}/.headroom:/tmp/headroom-home/.headroom @@ -35,8 +33,6 @@ services: # above for rationale. HEADROOM_WORKSPACE_DIR: /tmp/headroom-home/.headroom HEADROOM_CONFIG_DIR: /tmp/headroom-home/.headroom/config - # CLI-filtering dashboard figures require the `rtk` binary inside this - # container; it is not installed by this image. See docs/content/docs/docker-install.mdx. ports: - "${HEADROOM_PORT:-8787}:${HEADROOM_PORT:-8787}" volumes: diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index 2e301a051..c4bf1bc8f 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -5,27 +5,8 @@ description: All configuration options for the Headroom Python and TypeScript SD Headroom can be configured via the SDK constructor, proxy command line, environment variables, or per-request overrides. -## CLI Context Tool - -`headroom wrap ...` uses RTK for local shell-output filtering by default. -Set `HEADROOM_CONTEXT_TOOL=lean-ctx` to have wrap commands install or reuse -`lean-ctx` and run `lean-ctx init --agent ` instead of RTK setup. - -```bash -export HEADROOM_CONTEXT_TOOL=lean-ctx -headroom wrap claude -headroom wrap codex --prepare-only -``` - -Supported values are `rtk` and `lean-ctx`; unset defaults to `rtk`. - If Codex history disappeared after using an older wrapper, see [Recover Codex State](/docs/codex-recovery) before wrapping Codex again. -The proxy reads RTK lifetime savings with global scope by default so a shared -daemon reports savings across the operator's projects. Set -`HEADROOM_RTK_GAIN_SCOPE=project` to query `rtk gain --project` from the -proxy process working directory. - ## SDK Modes (`default_mode` / `headroom_mode`) These modes apply to SDK usage via `HeadroomClient(default_mode=...)` or per-request `headroom_mode=...`. They are **not** the same as the proxy `--mode` flag. diff --git a/docs/content/docs/docker-install.mdx b/docs/content/docs/docker-install.mdx index 747330cd9..06e90fe38 100644 --- a/docs/content/docs/docker-install.mdx +++ b/docs/content/docs/docker-install.mdx @@ -75,7 +75,7 @@ docker run --rm -it \ `wrap` is host-oriented in Docker-native mode: - the wrapper starts the Headroom proxy in Docker -- container-side prep writes Headroom config, memory, and selected CLI context-tool setup into mounted host files +- container-side prep writes Headroom config and memory into mounted host files - the target CLI itself is launched on the host by the wrapper Supported host wrap flows: @@ -191,9 +191,6 @@ That keeps provider auth and runtime config working without maintaining a separa - The install scripts are idempotent: rerunning them refreshes the wrapper and image without duplicating shell profile blocks. - For persistent service and task installs, use the Python-native `headroom install ...` workflow — see [Persistent Installs](/docs/persistent-installs). - For Docker-native `headroom install ...`, the wrapper persists its profile manifest under `~/.headroom/deploy//`. -- The `rtk` binary is not bundled in the Docker image. Dashboard CLI-filtering - savings figures show as "not installed" (not `0`) until `rtk` is installed - inside the container. ## Next steps diff --git a/docs/content/docs/filesystem-contract.mdx b/docs/content/docs/filesystem-contract.mdx index 6aeda5451..d54b75347 100644 --- a/docs/content/docs/filesystem-contract.mdx +++ b/docs/content/docs/filesystem-contract.mdx @@ -54,8 +54,6 @@ Examples: | Memory bridge state | `${WORKSPACE_DIR}/bridge_state.json` | — | | Proxy log directory | `${WORKSPACE_DIR}/logs/` | — | | HTTP 400 debug dumps | `${WORKSPACE_DIR}/logs/debug_400/` | — | -| Vendored `rtk` binary | `${WORKSPACE_DIR}/bin/rtk[.exe]` | — | -| Vendored `lean-ctx` binary | `${WORKSPACE_DIR}/bin/lean-ctx[.exe]` | — | | Deployment profiles | `${WORKSPACE_DIR}/deploy/` | — | | Beacon lock file | `${WORKSPACE_DIR}/.beacon_lock_` | — | diff --git a/docs/content/docs/grok-build.mdx b/docs/content/docs/grok-build.mdx index e11b658c0..583f92f81 100644 --- a/docs/content/docs/grok-build.mdx +++ b/docs/content/docs/grok-build.mdx @@ -3,7 +3,7 @@ title: Grok Build Integration description: Route Grok Build traffic through Headroom for token compression and per-project savings attribution. --- -Use `headroom wrap grok-build` to route Grok Build LLM traffic through the local Headroom proxy. The wrapper starts or reuses the proxy, injects a reversible `[model.grok-build]` override into `~/.grok/config.toml` (or `$GROK_HOME/config.toml`), optionally sets up RTK or `lean-ctx`, and prints next steps for launching `grok`. +Use `headroom wrap grok-build` to route Grok Build LLM traffic through the local Headroom proxy. The wrapper starts or reuses the proxy, injects a reversible `[model.grok-build]` override into `~/.grok/config.toml` (or `$GROK_HOME/config.toml`), and prints next steps for launching `grok`. ## Quick Start @@ -30,7 +30,6 @@ headroom unwrap grok-build | Proxy | Starts the Headroom proxy unless `--no-proxy` is set | | Model config | Writes or updates `[model.grok-build] base_url` in Grok's `config.toml`, pointing at `http://127.0.0.1:/v1` (with optional `/p/` prefix for savings attribution) | | Existing config | If you already have a `[model.grok-build]` table, Headroom rewrites `base_url` in place instead of appending a duplicate table (invalid TOML) | -| Context tool | Injects RTK or `lean-ctx` guidance into project `AGENTS.md` unless `--no-context-tool` is set | | MCP install | `headroom mcp install` can register Headroom MCP via `GrokRegistrar` | | Backup | Snapshots `config.toml` to `config.toml.headroom-backup` before the first injection | @@ -39,7 +38,6 @@ headroom unwrap grok-build ```bash headroom wrap grok-build \ --port 8787 \ # Proxy port (default: 8787) - --no-context-tool \ # Skip RTK / lean-ctx setup --no-proxy \ # Use an existing proxy instead of starting one --learn \ # Enable live traffic learning --memory # Enable persistent memory @@ -50,7 +48,6 @@ headroom wrap grok-build \ | Variable | Description | |---|---| | `GROK_HOME` | Override Grok config directory (default: `~/.grok`) | -| `HEADROOM_CONTEXT_TOOL` | Set to `lean-ctx` to use lean-ctx instead of RTK | | `XAI_API_KEY` | Grok API key (also accepts `GROK_CODE_XAI_API_KEY`) | ## Persistent Install diff --git a/docs/content/docs/opencode.mdx b/docs/content/docs/opencode.mdx index 837fa5e56..6c71d27ff 100644 --- a/docs/content/docs/opencode.mdx +++ b/docs/content/docs/opencode.mdx @@ -3,7 +3,7 @@ title: OpenCode Integration description: Route OpenCode traffic through Headroom for token compression, MCP tools, and cached model access. One command to wrap, one to unwrap. --- -Use `headroom wrap opencode` to route OpenCode LLM traffic through the Headroom proxy with a single command. The wrapper starts or reuses the proxy, writes OpenCode config, injects Headroom MCP tools, adds RTK context filtering, and launches OpenCode with the generated config. +Use `headroom wrap opencode` to route OpenCode LLM traffic through the Headroom proxy with a single command. The wrapper starts or reuses the proxy, writes OpenCode config, injects Headroom MCP tools, and launches OpenCode with the generated config. The `headroom-opencode` npm package also exports a native OpenCode plugin. The plugin can be used directly from OpenCode config when you want in-process transport interception plus the Headroom retrieve tool. @@ -27,7 +27,6 @@ headroom unwrap opencode | Provider injection | Writes a `headroom` provider using `@ai-sdk/openai-compatible` into `opencode.json`, pointing at `http://127.0.0.1:/v1` | | Runtime env | Sets `OPENCODE_CONFIG_CONTENT` with provider, plugin, and optional local MCP config so OpenCode picks up Headroom at launch | | Provider compatibility | Leaves `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` untouched so OpenCode `/connect` providers keep their own routing | -| Context tool | Injects RTK (or `lean-ctx`) instructions into `~/.config/opencode/AGENTS.md` and project `AGENTS.md` | | MCP setup | Registers the Headroom MCP server (`headroom_compress`, `headroom_retrieve`, `headroom_stats`) | | Serena MCP | Optionally registers Serena code graph tools (`--no-serena` to skip) | | Backup | Snapshots `opencode.json` to `opencode.json.headroom-backup` before making any changes | @@ -38,7 +37,6 @@ headroom unwrap opencode ```bash headroom wrap opencode \ --port 8787 \ - --no-rtk \ --no-mcp \ --no-serena \ --code-graph \ @@ -71,7 +69,6 @@ The default model is `headroom/claude-sonnet-4-6`. Change it in `opencode.json` |---|---| | `OPENCODE_CONFIG_CONTENT` | JSON payload with provider, plugin, and optional local MCP config injected by `wrap` | | `HEADROOM_PROXY_URL` | Proxy URL passed to Headroom MCP when a non-default port is used, and to the native plugin when configured | -| `HEADROOM_CONTEXT_TOOL` | Set to `lean-ctx` to use lean-ctx instead of RTK | ## Failure Learning diff --git a/docs/observability.md b/docs/observability.md index 33b2f20f6..b6de50dc6 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -3,7 +3,7 @@ The Headroom Rust proxy exposes Prometheus-format metrics on the `/metrics` endpoint of every running proxy instance. The metric catalogue below covers Phase D (Bedrock route instrumentation) and -Phase G PR-G3 (per-invocation RTK + proxy-wide observability). +Phase G PR-G3 (proxy-wide observability). All metric names + label keys are constants in `crates/headroom-proxy/src/observability/metric_names.rs`, so any @@ -59,17 +59,6 @@ intentional byte mutations do not trip the alarm. | `proxy_service_tier_count_total` | Counter | `tier` | Service-tier distribution observed at the proxy. | | `proxy_response_status_count_total` | Counter | `status` | Terminal status distribution (`completed`, `incomplete`, `failed`, `cancelled`, `in_progress`). | -#### Wrap CLI / RTK (Python-side) - -| Name | Type | Labels | Purpose | -|------|------|--------|---------| -| `wrap_rtk_invocations_total` | Counter | `tool` | RTK invocations observed via the wrap-CLI tail. Surfaced via the Python proxy's `/metrics` exporter; the wrap CLI bumps `headroom.cli.wrap_rtk_metrics.record_rtk_invocation(...)`. | - -> **C4 remediation:** This counter is Python-side because RTK is -> wrapped by `headroom wrap` (Python CLI) and the wrap-side tail -> is the natural emit site. The Rust proxy previously held a dead -> counter for this metric; that has been removed. - #### Image log redaction (Python-side) | Name | Type | Labels | Purpose | @@ -141,9 +130,6 @@ sum by (strategy) (rate(proxy_compression_rejected_by_token_check_total{strategy # Upstream rate-limit headroom (smaller = closer to throttle). proxy_rate_limit_remaining_tokens{provider="anthropic"} -# RTK invocation rate (Python-side). -sum by (tool) (rate(wrap_rtk_invocations_total{tool!="__init__"}[5m])) - # Image-redaction rate (Python-side). rate(proxy_image_generation_call_log_redacted_total[5m]) ``` @@ -259,16 +245,12 @@ Every label vocabulary is bounded by code, not customer input: `"other"` and a `tracing::warn!` is emitted so wire-format drift surfaces loudly in logs. - `status`: 5-variant enum. -- `tool` (Python-side `wrap_rtk_invocations_total`): bounded by the - set of tools the wrap CLI rewrites, captured by - `headroom.cli.wrap_rtk_metrics`. There is no code path where a malicious client can drive label cardinality unbounded. ## See also -- `docs/rtk-architecture.md` — why RTK lives wrap-side, not proxy-side. - `crates/headroom-proxy/src/observability/` — implementation. - `REALIGNMENT/09-phase-G-rtk-observability.md` — spec. - `REALIGNMENT/10-phase-H-python-retirement.md` — H1 acceptance gate. diff --git a/docs/rtk-architecture.md b/docs/rtk-architecture.md deleted file mode 100644 index 35ff9aadf..000000000 --- a/docs/rtk-architecture.md +++ /dev/null @@ -1,122 +0,0 @@ -# RTK architecture — why wrap-CLI only - -**Status:** decided. Locked at Phase G PR-G3 (2026-05). -**Owner:** Headroom realignment. - -## TL;DR - -**RTK is a wrap-CLI hook, not a proxy-side compressor.** The Headroom -proxy does NOT invoke RTK on tool-result content. Future contributors -who consider moving RTK into the proxy hot path: read this doc first. - -## Background - -RTK (Realtime Token Kompress) rewrites shell **commands** at exec -time so that a `git diff` or `grep` invocation emits a more -compressed output before the agent ever ingests it. RTK runs in the -wrap-CLI tail — `headroom wrap claude`, `headroom wrap codex`, etc. -— where it installs a `~/.rtk/bin/rtk` shim ahead of the agent CLI -and intercepts shelled-out subprocesses. - -It surfaces value in two places: -1. **Tokens saved per invocation** — measured by `rtk gain --format json`. -2. **Tokens saved per session** — aggregated at wrap-session end. - -Both signals feed `wrap_rtk_invocations_total` and -`wrap_rtk_tokens_saved_per_session` (registered by the Rust proxy's -observability surface so a single `/metrics` scrape exposes the full -picture). - -## Proxy-side RTK was considered and rejected - -At Phase G scoping, three reviewers floated the idea of invoking -RTK on the **proxy** side: when a `tool_result` block flows -upstream, dispatch it through RTK to shrink the content before it -hits the model. - -**Decision: rejected.** Three load-bearing reasons. - -### 1. Cache hot zone risk - -The proxy's Phase B cache-safety contract pins `tool_result` -content as part of the cache hot zone. Compression there bursts -the prompt cache because the rewritten bytes diverge from the -canonical wire bytes the upstream cached. Phase B PR-B2 → PR-B7 -spent ~3000 LOC carving the live-zone-only surface specifically -to prevent this class of cache-invalidation. Inserting RTK -proxy-side would re-introduce it. - -### 2. Parallel implementation with `log_compressor.rs` - -The Rust proxy already has a `crates/headroom-core/src/transforms/log_compressor.rs` -that compresses **tool output text** in the live zone. It uses the -same heuristics RTK uses (whitespace de-dup, line de-dup, -file-listing collapse) but invoked at the proxy's per-block -dispatcher rather than at the shell exec boundary. Adding RTK -proxy-side would mean two implementations of the same compression -in the same hot path; "no silent fallbacks, no parallel impls" is -explicit project policy. - -### 3. Command-rewrite vs output-rewrite — different value propositions - -RTK rewrites **commands** before they execute. The -`git log --oneline` you typed becomes `git log --oneline -n 50` -because RTK has learned that the first 50 commits are usually -enough context. That's a fundamentally different mechanism from -compressing the **output** of an unmodified command. A proxy-side -invocation would skip the command-rewrite half — the half that -generates the largest savings on heavy shell workloads — and only -catch the output side, which is already covered by -`log_compressor` and `code_compressor`. - -## What the proxy does provide - -Per Phase G PR-G3, the proxy exposes RTK-derived metrics via its -registry: - -- `wrap_rtk_invocations_total{tool}` — driven by the wrap-CLI - polling `rtk gain --format json` and incrementing the registered - counter by the delta since last poll. -- `wrap_rtk_tokens_saved_per_session` — emitted at wrap-session - close. - -This keeps the operator dashboard single-pane-of-glass without -re-implementing RTK inside the proxy. - -## What the wrap CLI does - -Every `headroom wrap ` subcommand: - -1. Ensures the RTK binary is installed via `_ensure_rtk_binary()`. -2. Injects the `` block into the - agent's instruction file (e.g. `AGENTS.md`, `.cursorrules`). -3. Spawns the proxy and the agent CLI side-by-side. -4. Polls `rtk gain --format json` on a 5-second memoization window - and feeds the delta into the proxy's metric registry. - -See `headroom/cli/wrap/` for the per-agent shims. - -## Re-litigation policy - -A change to this architecture should: - -1. Quote the live-zone-only contract from - `REALIGNMENT/04-phase-B-live-zone.md` and explain why the - cache-burst risk is acceptable. -2. Show measurements (not estimates) that proxy-side RTK adds value - beyond `log_compressor.rs` on real production traffic. -3. Have an exit ramp: a CLI flag to disable proxy-side RTK without - reverting the wrap-CLI integration. - -Without all three, treat the proposal as a regression and link this -doc. - -## References - -- `REALIGNMENT/09-phase-G-rtk-observability.md` — Phase G plan. -- `REALIGNMENT/04-phase-B-live-zone.md` — cache hot-zone contract. -- `headroom/cli/wrap/` — wrap-CLI implementation. -- `crates/headroom-core/src/transforms/log_compressor.rs` — the - proxy-side log compressor RTK would parallel. -- 2026-05-01 user direction message archived in - `project_compression_realignment_2026_05` memory note. diff --git a/e2e/wrap/run.py b/e2e/wrap/run.py index c779b7042..4546ffa8d 100644 --- a/e2e/wrap/run.py +++ b/e2e/wrap/run.py @@ -21,7 +21,6 @@ import httpx REPO_ROOT = Path("/workspace") PLUGIN_DIR = REPO_ROOT / "plugins" / "openclaw" SDK_DIR = REPO_ROOT / "sdk" / "typescript" -RTK_MARKER = "" PROXY_PORT = 28887 CODEX_PORT = 28888 AIDER_PORT = 28889 @@ -368,25 +367,10 @@ def create_shims(shim_dir: Path) -> None: raise SystemExit(0) """ ) - rtk_shim = textwrap.dedent( - """\ - #!/usr/bin/env python3 - from __future__ import annotations - - import sys - - if "--version" in sys.argv: - print("rtk e2e-shim") - else: - print("rtk shim") - raise SystemExit(0) - """ - ) write_executable(shim_dir / "claude", generic_shim) write_executable(shim_dir / "codex", codex_shim) write_executable(shim_dir / "aider", generic_shim) write_executable(shim_dir / "opencode", generic_shim) - write_executable(shim_dir / "rtk", rtk_shim) def start_mock_server(port: int) -> tuple[MockOpenAIServer, threading.Thread]: @@ -569,16 +553,6 @@ def verify_codex_wrap( cwd=project_dir, timeout=120, ) - # RTK guidance for Codex is global-only (#1240): it is injected into - # ~/.codex/AGENTS.md, never a project-level AGENTS.md. A project AGENTS.md is - # written only when `wrap codex --memory` is used (for memory guidance), which - # this scenario does not exercise. - global_agents = Path(base_env["HOME"]) / ".codex" / "AGENTS.md" - assert_true(global_agents.exists(), "Codex wrap should create ~/.codex/AGENTS.md") - assert_true( - RTK_MARKER in global_agents.read_text(encoding="utf-8"), "Missing global RTK marker" - ) - config_path = Path(base_env["HOME"]) / ".codex" / "config.toml" assert_true( config_path.exists(), @@ -688,13 +662,6 @@ def verify_aider_wrap(base_env: dict[str, str], project_dir: Path, log_dir: Path cwd=project_dir, timeout=120, ) - conventions = project_dir / "CONVENTIONS.md" - assert_true(conventions.exists(), "Aider wrap should create CONVENTIONS.md") - assert_true( - RTK_MARKER in conventions.read_text(encoding="utf-8"), - "Aider wrap should inject RTK instructions", - ) - entries = read_jsonl(log_dir / "aider.jsonl") assert_true(len(entries) > 0, "Aider shim should have been invoked") env_vars = entries[-1]["env"] @@ -745,84 +712,52 @@ def verify_cursor_wrap(base_env: dict[str, str], project_dir: Path) -> None: "Cursor wrap should print the Anthropic base URL override", ) wait_for_http(f"http://127.0.0.1:{port}/health", timeout=15) - # rtk registers a native Cursor hook (rtk init --agent cursor) when it - # can (~/.cursor exists); headroom only falls back to injecting - # .cursorrules text if that registration fails (GH #756). Accept - # either outcome rather than assuming the fallback path. - cursorrules = project_dir / ".cursorrules" - cursor_hooks_json = Path(base_env["HOME"]) / ".cursor" / "hooks.json" - native_hook_registered = ( - cursor_hooks_json.exists() and "rtk" in cursor_hooks_json.read_text(encoding="utf-8") - ) - if not native_hook_registered: - assert_true( - cursorrules.exists(), - "Cursor wrap should create .cursorrules when the native rtk hook is unavailable", - ) - assert_true( - RTK_MARKER in cursorrules.read_text(encoding="utf-8"), - "Cursor wrap should inject RTK instructions", - ) finally: stop_process(proc) def verify_cline_wrap(base_env: dict[str, str], project_dir: Path) -> None: - """Smoke test: `wrap cline --prepare-only` writes RTK guidance to .clinerules.""" + """Smoke test: `wrap cline --prepare-only` exits clean. + + These three wraps used to be verified by the hint-file guidance they wrote. + With the CLI context tools removed they produce no on-disk artifact, so the + remaining assertion is that the prepare path still runs without crashing — + ``run`` raises on a non-zero exit. + """ run( ["headroom", "wrap", "cline", "--prepare-only", "--port", str(CLINE_PORT)], env=base_env, cwd=project_dir, timeout=60, ) - clinerules = project_dir / ".clinerules" - assert_true(clinerules.exists(), "Cline wrap should create .clinerules") - assert_true( - RTK_MARKER in clinerules.read_text(encoding="utf-8"), - "Cline wrap should inject RTK instructions", - ) def verify_continue_wrap(base_env: dict[str, str], project_dir: Path) -> None: - """Smoke test: `wrap continue --prepare-only` injects RTK into .continue/config.json.""" + """Smoke test: `wrap continue --prepare-only` exits clean (see verify_cline_wrap).""" run( ["headroom", "wrap", "continue", "--prepare-only", "--port", str(CONTINUE_PORT)], env=base_env, cwd=project_dir, timeout=60, ) - config_file = project_dir / ".continue" / "config.json" - assert_true(config_file.exists(), "Continue wrap should create .continue/config.json") - data = json.loads(config_file.read_text(encoding="utf-8")) - system_message = data.get("systemMessage", "") - assert_true( - RTK_MARKER in system_message, - "Continue wrap should inject RTK instructions into systemMessage", - ) def verify_goose_wrap(base_env: dict[str, str], project_dir: Path) -> None: - """Smoke test: `wrap goose --prepare-only` writes RTK guidance to .goosehints.""" + """Smoke test: `wrap goose --prepare-only` exits clean (see verify_cline_wrap).""" run( ["headroom", "wrap", "goose", "--prepare-only", "--port", str(GOOSE_PORT)], env=base_env, cwd=project_dir, timeout=60, ) - goosehints = project_dir / ".goosehints" - assert_true(goosehints.exists(), "Goose wrap should create .goosehints") - assert_true( - RTK_MARKER in goosehints.read_text(encoding="utf-8"), - "Goose wrap should inject RTK instructions", - ) def verify_openhands_wrap(base_env: dict[str, str], project_dir: Path) -> None: - """Smoke test: `wrap openhands --prepare-only` exits clean and ensures rtk is present. + """Smoke test: `wrap openhands --prepare-only` exits clean. - OpenHands wires instructions via the OPENHANDS_INSTRUCTIONS env var at launch - time (no on-disk artifact), so --prepare-only just exercises the rtk-binary - setup path. The env-var wiring is covered by the unit tests. + This one is a real regression guard: openhands used to *require* the rtk + binary, so once rtk became opt-in the default path exited 1. Nothing is + stubbed here, so a reintroduced hard dependency fails the run. """ run( ["headroom", "wrap", "openhands", "--prepare-only", "--port", str(OPENHANDS_PORT)], @@ -953,9 +888,6 @@ def main() -> None: "PATH": f"{shim_dir}{os.pathsep}{base_env['PATH']}", "HEADROOM_E2E_LOG_DIR": str(log_dir), "OPENAI_TARGET_API_URL": "http://127.0.0.1:19001/v1", - # RTK is opt-in (off by default). These wrap smoke tests assert - # RTK-instruction injection, so exercise the RTK-on path. - "HEADROOM_RTK": "1", } ) @@ -987,19 +919,6 @@ def verify_opencode_wrap(base_env: dict[str, str], project_dir: Path, log_dir: P cwd=project_dir, timeout=120, ) - global_agents = Path(base_env["HOME"]) / ".config" / "opencode" / "AGENTS.md" - project_agents = project_dir / "AGENTS.md" - assert_true(global_agents.exists(), "Opencode wrap should create ~/.config/opencode/AGENTS.md") - assert_true(project_agents.exists(), "Opencode wrap should create project AGENTS.md") - assert_true( - RTK_MARKER in global_agents.read_text(encoding="utf-8"), - "Missing RTK marker in global AGENTS.md", - ) - assert_true( - RTK_MARKER in project_agents.read_text(encoding="utf-8"), - "Missing RTK marker in project AGENTS.md", - ) - entries = read_jsonl(log_dir / "opencode.jsonl") assert_true(len(entries) > 0, "Opencode shim should have been invoked") env_vars = entries[-1]["env"] diff --git a/headroom/audit/codex.py b/headroom/audit/codex.py index 934b085b3..d528519d9 100644 --- a/headroom/audit/codex.py +++ b/headroom/audit/codex.py @@ -1,8 +1,7 @@ """Codex transcript audit — read-pattern analysis for shell-based clients. Codex has no structured Read tool: it reads files through shell commands -(``cat``, ``sed -n 'a,bp'``, ``head``/``tail``, ``nl``) — frequently -wrapped by rtk (``rtk read ``, ``rtk proxy ``). This module +(``cat``, ``sed -n 'a,bp'``, ``head``/``tail``, ``nl``). This module classifies ``exec_command`` calls in Codex session transcripts (``~/.codex/sessions/**/*.jsonl``) and measures the read pattern so the read-maturation mechanism can be sized for Codex workloads. @@ -23,8 +22,8 @@ from collections import Counter from dataclasses import asdict, dataclass, field from pathlib import Path -# Programs whose output is file content. "read" is rtk's read command. -_READ_PROGS = frozenset({"cat", "sed", "head", "tail", "nl", "bat", "more", "read"}) +# Programs whose output is file content. +_READ_PROGS = frozenset({"cat", "sed", "head", "tail", "nl", "bat", "more"}) _SEARCH_PROGS = frozenset({"rg", "grep", "ugrep", "ag", "fd", "find"}) _BUILD_PROGS = frozenset({"python", "python3", "pytest", "cargo", "npm", "make", "uv", "ruff"}) _RANGE_RE = re.compile(r"^\d+([,:-]\d+)?p?$") @@ -57,19 +56,6 @@ class CodexAuditReport: return asdict(self) -def strip_wrappers(cmd: str) -> str: - """Peel rtk wrappers: ``rtk `` and ``rtk proxy ``.""" - c = cmd.strip() - while True: - if c.startswith("rtk "): - c = c[4:].strip() - continue - if c.startswith("proxy "): - c = c[6:].strip() - continue - return c - - def _resolve_path(path: str | None, workdir: str = "") -> str | None: if not path: return None @@ -129,7 +115,7 @@ def classify_command(cmd: str, workdir: str = "") -> tuple[str, str | None, bool Categories: read, search, git, edit, build/test, compound, other. For reads and edits, the path is resolved against ``workdir`` when relative. """ - c = strip_wrappers(cmd) + c = cmd.strip() try: toks = shlex.split(c) except ValueError: diff --git a/headroom/audit/maturation.py b/headroom/audit/maturation.py index e21b4cad3..18cab19ce 100644 --- a/headroom/audit/maturation.py +++ b/headroom/audit/maturation.py @@ -177,7 +177,7 @@ def simulate_codex_maturation(root: Path) -> MaturationSimReport: """Run the maturation simulation over Codex shell-based transcripts. Codex has no structured ``Read`` tool. It reads files through - ``exec_command`` calls such as ``cat``, ``sed -n``, and ``rtk read``. + ``exec_command`` calls such as ``cat``, ``sed -n``, and ``head``. This mirrors ``simulate_maturation`` with the Codex command classifier so ``headroom audit-reads --codex --simulate-maturation`` sizes the same read-maturation policy from Codex traffic instead of returning an diff --git a/headroom/cli/audit.py b/headroom/cli/audit.py index 26d2b7329..389801ce4 100644 --- a/headroom/cli/audit.py +++ b/headroom/cli/audit.py @@ -32,7 +32,7 @@ from .main import main "--codex", "codex_mode", is_flag=True, - help="Audit Codex transcripts instead (shell-based reads: cat/sed/rtk read). " + help="Audit Codex transcripts instead (shell-based reads: cat/sed/head). " "Default path becomes ~/.codex/sessions.", ) def audit_reads_cmd( diff --git a/headroom/cli/proxy.py b/headroom/cli/proxy.py index 513b14dd7..1a4dbe7ee 100644 --- a/headroom/cli/proxy.py +++ b/headroom/cli/proxy.py @@ -66,11 +66,6 @@ warnings.filterwarnings("ignore", category=UserWarning, module="huggingface_hub" # --------------------------------------------------------------------------- -_CONTEXT_TOOL_ENV = "HEADROOM_CONTEXT_TOOL" -_CONTEXT_TOOL_RTK = "rtk" -_CONTEXT_TOOL_LEAN_CTX = "lean-ctx" -_VALID_CONTEXT_TOOLS = {_CONTEXT_TOOL_RTK, _CONTEXT_TOOL_LEAN_CTX} - def _get_env_bool(name: str, default: bool) -> bool: val = os.environ.get(name) @@ -117,19 +112,6 @@ def _get_env_float_optional(name: str) -> float | None: raise click.ClickException(f"{name} must be a number, got {val!r}") from None -def _selected_context_tool() -> str: - raw = os.environ.get(_CONTEXT_TOOL_ENV, "").strip().lower().replace("_", "-") - if not raw: - return _CONTEXT_TOOL_RTK - if raw == "leanctx": - raw = _CONTEXT_TOOL_LEAN_CTX - if raw not in _VALID_CONTEXT_TOOLS: - raise click.ClickException( - f"{_CONTEXT_TOOL_ENV} must be one of: {', '.join(sorted(_VALID_CONTEXT_TOOLS))}" - ) - return raw - - @main.command() @click.option( "--port", @@ -1460,7 +1442,6 @@ Memory (Multi-Provider): from headroom.proxy.server import _get_code_aware_banner_status code_aware_line = f" Code-Aware: {_get_code_aware_banner_status(config)}" - context_tool_line = f" Context Tool: {_selected_context_tool()}" # Performance tuning section — only shown when at least one tuning var is active. _embed_socket = os.environ.get("HEADROOM_EMBEDDING_SERVER_SOCKET") or ( @@ -1490,7 +1471,6 @@ Starting proxy server... Memory: {memory_status} License: {license_status} {code_aware_line} -{context_tool_line} {extensions_line} {security_line} {stateless_line}{telemetry_line} diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 0afd8ec65..45dfa623e 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -1,7 +1,7 @@ """Wrap CLI commands to run through Headroom proxy. Usage: - headroom wrap claude # Start proxy + context tool + claude + headroom wrap claude # Start proxy + claude headroom wrap copilot -- --model ... # Start proxy + launch GitHub Copilot CLI headroom wrap codex # Start proxy + OpenAI Codex CLI headroom wrap aider # Start proxy + aider @@ -11,7 +11,6 @@ Usage: headroom wrap cursor # Start proxy + print Cursor config instructions headroom wrap grok-build # Start proxy + configure Grok Build headroom wrap openclaw # Install + configure OpenClaw plugin - headroom wrap claude --no-context-tool # Without CLI context-tool setup headroom wrap claude --port 9999 # Custom proxy port headroom wrap claude -- --model opus # Pass args to claude """ @@ -29,7 +28,6 @@ import signal import socket import subprocess import sys -import tempfile import time import urllib.parse from collections.abc import Callable @@ -205,15 +203,52 @@ def _write_text(path: Path, content: str) -> None: fsutil.write_text(path, content) +def _read_settings_for_write(path: Path) -> dict[str, Any]: + """Read a Claude settings file that is about to be mutated, or refuse to write. + + Callers previously fell back to ``{}`` when the file existed but would not + parse, then wrote that back — turning a hand-edited typo or a transient read + error into total loss of the user's ``permissions``/``env``/``hooks``. Abort + instead, mirroring ``mcp_registry.claude._read_json_for_write``: a malformed + config is the user's to fix, and no Headroom feature is worth erasing it. + + An **empty** file is the one safe exception and is treated as ``{}``: there + are no settings in it to lose, and refusing would strand the user behind a + file they cannot see anything wrong with. A zero-byte settings.json is also + the classic residue of an interrupted non-atomic write (the failure mode + :func:`headroom.fsutil.write_text` now prevents), so recovering from it is + exactly right. Anything non-empty that will not parse is treated as data. + """ + if not path.exists(): + return {} + try: + raw = _read_text(path) + except OSError as exc: + raise click.ClickException( + f"could not read {path} ({exc}). Fix or move it, then re-run — " + "refusing to overwrite it and lose your settings." + ) from exc + if not raw.strip(): + return {} + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + raise click.ClickException( + f"{path} is not valid JSON ({exc}). Fix or move it, then re-run — " + "refusing to overwrite it and lose your settings." + ) from exc + if not isinstance(payload, dict): + raise click.ClickException( + f"{path} does not contain a JSON object. Fix or move it, then re-run." + ) + return cast("dict[str, Any]", payload) + + def _append_text(path: Path, content: str) -> None: """Append to a text file as UTF-8 without translating line endings.""" fsutil.append_text(path, content) -_CONTEXT_TOOL_ENV = "HEADROOM_CONTEXT_TOOL" -_CONTEXT_TOOL_RTK = "rtk" -_CONTEXT_TOOL_LEAN_CTX = "lean-ctx" -_VALID_CONTEXT_TOOLS = {_CONTEXT_TOOL_RTK, _CONTEXT_TOOL_LEAN_CTX} _AGENT_SAVINGS_TARGET_AGENTS = {"claude", "codex", "cursor", "grok", "grok_build", "opencode"} _WRAP_PROXY_TIMEOUT_ENV = "HEADROOM_WRAP_PROXY_TIMEOUT" _WRAP_PROXY_TIMEOUT_DEFAULT_SECONDS = 45 @@ -382,26 +417,6 @@ def _live_wrap_module() -> Any: return cast(Any, sys.modules[__name__]) -def _selected_context_tool() -> str: - """Return the configured CLI context tool. - - RTK remains the default for backward compatibility. Set - ``HEADROOM_CONTEXT_TOOL=lean-ctx`` to let lean-ctx configure the supported - coding agent instead. - """ - - raw = os.environ.get(_CONTEXT_TOOL_ENV, "").strip().lower().replace("_", "-") - if not raw: - return _CONTEXT_TOOL_RTK - if raw == "leanctx": - raw = _CONTEXT_TOOL_LEAN_CTX - if raw not in _VALID_CONTEXT_TOOLS: - raise click.ClickException( - f"{_CONTEXT_TOOL_ENV} must be one of: {', '.join(sorted(_VALID_CONTEXT_TOOLS))}" - ) - return raw - - def _module_available(module_name: str) -> bool: """Return whether an optional module is installed without importing it.""" @@ -713,38 +728,95 @@ def _start_proxy( stdio_log_file.close() -def _rtk_opt_in() -> bool: - """Whether RTK CLI-command filtering was explicitly enabled. +# CLI context tools (rtk, lean-ctx) were removed from Headroom. The selector is +# kept only long enough to fail loudly: it lives in shell profiles, scripts and +# CI jobs, and silently ignoring it would look like Headroom had stopped working. +# See :mod:`headroom.context_tool_cleanup`, which uninstalls what they left behind. +_RETIRED_CONTEXT_TOOL_ENV = "HEADROOM_CONTEXT_TOOL" +_RETIRED_CONTEXT_TOOL_MESSAGE = ( + "CLI context tools (rtk, lean-ctx) have been removed from Headroom: they " + "rewrote shell commands through a third-party binary Headroom no longer " + "manages. Drop --context-tool / --no-context-tool and unset " + f"{_RETIRED_CONTEXT_TOOL_ENV}; `headroom wrap` uninstalls what they left " + "behind on first run." +) - RTK is opt-in (off by default): turn it on with ``--rtk`` (which sets - ``HEADROOM_RTK=1``) or by exporting ``HEADROOM_RTK=1``. ``--no-rtk`` remains - accepted as a deprecated no-op. + +def _retired_context_tool_callback(ctx: Any, param: Any, value: str | None) -> str | None: + """Click eager callback: reject any surviving context-tool selection. + + Also checks the env var (the callback runs on every wrap subcommand, flag + passed or not), so an exported ``HEADROOM_CONTEXT_TOOL`` fails with the same + message instead of silently doing nothing. """ - return os.environ.get("HEADROOM_RTK", "").strip().lower() in ("1", "true", "yes", "on") - - -def _rtk_flag_callback(ctx: Any, param: Any, value: bool) -> bool: - """Click eager callback: ``--rtk`` sets HEADROOM_RTK so the central RTK gate - (:func:`_rtk_opt_in`) sees the opt-in without threading a param through every - wrap subcommand.""" - if value: - os.environ["HEADROOM_RTK"] = "1" + if value is not None or os.environ.get(_RETIRED_CONTEXT_TOOL_ENV, "").strip(): + raise click.ClickException(_RETIRED_CONTEXT_TOOL_MESSAGE) return value -# Shared opt-in flag applied to every ``wrap`` subcommand. ``expose_value=False`` -# so no subcommand signature changes; it works purely through HEADROOM_RTK. -_rtk_option = click.option( - "--rtk", - is_flag=True, - default=False, +# Applied to every ``wrap`` subcommand. ``expose_value=False`` so no subcommand +# signature carries it; both spellings the flag ever had are accepted and +# rejected with one message. +_retired_context_tool_option = click.option( + "--context-tool", + "--no-context-tool", + default=None, + is_flag=False, + flag_value="", + metavar="TOOL", expose_value=False, is_eager=True, - callback=_rtk_flag_callback, - help="Enable RTK CLI-command filtering (opt-in; off by default). Also enabled by HEADROOM_RTK=1.", + hidden=True, + callback=_retired_context_tool_callback, + help="Removed: CLI context tools (rtk, lean-ctx) are no longer supported.", ) +def _should_purge_context_tools(ctx: click.Context) -> bool: + """Whether this invocation should run the retired-context-tool cleanup. + + Two exemptions, both about not doing filesystem surgery from a command the + caller expects to be inert: + + * ``wrap selfheal`` — runs from a SessionStart hook on every new + conversation, where rewriting ``~/.claude.json`` would race Claude Code's + own writer for no benefit. + * any ``--help`` invocation — help must stay read-only. Click resolves a + subcommand's help *after* this group callback, so it cannot be detected + from ``ctx``; scanning argv is blunt but correct, and a false positive only + defers the cleanup to the next real run. + """ + if ctx.invoked_subcommand == "selfheal": + return False + return not any(arg in ("--help", "-h") for arg in sys.argv[1:]) + + +def _report_context_tool_purge() -> None: + """Uninstall leftover rtk / lean-ctx state, reporting anything removed. + + Removing the integration code cannot help a machine that already ran the old + default: the Claude ``PreToolUse`` hook, the vendored binaries and the + injected hint-file guidance are all durable on disk. Running this once per + ``wrap`` / ``unwrap`` invocation is what actually makes the tools go away. + Silent when there is nothing to do, which is the steady state after the first + run, and never fatal — a cleanup failure must not block launching the tool. + + Reports on **stderr**: some subcommands (``wrap/unwrap openclaw + --prepare-only``) emit machine-readable JSON on stdout as their entire + contract, and a human cleanup line prepended to it breaks every + ``json.loads(stdout)`` consumer on the one run that has something to remove. + """ + from headroom.context_tool_cleanup import purge_context_tool_artifacts + + try: + removed = purge_context_tool_artifacts() + except Exception as exc: # pragma: no cover - defensive, cleanup is best-effort + click.echo(f"Warning: could not finish removing retired CLI context tools: {exc}", err=True) + return + for line in removed: + click.echo(f"Retired CLI context tool cleanup: {line}", err=True) + + def _serena_instructions_opt_in() -> bool: """Whether Serena instruction injection into the agent's hint file is enabled. @@ -775,7 +847,7 @@ def _serena_instructions_flag_callback(ctx: Any, param: Any, value: bool) -> boo # Shared opt-in flag for Serena instruction injection, applied to the wrap # subcommands that set up Serena. ``expose_value=False`` so no subcommand # signature changes; it works purely through HEADROOM_SERENA_INSTRUCTIONS. Same -# approach as _rtk_option above — set via the callback with NO ``envvar=`` so the +# approach as _code_memory_option below — set via the callback with NO ``envvar=`` so the # settings_store drift guard doesn't flag it. _serena_instructions_option = click.option( "--serena-instructions", @@ -792,7 +864,7 @@ _serena_instructions_option = click.option( # The code-memory MCP is Serena by default; turn it off with --code-memory none. # Selection flows through HEADROOM_CODE_MEMORY (set by the eager --code-memory # callback) so it works the same on every agent without threading a param -# through each subcommand — the same approach as _rtk_option above. +# through each subcommand — the same approach as _serena_instructions_option above. _CODE_MEMORY_ENV = "HEADROOM_CODE_MEMORY" _CODE_MEMORY_SERENA = "serena" _CODE_MEMORY_NONE = "none" @@ -850,165 +922,11 @@ _code_memory_option = click.option( ) -def _setup_rtk(verbose: bool = False) -> Path | None: - """Ensure rtk is installed and hooks are registered.""" - if not _rtk_opt_in(): - return None - from headroom.rtk import get_rtk_path - from headroom.rtk.installer import ensure_rtk, register_claude_hooks - - rtk_path = get_rtk_path() - - if rtk_path: - if verbose: - click.echo(f" rtk found at {rtk_path}") - else: - click.echo(" Downloading rtk (Rust Token Killer)...") - rtk_path = ensure_rtk() - if rtk_path: - click.echo(f" rtk installed at {rtk_path}") - else: - click.echo(" rtk download failed — continuing without it") - return None - - # Register hooks (idempotent) - if register_claude_hooks(rtk_path): - if verbose: - click.echo(" rtk hooks registered in Claude Code") - try: - linked = _ensure_rtk_on_path(rtk_path) - if linked and verbose: - click.echo(f" rtk linked onto PATH at {linked}") - except Exception as e: - if verbose: - click.echo(f" rtk PATH link skipped: {e}") - else: - click.echo(" rtk hook registration failed — continuing without it") - - return rtk_path - - -def _ensure_rtk_on_path(rtk_path: Path, path_dirs: list[str] | None = None) -> Path | None: - """Make the Headroom-managed rtk resolvable as a bare ``rtk`` on PATH. - - ``rtk init --global --auto-patch`` writes ``~/.claude/hooks/rtk-rewrite.sh``, - and ``rtk rewrite`` emits a bare ``rtk`` token at runtime that the hook feeds - back to the shell — so bare ``rtk`` has to resolve on PATH regardless of the - hook's contents. Since ``~/.headroom/bin`` (where Headroom installs rtk) is - not on PATH by default, that lookup fails and compression silently never - runs (issue #487). - - An earlier fix rewrote the generated hook to hard-code rtk's absolute path. - That mutates the hook *after* ``rtk init`` bakes in its expected SHA-256, so - rtk's integrity guard rejects it (``hook integrity check FAILED … RTK will - not execute``) and only absolutizes the hook's own ``rtk`` call — not the - bare ``rtk`` that ``rtk rewrite`` emits at runtime (issue #1631). Instead, - leave the canonical hook untouched and link the managed binary into a PATH - directory so bare ``rtk`` resolves. - - Idempotent and conservative: - * no-op if a ``rtk`` already resolves on PATH (managed or system); - * no-op on Windows (symlinks need privilege; hooks resolve differently); - * only creates/refreshes a symlink Headroom owns — never clobbers an - existing real file or foreign binary. - - Returns the link path that was created or already correct, else ``None``. - """ - if sys.platform == "win32": - return None - - # A bare `rtk` already resolves — the hook will find it, nothing to do. - if shutil.which("rtk"): - return None - - if path_dirs is None: - path_dirs = os.environ.get("PATH", "").split(os.pathsep) - - preferred = Path.home() / ".local" / "bin" - - # Prefer ~/.local/bin (conventionally on PATH), then any other PATH dir. - ordered: list[Path] = [] - if str(preferred) in path_dirs: - ordered.append(preferred) - for entry in path_dirs: - if not entry: - continue - candidate = Path(entry) - if candidate not in ordered: - ordered.append(candidate) - - target = rtk_path.resolve() - - for target_dir in ordered: - link = target_dir / "rtk" - try: - # Existing correct link — done. - if link.is_symlink() and link.resolve() == target: - return link - # Never clobber a real file or a link pointing elsewhere. - if link.exists() or link.is_symlink(): - continue - # Create ~/.local/bin on demand; other PATH dirs must already exist. - if target_dir == preferred: - target_dir.mkdir(parents=True, exist_ok=True) - if not target_dir.is_dir() or not os.access(target_dir, os.W_OK): - continue - link.symlink_to(target) - return link - except OSError: - continue - - return None - - -def _setup_lean_ctx_agent(agent: str, verbose: bool = False) -> Path | None: - """Run lean-ctx agent setup for the requested coding tool.""" - - from headroom.lean_ctx import get_lean_ctx_path - from headroom.lean_ctx.installer import ensure_lean_ctx - - lean_ctx = get_lean_ctx_path() - if not lean_ctx: - click.echo(" Downloading lean-ctx...") - lean_ctx = ensure_lean_ctx() - if not lean_ctx: - click.echo(" lean-ctx download failed — continuing without it") - return None - - try: - with tempfile.TemporaryDirectory(prefix="headroom-lean-ctx-") as setup_cwd: - # lean-ctx writes project-local files when initialized from a git - # checkout. Run from a non-project directory so setup is limited to - # home-scoped agent config such as ~/.codex or ~/.claude. - result = run( - [str(lean_ctx), "init", "--agent", agent], - capture_output=True, - text=True, - timeout=30, - cwd=setup_cwd, - ) - except Exception as e: - click.echo(f" lean-ctx setup failed — continuing without it: {e}") - return None - - if result.returncode != 0: - detail = (result.stderr or result.stdout).strip() - suffix = f": {detail}" if detail else "" - click.echo(f" lean-ctx setup failed — continuing without it{suffix}") - return None - - if verbose: - detail = result.stdout.strip() - if detail: - click.echo(f" lean-ctx configured for {agent}: {detail}") - else: - click.echo(f" lean-ctx configured for {agent}") - return lean_ctx - - # Hook-command markers Headroom manages in Claude settings.json. unwrap drops -# any hook entry whose command contains one of these. -_HEADROOM_HOOK_MARKERS = ("rtk-rewrite", "headroom-init-claude") +# any hook entry whose command contains one of these. (Retired rtk / lean-ctx +# hooks are removed separately, by +# headroom.context_tool_cleanup.purge_context_tool_artifacts.) +_HEADROOM_HOOK_MARKERS = ("headroom-init-claude",) # Env vars Headroom's init/wrap inject into Claude settings.json; unwrap removes # them. ENABLE_TOOL_SEARCH keeps Claude Code's tool deferral on behind the proxy @@ -1021,16 +939,14 @@ _HEADROOM_ENV_KEYS = ("ANTHROPIC_BASE_URL", "ENABLE_TOOL_SEARCH") _WRAP_SELFHEAL_HOOK_MARKER = "headroom-wrap-selfheal" -def _remove_claude_rtk_hooks(settings_path: Path | None = None) -> bool: +def _remove_claude_managed_hooks(settings_path: Path | None = None) -> bool: """Remove Headroom-managed entries from Claude settings.json. - Reverses what ``headroom init claude`` and ``rtk init --auto-patch`` add: + Reverses what ``headroom init claude`` adds: * PreToolUse / SessionStart hooks whose command contains a Headroom marker - (``rtk-rewrite`` or ``headroom-init-claude``), and + (``headroom-init-claude``), and * the ``ANTHROPIC_BASE_URL`` proxy-routing env var. - Unrelated settings and user-authored hooks are left untouched. (Previously - this only matched ``rtk-rewrite`` and returned early when no hooks existed, - so init's env + hooks survived unwrap.) + Unrelated settings and user-authored hooks are left untouched. """ path = settings_path or (Path.home() / ".claude" / "settings.json") @@ -1375,14 +1291,7 @@ def _ensure_claude_wrap_selfheal_hook(settings_path: Path) -> None: call mid-session, where a transient probe blip could clear a live session. Idempotent — an existing entry carrying the marker is not duplicated. """ - payload: dict[str, Any] = {} - if settings_path.exists(): - try: - payload = json.loads(_read_text(settings_path)) - except (OSError, json.JSONDecodeError): - payload = {} - if not isinstance(payload, dict): - payload = {} + payload = _read_settings_for_write(settings_path) hooks = dict(payload.get("hooks") or {}) if isinstance(payload.get("hooks"), dict) else {} entries = ( list(hooks.get("SessionStart") or []) if isinstance(hooks.get("SessionStart"), list) else [] @@ -1419,7 +1328,7 @@ def _ensure_claude_wrap_selfheal_hook(settings_path: Path) -> None: def _remove_claude_wrap_selfheal_hook(settings_path: Path) -> bool: """Remove the SessionStart self-heal hook that ``wrap claude`` installed (#2221). - Mirrors ``_remove_claude_rtk_hooks`` but matches only the wrap self-heal + Mirrors ``_remove_claude_managed_hooks`` but matches only the wrap self-heal marker in the project-local settings.local.json. Returns True if anything was removed. Unrelated hooks and user-authored entries are left untouched. """ @@ -1497,14 +1406,7 @@ def _write_claude_wrap_base_url( detected and self-healed (issue #1768). """ path = settings_path or (Path.cwd() / ".claude" / "settings.local.json") - payload: dict[str, Any] = {} - if path.exists(): - try: - payload = json.loads(_read_text(path)) - except (OSError, json.JSONDecodeError): - payload = {} - if not isinstance(payload, dict): - payload = {} + payload = _read_settings_for_write(path) env_map = dict(payload.get("env") or {}) if isinstance(payload.get("env"), dict) else {} key = _claude_wrap_base_url_env_key(foundry_mode=foundry_mode, vertex_mode=vertex_mode) previous = env_map.get(key) @@ -1683,8 +1585,8 @@ def _ensure_serena_dashboard_disabled(*, verbose: bool = False) -> None: # Marker-fenced guidance steering the agent toward Serena's symbol tools. -# Injected only when Serena is the active code-memory engine. Mirrors the RTK -# instruction block (idempotent, marker-guarded). +# Injected only when Serena is the active code-memory engine (idempotent, +# marker-guarded). _SERENA_MARKER = "" SERENA_INSTRUCTIONS_BLOCK = """\ @@ -2030,57 +1932,6 @@ def _setup_coding_compressor(registrar: Any, *, serena_context: str, **kwargs: A _CBM_MCP_SERVER_NAME = "codebase-memory-mcp" -# rtk instructions for tools without hook support (Codex, Cursor, Aider). -# These get injected into AGENTS.md / .cursorrules so the LLM voluntarily -# uses rtk-prefixed commands. Kept concise to minimize instruction overhead. -RTK_INSTRUCTIONS_BLOCK = """\ - -# RTK (Rust Token Killer) - Token-Optimized Commands - -When running shell commands, **always prefix with `rtk`**. This reduces context -usage by 60-90% with zero behavior change. If rtk has no filter for a command, -it passes through unchanged — so it is always safe to use. - -## Key Commands -```bash -# Git (59-80% savings) -rtk git status rtk git diff rtk git log - -# Files & Search (60-75% savings) -rtk ls rtk read rtk grep -rtk find rtk diff - -# Test (90-99% savings) — shows failures only -rtk pytest tests/ rtk cargo test rtk test - -# Build & Lint (80-90% savings) — shows errors only -rtk tsc rtk lint rtk cargo build -rtk prettier --check rtk mypy rtk ruff check - -# Analysis (70-90% savings) -rtk err rtk log rtk json -rtk summary rtk deps rtk env - -# GitHub (26-87% savings) -rtk gh pr view rtk gh run list rtk gh issue list - -# Infrastructure (85% savings) -rtk docker ps rtk kubectl get rtk docker logs - -# Package managers (70-90% savings) -rtk pip list rtk pnpm install rtk npm run ``` Microsoft Edge's Tracking Prevention classifies `unpkg.com` as a tracker and blocks it by default on Windows; locked-down corporate proxies block both hosts. On those machines none of the three scripts executed — no Tailwind CSS, no htmx polling, no Alpine bindings, plus an uncaught `ReferenceError: tailwind is not defined` from the inline `tailwind.config` assignment at `dashboard.html:21`. The dashboard rendered blank. Reported from a Windows user's console: ```text Tracking Prevention blocked access to storage for https://unpkg.com/htmx.org@1.9.10. Tracking Prevention blocked access to storage for https://unpkg.com/alpinejs@3.13.3/dist/cdn.min.js. ``` This vendors the three files and serves them from the proxy, so the dashboard has no external network dependency at all. Note for anyone triaging the same report: the `cdn.tailwindcss.com should not be used in production` line in that console output is **not** related. It is an unconditional `console.warn` in the Tailwind Play CDN build (no hostname guard), so it fires on every load, localhost included, and it still fires now that the bundle is self-hosted. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Vendored `headroom/dashboard/static/{tailwind.min.js,htmx.min.js,alpine.min.js}` — Tailwind Play CDN 3.4.17, htmx 1.9.10, Alpine 3.13.3, byte-for-byte as published. - `headroom/dashboard/__init__.py`: added `STATIC_DIR`. - `headroom/proxy/server.py`: mounted `/dashboard/static`, registered **before** `register_provider_routes`' catch-all so the asset requests are not tunneled to the wrapped upstream provider (same ordering constraint as the `/favicon.ico` route, GH #1787). `check_dir=False` so a missing assets directory 404s the dashboard JS rather than aborting proxy startup. - `headroom/dashboard/templates/{dashboard,settings}.html`: script `src` → `/dashboard/static/…`. - `NOTICE`: MIT / 0BSD attribution for the three vendored bundles. - `tests/test_dashboard_static_assets.py`: new. No packaging change needed — `[tool.maturin]` includes everything under `headroom/`, so the wheel picks the assets up. Wheel grows ~498 KB (407 KB of that is the Tailwind Play bundle). ## Testing - [x] Unit tests pass (`pytest`) — targeted, see note under *Not tested* - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_dashboard_static_assets.py tests/test_proxy_settings_endpoints.py -q tests/test_dashboard_static_assets.py ...... [ 21%] tests/test_proxy_settings_endpoints.py ...................... [100%] ============================== 28 passed in 4.47s ============================== $ ruff check . All checks passed! $ ruff format --check headroom/proxy/server.py headroom/dashboard/__init__.py tests/test_dashboard_static_assets.py 3 files already formatted $ mypy headroom Success: no issues found in 509 source files ``` ## Real Behavior Proof - **Environment:** macOS 15 (Darwin 25.4.0), Python 3.12.6, headless Chromium via Playwright, proxy served in-process with `create_app(ProxyConfig(optimize=False, cache_enabled=False, log_full_messages=True))` on `:8787`. - **Exact command / steps:** loaded `/dashboard` and `/dashboard/settings` with `wait_until="networkidle"`, then asserted the globals exist, that Tailwind actually generated CSS (computed style of a `px-3` element), and recorded every non-localhost request plus all `pageerror`/`console.error` events. - **Observed result:** ```text /dashboard | alpine: True | tailwind css: True | external: none | errors: none /dashboard/settings | alpine: True | tailwind css: True | external: none | errors: none /dashboard 200 text/html; charset=utf-8 191549 /dashboard/static/tailwind.min.js 200 text/javascript; charset=utf-8 407279 /dashboard/static/htmx.min.js 200 text/javascript; charset=utf-8 47755 /dashboard/static/alpine.min.js 200 text/javascript; charset=utf-8 43441 feed-toggle visible: True alpine loaded: True htmx: True tailwind: True tailwind applied (px-3 padding): 12px external hosts: none console errors: none ``` Zero external requests on either page, so the Edge/firewall failure mode is structurally gone rather than worked around. - **Not tested:** - No Windows machine available — the fix is verified as "makes zero external requests", which is the property the Windows failure depended on, but it has not been confirmed against Edge with Tracking Prevention on. Worth a check by someone on Windows before release. - Full `pytest` suite not run (targeted runs only); CI covers it. - `tests/test_dashboard/test_live_feed.py` still has 2 failures, both pre-existing and unrelated: those tests need a manually started proxy on `:8787` with `--log-messages`, and `test_live_feed_button_exists` asserts `is_visible()` with no wait for the `/stats` poll that flips `log_full_messages`. The other 2 in that file pass against this change, which is itself end-to-end evidence that Alpine and htmx work from the vendored bundles. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` ## Additional Notes - No issue number: reported directly rather than filed, so `Closes #` is omitted. Closed #22 ("Dashboard is not working") and closed #533 (Windows cp949 `get_dashboard_html()`) are different failures. - **Docs checklist item is N/A** — nothing user-facing changes; the dashboard URL and behaviour are identical. - Deliberately **not** switching to a real Tailwind CLI build. It would cut 407 KB to ~20 KB and silence the production warning, but it puts Node in the release path and silently leaves any class added to the 2,713-line template unstyled with no CI guard. The Play bundle behaves exactly as it does today, just served locally. Worth revisiting if wheel size becomes a problem (note the PyPI project-size ceiling). - Upgrades are now manual: bumping these three means re-downloading the files. Pinned versions are recorded in `NOTICE`. --- NOTICE | 14 ++++ headroom/dashboard/__init__.py | 4 + headroom/dashboard/static/alpine.min.js | 5 ++ headroom/dashboard/static/htmx.min.js | 1 + headroom/dashboard/static/tailwind.min.js | 83 +++++++++++++++++++ headroom/dashboard/templates/dashboard.html | 6 +- headroom/dashboard/templates/settings.html | 4 +- headroom/proxy/server.py | 14 ++++ ...est_dashboard_cache_lifetime_playwright.py | 8 +- tests/test_dashboard_cache_net_playwright.py | 8 +- tests/test_dashboard_cache_ttl_playwright.py | 23 ++++- tests/test_dashboard_static_assets.py | 53 ++++++++++++ 12 files changed, 215 insertions(+), 8 deletions(-) create mode 100644 headroom/dashboard/static/alpine.min.js create mode 100644 headroom/dashboard/static/htmx.min.js create mode 100644 headroom/dashboard/static/tailwind.min.js create mode 100644 tests/test_dashboard_static_assets.py diff --git a/NOTICE b/NOTICE index 547f55d09..67d8975de 100644 --- a/NOTICE +++ b/NOTICE @@ -41,3 +41,17 @@ NumPy (optional dependency) Copyright (c) 2005-2024, NumPy Developers Licensed under the BSD 3-Clause License https://github.com/numpy/numpy + +Vendored dashboard assets (headroom/dashboard/static/) +------------------------------------------------------ +Tailwind CSS 3.4.17 (Play CDN build) — MIT License +Copyright (c) Tailwind Labs, Inc. +https://github.com/tailwindlabs/tailwindcss + +htmx 1.9.10 — Zero-Clause BSD License +Copyright (c) 2020, Big Sky Software +https://github.com/bigskysoftware/htmx + +Alpine.js 3.13.3 — MIT License +Copyright (c) 2019-2025 Caleb Porzio and contributors +https://github.com/alpinejs/alpine diff --git a/headroom/dashboard/__init__.py b/headroom/dashboard/__init__.py index 4bb1e360f..f23a3cd46 100644 --- a/headroom/dashboard/__init__.py +++ b/headroom/dashboard/__init__.py @@ -4,6 +4,10 @@ from pathlib import Path DASHBOARD_DIR = Path(__file__).parent TEMPLATES_DIR = DASHBOARD_DIR / "templates" +# Vendored tailwind/htmx/alpine. Served locally because Edge's Tracking +# Prevention and corporate proxies block unpkg.com/cdn.tailwindcss.com, which +# left the dashboard unstyled and dataless on some Windows machines. +STATIC_DIR = DASHBOARD_DIR / "static" def get_dashboard_html() -> str: diff --git a/headroom/dashboard/static/alpine.min.js b/headroom/dashboard/static/alpine.min.js new file mode 100644 index 000000000..af7df6438 --- /dev/null +++ b/headroom/dashboard/static/alpine.min.js @@ -0,0 +1,5 @@ +(()=>{var tt=!1,rt=!1,V=[],nt=-1;function Vt(e){Sn(e)}function Sn(e){V.includes(e)||V.push(e),An()}function Ee(e){let t=V.indexOf(e);t!==-1&&t>nt&&V.splice(t,1)}function An(){!rt&&!tt&&(tt=!0,queueMicrotask(On))}function On(){tt=!1,rt=!0;for(let e=0;ee.effect(t,{scheduler:r=>{it?Vt(r):r()}}),ot=e.raw}function st(e){k=e}function Wt(e){let t=()=>{};return[n=>{let i=k(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}function q(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function O(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>O(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)O(n,t,!1),n=n.nextElementSibling}function v(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var Gt=!1;function Jt(){Gt&&v("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Gt=!0,document.body||v("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` - - + + + - + +