[eric] browser: a run out of wall time delivers what it has instead of nothing

This commit is contained in:
ciregenz
2026-07-30 20:50:37 -07:00
parent 324ee47937
commit f6ad6cfb91
2 changed files with 50 additions and 2 deletions
+16 -2
View File
@@ -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:
+34
View File
@@ -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()