From f6ad6cfb915d09a4d29c6379c945fcec203d0cdc Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 20:50:37 -0700 Subject: [PATCH] [eric] browser: a run out of wall time delivers what it has instead of nothing --- backend/apps/agents/browser/browser_agent.py | 18 +++++++++-- backend/tests/test_browser_agent_loop.py | 34 ++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 021ac694..90107a0e 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -58,6 +58,11 @@ P_BATCHABLE_ACTION_TOOLS = { } # Injected when the spin backstop trips: one chance to land a real answer from what's already gathered, instead of the loop cutting it off mid-thought. +# Past this many seconds a run stops exploring and delivers what it has. Sits above every run that +# actually succeeded in the 2026-07-30 sweeps (slowest good one: 142.8s) and well under the 300s +# that produced an empty answer. It nudges rather than kills, so the answer still comes from Done. +P_WALL_BUDGET_S = 180.0 + P_WRAPUP_NUDGE = ( "You've spent several turns looking without finishing. Wrap up NOW: call Done with the " "best answer you can give from what you've ALREADY gathered. For a find/list ask, put the " @@ -2042,10 +2047,19 @@ async def run_browser_agent( p_novel_read = True # Out of turn budget with no answer yet: nudge a wrap-up so a long-running gather delivers what it has via Done at the cap, instead of the for-loop ending on the model's half-finished sentence. Same one-shot channel. - if turn >= MAX_TURNS - 4 and not wrapup_nudged and not done_called and not send_confirmed: + # Turns are not minutes: 40 turns bounds the LOOP but not the clock, and nobody waits + # five minutes for a browser errand they could have run themselves. Measured: a reddit + # task ground past 300s and returned NOTHING, while every run that ever succeeded that + # day finished inside 143s. So the clock gets the same nudge the turn cap gets, and the + # answer arrives partial-but-honest instead of never. + p_out_of_time = time.time() - metrics_started_at > P_WALL_BUDGET_S + if ((turn >= MAX_TURNS - 4 or p_out_of_time) + and not wrapup_nudged and not done_called and not send_confirmed): wrapup_nudged = True wrapup_pending = True - logger.info(f"[browser-agent {session_id}] turn budget low ({turn}/{MAX_TURNS}); nudging wrap-up") + p_why = (f"wall budget spent ({int(time.time() - metrics_started_at)}s)" if p_out_of_time + else f"turn budget low ({turn}/{MAX_TURNS})") + logger.info(f"[browser-agent {session_id}] {p_why}; nudging wrap-up") # Spin backstop: a pure-perception turn that ISN'T gathering new data is wasted (re-verifying a send, or re-looking at the same page). Bound it. if p_turn_actions == 0: diff --git a/backend/tests/test_browser_agent_loop.py b/backend/tests/test_browser_agent_loop.py index 0ec1e5ac..2dab4119 100644 --- a/backend/tests/test_browser_agent_loop.py +++ b/backend/tests/test_browser_agent_loop.py @@ -367,6 +367,40 @@ def test_spin_backstop_nudges_a_clean_wrapup_instead_of_a_midthought(monkeypatch assert result.get("done") is True +def test_a_run_out_of_wall_time_delivers_what_it_has(monkeypatch): + # Turns bound the LOOP, not the clock. Measured 2026-07-30: a reddit task ground past 300s and + # returned an EMPTY answer, while every run that succeeded that day finished inside 143s. The + # clock now gets the same one-shot wrap-up nudge the turn cap gets, so a long errand comes back + # partial-but-honest instead of never. + BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear() + monkeypatch.setattr(BA, "P_WALL_BUDGET_S", 0.0) # over budget from the first turn + primary = FakeLLM([ + Resp([p_rp("looking around"), p_tu("BrowserGetText")]), + Resp([p_tu("Done", message="Top comment is from u/someone, 387 upvotes.")]), + ]) + p_install(monkeypatch, primary, FakeAux()) + result = asyncio.run(BA.run_browser_agent(task="read the top comment", browser_id="b1", model="sonnet")) + assert any("Wrap up NOW" in json.dumps(c["messages"]) for c in primary.calls), \ + "a run past its wall budget must be nudged to deliver" + assert result["summary"] == "Top comment is from u/someone, 387 upvotes." + assert result.get("done") is True + + +def test_a_quick_run_is_never_nudged_by_the_wall_budget(monkeypatch): + # The budget must not touch normal runs, or it would cut short the very tasks it exists to save. + BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear() + assert BA.P_WALL_BUDGET_S >= 150, "budget must sit above the slowest run that actually succeeded" + primary = FakeLLM([ + Resp([p_rp("reading"), p_tu("BrowserGetText")]), + Resp([p_tu("Done", message="It costs $9.99.")]), + ]) + p_install(monkeypatch, primary, FakeAux()) + result = asyncio.run(BA.run_browser_agent(task="what does it cost", browser_id="b1", model="sonnet")) + assert not any("Wrap up NOW" in json.dumps(c["messages"]) for c in primary.calls), \ + "a fast run must never see the wrap-up nudge" + assert result["summary"] == "It costs $9.99." + + 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()