[eric] browser: a confirmed send ends the run, no more stalling to re-verify what the confirm proved

This commit is contained in:
ciregenz
2026-06-07 18:52:20 -07:00
parent a54b4c0452
commit 889c7707e4
2 changed files with 75 additions and 9 deletions
+48 -7
View File
@@ -856,14 +856,16 @@ async def run_browser_agent(
)
text_parts = [] # initialized before loop so post-loop summary (line ~1294) has a default
# Circuit breaker for ReportProgress violations. Some models get stuck
# in a loop where they keep calling action tools without the brain-
# state preamble. Each iteration of this loop pumps websocket events
# to the frontend, which fans out to every useSelector subscriber and
# tanks UI responsiveness. After N consecutive violations the agent
# gives up and surfaces an error instead of churning through all
# MAX_TURNS doing the same broken thing.
rp_violations = 0 # turns the model acted without ReportProgress (now accepted + reminded, not rejected)
# Completion detection: once an irreversible SEND has confirmed, the goal is
# met. The model otherwise stalls re-verifying what the confirm already proved
# (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.
send_confirmed = False
post_send_stall = 0
_POST_SEND_STALL_LIMIT = 2
# rows already shown to the model; attached state shrinks to the delta
attached_state_seen: set[str] = set()
# under-batching telemetry + nudge state
@@ -1031,6 +1033,28 @@ async def run_browser_agent(
f"batch={_turn_has_batch} streak={single_action_streak}"
)
# Post-send stall: the send already CONFIRMED, so a pure-perception turn
# now (no action, the model didn't finish) is wasted re-verification. Push
# hard to the OUTCOME; if it spins again, end (the confirm IS the proof, the
# completion gate re-checks the log). An action turn means real more-to-do,
# so reset and let it continue (multi-send stays safe).
if send_confirmed and _turn_actions == 0:
post_send_stall += 1
if post_send_stall >= _POST_SEND_STALL_LIMIT:
logger.info(
f"[browser-agent {session_id}] ending: send confirmed, model kept "
f"re-perceiving {post_send_stall} turns instead of finishing"
)
# synthesize the OUTCOME from the confirmed send so the parent
# gets real proof, not a vague summary that triggers a re-dispatch
_proof = next((str(a.get("result_summary") or "") for a in reversed(action_log)
if a.get("ok") and "Confirmed:" in str(a.get("result_summary") or "")), "")
text_parts = ["OUTCOME: DONE - the action was completed and confirmed on the page. "
+ _proof[:160]]
break
elif _turn_actions > 0:
post_send_stall = 0
for tu in tool_uses_sorted:
if cancel_event.is_set():
cancelled = True
@@ -1351,6 +1375,23 @@ async def run_browser_agent(
result["confirmed"] = bool(_conf.get("found"))
if _conf.get("found"):
result["text"] = f"{result.get('text') or ''}\nConfirmed: '{_expect}' is now present."
# A confirmed IRREVERSIBLE send = the goal is met. Drive
# completion now so the model stops re-verifying (the
# opener-excluded check so a 'Message' open never trips it).
_cn = result.get("clickedName") or ""
_sub_send = any(
browser_batch_replay.is_replay_boundary(
{"action": "click", "name": r.get("clickedName") or ""})
for r in (result.get("results") or []))
if (_sub_send or browser_batch_replay.is_replay_boundary(
{"action": "click", "name": _cn})):
send_confirmed = True
result["text"] = (f"{result.get('text') or ''}\n\n[done] That "
"irreversible action CONFIRMED, the proof above IS your "
"verification, the task is complete. Your NEXT message must be "
"ONLY the 'OUTCOME: DONE - <result + the proof you saw>' line "
"with NO tool calls. Do NOT screenshot, re-list, or re-read to "
"double-check, you already have the proof.")
else:
result["text"] = (
f"{result.get('text') or ''}\nNOT confirmed: '{_expect}' did not appear within "
+27 -2
View File
@@ -102,8 +102,10 @@ def _install(monkeypatch, primary, aux):
# refused by the replay send-gate, which has its own test below
return {"text": '1 interactive elements:\n[1]<button "Search">', "url": DOC_URL}
if action == "click_index":
# frontend surfaces the clicked element's role/name for skill recording
return {"text": "Clicked index 1", "url": DOC_URL, "clickedRole": "button", "clickedName": "Search"}
# frontend surfaces the clicked element's role/name for skill recording;
# index 99 is the test sentinel for the irreversible "Send" button
_nm = "Send" if params.get("index") == 99 else "Search"
return {"text": f"Clicked index {params.get('index')}", "url": DOC_URL, "clickedRole": "button", "clickedName": _nm}
if action == "click_by_name":
return {"text": f'Clicked button "{params.get("name")}"', "url": DOC_URL}
if action == "click":
@@ -208,6 +210,29 @@ def test_missing_report_progress_runs_the_action_and_reminds_not_rejects(monkeyp
assert "REJECTED" not in all_msgs
def test_confirmed_send_ends_the_run_instead_of_stalling(monkeypatch):
# After an irreversible send CONFIRMS, the model must not burn turns re-verifying.
# Here it sends (index 99 = "Send", expect confirms) then tries to stall forever
# with pure-perception turns; the loop must END within a turn or two, not spin.
BH._browser_history.clear(); BH._domain_notes.clear()
primary = FakeLLM([
Resp([_rp("send the message"), _tu("BrowserClickIndex", index=99, expect="Sent")]),
# the model now STALLS, re-looking instead of finishing (the bug)
*[Resp([_rp("double-check it sent"), _tu("BrowserScreenshot")]) for _ in range(8)],
Resp([Blk("text", "OUTCOME: DONE - sent")], stop_reason="end_turn"),
])
aux = FakeAux()
sent = _install(monkeypatch, primary, aux)
result = asyncio.run(BA.run_browser_agent(task="text Tyler hello", browser_id="b1", model="sonnet"))
# the send ran and the run ended FAST (the stall guard stopped it), well before
# consuming all 8 scripted stall turns
assert any(c["action"] == "click_index" and c["params"].get("index") == 99 for c in sent)
assert primary.turn <= 4, f"run stalled {primary.turn} turns after a confirmed send"
assert result["summary"].startswith("OUTCOME: DONE") or "DONE" in result["summary"]
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