Files
hermes-agent/tests/tools/test_web_result_cache.py
T
Teknium 8adef09be8 fix(web): extract cache serves only after policy + provider gates; rescue and format/provider isolation
Review fixes for #94618 (all three blockers reproduced by the reviewer
through the real web_extract_tool):

1. Cache lookup moved AFTER provider resolution and strict-selection
   validation, and gated per-URL on the website blocklist policy — a
   blocklist-blocked or misconfigured-backend call now behaves exactly
   as it would without a cache instead of serving cached content.
2. Rescue-served extract batches are never cached (mirrors the search
   memo's exclusion), keeping one-shot rescue one-shot.
3. Cache entries now get dedicated per-(url, format, provider) files
   instead of sharing the URL-keyed truncate-store file — html and
   markdown (or two backends') copies of one URL no longer overwrite
   each other, and switching extract backends within the TTL never
   serves the old backend's rendering.

Also from review: per-process index tmp filename (cross-process writers
can no longer truncate each other mid-write) and held flight locks are
never evicted from the bounded lock table (eviction could have allowed
a duplicate paid request).

New regression tests for formats/provider keying; E2E harness extended
with policy-block, strict-selection, rescue-two-call, and dual-format
scenarios — 6/6 pass; original 13/13 still pass.
2026-08-25 04:21:45 -07:00

259 lines
9.8 KiB
Python

"""Tests for tools/web_result_cache.py — TTL memo for web_search and the
disk-backed extract cache, plus their wiring into web_tools.
The cache sits AFTER safety gates and around the paid vendor call only, so
these tests focus on: hit/miss semantics, TTL expiry, limit bucketing +
slicing, single-flight coalescing, error non-caching, the disable flag, and
extract index integrity (tamper = miss, oversized = not indexed).
"""
import json
import threading
import time
import pytest
import tools.web_result_cache as wrc
from tools.web_result_cache import (
SearchMemo,
bucket_limit,
extract_cache_get,
extract_cache_put,
normalize_query,
slice_search_response,
)
@pytest.fixture(autouse=True)
def _isolated_cache(tmp_path, monkeypatch):
"""Point the extract cache at a temp dir and force cache-on defaults."""
cache_dir = tmp_path / "cache" / "web"
cache_dir.mkdir(parents=True)
monkeypatch.setattr(wrc, "_cache_dir", lambda: cache_dir)
monkeypatch.setattr(wrc, "_web_config", lambda: {})
yield cache_dir
def _ok_response(n=10):
return {
"success": True,
"data": {"web": [
{"title": f"t{i}", "url": f"https://e.com/{i}", "description": "d"}
for i in range(n)
]},
}
# ── bucketing / normalization ────────────────────────────────────────────
def test_bucket_limit_rounds_up():
assert bucket_limit(1) == 10
assert bucket_limit(10) == 10
assert bucket_limit(11) == 20
assert bucket_limit(50) == 50
assert bucket_limit(99) == 100
assert bucket_limit(500) == 100
def test_normalize_query_folds_case_and_whitespace():
assert normalize_query(" Weather in\tVegas ") == "weather in vegas"
def test_slice_search_response_trims_to_requested_limit():
sliced = slice_search_response(_ok_response(10), 3)
assert len(sliced["data"]["web"]) == 3
# original untouched (defensive copy)
assert len(_ok_response(10)["data"]["web"]) == 10
# ── search memo ──────────────────────────────────────────────────────────
def test_search_memo_hit_within_ttl():
memo = SearchMemo()
memo.store("firecrawl", "weather in vegas", 5, _ok_response())
hit = memo.lookup("firecrawl", "Weather In Vegas", 8) # same bucket (10)
assert hit is not None and hit["success"]
def test_search_memo_miss_across_providers_and_buckets():
memo = SearchMemo()
memo.store("firecrawl", "q", 5, _ok_response())
assert memo.lookup("tavily", "q", 5) is None # different provider
assert memo.lookup("firecrawl", "q", 15) is None # different bucket
assert memo.lookup("firecrawl", "other", 5) is None # different query
def test_search_memo_expires_after_ttl(monkeypatch):
memo = SearchMemo()
memo.store("firecrawl", "q", 5, _ok_response())
monkeypatch.setattr(wrc, "ttl_seconds", lambda: 0.0)
# store used the old TTL; force expiry by faking monotonic forward
real = time.monotonic
monkeypatch.setattr(time, "monotonic", lambda: real() + 100 * 3600)
assert memo.lookup("firecrawl", "q", 5) is None
def test_search_memo_never_caches_failures():
memo = SearchMemo()
memo.store("firecrawl", "q", 5, {"success": False, "error": "boom"})
assert memo.lookup("firecrawl", "q", 5) is None
def test_search_memo_disabled_by_config(monkeypatch):
monkeypatch.setattr(wrc, "_web_config", lambda: {"cache_enabled": False})
memo = SearchMemo()
memo.store("firecrawl", "q", 5, _ok_response())
assert memo.lookup("firecrawl", "q", 5) is None
def test_search_memo_hit_returns_copy():
memo = SearchMemo()
memo.store("firecrawl", "q", 5, _ok_response())
first = memo.lookup("firecrawl", "q", 5)
first["data"]["web"].clear()
second = memo.lookup("firecrawl", "q", 5)
assert len(second["data"]["web"]) == 10
def test_single_flight_coalesces_concurrent_identical_queries():
"""Two threads race the same query: exactly one paid call happens."""
memo = SearchMemo()
calls = []
barrier = threading.Barrier(2)
results = []
def worker():
barrier.wait()
resp = memo.lookup("p", "q", 5)
if resp is None:
with memo.flight_lock("p", "q", 5):
resp = memo.lookup("p", "q", 5)
if resp is None:
calls.append(1) # the "paid" request
time.sleep(0.05) # widen the race window
resp = _ok_response()
memo.store("p", "q", 5, resp)
results.append(resp)
threads = [threading.Thread(target=worker) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(calls) == 1, "concurrent identical queries must share one request"
assert len(results) == 2 and all(r["success"] for r in results)
# ── extract cache ────────────────────────────────────────────────────────
def test_extract_cache_roundtrip(_isolated_cache):
extract_cache_put("https://example.com/a", "hello world", title="T")
hit = extract_cache_get("https://example.com/a")
assert hit is not None
assert hit["content"] == "hello world"
assert hit["title"] == "T"
assert hit["cached"] is True
def test_extract_cache_expired_entry_is_miss(monkeypatch, _isolated_cache):
extract_cache_put("https://e.com", "x")
monkeypatch.setattr(wrc, "ttl_seconds", lambda: 0.0)
assert extract_cache_get("https://e.com") is None
def test_extract_cache_format_participates_in_key(_isolated_cache):
extract_cache_put("https://e.com", "md content", format="markdown")
assert extract_cache_get("https://e.com", format="html") is None
assert extract_cache_get("https://e.com", format="markdown") is not None
def test_extract_cache_formats_do_not_overwrite_each_other(_isolated_cache):
"""Regression (#94618 review finding 3): html and markdown copies of one
URL must be stored independently — the original implementation shared a
URL-keyed backing file, so the later write clobbered the earlier one."""
extract_cache_put("https://e.com/page", "# MARKDOWN VERSION", format="markdown")
extract_cache_put("https://e.com/page", "<h1>HTML VERSION</h1>", format="html")
md = extract_cache_get("https://e.com/page", format="markdown")
html = extract_cache_get("https://e.com/page", format="html")
assert md is not None and md["content"] == "# MARKDOWN VERSION"
assert html is not None and html["content"] == "<h1>HTML VERSION</h1>"
def test_extract_cache_provider_participates_in_key(_isolated_cache):
"""Switching extract backends within the TTL must not serve the old
backend's rendering (#94618 review, additional risk 3)."""
extract_cache_put("https://e.com/p", "firecrawl version", provider="firecrawl")
assert extract_cache_get("https://e.com/p", provider="tavily") is None
hit = extract_cache_get("https://e.com/p", provider="firecrawl")
assert hit is not None and hit["content"] == "firecrawl version"
def test_extract_cache_oversized_page_not_indexed(_isolated_cache):
import tools.web_tools as wt
big = "x" * (wt.MAX_STORED_TEXT_CHARS + 1)
extract_cache_put("https://big.com", big)
assert extract_cache_get("https://big.com") is None
def test_extract_cache_tampered_index_path_is_miss(_isolated_cache, tmp_path):
"""An index entry pointing outside cache/web must never be read."""
outside = tmp_path / "outside.md"
outside.write_text("secret", encoding="utf-8")
index = {
wrc._url_digest("https://evil.com", None): {
"url": "https://evil.com",
"file": str(outside),
"title": "",
"fetched_at": time.time(),
}
}
(_isolated_cache / wrc._INDEX_FILENAME).write_text(json.dumps(index))
assert extract_cache_get("https://evil.com") is None
def test_extract_cache_missing_file_is_miss(_isolated_cache):
index = {
wrc._url_digest("https://gone.com", None): {
"url": "https://gone.com",
"file": str(_isolated_cache / "pruned.md"),
"title": "",
"fetched_at": time.time(),
}
}
(_isolated_cache / wrc._INDEX_FILENAME).write_text(json.dumps(index))
assert extract_cache_get("https://gone.com") is None
def test_extract_cache_corrupt_index_is_empty(_isolated_cache):
(_isolated_cache / wrc._INDEX_FILENAME).write_text("{not json")
assert extract_cache_get("https://any.com") is None
def test_extract_cache_disabled_by_config(monkeypatch, _isolated_cache):
extract_cache_put("https://e.com", "x")
monkeypatch.setattr(wrc, "_web_config", lambda: {"cache_enabled": False})
assert extract_cache_get("https://e.com") is None
def test_index_eviction_keeps_newest(monkeypatch, _isolated_cache):
monkeypatch.setattr(wrc, "_INDEX_MAX_ENTRIES", 3)
now = time.time()
index = {
f"digest{i}": {"url": f"u{i}", "file": "f", "fetched_at": now + i}
for i in range(6)
}
wrc._save_index(index)
saved = json.loads((_isolated_cache / wrc._INDEX_FILENAME).read_text())
assert len(saved) == 3
assert set(saved) == {"digest3", "digest4", "digest5"}
def test_ttl_clamping(monkeypatch):
monkeypatch.setattr(wrc, "_web_config", lambda: {"cache_ttl_minutes": 0})
assert wrc.ttl_seconds() == 60.0 # floor 1 minute
monkeypatch.setattr(wrc, "_web_config", lambda: {"cache_ttl_minutes": 99999})
assert wrc.ttl_seconds() == 1440 * 60.0 # ceiling 24h
monkeypatch.setattr(wrc, "_web_config", lambda: {"cache_ttl_minutes": "bogus"})
assert wrc.ttl_seconds() == 20 * 60.0 # default on garbage