Files
openswarm/backend/apps/agents/browser/browser_batch_replay.py
T
ciregenzandClaude Opus 5 3c50359146 [eric] browser-v3: fix the instrument first, then the two composer bugs it could finally see
Six of the eleven defects here were in the MEASUREMENT, not the product, and they
were wrong in both directions.

Harness, all of which silently produced wrong numbers:
- coverage.py preflight refused every sweep on a box holding exactly one backend:
  stack.sh's supervisor is a `bash -c` quoting the whole uvicorn line, so it carries
  both "-m uvicorn backend.main" AND the venv python path. Discriminate on POSITION.
- stack.sh status reported 2 backends over 1 and 0 webpack over a live dev server
  (webpack retitles its process). A status check whose job is preventing a second
  stack, failing in the direction that lets one land.
- c7_run.sh/c8_run.sh slice r6_be.log while stack.sh names logs by TAG: a stack under
  any other tag hands every trial an empty slice and the sweep reports a confident
  0/108. Now refuses loudly; it caught this exact mistake on first use.
- "Browser command timed out" was bucketed infra. It is ONE command blowing its own
  budget, not a dead webview: all 4 such rows were BrowserFindComposer at exactly its
  30s cap, every run completed after, zero card-gone markers in the whole log. Filed
  as infra it read as 11.8% flake AND lifted holdout reach 70% -> 84%.
- api_retry / rate_limit_error now grade as infra. A provider 429 storm turned clean
  15-21s exclusions into 188s product_no_composer rows.
- bench.py prints reach BOTH ways when a row is UNVERIFIED. An exclusion resting on
  the agent's own word quietly flatters the score, and coverage.py's own instruction
  to confirm it by hand goes unread (I quoted a 100% that excluded onlinegdb).

Timing was measuring 0.2% of the run: prestage completes BEFORE metrics_started_at,
so other_ms was 25ms of a 12700ms median while prestage (4146ms, ~61%) sat in no
bucket at all. prestage_ms/task_ms are now recorded; total_ms is deliberately NOT
redefined, which would invalidate every before/after already taken against it.

Product:
- find_composer rejected ACE/CodeMirror-5/Monaco composers. Their input is a ~1x1
  offscreen textarea that paints into a sibling div, so it can never pass a size
  gate. Accept it when a VISIBLE ancestor is composer-sized; honeypots stay out
  because the input itself must not be display:none/visibility:hidden/opacity:0.
  anon reach 80% -> 100%, holdout 89% -> 90%, p95 38.6s -> 9.7s.
- the composer poll slept a blind 0+1.2+1.4 = 2.6s whenever prestage staged nothing,
  which is nearly every run, and it was the whole of other_ms's suspicious constancy
  (2610-2613ms regardless of tools_ms). Stop when two reads are identical, the rule
  the opener poll 40 lines below already applies. other_ms -53.7%, tools_ms flat.
- prestage no longer navigates to the page it is already on, nor sleeps 0.35s before
  its first settle probe.
- is_replay_boundary reasoned from the NAME alone, so x.com's composer textbox named
  "Post text" was ruled an irreversible send and truncated its replay to a bare
  navigate. Excluded by ROLE; first_unsafe_step now passes role through at all.

