Files
hermes-agent/tools/web_result_cache.py
ethernet 612d542281 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	.gitignore
#	Dockerfile
#	agent/onboarding.py
#	apps/desktop/electron/main.ts
#	apps/desktop/electron/pool-stop.ts
#	apps/desktop/src/components/model-picker.test.tsx
#	apps/desktop/src/store/updates.ts
#	apps/desktop/vite.config.ts
#	datagen-config-examples/run_browser_tasks.sh
#	docs/rca-ssl-cacert-post-git-pull.md
#	gateway/run.py
#	hermes_cli/backup.py
#	hermes_cli/credential_lifecycle.py
#	hermes_cli/dashboard_procs.py
#	hermes_cli/doctor_state.py
#	hermes_cli/env_loader.py
#	hermes_cli/gateway_windows.py
#	hermes_cli/local_runtime/endpoint.py
#	hermes_cli/psutil_android.py
#	hermes_cli/update_cmd.py
#	hermes_cli/update_cmd_windows.py
#	hermes_cli/web_routers/local_models.py
#	hermes_cli/web_server_config.py
#	hermes_cli/web_server_cron.py
#	plugins/memory/hindsight/__init__.py
#	plugins/memory/holographic/__init__.py
#	plugins/memory/honcho/cli.py
#	plugins/memory/mem0/__init__.py
#	plugins/platforms/google_chat/oauth.py
#	plugins/platforms/photon/adapter.py
#	scripts/ci/list_os_marked_tests.py
#	scripts/run_tests.sh
#	tests/agent/test_compression_stall_fallback.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/gateway/test_google_chat_oauth_dependencies.py
#	tests/hermes_cli/conftest.py
#	tests/hermes_cli/test_cli_init.py
#	tests/hermes_cli/test_gateway_migrate_multiplex.py
#	tests/hermes_cli/test_psutil_android_extract.py
#	tests/hermes_cli/test_relaunch.py
#	tests/hermes_cli/test_update_check.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_worktree_gc.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_retry_policy.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_autostash_conflict_recovery.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_commit_pin_rollback.py
#	tests/scripts/install/test_install_diverged_update.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_macos_launcher.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_ps1_ascii_only.py
#	tests/scripts/install/test_install_ps1_browser_install.py
#	tests/scripts/install/test_install_ps1_managed_node_swap.py
#	tests/scripts/install/test_install_ps1_native_stderr_eap.py
#	tests/scripts/install/test_install_ps1_node_path_for_npm.py
#	tests/scripts/install/test_install_ps1_python_fallback_venv.py
#	tests/scripts/install/test_install_ps1_resolver_strictmode.py
#	tests/scripts/install/test_install_ps1_uv_install_fallback.py
#	tests/scripts/install/test_install_ps1_uv_powershell_host.py
#	tests/scripts/install/test_install_ps1_venv_process_tree.py
#	tests/scripts/install/test_install_ps1_venv_recreate_safety.py
#	tests/scripts/install/test_install_ps1_venv_rename_abort.py
#	tests/scripts/install/test_install_ps1_venv_transaction_boundary.py
#	tests/scripts/install/test_install_ps1_web_server_syntax_probe.py
#	tests/scripts/install/test_install_scripts_computer_use.py
#	tests/scripts/install/test_install_sh_acp_launcher.py
#	tests/scripts/install/test_install_sh_bootstrap_marker.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_install_method_stamp.py
#	tests/scripts/install/test_install_sh_node_deps_failure.py
#	tests/scripts/install/test_install_sh_node_deps_workspaces.py
#	tests/scripts/install/test_install_sh_node_global_prefix.py
#	tests/scripts/install/test_install_sh_node_npm_check.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_node_probe.py
#	tests/scripts/install/test_install_sh_node_tarball_without_xz.py
#	tests/scripts/install/test_install_sh_pythonpath_sanitization.py
#	tests/scripts/install/test_install_sh_reuse_supported_python.py
#	tests/scripts/install/test_install_sh_root_fhs_uv_python_path.py
#	tests/scripts/install/test_install_sh_setup_wizard_tty_probe.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_termux_network_prereqs.py
#	tests/scripts/install/test_install_sh_termux_python_bounds.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_project_metadata.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_tts_pythonpath_fallback.py
#	tests/tui_gateway/test_hosted_room_driver_runtime.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	tools/lazy_deps.py
#	tools/voice_mode.py
#	uv.lock
#	website/docs/developer-guide/macos-bundle-updates.md
#	website/docs/developer-guide/pm-audit-status.md
#	website/docs/developer-guide/shared-bundle-builds.md
#	website/docs/developer-guide/source-update-completion.md
#	website/docs/developer-guide/stable-releases.md
2026-09-14 15:38:34 -04:00

