mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 21:27:41 +02:00
[eric] browser: a confirmed send ends the run, no more stalling to re-verify what the confirm proved
This commit is contained in:
@@ -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 "
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user