mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-08 10:47:44 +02:00
[eric] browser: a cleared composer plus a failure toast is a refusal, not a delivery
This commit is contained in:
@@ -10,6 +10,7 @@ Gmail) and this module is never consulted, so proven sends keep their exact spee
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from typing import Awaitable, Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -22,6 +23,15 @@ ToolRunner = Callable[[str, dict, str, str], Awaitable[dict]]
|
||||
# Hosts known to accept-then-silently-drop an automated post. A newly-found one is a one-line add.
|
||||
GHOST_DROP_HOSTS = ("youtube.com",)
|
||||
|
||||
# What a site says when it REFUSED the write. Only ever consulted inside a live announcement
|
||||
# region, never against the whole page, so an unrelated "failed" in an article body can't match.
|
||||
P_REJECTION_RE = re.compile(
|
||||
r"something went wrong|went wrong|couldn'?t\s|could not\s|unable to|failed to|"
|
||||
r"\bfailed\b|try again|too many|rate.?limit|limit exceeded|not allowed|"
|
||||
r"blocked|error occurred|wasn'?t (?:sent|posted)|was not (?:sent|posted)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
@typechecked
|
||||
def is_ghost_drop_host(url: str) -> bool:
|
||||
@@ -53,6 +63,60 @@ async def payload_visible(payload: str, browser_id: str, tab_id: str, execute_to
|
||||
return bool(isinstance(v, dict) and v.get("visible"))
|
||||
|
||||
|
||||
@typechecked
|
||||
def rejection_probe_expression() -> str:
|
||||
"""JS returning the text of the page's live ANNOUNCEMENT regions only.
|
||||
|
||||
role="alert" and aria-live are how sites are required to announce a transient result to
|
||||
assistive tech, so error toasts land here on every major site without us naming any of them.
|
||||
Scoped deliberately: reading whole-page text for the word "failed" would match articles,
|
||||
changelogs and half the internet."""
|
||||
return ("(()=>{try{var out=[];"
|
||||
"var sel='[role=alert],[role=alertdialog],[aria-live=assertive],[aria-live=polite]';"
|
||||
"document.querySelectorAll(sel).forEach(function(e){"
|
||||
"var s=(e.innerText||'').trim(); if(s) out.push(s);});"
|
||||
"return {text: out.join(' | ').slice(0,600)};}"
|
||||
"catch(e){return {text:''};}})()")
|
||||
|
||||
|
||||
@typechecked
|
||||
async def send_rejected(browser_id: str, tab_id: str, execute_tool: ToolRunner) -> bool:
|
||||
"""Did the site announce that the write FAILED, right after the composer cleared?
|
||||
|
||||
A cleared composer is the receipt this whole fast path rests on, and the code has long admitted
|
||||
it "cannot tell submitted from dismissed". The realistic way that bites is not a mis-click: it
|
||||
is the site accepting the click, clearing the box, and popping "Something went wrong" or a rate
|
||||
limit. The receipt then reads as success and the agent tells the user it posted.
|
||||
|
||||
This only ever DEMOTES a claim, and only on an explicit failure announcement, so a normal send
|
||||
(no alert region, or a success toast) is untouched and keeps its measured speed. Any read
|
||||
failure returns False, because refusing to claim delivery on the basis of a broken probe would
|
||||
invent failures that did not happen."""
|
||||
try:
|
||||
r = await asyncio.wait_for(execute_tool(
|
||||
"BrowserEvaluate", {"expression": rejection_probe_expression()},
|
||||
browser_id, tab_id), timeout=4.0)
|
||||
except Exception:
|
||||
return False
|
||||
v = browser_submit_click.parse_eval_value(r)
|
||||
if not isinstance(v, dict):
|
||||
return False
|
||||
return bool(P_REJECTION_RE.search(str(v.get("text") or "")))
|
||||
|
||||
|
||||
@typechecked
|
||||
def rejected_send_note(url: str, payload: str) -> str:
|
||||
"""Honest line for a send the SITE said no to. Distinct from the unverified case: here we are
|
||||
not guessing, the page told us, so the user should be told plainly rather than asked to check."""
|
||||
host = urlparse(url or "").hostname or "the site"
|
||||
if host.startswith("www."):
|
||||
host = host[4:]
|
||||
clip = payload if len(payload) <= 80 else payload[:77] + "..."
|
||||
return (f'I typed "{clip}" and clicked send, but {host} rejected it: the composer cleared and '
|
||||
f'the page showed an error instead of posting. It did NOT go through. I did not retry, '
|
||||
f'since whatever the site refused is likely to be refused again.')
|
||||
|
||||
|
||||
@typechecked
|
||||
async def ghost_delivery_confirmed(
|
||||
payload: str, browser_id: str, tab_id: str, execute_tool: ToolRunner
|
||||
|
||||
@@ -127,7 +127,14 @@ async def complete_send(
|
||||
# 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
|
||||
if sent and browser_delivery_check.is_ghost_drop_host(current_url):
|
||||
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":
|
||||
@@ -144,9 +151,16 @@ async def complete_send(
|
||||
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.")
|
||||
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}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""A cleared composer is not proof when the site just said no.
|
||||
|
||||
The whole fast write path rests on one signal: the payload left the composer. browser_send_script
|
||||
has always carried the admission that this "cannot tell submitted from dismissed", guarded only for
|
||||
guessed clicks and ghost-drop hosts. The realistic way it bites is neither: the site accepts the
|
||||
click, clears the box, and pops "Something went wrong" or a rate limit. The receipt then reads as a
|
||||
clean success and the agent tells the user it posted.
|
||||
|
||||
The guard reads only the page's live announcement regions (role=alert, aria-live), which is the
|
||||
accessibility contract sites already follow, so it needs no per-site knowledge. It can only ever
|
||||
DEMOTE a claim, never manufacture one, and it fails open: a broken probe returns "not rejected"
|
||||
rather than inventing a failure that did not happen.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from backend.apps.agents.browser import browser_delivery_check as dc
|
||||
|
||||
REJECTIONS = [
|
||||
"Something went wrong. Try again.",
|
||||
"Couldn't post your reply",
|
||||
"Could not send message",
|
||||
"Unable to post right now",
|
||||
"Failed to send",
|
||||
"You've reached your daily limit. Try again later.",
|
||||
"Too many requests, please slow down",
|
||||
"Rate limit exceeded",
|
||||
"Your post wasn't sent",
|
||||
"An error occurred",
|
||||
]
|
||||
|
||||
# A live region is also how sites announce SUCCESS and ordinary chatter. Matching these would turn
|
||||
# every good send into a scary "it was rejected", which is a worse lie than the one being fixed.
|
||||
NOT_REJECTIONS = [
|
||||
"Your post was sent.",
|
||||
"Posted",
|
||||
"Message sent",
|
||||
"Draft saved",
|
||||
"1 new notification",
|
||||
"Copied to clipboard",
|
||||
"",
|
||||
" ",
|
||||
]
|
||||
|
||||
|
||||
def p_probe(text):
|
||||
"""A fake BrowserEvaluate returning whatever the announcement regions supposedly held.
|
||||
|
||||
Shape matters: parse_eval_value reads {"value": ...} at the TOP level. An earlier version of
|
||||
this fixture nested it one level deeper, which made every negative case pass for the wrong
|
||||
reason (unreadable result, not correct matching) while every positive case failed loudly."""
|
||||
async def run(tool, args, browser_id, tab_id):
|
||||
assert tool == "BrowserEvaluate"
|
||||
return {"value": {"text": text}}
|
||||
return run
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("text", REJECTIONS)
|
||||
async def test_a_refusal_is_detected(text):
|
||||
assert await dc.send_rejected("b", "t", p_probe(text)) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("text", NOT_REJECTIONS)
|
||||
async def test_success_and_chatter_are_left_alone(text):
|
||||
assert await dc.send_rejected("b", "t", p_probe(text)) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_broken_probe_does_not_invent_a_failure():
|
||||
"""Fail OPEN. Claiming rejection because we could not read the page would be the same class of
|
||||
lie in the other direction."""
|
||||
async def boom(tool, args, browser_id, tab_id):
|
||||
raise RuntimeError("evaluate died")
|
||||
assert await dc.send_rejected("b", "t", boom) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_hung_probe_does_not_block_the_send_path():
|
||||
"""The probe sits on the irreversible path; it must time out rather than wedge the turn."""
|
||||
import asyncio
|
||||
|
||||
async def hang(tool, args, browser_id, tab_id):
|
||||
await asyncio.sleep(30)
|
||||
return {}
|
||||
assert await dc.send_rejected("b", "t", hang) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_junk_shaped_result_is_not_a_rejection():
|
||||
async def junk(tool, args, browser_id, tab_id):
|
||||
return {"value": "not a dict"}
|
||||
assert await dc.send_rejected("b", "t", junk) is False
|
||||
|
||||
|
||||
# --- the probe itself ------------------------------------------------------------------------
|
||||
|
||||
def test_the_probe_reads_only_announcement_regions():
|
||||
"""Scope is the entire point. Whole-page text contains the word 'failed' on a huge fraction of
|
||||
the internet, and matching that would demote correct sends at random."""
|
||||
js = dc.rejection_probe_expression()
|
||||
assert "role=alert" in js
|
||||
assert "aria-live" in js
|
||||
assert "document.body" not in js, "the probe must not fall back to whole-page text"
|
||||
|
||||
|
||||
def test_the_probe_bounds_what_it_returns():
|
||||
"""An unbounded innerText from a chatty live region would bloat every send's payload."""
|
||||
assert "slice(0,600)" in dc.rejection_probe_expression()
|
||||
|
||||
|
||||
# --- what the user is told -------------------------------------------------------------------
|
||||
|
||||
def test_the_rejection_note_states_plainly_that_it_did_not_send():
|
||||
note = dc.rejected_send_note("https://x.com/home", "hello there")
|
||||
assert "did NOT go through" in note
|
||||
assert "x.com" in note and "www." not in note
|
||||
assert "hello there" in note
|
||||
|
||||
|
||||
def test_the_rejection_note_says_it_will_not_retry():
|
||||
"""A blind retry on a refusal is how you get rate-limited harder, or post twice if the refusal
|
||||
was cosmetic."""
|
||||
assert "did not retry" in dc.rejected_send_note("https://x.com", "hi").lower()
|
||||
|
||||
|
||||
def test_a_long_payload_is_clipped_in_the_note():
|
||||
note = dc.rejected_send_note("https://x.com", "y" * 500)
|
||||
assert "..." in note
|
||||
assert len(note) < 400
|
||||
|
||||
|
||||
def test_the_rejection_note_is_distinct_from_the_unverified_one():
|
||||
"""Different evidence, different claim. Collapsing them would either overclaim or send the user
|
||||
to go check something we already know."""
|
||||
rejected = dc.rejected_send_note("https://x.com", "hi")
|
||||
unverified = dc.unverified_send_note("https://x.com", "hi")
|
||||
assert rejected != unverified
|
||||
assert "could NOT confirm" in unverified
|
||||
assert "could NOT confirm" not in rejected
|
||||
Reference in New Issue
Block a user