""" Staged-send script: when the pre-stage leaves a READY composer (a compose textbox and a real Send button both visible) and the task names its payload in quotes, code performs the fill/verify/send/verify tail the model otherwise spends 4-5 turns (~15s) on. Safety is the same bar as the loop's, enforced in code: the payload must be SEEN committed to the textbox before the one irreversible click, the Send button is re-resolved from fresh state after the fill (indices shift), and the composer must be SEEN cleared after. Any ambiguity BEFORE the click aborts to the untouched model path; ambiguity AFTER the click hands the model a truthful "clicked, unverified, do NOT re-send" note, never a silent retry. """ import asyncio import logging import os import re import time from typing import Awaitable, Callable logger = logging.getLogger(__name__) # Double quotes are unambiguous. Single quotes only delimit when the opener is at a word boundary (start/space/colon), so an in-word apostrophe like "chen's" is never mistaken for a payload quote, that mispairing was silently corrupting the canonical "text him '...'" errand. P_QUOTED_DQ_RE = re.compile(r'"([^"]{4,300})"') P_QUOTED_SQ_RE = re.compile(r"(?:^|[\s:>])'([^']{4,300})'") P_COMPOSER_ROW_RE = re.compile(r"\[(\d+)\]\*?<\s*textbox\s+\"([^\"]*)\"", re.I) P_COMPOSER_NAME_RE = re.compile(r"write|message|compose|reply", re.I) ToolRunner = Callable[[str, dict, str, str], Awaitable[dict]] def script_enabled() -> bool: return os.environ.get("OSW_SEND_SCRIPT", "0") != "0" def quoted_payload(task: str) -> str: """The exact text the user quoted, only when it's unambiguous: exactly one distinct quoted span in the task. Anything else is the model's judgment call. Double quotes win outright; single quotes must be word-boundary-delimited so an apostrophe inside a name can't hijack the match.""" dq = {m.group(1).strip() for m in P_QUOTED_DQ_RE.finditer(task or "") if m.group(1).strip()} if dq: return dq.pop() if len(dq) == 1 else "" sq = {m.group(1).strip() for m in P_QUOTED_SQ_RE.finditer(task or "") if m.group(1).strip()} return sq.pop() if len(sq) == 1 else "" P_OPENER_ROW_RE = re.compile(r"\[(\d+)\]\*?<\s*(?:link|button)\s+\"(Message|Reply|Compose|New message)\"", re.I) # A verification probe quotes the very payload it's checking for, which is exactly the trap this gate exists for: quoted payload + composer = fire. Caught live (r243): the read-only send-probe delivered a REAL message. Read-only directives decline in code, fail-safe (a false match just means the model path). P_READONLY_RE = re.compile( r"read.?only|do\s+not\s+(?:send|type|click|post|submit)|don'?t\s+(?:send|post|submit)|" r"verify\s+whether|check\s+whether|verification", re.I, ) def opener_index_in_state(state_text: str): """(index, name) of the single exact-named composer OPENER, or None. Exact names only, so an upsell like 'Send InMail' can never match.""" hits = [(int(m.group(1)), m.group(2)) for m in P_OPENER_ROW_RE.finditer(state_text or "")] return hits[0] if len(hits) == 1 else None def composer_index_in_state(state_text: str): """(index, name) of the single compose-shaped textbox, or None. Two candidates = ambiguous = model's problem.""" hits = [(int(m.group(1)), m.group(2)) for m in P_COMPOSER_ROW_RE.finditer(state_text or "") if P_COMPOSER_NAME_RE.search(m.group(2) or "")] return hits[0] if len(hits) == 1 else None # Surfaces that carry a real person-composer: the full-page messaging thread AND # the profile page (whose docked-overlay composer is reached by the Message opener). # History: the overlay was first BANNED here because its Send ranks out of the capped # interactives list, so the script aborted and the handoff ran 4x slower (live A/B # r246-r251). Ground truth (probe r-dump) then showed the Send IS a real button the # model clicks by name; the click-by-name fallback now completes it (validated live, # receipt-cleared in 1.4s on /in/), so the overlay is winnable and back in scope. # Empty/unknown URLs still decline: firing on a non-composer page is net-negative. P_COMPOSER_PAGE_RE = re.compile(r"linkedin\.com/(messaging/|in/)", re.I) def surface_supports_script(current_url: str) -> bool: """True on a LinkedIn messaging thread or profile (both carry a person-composer whose Send resolves via the ranked list or the click-by-name fallback). Empty or off-surface URLs decline, since firing where there's no composer is net-negative.""" return bool(current_url and P_COMPOSER_PAGE_RE.search(current_url)) async def run_send_script( task: str, browser_id: str, tab_id: str, state_text: str, execute_tool: ToolRunner, send_index_in_state, payload_in_textbox, payload_source: str = "", current_url: str = "", ) -> dict | None: """None = stage not script-ready or aborted pre-click (model path, stage untouched except a possibly committed fill, which the model sees). A dict means the irreversible click RAN: {'sent': bool_receipt_verified, 'payload': str, 'log': [...], 'note': str}. payload_source is the RAW user prompt; the composed task carries the routing brief whose own quoted strings made every real payload look ambiguous (r242/r243).""" t0 = time.monotonic() if not surface_supports_script(current_url): logger.info(f"[browser-sendscript] decline: surface {current_url[:60]!r} carries no person-composer") return None if P_READONLY_RE.search(task) or P_READONLY_RE.search(payload_source or ""): logger.info("[browser-sendscript] decline: read-only directive in task") return None payload = quoted_payload(payload_source or task) if not payload: logger.info("[browser-sendscript] decline: no unambiguous quoted payload") return None log: list[dict] = [] async def fresh_list() -> str: try: r = await asyncio.wait_for( execute_tool("BrowserListInteractives", {}, browser_id, tab_id), timeout=6.0) return str(r.get("text") or "") if isinstance(r, dict) and "error" not in r else "" except Exception: return "" composer = composer_index_in_state(state_text) if not composer: # The staged snapshot is prestage's, frozen the instant it clicked Message; the overlay composer lazy-renders a beat later (r263/r269 declined on exactly this, prestage's LAST step was the Message click). Poll a short window so the overlay has time to appear before we fall back to the opener. for wait_s in (0.6, 1.2, 1.4): await asyncio.sleep(wait_s) fresh = await fresh_list() composer = composer_index_in_state(fresh) if composer: state_text = fresh break if not composer: # Reversible-opener hop: prestage often stops on the profile with the "Message" opener visible (its settle raced the overlay). Opening a composer is the allowed opener class; the irreversible bar is unchanged. opener = opener_index_in_state(state_text) if not opener: logger.info("[browser-sendscript] decline: no composer and no single exact-named opener in staged or fresh state") return None logger.info(f"[browser-sendscript] firing via opener {opener[1]!r} [{opener[0]}]") r_open = await execute_tool("BrowserClickIndex", {"index": opener[0]}, browser_id, tab_id) if not (isinstance(r_open, dict) and "error" not in r_open): return None log.append({"tool": "BrowserClickIndex", "input": {"index": opener[0]}, "ok": True, "result_summary": f"script opened composer via {opener[1]!r}"[:200], "elapsed_ms": 0}) for wait_s in (0.6, 1.2): await asyncio.sleep(wait_s) state_text = await fresh_list() composer = composer_index_in_state(state_text) if composer: break if not composer: logger.info("[browser-sendscript] opener clicked but no composer appeared; handing to model") 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]}]") # 1. fill (focused by node, the composer overlay path coordinate clicks miss) r_fill = await execute_tool("BrowserClickIndex", {"index": composer[0], "text": payload}, browser_id, tab_id) fill_ok = isinstance(r_fill, dict) and "error" not in r_fill log.append({"tool": "BrowserClickIndex", "input": {"index": composer[0], "text": payload}, "ok": fill_ok, "result_summary": f"script fill into {composer[1]!r}"[:200], "elapsed_ms": 0}) if not fill_ok: logger.info("[browser-sendscript] fill errored; handing to model untouched") return None # 2. verify the fill committed. Send is resolved AFTER, two ways: LinkedIn enables Send only once its JS digests the input (beats later than the text is visible), so the scan waits a little. state2 = "" committed = False for wait_s in (0.4, 0.8, 1.2, 1.6): await asyncio.sleep(wait_s) state2 = await fresh_list() committed = bool(state2 and payload_in_textbox(state2, payload)) if committed: break if not committed: logger.info("[browser-sendscript] fill not seen committed; aborting pre-click") return None # 3. the one irreversible click, solo. Prefer the ranked list's exact Send (an index click, cheapest); fall back to click-by-name, which searches the FULL enumerated DOM. GROUND TRUTH (probe r-dump): the profile-overlay Send is a real button the model clicks by name, but it ranks OUT of the capped numbered list, so send_index_in_state alone never sees it. click-by-name is exactly how the model sends there. send_btn2 = send_index_in_state(state2) if send_btn2: r_send = await execute_tool("BrowserClickIndex", {"index": send_btn2[0]}, browser_id, tab_id) send_name = send_btn2[1] else: r_send = await execute_tool("BrowserClickByName", {"name": "Send", "role": "button"}, browser_id, tab_id) send_name = "Send (by-name)" send_ok = isinstance(r_send, dict) and "error" not in r_send log.append({"tool": "send click", "input": {"via": "index" if send_btn2 else "by-name"}, "ok": send_ok, "result_summary": f"script send click {send_name!r}"[:200], "elapsed_ms": 0, "clicked_role": "button", "clicked_name": send_name}) if not send_ok: logger.info(f"[browser-sendscript] send click errored ({send_name}); handing to model (fill committed, NOT sent)") return None # 4. two-sided receipt: composer seen cleared of the payload cleared = False for wait_s in (0.4, 1.0, 1.6): await asyncio.sleep(wait_s) state3 = await fresh_list() if state3 and not payload_in_textbox(state3, payload): cleared = True break note = ("" if cleared else "A Send-class click already RAN for this payload but the composer state is unverified: " "verify on the page whether it delivered; do NOT send again unless verifiably absent.") logger.info(f"[browser-sendscript] done sent_receipt={cleared} in {int((time.monotonic() - t0) * 1000)}ms") return {"sent": cleared, "payload": payload, "log": log, "note": note}