Measured on this box: reach 100% (83% if onlinegdb's unverified exclusion is bogus),
0 false successes in ~155 runs, prestage tier-0/1 2702ms, other_ms -53.7%, infra
flake 0/158, holdout 18/20. Criteria 2/4/9 need live writes and are untouched.
Full evidence, including what did NOT work, in e2e/browser-v3/RESULTS_2026-08-06.md.

Also drops the tracked electron/node_modules symlink pointing at another machine's
Downloads folder; it is dangling on every other checkout and re-breaks the install on
any stash or checkout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 23:21:05 -07:00

319 lines
16 KiB
Python

"""
Intra-run batch replay: do a mechanical sub-flow ONCE, then replay it for many
inputs without re-screenshotting/re-analyzing each time.
The case (the user's): the agent searches LinkedIn and reads person A's profile,
realizes it must do the same for B, C, D... Instead of the full
screenshot->analyze->decide loop per person, it hands us the step template (with
{{value}} where the input varies) + the list of values, and we replay it per
value at machine speed: zero screenshots, zero LLM turns.
Held to extreme rigor because this is the HIGHEST ghost-risk feature, per-item
pages vary (profile A has "Message", B is connect-only, C hits a wall), so blind
replay would click the wrong thing and claim success it didn't earn:
1. VERIFY EVERY STEP, FALL BACK NEVER GHOST. Each step's result is checked; the
instant an item's page doesn't match the template (any step errors), that
item is abandoned and reported as needs-manual, the loop never pretends.
Honest tally always: "did N of M; these need you."
2. SENDS ARE GATED, READS ARE FREE. Read/search/navigate loops are safe and run
freely. Any irreversible step (click "Send"/"Submit"/"Connect"/"Pay"/..., or
typing into a message composer) makes the whole template unsafe to auto-replay,
we refuse and tell the agent to do those one at a time with confirmation. A
ghost in a send-loop isn't slow, it's wrong messages, so we don't allow it.
3. NETWORK-FIRST WHERE POSSIBLE. A step can be a `replay_route` (hit a captured
API endpoint with the value substituted) instead of clicking the UI, the fast,
reliable tier the audit found we use ~0% of the time. A read-loop over an
endpoint is dramatically faster than navigating N pages.
This module is the PURE, browser-free core (validate / gate / fill); the execute+
verify+report loop lives in browser_agent where the executor is.
"""
import re
PLACEHOLDER = "{{value}}"
# Agent-facing step action -> (tool_name, the param keys it carries).
P_STEP_TOOLS: dict[str, tuple[str, tuple[str, ...]]] = {
"navigate": ("BrowserNavigate", ("url",)),
"get_text": ("BrowserGetText", ()),
"evaluate": ("BrowserEvaluate", ("expression",)),
"type": ("BrowserType", ("selector", "text")),
"click": ("BrowserClickByName", ("role", "name")),
"press_key": ("BrowserPressKey", ("key",)),
"scroll": ("BrowserScroll", ("direction", "amount")),
"replay_route": ("BrowserReplayRoute", ("url",)), # the fast network tier
}
# Reads/navigation don't mutate anything irreversible; safe to loop freely.
P_READONLY_ACTIONS = {"navigate", "get_text", "evaluate", "scroll", "replay_route"}
# Irreversible / outward-facing words on a clicked control. Conservative on purpose: we'd rather refuse a borderline loop than auto-send 10 messages.
P_SEND_NAME_RE = re.compile(
r"\b(send|submit|post|publish|connect|invite|follow|like|react|comment|reply|"
r"share|message|dm|pay|buy|order|checkout|purchase|place\s*order|book|"
r"confirm|apply|accept|decline|delete|remove|unsend|withdraw|endorse)\b",
re.I,
)
# A field that reads like a message/comment composer; typing here is part of a send.
P_COMPOSE_SEL_RE = re.compile(r"message|compose|comment|msg|reply|editor|body|tweet|post", re.I)
def is_send_step(step: dict) -> bool:
"""True if this step is irreversible / outward-facing, so the whole loop must
be gated rather than auto-replayed."""
action = step.get("action")
if action == "click" and P_SEND_NAME_RE.search(str(step.get("name") or "")):
return True
if action == "type" and P_COMPOSE_SEL_RE.search(str(step.get("selector") or "")):
return True
return False
def validate_template(steps) -> tuple[bool, str]:
"""Structural check: non-empty, every step a known action with its required
fields present. Returns (ok, reason)."""
if not isinstance(steps, list) or not steps:
return False, "no steps provided"
for i, step in enumerate(steps):
if not isinstance(step, dict):
return False, f"step {i+1} is not an object"
action = step.get("action")
spec = P_STEP_TOOLS.get(action)
if not spec:
return False, f"step {i+1}: unknown action {action!r} (allowed: {', '.join(P_STEP_TOOLS)})"
_, required = spec
for key in required:
if step.get(key) in (None, ""):
return False, f"step {i+1} ({action}) is missing '{key}'"
return True, ""
def template_safety(steps) -> tuple[bool, str]:
"""True if the template is safe to auto-replay (no irreversible step). On a
send/submit step, returns (False, reason naming it) so the caller refuses and
routes those through normal per-item confirmation."""
for i, step in enumerate(steps):
if is_send_step(step):
what = step.get("name") or step.get("selector") or step.get("action")
return False, (f"step {i+1} looks irreversible/outward-facing ({what!r}); "
"do sends/submits one at a time with confirmation, not in a batch")
return True, ""
# Like P_SEND_NAME_RE minus composer-openers ("Message"/"DM" buttons open a compose box, they don't send), so routine flows still batch freely.
P_LIVE_IRREVERSIBLE_RE = re.compile(
r"\b(send|submit|post|publish|connect|invite|follow|like|react|comment|reply|"
r"share|pay|buy|order|checkout|purchase|place\s*order|book|"
r"confirm|apply|accept|decline|delete|remove|unsend|withdraw|endorse)\b",
re.I,
)
# Composer OPENERS phrased with a send-word: LinkedIn's profile button is literally
# named "Send a message to <person>", which opens the compose box (reversible), not
# a real Send. A true Send control is short and exact ("Send", "Send now"); these
# describe opening a conversation, so they must NOT trip the irreversible boundary.
P_SEND_OPENER_RE = re.compile(r"send (a |an |the )?(message|note|inmail|dm) to\b", re.I)
# Roles whose click is a FOCUS, not an action. Deliberately only text-entry roles: "button" is absent because that is exactly what a Send is.
P_TEXT_ENTRY_ROLES = frozenset({"textbox", "searchbox", "combobox"})
def is_replay_boundary(step: dict) -> bool:
"""The genuinely irreversible step where a learned skill's mechanical replay
must STOP and hand to the live agent. Same as is_send_step EXCEPT a composer
OPENER ('Message'/'DM' click, incl. 'Send a message to X') is reversible and NOT
a boundary: the prefix can mechanically open the composer, and only the real Send
(and composer typing) crosses to the live model."""
action = step.get("action")
name = str(step.get("name") or "")
if action == "click" and P_SEND_OPENER_RE.search(name):
return False # opener phrasing, not a real send
# Clicking a TEXT-ENTRY box focuses it; focusing is reversible whatever the box happens to be called. The wordlist matches on the name alone, so x.com's composer -- a textbox literally named "Post text" -- tripped \bpost\b and was ruled irreversible, truncating that skill's replay to a bare navigate (measured: PREFIX replay 1/2 steps). A real Send control is a button/link/menuitem, never a textbox, so excluding these roles cannot let a send through. is_send_completed below already reasons from role for the same reason.
if action == "click" and str(step.get("role") or "").lower() in P_TEXT_ENTRY_ROLES:
return False
if action == "click" and P_LIVE_IRREVERSIBLE_RE.search(name):
return True
if action == "type" and P_COMPOSE_SEL_RE.search(str(step.get("selector") or "")):
return True
return False
P_SEND_COMPLETED_RE = re.compile(
r"\b(send|submit|pay|place\s*order|complete\s*(order|purchase|checkout|payment))\b",
re.I,
)
P_OPENER_ROLES = frozenset({"menuitem", "menuitemcheckbox", "menuitemradio", "link", "tab"})
def is_send_completed(step: dict) -> bool:
"""True only when the click was a non-opener role AND the label matches an
unambiguous send-completion verb. Menuitems, links, and tabs label proximate
UI rather than the action itself, so they never count even if their name
matches (Drive's 'Share' menuitem was the false positive that prompted this)."""
if step.get("action") != "click":
return False
role = str(step.get("role") or "").lower()
if role in P_OPENER_ROLES:
return False
return bool(P_SEND_COMPLETED_RE.search(str(step.get("name") or "")))
def live_batch_guard(actions, seen_lines, composer_pending: bool = False) -> str:
"""Reason string if a live BrowserBatch carries an irreversible step, else ''.
The solo-send rule was prompt-only until now; this makes it physical. A
click_index resolves to its element line from the last attached state (an
unresolvable index passes: it fails at execution anyway), and Enter after
typing into a composer counts as the send it is. composer_pending arms the
Enter check across turns: r47 typed solo then batched [Enter, wait], which
slid past the within-batch check."""
typed_composer = composer_pending
for i, a in enumerate(actions or []):
if not isinstance(a, dict):
continue
typ = a.get("type")
params = a.get("params") if isinstance(a.get("params"), dict) else {}
label = ""
if typ == "click_index":
prefix = f"[{params.get('index')}]"
label = next((l for l in (seen_lines or ()) if str(l).startswith(prefix)), "")
elif typ == "click":
label = str(params.get("selector") or "")
elif typ == "type":
if P_COMPOSE_SEL_RE.search(str(params.get("selector") or "")):
typed_composer = True
continue
elif typ == "press_key":
if typed_composer and str(params.get("key") or "").strip().lower() in ("enter", "return"):
return (f"sub-action {i+1} presses Enter after typing into a message "
"composer, which sends it")
continue
else:
continue
# selectors hide words behind underscores/dashes (msg-form__send-button), which defeat \b; flatten separators so the word check still sees them
if label and P_LIVE_IRREVERSIBLE_RE.search(re.sub(r"[_\-./#\[\]]+", " ", label)):
return (f"sub-action {i+1} ({typ}) targets {label.strip()!r}, "
"which is irreversible/outward-facing")
return ""
def send_payload_from_log(action_log, prompt: str = "") -> 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 "")
summ = str(a.get("result_summary") or "")
# focus+type results carry no clicked fields (r47's live miss); the executor's own "typed the text" wording is the surviving signal
if P_COMPOSE_SEL_RE.search(name) or (len(text) >= 20 and (
role == "textbox" or "typed the text" in summ.lower())):
typed.append(text)
elif tool == "BrowserType":
sel = str(inp.get("selector") or "")
if P_COMPOSE_SEL_RE.search(sel) or (not sel and len(text) >= 20):
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()
sub_sel = str(p.get("selector") or "")
if sub.get("type") == "type" and sub_text and (
P_COMPOSE_SEL_RE.search(sub_sel)
or (not sub_sel and len(sub_text) >= 20)):
typed.append(sub_text)
if not typed:
return ""
# the task usually quotes the message; a candidate echoed there beats a longer search query or a garbled retype
for t in reversed(typed):
if t in (prompt or ""):
return t
return typed[-1]
def p_sub(val, value: str):
return value if val == PLACEHOLDER else (
val.replace(PLACEHOLDER, value) if isinstance(val, str) else val
)
def fill_step(step: dict, value: str) -> tuple[str, dict]:
"""Turn one template step + one value into (tool_name, params) ready for
execute_browser_tool. Substitutes {{value}} anywhere it appears."""
action = step["action"]
tool_name, keys = P_STEP_TOOLS[action]
params = {}
for k in keys:
if k in step:
params[k] = p_sub(step[k], value)
# carry an optional role default for clicks
if action == "click" and "role" not in params:
params["role"] = p_sub(step.get("role", ""), value)
return tool_name, params
def fill_template(steps, value: str) -> list[tuple[str, dict]]:
return [fill_step(s, value) for s in steps]
def is_readonly_template(steps) -> bool:
"""True if every step is a pure read/navigation (no clicks/types at all), the
safest class of loop."""
return all(s.get("action") in P_READONLY_ACTIONS for s in steps)
# A batch READ is useless if it doesn't hand the data back. We return each item's read output, capped so a 20-item batch stays cheap, and stay honest about failures (named, with the error) and truncation (named, never silently dropped).
P_MAX_ITEM_CHARS = 500
P_MAX_TOTAL_CHARS = 6000
def summarize_batch(records: list[dict], readonly: bool,
max_item_chars: int = P_MAX_ITEM_CHARS,
max_total_chars: int = P_MAX_TOTAL_CHARS) -> str:
"""Turn per-item batch results into the text the agent gets back.
`records`: [{value, ok, text}]. For a successful item `text` is its read
output (the data); for a failed one it's the error. Successes show their data
(capped); once the total budget is hit, remaining successes are listed by
value only (so nothing is silently lost); failures are always named with a
short reason so a partial batch never reads as 'all done'."""
done = [r for r in records if r.get("ok")]
failed = [r for r in records if not r.get("ok")]
verb = "Read" if readonly else "Completed"
lines, used, overflow = [], 0, []
for r in done:
body = " ".join(str(r.get("text") or "").split())[:max_item_chars]
line = f"- {r['value']}: {body}" if body else f"- {r['value']}: (done, no content)"
if used and used + len(line) > max_total_chars:
overflow.append(str(r["value"]))
continue
lines.append(line)
used += len(line)
out = f"{verb} {len(done)} of {len(records)}."
if lines:
out += "\n" + "\n".join(lines)
if overflow:
out += (f"\n(+{len(overflow)} more done but not shown to save space: "
f"{', '.join(overflow[:20])}; ask for specific ones if needed)")
if failed:
fails = ", ".join(
f"{r['value']} ({' '.join(str(r.get('text') or 'failed').split())[:60]})"
for r in failed[:20]
)
out += (f"\n{len(failed)} couldn't be done and need you to handle them "
f"individually: {fails}")
return out