diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index eecc931f..907e2215 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -36,6 +36,7 @@ from backend.apps.agents.browser.browser_loop import ( card_is_unavailable, completion_is_honest, deliverable_is_informational, + interstitial_dismiss_target, replay_recheck_is_safe, stagnation_exhausted, ) @@ -475,6 +476,7 @@ async def run_browser_agent( # auto candidate scan: aux-read results pages so pick-a-candidate happens in # the same turn as the landing, not a read-then-decide pair later auto_scanned_urls: set[str] = set() + dismissed_popup_urls: set[str] = set() # interstitials auto-closed, once per URL auto_scan_count = 0 llm_ms_total = 0 out_tokens_total = 0 # sum of per-turn output tokens (the latency driver) @@ -1435,6 +1437,29 @@ async def run_browser_agent( # later solo re-list is still caught as redundant. fresh_state_pending = True + # Auto-dismiss a blocking junk popup (cookie wall / upsell / + # coachmark) before it costs the model a turn. Mechanical, once + # per URL, only on the tight throwaway-dismiss vocabulary that + # never sits on a task-needed control, so it can't close anything + # required. After closing, re-list so the model sees the page beneath. + if tu.name in _AUTO_STATE_TOOLS and "error" not in result: + _pop_url = (result.get("url") or last_seen_url or "").split("#")[0] + if _pop_url and _pop_url not in dismissed_popup_urls: + _close = interstitial_dismiss_target("\n".join(attached_state_seen)) + if _close: + dismissed_popup_urls.add(_pop_url) + _dres = await _cancellable(execute_browser_tool( + "BrowserClickByName", {"name": _close}, browser_id, tab_id)) + _dok = isinstance(_dres, dict) and "error" not in _dres + logger.info(f"[browser-popup {session_id}] auto-dismissed '{_close}' " + f"ok={_dok} on {_pop_url[:80]}") + if _dok: + _fresh = await _post_action_state( + "BrowserClickByName", {}, _dres or {}, browser_id, tab_id, + _wait_exec, current_next_goal, seen_lines=attached_state_seen) + result["text"] = (f"{result.get('text') or ''}\n\n[auto] Closed a blocking " + f"popup ('{_close}'); the page beneath is now active.{_fresh}") + # Auto candidate scan: landing on a results-shaped page normally # costs a read-then-decide turn pair; the cheap aux model reads it # now so the pick happens on this same turn. Capped, per-URL, diff --git a/backend/apps/agents/browser/browser_loop.py b/backend/apps/agents/browser/browser_loop.py index 9bfbdd4c..987c15fb 100644 --- a/backend/apps/agents/browser/browser_loop.py +++ b/backend/apps/agents/browser/browser_loop.py @@ -32,6 +32,41 @@ _LOOP_REPEAT_THRESHOLD = 2 # the SECOND identical (tool,input,result) is alread _LOOP_HARD_CAP = 5 +# Universal close-affordance vocabulary for blocking popups (cookie walls, +# upsells, app-install nags, coachmarks). These phrases sit on a throwaway +# dismiss and NEVER on a control a real task needs (you never "No thanks" your +# way through a send), so a mechanical dismiss of one cannot close something the +# task required. Deliberately omits generic "Close"/"Dismiss"/"Skip", which DO +# appear on needed dialogs (e.g. "Close your conversation"). Keys on the pattern, +# not any one site, so it generalizes. +_DISMISS_NAMES = frozenset({ + "no thanks", "no, thanks", "maybe later", "not now", "skip for now", + "remind me later", "got it", "decline", "no, maybe later", "not interested", +}) +# never dismiss anything that smells like security or a real decision +_DANGER_NAME_RE = re.compile(r"verif|confirm|2fa|password|sign|pay|delete|send|post|submit", re.I) +_ROW_RE = re.compile(r'<\s*([a-z]+)\s+"([^"]*)"', re.I) # matches a [i] row + + +def interstitial_dismiss_target(interactives_text: str) -> str | None: + """The accessible name of an unambiguous junk-popup close control on the + page, or None. Conservative by construction: matches only throwaway-dismiss + vocabulary that never sits on a task-needed control, on a button/link, and + never anything with security/confirm/commit wording, so a mechanical dismiss + can never close a dialog the task actually required.""" + for line in (interactives_text or "").splitlines(): + m = _ROW_RE.search(line) + if not m: + continue + role, name = m.group(1).lower(), m.group(2).strip() + if role not in ("button", "link"): + continue + norm = re.sub(r"[^a-z, ]", "", name.lower()).strip() + if norm in _DISMISS_NAMES and not _DANGER_NAME_RE.search(name): + return name + return None + + def _hash_tool_call(tool_name: str, tool_input: dict, result: dict) -> tuple[str, str, str]: """Build a stable hash key for a tool call, including its result. diff --git a/backend/tests/test_browser_agent_loop.py b/backend/tests/test_browser_agent_loop.py index ccefdd30..bbcce019 100644 --- a/backend/tests/test_browser_agent_loop.py +++ b/backend/tests/test_browser_agent_loop.py @@ -1301,3 +1301,30 @@ def test_informational_gate_strips_outcome_boilerplate_on_tie_break(): assert not deliverable_is_informational(short_action, "") listy = "Found these:\n- a\n- b\n- c" assert deliverable_is_informational(listy, "") + + +def test_interstitial_dismiss_target_generalizable_and_safe(): + from backend.apps.agents.browser.browser_loop import interstitial_dismiss_target + # a junk popup with a throwaway-dismiss control gets found (any site) + page = '\n'.join([ + '[3]