[eric] browser: fast path sharpens tasks via aux brief and retries one recovery dispatch

This commit is contained in:
ciregenz
2026-06-05 13:00:49 -07:00
parent 8a714b5064
commit fe63ae63d7
3 changed files with 120 additions and 41 deletions
+26 -19
View File
@@ -3326,6 +3326,7 @@ class AgentManager:
# outcome). Conservative gates + a cheap aux classifier; any miss or
# error falls through to the normal loop.
use_fast_path = False
fast_brief = ""
if not hidden:
try:
from backend.apps.agents.browser import browser_fast_path
@@ -3335,22 +3336,23 @@ class AgentManager:
prompt, session.mode or "", session.dashboard_id, is_first_message, _extras,
):
from backend.apps.agents.providers.registry import get_api_type
use_fast_path = await browser_fast_path.classify_browser_only(
use_fast_path, fast_brief = await browser_fast_path.classify_and_brief(
prompt, load_settings(), get_api_type(session.model),
)
except Exception as e:
logger.warning(f"[browser-fast-path] gate error, normal path: {e}")
if use_fast_path:
task = asyncio.create_task(self._run_browser_fast_path(session_id, prompt, selected_browser_ids))
task = asyncio.create_task(self._run_browser_fast_path(session_id, prompt, selected_browser_ids, fast_brief))
else:
task = asyncio.create_task(self._run_agent_loop(session_id, prompt, images=images, context_paths=context_paths, forced_tools=forced_tools, attached_skills=attached_skills, selected_browser_ids=selected_browser_ids))
self.tasks[session_id] = task
async def _run_browser_fast_path(self, session_id: str, prompt: str, selected_browser_ids: list[str] | None):
async def _run_browser_fast_path(self, session_id: str, prompt: str, selected_browser_ids: list[str] | None, brief: str = ""):
"""Dispatch the browser sub-agent directly and reply with its outcome;
the orchestrator LLM never runs. stop_agent still works: it cancels
this task and the browser-agent child sessions it spawned."""
the orchestrator LLM never runs. A failed first dispatch gets ONE
informed recovery dispatch (the orchestrator's old retry role).
stop_agent still works: it cancels this task and the children."""
session = self.sessions.get(session_id)
if not session:
return
@@ -3358,21 +3360,26 @@ class AgentManager:
text = ""
try:
from backend.apps.agents.browser.browser_agent import run_browser_agents
from backend.apps.agents.browser import browser_fast_path
selected = [b for b in (selected_browser_ids or []) if b]
results = await run_browser_agents(
tasks=[{"task": prompt, "browser_id": selected[0] if selected else "", "url": ""}],
model=session.model,
dashboard_id=session.dashboard_id,
pre_selected_browser_ids=selected,
parent_session_id=session_id,
)
r = results[0] if results else {}
if isinstance(r, dict):
text = (r.get("summary") or "").strip()
if not text:
text = f"The browser agent couldn't complete this: {r.get('error') or 'unknown error'}"
else:
text = f"The browser agent couldn't complete this: {r}"
async def _dispatch(task_text: str) -> str:
results = await run_browser_agents(
tasks=[{"task": task_text, "browser_id": selected[0] if selected else "", "url": ""}],
model=session.model,
dashboard_id=session.dashboard_id,
pre_selected_browser_ids=selected,
parent_session_id=session_id,
)
r = results[0] if results else {}
return (r.get("summary") or "").strip() if isinstance(r, dict) else str(r or "")
text = await _dispatch(browser_fast_path.compose_task(prompt, brief))
if browser_fast_path.dispatch_failed(text):
logger.info(f"[browser-fast-path] first dispatch failed for {session_id}; one recovery dispatch")
text = await _dispatch(browser_fast_path.recovery_task(prompt, text))
if not text:
text = "The browser agent couldn't complete this and gave no report."
except asyncio.CancelledError:
raise
except Exception as e:
@@ -41,17 +41,24 @@ _CLASSIFIER_SYSTEM = (
"When a website or web app is the context, 'text/message/DM someone' means "
"sending the message inside that site, which is browsing. Treat 'text' as SMS "
"only when a phone number is given or no site is involved.\n"
"Answer YES if browsing alone fully completes the request.\n"
"Answer NO if any part clearly needs something a browser cannot do: local files "
"or folders, writing or running code, a terminal, creating documents or "
"spreadsheets, SMS to a phone number, or other desktop apps.\n"
"Plain conversation or questions answerable without visiting any site: NO.\n"
"Line 1 of your reply: YES if browsing alone fully completes the request. NO if "
"any part clearly needs something a browser cannot do: local files or folders, "
"writing or running code, a terminal, creating documents or spreadsheets, SMS "
"to a phone number, or other desktop apps. NO for plain conversation or "
"questions answerable without visiting any site.\n"
"Examples:\n"
"'go to maya's linkedin and text her thanks' -> YES\n"
"'open hacker news and tell me the top story' -> YES\n"
"'find the report on stripe.com and save it to my desktop' -> NO\n"
"'text 555-0102 that I'm late' -> NO\n"
"Output exactly one word: YES or NO."
"If line 1 is NO, reply with exactly the word NO and nothing else.\n"
"If line 1 is YES, follow it with a short browsing brief:\n"
"ENTRY: the best starting URL; use a direct deep/search URL when the site's "
"pattern is well known (LinkedIn people search is "
"https://www.linkedin.com/search/results/people/?keywords=NAME).\n"
"Then 3-6 numbered steps, one short action each.\n"
"Copy any text the user wants typed, sent, or posted EXACTLY, character for "
"character. Never invent names, values, or wording the user did not give."
)
@@ -72,8 +79,44 @@ def fast_path_eligible(
return bool(_BROWSY_RE.search(prompt))
def _parse_verdict(text: str) -> bool:
return text.strip().upper().startswith("YES")
def _parse_verdict_and_brief(text: str) -> tuple[bool, str]:
"""Line 1 carries the YES/NO; the rest is the optional routing brief."""
lines = (text or "").strip().splitlines()
if not lines or not lines[0].strip().upper().startswith("YES"):
return False, ""
brief = "\n".join(line for line in lines[1:] if line.strip()).strip()
return True, brief[:700]
def compose_task(prompt: str, brief: str) -> str:
"""User's words first and authoritative; the brief is advisory routing.
Skill replay keys on the parent's user message, so brief variance is safe."""
if not brief:
return prompt
return (
f"{prompt}\n\n"
"[routing brief from a fast pre-pass; follow it unless the live page disagrees]\n"
f"{brief}"
)
def dispatch_failed(summary: str) -> bool:
s = (summary or "").strip()
return not s or s.startswith("Error:") or "OUTCOME: NOT DONE" in s.upper()
def recovery_task(prompt: str, first_report: str) -> str:
"""One informed retry, replacing the orchestrator's recovery role. Verify-
first wording keeps a maybe-already-sent irreversible step from repeating."""
report = (first_report or "").strip()[:600] or "no report (the browser died)"
return (
"A previous browser attempt at this task did not finish. It reported:\n"
f"{report}\n\n"
f"Finish the task: {prompt}\n\n"
"If that attempt may have already performed an irreversible step "
"(send/submit/post/pay), FIRST verify on the page whether it happened; "
"if it did, do NOT repeat it, report DONE with that proof."
)
def _normalize_for_classifier(prompt: str) -> str:
@@ -85,8 +128,9 @@ def _normalize_for_classifier(prompt: str) -> str:
return re.sub(r"\btext(ing|ed|s)?\b", "message", prompt, flags=re.I)
async def classify_browser_only(prompt: str, settings, primary_api: str | None) -> bool:
"""One cheap aux call, timeboxed; any failure means NO (normal path)."""
async def classify_and_brief(prompt: str, settings, primary_api: str | None) -> tuple[bool, str]:
"""One cheap aux call returns the YES/NO verdict plus a routing brief (entry
URL + step outline), timeboxed; any failure means NO (normal path)."""
try:
from backend.apps.settings.credentials import get_anthropic_client_for_model
from backend.apps.agents.providers.registry import resolve_aux_model
@@ -98,17 +142,17 @@ async def classify_browser_only(prompt: str, settings, primary_api: str | None)
resp = await asyncio.wait_for(
client.messages.create(
model=aux_model,
max_tokens=4,
max_tokens=250,
temperature=0,
system=_CLASSIFIER_SYSTEM,
messages=[{"role": "user", "content": _normalize_for_classifier(prompt[:2000])}],
),
timeout=5.0,
timeout=8.0,
)
from backend.apps.agents.core.aux_llm import _safe_resp_text
verdict = _parse_verdict(_safe_resp_text(resp))
logger.info(f"[browser-fast-path] classifier: {'YES' if verdict else 'NO'}")
return verdict
verdict, brief = _parse_verdict_and_brief(_safe_resp_text(resp))
logger.info(f"[browser-fast-path] classifier: {'YES' if verdict else 'NO'} brief={len(brief)}ch")
return verdict, brief
except Exception as e:
logger.warning(f"[browser-fast-path] classifier unavailable, normal path: {e}")
return False
return False, ""
+34 -6
View File
@@ -1,7 +1,10 @@
from backend.apps.agents.browser.browser_fast_path import (
_normalize_for_classifier,
_parse_verdict,
_parse_verdict_and_brief,
compose_task,
dispatch_failed,
fast_path_eligible,
recovery_task,
)
@@ -27,11 +30,36 @@ def test_non_browsy_or_gated_messages_fall_through():
def test_verdict_parsing_is_strict():
assert _parse_verdict("YES")
assert _parse_verdict("yes, this is browser-only")
assert not _parse_verdict("NO")
assert not _parse_verdict("Maybe")
assert not _parse_verdict("")
ok, brief = _parse_verdict_and_brief("YES\nENTRY: https://news.ycombinator.com\n1. read top story")
assert ok and brief.startswith("ENTRY:") and "top story" in brief
assert _parse_verdict_and_brief("yes") == (True, "")
assert _parse_verdict_and_brief("NO") == (False, "")
assert _parse_verdict_and_brief("Maybe\nENTRY: x") == (False, "")
assert _parse_verdict_and_brief("") == (False, "")
long_brief = "YES\n" + "x" * 2000
assert len(_parse_verdict_and_brief(long_brief)[1]) == 700
def test_compose_task_keeps_user_words_first():
assert compose_task("go to hn", "") == "go to hn"
composed = compose_task("go to hn", "ENTRY: https://news.ycombinator.com")
assert composed.startswith("go to hn\n\n[routing brief")
assert composed.endswith("ENTRY: https://news.ycombinator.com")
def test_dispatch_failure_detection():
assert dispatch_failed("")
assert dispatch_failed("Error: browser card was deleted")
assert dispatch_failed("Could not find the thread. OUTCOME: NOT DONE - login wall")
assert not dispatch_failed("Sent it. OUTCOME: DONE - bubble visible at 12:05 PM")
def test_recovery_task_verifies_before_repeating():
t = recovery_task("text bob 'hi' on linkedin", "OUTCOME: NOT DONE - hung before confirming send")
assert "text bob 'hi' on linkedin" in t
assert "hung before confirming" in t
assert "do NOT repeat it" in t
assert "no report (the browser died)" in recovery_task("go to hn", "")
def test_text_normalizes_to_message_without_phone_number():