diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 64513aa8..45a2bf2c 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -935,7 +935,10 @@ async def run_browser_agent( # (measured: send done at turn ~11, then ~12 wasted perception turns). We drive # it to the OUTCOME and, if it keeps re-perceiving, end the run. A genuine # multi-send task issues its NEXT send (an action) which resets the stall, so - # only true spinning ends here. + # only true spinning ends here. This whole shortcut is meaningless for a + # gather/read task (no send to confirm), and arming it there let a cookie + # 'Accept all' click masquerade as the task's send, so we gate it on intent. + task_is_send = not deliverable_is_informational("", task) send_confirmed = False perception_stall = 0 # consecutive turns the model only LOOKED (no action) _POST_SEND_STALL_LIMIT = 2 # once the send registered, finish fast @@ -1495,7 +1498,7 @@ async def run_browser_agent( # across nodes, or scrolled off, so the text-probe is unreliable, which # is exactly what left the model stalling to "double-check". A clean # send click is proof enough; drive to the OUTCOME. - if not send_confirmed and "error" not in result and tu.name in _CONFIRM_TOOLS: + if task_is_send and not send_confirmed and "error" not in result and tu.name in _CONFIRM_TOOLS: _cn = result.get("clickedName") or "" _send_click = browser_batch_replay.is_replay_boundary( {"action": "click", "name": _cn}) or any( diff --git a/backend/tests/test_browser_agent_loop.py b/backend/tests/test_browser_agent_loop.py index 505d5e3f..667a19e3 100644 --- a/backend/tests/test_browser_agent_loop.py +++ b/backend/tests/test_browser_agent_loop.py @@ -282,6 +282,27 @@ def test_run_that_never_calls_done_is_not_a_clean_success(monkeypatch): assert result.get("done") is False # no explicit Done -> not a clean success +def test_send_shortcut_does_not_arm_on_a_gather_task(monkeypatch): + # The Airbnb bug: a send-class click (here the index-99 sentinel = "Send", same + # as a cookie "Accept all" tripping the detector) on a FIND/gather task must NOT + # arm the send-completion shortcut, there is no send to confirm. If it did, the + # run cuts at the 2-turn post-send limit and leaks the canned "message went + # through" line. On a gather task it should run the full perception budget. + BH._browser_history.clear(); BH._domain_notes.clear() + primary = FakeLLM([ + Resp([_rp("dismiss the cookie banner"), _tu("BrowserClickIndex", index=99)]), + *[Resp([_rp("keep reading the list"), _tu("BrowserScreenshot")]) for _ in range(8)], + Resp([_tu("Done", message="Here are the top items: a, b, c")]), + ]) + aux = FakeAux() + _install(monkeypatch, primary, aux) + result = asyncio.run(BA.run_browser_agent(task="find me the top 10 repos", browser_id="b1", model="sonnet")) + # the send shortcut never armed: it ran past the 2-turn post-send cutoff toward + # the 6-turn perception budget, and no send-confirmation line leaked + assert primary.turn >= 6, f"gather task cut short at turn {primary.turn} (send shortcut wrongly armed)" + assert "went through" not 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