Skip to content

Commit 2ac612d

Browse files
author
SentienceDEV
committed
fix snapshot crash issue during browser navigation; vision model
1 parent af5729b commit 2ac612d

File tree

3 files changed

+208
-42
lines changed

3 files changed

+208
-42
lines changed

sentience/backends/snapshot.py

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
cache.invalidate() # Force refresh on next get()
2222
"""
2323

24+
import asyncio
2425
import time
2526
from typing import TYPE_CHECKING, Any
2627

@@ -37,6 +38,57 @@
3738
from .protocol import BrowserBackend
3839

3940

41+
def _is_execution_context_destroyed_error(e: Exception) -> bool:
42+
"""
43+
Playwright (and other browser backends) can throw while a navigation is in-flight.
44+
45+
Common symptoms:
46+
- "Execution context was destroyed, most likely because of a navigation"
47+
- "Cannot find context with specified id"
48+
"""
49+
msg = str(e).lower()
50+
return (
51+
"execution context was destroyed" in msg
52+
or "most likely because of a navigation" in msg
53+
or "cannot find context with specified id" in msg
54+
)
55+
56+
57+
async def _eval_with_navigation_retry(
58+
backend: "BrowserBackend",
59+
expression: str,
60+
*,
61+
retries: int = 10,
62+
settle_state: str = "interactive",
63+
settle_timeout_ms: int = 10000,
64+
) -> Any:
65+
"""
66+
Evaluate JS, retrying once/ twice if the page is mid-navigation.
67+
68+
This makes snapshots resilient to cases like:
69+
- press Enter (navigation) → snapshot immediately → context destroyed
70+
"""
71+
last_err: Exception | None = None
72+
for attempt in range(retries + 1):
73+
try:
74+
return await backend.eval(expression)
75+
except Exception as e:
76+
last_err = e
77+
if not _is_execution_context_destroyed_error(e) or attempt >= retries:
78+
raise
79+
# Navigation is in-flight; wait for new document context then retry.
80+
try:
81+
await backend.wait_ready_state(state=settle_state, timeout_ms=settle_timeout_ms) # type: ignore[arg-type]
82+
except Exception:
83+
# If readyState polling also fails mid-nav, still retry after a short backoff.
84+
pass
85+
# Exponential-ish backoff (caps quickly), tuned for real navigations.
86+
await asyncio.sleep(min(0.25 * (attempt + 1), 1.5))
87+
88+
# Unreachable in practice, but keeps type-checkers happy.
89+
raise last_err if last_err else RuntimeError("eval failed")
90+
91+
4092
class CachedSnapshot:
4193
"""
4294
Snapshot cache with staleness detection.
@@ -289,13 +341,14 @@ async def _snapshot_via_extension(
289341
ext_options = _build_extension_options(options)
290342

291343
# Call extension's snapshot function
292-
result = await backend.eval(
344+
result = await _eval_with_navigation_retry(
345+
backend,
293346
f"""
294347
(() => {{
295348
const options = {_json_serialize(ext_options)};
296349
return window.sentience.snapshot(options);
297350
}})()
298-
"""
351+
""",
299352
)
300353

301354
if result is None:
@@ -310,14 +363,15 @@ async def _snapshot_via_extension(
310363
if options.show_overlay:
311364
raw_elements = result.get("raw_elements", [])
312365
if raw_elements:
313-
await backend.eval(
366+
await _eval_with_navigation_retry(
367+
backend,
314368
f"""
315369
(() => {{
316370
if (window.sentience && window.sentience.showOverlay) {{
317371
window.sentience.showOverlay({_json_serialize(raw_elements)}, null);
318372
}}
319373
}})()
320-
"""
374+
""",
321375
)
322376

323377
# Build and return Snapshot
@@ -341,13 +395,14 @@ async def _snapshot_via_api(
341395
raw_options["screenshot"] = options.screenshot
342396

343397
# Call extension to get raw elements
344-
raw_result = await backend.eval(
398+
raw_result = await _eval_with_navigation_retry(
399+
backend,
345400
f"""
346401
(() => {{
347402
const options = {_json_serialize(raw_options)};
348403
return window.sentience.snapshot(options);
349404
}})()
350-
"""
405+
""",
351406
)
352407

353408
if raw_result is None:
@@ -372,14 +427,15 @@ async def _snapshot_via_api(
372427
if options.show_overlay:
373428
elements = api_result.get("elements", [])
374429
if elements:
375-
await backend.eval(
430+
await _eval_with_navigation_retry(
431+
backend,
376432
f"""
377433
(() => {{
378434
if (window.sentience && window.sentience.showOverlay) {{
379435
window.sentience.showOverlay({_json_serialize(elements)}, null);
380436
}}
381437
}})()
382-
"""
438+
""",
383439
)
384440

385441
return Snapshot(**snapshot_data)

sentience/llm_provider.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from abc import ABC, abstractmethod
77
from dataclasses import dataclass
8+
from typing import Any
89

910
from .llm_provider_utils import get_api_key_from_env, handle_provider_error, require_package
1011
from .llm_response_builder import LLMResponseBuilder
@@ -777,21 +778,44 @@ def __init__(
777778
elif load_in_8bit:
778779
quantization_config = BitsAndBytesConfig(load_in_8bit=True)
779780

781+
device = (device or "auto").strip().lower()
782+
780783
# Determine torch dtype
781784
if torch_dtype == "auto":
782-
dtype = torch.float16 if device != "cpu" else torch.float32
785+
dtype = torch.float16 if device not in {"cpu"} else torch.float32
783786
else:
784787
dtype = getattr(torch, torch_dtype)
785788

786-
# Load model
787-
self.model = AutoModelForCausalLM.from_pretrained(
788-
model_name,
789-
quantization_config=quantization_config,
790-
torch_dtype=dtype if quantization_config is None else None,
791-
device_map=device,
792-
trust_remote_code=True,
793-
low_cpu_mem_usage=True,
794-
)
789+
# device_map is a Transformers concept (not a literal "cpu/mps/cuda" device string).
790+
# - "auto" enables Accelerate device mapping.
791+
# - Otherwise, we load normally and then move the model to the requested device.
792+
device_map: str | None = "auto" if device == "auto" else None
793+
794+
def _load(*, device_map_override: str | None) -> Any:
795+
return AutoModelForCausalLM.from_pretrained(
796+
model_name,
797+
quantization_config=quantization_config,
798+
torch_dtype=dtype if quantization_config is None else None,
799+
device_map=device_map_override,
800+
trust_remote_code=True,
801+
low_cpu_mem_usage=True,
802+
)
803+
804+
try:
805+
self.model = _load(device_map_override=device_map)
806+
except KeyError as e:
807+
# Some envs / accelerate versions can crash on auto mapping (e.g. KeyError: 'cpu').
808+
# Keep demo ergonomics: default stays "auto", but we gracefully fall back.
809+
if device == "auto" and ("cpu" in str(e).lower()):
810+
device = "cpu"
811+
dtype = torch.float32
812+
self.model = _load(device_map_override=None)
813+
else:
814+
raise
815+
816+
# If we didn't use device_map, move model explicitly (only safe for non-quantized loads).
817+
if device_map is None and quantization_config is None and device in {"cpu", "cuda", "mps"}:
818+
self.model = self.model.to(device)
795819
self.model.eval()
796820

797821
def generate(

0 commit comments

Comments
 (0)