feat(browser): make snapshot threshold configurable

This commit is contained in:
Leegenux
2026-07-12 17:45:28 +08:00
committed by Teknium
parent 9cce872505
commit 6ce7ab8bfb
7 changed files with 303 additions and 20 deletions
+6
View File
@@ -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
# =============================================================================
+1
View File
@@ -0,0 +1 @@
Leegenux
+1
View File
@@ -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.)
@@ -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"]
+8 -7
View File
@@ -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:
})
+56 -11
View File
@@ -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
+12 -2
View File
@@ -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