diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index fba6304b..64513aa8 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -2038,9 +2038,11 @@ async def run_browser_agent( "browser_id": browser_id, "summary": summary, # structured success signal the parent reads (replaces grepping the - # summary for a tag): true only if the run is honest AND, when the - # model called Done, it reported success. No-Done runs lean on honest. - "done": honest and (done_success if done_called else True), + # summary for a tag): true ONLY when the model explicitly finished via + # Done with success AND the honesty gate agreed. A run that just stopped + # (max turns, gave up, never called Done) is NOT a clean success, so the + # fast path recovers instead of shipping a silent half-finish. + "done": honest and done_called and done_success, # surface the honest failure to the parent so it doesn't treat a # did-nothing run as a success it can build on **({} if honest else {"error": summary}), diff --git a/backend/apps/agents/browser/browser_loop.py b/backend/apps/agents/browser/browser_loop.py index cc4a954b..b1335864 100644 --- a/backend/apps/agents/browser/browser_loop.py +++ b/backend/apps/agents/browser/browser_loop.py @@ -308,7 +308,8 @@ def replay_recheck_is_safe(action_log: list[dict]) -> bool: # never replay (the answer must be fresh), an action ask can. _INFO_ASK_RE = re.compile( r"\b(tell me|what(?:'s| is| are)|how (?:many|much)|count|list|summari[sz]e|" - r"extract|find out|read (?:me|the)|get the|give me|which|who (?:is|are)|report back)\b", + r"extract|find (?:me|out)|show me|look up|read (?:me|the)|get the|give me|which|" + r"who (?:is|are)|report back|most (?:viewed|popular|liked|recent|rated|watched)|top \d+)\b", re.I, ) _ACTION_ASK_RE = re.compile( diff --git a/backend/apps/agents/browser/browser_schema.py b/backend/apps/agents/browser/browser_schema.py index 93d456ee..f8518321 100644 --- a/backend/apps/agents/browser/browser_schema.py +++ b/backend/apps/agents/browser/browser_schema.py @@ -816,7 +816,15 @@ SYSTEM_PROMPT = ( "- To pull SPECIFIC FIELDS off a page (names + links from results, prices, table " "rows), call BrowserExtract with what you want; a helper model reads the page and " "hands you just the JSON. One call replaces BrowserGetText plus you reading 15k " - "chars, so prefer it whenever you know the fields you're after.\n\n" + "chars, so prefer it whenever you know the fields you're after.\n" + "- For ANY 'find me / list / get all / top N / most-viewed' gathering task, " + "BrowserExtract IS your tool: state the fields and let the helper read the rendered " + "page. Do NOT hand-roll CSS/DOM selectors in BrowserEvaluate to scrape a list, modern " + "sites (YouTube, LinkedIn, Amazon) obfuscate and lazy-render class names, so selector " + "scraping burns turns and silently returns the wrong nodes. If one BrowserEvaluate read " + "comes back empty or shaped wrong, STOP, that is your signal to switch to BrowserExtract, " + "not to debug another selector. The answer must end up IN your Done message, so gather it " + "for real; never report done on a gather task you couldn't actually read.\n\n" "## When you genuinely cannot proceed\n" "Use RequestHumanIntervention for:\n" diff --git a/backend/tests/test_browser_agent_loop.py b/backend/tests/test_browser_agent_loop.py index 27384085..505d5e3f 100644 --- a/backend/tests/test_browser_agent_loop.py +++ b/backend/tests/test_browser_agent_loop.py @@ -267,6 +267,21 @@ def test_done_tool_success_false_marks_not_done(monkeypatch): assert "login wall" in result["summary"] +def test_run_that_never_calls_done_is_not_a_clean_success(monkeypatch): + # A run that does real work but stops with plain text (never calls Done) is a + # half-finish, not a clean success: done must be False so the fast path recovers + # instead of shipping a silent stop (the 'Task completed.' that wasn't). + BH._browser_history.clear(); BH._domain_notes.clear() + primary = FakeLLM([ + Resp([_rp("click it"), _tu("BrowserClickIndex", index=3)]), + Resp([Blk("text", "I clicked the thing.")], stop_reason="end_turn"), + ]) + aux = FakeAux() + _install(monkeypatch, primary, aux) + result = asyncio.run(BA.run_browser_agent(task="open the settings page", browser_id="b1", model="sonnet")) + assert result.get("done") is False # no explicit Done -> not a clean success + + 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 @@ -1402,6 +1417,16 @@ def test_informational_gate_strips_outcome_boilerplate_on_tie_break(): assert deliverable_is_informational(listy, "") +def test_find_me_and_most_viewed_asks_are_informational(): + from backend.apps.agents.browser.browser_loop import deliverable_is_informational + # the exact MKBHD task that previously slipped past the gate ("find me" + "most viewed") + assert deliverable_is_informational("", "find me his 50 most viewed vids") + assert deliverable_is_informational("", "show me the top 10 trending repos") + assert deliverable_is_informational("", "look up the cheapest flight") + # a pure action ask still is not informational + assert not deliverable_is_informational("", "send Tyler a message saying hi") + + 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)