mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 17:57:43 +02:00
[eric] browser: fast path sharpens tasks via aux brief and retries one recovery dispatch
This commit is contained in:
@@ -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, ""
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user