mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
474 lines
30 KiB
Python
474 lines
30 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 time
|
|
from typing import Awaitable, Callable, Dict
|
|
|
|
from backend.apps.agents.browser import (
|
|
browser_delivery_check, browser_fast_path, browser_send_parse, browser_submit_click,
|
|
browser_verified_action)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
ToolRunner = Callable[[str, dict, str, str], Awaitable[dict]]
|
|
|
|
# The worst case this routine can legitimately take, so the CALLER cannot starve it. Roughly: the
|
|
# composer poll (3 backoff waits plus three 6s interactive lists), one structural finder call (which
|
|
# carries its own 30s command timeout), then fill-and-commit and submit-and-receipt.
|
|
#
|
|
# This constant exists because the caller's timeout and this routine's real cost silently drifted
|
|
# apart: raising find_composer's command timeout from 15s to 30s made a single finder call able to
|
|
# eat the caller's entire 30s budget, so the whole script was killed mid-send and EVERY write fell
|
|
# back to the slow model loop. It failed invisibly, because asyncio.TimeoutError stringifies to
|
|
# nothing and the log read "outer skip ()". Measured live on LinkedIn: a 190.9s write that never
|
|
# posted. Import this instead of writing a number at the call site.
|
|
WORST_CASE_BUDGET_S = 75.0
|
|
|
|
|
|
def script_enabled() -> bool:
|
|
"""Default ON. Every gate below it fails CLOSED to the old model loop, so the worst case is
|
|
today's behaviour, never a wrong send: the payload must be unambiguously quoted, the task must
|
|
not read as a question, the surface must not be a login wall, a post task will not settle for a
|
|
comment box, the fill must be seen committed, and delivery needs a two-sided receipt.
|
|
Turning this off also disables the prestage opener and the mid-loop autosend takeover."""
|
|
return os.environ.get("OSW_SEND_SCRIPT", "1") != "0"
|
|
|
|
|
|
def autosend_enabled() -> bool:
|
|
"""The mid-loop post-fill takeover: after the MODEL types the message into a composer, the code
|
|
finishes the send (find Send, click, verify receipt) instead of the model burning ~3-4 turns on
|
|
a Send button whose index goes stale after the fill. Rides with the send-script family (same
|
|
tail + safety), with its own kill switch."""
|
|
return script_enabled() and os.environ.get("OSW_AUTOSEND", "1") != "0"
|
|
|
|
|
|
async def complete_send(
|
|
payload: str, state_committed: str, browser_id: str, tab_id: str,
|
|
execute_tool: ToolRunner, send_index_in_state: Callable[[str, int], object],
|
|
composer_index: int = -1, current_url: str = "",
|
|
) -> Dict[str, object]:
|
|
"""Send tail for a composer that ALREADY holds `payload` (visible in state_committed): find the
|
|
Send control (ranked index first, else click-by-name over the full DOM), click it once, and
|
|
verify the two-sided receipt (the composer cleared the payload). Returns {clicked, sent, log,
|
|
note}: `clicked` = the send click landed, `sent` = the clear was verified. Never types, so it
|
|
can't fabricate content; a wrong Send match just fails the receipt, never a false claim. Shared
|
|
by the dispatch send-script and the mid-loop post-fill takeover."""
|
|
log: list = []
|
|
|
|
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 ""
|
|
|
|
send_btn = send_index_in_state(state_committed, composer_index)
|
|
via = "index"
|
|
if send_btn:
|
|
r_send = await execute_tool("BrowserClickIndex", {"index": send_btn[0]}, browser_id, tab_id)
|
|
send_name = send_btn[1]
|
|
else:
|
|
# No submit listed below the composer (the capped listing can starve a modal of its own
|
|
# button): resolve the submit inside the composer's OWN container and click it with REAL
|
|
# input (synthetic clicks are ignored by web-component sites), then last-resort by-name.
|
|
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)
|
|
if isinstance(p_v, dict) and p_v.get("ok") and p_v.get("xPct") is not None:
|
|
r_send = await execute_tool(
|
|
"BrowserClickPoint",
|
|
{"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")
|
|
r_send = await execute_tool("BrowserClickByName", {"name": "Send", "role": "button"}, browser_id, tab_id)
|
|
send_name = "Send (by-name)"
|
|
via = "by-name"
|
|
clicked = isinstance(r_send, dict) and "error" not in r_send
|
|
log.append({"tool": "send click", "input": {"via": via},
|
|
"ok": clicked, "result_summary": f"send click {send_name!r}"[:200],
|
|
"elapsed_ms": 0, "clicked_role": "button", "clicked_name": send_name})
|
|
if not clicked:
|
|
return {"clicked": False, "sent": False, "log": log, "note": "send click errored; fill committed, NOT sent"}
|
|
sent = False
|
|
# Name WHY a receipt fails. A withheld receipt costs the whole fast path (measured on LinkedIn:
|
|
# the script finished in 9.7s, the receipt missed, and the model then spent 28.6s re-verifying a
|
|
# post that HAD landed, 60s total against ~24s when the receipt passes), and "sent_receipt=False"
|
|
# alone cannot tell you whether the composer still holds the text or we simply could not read the
|
|
# page. Those are different bugs with different fixes.
|
|
p_why = "no-poll"
|
|
for wait_s in (0.4, 1.0, 1.6):
|
|
await asyncio.sleep(wait_s)
|
|
state3 = await fresh_list()
|
|
if not state3:
|
|
p_why = "unreadable-list"
|
|
continue
|
|
if browser_verified_action.expectation_met(f"cleared:{payload}", state_committed, state3):
|
|
sent = True
|
|
break
|
|
p_why = f"payload-still-in-a-textbox (textbox rows={sum(1 for x in state3.splitlines() if '<textbox' in x)})"
|
|
if not sent:
|
|
logger.info(f"[browser-sendscript] receipt withheld after {sum((0.4, 1.0, 1.6)):.1f}s of polling: {p_why}")
|
|
# A cleared composer is proof of delivery everywhere EXCEPT the ghost-drop hosts, which clear
|
|
# then silently eat the post; there we verify it persisted. delivered stays None (unchecked,
|
|
# composer-clear trusted) for every other site, so proven sends keep their exact speed.
|
|
delivered = None
|
|
rejected = False
|
|
# The site gets the first word. A cleared composer plus "Something went wrong" is a REFUSAL, and
|
|
# trusting the clear there is how the agent ends up announcing a post that never existed.
|
|
if sent and await browser_delivery_check.send_rejected(browser_id, tab_id, execute_tool):
|
|
rejected, delivered, sent = True, False, False
|
|
logger.info("[browser-sendscript] composer cleared but the page announced a failure; "
|
|
"treating as REJECTED, not delivered")
|
|
elif sent and browser_delivery_check.is_ghost_drop_host(current_url):
|
|
delivered = await browser_delivery_check.ghost_delivery_confirmed(
|
|
payload, browser_id, tab_id, execute_tool)
|
|
elif sent and via == "by-name":
|
|
# The by-name click is the ONE path where we never actually located the submit: both
|
|
# structured resolvers failed, so the literal "Send" is a guess, and it can land on some
|
|
# OTHER widget's Send while this composer closes anyway. Measured live on LinkedIn's feed
|
|
# composer (whose submit is "Post", not "Send"): sent_receipt=True and nothing posted, on
|
|
# either the posts or the comments tab. A cleared composer cannot tell submitted from
|
|
# dismissed, so a guessed click does not get to be proof by itself; it has to show the
|
|
# payload actually rendered on the page. The two resolved paths are untouched and keep
|
|
# their measured speed.
|
|
delivered = await browser_delivery_check.payload_visible(
|
|
payload, browser_id, tab_id, execute_tool)
|
|
if delivered is False:
|
|
logger.info("[browser-sendscript] by-name click cleared the composer but the payload "
|
|
"never rendered; treating as NOT delivered")
|
|
elif delivered is None:
|
|
# We could not look. That is not the same as looking and finding nothing, and saying
|
|
# "it did not render" here would be inventing a failure out of a broken probe.
|
|
logger.info("[browser-sendscript] by-name click cleared the composer but the delivery "
|
|
"probe was unreadable; leaving delivery UNKNOWN")
|
|
if rejected:
|
|
# We are not guessing here: the page said no. Saying "unverified" would send the user off to
|
|
# check something we already know the answer to.
|
|
note = browser_delivery_check.rejected_send_note(current_url, payload)
|
|
elif sent:
|
|
note = ""
|
|
else:
|
|
note = ("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.")
|
|
return {"clicked": True, "sent": sent, "delivered": delivered, "log": log, "note": note}
|
|
|
|
|
|
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()
|
|
# 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:
|
|
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 ""
|
|
|
|
# The name-based surface gate can't see an unnamed/non-standard composer; under the
|
|
# structural flag, don't early-decline on it, the in-page finder gets a chance below.
|
|
if not browser_send_parse.surface_supports_script(current_url, state_text) and not p_struct:
|
|
# The composer lazy-renders a beat after prestage snapshotted (X home does this ~half the
|
|
# time), so poll a fresh perception before declining, else a late box is a false "no
|
|
# composer" and the whole write flakes to the slow model path.
|
|
for wait_s in (0.6, 1.0, 1.4):
|
|
await asyncio.sleep(wait_s)
|
|
fresh = await fresh_list()
|
|
if browser_send_parse.surface_supports_script(current_url, fresh):
|
|
state_text = fresh
|
|
break
|
|
else:
|
|
logger.info(f"[browser-sendscript] decline: no composer or opener after poll ({current_url[:50]!r})")
|
|
return None
|
|
# Key read-only on words a HUMAN wrote: the task minus the aux routing brief (the brief wrote
|
|
# "do not submit it" for a plain "start a post", falsely read-only-flagging a real send) PLUS
|
|
# the raw prompt when threaded through. The task text itself must keep declining regardless: a
|
|
# read-only VERIFY probe arrives as the task, and one once delivered a real message (r243).
|
|
task_sans_brief = task.split(browser_fast_path.BRIEF_MARKER, 1)[0]
|
|
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
|
|
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:
|
|
logger.info("[browser-sendscript] decline: no unambiguous quoted payload")
|
|
return None
|
|
log: list[dict] = []
|
|
|
|
composer = browser_send_parse.composer_index_in_state(state_text)
|
|
if composer and browser_send_parse.surface_mismatch(task_sans_brief, composer[1]):
|
|
# Asked to POST, found a COMMENT box: that is someone else's content, not a slower route to
|
|
# ours. Drop it and let the tiers below (opener, then the structural finder, which does find
|
|
# LinkedIn's real composer) look properly.
|
|
logger.info(f"[browser-sendscript] ignoring {composer[1]!r}: a comment box is not where a post goes")
|
|
composer = None
|
|
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 = browser_send_parse.composer_index_in_state(fresh)
|
|
if composer:
|
|
state_text = fresh
|
|
break
|
|
p_struct_selector: str = ""
|
|
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 = browser_send_parse.opener_index_in_state(state_text)
|
|
if opener and browser_send_parse.surface_mismatch(task_sans_brief, opener[1]):
|
|
# The same post-is-not-a-comment rule the composer already enforces, applied one step
|
|
# earlier. Measured on linkedin.com with the task "start a post": the only opener listed
|
|
# was 'Comment', so the script opened a stranger's comment box, found no post composer
|
|
# inside it, and declined. Opening the wrong surface is not a slower route to the right
|
|
# one, and here it also burns the reversible-opener hop we only get once.
|
|
logger.info(f"[browser-sendscript] ignoring opener {opener[1]!r}: a comment box is not "
|
|
f"where a post goes")
|
|
opener = None
|
|
if opener:
|
|
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})
|
|
# Wait for the surface to STOP MOVING, not for a number of seconds. Fixed budgets kept
|
|
# being wrong in both directions: 1.8s missed gmail and linkedin entirely, 5.3s still
|
|
# missed a cold gmail compose window that existed a beat later, and simply making the
|
|
# number bigger taxes every run that was never going to succeed. A mounting surface
|
|
# keeps changing the element list; once two consecutive reads are identical, nothing
|
|
# more is coming and more waiting is pure cost.
|
|
p_prev = ""
|
|
p_settled = 0
|
|
for wait_s in (0.6, 1.2, 1.5, 2.0, 2.0, 2.0):
|
|
await asyncio.sleep(wait_s)
|
|
state_text = await fresh_list()
|
|
composer = browser_send_parse.composer_index_in_state(state_text)
|
|
if composer:
|
|
break
|
|
p_settled = p_settled + 1 if state_text and state_text == p_prev else 0
|
|
p_prev = state_text
|
|
if p_settled >= 1:
|
|
logger.info("[browser-sendscript] opener surface settled with no composer; "
|
|
"not waiting out the rest of the budget")
|
|
break
|
|
# Structural fallback: the AX-name detector missed it (an unnamed contenteditable, a
|
|
# non-standard rich editor, or two textboxes it couldn't disambiguate). Ask the page to
|
|
# rank its editable regions and fill+read-back the winner IN-PAGE (the only reliable
|
|
# commit-check for a React contenteditable, whose text never reaches the AX value).
|
|
# Flag-gated so the proven name path stays the default.
|
|
if not composer and p_struct:
|
|
# 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.
|
|
# 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
|
|
# reports it fired `open-first` but found nothing, the page is now loading the item;
|
|
# give it a beat and run the finder ONCE more on the destination. Bounded to 2 tries so
|
|
# a feed-of-feeds can't walk forever.
|
|
fc: Dict[str, object] = {}
|
|
for attempt in range(2):
|
|
fc = await execute_tool("BrowserFindComposer", {"fill": payload, "reveal": p_reveal}, browser_id, tab_id)
|
|
if isinstance(fc, dict) and fc.get("found") and fc.get("filled"):
|
|
break
|
|
revs = fc.get("reveals") if isinstance(fc, dict) else None
|
|
navigated = p_reveal and isinstance(revs, list) and "open-first" in revs
|
|
if not navigated:
|
|
break
|
|
logger.info("[browser-sendscript] reveal navigated (open-first); re-perceiving the destination")
|
|
await asyncio.sleep(1.5)
|
|
dest = await fresh_list()
|
|
# open-first can land on a login redirect (a logged-out feed's first item);
|
|
# stop before the NEXT fill so we never type into the auth form we just opened.
|
|
if browser_send_parse.looks_like_login_wall("", dest):
|
|
logger.info("[browser-sendscript] decline: reveal landed on a login/auth wall")
|
|
fc = {}
|
|
break
|
|
if isinstance(fc, dict) and fc.get("found") and fc.get("filled"):
|
|
p_struct_selector = str(fc.get("selector") or "")
|
|
logger.info(f"[browser-sendscript] structural composer role={fc.get('role')!r} "
|
|
f"score={fc.get('score')} nearSubmit={fc.get('nearSubmit')} "
|
|
f"reveals={fc.get('reveals')} fillMode={fc.get('fillMode')} filled+verified")
|
|
log.append({"tool": "BrowserFindComposer", "input": {"fill": "<payload>"}, "ok": True,
|
|
"result_summary": f"structural composer {fc.get('role')!r} filled+verified"[:200], "elapsed_ms": 0})
|
|
composer = (-1, str(fc.get("role") or "composer"))
|
|
else:
|
|
logger.info(f"[browser-sendscript] structural finder: no usable composer ({str(fc)[:120]})")
|
|
if not composer:
|
|
# 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]}]")
|
|
|
|
if p_struct_selector:
|
|
# The finder already filled + read-back-verified in-page; nothing to re-fill or re-check.
|
|
state2 = await fresh_list()
|
|
committed = True
|
|
else:
|
|
# 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 and browser_submit_click.is_stale_index_error(r_fill):
|
|
# The opener click opens a modal that keeps re-rendering after we listed it, so the
|
|
# composer node we resolved is already detached by the time the fill lands. Measured
|
|
# live on x.com: 'Index 53 is not in the cached element map', on the exact run where
|
|
# the script had correctly found opener 'Post' and target 'Post text'. Re-listing is
|
|
# what the error itself prescribes, so take it once rather than surrendering a send
|
|
# the script had already located. One retry only: a second failure is a different
|
|
# problem and the model path is the right answer for it.
|
|
# Poll, don't snapshot. A single re-list catches the composer only if the modal happens
|
|
# to be settled at that instant; mid-churn it shows zero or two compose-shaped boxes,
|
|
# composer_index_in_state returns None (ambiguous), and the retry used to give up
|
|
# without a word. Measured: 5 successful retries in one arm, 0 in the next, same code,
|
|
# purely on timing. Same poll shape the opener path already uses.
|
|
composer_retry = None
|
|
for wait_s in (0.0, 0.5, 1.0):
|
|
if wait_s:
|
|
await asyncio.sleep(wait_s)
|
|
state_retry = await fresh_list()
|
|
composer_retry = browser_send_parse.composer_index_in_state(state_retry)
|
|
if composer_retry:
|
|
break
|
|
if not composer_retry:
|
|
logger.info("[browser-sendscript] composer index went stale and did not re-resolve "
|
|
"within 1.5s of polling; handing to model")
|
|
if composer_retry:
|
|
logger.info(f"[browser-sendscript] stale composer index {composer[0]}; refreshed to "
|
|
f"{composer_retry[0]} and retrying the fill once")
|
|
composer = composer_retry
|
|
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 retry into {composer[1]!r}"[:200],
|
|
"elapsed_ms": 0})
|
|
if not fill_ok:
|
|
# Name the cause. "fill errored" alone cannot tell a stale index from a detached node
|
|
# from a site that refuses synthetic input, and those are three different fixes. Same
|
|
# lesson as the bare TimeoutError that used to log "outer skip ()".
|
|
p_err = r_fill.get("error") if isinstance(r_fill, dict) else type(r_fill).__name__
|
|
logger.info(f"[browser-sendscript] fill errored ({str(p_err)[:160]}); "
|
|
f"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
|
|
# Dry-run probe: prove the script FIRES + fills on a NON-LinkedIn site without ever
|
|
# doing the outward send. Everything up to here ran (surface gate passed, composer
|
|
# 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]))
|
|
# 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.
|
|
r = await complete_send(payload, state2, browser_id, tab_id, execute_tool, send_index_in_state,
|
|
composer_index=composer[0], current_url=current_url)
|
|
log.extend(r["log"])
|
|
if not r["clicked"]:
|
|
logger.info("[browser-sendscript] send click errored; handing to model (fill committed, NOT sent)")
|
|
return None
|
|
logger.info(f"[browser-sendscript] done sent_receipt={r['sent']} delivered={r.get('delivered')} in {int((time.monotonic() - t0) * 1000)}ms")
|
|
return {"sent": bool(r["sent"]), "delivered": r.get("delivered"),
|
|
"payload": payload, "log": log, "note": str(r["note"])}
|