312 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Result caching for web_search / web_extract; both caches TTL-bounded (default 20 min,
``web.cache_ttl_minutes``; disable with ``web.cache_enabled: false``), only successful responses cache.
* **Search memo** — in-memory, per-process, single-flighted: concurrent identical queries share one
paid request. Limits bucket to 10/20/50/100 so near-identical requests share an entry.
* **Extract cache** — disk-backed under ``cache/web`` (cross-process) with a JSON sidecar index:
URL digest → (file, fetched_at, title). Hits re-run the normal truncate pipeline.
Lives here, not in tool dispatch, so hits sit *after* every safety check and skip only the vendor call.
"""
import hashlib
import json
import logging
import re
import threading
import time
from contextlib import suppress
from pathlib import Path
from typing import Dict, Optional, Tuple
from urllib.parse import urlparse
from utils import atomic_json_write
logger = logging.getLogger(__name__)
# Requested limits round UP to a bucket so cache keys collide on purpose.
_LIMIT_BUCKETS = (10, 20, 50, 100)
DEFAULT_TTL_MINUTES = 20
_INDEX_FILENAME = "extract-index.json"
_INDEX_MAX_ENTRIES = 500 # oldest entries evicted past this
def _web_config() -> dict:
try:
from tools.web_tools import _load_web_config
return _load_web_config()
except Exception: # noqa: BLE001 — config problems must never break tools
return {}
def cache_enabled() -> bool:
"""Both caches honor ``web.cache_enabled`` (default: on)."""
return True if (val := _web_config().get("cache_enabled")) is None else bool(val)
def ttl_seconds() -> float:
"""TTL from ``web.cache_ttl_minutes`` (default 20, clamped 1–1440)."""
raw = _web_config().get("cache_ttl_minutes")
try:
minutes = float(raw) if raw is not None else DEFAULT_TTL_MINUTES
except (TypeError, ValueError):
minutes = DEFAULT_TTL_MINUTES
return max(1.0, min(minutes, 1440.0)) * 60.0
def bucket_limit(limit: int) -> int:
"""Round a requested result count up to the nearest bucket."""
return next((b for b in _LIMIT_BUCKETS if limit <= b), _LIMIT_BUCKETS[-1])
def normalize_query(query: str) -> str:
"""Case-fold and collapse whitespace so trivial variants share an entry."""
return re.sub(r"\s+", " ", (query or "").strip().lower())
def _host_slug(url: str) -> str:
"""Filesystem-safe hostname slug for cache filenames (``"page"`` when hostless). Shared with
tools.web_tools_truncate."""
host = (urlparse(url).hostname or "page").replace(":", "_")
return re.sub(r"[^A-Za-z0-9._-]", "-", host)[:60].strip("-") or "page"
def _deep_copy(response: dict) -> dict:
"""Defensive copy so callers mutating a hit never corrupt the cached entry."""
return json.loads(json.dumps(response))
# ─── Search memo (in-memory, single-flight) ───────────────────────────────────
class SearchMemo:
"""TTL memo + single-flight coalescer for search responses. Thread-safe: the parallel tool-dispatch pool
and subagents share this process, so identical queries genuinely race; per-key locks make the losers
wait for (and share) the winner's response."""
def __init__(self) -> None:
self._store: Dict[tuple, Tuple[float, dict]] = {} # key -> (expires_at, response)
self._store_lock = threading.Lock()
self._key_locks: Dict[tuple, threading.Lock] = {}
@staticmethod
def _key(provider: str, query: str, limit: int) -> tuple:
return (provider, normalize_query(query), bucket_limit(limit))
def lookup(self, provider: str, query: str, limit: int) -> Optional[dict]:
if not cache_enabled():
return None
key = self._key(provider, query, limit)
with self._store_lock:
hit = self._store.get(key)
if hit is None or time.monotonic() >= hit[0]:
self._store.pop(key, None)
return None
logger.info("web_search cache hit: %r via %s", query, provider)
return _deep_copy(hit[1])
def store(self, provider: str, query: str, limit: int, response: dict) -> None:
"""Cache a SUCCESSFUL response for the bucketed key."""
if not cache_enabled() or not isinstance(response, dict) or not response.get("success"):
return
key = self._key(provider, query, limit)
with self._store_lock:
now = time.monotonic() # opportunistic expiry sweep bounds memory
for k in [k for k, (exp, _) in self._store.items() if now >= exp]:
del self._store[k]
self._store[key] = (now + ttl_seconds(), _deep_copy(response))
def flight_lock(self, provider: str, query: str, limit: int) -> threading.Lock:
"""Per-key lock held around lookup-miss → paid request → store."""
key = self._key(provider, query, limit)
with self._store_lock:
lock = self._key_locks.get(key)
if lock is None:
# Bound the lock table, but never evict a HELD lock: dropping one lets a concurrent
# identical request mint a fresh lock and issue a duplicate paid call. locked() is a
# safe snapshot under _store_lock because holders already have their reference.
# See #94618.
if len(self._key_locks) > 256:
self._key_locks = {k: v for k, v in self._key_locks.items() if v.locked()}
lock = self._key_locks[key] = threading.Lock()
return lock
def clear(self) -> None:
"""Drop all cached entries (tests; config changes)."""
with self._store_lock:
self._store.clear()
self._key_locks.clear()
search_memo = SearchMemo()
def slice_search_response(response: dict, limit: int) -> dict:
"""Trim a bucketed response's result list down to the caller's limit."""
try:
web = response.get("data", {}).get("web")
if isinstance(web, list) and len(web) > limit:
out = _deep_copy(response)
out["data"]["web"] = out["data"]["web"][:limit]
return out
except Exception: # noqa: BLE001
pass
return response
# ─── Extract cache (disk-backed, reuses cache/web) ────────────────────────────
_index_lock = threading.Lock()
def _cache_dir() -> Optional[Path]:
try:
from hermes_constants import get_hermes_dir
d = get_hermes_dir("cache/web", "web_cache")
d.mkdir(parents=True, exist_ok=True)
return d
except Exception: # noqa: BLE001
return None
def _load_index() -> dict:
try:
data = json.loads((_cache_dir() / _INDEX_FILENAME).read_text(encoding="utf-8-sig"))
return data if isinstance(data, dict) else {}
except Exception: # noqa: BLE001 — missing/corrupt index == empty cache
return {}
def _save_index(index: dict) -> None:
if (d := _cache_dir()) is None:
return
path = d / _INDEX_FILENAME
try:
if len(index) > _INDEX_MAX_ENTRIES:
newest = sorted(index.items(), key=lambda kv: kv[1].get("fetched_at", 0), reverse=True)
index = dict(newest[:_INDEX_MAX_ENTRIES])
# CLI, gateway, cron, and subagents all write this index; the replace is atomic, so the worst case
# under concurrent writers is a lost insert, never a truncated index.
atomic_json_write(path, index, indent=None)
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], provider: str = "") -> str:
# format AND provider are part of the key: html != markdown, and one backend's rendering is not another's.
raw = f"{url}\n{format or 'markdown'}\n{provider or ''}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
def _entry_file_path(url: str, format: Optional[str], provider: str) -> Optional[Path]:
"""Dedicated cache file per (url, format, provider) — deliberately NOT the truncate-store file
(keyed on URL alone), which html/markdown or two providers' copies of one URL would overwrite.
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. See #94618.
"""
if (d := _cache_dir()) is None:
return None
slug = "page"
with suppress(Exception):
slug = _host_slug(url)
return d / f"{slug}-{_url_digest(url, format, provider)}.cache.md"
def _host_matches_pattern(host: str, pattern: str) -> bool:
"""Case-insensitive: exact, ``*.wildcard``, or bare-domain suffix
(``mysite.dev`` also matches ``preview.mysite.dev``)."""
host = host.lower().strip(".")
pattern = (pattern or "").lower().strip().strip(".").removeprefix("*.")
return bool(pattern) and (host == pattern or host.endswith("." + pattern))
def _is_cache_exempt_host(url: str) -> bool:
"""True when the host matches ``web.cache_exempt_hosts`` — sites the user develops over public DNS
(staging, tunnels, previews) that must fetch live."""
try:
patterns = _web_config().get("cache_exempt_hosts") or []
host = (urlparse(url).hostname or "").strip("[]")
if not isinstance(patterns, (list, tuple)) or not host:
return False
return any(_host_matches_pattern(host, str(p)) for p in patterns)
except Exception: # noqa: BLE001 — config problems never break tools
return False
def _is_local_dev_url(url: str) -> bool:
"""True for loopback/private/LAN URLs — never cached: they are the user's own fast-changing dev servers.
Hostname heuristics only, no DNS: this is a freshness decision, not a security boundary (SSRF enforcement
lives in tools/url_safety.py, which blocks these by default anyway)."""
try:
host = (urlparse(url).hostname or "").strip("[]").lower()
# Unparseable → don't cache; single-label (no "." / ":") == LAN name, not public DNS.
if not host or host == "localhost" or host.endswith((".localhost", ".local")):
return True
if "." not in host and ":" not in host:
return True
import ipaddress
try:
ip = ipaddress.ip_address(host)
except ValueError:
return False # public DNS name
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_unspecified
except Exception: # noqa: BLE001 — on doubt, don't cache
return True
def _cacheable(url: str) -> bool:
"""Extract-cache gate: enabled, not a local-dev host, not user-exempted."""
return cache_enabled() and not (_is_local_dev_url(url) or _is_cache_exempt_host(url))
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 _cacheable(url):
return None
with _index_lock:
entry = _load_index().get(_url_digest(url, format, provider))
if not entry or (time.time() - float(entry.get("fetched_at", 0))) >= ttl_seconds():
return None
try:
file_path, cache_root = Path(entry["file"]), _cache_dir()
# The index is plain JSON on disk; never let a tampered entry read outside cache/web.
if cache_root.resolve() not in file_path.resolve().parents:
return None
content = file_path.read_text(encoding="utf-8-sig")
except Exception: # noqa: BLE001 — evicted/pruned file == miss (or no cache dir)
return None
logger.info("web_extract cache hit: %s", url)
return {"url": url, "title": entry.get("title", ""), "content": content, "error": None, "cached": True}
def extract_cache_put(
url: str, content: str, title: str = "", format: Optional[str] = None, provider: str = ""
) -> None:
"""Store one successful extraction's full clean text for TTL reuse; pages over the truncate-store
ceiling are not cached (serving a capped copy back as if whole would silently lose the tail)."""
if not content or not _cacheable(url):
return
try:
from tools.web_tools_truncate import MAX_STORED_TEXT_CHARS
file_path = _entry_file_path(url, format, provider)
if len(content) > MAX_STORED_TEXT_CHARS or 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, provider)] = {
"url": url, "file": str(file_path), "title": title or "", "fetched_at": time.time(),
}
_save_index(index)
except Exception as exc: # noqa: BLE001 — cache writes are best-effort
logger.debug("Failed to cache web extract for %s: %s", url, exc)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Any # noqa: F401,E402
from typing import List # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----