mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 21:27:41 +02:00
[eric] browser: smart-wait returns when the page's network settles instead of a blind fixed sleep
This commit is contained in:
@@ -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}
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user