From 6ce7ab8bfb3fce3ba116f52a11a438d6c7e4c03d Mon Sep 17 00:00:00 2001 From: Leegenux Date: Sun, 12 Jul 2026 17:45:28 +0800 Subject: [PATCH] feat(browser): make snapshot threshold configurable --- cli-config.yaml.example | 6 + contributors/emails/evanlee99@qq.com | 1 + hermes_cli/config_defaults.py | 1 + .../tools/test_browser_snapshot_threshold.py | 219 ++++++++++++++++++ tools/browser_camofox.py | 15 +- tools/browser_tool.py | 67 +++++- website/docs/user-guide/features/browser.md | 14 +- 7 files changed, 303 insertions(+), 20 deletions(-) create mode 100644 contributors/emails/evanlee99@qq.com create mode 100644 tests/tools/test_browser_snapshot_threshold.py diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 423426a546..e98391601f 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -494,6 +494,12 @@ browser: extension_control: enabled: false + # Maximum characters of snapshot content before truncate-and-store. + # Increase for long pages (for example, documentation or financial reports), + # or decrease to reduce context usage. Minimum: 1000; default: 15000 + # (same per-page budget as web_extract). + # snapshot_threshold: 15000 + # ============================================================================= # Tool Loop Guardrails # ============================================================================= diff --git a/contributors/emails/evanlee99@qq.com b/contributors/emails/evanlee99@qq.com new file mode 100644 index 0000000000..bfb94a9bfe --- /dev/null +++ b/contributors/emails/evanlee99@qq.com @@ -0,0 +1 @@ +Leegenux diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 05b36c0ce2..905d5e5be0 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -543,6 +543,7 @@ DEFAULT_CONFIG = { "backend": "", "inactivity_timeout": 120, "command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.) + "snapshot_threshold": 15000, # Max chars before snapshot truncate-and-store (min 1000) "record_sessions": False, # Auto-record browser sessions as WebM videos "headed": False, # Local mode: launch Chromium with a visible window (also skips per-turn cleanup so the window persists between turns; idle reaper still applies) "allow_private_urls": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.) diff --git a/tests/tools/test_browser_snapshot_threshold.py b/tests/tools/test_browser_snapshot_threshold.py new file mode 100644 index 0000000000..e91cdde4b1 --- /dev/null +++ b/tests/tools/test_browser_snapshot_threshold.py @@ -0,0 +1,219 @@ +"""Behavior tests for config-driven browser snapshot thresholds.""" + +import json +from unittest.mock import Mock + +import pytest + +from hermes_cli.config import DEFAULT_CONFIG +from tools import browser_camofox, browser_tool + + +@pytest.fixture(autouse=True) +def isolated_snapshot_threshold(tmp_path, monkeypatch): + """Use a real, isolated config file and reset module-level caches.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + original_cached = browser_tool._cached_snapshot_threshold + original_resolved = browser_tool._snapshot_threshold_resolved + browser_tool._cached_snapshot_threshold = None + browser_tool._snapshot_threshold_resolved = False + yield tmp_path + browser_tool._cached_snapshot_threshold = original_cached + browser_tool._snapshot_threshold_resolved = original_resolved + + +def _write_threshold(hermes_home, value): + (hermes_home / "config.yaml").write_text( + f"browser:\n snapshot_threshold: {value}\n", + encoding="utf-8", + ) + + +def _long_snapshot(chars: int) -> str: + line = "button [ref=e1] example content\n" + return line * ((chars // len(line)) + 2) + + +def test_default_matches_browser_config(isolated_snapshot_threshold): + assert browser_tool.get_browser_snapshot_threshold() == ( + DEFAULT_CONFIG["browser"]["snapshot_threshold"] + ) + + +def test_reads_profile_config_override(isolated_snapshot_threshold): + _write_threshold(isolated_snapshot_threshold, 30000) + + assert browser_tool.get_browser_snapshot_threshold() == 30000 + + +def test_clamps_small_values_to_safe_floor(isolated_snapshot_threshold): + _write_threshold(isolated_snapshot_threshold, 10) + + assert browser_tool.get_browser_snapshot_threshold() == ( + browser_tool.MIN_SNAPSHOT_THRESHOLD + ) + + +def test_invalid_values_fall_back_to_default(isolated_snapshot_threshold): + _write_threshold(isolated_snapshot_threshold, "not-a-number") + + assert browser_tool.get_browser_snapshot_threshold() == ( + browser_tool.DEFAULT_SNAPSHOT_THRESHOLD + ) + + +def test_cleanup_reloads_updated_profile_config(isolated_snapshot_threshold): + _write_threshold(isolated_snapshot_threshold, 12000) + assert browser_tool.get_browser_snapshot_threshold() == 12000 + + _write_threshold(isolated_snapshot_threshold, 15001) + assert browser_tool.get_browser_snapshot_threshold() == 12000 + + browser_tool.cleanup_all_browsers() + assert browser_tool.get_browser_snapshot_threshold() == 15001 + + +def test_browser_snapshot_applies_profile_threshold( + isolated_snapshot_threshold, + monkeypatch, +): + _write_threshold(isolated_snapshot_threshold, 1000) + snapshot = _long_snapshot(1500) + + monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: False) + monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: True) + monkeypatch.setattr(browser_tool, "_last_session_key", lambda task_id: task_id) + monkeypatch.setattr( + browser_tool, + "_run_browser_command", + lambda *args, **kwargs: { + "success": True, + "data": {"snapshot": snapshot, "refs": {"e1": {}}}, + }, + ) + + result = json.loads(browser_tool.browser_snapshot(task_id="threshold-test")) + + assert result["success"] is True + assert len(result["snapshot"]) < len(snapshot) + assert "more lines truncated" in result["snapshot"] + + +def test_browser_navigation_applies_profile_threshold( + isolated_snapshot_threshold, + monkeypatch, +): + _write_threshold(isolated_snapshot_threshold, 1000) + snapshot = _long_snapshot(1500) + task_id = "threshold-navigate-test" + + monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: True) + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: None) + monkeypatch.setattr( + browser_tool, + "_get_session_info", + lambda session_key: { + "session_name": "threshold-test", + "_first_nav": False, + "features": {"local": True, "proxies": True}, + }, + ) + monkeypatch.setattr( + browser_tool, + "_run_browser_command", + Mock( + side_effect=[ + { + "success": True, + "data": { + "title": "Example", + "url": "https://example.com/", + }, + }, + { + "success": True, + "data": {"snapshot": snapshot, "refs": {"e1": {}}}, + }, + ] + ), + ) + + result = json.loads( + browser_tool.browser_navigate( + "https://example.com", + task_id=task_id, + ) + ) + browser_tool._last_active_session_key.pop(task_id, None) + + assert result["success"] is True + assert len(result["snapshot"]) < len(snapshot) + assert "more lines truncated" in result["snapshot"] + + +def test_camofox_navigation_applies_same_profile_threshold( + isolated_snapshot_threshold, + monkeypatch, +): + _write_threshold(isolated_snapshot_threshold, 1000) + snapshot = _long_snapshot(1500) + session = {"tab_id": "tab-1", "user_id": "user-1"} + + monkeypatch.setattr( + browser_camofox, + "_rewrite_loopback_url_for_camofox", + lambda url: (url, None), + ) + monkeypatch.setattr(browser_camofox, "_get_session", lambda task_id: session) + monkeypatch.setattr( + browser_camofox, + "_post", + lambda *args, **kwargs: {"url": "https://example.com", "title": "Example"}, + ) + monkeypatch.setattr( + browser_camofox, + "_get", + lambda *args, **kwargs: {"snapshot": snapshot, "refsCount": 1}, + ) + monkeypatch.setattr(browser_camofox, "get_vnc_url", lambda: None) + + result = json.loads( + browser_camofox.camofox_navigate( + "https://example.com", + task_id="threshold-test", + ) + ) + + assert result["success"] is True + assert len(result["snapshot"]) < len(snapshot) + assert "more lines truncated" in result["snapshot"] + + +def test_camofox_snapshot_applies_same_profile_threshold( + isolated_snapshot_threshold, + monkeypatch, +): + _write_threshold(isolated_snapshot_threshold, 1000) + snapshot = _long_snapshot(1500) + session = {"tab_id": "tab-1", "user_id": "user-1"} + + monkeypatch.setattr(browser_camofox, "_get_session", lambda task_id: session) + monkeypatch.setattr( + browser_camofox, + "_camofox_private_page_block", + lambda *args, **kwargs: None, + ) + monkeypatch.setattr( + browser_camofox, + "_get", + lambda *args, **kwargs: {"snapshot": snapshot, "refsCount": 1}, + ) + + result = json.loads( + browser_camofox.camofox_snapshot(task_id="threshold-snapshot-test") + ) + + assert result["success"] is True + assert len(result["snapshot"]) < len(snapshot) + assert "more lines truncated" in result["snapshot"] diff --git a/tools/browser_camofox.py b/tools/browser_camofox.py index b5cc1be457..4fad1eb0b7 100644 --- a/tools/browser_camofox.py +++ b/tools/browser_camofox.py @@ -568,11 +568,12 @@ def camofox_navigate(url: str, task_id: Optional[str] = None) -> str: ) snapshot_text = snap_data.get("snapshot", "") from tools.browser_tool import ( - SNAPSHOT_SUMMARIZE_THRESHOLD, + get_browser_snapshot_threshold, _truncate_snapshot, ) - if len(snapshot_text) > SNAPSHOT_SUMMARIZE_THRESHOLD: - snapshot_text = _truncate_snapshot(snapshot_text) + threshold = get_browser_snapshot_threshold() + if len(snapshot_text) > threshold: + snapshot_text = _truncate_snapshot(snapshot_text, max_chars=threshold) result["snapshot"] = snapshot_text result["element_count"] = snap_data.get("refsCount", 0) except Exception: @@ -654,12 +655,13 @@ def camofox_snapshot(full: bool = False, task_id: Optional[str] = None, # line boundaries, store the full tree to cache/web, append a # read_file pointer. from tools.browser_tool import ( - SNAPSHOT_SUMMARIZE_THRESHOLD, + get_browser_snapshot_threshold, _truncate_snapshot, ) - if len(snapshot) > SNAPSHOT_SUMMARIZE_THRESHOLD: - snapshot = _truncate_snapshot(snapshot) + threshold = get_browser_snapshot_threshold() + if len(snapshot) > threshold: + snapshot = _truncate_snapshot(snapshot, max_chars=threshold) return json.dumps({ "success": True, @@ -970,4 +972,3 @@ def camofox_console(clear: bool = False, task_id: Optional[str] = None) -> str: }) - diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 139880dc18..363a68607e 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -282,11 +282,17 @@ DEFAULT_COMMAND_TIMEOUT = 30 MIN_OPEN_TIMEOUT = 60 MIN_FIRST_OPEN_TIMEOUT = 120 -# Max chars for snapshot content before truncation/summarization. Aligned -# with web_tools.DEFAULT_EXTRACT_CHAR_LIMIT (15000) — the snapshot and +# Default max chars for snapshot content before truncation. Aligned with +# web_tools.DEFAULT_EXTRACT_CHAR_LIMIT (15000) — the snapshot and # web_extract paths share the same truncate-and-store pattern, so the model -# gets the same per-page budget from both. -SNAPSHOT_SUMMARIZE_THRESHOLD = 15000 +# gets the same per-page budget from both. Configurable via +# ``browser.snapshot_threshold`` in config.yaml. +DEFAULT_SNAPSHOT_THRESHOLD = 15000 +MIN_SNAPSHOT_THRESHOLD = 1000 + +# Backwards-compatible import surface. Runtime call sites use +# ``get_browser_snapshot_threshold()`` so config overrides take effect. +SNAPSHOT_SUMMARIZE_THRESHOLD = DEFAULT_SNAPSHOT_THRESHOLD # Hard ceiling on the full-snapshot file written to cache/web when a snapshot # is truncated. Mirrors web_tools.MAX_STORED_TEXT_CHARS — @@ -299,6 +305,8 @@ _EMPTY_OK_COMMANDS: frozenset = frozenset({"close", "record"}) _cached_command_timeout: Optional[int] = None _command_timeout_resolved = False +_cached_snapshot_threshold: Optional[int] = None +_snapshot_threshold_resolved = False def _sanitize_url_for_logs(value: object) -> str: @@ -353,6 +361,33 @@ def _safe_command_timeout() -> int: return val if val is not None else DEFAULT_COMMAND_TIMEOUT +def get_browser_snapshot_threshold() -> int: + """Return the configured maximum browser snapshot size in characters. + + Reads the raw profile-aware config so tool JSON output is not affected by + config-loader warnings. The value is cached for the browser lifecycle and + reset by :func:`cleanup_all_browsers`. + """ + global _cached_snapshot_threshold, _snapshot_threshold_resolved + if _snapshot_threshold_resolved and _cached_snapshot_threshold is not None: + return _cached_snapshot_threshold + + result = DEFAULT_SNAPSHOT_THRESHOLD + try: + from hermes_cli.config import read_raw_config + cfg = read_raw_config() + val = cfg_get(cfg, "browser", "snapshot_threshold") + if val is not None: + result = max(int(val), MIN_SNAPSHOT_THRESHOLD) + except Exception as exc: + logger.debug("Could not read browser.snapshot_threshold: %s", exc) + + # Preserve the same race-safety invariant as the command-timeout cache. + _cached_snapshot_threshold = result + _snapshot_threshold_resolved = True + return result + + def _get_open_command_timeout(*, first_open: bool = False) -> int: """Timeout for agent-browser ``open`` (navigation / daemon cold start).""" base = _safe_command_timeout() @@ -3167,7 +3202,7 @@ def _store_full_snapshot(snapshot_text: str) -> Optional[str]: return None -def _truncate_snapshot(snapshot_text: str, max_chars: int = SNAPSHOT_SUMMARIZE_THRESHOLD) -> str: +def _truncate_snapshot(snapshot_text: str, max_chars: Optional[int] = None) -> str: """Structure-aware truncation for snapshots. Cuts at line boundaries so that accessibility tree elements are never @@ -3178,11 +3213,15 @@ def _truncate_snapshot(snapshot_text: str, max_chars: int = SNAPSHOT_SUMMARIZE_T Args: snapshot_text: The snapshot text to truncate - max_chars: Maximum characters to keep + max_chars: Maximum characters to keep. Defaults to the configured + ``browser.snapshot_threshold`` (see + :func:`get_browser_snapshot_threshold`). Returns: Truncated text with a stored-full-text pointer if truncated """ + if max_chars is None: + max_chars = get_browser_snapshot_threshold() if len(snapshot_text) <= max_chars: return snapshot_text @@ -3477,8 +3516,9 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: snap_data = snap_result.get("data", {}) snapshot_text = snap_data.get("snapshot", "") refs = snap_data.get("refs", {}) - if len(snapshot_text) > SNAPSHOT_SUMMARIZE_THRESHOLD: - snapshot_text = _truncate_snapshot(snapshot_text) + threshold = get_browser_snapshot_threshold() + if len(snapshot_text) > threshold: + snapshot_text = _truncate_snapshot(snapshot_text, max_chars=threshold) response["snapshot"] = _redact_browser_output(snapshot_text) response["element_count"] = len(refs) if refs else 0 if snap_result.get("fallback_warning") and not response.get("fallback_warning"): @@ -3563,9 +3603,11 @@ def browser_snapshot( # Oversized snapshots truncate at line boundaries; the full # accessibility tree is stored to cache/web and the appended note # tells the agent how to page through it with read_file (same - # pattern as web_extract — no LLM summarization). - if len(snapshot_text) > SNAPSHOT_SUMMARIZE_THRESHOLD: - snapshot_text = _truncate_snapshot(snapshot_text) + # pattern as web_extract — no LLM summarization). Threshold is + # configurable via browser.snapshot_threshold. + threshold = get_browser_snapshot_threshold() + if len(snapshot_text) > threshold: + snapshot_text = _truncate_snapshot(snapshot_text, max_chars=threshold) response = { "success": True, @@ -5028,6 +5070,7 @@ def cleanup_all_browsers() -> None: # Reset cached lookups so they are re-evaluated on next use. global _cached_agent_browser, _agent_browser_resolved global _cached_command_timeout, _command_timeout_resolved + global _cached_snapshot_threshold, _snapshot_threshold_resolved global _cached_chromium_installed global _cached_browser_engine, _browser_engine_resolved _cached_agent_browser = None @@ -5037,6 +5080,8 @@ def cleanup_all_browsers() -> None: # reader never sees ``resolved=True`` with ``cache=None`` (#14331). _command_timeout_resolved = False _cached_command_timeout = None + _snapshot_threshold_resolved = False + _cached_snapshot_threshold = None _cached_chromium_installed = None global _chromium_autoinstall_attempted _chromium_autoinstall_attempted = False diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index 85b5a29e11..551537b28d 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -511,7 +511,17 @@ Get a text-based snapshot of the current page's accessibility tree. Returns inte - **`full=false`** (default): Compact view showing only interactive elements - **`full=true`**: Complete page content -Snapshots over 15,000 characters are automatically truncated at line boundaries (the same per-page budget as `web_extract` — no LLM summarization). When that happens, the complete snapshot is saved to `~/.hermes/cache/web/` and the tool output includes the file path plus a ready-to-use `read_file` call, so the agent can page through the full accessibility tree — including element refs beyond the cut — without re-snapshotting. +Snapshots larger than `browser.snapshot_threshold` (default 15,000 characters — the same per-page budget as `web_extract`) are automatically truncated at line boundaries; no LLM summarization is involved. When that happens, the complete snapshot is saved to `~/.hermes/cache/web/` and the tool output includes the file path plus a ready-to-use `read_file` call, so the agent can page through the full accessibility tree — including element refs beyond the cut — without re-snapshotting. + +Increase the threshold for long pages where more source content should reach the agent inline: + +```yaml +# ~/.hermes/config.yaml +browser: + snapshot_threshold: 30000 +``` + +You can also run `hermes config set browser.snapshot_threshold 30000`. The setting applies to both explicit `browser_snapshot` calls and the automatic snapshot returned after navigation, including the Camofox backend (minimum 1000). Restart the current Hermes session after changing it so the browser config cache reloads. ### `browser_click` @@ -743,7 +753,7 @@ If paid features aren't available on your plan, Hermes automatically falls back ## Limitations - **Text-based interaction** — relies on accessibility tree, not pixel coordinates -- **Snapshot size** — large pages are truncated at 15,000 characters (matching `web_extract`; no LLM summarization); the complete snapshot is saved to `~/.hermes/cache/web/` and the output points at it for `read_file` paging +- **Snapshot size** — large pages are truncated at `browser.snapshot_threshold` (default 15,000 characters, matching `web_extract`; no LLM summarization); the complete snapshot is saved to `~/.hermes/cache/web/` and the output points at it for `read_file` paging - **Session timeout** — cloud sessions expire based on your provider's plan settings - **Cost** — cloud sessions consume provider credits; sessions are automatically cleaned up when the conversation ends or after inactivity. Use `/browser connect` for free local browsing. - **No file downloads** — cannot download files from the browser