mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-23 21:12:22 +02:00
182 lines
8.1 KiB
Python
182 lines
8.1 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).
|
|
_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.
|
|
_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.
|
|
_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.
|
|
_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 _SEND_NAME_RE.search(str(step.get("name") or "")):
|
|
return True
|
|
if action == "type" and _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 = _STEP_TOOLS.get(action)
|
|
if not spec:
|
|
return False, f"step {i+1}: unknown action {action!r} (allowed: {', '.join(_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, ""
|
|
|
|
|
|
def _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 = _STEP_TOOLS[action]
|
|
params = {}
|
|
for k in keys:
|
|
if k in step:
|
|
params[k] = _sub(step[k], value)
|
|
# carry an optional role default for clicks
|
|
if action == "click" and "role" not in params:
|
|
params["role"] = _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 _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).
|
|
_MAX_ITEM_CHARS = 500
|
|
_MAX_TOTAL_CHARS = 6000
|
|
|
|
|
|
def summarize_batch(records: list[dict], readonly: bool,
|
|
max_item_chars: int = _MAX_ITEM_CHARS,
|
|
max_total_chars: int = _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
|