[eric] browser: fast path skips orchestrator for browser-only first messages

This commit is contained in:
ciregenz
2026-06-05 12:02:17 -07:00
parent 1d4542bbf1
commit 8ee209a83c
3 changed files with 235 additions and 1 deletions
+75 -1
View File
@@ -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
@@ -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
+46
View File
@@ -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)