From 1204e733b5f41974616f1931b017c19d80670632 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 7 Jun 2026 19:12:43 -0700 Subject: [PATCH] [eric] browser: stall backstop only fires after a real action, never cuts short early orientation --- backend/apps/agents/browser/browser_agent.py | 10 +++++++++- backend/tests/test_browser_agent_loop.py | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index f2c0e41c..5c622a87 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -1041,7 +1041,15 @@ async def run_browser_agent( # so reset and let it continue (multi-send stays safe). if _turn_actions == 0: perception_stall += 1 - _stall_limit = _POST_SEND_STALL_LIMIT if send_confirmed else _PERCEPTION_STALL_LIMIT + # The general backstop only applies AFTER the agent has actually done + # something (a real mutation, not a read), early pure-perception is + # legitimate orienting (a cold/slow page can need several look turns + # before the first action) and we must never cut that short. Reads + # land in action_log with ok=True too, so check the TOOL, not just ok. + _acted = any(a.get("ok") and a.get("tool") in (_BATCHABLE_ACTION_TOOLS | {"BrowserBatch"}) + for a in action_log) + _stall_limit = (_POST_SEND_STALL_LIMIT if send_confirmed + else (_PERCEPTION_STALL_LIMIT if _acted else 10 ** 9)) if perception_stall >= _stall_limit: logger.info( f"[browser-agent {session_id}] ending: {perception_stall} pure-perception " diff --git a/backend/tests/test_browser_agent_loop.py b/backend/tests/test_browser_agent_loop.py index 7a541250..36653ca9 100644 --- a/backend/tests/test_browser_agent_loop.py +++ b/backend/tests/test_browser_agent_loop.py @@ -233,6 +233,25 @@ def test_confirmed_send_ends_the_run_instead_of_stalling(monkeypatch): assert result["summary"].startswith("OUTCOME: DONE") or "DONE" in result["summary"] +def test_early_perception_is_not_cut_short_before_any_action(monkeypatch): + # Orienting on a cold/slow page can take several look-only turns; the stall + # backstop must NOT fire before the agent has done anything (it only bounds a + # POST-action spin). Here 7 perception turns precede the finish; all must run. + BH._browser_history.clear(); BH._domain_notes.clear() + # varied read tools so the (separate) identical-repeat loop detector doesn't trip; + # this isolates the stall backstop, which must NOT fire pre-action + _reads = ["BrowserListInteractives", "BrowserGetText", "BrowserScreenshot"] + primary = FakeLLM([ + *[Resp([_rp("still orienting"), _tu(_reads[i % 3])]) for i in range(7)], + Resp([Blk("text", "OUTCOME: NOT DONE - could not find it")], stop_reason="end_turn"), + ]) + aux = FakeAux() + _install(monkeypatch, primary, aux) + asyncio.run(BA.run_browser_agent(task="find the thing", browser_id="b1", model="sonnet")) + # it ran all 8 scripted turns (was NOT force-ended at the 6-perception backstop) + assert primary.turn >= 8, f"early orientation was cut short at turn {primary.turn}" + + def test_aux_adjudication_fires_even_when_loop_detector_trips(monkeypatch): # Repeated IDENTICAL failing clicks trip the exact-repeat loop detector AND # reach stagnation exhaustion on the same turn. The aux escape hatch must