From bcfbb8d24fa1e9d91afb32fcd744a7d117da5fc4 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 2 Jun 2026 18:19:33 -0700 Subject: [PATCH] [eric] browser: fail fast with an honest message when the card's webview is gone --- backend/apps/agents/browser/browser_agent.py | 21 ++++++++++-- backend/apps/agents/browser/browser_loop.py | 14 ++++++++ backend/tests/test_browser_agent_loop.py | 35 ++++++++++++++++++++ backend/tests/test_browser_stagnation.py | 9 +++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 003020ab..1eee418c 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -30,7 +30,9 @@ from backend.apps.agents.browser.browser_loop import ( _LOOP_WINDOW_SIZE, _detect_loop, _hash_tool_call, + _CARD_GONE_LIMIT, advance_stagnation, + card_is_unavailable, completion_is_honest, stagnation_exhausted, ) @@ -320,6 +322,7 @@ async def run_browser_agent( # Loop detection state; sliding window of recent state-mutating tool calls recent_tool_calls: list[tuple[str, str, str]] = [] loop_trigger_count = 0 + card_gone_streak = 0 # consecutive "card is gone" results -> fail fast, don't spin # Stagnation state: busy-but-stuck detection (no URL change + failures # across a run of actions), distinct from the exact-repeat loop above. @@ -836,6 +839,7 @@ async def run_browser_agent( }) if result.get("url"): last_seen_url = result["url"] + card_gone_streak = card_gone_streak + 1 if card_is_unavailable(result) else 0 if tu.name == "BrowserScreenshot" and result.get("image"): final_screenshot = result["image"] @@ -949,6 +953,15 @@ async def run_browser_agent( ) break + # The card's webview is gone (closed / dashboard not open). The agent + # can't bring it back, so stop retrying and report it honestly below. + if card_gone_streak >= _CARD_GONE_LIMIT: + logger.warning( + f"[browser-agent {session_id}] browser card {browser_id} is gone " + f"({card_gone_streak} consecutive misses); aborting fast" + ) + break + if cancel_event.is_set(): session.status = "stopped" browser_metrics.record_task(session_id, browser_id, task, "stopped", @@ -991,8 +1004,12 @@ async def run_browser_agent( # Honesty gate: the model declaring done is not proof the goal happened. # If the run did no real work (zero actions, all actions errored, or only - # looked around), report the truth instead of a ghost "completed". - honest, dishonest_reason = completion_is_honest(action_log) + # looked around), report the truth instead of a ghost "completed". A gone + # card gets its own precise reason instead of the generic verdict. + if card_gone_streak >= _CARD_GONE_LIMIT: + honest, dishonest_reason = False, "the browser card is no longer open (it was closed or never opened)" + else: + honest, dishonest_reason = completion_is_honest(action_log) final_status = "completed" if honest else "error" if not honest: summary = f"I was not able to complete this task ({dishonest_reason})." diff --git a/backend/apps/agents/browser/browser_loop.py b/backend/apps/agents/browser/browser_loop.py index 7568d1b0..efb88177 100644 --- a/backend/apps/agents/browser/browser_loop.py +++ b/backend/apps/agents/browser/browser_loop.py @@ -199,6 +199,20 @@ _READ_TOOLS = { } +# A card whose webview is gone is UNRECOVERABLE by the agent (it cannot resurrect +# the card), unlike a missing selector it could route around. The frontend +# returns these only AFTER a 2s re-register grace, so they mean the card is truly +# gone (closed) or the dashboard was never open. Retrying just burns the turn +# budget (the multi-minute spins we measured), so the caller fails fast instead. +_CARD_GONE_MARKERS = ("not an electron webview", "no dashboard is connected") +_CARD_GONE_LIMIT = 2 # consecutive misses before we give up (absorbs a transient) + + +def card_is_unavailable(result: dict) -> bool: + err = str(result.get("error") or "").lower() + return any(m in err for m in _CARD_GONE_MARKERS) + + def completion_is_honest(action_log: list[dict]) -> tuple[bool, str]: """Reality-check a run the model declared done. Returns (honest, reason). diff --git a/backend/tests/test_browser_agent_loop.py b/backend/tests/test_browser_agent_loop.py index 84609523..487a52ad 100644 --- a/backend/tests/test_browser_agent_loop.py +++ b/backend/tests/test_browser_agent_loop.py @@ -394,6 +394,41 @@ def test_ghost_completion_is_reported_as_error_not_completed(monkeypatch): assert SK.find_skill("docs.google.com", "Submit the form") is None +def test_dead_browser_card_aborts_fast_without_spinning(monkeypatch): + # The measured waste: a sub-agent dispatched to a released card retried the + # dead webview for many turns. Now a gone card must abort fast (a couple of + # turns, not the whole budget) and report the precise reason. + import backend.apps.agents.browser.browser_skills as SK + SK.clear() + BH._browser_history.clear() + # the model would happily keep clicking for 8 turns if we let it + primary = FakeLLM( + [Resp([_rp("click"), _tu("BrowserClick", selector=f".s{i}")]) for i in range(8)] + + [Resp([Blk("text", "done")], stop_reason="end_turn")] + ) + aux = FakeAux() + _install(monkeypatch, primary, aux) + + async def _card_gone(request_id, action, browser_id, params, tab_id=""): + return {"error": f"Browser card '{browser_id}' not found or not an Electron webview"} + monkeypatch.setattr(BA.ws_manager, "send_browser_command", _card_gone, raising=False) + captured = {} + orig = BA.ws_manager.send_to_session + + async def _cap(session_id, event, payload): + if event == "agent:status": + captured["status"] = payload.get("status") + return await orig(session_id, event, payload) + monkeypatch.setattr(BA.ws_manager, "send_to_session", _cap, raising=False) + + r = asyncio.run(BA.run_browser_agent( + task="Click submit", browser_id="b1", model="sonnet", initial_url=DOC_URL, + )) + assert len(primary.calls) <= 3, "a dead card must fail fast, not spin the whole budget" + assert captured.get("status") == "error" + assert "no longer open" in r["summary"].lower() + + def test_perception_is_frontloaded_into_first_turn(monkeypatch): # With a known start URL, the agent should prefetch the element list + page # text and put them in the FIRST user message, so the model can act on turn 1 diff --git a/backend/tests/test_browser_stagnation.py b/backend/tests/test_browser_stagnation.py index b593ca93..a1996b17 100644 --- a/backend/tests/test_browser_stagnation.py +++ b/backend/tests/test_browser_stagnation.py @@ -5,6 +5,7 @@ from backend.apps.agents.browser.browser_loop import ( _STAGNATION_MAX, _looks_like_failure, advance_stagnation, + card_is_unavailable, completion_is_honest, is_unproductive, stagnation_exhausted, @@ -151,3 +152,11 @@ def test_completion_honest_when_some_errors_but_an_action_landed(): log = [_err("BrowserClick"), _err("BrowserClick"), _ok("BrowserClickIndex", "Clicked Submit")] honest, reason = completion_is_honest(log) assert honest + + +def test_card_is_unavailable_only_for_unrecoverable_errors(): + # a gone card is unrecoverable (fail fast); a missing selector is not (route around) + assert card_is_unavailable({"error": "Browser card 'b1' not found or not an Electron webview"}) + assert card_is_unavailable({"error": "No dashboard is connected. Open the dashboard to use browser tools."}) + assert not card_is_unavailable({"error": "Element not found: '.submit'"}) + assert not card_is_unavailable({"text": "ok", "url": "http://x"})