[eric] browser: smart-wait returns when the page's network settles instead of a blind fixed sleep

This commit is contained in:
ciregenz
2026-06-03 00:46:30 -07:00
parent 7572462116
commit 186c6cf72e
5 changed files with 239 additions and 6 deletions
+14 -3
View File
@@ -43,6 +43,7 @@ from backend.apps.agents.browser import browser_batch_replay
from backend.apps.agents.browser import browser_metrics
from backend.apps.agents.browser import browser_playbook
from backend.apps.agents.browser import browser_skills
from backend.apps.agents.browser import browser_wait
from backend.apps.agents.browser.browser_schema import (
_ACTION_TOOLS_REQUIRING_REPORT,
ACTION_MAP,
@@ -952,9 +953,19 @@ async def run_browser_agent(
tool_input = tu.input
if tu.name == "BrowserListInteractives" and current_next_goal:
tool_input = {**tu.input, "goal": current_next_goal}
result = await _cancellable(execute_browser_tool(
tu.name, tool_input, browser_id, tab_id,
))
if tu.name == "BrowserWait":
# Smart wait: return as soon as the page's network settles
# instead of sleeping the full fixed duration (the audit's
# 42%-of-time hog). Caps at the requested ms; never premature.
async def _wait_exec(tool, params, bid, tid):
return await _cancellable(execute_browser_tool(tool, params, bid, tid))
result = await browser_wait.smart_wait(
_wait_exec, browser_id, tab_id, tu.input.get("milliseconds"),
)
else:
result = await _cancellable(execute_browser_tool(
tu.name, tool_input, browser_id, tab_id,
))
if result is None:
cancelled = True
break
@@ -269,9 +269,11 @@ BROWSER_TOOLS_SCHEMA = [
{
"name": "BrowserWait",
"description": (
"Wait for a specified duration. Useful after navigation or actions that "
"trigger page loads, animations, or async content rendering. "
"Min 100ms, max 10000ms."
"Wait for the page to settle after navigation or an action that loads "
"async content. This is SMART: it returns as soon as the page's network "
"goes quiet, so the duration you give is just an upper bound, not a fixed "
"sleep. Pass a generous cap (e.g. 4000) without worrying about wasted "
"time; you usually get control back in a few hundred ms. Min 100, max 10000."
),
"input_schema": {
"type": "object",
@@ -0,0 +1,89 @@
"""
Smart wait: return as soon as the page's network has SETTLED, instead of the
blind fixed sleep BrowserWait used to do.
The audit found blind `BrowserWait(2500)` sleeps eat ~42% of all run time, the
page is usually ready long before the fixed duration elapses (navigate already
waits for the main load, so the agent's extra wait is just for SPA XHR content
to finish). So we poll the page's actual network activity (the Performance
Resource Timing API, which records every fetch/XHR with timestamps) and return
the instant it's been quiet for a short window.
Reliability-preserving by construction, the whole point is to be FASTER without
being flakier:
- We wait for REAL network quiet, not a guess, so we don't read a half-loaded page.
- We NEVER return before the floor (skips a momentary gap between two requests).
- We NEVER wait longer than the caller asked (the requested ms is a hard cap).
- A page that keeps fetching (live feed) simply rides to the cap, same as before.
Backend-side + provider-free: the probe runs through the existing BrowserEvaluate
path, so there's no Electron/IPC change to packaged-build-test, and the decision
logic is a pure function we can hammer with tests.
"""
import asyncio
import json
import time
# One probe: is the document complete, and how long since the last network
# resource finished/started? Returns a JSON string (BrowserEvaluate hands back
# string results verbatim).
PROBE_JS = (
"(()=>{const n=performance.now();"
"const es=performance.getEntriesByType('resource');let last=0;"
"for(const e of es){const t=Math.max(e.responseEnd||0,e.startTime||0);if(t>last)last=t;}"
"return JSON.stringify({ready:document.readyState==='complete',quiet:Math.round(n-last)});})()"
)
_QUIET_WINDOW_MS = 400 # network must be silent this long to count as settled
_FLOOR_MS = 250 # never return before this (a momentary gap isn't 'settled')
_POLL_MS = 150
def decide_stop(ready, quiet_ms, elapsed_ms,
floor_ms=_FLOOR_MS, quiet_window_ms=_QUIET_WINDOW_MS) -> bool:
"""Pure decision: stop waiting once we're past the floor AND the document is
complete AND the network has been quiet for the settle window."""
if elapsed_ms < floor_ms:
return False
return bool(ready) and (quiet_ms or 0) >= quiet_window_ms
async def smart_wait(execute_fn, browser_id, tab_id, max_ms, *,
poll_ms=_POLL_MS, floor_ms=_FLOOR_MS,
quiet_window_ms=_QUIET_WINDOW_MS) -> dict:
"""Wait up to `max_ms`, returning early once the page settles. `execute_fn`
is an async (tool, params, browser_id, tab_id) -> result|None (None = the run
was cancelled). Never raises into the caller."""
max_ms = max(100, min(int(max_ms or 1000), 10000))
start = time.monotonic()
settled = False
last_url = ""
def _elapsed():
return (time.monotonic() - start) * 1000
while _elapsed() < max_ms:
await asyncio.sleep(min(poll_ms, max(0, max_ms - _elapsed())) / 1000)
if _elapsed() >= max_ms:
break
res = await execute_fn("BrowserEvaluate", {"expression": PROBE_JS}, browser_id, tab_id)
if res is None: # cancelled mid-wait
break
last_url = res.get("url") or last_url
if "error" in res: # page mid-navigation / not evaluable yet, keep waiting
continue
try:
probe = json.loads(res.get("text") or "{}")
except Exception:
continue
if decide_stop(probe.get("ready"), probe.get("quiet", 0), _elapsed(),
floor_ms=floor_ms, quiet_window_ms=quiet_window_ms):
settled = True
break
waited = round(_elapsed())
text = f"Waited {waited}ms ({'page settled' if settled else 'reached cap'})."
if last_url:
text += f" Current URL: {last_url}"
return {"text": text, "url": last_url, "settled": settled, "waited_ms": waited}
+24
View File
@@ -90,6 +90,10 @@ def _install(monkeypatch, primary, aux):
async def _send_browser_command(request_id, action, browser_id, params, tab_id=""):
sent.append({"action": action, "params": params})
# smart-wait probes via evaluate; report 'settled' so BrowserWait returns
# fast in tests instead of riding the full cap.
if action == "evaluate" and "getEntriesByType('resource')" in str(params.get("expression", "")):
return {"text": '{"ready": true, "quiet": 9999}', "url": DOC_URL}
if action == "list_interactives":
return {"text": '1 interactive elements:\n[1]<button "Submit">', "url": DOC_URL}
if action == "click_index":
@@ -898,6 +902,26 @@ def test_batch_replay_uses_the_fast_network_route_per_value(monkeypatch):
assert any("u=ada" in u for u in routes) and any("u=grace" in u for u in routes)
def test_browser_wait_routes_through_smart_wait_and_returns_early(monkeypatch):
# BrowserWait must no longer be a blind sleep: it probes the page (evaluate)
# and returns as soon as it's settled, well under the requested cap.
BH._browser_history.clear()
primary = FakeLLM([
Resp([_rp("let it settle"), _tu("BrowserWait", milliseconds=8000)]),
Resp([Blk("text", "Settled, moving on.")], stop_reason="end_turn"),
])
sent = _install(monkeypatch, primary, FakeAux())
import time as _t
t0 = _t.time()
asyncio.run(BA.run_browser_agent(task="wait then act", browser_id="b1", model="sonnet", initial_url=DOC_URL))
elapsed = _t.time() - t0
# it probed via evaluate (smart), not a blind 'wait' action...
assert any(c["action"] == "evaluate" and "getEntriesByType" in str(c["params"].get("expression", "")) for c in sent)
assert not any(c["action"] == "wait" for c in sent), "no blind wait dispatched"
# ...and the whole run finished far faster than the 8s cap (it settled early)
assert elapsed < 4.0, "smart wait returned early instead of sleeping the full cap"
def test_prior_domain_hint_is_seeded_into_system_prompt(monkeypatch):
BH._browser_history.clear(); BH._domain_notes.clear()
BH.set_domain_note("google.com", "REMEMBERED: Share button is index 43; Tab into the dialog.")
+107
View File
@@ -0,0 +1,107 @@
"""Smart wait: return when the page's network settles, not on a blind timer.
The wait runs on EVERY browser task and the audit says it's 42% of all time, so
this is high-blast-radius. These pin down that it (1) returns early when settled,
(2) never returns before the floor (no half-loaded reads), (3) never exceeds the
cap, (4) keeps waiting through a still-loading SPA, (5) survives a cancel or a
mid-navigation probe error. Edge-case-complete on purpose.
"""
import json
import pytest
from backend.apps.agents.browser import browser_wait as bw
# --- the pure decision (hammer it) ------------------------------------------
def test_decide_stop_waits_until_past_the_floor():
# even a fully-settled page must not return before the floor (a momentary gap
# between two requests would otherwise look 'settled')
assert bw.decide_stop(ready=True, quiet_ms=9999, elapsed_ms=100, floor_ms=250) is False
assert bw.decide_stop(ready=True, quiet_ms=9999, elapsed_ms=300, floor_ms=250) is True
def test_decide_stop_needs_ready_and_quiet():
# past floor, but document not complete -> keep waiting
assert bw.decide_stop(ready=False, quiet_ms=9999, elapsed_ms=500) is False
# past floor, ready, but network still active (quiet below the window) -> wait
assert bw.decide_stop(ready=True, quiet_ms=100, elapsed_ms=500, quiet_window_ms=400) is False
# past floor, ready, quiet long enough -> stop
assert bw.decide_stop(ready=True, quiet_ms=400, elapsed_ms=500, quiet_window_ms=400) is True
def test_decide_stop_handles_missing_quiet():
assert bw.decide_stop(ready=True, quiet_ms=None, elapsed_ms=500) is False
# --- the async loop with a scripted probe -----------------------------------
def _probe(ready, quiet):
return {"text": json.dumps({"ready": ready, "quiet": quiet}), "url": "https://x.com"}
class FakeExec:
"""Returns scripted probe results in sequence (last one repeats)."""
def __init__(self, results):
self.results = results
self.calls = 0
async def __call__(self, tool, params, bid, tid):
assert tool == "BrowserEvaluate"
r = self.results[min(self.calls, len(self.results) - 1)]
self.calls += 1
return r
@pytest.mark.asyncio
async def test_returns_early_once_settled():
# first probe: still loading; second: settled -> should stop well under the cap
ex = FakeExec([_probe(False, 0), _probe(True, 999)])
out = await bw.smart_wait(ex, "b", "", 5000, poll_ms=20, floor_ms=20, quiet_window_ms=50)
assert out["settled"] is True
assert out["waited_ms"] < 5000
assert "page settled" in out["text"]
@pytest.mark.asyncio
async def test_rides_to_cap_when_page_never_settles():
# an SPA that keeps fetching (quiet always small) -> never settles -> caps out
ex = FakeExec([_probe(True, 10)])
out = await bw.smart_wait(ex, "b", "", 200, poll_ms=20, floor_ms=20, quiet_window_ms=400)
assert out["settled"] is False
assert out["waited_ms"] >= 180 # ~the cap
assert "reached cap" in out["text"]
@pytest.mark.asyncio
async def test_never_returns_before_the_floor():
# settled from the very first probe, but the floor must still be respected
ex = FakeExec([_probe(True, 9999)])
out = await bw.smart_wait(ex, "b", "", 5000, poll_ms=10, floor_ms=200, quiet_window_ms=50)
assert out["waited_ms"] >= 200, "must not read a page before the settle floor"
assert out["settled"] is True
@pytest.mark.asyncio
async def test_cancel_mid_wait_stops_cleanly():
async def _cancelled(tool, params, bid, tid):
return None # _cancellable returns None when the run is cancelled
out = await bw.smart_wait(_cancelled, "b", "", 5000, poll_ms=10, floor_ms=10)
assert out["settled"] is False and out["waited_ms"] < 5000
@pytest.mark.asyncio
async def test_probe_error_during_navigation_keeps_waiting_then_settles():
# while the page is navigating, evaluate errors; we must keep polling, not bail
ex = FakeExec([{"error": "Cannot evaluate, page navigating"},
{"error": "still navigating"},
_probe(True, 999)])
out = await bw.smart_wait(ex, "b", "", 5000, poll_ms=15, floor_ms=15, quiet_window_ms=50)
assert out["settled"] is True and ex.calls >= 3
@pytest.mark.asyncio
async def test_garbage_probe_text_does_not_crash():
ex = FakeExec([{"text": "not json", "url": "u"}, _probe(True, 999)])
out = await bw.smart_wait(ex, "b", "", 3000, poll_ms=15, floor_ms=15, quiet_window_ms=50)
assert out["settled"] is True