Files
openswarm/backend/apps/agents/browser/browser_send_script.py
T

211 lines
12 KiB
Python

"""
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) and os.environ.get("OSW_SENDSCRIPT_PROBE") != "1":
logger.info(f"[browser-sendscript] decline: surface {current_url[:60]!r} not a full-page composer (overlay firing is net-negative)")
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, taken the instant it clicked Message; the overlay composer lazy-renders a beat later, so it's on the page but not in that frozen list (r263 declined on exactly this). One fresh read catches it before we fall back to the opener.
fresh = await fresh_list()
composer = composer_index_in_state(fresh)
if composer:
state_text = fresh
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:
if os.environ.get("OSW_SENDSCRIPT_PROBE") == "1":
logger.info(f"[browser-sendscript-probe] committed-fill interactives (Send not in ranked list):\n{state2[:3000]}")
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}