Skip to content

HTMLExtractor replaces tool results with unrecoverable output: CompressionStrategy.HTML is missing from LOSSY_UNMARKED_STRATEGIES #3775

Description

@chopratejas

Summary

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.

Reproduction

trafilatura must be installed (the html extra).

from headroom.transforms.content_router import ContentRouter, ContentRouterConfig
from headroom.providers import OpenAIProvider
from headroom.tokenizer import Tokenizer

tok = Tokenizer(OpenAIProvider().get_token_counter("gpt-4o"), "gpt-4o")
MINIFIED_JS = "var a=1;function f(x){return x*2};" * 100
body = ("<!doctype html><html><head><script>" + MINIFIED_JS * 3
        + "</script></head><body><p>the answer is 42</p></body></html>")

messages = [
    {"role": "user", "content": "check the site"},
    {"role": "assistant", "content": [
        {"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "curl -s x"}}]},
    {"role": "user", "content": [
        {"type": "tool_result", "tool_use_id": "t1", "content": body}]},
    {"role": "assistant", "content": "ok"},
    {"role": "user", "content": "and now?"},
]

router = ContentRouter(ContentRouterConfig(enable_kompress=False, min_section_tokens=10))
res = router.apply(messages, tok)
blk = res.messages[2]["content"][0]["content"]
text = blk if isinstance(blk, str) else blk[0]["text"]
print(len(body), "->", len(text), repr(text))
print("marker:", "Retrieve original: hash=" in text)

Observed:

tool_result in : 10294 chars
tool_result out: 16 chars -> 'the answer is 42'
marker present : False

Direct router.compress(html, context="tool_result") on a smaller sample shows the same thing with the strategy named:

strategy_used : html
chain         : ['html']
input chars   : 3464
output chars  : 8   -> 'hi there'
savings_pct   : 99.79797979797979
marker present: False

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 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]"]
  • sandbox = ["headroom-ai[proxy,code,relevance,reports,otel,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:

  1. Add CompressionStrategy.HTML to LOSSY_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_recoverable cover HTML, so an unmarked HTML extraction can no longer replace a tool result or be frozen across turns.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions