diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 59bf7ac0..a15194f7 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -1658,8 +1658,10 @@ async def run_browser_agent( # in this card (the persistent partition keeps the session, so future runs won't ask # again), then continue. At most one pause per domain per run; the model's own # RequestHumanIntervention stays as the fallback for walls this detector misses. + # Soft signed-out (composer withheld behind a "Sign in") only counts once the agent has + # actually tried and is still stuck, so a first-turn glance can't raise a false prompt. p_wall_dom = browser_login_handoff.login_wall_domain( - last_seen_url, "\n".join(attached_state_seen)) + last_seen_url, "\n".join(attached_state_seen), allow_soft=(turn >= 2)) if p_wall_dom and p_wall_dom not in p_login_prompted: p_login_prompted.add(p_wall_dom) p_login_problem, p_login_instruction = browser_login_handoff.prompt_copy(p_wall_dom) diff --git a/backend/apps/agents/browser/browser_login_handoff.py b/backend/apps/agents/browser/browser_login_handoff.py index 3217cfcb..a79ba4e7 100644 --- a/backend/apps/agents/browser/browser_login_handoff.py +++ b/backend/apps/agents/browser/browser_login_handoff.py @@ -73,12 +73,21 @@ def record_login(url_or_host: str) -> None: @typechecked -def login_wall_domain(current_url: str, state_text: str) -> Optional[str]: +def login_wall_domain(current_url: str, state_text: str, allow_soft: bool = False) -> Optional[str]: """The registrable domain of a login wall the agent is stuck on, or None. One definition of - 'login wall', shared with the send-script's decline gate.""" - if not browser_send_parse.looks_like_login_wall(current_url or "", state_text or ""): - return None - return registrable_domain(current_url) or None + 'login wall', shared with the send-script's decline gate. + + `allow_soft` additionally accepts a SOFT signed-out page: browsable, no auth form, composer + simply withheld behind a "Sign in" control (bsky/stackoverflow/tiktok). Those never match the + hard wall, so the run used to fail as "couldn't find the compose box" instead of offering the + one thing that fixes it. Off by default because this pause interrupts the user: the caller + turns it on only once the agent is demonstrably stuck, so a stray "Sign up" link on a page + we're actually signed into can't raise a spurious prompt.""" + if browser_send_parse.looks_like_login_wall(current_url or "", state_text or ""): + return registrable_domain(current_url) or None + if allow_soft and browser_send_parse.looks_signed_out(state_text or ""): + return registrable_domain(current_url) or None + return None @typechecked diff --git a/backend/apps/agents/browser/browser_send_parse.py b/backend/apps/agents/browser/browser_send_parse.py index dca6ce49..28147c5f 100644 --- a/backend/apps/agents/browser/browser_send_parse.py +++ b/backend/apps/agents/browser/browser_send_parse.py @@ -60,6 +60,31 @@ def looks_like_login_wall(current_url: str, state_text: str) -> bool: return bool(state_text and P_LOGIN_WALL_STATE_RE.search(state_text)) +# SOFT signed-out: the site serves a browsable page with no auth form and no login URL, it just +# withholds the composer and offers a "Sign in" control (bsky, stackoverflow, tiktok, threads all +# behave this way). The hard-wall gate above sees nothing, so the run used to report "I couldn't +# find the compose box" when the truth was "you are not signed in", which is a different problem +# with a different fix. Only ever consulted AFTER a composer miss, so it cannot affect a success. +P_SIGNIN_AFFORDANCE_RE = re.compile( + r'<\s*(?:link|button)\s+"[^"]*(?:sign[_ -]?in|log[_ -]?in|sign[_ -]?up|create account|join now)', + re.I) +# Anything only a signed-IN page shows. Its presence vetoes the verdict, so a stray "Log in" on an +# authenticated page (a second product's promo) can't make us tell the user to sign in again. +P_SIGNED_IN_RE = re.compile( + r'(?:sign|log)[_ -]?out|your profile|account menu|my account|notifications|' + r'<\s*(?:link|button)\s+"[^"]*(?:profile|avatar|inbox)', + re.I) + + +def looks_signed_out(state_text: str) -> bool: + """True when the page offers a way to sign IN and shows nothing only a signed-in user sees.""" + if not state_text: + return False + if P_SIGNED_IN_RE.search(state_text): + return False + return bool(P_SIGNIN_AFFORDANCE_RE.search(state_text)) + + def is_readonly(text: str) -> bool: """A read-only directive ('verify whether', 'do not send') that must decline the scripted send even with a quoted payload in hand. Keeps the regex private to this file.""" diff --git a/backend/apps/agents/browser/browser_send_script.py b/backend/apps/agents/browser/browser_send_script.py index 545051ed..912f00ce 100644 --- a/backend/apps/agents/browser/browser_send_script.py +++ b/backend/apps/agents/browser/browser_send_script.py @@ -244,7 +244,13 @@ async def run_send_script( else: logger.info(f"[browser-sendscript] structural finder: no usable composer ({str(fc)[:120]})") if not composer: - logger.info("[browser-sendscript] decline: no composer, opener, or structural editable") + # Name WHY. A site that withholds the composer because nobody is signed in is a + # different problem from one whose composer we failed to find, and only the first is + # fixable by the user (sign in once). Consulted only here, on the already-failed path. + if browser_send_parse.looks_signed_out(state_text): + logger.info("[browser-sendscript] decline: signed OUT (composer withheld, sign-in offered)") + else: + logger.info("[browser-sendscript] decline: no composer, opener, or structural editable") return None # No Send-button precondition: composer sites (LinkedIn) lazy-render Send only AFTER text commits, so it's resolved post-fill; never appearing = clean pre-click abort. logger.info(f"[browser-sendscript] fill target {composer[1]!r} [{composer[0]}]")