mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-07 10:17:43 +02:00
[eric] browser: fast path skips orchestrator for browser-only first messages
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user