[eric] browser: mechanical verify-first gate on send-class recovery, probe verdict gates the retry in code

This commit is contained in:
ciregenz
2026-06-05 22:55:05 -07:00
parent fcc02ce77a
commit 6fd1047543
5 changed files with 200 additions and 15 deletions
+31 -8
View File
@@ -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:
@@ -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
@@ -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 <where and timestamp>' "
"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:
@@ -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"]) == ""
+35
View File
@@ -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