diff --git a/tests/tools/test_web_result_cache.py b/tests/tools/test_web_result_cache.py index 516b31e482..0f28132a07 100644 --- a/tests/tools/test_web_result_cache.py +++ b/tests/tools/test_web_result_cache.py @@ -147,26 +147,9 @@ def test_single_flight_coalesces_concurrent_identical_queries(): # ── extract cache ──────────────────────────────────────────────────────── -def _put_and_get(url="https://example.com/a", content="hello world", - fmt=None, **kw): - extract_cache_put(url, content, title="T", format=fmt, **kw) - return extract_cache_get(url, format=fmt) - - -def test_extract_cache_roundtrip(monkeypatch, _isolated_cache): - # _store_full_text writes to the real hermes dir; redirect it here. - stored = {} - - def fake_store(url, content): - p = _isolated_cache / "page.md" - p.write_text(content, encoding="utf-8") - stored["path"] = str(p) - return str(p) - - import tools.web_tools as wt - monkeypatch.setattr(wt, "_store_full_text", fake_store) - - hit = _put_and_get() +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" @@ -174,31 +157,40 @@ def test_extract_cache_roundtrip(monkeypatch, _isolated_cache): def test_extract_cache_expired_entry_is_miss(monkeypatch, _isolated_cache): - import tools.web_tools as wt - p = _isolated_cache / "page.md" - p.write_text("x", encoding="utf-8") - monkeypatch.setattr(wt, "_store_full_text", lambda u, c: str(p)) 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(monkeypatch, _isolated_cache): - import tools.web_tools as wt - p = _isolated_cache / "page.md" - p.write_text("md", encoding="utf-8") - monkeypatch.setattr(wt, "_store_full_text", lambda u, c: str(p)) - extract_cache_put("https://e.com", "md", format="markdown") +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_oversized_page_not_indexed(monkeypatch, _isolated_cache): +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", "

HTML VERSION

", 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"] == "

HTML VERSION

