You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
ContentRouter has an explicit safety rule (#1307): a lossy summarizer that emits no CCR retrieval marker must not be allowed to replace tool ground truth, because the original is then unrecoverable.
# headroom/transforms/content_router.py:1921# Lossy summarizers that emit a CCR retrieve marker only when they store the# original — a marker-less result from one of these is unrecoverable. Tool# ground truth (role="tool") must not be replaced by such a result (#1307).LOSSY_UNMARKED_STRATEGIES=frozenset(
{
CompressionStrategy.KOMPRESS,
CompressionStrategy.TEXT,
CompressionStrategy.CODE_AWARE,
}
)
CompressionStrategy.HTML is not in that set, but it belongs there. HTMLExtractor (headroom/transforms/html_extractor.py, 233 lines) never emits a retrieval marker — the string Retrieve original: hash= does not appear anywhere in the module — and it is aggressively lossy: it strips <script> and <style> and keeps only extracted prose.
The result is that a large HTML tool result is silently replaced by a few words of body text, booked as ~99.8% savings, with no way for the agent to retrieve what was removed.
The script/style content is not always noise. A tool result fetched precisely to inspect a page's inline JS, a CSP header block, or a JSON-LD <script type="application/ld+json"> payload is reduced to the visible prose, and the agent has no marker to retrieve the rest — it cannot even tell that anything was dropped. That is the exact failure mode #1307 was written to prevent for KOMPRESS/TEXT/CODE_AWARE.
Savings accounting also books this as ~99.8% saved, which is real token reduction but not honest compression, since it is not retrievable.
Exposure
enable_html_extractor defaults to True (content_router.py:1605), so this is live for anyone who has trafilatura importable. That is not a niche opt-in: html is a member of both aggregate extras in pyproject.toml —
all = ["headroom-ai[proxy,code,ml,memory,relevance,image,reports,otel,evals,voice,html,mcp,spreadsheet]"]
so pip install headroom-ai[all] and pip install headroom-ai[sandbox] both turn it on.
Why CI has never caught it
No CI job that runs the pytest suite installs the html extra, so trafilatura is absent wherever the tests run and HTMLExtractor never wins routing there. The extras installed across all workflows are [proxy], [dev], [dev,relevance], [dev,agno], [all] and [sandbox] — and the last two, which do pull trafilatura, are not test jobs:
ci.yml installs [sandbox] only for the wheels-only smoke job (it asserts proxy deps import and the proxy starts healthy — no pytest).
eval.yml installs [all] for the weekly/workflow_dispatch evaluation suite, which runs CCR and compression evals, not the unit tests.
security.yml references [all] for a dependency audit.
So adding the extra to one of those jobs is not enough; the fix has to put html on a job that actually runs the suite.
This surfaced while debugging two tests/test_transforms/test_dense_line_elider.py tests (added in 9263b42, #3685) that fail in any checkout where the html extra is installed: they assert the elider's marker, but HTMLExtractor wins routing first and returns unmarked output. Those two tests are pinned to enable_html_extractor=False in #3774 so they test the compressor they name — that is a test fix and deliberately does not address this defect.
Note the age: LOSSY_UNMARKED_STRATEGIES was introduced 2026-07-14 in a069979 (#1620), and the router already routed HTML at that commit. HTML has never been a member.
Suggested fix
Two independent parts, either of which closes the data-loss hole:
Have HTMLExtractor store the original and emit a retrieval marker, the way the elider and Kompress do. Strictly better — it keeps the savings and makes them retrievable — but it is the larger change.
(1) is the safe fix to ship for 0.39; (2) is the one that keeps the token win. They are not mutually exclusive: doing (2) makes (1) a no-op in practice, since a marked result is recoverable.
Whichever lands should come with a regression test that asserts a <script>-heavy HTML tool result is either left alone or comes back carrying Retrieve original: hash=, and CI should gain a job (or an existing job should gain the extra) that installs html — otherwise the next regression here is equally invisible.
Summary
ContentRouterhas an explicit safety rule (#1307): a lossy summarizer that emits no CCR retrieval marker must not be allowed to replace tool ground truth, because the original is then unrecoverable.CompressionStrategy.HTMLis not in that set, but it belongs there.HTMLExtractor(headroom/transforms/html_extractor.py, 233 lines) never emits a retrieval marker — the stringRetrieve original: hash=does not appear anywhere in the module — and it is aggressively lossy: it strips<script>and<style>and keeps only extracted prose.The result is that a large HTML tool result is silently replaced by a few words of body text, booked as ~99.8% savings, with no way for the agent to retrieve what was removed.
Reproduction
trafilaturamust be installed (thehtmlextra).Observed:
Direct
router.compress(html, context="tool_result")on a smaller sample shows the same thing with the strategy named:Why it matters
The script/style content is not always noise. A tool result fetched precisely to inspect a page's inline JS, a CSP header block, or a JSON-LD
<script type="application/ld+json">payload is reduced to the visible prose, and the agent has no marker to retrieve the rest — it cannot even tell that anything was dropped. That is the exact failure mode #1307 was written to prevent forKOMPRESS/TEXT/CODE_AWARE.Savings accounting also books this as ~99.8% saved, which is real token reduction but not honest compression, since it is not retrievable.
Exposure
enable_html_extractordefaults toTrue(content_router.py:1605), so this is live for anyone who hastrafilaturaimportable. That is not a niche opt-in:htmlis a member of both aggregate extras inpyproject.toml—all = ["headroom-ai[proxy,code,ml,memory,relevance,image,reports,otel,evals,voice,html,mcp,spreadsheet]"]sandbox = ["headroom-ai[proxy,code,relevance,reports,otel,html,mcp,spreadsheet]"]so
pip install headroom-ai[all]andpip install headroom-ai[sandbox]both turn it on.Why CI has never caught it
No CI job that runs the pytest suite installs the
htmlextra, sotrafilaturais absent wherever the tests run andHTMLExtractornever wins routing there. The extras installed across all workflows are[proxy],[dev],[dev,relevance],[dev,agno],[all]and[sandbox]— and the last two, which do pulltrafilatura, are not test jobs:ci.ymlinstalls[sandbox]only for the wheels-only smoke job (it asserts proxy deps import and the proxy starts healthy — no pytest).eval.ymlinstalls[all]for the weekly/workflow_dispatchevaluation suite, which runs CCR and compression evals, not the unit tests.security.ymlreferences[all]for a dependency audit.So adding the extra to one of those jobs is not enough; the fix has to put
htmlon a job that actually runs the suite.This surfaced while debugging two
tests/test_transforms/test_dense_line_elider.pytests (added in 9263b42, #3685) that fail in any checkout where thehtmlextra is installed: they assert the elider's marker, butHTMLExtractorwins routing first and returns unmarked output. Those two tests are pinned toenable_html_extractor=Falsein #3774 so they test the compressor they name — that is a test fix and deliberately does not address this defect.Note the age:
LOSSY_UNMARKED_STRATEGIESwas introduced 2026-07-14 in a069979 (#1620), and the router already routed HTML at that commit. HTML has never been a member.Suggested fix
Two independent parts, either of which closes the data-loss hole:
CompressionStrategy.HTMLtoLOSSY_UNMARKED_STRATEGIES. One-line change; makes the existing Compressing tool_result content corrupts command output and makes agents act on false information #1307 guard and_frozen_verdict_recoverablecover HTML, so an unmarked HTML extraction can no longer replace a tool result or be frozen across turns.HTMLExtractorstore the original and emit a retrieval marker, the way the elider and Kompress do. Strictly better — it keeps the savings and makes them retrievable — but it is the larger change.(1) is the safe fix to ship for 0.39; (2) is the one that keeps the token win. They are not mutually exclusive: doing (2) makes (1) a no-op in practice, since a marked result is recoverable.
Whichever lands should come with a regression test that asserts a
<script>-heavy HTML tool result is either left alone or comes back carryingRetrieve original: hash=, and CI should gain a job (or an existing job should gain the extra) that installshtml— otherwise the next regression here is equally invisible.