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

101 lines
5.6 KiB
Python

"""BrowserDeleteItem: a model-invoked tool that removes ONE on-page item the model names by
text, deterministically. The model handles getting to the item's page (its strength); this runs
the site's own remove flow (open that item's overflow menu -> Delete -> confirm -> verify-gone),
which the model fails at by hand (measured live on X: 4 aborts on the tiny caret menu).
It is a tool, not a pre-navigation tier: the earlier tier fired before the model reached the
item and always declined. As a tool the model calls it AFTER navigating, so the item and its
(late-rendering) caret are present. Translated to a single BrowserEvaluate in execute_browser_tool
(the App-bridge pattern), so no frontend handler is needed.
Safety, in code:
- Acts ONLY inside the element that contains the target text, so it can never touch another item.
- The site enforces ownership (only your own item exposes Delete), so a target you don't own has
no menu entry and the tool reports that, it never forces one.
- Success REQUIRES verify-gone (the target text left the page). One destructive confirm click.
- Flag-gated (OSW_DELETE_SCRIPT): the tool is hidden from the model until Eric flips it.
"""
import json
import os
from typing import Any, Dict
MIN_TARGET_CHARS = 6
def delete_tool_enabled() -> bool:
return os.environ.get("OSW_DELETE_SCRIPT", "0") != "0"
# The in-page removal flow, scoped to the item holding the target text. Returns a JSON-able
# {stage, ok, msg} that parse_delete_result reads. The caret renders a beat late, so it polls.
P_DELETE_JS = r"""(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const TARGET = %s;
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
// Web-component sites (shreddit, YouTube) hide the item AND its menus in shadow roots, so every lookup pierces.
const deep = (root, sel, out, depth) => {
if (depth > 10 || out.length > 4000) return out;
let hits; try { hits = root.querySelectorAll(sel); } catch (e) { hits = []; }
for (const el of hits) out.push(el);
let all; try { all = root.querySelectorAll('*'); } catch (e) { return out; }
for (const el of all) { if (el.shadowRoot) deep(el.shadowRoot, sel, out, depth + 1); }
return out;
};
const vis = (el) => el && el.offsetParent !== null && el.getClientRects().length > 0;
const CONTAINERS = 'article,[role="article"],li,[role="listitem"],shreddit-post,'
+ '[data-testid*="tweet"],[data-testid*="post"],[data-testid*="comment"],[data-testid*="Post"],[id^="t3_"],[id^="t1_"]';
const MORE = 'button[aria-label*="More" i],button[aria-label*="option" i],'
+ '[data-testid="caret"],button[aria-haspopup="menu"],[aria-label*="menu" i]';
const holders = () => deep(document, CONTAINERS, [], 0)
.filter((el) => el.textContent && el.textContent.includes(TARGET))
.sort((a, b) => (a.textContent || '').length - (b.textContent || '').length); // tightest match first
if (!holders().length) return { stage: 'find', ok: false, msg: 'target text not on this page' };
// The caret/overflow renders a beat late and may sit in a shadow root, so poll + pierce. Take the
// first holding container that owns an overflow control, so the control belongs to THIS item.
let item = null, more = null;
for (let attempt = 0; attempt < 6 && !more; attempt++) {
if (attempt) await sleep(500);
for (const h of holders()) { const m = deep(h, MORE, [], 0).find(vis); if (m) { item = h; more = m; break; } }
}
if (!more) return { stage: 'more', ok: false, msg: 'found the item but no overflow/More control on it' };
more.click();
await sleep(1000);
const DEL = /^\s*(delete|remove)\b/i; // exact-ish: not "delete row" or "remove filter"
const del = deep(document, '[role="menuitem"],[role="option"],button,a,li', [], 0)
.find((m) => DEL.test(norm(m.innerText)) && vis(m));
if (!del) return { stage: 'menuitem', ok: false,
msg: 'no Delete/Remove entry in the menu (is this your own item?)',
menu: deep(document, '[role="menuitem"],[role="option"]', [], 0).map((m) => norm(m.innerText)).filter(Boolean).slice(0, 10) };
del.click();
await sleep(1000);
// Confirm dialog (pierce shadow DOM; Reddit's confirm is a faceplate/shreddit web component).
const dlg = deep(document, '[role="dialog"],[role="alertdialog"],[data-testid="confirmationSheetDialog"],faceplate-dialog,shreddit-async-loader', [], 0)[0] || document;
const CONF = /^\s*(delete|remove|yes|confirm)\b/i;
const conf = deep(dlg, '[data-testid="confirmationSheetConfirm"]', [], 0)[0]
|| deep(dlg, 'button', [], 0).find((b) => CONF.test(norm(b.innerText)) && vis(b));
if (conf) { conf.click(); await sleep(1800); }
const still = holders().length > 0;
return { stage: 'done', ok: !still, msg: still ? 'clicked delete but the item is still on the page' : 'item removed' };
})()"""
def delete_item_expression(target_text: str) -> str:
return P_DELETE_JS % json.dumps(target_text)
def parse_delete_result(res: object) -> Dict[str, Any]:
"""Turn the BrowserEvaluate result into {removed: bool, stage, msg}. A shape we can't read
is an honest failure (never a false 'removed')."""
val: object = None
if isinstance(res, dict) and "error" not in res:
val = res.get("value")
if val is None and isinstance(res.get("text"), str):
try:
val = json.loads(res["text"])
except (json.JSONDecodeError, ValueError):
val = None
if not isinstance(val, dict):
return {"removed": False, "stage": "eval", "msg": "the remove flow returned no readable result"}
return {"removed": bool(val.get("ok")), "stage": str(val.get("stage") or ""), "msg": str(val.get("msg") or "")}