mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-21 04:02:22 +02:00
[eric] browser: fail fast with an honest message when the card's webview is gone
This commit is contained in:
@@ -30,7 +30,9 @@ from backend.apps.agents.browser.browser_loop import (
|
||||
_LOOP_WINDOW_SIZE,
|
||||
_detect_loop,
|
||||
_hash_tool_call,
|
||||
_CARD_GONE_LIMIT,
|
||||
advance_stagnation,
|
||||
card_is_unavailable,
|
||||
completion_is_honest,
|
||||
stagnation_exhausted,
|
||||
)
|
||||
@@ -320,6 +322,7 @@ async def run_browser_agent(
|
||||
# Loop detection state; sliding window of recent state-mutating tool calls
|
||||
recent_tool_calls: list[tuple[str, str, str]] = []
|
||||
loop_trigger_count = 0
|
||||
card_gone_streak = 0 # consecutive "card is gone" results -> fail fast, don't spin
|
||||
|
||||
# Stagnation state: busy-but-stuck detection (no URL change + failures
|
||||
# across a run of actions), distinct from the exact-repeat loop above.
|
||||
@@ -836,6 +839,7 @@ async def run_browser_agent(
|
||||
})
|
||||
if result.get("url"):
|
||||
last_seen_url = result["url"]
|
||||
card_gone_streak = card_gone_streak + 1 if card_is_unavailable(result) else 0
|
||||
|
||||
if tu.name == "BrowserScreenshot" and result.get("image"):
|
||||
final_screenshot = result["image"]
|
||||
@@ -949,6 +953,15 @@ async def run_browser_agent(
|
||||
)
|
||||
break
|
||||
|
||||
# The card's webview is gone (closed / dashboard not open). The agent
|
||||
# can't bring it back, so stop retrying and report it honestly below.
|
||||
if card_gone_streak >= _CARD_GONE_LIMIT:
|
||||
logger.warning(
|
||||
f"[browser-agent {session_id}] browser card {browser_id} is gone "
|
||||
f"({card_gone_streak} consecutive misses); aborting fast"
|
||||
)
|
||||
break
|
||||
|
||||
if cancel_event.is_set():
|
||||
session.status = "stopped"
|
||||
browser_metrics.record_task(session_id, browser_id, task, "stopped",
|
||||
@@ -991,8 +1004,12 @@ async def run_browser_agent(
|
||||
|
||||
# Honesty gate: the model declaring done is not proof the goal happened.
|
||||
# If the run did no real work (zero actions, all actions errored, or only
|
||||
# looked around), report the truth instead of a ghost "completed".
|
||||
honest, dishonest_reason = completion_is_honest(action_log)
|
||||
# looked around), report the truth instead of a ghost "completed". A gone
|
||||
# card gets its own precise reason instead of the generic verdict.
|
||||
if card_gone_streak >= _CARD_GONE_LIMIT:
|
||||
honest, dishonest_reason = False, "the browser card is no longer open (it was closed or never opened)"
|
||||
else:
|
||||
honest, dishonest_reason = completion_is_honest(action_log)
|
||||
final_status = "completed" if honest else "error"
|
||||
if not honest:
|
||||
summary = f"I was not able to complete this task ({dishonest_reason})."
|
||||
|
||||
@@ -199,6 +199,20 @@ _READ_TOOLS = {
|
||||
}
|
||||
|
||||
|
||||
# A card whose webview is gone is UNRECOVERABLE by the agent (it cannot resurrect
|
||||
# the card), unlike a missing selector it could route around. The frontend
|
||||
# returns these only AFTER a 2s re-register grace, so they mean the card is truly
|
||||
# gone (closed) or the dashboard was never open. Retrying just burns the turn
|
||||
# budget (the multi-minute spins we measured), so the caller fails fast instead.
|
||||
_CARD_GONE_MARKERS = ("not an electron webview", "no dashboard is connected")
|
||||
_CARD_GONE_LIMIT = 2 # consecutive misses before we give up (absorbs a transient)
|
||||
|
||||
|
||||
def card_is_unavailable(result: dict) -> bool:
|
||||
err = str(result.get("error") or "").lower()
|
||||
return any(m in err for m in _CARD_GONE_MARKERS)
|
||||
|
||||
|
||||
def completion_is_honest(action_log: list[dict]) -> tuple[bool, str]:
|
||||
"""Reality-check a run the model declared done. Returns (honest, reason).
|
||||
|
||||
|
||||
@@ -394,6 +394,41 @@ def test_ghost_completion_is_reported_as_error_not_completed(monkeypatch):
|
||||
assert SK.find_skill("docs.google.com", "Submit the form") is None
|
||||
|
||||
|
||||
def test_dead_browser_card_aborts_fast_without_spinning(monkeypatch):
|
||||
# The measured waste: a sub-agent dispatched to a released card retried the
|
||||
# dead webview for many turns. Now a gone card must abort fast (a couple of
|
||||
# turns, not the whole budget) and report the precise reason.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH._browser_history.clear()
|
||||
# the model would happily keep clicking for 8 turns if we let it
|
||||
primary = FakeLLM(
|
||||
[Resp([_rp("click"), _tu("BrowserClick", selector=f".s{i}")]) for i in range(8)]
|
||||
+ [Resp([Blk("text", "done")], stop_reason="end_turn")]
|
||||
)
|
||||
aux = FakeAux()
|
||||
_install(monkeypatch, primary, aux)
|
||||
|
||||
async def _card_gone(request_id, action, browser_id, params, tab_id=""):
|
||||
return {"error": f"Browser card '{browser_id}' not found or not an Electron webview"}
|
||||
monkeypatch.setattr(BA.ws_manager, "send_browser_command", _card_gone, raising=False)
|
||||
captured = {}
|
||||
orig = BA.ws_manager.send_to_session
|
||||
|
||||
async def _cap(session_id, event, payload):
|
||||
if event == "agent:status":
|
||||
captured["status"] = payload.get("status")
|
||||
return await orig(session_id, event, payload)
|
||||
monkeypatch.setattr(BA.ws_manager, "send_to_session", _cap, raising=False)
|
||||
|
||||
r = asyncio.run(BA.run_browser_agent(
|
||||
task="Click submit", browser_id="b1", model="sonnet", initial_url=DOC_URL,
|
||||
))
|
||||
assert len(primary.calls) <= 3, "a dead card must fail fast, not spin the whole budget"
|
||||
assert captured.get("status") == "error"
|
||||
assert "no longer open" in r["summary"].lower()
|
||||
|
||||
|
||||
def test_perception_is_frontloaded_into_first_turn(monkeypatch):
|
||||
# With a known start URL, the agent should prefetch the element list + page
|
||||
# text and put them in the FIRST user message, so the model can act on turn 1
|
||||
|
||||
@@ -5,6 +5,7 @@ from backend.apps.agents.browser.browser_loop import (
|
||||
_STAGNATION_MAX,
|
||||
_looks_like_failure,
|
||||
advance_stagnation,
|
||||
card_is_unavailable,
|
||||
completion_is_honest,
|
||||
is_unproductive,
|
||||
stagnation_exhausted,
|
||||
@@ -151,3 +152,11 @@ def test_completion_honest_when_some_errors_but_an_action_landed():
|
||||
log = [_err("BrowserClick"), _err("BrowserClick"), _ok("BrowserClickIndex", "Clicked Submit")]
|
||||
honest, reason = completion_is_honest(log)
|
||||
assert honest
|
||||
|
||||
|
||||
def test_card_is_unavailable_only_for_unrecoverable_errors():
|
||||
# a gone card is unrecoverable (fail fast); a missing selector is not (route around)
|
||||
assert card_is_unavailable({"error": "Browser card 'b1' not found or not an Electron webview"})
|
||||
assert card_is_unavailable({"error": "No dashboard is connected. Open the dashboard to use browser tools."})
|
||||
assert not card_is_unavailable({"error": "Element not found: '.submit'"})
|
||||
assert not card_is_unavailable({"text": "ok", "url": "http://x"})
|
||||
|
||||
Reference in New Issue
Block a user