From 8ee209a83cd8be28ba6018aa16093e3fcd926414 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 5 Jun 2026 12:02:17 -0700 Subject: [PATCH] [eric] browser: fast path skips orchestrator for browser-only first messages --- backend/apps/agents/agent_manager.py | 76 +++++++++++- .../apps/agents/browser/browser_fast_path.py | 114 ++++++++++++++++++ backend/tests/test_browser_fast_path.py | 46 +++++++ 3 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 backend/apps/agents/browser/browser_fast_path.py create mode 100644 backend/tests/test_browser_fast_path.py diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index b0aea358..1676e6ce 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -3320,9 +3320,83 @@ class AgentManager: "session": session.model_dump(mode="json"), }) - 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)) + # Browser fast path: a plainly browser-only first message skips the + # orchestrator LLM entirely (it was ~2/3 of the token bill on these + # tasks, spent deciding "delegate to a browser" and restating the + # outcome). Conservative gates + a cheap aux classifier; any miss or + # error falls through to the normal loop. + use_fast_path = False + if not hidden: + try: + from backend.apps.agents.browser import browser_fast_path + _extras = bool(images or context_paths or forced_tools or attached_skills + or len(selected_browser_ids or []) > 1) + if browser_fast_path.fast_path_eligible( + 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( + 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)) + 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): + """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.""" + session = self.sessions.get(session_id) + if not session: + return + logger.info(f"[browser-fast-path] direct dispatch for session {session_id}") + text = "" + try: + from backend.apps.agents.browser.browser_agent import run_browser_agents + 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}" + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning(f"[browser-fast-path] dispatch failed: {e}") + text = f"The browser agent couldn't complete this: {e}" + + asst_msg = Message(role="assistant", content=text, branch_id=session.active_branch_id) + session.messages.append(asst_msg) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, + "message": asst_msg.model_dump(mode="json"), + }) + session.status = "completed" + session.closed_at = datetime.now() + await ws_manager.send_to_session(session_id, "agent:status", { + "session_id": session_id, + "status": "completed", + "session": session.model_dump(mode="json"), + }) + try: + _save_session(session_id, session.model_dump(mode="json")) + except Exception as e: + logger.warning(f"Failed to snapshot session {session_id}: {e}") + async def stop_agent(self, session_id: str): """Stop a running agent and all its browser-agent children.""" # Stop children first so browser agents get cancelled before parent diff --git a/backend/apps/agents/browser/browser_fast_path.py b/backend/apps/agents/browser/browser_fast_path.py new file mode 100644 index 00000000..243ca19e --- /dev/null +++ b/backend/apps/agents/browser/browser_fast_path.py @@ -0,0 +1,114 @@ +""" +Browser fast path: skip the orchestrator for plainly browser-only requests. + +The orchestrator LLM is ~2/3 of the token bill on a single-browser task and +adds two model turns of latency, all to decide "delegate this to a browser +agent" and then restate the agent's own outcome. When the request is clearly +just browsing, dispatch the browser sub-agent directly and let its OUTCOME +line be the reply. + +Three gates, all conservative; any miss falls through to the orchestrator: +1. eligibility: first message of an agent session on a dashboard, no + attachments/images/skills/forced tools (those need the orchestrator). +2. a zero-cost wordlist prefilter, so non-browsy chats never pay the + classifier's latency. +3. a cheap-tier aux YES/NO classifier (provider-agnostic, timeboxed); only + an unambiguous YES takes the fast path. +""" + +import asyncio +import logging +import re + +logger = logging.getLogger(__name__) + +# Zero-cost smell test: only prompts that mention the web at all are worth a +# classifier call. False negatives just take the normal path. +_BROWSY_RE = re.compile( + r"https?://|www\.|\b[a-z0-9-]+\.(com|org|net|io|co|ai|dev|app)\b" + r"|\b(browse|browser|website|web ?page|webpage|site|url|tab)\b" + r"|\b(go to|open|visit|navigate|log ?in|sign ?in|search on|look up on|check on)\b" + r"|\b(linkedin|twitter|x\.com|facebook|instagram|reddit|youtube|amazon|gmail|github" + r"|google|wikipedia|hacker ?news|tiktok|tinder|slack|notion|ebay|etsy|zillow|airbnb)\b", + re.I, +) + +_CLASSIFIER_SYSTEM = ( + "You route requests to a web-browsing agent. It drives a real signed-in browser: " + "navigating sites, reading or extracting or counting what is on pages, clicking, " + "typing, and acting inside web apps (sending messages on LinkedIn or any site, " + "posting, ordering, booking, filling forms).\n" + "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" + "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." +) + + +def fast_path_eligible( + prompt: str, + mode: str, + dashboard_id: str | None, + is_first_message: bool, + has_attachments: bool, +) -> bool: + """Pure gate: cheap, no I/O. Follow-ups are excluded because the sub-agent + only receives the prompt text; the orchestrator carries the history a + follow-up usually leans on.""" + if mode != "agent" or not dashboard_id or not is_first_message or has_attachments: + return False + if not prompt or not prompt.strip(): + return False + return bool(_BROWSY_RE.search(prompt)) + + +def _parse_verdict(text: str) -> bool: + return text.strip().upper().startswith("YES") + + +def _normalize_for_classifier(prompt: str) -> str: + """Haiku reads bare 'text him' as SMS even with a site as context. In the + browsy-prefiltered pool, text-with-no-phone-number is in-site messaging, + so spell it out for the small model. Only the classifier sees this.""" + if re.search(r"\d{7,}", prompt): + return prompt + 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).""" + try: + from backend.apps.settings.credentials import get_anthropic_client_for_model + from backend.apps.agents.providers.registry import resolve_aux_model + + aux_model, _ = await resolve_aux_model( + settings, preferred_tier="haiku", primary_api=primary_api, + ) + client = get_anthropic_client_for_model(settings, aux_model) + resp = await asyncio.wait_for( + client.messages.create( + model=aux_model, + max_tokens=4, + temperature=0, + system=_CLASSIFIER_SYSTEM, + messages=[{"role": "user", "content": _normalize_for_classifier(prompt[:2000])}], + ), + timeout=5.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 + except Exception as e: + logger.warning(f"[browser-fast-path] classifier unavailable, normal path: {e}") + return False diff --git a/backend/tests/test_browser_fast_path.py b/backend/tests/test_browser_fast_path.py new file mode 100644 index 00000000..e3529367 --- /dev/null +++ b/backend/tests/test_browser_fast_path.py @@ -0,0 +1,46 @@ +from backend.apps.agents.browser.browser_fast_path import ( + _normalize_for_classifier, + _parse_verdict, + fast_path_eligible, +) + + +def test_browsy_first_messages_are_eligible(): + for p in ( + "go to tyler chen's linkedin hes in entrepreneurs first and text him 'hi'", + "open hacker news and tell me the top story", + "look up on amazon how much a herman miller aeron costs", + "check https://example.com/pricing and summarize the tiers", + ): + assert fast_path_eligible(p, "agent", "dash1", True, False), p + + +def test_non_browsy_or_gated_messages_fall_through(): + assert not fast_path_eligible("write me a poem about autumn", "agent", "dash1", True, False) + assert not fast_path_eligible("fix the bug in agent_manager.py", "agent", "dash1", True, False) + browsy = "open hacker news and tell me the top story" + assert not fast_path_eligible(browsy, "chat", "dash1", True, False) + assert not fast_path_eligible(browsy, "agent", None, True, False) + assert not fast_path_eligible(browsy, "agent", "dash1", False, False) + assert not fast_path_eligible(browsy, "agent", "dash1", True, True) + assert not fast_path_eligible("", "agent", "dash1", True, False) + + +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("") + + +def test_text_normalizes_to_message_without_phone_number(): + assert ( + _normalize_for_classifier("go to maya's linkedin and text her thanks") + == "go to maya's linkedin and message her thanks" + ) + assert _normalize_for_classifier("keep texting until he replies").startswith("keep message") + sms = "text 4085551234 saying im running late" + assert _normalize_for_classifier(sms) == sms + count = "count messages containing the exact text r10-os" + assert "message r10-os" in _normalize_for_classifier(count)