mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-28 04:24:51 +02:00
[eric] browser: mechanical verify-first gate on send-class recovery, probe verdict gates the retry in code
This commit is contained in:
@@ -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"]) == ""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user