ulysses/services/search
Kenny Van de Maele 074a1e6eff
fix(search): add download budgets to web_fetch with truncation notice and hard ceiling (#3955)
* fix(search): add download budgets to web_fetch with truncation notice and hard ceiling

MAX_OUTPUT_CHARS only trims what the agent sees; fetch_webpage_content
buffered and cached the entire response body first, so a large or hostile
URL could pull arbitrarily many bytes into memory and the content cache.

The fetch is now a capped streaming GET (SSRF redirect guard unchanged):
a soft default budget (WEB_FETCH_SOFT_MAX_BYTES, 2 MB), a per-call
override via full/max_bytes on the web_fetch tool, and a hard ceiling
(WEB_FETCH_HARD_MAX_BYTES, 20 MB) that the override can never exceed.
When Content-Length already declares a body over the ceiling the fetch
is refused before any body bytes are buffered. Truncated results carry
truncated/fetched_bytes/total_bytes, the tool output leads with a
partial-content notice telling the model how to re-fetch with full=true,
and the tool schema documents the flag. A truncated PDF is reported as
a budget error since a cut PDF is unparseable. The effective cap is part
of the content-cache key so a truncated fetch is never served to a
full-budget request.

Existing tests that faked httpx.get or the old _get_public_url signature
are adapted to the streaming interface; behavior pins are unchanged.

Fixes #3812

* fix(search): close compressed-body cap bypass and protect the partial notice

Addresses RaresKeY's review on #3955:

- Force Accept-Encoding: identity for the capped fetch. With gzip/deflate the
  wire bytes (and Content-Length) can be a fraction of the decoded body, so a
  tiny compressed response could pass the hard-cap preflight and then expand
  past the ceiling in a single decoded chunk before the streamed cap could
  slice it. Identity makes Content-Length the true body size and keeps each
  streamed chunk bounded by the network read, so the hard ceiling actually
  bounds memory.
- Lead web_fetch output with the partial-content notice and cap the page
  title. The notice is the user-facing contract for partial fetches, but the
  title is untrusted, uncapped page content; placed ahead of the notice a giant
  title could push it past MAX_OUTPUT_CHARS and drop it. The notice now leads
  and the title is capped as a second guard.

Adds regressions: the fetch advertises identity encoding, and a truncated
result with an oversized title still surfaces the partial notice.

* fix(search): reject compressed responses that ignore the identity request

Requesting Accept-Encoding: identity is not enough on its own: a server can
ignore it and still return Content-Encoding: gzip, and httpx.iter_bytes would
decode that, so a tiny compressed body could balloon into one decoded chunk
far past the hard cap before the streamed loop slices it (and Content-Length,
the compressed wire length, makes the preflight and size metadata unreliable).

Refuse a non-identity Content-Encoding before reading the body. Adds a
regression where the server ignores the identity request and returns gzip;
the fetch is refused before any body is decoded.
2026-06-15 17:38:09 +00:00
..
__init__.py Odysseus v1.0 2026-05-31 23:58:26 +09:00
analytics.py fix: search analytics FileHandler crashes on startup writing to read-only image layer (#2366) 2026-06-07 19:26:22 +02:00
cache.py fix(search): write cache under DATA_DIR, guard mkdir against read-only path (#3334) 2026-06-07 22:37:12 +01:00
content.py fix(search): add download budgets to web_fetch with truncation notice and hard ceiling (#3955) 2026-06-15 17:38:09 +00:00
core.py fix: web search content blocks numbered by fetch completion order break citations (#1672) 2026-06-03 14:22:55 +09:00
providers.py refactor(search): import REQUEST_TIMEOUT from constants in providers.py (#4331) 2026-06-15 17:22:08 +00:00
query.py fix: _extract_entities crashes on a non-string query (#1724) 2026-06-03 13:30:28 +09:00
ranking.py Word-boundary match for snippet and subject-term ranking (#1473 follow-up) (#2556) 2026-06-05 08:04:31 +01:00
service.py fix: SearchService.search() calls comprehensive_web_search incorrectly (broken public API) (#1720) 2026-06-03 13:33:56 +09:00