132 lines
5.3 KiB
Python
132 lines
5.3 KiB
Python
"""Snapshot post-processing for the browser tools: truncate-and-store of oversized
|
|
accessibility trees, model-boundary secret redaction, screenshot-path recovery.
|
|
|
|
Origin-module symbols are resolved lazily through ``tools.browser_tool`` (``_bt``)
|
|
so ``patch("tools.browser_tool.X")`` keeps working; never import ``tools.browser_tool``
|
|
at import time (cycle).
|
|
"""
|
|
|
|
import re
|
|
from typing import Any, Optional
|
|
from tools.browser_tool_origin import origin_module as _origin
|
|
|
|
|
|
def _extract_screenshot_path_from_text(text: str) -> Optional[str]:
|
|
"""Extract a screenshot file path from agent-browser human-readable output."""
|
|
if not text:
|
|
return None
|
|
|
|
patterns = [
|
|
r"Screenshot saved to ['\"](?P<path>/[^'\"]+?\.png)['\"]",
|
|
r"Screenshot saved to (?P<path>/\S+?\.png)(?:\s|$)",
|
|
r"(?P<path>/\S+?\.png)(?:\s|$)",
|
|
]
|
|
|
|
for pattern in patterns:
|
|
match = re.search(pattern, text)
|
|
if match:
|
|
path = match.group("path").strip().strip("'\"")
|
|
if path:
|
|
return path
|
|
|
|
return None
|
|
|
|
|
|
def _store_full_snapshot(snapshot_text: str) -> Optional[str]:
|
|
"""Write a full snapshot to cache/web and return its path (None on failure — best-effort).
|
|
|
|
Mirrors ``web_tools._store_full_text``: cache/web is mounted read-only into
|
|
remote backends, so read_file can page through the complete tree on any
|
|
backend. The stored copy is force-redacted (page-rendered secrets must not
|
|
hit disk unmasked) and named by content hash so identical snapshots dedupe.
|
|
"""
|
|
_bt = _origin()
|
|
try:
|
|
import hashlib
|
|
from hermes_constants import get_hermes_dir
|
|
from agent.redact import redact_sensitive_text
|
|
|
|
content = redact_sensitive_text(snapshot_text, force=True)
|
|
if len(content) > _bt.MAX_STORED_SNAPSHOT_CHARS:
|
|
content = (
|
|
content[:_bt.MAX_STORED_SNAPSHOT_CHARS]
|
|
+ f"\n\n[... stored copy truncated at {_bt.MAX_STORED_SNAPSHOT_CHARS:,} chars "
|
|
f"of {len(content):,} ...]"
|
|
)
|
|
from tools.spill_safety import ensure_spill_dir, write_text_exclusive
|
|
|
|
cache_dir = get_hermes_dir("cache/web", "web_cache")
|
|
ensure_spill_dir(cache_dir, private=False)
|
|
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()[:10]
|
|
path = cache_dir / f"browser-snapshot-{digest}.txt"
|
|
# Deterministic filename in a well-known dir: refuse symlinks via
|
|
# lstat-unlink + exclusive create. Re-snapshotting the same page
|
|
# state legitimately overwrites (same content-hash name). Not
|
|
# private: cache/web is bind-mounted into remote backends whose
|
|
# container UID must be able to read it.
|
|
write_text_exclusive(path, content, private=False, overwrite=True)
|
|
return str(path)
|
|
except Exception as exc: # noqa: BLE001
|
|
_bt.logger.debug("Failed to store full browser snapshot: %s", exc)
|
|
return None
|
|
|
|
|
|
def _truncate_snapshot(snapshot_text: str, max_chars: Optional[int] = None) -> str:
|
|
"""Truncate a snapshot at line boundaries (never mid-element) to ``max_chars``.
|
|
|
|
Defaults to ``browser.snapshot_threshold``. The full snapshot is stored to
|
|
cache/web and the appended note tells the agent how to page through it
|
|
with read_file — element refs beyond the cut are in the file, not lost.
|
|
"""
|
|
_bt = _origin()
|
|
if max_chars is None:
|
|
max_chars = _bt.get_browser_snapshot_threshold()
|
|
if len(snapshot_text) <= max_chars:
|
|
return snapshot_text
|
|
|
|
stored_path = _store_full_snapshot(snapshot_text)
|
|
|
|
lines = snapshot_text.split('\n')
|
|
result: list[str] = []
|
|
chars = 0
|
|
# Reserve space for the truncation note (the stored-path variant is the
|
|
# longer of the two). Clamp so tiny max_chars values still keep content.
|
|
reserve = min(110 + len(stored_path or ""), max_chars // 2)
|
|
for line in lines:
|
|
if chars + len(line) + 1 > max_chars - reserve:
|
|
break
|
|
result.append(line)
|
|
chars += len(line) + 1
|
|
remaining = len(lines) - len(result)
|
|
if remaining > 0:
|
|
if stored_path:
|
|
next_line = len(result) + 1
|
|
result.append(
|
|
f'\n[... {remaining} more lines truncated — full snapshot: '
|
|
f'read_file path="{stored_path}" offset={next_line} limit=200]'
|
|
)
|
|
else:
|
|
result.append(f'\n[... {remaining} more lines truncated, use browser_snapshot for full content]')
|
|
return '\n'.join(result)
|
|
|
|
|
|
def _redact_browser_output(value: Any) -> Any:
|
|
"""Redact secrets from browser-originated data before returning to the model.
|
|
|
|
Browser snapshots, console messages, JS exceptions, and eval results can
|
|
contain page-rendered API keys, cookies, bearer tokens, or pasted secrets.
|
|
Tool output is a model boundary, so force redaction here even if global log
|
|
redaction is disabled for debugging.
|
|
"""
|
|
from agent.redact import redact_sensitive_text
|
|
|
|
if isinstance(value, str):
|
|
return redact_sensitive_text(value, force=True)
|
|
if isinstance(value, list):
|
|
return [_redact_browser_output(item) for item in value]
|
|
if isinstance(value, tuple):
|
|
return tuple(_redact_browser_output(item) for item in value)
|
|
if isinstance(value, dict):
|
|
return {key: _redact_browser_output(item) for key, item in value.items()}
|
|
return value
|