[eric] browser: a guessed submit click must prove the payload rendered, and questions never post

This commit is contained in:
ciregenz
2026-07-28 15:27:36 -07:00
parent 95b0e00d51
commit 9a1106bcb5
5 changed files with 132 additions and 8 deletions
@@ -42,7 +42,7 @@ def delivery_probe_expression(payload: str) -> str:
@typechecked
async def p_payload_visible(payload: str, browser_id: str, tab_id: str, execute_tool: ToolRunner) -> bool:
async def payload_visible(payload: str, browser_id: str, tab_id: str, execute_tool: ToolRunner) -> bool:
try:
r = await asyncio.wait_for(execute_tool(
"BrowserEvaluate", {"expression": delivery_probe_expression(payload)},
@@ -61,10 +61,10 @@ async def ghost_delivery_confirmed(
only if the payload is visible now and STILL visible a few seconds later. A post that never
rendered, or rendered then vanished, returns False, so we never claim a delivery the site ate.
Pure page reads (no navigation), invisible to the site."""
if not await p_payload_visible(payload, browser_id, tab_id, execute_tool):
if not await payload_visible(payload, browser_id, tab_id, execute_tool):
return False
await asyncio.sleep(3.5)
return await p_payload_visible(payload, browser_id, tab_id, execute_tool)
return await payload_visible(payload, browser_id, tab_id, execute_tool)
@typechecked
@@ -233,6 +233,11 @@ async def run_prestage(
staged_complete = False
async def settle(pre_url: str, pre_text: str, pre_li: str) -> bool:
"""Wait for the page to actually change after an action, capped.
Timed by the caller's log line: this polls with a full perceive each round, so it is a
real share of prestage's cost, and separating it from the aux plan is what says whether
the fix is a cheaper decision or a faster wait."""
# A click returns before the page swaps; perceiving too early reads the OLD page and the aux re-issues the same click (observed 4x loop). Wait for the page to actually change, capped. False = the action verifiably did NOT take. An overlay (message composer) changes the INTERACTIVES but not the URL and often not the first 400 chars of text, so the element list counts as change too.
t_s = time.monotonic()
while time.monotonic() - t_s < 3.0:
@@ -247,8 +252,15 @@ async def run_prestage(
p_system = P_SYSTEM_OPENER if opener_mode() else P_SYSTEM
p_results_overruled = False
while steps < p_max_steps and (time.monotonic() - t0) < p_total_timeout:
# Per-step cost, broken out. Prestage is the largest single phase of a LinkedIn write
# (measured 18.6s of a 50.6s run, more than the send itself), and "steps=2 in 18587ms"
# cannot tell you whether that is the aux deciding, the page settling, or the click.
# Those have completely different fixes, so the log has to separate them.
p_t_step = time.monotonic()
li_text, gt_text, seen_url = await perceive()
p_t_perceive = time.monotonic() - p_t_step
current_url = seen_url or current_url
p_t_aux = time.monotonic()
reply = safe_resp_text(await asyncio.wait_for(
client.messages.create(
model=aux_model, max_tokens=60, temperature=0, system=p_system,
@@ -260,6 +272,9 @@ async def run_prestage(
),
timeout=STEP_TIMEOUT_S,
)).strip()
p_aux_ms = int((time.monotonic() - p_t_aux) * 1000)
logger.info(f"[browser-prestage] step {steps + 1} plan: perceive={int(p_t_perceive * 1000)}ms "
f"aux={p_aux_ms}ms reply={reply[:40]!r}")
verb, arg = parse_step(reply)
if verb == "ready" or not arg:
# A results LIST is never the staged page for a task about one specific person/thing; the aux accepts it about half the time (measured, 2/4 cold LinkedIn runs) and every downstream tier then declines. Overrule ONCE with a nudge re-ask; a second READY is accepted, some tasks really do target the list.
@@ -289,7 +304,10 @@ async def run_prestage(
logger.info(f"[browser-prestage] step {steps + 1}: nav {arg} ok={ok}")
if not ok:
break
if not await settle(current_url, gt_text, li_text):
p_t_settle = time.monotonic()
p_settled = await settle(current_url, gt_text, li_text)
logger.info(f"[browser-prestage] step {steps + 1} nav settle={int((time.monotonic() - p_t_settle) * 1000)}ms ok={p_settled}")
if not p_settled:
logger.info(f"[browser-prestage] nav {arg} did not settle; stopping unstaged")
break
done_desc.append(f"navigated to {arg}")
@@ -309,7 +327,10 @@ async def run_prestage(
logger.info(f"[browser-prestage] step {steps + 1}: click [{idx}] {entry[:60]!r} ok={ok}")
if not ok:
break
if not await settle(current_url, gt_text, li_text):
p_t_settle = time.monotonic()
p_settled = await settle(current_url, gt_text, li_text)
logger.info(f"[browser-prestage] step {steps + 1} click settle={int((time.monotonic() - p_t_settle) * 1000)}ms ok={p_settled}")
if not p_settled:
# The click ran but the page never changed (occluded element, overlay, stale index). Recording it would make the handoff note LIE ("navigation done") and send the main loop on a walkabout; observed live as 27-turn/112s regressions.
logger.info(f"[browser-prestage] click [{idx}] did not settle; stopping unstaged")
break
@@ -45,8 +45,16 @@ P_OPENER_ROW_RE = re.compile(
# 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",
r"read.?only|do\s+not\s+(?:send|type|click|post|submit|change|edit|delete)|"
r"don'?t\s+(?:send|post|submit|change|edit|delete)|"
# "verify/check/tell me/say/confirm WHETHER x is there" is the whole family, not two phrasings
# of it. Measured: "say whether anything containing <quoted text> is still there. Change
# nothing." slipped through and POSTED the quoted text to a real LinkedIn feed, because only
# "verify whether" and "check whether" were listed. Anchor on the question shape.
r"(?:verify|check|confirm|tell\s+me|say|see|find\s+out|look)\s+(?:me\s+)?(?:if|whether)|"
r"is\s+(?:it|there|this|that)\s+(?:still\s+)?(?:there|published|posted|live|present)|"
r"still\s+(?:there|published|posted|live|up)|"
r"change\s+nothing|without\s+(?:sending|posting|changing)|verification",
re.I,
)
@@ -105,12 +105,24 @@ async def complete_send(
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 state3 and browser_verified_action.expectation_met(f"cleared:{payload}", state_committed, state3):
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.
@@ -118,6 +130,20 @@ async def complete_send(
if 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 not delivered:
logger.info("[browser-sendscript] by-name click cleared the composer but the payload "
"never rendered; treating as NOT delivered")
note = ("" if sent 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.")
@@ -0,0 +1,69 @@
"""A question about a post must never become another post.
A verification prompt quotes the very text it is asking about, so it looks identical to a send task:
quoted payload plus a composer equals fire. Measured live on a real LinkedIn account 2026-07-28:
"say whether anything containing "<text>" is still there. Change nothing."
posted <text> to the feed. Only "verify whether" and "check whether" were in the guard, so the
question shape was matched by two exact phrasings rather than by what it actually is.
The guard fails SAFE in the other direction: a false match only means the send goes through the
model path instead of the script, which costs turns, never a wrong action.
"""
from backend.apps.agents.browser import browser_send_parse as sp
# Every one of these is somebody asking a QUESTION about content, with the content quoted.
QUESTIONS = [
'say whether anything containing "hello there" is still there. Change nothing.',
'check whether "hello there" is still on my profile',
'verify whether the post "hello there" went through',
'tell me if "hello there" is still published',
'confirm whether "hello there" posted',
'is "hello there" still there?',
'is the post "hello there" still live',
'find out if "hello there" is still up',
'look for "hello there" without posting anything',
'this is read-only, do not post: is "hello there" there',
'do not change anything, just say if "hello there" is present',
]
# Real send intents that must STILL fire the script; over-widening the guard would quietly disable
# the whole fast write path, which is the failure mode on the other side.
SENDS = [
'post this, exactly: "hello there"',
'tweet "hello there"',
'message Tyler and say "hello there"',
'reply to that thread with "hello there"',
'comment "hello there" on the first post',
'send "hello there" to my brother on whatsapp',
'put "hello there" in a new linkedin post',
]
def test_every_question_shape_declines():
missed = [q for q in QUESTIONS if not sp.is_readonly(q)]
assert not missed, f"these questions would be treated as sends: {missed}"
def test_the_exact_prompt_that_posted_for_real_is_caught():
"""The literal string that put a test post on a real LinkedIn feed."""
assert sp.is_readonly(
'Go to linkedin.com and say whether anything containing "x" is still there. Change nothing.')
def test_real_sends_still_fire():
blocked = [s for s in SENDS if sp.is_readonly(s)]
assert not blocked, f"the guard swallowed real send intents: {blocked}"
def test_the_guard_is_case_and_spacing_insensitive():
assert sp.is_readonly('IS "hello" STILL THERE?')
assert sp.is_readonly("Tell me if it posted")
def test_empty_and_junk_are_not_readonly():
"""An empty task is not a question; treating it as read-only would silently disable the script
on a malformed input rather than letting the normal gates decide."""
assert not sp.is_readonly("")
assert not sp.is_readonly(" ")