From 186c6cf72ec5cc66e5929c3d1da279d388b90636 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 3 Jun 2026 00:46:30 -0700 Subject: [PATCH] [eric] browser: smart-wait returns when the page's network settles instead of a blind fixed sleep --- backend/apps/agents/browser/browser_agent.py | 17 ++- backend/apps/agents/browser/browser_schema.py | 8 +- backend/apps/agents/browser/browser_wait.py | 89 +++++++++++++++ backend/tests/test_browser_agent_loop.py | 24 ++++ backend/tests/test_browser_wait.py | 107 ++++++++++++++++++ 5 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 backend/apps/agents/browser/browser_wait.py create mode 100644 backend/tests/test_browser_wait.py diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 1229fbb9..06cb9943 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -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 diff --git a/backend/apps/agents/browser/browser_schema.py b/backend/apps/agents/browser/browser_schema.py index a5c37da0..5d152a32 100644 --- a/backend/apps/agents/browser/browser_schema.py +++ b/backend/apps/agents/browser/browser_schema.py @@ -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", diff --git a/backend/apps/agents/browser/browser_wait.py b/backend/apps/agents/browser/browser_wait.py new file mode 100644 index 00000000..79cf524b --- /dev/null +++ b/backend/apps/agents/browser/browser_wait.py @@ -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} diff --git a/backend/tests/test_browser_agent_loop.py b/backend/tests/test_browser_agent_loop.py index f9d69edd..31faf8f3 100644 --- a/backend/tests/test_browser_agent_loop.py +++ b/backend/tests/test_browser_agent_loop.py @@ -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]