From 1e6dd84495cf0bd5408ca9844ec3edf05799ffbb Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 7 Jul 2026 18:00:35 -0700 Subject: [PATCH] [eric] browser: verified-action executor foundation = generic site-agnostic effect verification (did the action produce the SPECIFIC expected effect: url_changed/appeared/gone/filled/cleared), zero per-site code; wired into the send-script's receipt (proves it on the real flow, behavior-identical to the old inline check via an equivalence test) so it's the executor's verification core, not dead scaffolding --- .../agents/browser/browser_send_script.py | 9 ++- .../agents/browser/browser_verified_action.py | 72 +++++++++++++++++++ backend/tests/test_browser_verified_action.py | 45 ++++++++++++ 3 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 backend/apps/agents/browser/browser_verified_action.py create mode 100644 backend/tests/test_browser_verified_action.py diff --git a/backend/apps/agents/browser/browser_send_script.py b/backend/apps/agents/browser/browser_send_script.py index cdb982ad..85058006 100644 --- a/backend/apps/agents/browser/browser_send_script.py +++ b/backend/apps/agents/browser/browser_send_script.py @@ -19,6 +19,8 @@ import re import time from typing import Awaitable, Callable +from backend.apps.agents.browser import browser_verified_action + 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. @@ -196,12 +198,15 @@ async def run_send_script( 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 + # 4. two-sided receipt via the GENERIC verifier: the send is confirmed only when + # the composer clears the payload ("cleared:"). Same verdict as the old + # inline check, now expressed as the site-agnostic expectation a verified-action + # executor uses everywhere, so this proven flow IS the executor's verification core. 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): + if state3 and browser_verified_action.expectation_met(f"cleared:{payload}", state2, state3): cleared = True break note = ("" if cleared else diff --git a/backend/apps/agents/browser/browser_verified_action.py b/backend/apps/agents/browser/browser_verified_action.py new file mode 100644 index 00000000..2eae16b3 --- /dev/null +++ b/backend/apps/agents/browser/browser_verified_action.py @@ -0,0 +1,72 @@ +"""Generic, site-agnostic verification: did an action produce the SPECIFIC effect +it was meant to? This is the load-bearing piece that lets a verified-action executor +work on any website without per-site code, the model (or a scripted flow) names a +generic expectation, and this checks it against a cheap before/after page snapshot. + +A snapshot is just the interactives-list text plus the URL, the same things every +site exposes, so nothing here knows about LinkedIn or any particular page. It +generalizes the send-script's proven two-sided receipt ("the composer cleared") from +one hand-tuned flow into one predicate ("cleared:") reusable everywhere. + +Expectations (kind, or "kind:arg"): + url_changed the page navigated + changed the page changed at all (weakest; a fallback) + appeared:X X is present now but wasn't before (a menu/dialog/result opened) + gone:X X was present before but isn't now (an item/row deleted) + filled:X some textbox value now carries X (a fill committed) + cleared:X no textbox value carries X (a composer sent + emptied) +""" + +import re +from typing import Tuple + +# Match payload_in_textbox: long values truncate in the list, so compare on a prefix. +P_VALUE_PREFIX_LEN = 24 +P_TEXTBOX_LINE = " Tuple[str, str]: + """(kind, arg) from 'kind' or 'kind:arg'. Unknown kinds are returned as-is and + treated as unmet by expectation_met, so a typo fails safe (verification withheld).""" + raw = (expect or "").strip() + if ":" in raw: + kind, arg = raw.split(":", 1) + return kind.strip().lower(), arg.strip() + return raw.lower(), "" + + +def value_present(state_text: str, sub: str) -> bool: + """True if any listed textbox VALUE carries sub (prefix match, like a committed + fill). Same logic as payload_in_textbox so the send-script stays behavior-identical.""" + probe = (sub or "")[:P_VALUE_PREFIX_LEN] + if not probe: + return False + return any(P_TEXTBOX_LINE in line and probe in line + for line in (state_text or "").splitlines()) + + +def p_contains(state_text: str, sub: str) -> bool: + s = (sub or "").strip().lower() + return bool(s) and s in (state_text or "").lower() + + +def expectation_met( + expect: str, before: str, after: str, + before_url: str = "", after_url: str = "", +) -> bool: + """Did `after` satisfy `expect` given `before`? Pure; unknown expectation = False + (fail safe: verification withheld rather than a false pass).""" + kind, arg = parse_expectation(expect) + if kind == "url_changed": + return bool(after_url) and after_url != before_url + if kind == "changed": + return before != after or (bool(after_url) and after_url != before_url) + if kind == "appeared": + return p_contains(after, arg) and not p_contains(before, arg) + if kind == "gone": + return p_contains(before, arg) and not p_contains(after, arg) + if kind == "filled": + return value_present(after, arg) + if kind == "cleared": + return not value_present(after, arg) + return False diff --git a/backend/tests/test_browser_verified_action.py b/backend/tests/test_browser_verified_action.py new file mode 100644 index 00000000..2cbcb922 --- /dev/null +++ b/backend/tests/test_browser_verified_action.py @@ -0,0 +1,45 @@ +"""The generic, site-agnostic verification core of the verified-action executor: +does an action produce the SPECIFIC expected effect, checked against a before/after +snapshot with zero per-site code. Pinned here, and pinned to match the send-script's +proven receipt so wiring it in was behavior-preserving.""" +from backend.apps.agents.browser import browser_verified_action as va +from backend.apps.agents.browser.browser_agent import payload_in_textbox + +EMPTY = '[2]\n[9]