mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
405 lines
20 KiB
Python
405 lines
20 KiB
Python
"""
|
|
Loop detection for the browser sub-agent.
|
|
|
|
Tracks recent state-mutating tool calls in a sliding window. If the model
|
|
repeats the same (tool, input) with the same result several times, we inject
|
|
an is_error message in the next tool_result to force a strategy change. This
|
|
prevents the model from burning the entire turn budget on a failing approach.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
|
|
from backend.apps.agents.browser import browser_send_parse
|
|
from backend.apps.agents.browser.intervention_copy import (
|
|
LOOP_WARNING_INTERVENTION_FIX,
|
|
STAGNATION_INTERVENTION_TAIL,
|
|
)
|
|
|
|
# Tools that are read-only / idempotent and should NOT count toward loop detection. Repeating these is normal (scrolling through a feed, taking successive screenshots, polling for an element to appear).
|
|
LOOP_DETECTION_EXCLUDED_TOOLS = {
|
|
"BrowserScreenshot",
|
|
"BrowserGetText",
|
|
"BrowserGetConsole", # read-only diagnostic; reading it repeatedly is fine
|
|
"BrowserGetElements",
|
|
"BrowserListInteractives", # Phase 3
|
|
"BrowserWait",
|
|
"ReportProgress", # Phase 2
|
|
"RequestHumanIntervention",
|
|
"BrowserListSkills", # meta: inspect own learned skills
|
|
"BrowserDeprecateSkill", # meta: prune a stale skill
|
|
"BrowserRepeatFlow", # batch: drives its own verified per-item loop
|
|
}
|
|
|
|
LOOP_WINDOW_SIZE = 5
|
|
P_LOOP_REPEAT_THRESHOLD = 2 # the SECOND identical (tool,input,result) is already a wall
|
|
LOOP_HARD_CAP = 5
|
|
|
|
|
|
# Universal close-affordance vocabulary for blocking popups (cookie walls, upsells, app-install nags, coachmarks). These phrases sit on a throwaway dismiss and NEVER on a control a real task needs (you never "No thanks" your way through a send), so a mechanical dismiss of one cannot close something the task required. Deliberately omits generic "Close"/"Dismiss"/"Skip", which DO appear on needed dialogs (e.g. "Close your conversation"). Keys on the pattern, not any one site, so it generalizes.
|
|
P_DISMISS_NAMES = frozenset({
|
|
"no thanks", "no, thanks", "maybe later", "not now", "skip for now",
|
|
"remind me later", "got it", "decline", "no, maybe later", "not interested",
|
|
})
|
|
# never dismiss anything that smells like security or a real decision
|
|
P_DANGER_NAME_RE = re.compile(r"verif|confirm|2fa|password|sign|pay|delete|send|post|submit", re.I)
|
|
P_ROW_RE = re.compile(r'<\s*([a-z]+)\s+"([^"]*)"', re.I) # matches a [i]<role "name"> row
|
|
|
|
|
|
def interstitial_dismiss_target(interactives_text: str) -> str | None:
|
|
"""The accessible name of an unambiguous junk-popup close control on the
|
|
page, or None. Conservative by construction: matches only throwaway-dismiss
|
|
vocabulary that never sits on a task-needed control, on a button/link, and
|
|
never anything with security/confirm/commit wording, so a mechanical dismiss
|
|
can never close a dialog the task actually required."""
|
|
for line in (interactives_text or "").splitlines():
|
|
m = P_ROW_RE.search(line)
|
|
if not m:
|
|
continue
|
|
role, name = m.group(1).lower(), m.group(2).strip()
|
|
if role not in ("button", "link"):
|
|
continue
|
|
norm = re.sub(r"[^a-z, ]", "", name.lower()).strip()
|
|
if norm in P_DISMISS_NAMES and not P_DANGER_NAME_RE.search(name):
|
|
return name
|
|
return None
|
|
|
|
|
|
def hash_tool_call(tool_name: str, tool_input: dict, result: dict) -> tuple[str, str, str]:
|
|
"""Build a stable hash key for a tool call, including its result.
|
|
|
|
Including the result hash means that legitimate progress (same input,
|
|
different output; e.g. BrowserScroll on a long feed) does NOT count
|
|
as a loop. Only same-input + same-output is treated as stuck.
|
|
"""
|
|
try:
|
|
input_key = json.dumps(tool_input, sort_keys=True, default=str)
|
|
except Exception:
|
|
input_key = repr(tool_input)
|
|
try:
|
|
# Truncate the result hash to avoid huge image blobs in the key
|
|
result_key = json.dumps(result, sort_keys=True, default=str)[:300]
|
|
except Exception:
|
|
result_key = repr(result)[:300]
|
|
return (tool_name, input_key, result_key)
|
|
|
|
|
|
def detect_loop(
|
|
recent_calls: list[tuple[str, str, str]],
|
|
new_call: tuple[str, str, str],
|
|
) -> bool:
|
|
"""Return True if `new_call` constitutes a loop given recent history.
|
|
|
|
A loop is when the same (tool, input, result) has appeared at least
|
|
`P_LOOP_REPEAT_THRESHOLD` times within the last `LOOP_WINDOW_SIZE`
|
|
state-mutating calls (the new call counts as one of those occurrences).
|
|
"""
|
|
if new_call[0] in LOOP_DETECTION_EXCLUDED_TOOLS:
|
|
return False
|
|
window = recent_calls[-(LOOP_WINDOW_SIZE - 1):] + [new_call]
|
|
matches = sum(1 for c in window if c == new_call)
|
|
return matches >= P_LOOP_REPEAT_THRESHOLD
|
|
|
|
|
|
LOOP_WARNING_TEXT = (
|
|
"LOOP DETECTED: the same action got the same result {count} times, so repeating "
|
|
"it will NOT help. Diagnose the REAL cause before anything else, do not assume: "
|
|
"read the exact error in the result; call BrowserGetConsole to see the page's own "
|
|
"JS/network errors (a failed API call or crashed app is often the real reason); "
|
|
"use BrowserEvaluate to check whether your target actually exists but is disabled, "
|
|
"hidden, or covered by an overlay; use BrowserGetText or BrowserScreenshot to check "
|
|
"whether the page is really a login wall, captcha, or error page. THEN fix that exact "
|
|
"cause: a wrong selector means switch to BrowserListInteractives + BrowserClickIndex "
|
|
"or BrowserPressKey; a blocked element means clear the blocker first; "
|
|
+ LOOP_WARNING_INTERVENTION_FIX + ". Don't just try another "
|
|
"selector if the problem isn't a selector."
|
|
)
|
|
|
|
|
|
# --- Stagnation detection ------------------------------------------------- Distinct from the exact-repeat loop above. The agent can be "busy but stuck": trying selector A, then B, then C, all failing. The inputs differ so the exact-repeat detector never fires, yet the page never changes. We watch for a run of state-mutating actions that produced no URL change AND looked like failures (or just repeated the same observation), and nudge the model down the strategy ladder before it burns the whole turn budget.
|
|
|
|
# Read-only / meta tools don't count toward stagnation (same exemption set as the loop detector): re-orienting is not "being stuck".
|
|
P_STAGNATION_NEUTRAL_TOOLS = LOOP_DETECTION_EXCLUDED_TOOLS
|
|
STAGNATION_ESCALATION_AT = 3
|
|
STAGNATION_MAX = 5
|
|
|
|
P_FAILURE_MARKERS = (
|
|
"error", "not found", "no longer valid", "no box model",
|
|
"no valid bounding rect", "failed", "rejected", "timed out",
|
|
"could not", "unable to", "denied",
|
|
)
|
|
|
|
|
|
def looks_like_failure(text: str) -> bool:
|
|
low = text.lower()
|
|
return any(m in low for m in P_FAILURE_MARKERS)
|
|
|
|
|
|
def is_unproductive(
|
|
tool_name: str, result: dict, prev_url: str, prev_text: str,
|
|
) -> bool:
|
|
"""True if a state-mutating action changed nothing observable.
|
|
|
|
Productive (returns False): a URL change, or a success-shaped result, gets
|
|
the benefit of the doubt (a click that opens a dropdown changes no URL but
|
|
is real progress). Unproductive (returns True): an error result, a
|
|
failure-shaped message, or the exact same observation as the previous
|
|
action, all with no URL change. Neutral tools (screenshot, get_text, etc.)
|
|
never count.
|
|
"""
|
|
if tool_name in P_STAGNATION_NEUTRAL_TOOLS:
|
|
return False
|
|
new_url = str(result.get("url") or "")
|
|
if new_url and prev_url and new_url != prev_url:
|
|
return False
|
|
if "error" in result:
|
|
return True
|
|
text = str(result.get("text") or result.get("error") or "")
|
|
if looks_like_failure(text):
|
|
return True
|
|
if prev_text and text[:200] == prev_text[:200]:
|
|
return True
|
|
return False
|
|
|
|
|
|
P_STAGNATION_NUDGE = (
|
|
"NO PROGRESS: your last {streak} actions changed nothing and looked like "
|
|
"failures. Before trying yet another variation, find out WHY: read the exact "
|
|
"errors; call BrowserGetConsole for the page's own JS/network errors; use "
|
|
"BrowserEvaluate to check if the target is disabled, hidden, or behind an "
|
|
"overlay; take ONE BrowserGetText or BrowserScreenshot to confirm the "
|
|
"page is what you think (not a login wall, captcha, or error page). Act on the "
|
|
"real cause; only if it is truly a selector miss do you walk the ladder "
|
|
"(BrowserListInteractives + BrowserClickIndex, then BrowserPressKey, then "
|
|
"find-by-text with BrowserEvaluate)."
|
|
)
|
|
|
|
|
|
def stagnation_nudge(streak: int) -> str:
|
|
base = P_STAGNATION_NUDGE.format(streak=streak)
|
|
if streak >= STAGNATION_MAX:
|
|
base += (
|
|
" Switching selectors hasn't worked, so the PLAN itself is likely "
|
|
"wrong: step back and revise your overall approach (a different page, "
|
|
"route, or entry point), not just the selector. "
|
|
+ STAGNATION_INTERVENTION_TAIL
|
|
)
|
|
return base
|
|
|
|
|
|
def advance_stagnation(
|
|
streak: int, prev_url: str, prev_text: str, tool_name: str, result: dict,
|
|
) -> tuple[int, str, str, str | None]:
|
|
"""Advance the stagnation streak for one executed tool.
|
|
|
|
Neutral read/meta tools pass through unchanged (no bump, no reset). For a
|
|
state-mutating action, bump the streak when unproductive else reset it, and
|
|
return a nudge string when the streak crosses an escalation threshold.
|
|
Returns (new_streak, new_prev_url, new_prev_text, nudge_or_None).
|
|
"""
|
|
if tool_name in P_STAGNATION_NEUTRAL_TOOLS:
|
|
return streak, prev_url, prev_text, None
|
|
if is_unproductive(tool_name, result, prev_url, prev_text):
|
|
streak += 1
|
|
else:
|
|
streak = 0
|
|
new_url = str(result.get("url") or "") or prev_url
|
|
new_text = str(result.get("text") or result.get("error") or "")[:200]
|
|
nudge = (
|
|
stagnation_nudge(streak)
|
|
if streak in (STAGNATION_ESCALATION_AT, STAGNATION_MAX)
|
|
else None
|
|
)
|
|
return streak, new_url, new_text, nudge
|
|
|
|
|
|
def stagnation_exhausted(streak: int) -> bool:
|
|
"""True once deterministic nudging has been exhausted; the caller may then
|
|
escalate to a one-shot aux-LLM adjudication (see browser_validator)."""
|
|
return streak >= STAGNATION_MAX
|
|
|
|
|
|
# --- completion honesty gate ---------------------------------------------- A model that ends its turn is NOT proof the goal happened. The worst ghost we measured: multi-minute runs where every tool errored, still reported "completed". This deterministic gate reality-checks the run before we let the status say "done", so a fake success is reported as the failure it actually is.
|
|
|
|
# State-changing tools: a task that needed to DO something must land one of these.
|
|
P_PRODUCTIVE_TOOLS = {
|
|
"BrowserClick", "BrowserClickIndex", "BrowserType", "BrowserNavigate",
|
|
"BrowserPressKey", "BrowserScroll", "BrowserBatch", "BrowserActVerified",
|
|
}
|
|
# Read/extract tools: a look-only task's evidence is that a read returned content.
|
|
P_READ_TOOLS = {
|
|
"BrowserGetText", "BrowserGetElements", "BrowserListInteractives",
|
|
"BrowserListRoutes", "BrowserReplayRoute", "BrowserScreenshot", "BrowserEvaluate",
|
|
}
|
|
|
|
|
|
# A card the agent can't make progress on, EITHER gone (closed/dashboard not open; unrecoverable) OR hung (a wedged tab where every command times out / the page never responds). Both look the same to the agent: retrying just burns time (the 20-minute LinkedIn spin), so we fail fast. The streak (reset on any good result) absorbs a one-off transient; only a SUSTAINED pattern trips it, so a merely-busy page that recovers is never mistaken for dead.
|
|
P_CARD_GONE_MARKERS = (
|
|
"not an electron webview", # card closed / destroyed
|
|
"no dashboard is connected", # dashboard view not mounted
|
|
"command timed out", # hung: the command never came back
|
|
"page unresponsive", # hung: smart-wait gave up probing the tab
|
|
)
|
|
CARD_GONE_LIMIT = 2 # consecutive misses before we give up (absorbs a transient)
|
|
|
|
|
|
def card_is_unavailable(result: dict) -> bool:
|
|
err = str(result.get("error") or "").lower()
|
|
return any(m in err for m in P_CARD_GONE_MARKERS)
|
|
|
|
|
|
# Errors where the action MISSED but the page is alive (stale index after a reshuffle, a transient overlay covering the target, off-screen). The page itself is fine, so re-attaching the CURRENT element list to the error lets the model re-act next turn instead of burning a turn re-listing. This NEVER retries the action (no double-send risk); it only enriches the error with fresh state.
|
|
P_RECOVERABLE_ERR_MARKERS = (
|
|
"no longer valid", "no node with given id", "page may have changed",
|
|
"covered it", "obscured", "intercepted", "not clickable",
|
|
"box model", "try scrolling", "not visible",
|
|
)
|
|
|
|
|
|
def recoverable_tool_error(err: str) -> bool:
|
|
"""True for a 'the action missed but the page is alive' error worth showing
|
|
fresh state for. False for a dead card (handled separately) or no error."""
|
|
e = (err or "").lower()
|
|
if not e or any(m in e for m in P_CARD_GONE_MARKERS):
|
|
return False
|
|
return any(m in e for m in P_RECOVERABLE_ERR_MARKERS)
|
|
|
|
|
|
# Actions that DIRTY the page so replay-from-here is no longer equivalent to a clean dispatch. Navigation and reads don't dirty anything (they just get us to the page), so the deferred replay re-check is allowed after only those.
|
|
P_REPLAY_DIRTYING_TOOLS = {
|
|
"BrowserType", "BrowserClick", "BrowserClickIndex",
|
|
"BrowserPressKey", "BrowserScroll", "BrowserBatch", "BrowserActVerified",
|
|
}
|
|
|
|
|
|
def replay_recheck_is_safe(action_log: list[dict]) -> bool:
|
|
"""True if nothing in the run so far has mutated page state, so switching to
|
|
a learned-skill replay now is equivalent to replaying from a clean dispatch
|
|
(the agent only navigated / looked around to get to the right host)."""
|
|
return not any(a.get("tool") in P_REPLAY_DIRTYING_TOOLS for a in action_log)
|
|
|
|
|
|
# What the user ASKED FOR outranks how the sub narrated it: an info ask can never replay (the answer must be fresh), an action ask can.
|
|
P_INFO_ASK_RE = re.compile(
|
|
r"\b(tell me|what(?:'s| is| are)|how (?:many|much)|count|list|summari[sz]e|"
|
|
r"extract|find (?:me|out)|show me|look up|read (?:me|the)|get the|give me|which|"
|
|
r"who (?:is|are)|report back|most (?:viewed|popular|liked|recent|rated|watched)|top \d+)\b",
|
|
re.I,
|
|
)
|
|
P_ACTION_ASK_RE = re.compile(
|
|
r"\b(open|go to|navigate|click|send|post|submit|fill|type|search for|log ?in|"
|
|
r"sign ?in|upload|download|book|order|buy|add|create|delete|message|dm|text)\b",
|
|
re.I,
|
|
)
|
|
|
|
P_DELETE_INTENT_RE = re.compile(
|
|
r"\b(delete|remove|take ?down|unsend|retract|unpost|discard|trash)\b", re.I)
|
|
|
|
|
|
def is_removal_task(task: str) -> bool:
|
|
"""A delete/remove ask. The send-script must stand down on these: a removal task is also
|
|
task_is_send (the classifier keys on the verb), so without this the composer fill would
|
|
TYPE the target text and POST it (measured live: delete tasks re-posted the marker)."""
|
|
return bool(P_DELETE_INTENT_RE.search(task or ""))
|
|
|
|
|
|
# Verbs that put something OUT into the world, as opposed to merely acting on a page. Deliberately
|
|
# narrower than task_is_send, which only means "not an informational ask" and so counts a plain
|
|
# "click the Search button": gating the skill store on THAT stopped the agent learning any click
|
|
# task at all, which is the whole speed mechanism.
|
|
P_PUBLISH_INTENT_RE = re.compile(
|
|
r"\b(post|submit|publish|send|tweet|comment|repl(?:y|ies)|dm|message)\b", re.I)
|
|
|
|
|
|
def is_publish_task(task: str) -> bool:
|
|
"""A task whose deliverable LEAVES the machine (a post, a reply, a message).
|
|
|
|
Keeps an unconfirmed write out of the skill store. Measured live on reddit: the composer filled,
|
|
`send_button_found=False`, the agent blind-tapped a coordinate, nothing posted, and a one-step
|
|
"skill" (click the body textbox) still got recorded for "create a text post and submit it".
|
|
Replaying that reports done in one turn while posting nothing, the same ghost the removal gate
|
|
already exists to stop.
|
|
|
|
The verbs are also ordinary NOUNS ("the top comment", "the first post", "the first reply"), so
|
|
an informational ask is excluded: without that, "read the top comment" scored as a publish and
|
|
the honesty gate told the user their send was never confirmed, on a task that never sent
|
|
anything. Failing safe here means at worst we skip a gate on a genuine write, never that we
|
|
call a perfectly good read a failure."""
|
|
if not P_PUBLISH_INTENT_RE.search(task or ""):
|
|
return False
|
|
# An explicit read-only directive settles it: the user said "do NOT submit anything", so nothing
|
|
# was ever supposed to leave, and demanding a send receipt turns a correct read into a reported
|
|
# failure. Measured live on reddit's own discovery probe ("Do NOT type or submit anything. Is
|
|
# the post title/body compose form present?"), which the informational heuristic below scored as
|
|
# a publish and this gate then failed. Reuses the SAME authority the send script declines on,
|
|
# rather than growing a second opinion that can drift away from it.
|
|
if browser_send_parse.is_readonly(task or ""):
|
|
return False
|
|
return not deliverable_is_informational("", task)
|
|
|
|
|
|
def deliverable_is_informational(summary: str, task: str = "") -> bool:
|
|
"""True if the run's final answer is GATHERED CONTENT (a list/report the model
|
|
extracted or judged), not a short action confirmation. A deterministic replay
|
|
reproduces clicks and navigations but CANNOT regenerate judged/collected
|
|
information, so recording a skill for such a run would make a thin shortcut
|
|
that replays the mechanical scaffolding and then falsely claims the whole task
|
|
is done (the 'find me 10 X' ghost). The task's ask decides when it's clear
|
|
(mixed asks count as informational); the summary's shape breaks ties, with the
|
|
mandatory OUTCOME line stripped first since boilerplate made every summary
|
|
look like a report and silently stopped all recording. Conservative +
|
|
FAIL-SAFE: when in doubt we DON'T record, so the worst case is a lost speedup
|
|
(re-run via the LLM), never a ghost completion."""
|
|
t = (task or "").strip()
|
|
if t:
|
|
if P_INFO_ASK_RE.search(t):
|
|
return True
|
|
if P_ACTION_ASK_RE.search(t):
|
|
return False
|
|
s = (summary or "").strip()
|
|
s = re.sub(r"OUTCOME:.*$", "", s, flags=re.S).strip()
|
|
if len(s) > 300:
|
|
return True
|
|
if s.count("\n") >= 2: # 3+ lines reads as a list/report, not a one-liner
|
|
return True
|
|
return False
|
|
|
|
|
|
def completion_is_honest(
|
|
action_log: list[dict], publish_task: bool = False, send_confirmed: bool = False,
|
|
) -> tuple[bool, str]:
|
|
"""Reality-check a run the model declared done. Returns (honest, reason).
|
|
|
|
Conservative by design (it can flip a 'completed' into an error, so it must
|
|
not cry wolf on a real success): it flags ONLY the unambiguous ghosts, a run
|
|
that took zero actions, one whose every state-changing action errored, or one
|
|
that only looked around (no action and no read returned content). A read-only
|
|
task stays honest as long as some read came back with content; a partially
|
|
erroring run that still landed a real action stays honest.
|
|
"""
|
|
# A publish task has exactly ONE deliverable: the thing leaving the machine. Clicks that
|
|
# "succeeded" are not evidence it went out. Measured live on reddit 2026-07-31: the composer
|
|
# filled, no send control was ever found (`send_button_found=False`), the agent blind-tapped a
|
|
# percentage coordinate, every action reported ok, and the user was told "Done, I sent it for
|
|
# you" while r/test never received a thing. Checked FIRST because it is the most serious lie we
|
|
# can tell. Note the direction of the residual risk: autosend sets send_confirmed the moment its
|
|
# click runs (a resend guard, not proof), so this can still let a bad send through, but it can
|
|
# never newly flag a run that had any send signal at all.
|
|
if publish_task and not send_confirmed:
|
|
return False, ("the send was never confirmed, so it may not have gone out; "
|
|
"check the page before trusting this")
|
|
if not action_log:
|
|
return False, "declared done without taking a single action"
|
|
actions = [a for a in action_log if a.get("tool") in P_PRODUCTIVE_TOOLS]
|
|
actions_ok = [a for a in actions if a.get("ok")]
|
|
reads_ok = [
|
|
a for a in action_log
|
|
if a.get("tool") in P_READ_TOOLS and a.get("ok")
|
|
and str(a.get("result_summary") or "").strip()
|
|
]
|
|
if actions and not actions_ok:
|
|
return False, "every state-changing action failed"
|
|
if not actions and not reads_ok:
|
|
return False, "only looked around: no action taken and no content read back"
|
|
return True, ""
|