diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 1676e6ce..7600e103 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -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: diff --git a/backend/apps/agents/browser/browser_fast_path.py b/backend/apps/agents/browser/browser_fast_path.py index 243ca19e..30ae2b86 100644 --- a/backend/apps/agents/browser/browser_fast_path.py +++ b/backend/apps/agents/browser/browser_fast_path.py @@ -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, "" diff --git a/backend/tests/test_browser_fast_path.py b/backend/tests/test_browser_fast_path.py index e3529367..2a265d2b 100644 --- a/backend/tests/test_browser_fast_path.py +++ b/backend/tests/test_browser_fast_path.py @@ -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():