mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-09 11:17:44 +02:00
[eric] browser: act-and-confirm, declare the expected change and verify it instead of assuming
This commit is contained in:
@@ -58,6 +58,13 @@ from backend.apps.tools_lib.tools_lib import load_builtin_permissions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mutating actions that can carry an `expect` (the change they should cause) and be
|
||||
# confirmed after running. Reads/waits aren't here, there's nothing to confirm.
|
||||
_CONFIRM_TOOLS = {
|
||||
"BrowserClick", "BrowserClickIndex", "BrowserClickByName",
|
||||
"BrowserType", "BrowserNavigate", "BrowserPressKey", "BrowserBatch",
|
||||
}
|
||||
|
||||
|
||||
async def execute_browser_tool(
|
||||
tool_name: str, tool_input: dict, browser_id: str, tab_id: str = "",
|
||||
@@ -979,12 +986,13 @@ async def run_browser_agent(
|
||||
tool_input = tu.input
|
||||
if tu.name == "BrowserListInteractives" and current_next_goal:
|
||||
tool_input = {**tu.input, "goal": current_next_goal}
|
||||
|
||||
async def _wait_exec(tool, params, bid, tid):
|
||||
return await _cancellable(execute_browser_tool(tool, params, bid, tid))
|
||||
|
||||
if tu.name == "BrowserWait":
|
||||
# Smart wait: return as soon as the page's network settles
|
||||
# instead of sleeping the full fixed duration (the audit's
|
||||
# 42%-of-time hog). Caps at the requested ms; never premature.
|
||||
async def _wait_exec(tool, params, bid, tid):
|
||||
return await _cancellable(execute_browser_tool(tool, params, bid, tid))
|
||||
# Smart wait: return as soon as the page is ready (target or DOM
|
||||
# settle), not on a blind timer (the audit's 42%-of-time hog).
|
||||
result = await browser_wait.smart_wait(
|
||||
_wait_exec, browser_id, tab_id, tu.input.get("milliseconds"),
|
||||
until=(tu.input.get("until") or ""),
|
||||
@@ -998,6 +1006,27 @@ async def run_browser_agent(
|
||||
break
|
||||
elapsed_ms = int((time.time() - start) * 1000)
|
||||
|
||||
# Act-and-confirm: if the agent declared the change it expects, VERIFY
|
||||
# it actually happened, success is observed, never assumed. A hit returns
|
||||
# fast (act + confirm in one turn); a miss is a clear "may not have worked"
|
||||
# (and a wedge surfaces as a clean not-confirmed, not a blind 20s timeout),
|
||||
# so the agent never claims a success it didn't see or re-fires blindly.
|
||||
_expect = (str(tu.input.get("expect") or "").strip()
|
||||
if isinstance(tu.input, dict) else "")
|
||||
if _expect and "error" not in result and tu.name in _CONFIRM_TOOLS:
|
||||
_conf = await browser_wait.smart_wait(_wait_exec, browser_id, tab_id, 3500, until=_expect)
|
||||
if isinstance(_conf, dict):
|
||||
result["confirmed"] = bool(_conf.get("found"))
|
||||
if _conf.get("found"):
|
||||
result["text"] = f"{result.get('text') or ''}\nConfirmed: '{_expect}' is now present."
|
||||
else:
|
||||
result["text"] = (
|
||||
f"{result.get('text') or ''}\nNOT confirmed: '{_expect}' did not appear within "
|
||||
f"{_conf.get('waited_ms')}ms, so the action may not have worked. Check the page "
|
||||
"before assuming success, and never re-fire an irreversible action "
|
||||
"(Send/Submit/Pay/Post) without first verifying the previous one did not go through."
|
||||
)
|
||||
|
||||
action_log.append({
|
||||
"tool": tu.name,
|
||||
"input": tu.input,
|
||||
|
||||
@@ -12,6 +12,20 @@ MODEL_MAP = {
|
||||
"haiku": "claude-haiku-4-5-20251001",
|
||||
}
|
||||
|
||||
# The change an action should cause, declared by the agent and CONFIRMED after the
|
||||
# action runs (success is observed, never assumed). A hit returns fast; a miss tells
|
||||
# the agent it may not have worked instead of letting it claim a false success.
|
||||
_EXPECT_DESC = {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional but recommended: the specific change this action should cause, a "
|
||||
"button label, text, or element you expect to see afterward (e.g. 'Write a "
|
||||
"message', the recipient's name in the thread). It's confirmed right after, so "
|
||||
"you learn whether it actually worked. REQUIRED for anything you can't undo "
|
||||
"(Send/Submit/Pay/Post): set it to proof the action landed."
|
||||
),
|
||||
}
|
||||
|
||||
BROWSER_TOOLS_SCHEMA = [
|
||||
{
|
||||
"name": "ReportProgress",
|
||||
@@ -108,6 +122,7 @@ BROWSER_TOOLS_SCHEMA = [
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selector": {"type": "string", "description": "CSS selector of the element to click."},
|
||||
"expect": _EXPECT_DESC,
|
||||
},
|
||||
"required": ["selector"],
|
||||
},
|
||||
@@ -210,6 +225,7 @@ BROWSER_TOOLS_SCHEMA = [
|
||||
"type": "integer",
|
||||
"description": "The numeric index from BrowserListInteractives (1-based).",
|
||||
},
|
||||
"expect": _EXPECT_DESC,
|
||||
},
|
||||
"required": ["index"],
|
||||
},
|
||||
@@ -528,6 +544,17 @@ SYSTEM_PROMPT = (
|
||||
"(BrowserScreenshot, BrowserGetText, BrowserGetConsole, BrowserGetElements, BrowserWait) do not "
|
||||
"require ReportProgress.\n\n"
|
||||
|
||||
"## Act and confirm: trust only what you observe\n"
|
||||
"Success is OBSERVED, never assumed. On any action that changes the page (click, "
|
||||
"type, navigate), add `expect`: the change it should cause (a label, text, or the "
|
||||
"element you expect to see). It's confirmed right after, a hit comes back fast and "
|
||||
"you move on; a 'NOT confirmed' means it may not have worked, so check the page "
|
||||
"instead of pressing on. For anything you CANNOT undo (Send, Submit, Pay, Post): "
|
||||
"first make sure the goal isn't already done (e.g. your message isn't already the "
|
||||
"last one in the thread), pass `expect` set to proof it landed, and NEVER fire it a "
|
||||
"second time unless you have verified the first did NOT go through. This is how you "
|
||||
"avoid both ghost-successes and double-sends.\n\n"
|
||||
|
||||
"## Loop awareness\n"
|
||||
"If you see a tool result containing 'LOOP DETECTED' or '⚠️', it means you "
|
||||
"have called the same tool with the same parameters and gotten the same "
|
||||
|
||||
@@ -162,5 +162,5 @@ async def smart_wait(execute_fn, browser_id, tab_id, max_ms, *, until="",
|
||||
text += " The page or tab appears unresponsive."
|
||||
if last_url:
|
||||
text += f" Current URL: {last_url}"
|
||||
return {"text": text, "url": last_url, "settled": settled, "hung": hung,
|
||||
return {"text": text, "url": last_url, "settled": settled, "found": found, "hung": hung,
|
||||
"waited_ms": waited, **({"error": "page unresponsive"} if hung else {})}
|
||||
|
||||
@@ -93,7 +93,10 @@ def _install(monkeypatch, primary, aux):
|
||||
# smart-wait probes via evaluate; report 'settled' so BrowserWait returns
|
||||
# fast in tests instead of riding the full cap.
|
||||
if action == "evaluate" and "getEntriesByType('resource')" in str(params.get("expression", "")):
|
||||
return {"text": '{"ready": true, "quiet": 9999}', "url": DOC_URL}
|
||||
expr = str(params.get("expression", ""))
|
||||
# a confirm/target probe embeds a non-empty `const spec="..."`; report it found
|
||||
found = "const spec=" in expr and 'const spec=""' not in expr
|
||||
return {"text": json.dumps({"ready": True, "quiet": 9999, "elems": 100, "found": found}), "url": DOC_URL}
|
||||
if action == "list_interactives":
|
||||
return {"text": '1 interactive elements:\n[1]<button "Submit">', "url": DOC_URL}
|
||||
if action == "click_index":
|
||||
@@ -159,6 +162,29 @@ def test_full_loop_goal_stagnation_adjudication_and_hint_write(monkeypatch):
|
||||
assert "cross-origin iframe" in BH.get_domain_note("google.com")
|
||||
|
||||
|
||||
def test_action_with_expect_is_confirmed(monkeypatch):
|
||||
# An action that declares `expect` is CONFIRMED after it runs: the loop issues a
|
||||
# target-aware confirm probe and feeds the next turn a tool_result stating the
|
||||
# expected change is present (observed success, never assumed).
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("click submit and confirm"),
|
||||
_tu("BrowserClickIndex", index=1, expect="Submitted")]),
|
||||
Resp([Blk("text", "Confirmed and done.")], stop_reason="end_turn"),
|
||||
])
|
||||
aux = FakeAux()
|
||||
sent = _install(monkeypatch, primary, aux)
|
||||
|
||||
asyncio.run(BA.run_browser_agent(task="submit the form", browser_id="b1", model="sonnet"))
|
||||
|
||||
# a confirm probe carrying the declared target was issued
|
||||
assert any(c["action"] == "evaluate" and "Submitted" in str(c["params"].get("expression", ""))
|
||||
for c in sent), "no confirm probe for the declared target"
|
||||
# and the confirmation was fed back to the model on the next turn
|
||||
all_msgs = json.dumps([c["messages"] for c in primary.calls])
|
||||
assert "Confirmed: 'Submitted' is now present." in all_msgs
|
||||
|
||||
|
||||
def test_aux_adjudication_fires_even_when_loop_detector_trips(monkeypatch):
|
||||
# Repeated IDENTICAL failing clicks trip the exact-repeat loop detector AND
|
||||
# reach stagnation exhaustion on the same turn. The aux escape hatch must
|
||||
|
||||
@@ -84,7 +84,7 @@ async def test_returns_early_once_settled():
|
||||
# first probe: still loading; second: settled -> should stop well under the cap
|
||||
ex = FakeExec([_probe(False, 0), _probe(True, 999)])
|
||||
out = await bw.smart_wait(ex, "b", "", 5000, poll_ms=20, floor_ms=20, quiet_window_ms=50)
|
||||
assert out["settled"] is True
|
||||
assert out["settled"] is True and out["found"] is False
|
||||
assert out["waited_ms"] < 5000
|
||||
assert "page settled" in out["text"]
|
||||
|
||||
@@ -117,7 +117,7 @@ async def test_returns_the_instant_target_is_found():
|
||||
_probe(False, 5, elems=200, found=True)])
|
||||
out = await bw.smart_wait(ex, "b", "", 5000, until="Send",
|
||||
poll_ms=20, floor_ms=800, quiet_window_ms=999)
|
||||
assert out["settled"] is True and "found target" in out["text"]
|
||||
assert out["settled"] is True and out["found"] is True and "found target" in out["text"]
|
||||
assert out["waited_ms"] < 800 # bypassed the floor because the target was there
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user