[eric] browser: per-domain hints, deterministic stagnation, and aux-LLM fallback

This commit is contained in:
ciregenz
2026-06-02 03:56:07 -07:00
parent 21360cd2ea
commit 82dcb6bb4e
4 changed files with 284 additions and 2 deletions
+103 -2
View File
@@ -29,7 +29,10 @@ from backend.apps.agents.browser.browser_loop import (
_LOOP_WINDOW_SIZE,
_detect_loop,
_hash_tool_call,
advance_stagnation,
stagnation_exhausted,
)
from backend.apps.agents.browser.browser_validator import adjudicate_stuck
from backend.apps.agents.browser.browser_schema import (
_ACTION_TOOLS_REQUIRING_REPORT,
ACTION_MAP,
@@ -271,6 +274,49 @@ async def run_browser_agent(
recent_tool_calls: list[tuple[str, str, str]] = []
loop_trigger_count = 0
# Stagnation state: busy-but-stuck detection (no URL change + failures
# across a run of actions), distinct from the exact-repeat loop above.
stagnation_streak = 0
stagnation_prev_url = ""
stagnation_prev_text = ""
aux_adjudicated = False # the one-shot stuck-adjudication fires at most once per run
# Lazily-resolved cheap aux client, used only for the rare stuck-adjudication
# call once deterministic nudging is exhausted. Provider-agnostic.
_aux_state = {"resolved": False, "client": None, "model": None}
async def _get_aux_client():
if not _aux_state["resolved"]:
_aux_state["resolved"] = True
try:
aux_model, _ = await resolve_aux_model(browser_settings, preferred_tier="haiku")
_aux_state["model"] = aux_model
_aux_state["client"] = get_anthropic_client_for_model(browser_settings, aux_model)
except Exception as e:
logger.warning(f"[browser-agent {session_id}] no aux model for adjudication: {e}")
return _aux_state["client"], _aux_state["model"]
# Latest goal from ReportProgress; threaded into BrowserListInteractives so
# the frontend floats goal-matching elements to the top of the list. Seeded
# with the task so the first listing (before any ReportProgress) is boosted.
current_next_goal = task
# Advisory per-domain hints: seed the system prompt with what a prior agent
# learned about this domain (if we know the domain at start), and keep the
# store fresh from each ReportProgress. Re-verify, never blindly trust.
start_domain = _extract_domain(initial_url) if initial_url else None
run_system_prompt = SYSTEM_PROMPT
if start_domain:
prior_note = browser_history.get_domain_note(start_domain)
if prior_note:
run_system_prompt = (
SYSTEM_PROMPT
+ f"\n\n## Notes from a previous visit to {start_domain}\n"
+ "Learned last time on this site. Use it as a head start, but "
+ "re-verify since the page may have changed:\n"
+ prior_note
)
user_msg = Message(role="user", content=task)
session.messages.append(user_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
@@ -309,7 +355,7 @@ async def run_browser_agent(
response = await _cancellable(client.messages.create(
model=api_model,
max_tokens=4096,
system=SYSTEM_PROMPT,
system=run_system_prompt,
tools=BROWSER_TOOLS_SCHEMA,
messages=messages,
))
@@ -434,6 +480,21 @@ async def run_browser_agent(
eval_prev = tu.input.get("evaluation_previous", "")
working_mem = tu.input.get("working_memory", "")
next_goal = tu.input.get("next_goal", "")
if next_goal:
current_next_goal = next_goal
# Distill the agent's own working memory into a per-domain
# hint for the next visit. Only persist when the run stayed
# on a SINGLE apex domain: working_memory is cumulative, so
# on a multi-domain run it would describe one site but get
# filed under whichever domain happens to be current.
note_domain = (
session.browser_domains[-1]
if session.browser_domains
else start_domain
)
single_domain = len(set(session.browser_domains)) <= 1
if note_domain and working_mem and single_domain:
browser_history.set_domain_note(note_domain, working_mem)
brain_text = (
f"📋 **Plan**\n"
f"_Previous_: {eval_prev}\n"
@@ -563,8 +624,11 @@ async def run_browser_agent(
continue
start = time.time()
tool_input = tu.input
if tu.name == "BrowserListInteractives" and current_next_goal:
tool_input = {**tu.input, "goal": current_next_goal}
result = await _cancellable(execute_browser_tool(
tu.name, tu.input, browser_id, tab_id,
tu.name, tool_input, browser_id, tab_id,
))
if result is None:
cancelled = True
@@ -612,6 +676,43 @@ async def run_browser_agent(
content_blocks = content_blocks + [
{"type": "text", "text": f"\n\n⚠️ {warning}"}
]
# Stagnation: busy-but-stuck (no URL change + failures across a
# run of actions), distinct from the exact-repeat loop above.
stagnation_streak, stagnation_prev_url, stagnation_prev_text, stag_nudge = advance_stagnation(
stagnation_streak, stagnation_prev_url, stagnation_prev_text, tu.name, result,
)
# Skip the nudge when the loud loop warning already fired this
# turn (avoid double-messaging), but the aux adjudication below
# is NOT gated on is_loop: repeated identical failures trip BOTH
# detectors, and that's exactly when the escape hatch is needed.
if stag_nudge and not is_loop:
logger.warning(
f"[browser-agent {session_id}] stagnation streak "
f"{stagnation_streak} on {tu.name}"
)
content_blocks = content_blocks + [
{"type": "text", "text": f"\n\n⚠️ {stag_nudge}"}
]
# Deterministic nudging exhausted: ONE cheap aux adjudication
# to suggest a concrete next step before we keep failing.
if stagnation_exhausted(stagnation_streak) and not aux_adjudicated:
aux_adjudicated = True
aux_client, aux_model = await _get_aux_client()
if aux_client and aux_model:
recent = "\n".join(
f"- {a['tool']} -> {str(a.get('result_summary', ''))[:120]}"
for a in action_log[-3:]
)
page_text = str(result.get("text") or result.get("error") or "")
guidance = await _cancellable(adjudicate_stuck(
aux_client, aux_model, current_next_goal, recent, page_text,
))
if guidance:
content_blocks = content_blocks + [
{"type": "text", "text": f"\n\n💡 Suggested next step: {guidance}"}
]
tool_results.append({
"type": "tool_result",
"tool_use_id": tu.id,
@@ -15,6 +15,25 @@ _browser_history: dict[str, list[dict]] = {}
# Cap history to prevent unbounded growth on long-lived browsers.
_MAX_HISTORY_MESSAGES = 30
# Per-apex-domain advisory notes, distilled from the agent's own ReportProgress
# working_memory. Process-lifetime only (never written to disk); seeds a later
# agent on the same domain so it skips re-learning the same quirks. Advisory
# text only, never auto-executed.
_domain_notes: dict[str, str] = {}
_MAX_DOMAIN_NOTE_CHARS = 600
def get_domain_note(domain: str) -> str:
"""Return the advisory note for a domain, or empty string if none."""
return _domain_notes.get(domain, "")
def set_domain_note(domain: str, note: str) -> None:
"""Store/overwrite the advisory note for a domain (trimmed + capped)."""
if not domain or not note or not note.strip():
return
_domain_notes[domain] = note.strip()[:_MAX_DOMAIN_NOTE_CHARS]
def clear_browser_history(browser_id: str) -> None:
"""Drop cached conversation history for a browser (e.g. when it's closed)."""
+105
View File
@@ -72,3 +72,108 @@ _LOOP_WARNING_TEXT = (
"(3) use BrowserPressKey for keyboard shortcuts if the site supports them, "
"or (4) call RequestHumanIntervention if you genuinely cannot proceed."
)
# --- 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".
_STAGNATION_NEUTRAL_TOOLS = _LOOP_DETECTION_EXCLUDED_TOOLS
_STAGNATION_ESCALATION_AT = 3
_STAGNATION_MAX = 5
_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 _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 _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
_STAGNATION_NUDGE = (
"NO PROGRESS: your last {streak} actions changed nothing on the page and "
"looked like failures. STOP repeating this approach. Walk DOWN the strategy "
"ladder: switch from CSS clicks to BrowserListInteractives + "
"BrowserClickIndex; if that already failed, try BrowserPressKey (Tab/Enter) "
"or use BrowserEvaluate to find the element by its visible text; take ONE "
"BrowserScreenshot to re-orient if you are unsure what's on screen."
)
def stagnation_nudge(streak: int) -> str:
base = _STAGNATION_NUDGE.format(streak=streak)
if streak >= _STAGNATION_MAX:
base += (
" If nothing here works, call RequestHumanIntervention instead of "
"continuing to fail."
)
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 _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
@@ -0,0 +1,57 @@
"""
Last-resort adjudication for a stuck browser sub-agent.
Deterministic stagnation detection (browser_loop) handles the common cases for
free. When it's exhausted (a run of actions with no progress despite escalating
nudges), we make ONE cheap aux-tier LLM call to suggest a concrete next step.
It is rare by construction, so the cost stays near zero. The model + client are
resolved provider-agnostically by the caller (cheap tier of whatever provider
the user has connected), so nothing here hardcodes Anthropic/Haiku.
"""
import logging
logger = logging.getLogger(__name__)
_ADJUDICATION_PROMPT = (
"A browser automation agent is stuck: its recent actions produced no "
"progress on the page.\n\n"
"GOAL: {goal}\n\n"
"RECENT ACTIONS (most recent last):\n{recent}\n\n"
"CURRENT PAGE (truncated):\n{page}\n\n"
"In 2 to 3 sentences, give the single most promising concrete next step. "
"Prefer, in order: a different element or selector, a keyboard shortcut "
"(Tab then Enter), scrolling to reveal a hidden control, or calling "
"RequestHumanIntervention if this is a login / captcha / 2FA wall. Be "
"specific and brief; do not restate the goal."
)
def _extract_text(response) -> str:
"""Pull the text out of an Anthropic-style response object."""
parts = []
for block in getattr(response, "content", None) or []:
if getattr(block, "type", None) == "text" and getattr(block, "text", ""):
parts.append(block.text.strip())
return " ".join(p for p in parts if p).strip()
async def adjudicate_stuck(
client, model: str, goal: str, recent_actions: str, page_text: str,
) -> str:
"""One cheap aux call returning concrete guidance, or "" on any failure."""
prompt = _ADJUDICATION_PROMPT.format(
goal=(goal or "(unknown)")[:400],
recent=(recent_actions or "(none)")[:1200],
page=(page_text or "(empty)")[:1500],
)
try:
response = await client.messages.create(
model=model,
max_tokens=300,
messages=[{"role": "user", "content": prompt}],
)
except Exception as e:
logger.warning(f"[browser-validator] adjudication call failed: {e}")
return ""
return _extract_text(response)