mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-22 01:24:52 +02:00
[eric] browser: a hard login wall with nothing to borrow hands off to the user at the card and resumes itself when the wall clears (ENG-279)
This commit is contained in:
@@ -86,6 +86,7 @@ from backend.apps.agents.browser import browser_metrics
|
||||
from backend.apps.agents.browser import browser_send_script
|
||||
from backend.apps.agents.browser import browser_send_parse
|
||||
from backend.apps.agents.browser import browser_login_handoff
|
||||
from backend.apps.agents.browser.wait_for_user_signin import wait_for_user_signin
|
||||
from backend.apps.agents.browser import browser_session_import
|
||||
from backend.apps.agents.browser import browser_delivery_check
|
||||
from backend.apps.agents.browser import browser_submit_click
|
||||
@@ -1368,6 +1369,7 @@ async def run_browser_agent(
|
||||
card_gone_streak = 0 # consecutive "card is gone" results -> fail fast, don't spin
|
||||
route_hinted_hosts: set[str] = set() # surface the fast network tier once per host
|
||||
p_login_attempted: set[str] = set() # login-once handoff: at most one silent borrow attempt per domain per run
|
||||
p_signin_wait_used = False # at most ONE user-signin pause per run, so stacked walls can't stall it for 3min each
|
||||
|
||||
# Stagnation state: busy-but-stuck detection (no URL change + failures across a run of actions), distinct from the exact-repeat loop above.
|
||||
stagnation_streak = 0
|
||||
@@ -1936,10 +1938,41 @@ async def run_browser_agent(
|
||||
last_seen_url, "\n".join(attached_state_seen), allow_soft=(turn >= 2))
|
||||
if p_wall_dom and p_wall_dom not in p_login_attempted:
|
||||
p_login_attempted.add(p_wall_dom)
|
||||
p_signed_note = None
|
||||
if await try_borrow_signin(p_wall_dom, browser_id, tab_id, last_seen_url):
|
||||
browser_login_handoff.record_login(p_wall_dom)
|
||||
p_signed_note = (f"You are now signed in to {p_wall_dom}. The page has changed; "
|
||||
"look at it fresh and continue the task.")
|
||||
elif (not p_signin_wait_used and ws_manager.global_connections
|
||||
and browser_login_handoff.login_wall_domain(last_seen_url, "\n".join(attached_state_seen))):
|
||||
# No session to borrow and a HARD wall (soft signed-out pages stay browsable, no
|
||||
# pause). The card is on the user's screen (a renderer is attached), so hand off:
|
||||
# tell them, watch the page, resume the moment the wall clears (ENG-279).
|
||||
p_signin_wait_used = True
|
||||
p_wait_msg = Message(role="assistant", content=(
|
||||
f"⏸ {p_wall_dom} needs you to sign in. Use the browser card directly; "
|
||||
"I'll continue automatically once you're in (waiting up to 3 minutes)."))
|
||||
session.messages.append(p_wait_msg)
|
||||
await ws_manager.send_to_session(session_id, "agent:message", {
|
||||
"session_id": session_id,
|
||||
"message": p_wait_msg.model_dump(mode="json"),
|
||||
})
|
||||
|
||||
async def p_signin_probe() -> tuple:
|
||||
p_gt = await execute_browser_tool("BrowserGetText", {}, browser_id, tab_id)
|
||||
if not isinstance(p_gt, dict):
|
||||
return ("", "")
|
||||
return (str(p_gt.get("url") or ""), str(p_gt.get("text") or ""))
|
||||
|
||||
if await wait_for_user_signin(p_wall_dom, p_signin_probe, cancel_event):
|
||||
browser_login_handoff.record_login(p_wall_dom)
|
||||
p_signed_note = (f"The user just signed in to {p_wall_dom} themselves. The page "
|
||||
"has changed; look at it fresh and continue the task.")
|
||||
else:
|
||||
p_signed_note = (f"The user did not sign in to {p_wall_dom} within the wait "
|
||||
"window. Do what is possible without it, and say plainly in "
|
||||
"Done what still needs the sign-in.")
|
||||
if p_signed_note:
|
||||
if messages and messages[-1].get("role") == "user":
|
||||
p_prev = messages[-1]["content"]
|
||||
if isinstance(p_prev, list):
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""A browser agent stuck on a hard login wall, with no session to borrow, used to just keep
|
||||
failing at the wall; the one thing that fixes it is the human signing in, and the browser card is
|
||||
RIGHT THERE to do it in. So: pause the model loop and watch the page until the wall clears, then
|
||||
resume automatically (ENG-279). This must never fire RequestHumanIntervention (Eric's call,
|
||||
2026-08-08: auto-injected interventions made workflows impossible to run unattended), which is why
|
||||
it is a backend poll with a hard deadline, not a HITL tool: an unattended run stalls at most
|
||||
MAX_WAIT_SECONDS and then continues with an honest note."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Awaitable, Callable, Tuple
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.agents.browser import browser_login_handoff
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
POLL_SECONDS = 4.0
|
||||
MAX_WAIT_SECONDS = 180.0
|
||||
# One clear poll can be an OAuth redirect mid-flight (interstitial URL that matches no wall);
|
||||
# require two in a row so the resume note only fires once the destination page is really back.
|
||||
CLEAR_POLLS_NEEDED = 2
|
||||
|
||||
|
||||
@typechecked
|
||||
async def wait_for_user_signin(
|
||||
domain: str,
|
||||
probe: Callable[[], Awaitable[Tuple[str, str]]],
|
||||
cancel_event: asyncio.Event,
|
||||
) -> bool:
|
||||
"""Poll the page until the login wall for `domain` clears. `probe` returns the page's current
|
||||
(url, visible_text). True = the user signed in (two consecutive wall-free polls); False =
|
||||
deadline, cancellation, or probe failure. Never raises."""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + MAX_WAIT_SECONDS
|
||||
clear_streak = 0
|
||||
while loop.time() < deadline:
|
||||
if cancel_event.is_set():
|
||||
return False
|
||||
try:
|
||||
await asyncio.wait_for(cancel_event.wait(), timeout=POLL_SECONDS)
|
||||
return False
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
try:
|
||||
url, text = await probe()
|
||||
except Exception:
|
||||
clear_streak = 0
|
||||
continue
|
||||
if not url:
|
||||
clear_streak = 0
|
||||
continue
|
||||
if browser_login_handoff.login_wall_domain(url, text) is None:
|
||||
clear_streak += 1
|
||||
if clear_streak >= CLEAR_POLLS_NEEDED:
|
||||
logger.info(f"[signin-wait] wall on {domain} cleared by the user")
|
||||
return True
|
||||
else:
|
||||
clear_streak = 0
|
||||
logger.info(f"[signin-wait] {domain} still walled after {int(MAX_WAIT_SECONDS)}s; continuing without")
|
||||
return False
|
||||
@@ -0,0 +1,78 @@
|
||||
"""The ENG-279 sign-in pause: a hard login wall with nothing to borrow hands off to the human at
|
||||
the browser card and resumes itself. The dangerous edges are (1) resuming on an OAuth redirect
|
||||
interstitial (one wall-free poll is not signed in), (2) never ending (deadline must hold), and
|
||||
(3) outliving a Stop (cancel must win instantly)."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.apps.agents.browser import wait_for_user_signin as w
|
||||
|
||||
|
||||
WALL = ("https://x.com/i/flow/login", "Sign in to X\nPassword")
|
||||
CLEAR = ("https://x.com/home", "Home\nFor you\nFollowing\nPost")
|
||||
|
||||
|
||||
def p_probe_from(seq):
|
||||
it = iter(seq)
|
||||
last = seq[-1]
|
||||
|
||||
async def probe():
|
||||
return next(it, last)
|
||||
return probe
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_clear_polls_resume(monkeypatch):
|
||||
monkeypatch.setattr(w, "POLL_SECONDS", 0.01)
|
||||
monkeypatch.setattr(w, "MAX_WAIT_SECONDS", 5.0)
|
||||
ok = await w.wait_for_user_signin("x.com", p_probe_from([WALL, WALL, CLEAR, CLEAR]), asyncio.Event())
|
||||
assert ok is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_clear_poll_is_not_signed_in(monkeypatch):
|
||||
# An OAuth redirect interstitial matches no wall for a beat; a single clear poll must not resume.
|
||||
monkeypatch.setattr(w, "POLL_SECONDS", 0.01)
|
||||
monkeypatch.setattr(w, "MAX_WAIT_SECONDS", 0.3)
|
||||
ok = await w.wait_for_user_signin("x.com", p_probe_from([WALL, CLEAR, WALL, CLEAR, WALL]), asyncio.Event())
|
||||
assert ok is False, "alternating clear/wall must never satisfy the two-in-a-row rule"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deadline_holds_when_user_never_signs_in(monkeypatch):
|
||||
monkeypatch.setattr(w, "POLL_SECONDS", 0.01)
|
||||
monkeypatch.setattr(w, "MAX_WAIT_SECONDS", 0.15)
|
||||
t0 = asyncio.get_running_loop().time()
|
||||
ok = await w.wait_for_user_signin("x.com", p_probe_from([WALL]), asyncio.Event())
|
||||
assert ok is False
|
||||
assert asyncio.get_running_loop().time() - t0 < 2.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_wins_instantly(monkeypatch):
|
||||
monkeypatch.setattr(w, "POLL_SECONDS", 30.0)
|
||||
monkeypatch.setattr(w, "MAX_WAIT_SECONDS", 300.0)
|
||||
ev = asyncio.Event()
|
||||
|
||||
async def cancel_soon():
|
||||
await asyncio.sleep(0.05)
|
||||
ev.set()
|
||||
task = asyncio.create_task(cancel_soon())
|
||||
t0 = asyncio.get_running_loop().time()
|
||||
ok = await w.wait_for_user_signin("x.com", p_probe_from([WALL]), ev)
|
||||
await task
|
||||
assert ok is False
|
||||
assert asyncio.get_running_loop().time() - t0 < 2.0, "a Stop must not wait out the poll interval"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_failure_reads_as_still_walled(monkeypatch):
|
||||
monkeypatch.setattr(w, "POLL_SECONDS", 0.01)
|
||||
monkeypatch.setattr(w, "MAX_WAIT_SECONDS", 0.15)
|
||||
|
||||
async def broken_probe():
|
||||
raise RuntimeError("webview gone")
|
||||
ok = await w.wait_for_user_signin("x.com", broken_probe, asyncio.Event())
|
||||
assert ok is False
|
||||
Reference in New Issue
Block a user