" + + +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 - monkeypatch.setattr( - wt, "_store_full_text", - lambda u, c: str(_isolated_cache / "should-not-happen.md"), - ) big = "x" * (wt.MAX_STORED_TEXT_CHARS + 1) extract_cache_put("https://big.com", big) assert extract_cache_get("https://big.com") is None @@ -239,10 +231,6 @@ def test_extract_cache_corrupt_index_is_empty(_isolated_cache): def test_extract_cache_disabled_by_config(monkeypatch, _isolated_cache): - import tools.web_tools as wt - p = _isolated_cache / "page.md" - p.write_text("x", encoding="utf-8") - monkeypatch.setattr(wt, "_store_full_text", lambda u, c: str(p)) 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 diff --git a/tools/web_result_cache.py b/tools/web_result_cache.py index 8163f51a18..9799e34612 100644 --- a/tools/web_result_cache.py +++ b/tools/web_result_cache.py @@ -29,6 +29,7 @@ Disable with ``web.cache_enabled: false``; both TTLs come from import hashlib import json import logging +import os import re import threading import time @@ -152,9 +153,16 @@ class SearchMemo: with self._store_lock: lock = self._key_locks.get(key) if lock is None: - # Bound the lock table alongside the store. + # Bound the lock table alongside the store — but never evict + # a HELD lock: dropping one lets a concurrent identical + # request mint a fresh lock and issue a duplicate paid call + # (review finding on #94618). locked() under _store_lock is + # a safe snapshot because flight locks are only ever + # acquired by callers that already hold a reference. if len(self._key_locks) > 256: - self._key_locks.clear() + self._key_locks = { + k: v for k, v in self._key_locks.items() if v.locked() + } lock = threading.Lock() self._key_locks[key] = lock return lock @@ -227,26 +235,57 @@ def _save_index(index: dict) -> None: reverse=True, )[:_INDEX_MAX_ENTRIES] index = dict(newest) - tmp = path.with_suffix(".tmp") + # Per-process tmp name: CLI, gateway, cron, and subagent processes + # all write this index; a shared fixed tmp filename would let two + # concurrent writers truncate each other mid-write. os.replace is + # atomic per writer, so the worst cross-process outcome is one + # writer's entry winning — a lost cache insert, never a torn file. + tmp = path.with_suffix(f".tmp.{os.getpid()}") tmp.write_text(json.dumps(index), encoding="utf-8") tmp.replace(path) except Exception as exc: # noqa: BLE001 logger.debug("Failed to save web extract cache index: %s", exc) -def _url_digest(url: str, format: Optional[str]) -> str: - # format participates in the key: an html extract is not a markdown one. - raw = f"{url}\n{format or 'markdown'}" +def _url_digest(url: str, format: Optional[str], provider: str = "") -> str: + # format AND provider participate in the key: an html extract is not a + # markdown one, and one backend's rendering of a page is not another's. + raw = f"{url}\n{format or 'markdown'}\n{provider or ''}" return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] -def extract_cache_get(url: str, format: Optional[str] = None) -> Optional[dict]: +def _entry_file_path(url: str, format: Optional[str], provider: str) -> Optional[Path]: + """Dedicated cache file per (url, format, provider) entry. + + Deliberately NOT the truncate-store file from ``_store_full_text`` — that + filename keys on URL alone, so html/markdown (or two providers') copies + of one URL would overwrite each other (review finding on #94618). The + truncate-store file keeps its role for read_file paging; these files + exist only for cache reuse and carry the full key in their name. + """ + d = _cache_dir() + if d is None: + return None + try: + from urllib.parse import urlparse + host = (urlparse(url).hostname or "page").replace(":", "_") + slug = re.sub(r"[^A-Za-z0-9._-]", "-", host)[:60].strip("-") or "page" + except Exception: # noqa: BLE001 + slug = "page" + return d / f"{slug}-{_url_digest(url, format, provider)}.cache.md" + + +def extract_cache_get( + url: str, + format: Optional[str] = None, + provider: str = "", +) -> Optional[dict]: """Return {'url','title','content'} for a fresh cached page, else None.""" if not cache_enabled(): return None with _index_lock: index = _load_index() - entry = index.get(_url_digest(url, format)) + entry = index.get(_url_digest(url, format, provider)) if not entry: return None if (time.time() - float(entry.get("fetched_at", 0))) >= ttl_seconds(): @@ -276,28 +315,32 @@ def extract_cache_put( content: str, title: str = "", format: Optional[str] = None, + provider: str = "", ) -> None: """Store one successful extraction's full clean text for TTL reuse. - Pages larger than the truncate-store ceiling are NOT indexed for reuse: - the stored copy would be incomplete, and serving it back as if whole - would silently lose the tail. (The capped file is still written by the - truncate-store path for read_file paging — we just don't index it.) + Writes a dedicated per-(url, format, provider) cache file (see + ``_entry_file_path``) — never the URL-keyed truncate-store file, which + different formats/providers would overwrite. Pages larger than the + truncate-store ceiling are not cached: serving a capped copy back as if + whole would silently lose the tail. """ if not cache_enabled() or not content: return try: - from tools.web_tools import MAX_STORED_TEXT_CHARS, _store_full_text + from tools.web_tools import MAX_STORED_TEXT_CHARS if len(content) > MAX_STORED_TEXT_CHARS: return - file_path = _store_full_text(url, content) - if not file_path: + file_path = _entry_file_path(url, format, provider) + if file_path is None: return + from tools.spill_safety import write_text_exclusive + write_text_exclusive(file_path, content, private=False, overwrite=True) with _index_lock: index = _load_index() - index[_url_digest(url, format)] = { + index[_url_digest(url, format, provider)] = { "url": url, - "file": file_path, + "file": str(file_path), "title": title or "", "fetched_at": time.time(), } diff --git a/tools/web_tools.py b/tools/web_tools.py index dd09b88d8c..0d9aca14f3 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -1160,22 +1160,136 @@ async def web_extract_tool( if not safe_urls: results = [] else: + backend = _get_extract_backend() + + # All seven providers (brave-free, ddgs, searxng, exa, parallel, + # tavily, firecrawl) now live as plugins. The dispatcher is a + # registry lookup + delegation. Some providers' extract() is + # async (parallel, firecrawl), others sync (exa, tavily) — we + # detect coroutine functions and await; sync functions run + # inline (the policy gate, SSRF re-check, etc. live inside the + # provider itself for the firecrawl per-URL loop). + _ensure_web_plugins_loaded() + from agent.web_search_registry import ( + get_active_extract_provider, + get_provider as _wsp_get_provider, + _disabled_web_plugin_for, + ) + + provider = _wsp_get_provider(backend) if backend else None + if provider is None or not provider.supports_extract(): + # When the configured name IS registered but doesn't support + # extract (search-only providers like brave-free / ddgs / + # searxng), surface that as a typed "search-only" error + # rather than silently switching backends. When the name + # isn't registered at all (typo / uninstalled plugin), fall + # through to the active-provider walk. + if provider is not None and not provider.supports_extract(): + return json.dumps( + { + "success": False, + "error": ( + f"{provider.display_name} is a search-only " + "backend and cannot extract URL content. " + "Set web.extract_backend to firecrawl, " + "tavily, exa, or parallel." + ), + }, + ensure_ascii=False, + ) + from tools.tool_backend_helpers import ( + selection_error, + selection_exists, + ) + + if backend and selection_exists("web"): + # Strict selection: a stored-but-unregistered backend + # errors by name instead of silently switching to + # whatever the availability walk finds. + disabled_key = _disabled_web_plugin_for(capability="extract") + if disabled_key: + _vendor = disabled_key.split("/", 1)[-1] + error_text = ( + f"web.extract_backend is set to '{_vendor}', but " + f"its plugin ('{disabled_key}') is disabled in " + f"config. Re-enable it with `hermes plugins " + f"enable {disabled_key}` (or remove it from " + "plugins.disabled)." + ) + else: + error_text = selection_error( + "web", + f"'{backend}'", + "no registered web extract provider has that name", + ) + return json.dumps( + {"success": False, "error": error_text}, + ensure_ascii=False, + ) + provider = get_active_extract_provider() + if provider is None: + # If the configured backend is a bundled web plugin the + # user explicitly disabled, the backend is set correctly + # and the real fix is to re-enable the plugin — say so + # instead of telling them to set web.extract_backend + # (which they already did). #40190 follow-up. + disabled_key = _disabled_web_plugin_for(capability="extract") + if disabled_key: + _vendor = disabled_key.split("/", 1)[-1] + return json.dumps( + { + "success": False, + "error": ( + f"web.extract_backend is set to '{_vendor}', " + f"but its plugin ('{disabled_key}') is disabled " + "in config. Re-enable it with " + f"`hermes plugins enable {disabled_key}` " + "(or remove it from plugins.disabled)." + ), + }, + ensure_ascii=False, + ) + return json.dumps( + { + "success": False, + "error": ( + "No web extract provider configured. " + "Set web.extract_backend to firecrawl, " + "tavily, exa, or parallel." + ), + }, + ensure_ascii=False, + ) + + # ── Extract cache (tools/web_result_cache.py) ───────────────── - # Disk-backed via the existing cache/web full-text store: a URL - # extracted within the TTL is served from disk instead of - # re-scraped. Sits AFTER the secret-URL and SSRF gates so hits - # skip only the vendor call, never a safety check. Cached - # entries re-run the normal truncate pipeline below, so a - # different caller char_limit still works off one scrape. + # Disk-backed via cache/web: a URL extracted within the TTL is + # served from disk instead of re-scraped. Deliberately placed + # AFTER the secret-URL gate, SSRF gate, provider resolution, and + # strict-selection validation, and gated per-URL on the website + # blocklist policy — a hit skips only the vendor call, never a + # control. Policy-blocked URLs are treated as cache misses so + # dispatch handles them exactly as it would without a cache. + # Keys include the provider and format, so switching backends or + # formats within the TTL never serves the other's content. from tools.web_result_cache import ( extract_cache_get as _extract_cache_get, extract_cache_put as _extract_cache_put, ) + from tools.website_policy import check_website_access as _check_site cached_results: Dict[int, Dict[str, Any]] = {} fetch_urls: List[str] = [] fetch_positions: List[int] = [] for position, url in enumerate(safe_urls): - hit = _extract_cache_get(url, format=format) + hit = None + try: + _policy_block = _check_site(url) + except Exception: # noqa: BLE001 — policy errors fail open like dispatch + _policy_block = None + if _policy_block is None: + hit = _extract_cache_get( + url, format=format, provider=provider.name + ) if hit is not None: cached_results[position] = hit else: @@ -1185,107 +1299,6 @@ async def web_extract_tool( if not fetch_urls: results = [cached_results[i] for i in range(len(safe_urls))] else: - backend = _get_extract_backend() - - # All seven providers (brave-free, ddgs, searxng, exa, parallel, - # tavily, firecrawl) now live as plugins. The dispatcher is a - # registry lookup + delegation. Some providers' extract() is - # async (parallel, firecrawl), others sync (exa, tavily) — we - # detect coroutine functions and await; sync functions run - # inline (the policy gate, SSRF re-check, etc. live inside the - # provider itself for the firecrawl per-URL loop). - _ensure_web_plugins_loaded() - from agent.web_search_registry import ( - get_active_extract_provider, - get_provider as _wsp_get_provider, - _disabled_web_plugin_for, - ) - - provider = _wsp_get_provider(backend) if backend else None - if provider is None or not provider.supports_extract(): - # When the configured name IS registered but doesn't support - # extract (search-only providers like brave-free / ddgs / - # searxng), surface that as a typed "search-only" error - # rather than silently switching backends. When the name - # isn't registered at all (typo / uninstalled plugin), fall - # through to the active-provider walk. - if provider is not None and not provider.supports_extract(): - return json.dumps( - { - "success": False, - "error": ( - f"{provider.display_name} is a search-only " - "backend and cannot extract URL content. " - "Set web.extract_backend to firecrawl, " - "tavily, exa, or parallel." - ), - }, - ensure_ascii=False, - ) - from tools.tool_backend_helpers import ( - selection_error, - selection_exists, - ) - - if backend and selection_exists("web"): - # Strict selection: a stored-but-unregistered backend - # errors by name instead of silently switching to - # whatever the availability walk finds. - disabled_key = _disabled_web_plugin_for(capability="extract") - if disabled_key: - _vendor = disabled_key.split("/", 1)[-1] - error_text = ( - f"web.extract_backend is set to '{_vendor}', but " - f"its plugin ('{disabled_key}') is disabled in " - f"config. Re-enable it with `hermes plugins " - f"enable {disabled_key}` (or remove it from " - "plugins.disabled)." - ) - else: - error_text = selection_error( - "web", - f"'{backend}'", - "no registered web extract provider has that name", - ) - return json.dumps( - {"success": False, "error": error_text}, - ensure_ascii=False, - ) - provider = get_active_extract_provider() - if provider is None: - # If the configured backend is a bundled web plugin the - # user explicitly disabled, the backend is set correctly - # and the real fix is to re-enable the plugin — say so - # instead of telling them to set web.extract_backend - # (which they already did). #40190 follow-up. - disabled_key = _disabled_web_plugin_for(capability="extract") - if disabled_key: - _vendor = disabled_key.split("/", 1)[-1] - return json.dumps( - { - "success": False, - "error": ( - f"web.extract_backend is set to '{_vendor}', " - f"but its plugin ('{disabled_key}') is disabled " - "in config. Re-enable it with " - f"`hermes plugins enable {disabled_key}` " - "(or remove it from plugins.disabled)." - ), - }, - ensure_ascii=False, - ) - return json.dumps( - { - "success": False, - "error": ( - "No web extract provider configured. " - "Set web.extract_backend to firecrawl, " - "tavily, exa, or parallel." - ), - }, - ensure_ascii=False, - ) - logger.info( "Web extract via %s: %d URL(s)", provider.name, len(fetch_urls) ) @@ -1293,6 +1306,7 @@ async def web_extract_tool( # Async-or-sync dispatch: parallel + firecrawl have async # extract(); exa + tavily are sync. import inspect + _extract_rescued = False try: if inspect.iscoroutinefunction(provider.extract): results = await provider.extract(fetch_urls, format=format) @@ -1304,6 +1318,7 @@ async def web_extract_tool( ) except Exception as exc: # noqa: BLE001 — candidate for rescue if _rescue_eligible(provider): + _extract_rescued = True failed = [ {"url": u, "title": "", "content": "", "error": str(exc)} for u in fetch_urls @@ -1322,27 +1337,34 @@ async def web_extract_tool( and all(r.get("error") for r in results) and _rescue_eligible(provider) ): + _extract_rescued = True results = await asyncio.to_thread( _rescue_extract, provider.name, fetch_urls, results ) # Cache each successful fetch's full clean text for TTL reuse # (best-effort; oversized pages are skipped by the cache). - for fetched_pos, fetched in enumerate(results): - if fetched_pos >= len(fetch_urls): - break - if fetched.get("error"): - continue - _content = ( - fetched.get("raw_content", "") or fetched.get("content", "") - ) - if _content: - _extract_cache_put( - fetch_urls[fetched_pos], - _content, - title=fetched.get("title", ""), - format=format, + # NEVER cache a rescue-served batch: it came from a ring + # vendor, not the chosen backend, and caching it would make + # the one-shot rescue sticky for a whole TTL — the next call + # must attempt the chosen backend again. + if not _extract_rescued: + for fetched_pos, fetched in enumerate(results): + if fetched_pos >= len(fetch_urls): + break + if fetched.get("error"): + continue + _content = ( + fetched.get("raw_content", "") or fetched.get("content", "") ) + if _content: + _extract_cache_put( + fetch_urls[fetched_pos], + _content, + title=fetched.get("title", ""), + format=format, + provider=provider.name, + ) # Merge fetched results back with cache hits, restoring the # safe_urls order the downstream reconstruction expects. diff --git a/website/docs/user-guide/features/web-search.md b/website/docs/user-guide/features/web-search.md index daf4465e6e..ceda417248 100644 --- a/website/docs/user-guide/features/web-search.md +++ b/website/docs/user-guide/features/web-search.md @@ -69,11 +69,11 @@ Repeat web calls within a short window are served from cache instead of the paid | Call | Cache | Scope | |------|-------|-------| | `web_search` — same query (case/whitespace-insensitive), same provider | In-memory memo | Per process | -| `web_extract` — same URL, same format | Full text stored under `~/.hermes/cache/web/` | Shared across CLI, gateway, cron, and subagent processes | +| `web_extract` — same URL, same format, same provider | Full text stored under `~/.hermes/cache/web/` | Shared across CLI, gateway, cron, and subagent processes | Concurrent identical searches (a parallel subagent fan-out firing the same query at once) are **coalesced into a single backend request** — the first caller pays; the rest share the response. Requested search limits are bucketed up to 10/20/50/100 so near-identical requests (`limit=5` vs `limit=8`) share one entry, with each caller receiving its requested count. -Only successful responses are cached. Failures always retry the backend, and responses served by the one-shot keyless rescue are never cached (the next call attempts your chosen backend again). Cached extracts re-run the normal truncation pipeline, so a different `char_limit` on the second call works off the same stored scrape. +Only successful responses are cached. Failures always retry the backend, responses served by the one-shot keyless rescue are never cached (the next call attempts your chosen backend again), and URLs matched by your `security.website_blocklist` are never served from cache. Cached extracts re-run the normal truncation pipeline, so a different `char_limit` on the second call works off the same stored scrape. ```yaml # ~/.hermes/config.yaml