From 6fd104754371d31a8e63ebdbf953141ca60e0cad Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 5 Jun 2026 22:55:05 -0700 Subject: [PATCH] [eric] browser: mechanical verify-first gate on send-class recovery, probe verdict gates the retry in code --- backend/apps/agents/agent_manager.py | 39 ++++++++--- .../agents/browser/browser_batch_replay.py | 34 +++++++++ .../apps/agents/browser/browser_fast_path.py | 69 +++++++++++++++++-- backend/tests/test_browser_batch_replay.py | 38 ++++++++++ backend/tests/test_browser_fast_path.py | 35 ++++++++++ 5 files changed, 200 insertions(+), 15 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index f7dbe45c..b311012f 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -3376,7 +3376,7 @@ class AgentManager: if not text: _fp_path = "read->browser" - async def _dispatch(task_text: str) -> str: + async def _dispatch(task_text: str) -> dict: results = await run_browser_agents( tasks=[{"task": task_text, "browser_id": selected[0] if selected else "", "url": ""}], model=session.model, @@ -3385,20 +3385,43 @@ class AgentManager: parent_session_id=session_id, ) r = results[0] if results else {} - return (r.get("summary") or "").strip() if isinstance(r, dict) else str(r or "") + return r if isinstance(r, dict) else {"summary": str(r or ""), "action_log": []} + + def _summary(r: dict) -> str: + return (r.get("summary") or "").strip() if not text: - text = await _dispatch(browser_fast_path.compose_task(prompt, brief)) + first = await _dispatch(browser_fast_path.compose_task(prompt, brief)) + text = _summary(first) if browser_fast_path.dispatch_failed(text): # Retry only transient failures; a dead dashboard fails the # retry identically, so skip it and tell the user instead. - if ws_manager.global_connections: - logger.info(f"[browser-fast-path] first dispatch failed for {session_id}; one recovery dispatch") - _fp_path += "+recovery" - text = await _dispatch(browser_fast_path.recovery_task(prompt, text)) - else: + if not ws_manager.global_connections: _fp_path += "+no-dashboard" text = browser_fast_path.NO_DASHBOARD_REPLY + else: + from backend.apps.agents.browser import browser_batch_replay + payload = browser_batch_replay.send_payload_from_log(first.get("action_log")) + if payload: + # The dead attempt had already typed into a composer, so a + # blind retry risks a double-send: a read-only probe's + # verdict gates the retry in code, not prose. + logger.info(f"[browser-fast-path] send-zone failure for {session_id}; payload probe before any retry") + probe_text = _summary(await _dispatch(browser_fast_path.send_probe_task(prompt, payload))) + pv = browser_fast_path.probe_verdict(probe_text) + logger.info(f"[browser-fast-path] send-probe verdict={pv} for {session_id}") + _fp_path += f"+send-probe={pv}" + if pv == "found": + text = browser_fast_path.already_sent_reply(payload, probe_text) + elif pv == "not-found": + text = _summary(await _dispatch( + browser_fast_path.recovery_task(prompt, text, verified_undelivered=True))) + else: + text = browser_fast_path.unverifiable_reply(payload, text) + else: + logger.info(f"[browser-fast-path] first dispatch failed for {session_id}; one recovery dispatch") + _fp_path += "+recovery" + text = _summary(await _dispatch(browser_fast_path.recovery_task(prompt, text))) if not text: text = "The browser agent couldn't complete this and gave no report." except asyncio.CancelledError: diff --git a/backend/apps/agents/browser/browser_batch_replay.py b/backend/apps/agents/browser/browser_batch_replay.py index 4edec0a2..60a9f7cd 100644 --- a/backend/apps/agents/browser/browser_batch_replay.py +++ b/backend/apps/agents/browser/browser_batch_replay.py @@ -153,6 +153,40 @@ def live_batch_guard(actions, seen_lines) -> str: return "" +def send_payload_from_log(action_log) -> str: + """The text a failed run typed into a composer-ish field, '' if it never + reached the send zone. Gates the recovery verify-first probe: r44's retry + SAID it would verify first then didn't, so the check must be code, not prose.""" + typed: list[str] = [] + for a in action_log or []: + if not isinstance(a, dict): + continue + tool = a.get("tool") + inp = a.get("input") if isinstance(a.get("input"), dict) else {} + text = str(inp.get("text") or "").strip() + if not text and tool != "BrowserBatch": + continue + if tool == "BrowserClickIndex": + name = str(a.get("clicked_name") or "") + role = str(a.get("clicked_role") or "") + # a filter/search box also has role textbox; real messages are longer + if _COMPOSE_SEL_RE.search(name) or (role == "textbox" and len(text) >= 20): + typed.append(text) + elif tool == "BrowserType": + if _COMPOSE_SEL_RE.search(str(inp.get("selector") or "")): + typed.append(text) + elif tool == "BrowserBatch": + for sub in (inp.get("actions") or []): + if not isinstance(sub, dict): + continue + p = sub.get("params") if isinstance(sub.get("params"), dict) else {} + sub_text = str(p.get("text") or "").strip() + if (sub.get("type") == "type" and sub_text + and _COMPOSE_SEL_RE.search(str(p.get("selector") or ""))): + typed.append(sub_text) + return max(typed, key=len) if typed else "" + + def _sub(val, value: str): return value if val == PLACEHOLDER else ( val.replace(PLACEHOLDER, value) if isinstance(val, str) else val diff --git a/backend/apps/agents/browser/browser_fast_path.py b/backend/apps/agents/browser/browser_fast_path.py index 14339bb5..49e2c417 100644 --- a/backend/apps/agents/browser/browser_fast_path.py +++ b/backend/apps/agents/browser/browser_fast_path.py @@ -132,18 +132,73 @@ NO_DASHBOARD_REPLY = ( ) -def recovery_task(prompt: str, first_report: str) -> str: - """One informed retry, replacing the orchestrator's recovery role. Verify- - first wording keeps a maybe-already-sent irreversible step from repeating.""" +def recovery_task(prompt: str, first_report: str, verified_undelivered: bool = False) -> str: + """One informed retry, replacing the orchestrator's recovery role. With + verified_undelivered the send-probe already proved nothing landed, so the + retry gets clearance instead of hedging; otherwise verify-first wording + keeps a maybe-already-sent irreversible step from repeating.""" report = (first_report or "").strip()[:600] or "no report (the browser died)" - return ( - "A previous browser attempt at this task did not finish. It reported:\n" - f"{report}\n\n" - f"Finish the task: {prompt}\n\n" + guard = ( + "A read-only check JUST confirmed the message is NOT yet delivered, so " + "performing the send is safe. Do it exactly ONCE, solo, with `expect` proof." + ) if verified_undelivered else ( "If that attempt may have already performed an irreversible step " "(send/submit/post/pay), FIRST verify on the page whether it happened; " "if it did, do NOT repeat it, report DONE with that proof." ) + return ( + "A previous browser attempt at this task did not finish. It reported:\n" + f"{report}\n\n" + f"Finish the task: {prompt}\n\n{guard}" + ) + + +def send_probe_task(prompt: str, payload: str) -> str: + """Recovery pre-check for send-class failures: a read-only dispatch whose + verdict gates the retry in code (r44's retry skipped its promised verify + step, so prose alone is not a guard).""" + return ( + "READ-ONLY verification, do NOT send, type, click any send/submit " + "control, or open a compose box. A previous attempt at the task below " + f"may or may not have already delivered its message:\n{prompt}\n\n" + "Check the relevant conversation/thread/history for this exact text:\n" + f'"{payload}"\n' + "Count near-variants too (extra whitespace, duplicated text). " + "End with exactly one line: 'OUTCOME: PAYLOAD-FOUND ' " + "or 'OUTCOME: PAYLOAD-NOT-FOUND'." + ) + + +def probe_verdict(summary: str) -> str: + """'found' | 'not-found' | 'unknown'. NOT-FOUND is checked first because + the FOUND token is its substring.""" + s = (summary or "").upper() + if "PAYLOAD-NOT-FOUND" in s: + return "not-found" + if "PAYLOAD-FOUND" in s: + return "found" + return "unknown" + + +def already_sent_reply(payload: str, probe_report: str) -> str: + """First attempt delivered before dying; the fix is evidence, not a resend.""" + proof = (probe_report or "").strip()[:400] + return ( + "The first attempt actually delivered the message before it lost the " + f'browser: a read-only check found "{payload}" already in the ' + f"conversation, so I did NOT send it again.\n\n{proof}" + ) + + +def unverifiable_reply(payload: str, first_report: str) -> str: + """Fail-closed: can't prove the send didn't land, so don't risk a double.""" + report = (first_report or "").strip()[:400] + return ( + "The browser attempt failed after it had already typed the message " + f'("{payload}"), and a read-only check could not confirm whether it was ' + "sent. I'm not retrying an irreversible send blind; please glance at " + f"the thread and re-ask if it's missing.\n\nFirst attempt: {report}" + ) def _normalize_for_classifier(prompt: str) -> str: diff --git a/backend/tests/test_browser_batch_replay.py b/backend/tests/test_browser_batch_replay.py index 2c574167..e71756dd 100644 --- a/backend/tests/test_browser_batch_replay.py +++ b/backend/tests/test_browser_batch_replay.py @@ -214,3 +214,41 @@ def test_guard_allows_search_type_then_enter(): {"type": "type", "params": {"selector": "input.search-global-typeahead__input", "text": "q"}}, {"type": "press_key", "params": {"key": "Enter"}}, ], set()) == "" + + +# --- send payload extraction (recovery verify-first gate) ----------------------- +def test_payload_extracted_from_composer_click_index_fill(): + log = [ + {"tool": "BrowserNavigate", "input": {"url": "https://x.com"}}, + {"tool": "BrowserClickIndex", "input": {"index": 4, "text": "[test] hello world r44-os"}, + "clicked_role": "textbox", "clicked_name": "Write a message…"}, + ] + assert br.send_payload_from_log(log) == "[test] hello world r44-os" + + +def test_payload_ignores_search_fills_and_short_filter_textboxes(): + log = [ + {"tool": "BrowserClickIndex", "input": {"index": 2, "text": "tyler chen entrepreneurs"}, + "clicked_role": "searchbox", "clicked_name": "Search"}, + {"tool": "BrowserClickIndex", "input": {"index": 8, "text": "Entrepreneurs First"}, + "clicked_role": "textbox", "clicked_name": "Add a company"}, + ] + assert br.send_payload_from_log(log) == "" + + +def test_payload_from_type_and_batch_composer_selectors_longest_wins(): + log = [ + {"tool": "BrowserType", "input": {"selector": "div.msg-form__contenteditable", "text": "hi"}}, + {"tool": "BrowserBatch", "input": {"actions": [ + {"type": "type", "params": {"selector": "div.msg-form__contenteditable", + "text": "a much longer message body"}}, + {"type": "type", "params": {"selector": "input.search-typeahead", "text": "ignored search"}}, + ]}}, + ] + assert br.send_payload_from_log(log) == "a much longer message body" + + +def test_payload_empty_log_and_garbage_safe(): + assert br.send_payload_from_log([]) == "" + assert br.send_payload_from_log(None) == "" + assert br.send_payload_from_log([{"tool": "BrowserClickIndex"}, "junk"]) == "" diff --git a/backend/tests/test_browser_fast_path.py b/backend/tests/test_browser_fast_path.py index 15176b56..f6895efb 100644 --- a/backend/tests/test_browser_fast_path.py +++ b/backend/tests/test_browser_fast_path.py @@ -104,3 +104,38 @@ def test_dispatch_refused_instantly_when_no_dashboard_connected(): assert len(results) == 1 assert results[0]["summary"].startswith("Error: no dashboard window is connected") assert dispatch_failed(results[0]["summary"]) + + +def test_send_probe_verdict_parsing_order_and_fail_closed(): + from backend.apps.agents.browser.browser_fast_path import probe_verdict + assert probe_verdict("OUTCOME: PAYLOAD-NOT-FOUND") == "not-found" + assert probe_verdict("checked thread. OUTCOME: PAYLOAD-FOUND at 10:43 PM") == "found" + assert probe_verdict("the browser became unresponsive") == "unknown" + assert probe_verdict("") == "unknown" + # a report quoting BOTH tokens must not read as found + assert probe_verdict("PAYLOAD-FOUND? no: PAYLOAD-NOT-FOUND") == "not-found" + + +def test_send_probe_task_is_read_only_and_names_payload(): + from backend.apps.agents.browser.browser_fast_path import send_probe_task + t = send_probe_task("dm tyler", "[test] hello r45-os") + assert "READ-ONLY" in t and "[test] hello r45-os" in t + assert "PAYLOAD-FOUND" in t and "PAYLOAD-NOT-FOUND" in t + + +def test_recovery_task_clearance_only_when_verified(): + from backend.apps.agents.browser.browser_fast_path import recovery_task + hedged = recovery_task("dm tyler", "browser died") + cleared = recovery_task("dm tyler", "browser died", verified_undelivered=True) + assert "FIRST verify" in hedged and "NOT yet delivered" not in hedged + assert "NOT yet delivered" in cleared and "exactly ONCE" in cleared + + +def test_send_probe_replies_are_honest(): + from backend.apps.agents.browser.browser_fast_path import ( + already_sent_reply, unverifiable_reply, + ) + a = already_sent_reply("[test] hi r46-os", "OUTCOME: PAYLOAD-FOUND at 11:02 PM") + assert "did NOT send it again" in a and "r46-os" in a + u = unverifiable_reply("[test] hi r46-os", "browser became unresponsive") + assert "not retrying" in u.lower() and "r46-os" in u