From c9c25542b9246aa00e132f489e558bf59856a42e Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 13:03:59 -0700 Subject: [PATCH] [eric] browser: never click a disabled submit, and turn the structural composer finder on --- backend/apps/agents/browser/browser_agent.py | 10 ++++ .../agents/browser/browser_send_script.py | 52 +++++++++++++++++-- backend/tests/test_browser_guessed_submit.py | 29 ++++++++++- backend/tests/test_browser_reveal.py | 18 ++++--- backend/tests/test_browser_send_script.py | 13 +++-- 5 files changed, 104 insertions(+), 18 deletions(-) diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 9145641d..22b5a813 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -2705,6 +2705,16 @@ async def run_browser_agent( else: task = f"{task}\n\n[{p_cs.get('note')}]" logger.info(f"[browser-autosend {session_id}] post-fill send click ran, receipt unverified; model verifies") + elif p_cs.get("note"): + # Nothing was clicked, and the reason is worth more than the silence: + # a disabled submit means the FORM is incomplete, which is the one + # failure here the model can actually fix (fill the title, the subject, + # the missing required field). Dropping this note left it re-clicking a + # greyed-out button until the turn budget ran out. + task = f"{task}\n\n[{p_cs.get('note')}]" + action_log.extend(p_cs.get("log") or []) + logger.info(f"[browser-autosend {session_id}] no send click ran; handed the " + f"model the reason instead") # One gentle nudge per violating turn, folded onto the action that ran, so the model self-corrects next turn without us costing it one. if rp_reminder_pending and tu.name in ACTION_TOOLS_REQUIRING_REPORT: diff --git a/backend/apps/agents/browser/browser_send_script.py b/backend/apps/agents/browser/browser_send_script.py index e00532b6..b1af1f1d 100644 --- a/backend/apps/agents/browser/browser_send_script.py +++ b/backend/apps/agents/browser/browser_send_script.py @@ -97,6 +97,21 @@ async def complete_send( {"xPercent": float(p_v["xPct"]), "yPercent": float(p_v["yPct"])}, browser_id, tab_id) send_name = str(p_v.get("name") or "submit") via = "container" + elif isinstance(p_v, dict) and p_v.get("disabled"): + # The submit EXISTS and the site is refusing it, so there is nothing here a better + # click could win: guessing at the literal "Send" would tap some other widget and the + # cleared composer would read as delivery. Measured live on reddit's r/test/submit, + # which is the whole shape of issue #94: its "post" button sits greyed out until the + # title field is filled, and every run blind-tapped a coordinate and claimed success. + # Handing back untouched is what lets the model do the one thing that CAN fix this, + # fill the rest of the form, and it costs a run that was never going to send anyway. + logger.info(f"[browser-sendscript] submit {str(p_v.get('name'))!r} is present but DISABLED; " + f"the form is incomplete, handing to the model without clicking anything") + return {"clicked": False, "sent": False, "log": log, + "note": (f"The {str(p_v.get('name')) or 'submit'} button is visible but disabled, so this " + f"form is not ready to send: something it requires is still empty (often a " + f"title or subject), or the editor never registered the typed text. Fill the " + f"remaining fields, then send. Nothing was clicked and nothing was posted.")} else: p_why = p_v.get("why") if isinstance(p_v, dict) else "unreadable eval" logger.info(f"[browser-sendscript] container submit miss ({p_why}); by-name fallback") @@ -192,7 +207,13 @@ async def run_send_script( prompt; the composed task carries the routing brief whose own quoted strings made every real payload look ambiguous (r242/r243).""" t0 = time.monotonic() - p_struct = os.environ.get("OSW_COMPOSER_STRUCT") == "1" + # Default ON as of 2026-07-31, on a measured sweep over the 9 sites this profile is genuinely + # signed into: composer reach 3/9 -> 6/9, submit resolution 2/9 -> 5/9. The name-based detector + # it backs up only sees a composer that carries a recognisable accessible name, which is a + # minority of the web; instagram reached its composer on this tier and nothing else. It costs + # one page scan on a page that has no composer, and every gate downstream (quoted payload, + # fill-seen-committed, two-sided receipt) is unchanged, so a wider search cannot loosen safety. + p_struct = os.environ.get("OSW_COMPOSER_STRUCT", "1") != "0" async def fresh_list() -> str: try: @@ -225,8 +246,10 @@ async def run_send_script( if browser_send_parse.is_readonly(task_sans_brief) or (payload_source and browser_send_parse.is_readonly(payload_source)): logger.info("[browser-sendscript] decline: read-only directive in user request") return None - if browser_send_parse.looks_like_login_wall(current_url, state_text): - logger.info(f"[browser-sendscript] decline: login/auth wall ({(current_url or '')[:60]!r})") + p_wall = browser_send_parse.login_wall_reason(current_url, state_text) + if p_wall: + logger.info(f"[browser-sendscript] decline: login/auth wall ({(current_url or '')[:60]!r}) " + f"triggered by {p_wall}") return None payload = browser_send_parse.quoted_payload(payload_source or task) if not payload: @@ -299,7 +322,11 @@ async def run_send_script( # OSW_COMPOSER_REVEAL: let the finder take one reversible reveal action (open the # compose surface: a modal trigger, the first conversation, or a scroll) when the # composer isn't painted yet. It never commits a send, only opens a surface. - p_reveal = os.environ.get("OSW_COMPOSER_REVEAL") == "1" + # Default ON with the same sweep behind it: youtube's comment box exists only after a + # scroll and a click on its placeholder, so no amount of scanning a painted page finds + # it, and it was the single site this tier won. Reveal never commits anything: it opens + # a surface, and its HARDBLOCK list keeps it off send/submit/pay/delete controls. + p_reveal = os.environ.get("OSW_COMPOSER_REVEAL", "1") != "0" # A reveal that OPENS the first list item (a Reddit thread, a TikTok video, a GitHub # issue) is a full-page NAVIGATION: it kills the finder's own JS context, so that one # call can't reach the composer that only exists on the destination. When the finder @@ -416,7 +443,22 @@ async def run_send_script( # found, fill committed); we stop before the irreversible click and report readiness. if os.environ.get("OSW_SENDSCRIPT_DRYRUN") == "1": send_ready = bool(send_index_in_state(state2, composer[0])) - logger.info(f"[browser-sendscript] DRYRUN: WOULD send (fill committed, send_button_listed={send_ready}); not clicking") + # Resolve the submit too, WITHOUT clicking it: the resolver is a page read that hands back + # coordinates, so a dry run can measure both halves of coverage (did we reach a composer AND + # can we find its send) on the great majority of sites we are never allowed to post to. + # Measuring only the fill is exactly how a resolver that could not find reddit's button hid + # behind a passing suite: every dry sweep said "ready to send" about a send that would have + # blind-tapped a coordinate. + r_ev = await execute_tool( + "BrowserEvaluate", + {"expression": browser_submit_click.container_submit_expression(payload)}, browser_id, tab_id) + p_v = browser_submit_click.parse_eval_value(r_ev) + p_ok = bool(isinstance(p_v, dict) and p_v.get("ok")) + p_named = (str(p_v.get("name") or "") if p_ok else + str(p_v.get("why") or "unreadable eval") if isinstance(p_v, dict) else "unreadable eval") + logger.info(f"[browser-sendscript] DRYRUN: WOULD send (fill committed, " + f"send_button_listed={send_ready}, submit_resolved={p_ok}, " + f"submit_rank={p_v.get('rank') if p_ok else 0}, submit={p_named!r}); not clicking") return {"sent": False, "payload": payload, "log": log, "note": "DRYRUN: filled + ready to send, stopped before the irreversible click"} # 3+4: the irreversible click + two-sided receipt, shared with the mid-loop takeover. A click error hands back to the model (fill committed, not sent); a clicked-but-unverified send returns sent=False so the caller never claims delivery. diff --git a/backend/tests/test_browser_guessed_submit.py b/backend/tests/test_browser_guessed_submit.py index ed484085..6ea082d8 100644 --- a/backend/tests/test_browser_guessed_submit.py +++ b/backend/tests/test_browser_guessed_submit.py @@ -20,7 +20,8 @@ COMMITTED = f'[2]' CLEARED = '[2]\n[9]