diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 2d72e15d..08f41a02 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -527,7 +527,7 @@ async def p_request_browser_approval( # Background learning tasks (playbook distill) held by strong ref; asyncio only weak-refs tasks, and a GC'd task dies silently mid-distill. -p_learn_tasks: set[asyncio.Task] = set() +learn_tasks: set[asyncio.Task] = set() async def run_browser_agent( @@ -2043,6 +2043,9 @@ async def run_browser_agent( f"[browser-agent {session_id}] browser card {browser_id} is unusable " f"({card_gone_streak} consecutive gone/hung results); aborting fast" ) + if os.environ.get("OSW_DEADCARD_EVICT") == "1": + DEAD_CARDS.add(browser_id) + logger.info(f"[browser-agent] {browser_id} marked dead; same-host reuse will skip it") break if cancel_event.is_set(): @@ -2189,8 +2192,8 @@ async def run_browser_agent( logger.debug(f"[browser-playbook] distill skipped: {e}") # Learning is advisory to FUTURE runs, so the user's reply must not wait on this aux call; it used to sit between "done" and the reply. p_lt = asyncio.create_task(p_distill_learning()) - p_learn_tasks.add(p_lt) - p_lt.add_done_callback(p_learn_tasks.discard) + learn_tasks.add(p_lt) + p_lt.add_done_callback(learn_tasks.discard) # The model asked to leave the browser open because the deliverable lives on the page (a video playing, a page to read). Pin the card so the auto-close on parent finish skips it. Only on honest success: never pin a broken or ghost run open. The keep broadcast lands before the parent reaches terminal state (it awaits this run), so the frontend has the flag set before any close path runs. if honest and done_keep_open and dashboard_id: try: @@ -2255,6 +2258,8 @@ async def run_browser_agent( # Cards a sub-agent is actively driving in this process. Reuse must never hand two agents one webview (their commands would interleave into chaos). ACTIVE_AGENT_CARDS: set[str] = set() +# Cards a run declared unusable (gone/hung streak); reuse must not resurrect them or every retry inherits the wedge. +DEAD_CARDS: set[str] = set() # find+claim+create must be one critical section or two parallel dispatches race to claim the same idle card (or both miss and double-create). p_card_pick_lock = asyncio.Lock() @@ -2277,7 +2282,7 @@ def find_reusable_card(dashboard_id: str, url: str, parent_session_id: str | Non own, orphan = "", "" for bid, card in cards.items(): spawned = getattr(card, "spawned_by", None) - if not spawned or bid in ACTIVE_AGENT_CARDS: + if not spawned or bid in ACTIVE_AGENT_CARDS or bid in DEAD_CARDS: continue if browser_skills.host_of(getattr(card, "url", "") or "") != want: continue @@ -2386,6 +2391,19 @@ async def run_browser_agents( await execute_browser_tool("BrowserNavigate", {"url": url}, browser_id) except Exception: pass + elif os.environ.get("OSW_PRELUDE_TRIM") == "1": + # Poll until the mounting card serves real page text instead of a blind 2s; capped, so the worst case is the old wait plus one probe. + p_mount_t0 = time.monotonic() + while time.monotonic() - p_mount_t0 < 2.5: + try: + p_probe = await execute_browser_tool("BrowserGetText", {}, browser_id) + if (isinstance(p_probe, dict) and not p_probe.get("error") + and len(str(p_probe.get("text") or "")) > 200): + break + except Exception: + pass + await asyncio.sleep(0.25) + logger.info(f"[browser-cold] mount poll {int((time.monotonic() - p_mount_t0) * 1000)}ms for {browser_id}") else: await asyncio.sleep(2.0) elif browser_id and not app_mode: @@ -2393,6 +2411,10 @@ async def run_browser_agents( is_pre_selected = browser_id in pre_selected p_nav_url = url or ("" if reused else entry_url) + # The fresh card already opened AT entry_url, so the pre-loop nav is a full second page load of the same page; trim mode drops it (perceive reads the mounting page, and the loop can still navigate itself if that read comes up empty). + if (os.environ.get("OSW_PRELUDE_TRIM") == "1" and not reused and not url + and entry_url and p_nav_url == entry_url): + p_nav_url = "" try: return await run_browser_agent( task=task_text, diff --git a/backend/apps/agents/browser/browser_fast_read.py b/backend/apps/agents/browser/browser_fast_read.py index 6968c872..dc13aaad 100644 --- a/backend/apps/agents/browser/browser_fast_read.py +++ b/backend/apps/agents/browser/browser_fast_read.py @@ -8,8 +8,10 @@ old path, never a wrong answer from a thin read. import asyncio import logging +import os import re import time +from urllib.parse import urljoin logger = logging.getLogger(__name__) @@ -26,6 +28,53 @@ P_ANSWER_SYSTEM = ( "exactly the single word INSUFFICIENT." ) +# One-hop mode: same contract plus a FOLLOW escape so a link-deep answer costs one more fetch instead of a full browser dispatch. +P_HOP_SYSTEM = P_ANSWER_SYSTEM + ( + "\nEXCEPTION: if the page text is insufficient but exactly one of the " + "numbered links clearly leads to the page that would contain the answer, " + "reply with exactly 'FOLLOW ' and nothing else." +) +P_FOLLOW_RE = re.compile(r"^FOLLOW\s+(\d+)\s*$", re.I) +P_LINK_RE = re.compile(r"]*?href=[\"'](?!javascript:|#|mailto:)([^\"'>]+)[\"'][^>]*>(.*?)", re.I | re.S) +P_MAX_LINKS = 60 + + +def hop_enabled() -> bool: + return os.environ.get("OSW_FASTREAD_HOP") == "1" + + +def extract_links(html: str, base_url: str) -> list[tuple[str, str]]: + """(anchor text, absolute url) pairs, deduped, capped. Text-less anchors are + useless to the picker so they're dropped.""" + out: list[tuple[str, str]] = [] + seen: set[str] = set() + for href, inner in P_LINK_RE.findall(html or ""): + text = re.sub(r"<[^>]+>", " ", inner) + text = re.sub(r"\s+", " ", text).strip() + if not text: + continue + absolute = urljoin(base_url, href.strip()) + if not absolute.startswith(("http://", "https://")) or absolute in seen: + continue + seen.add(absolute) + out.append((text[:80], absolute)) + if len(out) >= P_MAX_LINKS: + break + return out + + +def format_link_menu(links: list[tuple[str, str]]) -> str: + return "\n".join(f"{i + 1}. {text} -> {url}" for i, (text, url) in enumerate(links)) + + +def parse_follow(answer: str, links: list[tuple[str, str]]) -> str: + """The hop URL the aux picked, or ''. Out-of-range picks are ''.""" + m = P_FOLLOW_RE.match((answer or "").strip()) + if not m: + return "" + idx = int(m.group(1)) - 1 + return links[idx][1] if 0 <= idx < len(links) else "" + def extract_entry_url(brief: str) -> str: m = P_ENTRY_RE.search(brief or "") @@ -40,6 +89,45 @@ def page_is_thin(text: str) -> bool: return len(body.strip()) < P_MIN_PAGE_CHARS +async def fetch_page_text(url: str, prompt: str) -> str: + from backend.apps.agents.tools.web import WebFetchTool + + parts = await asyncio.wait_for( + WebFetchTool().execute({"url": url, "prompt": prompt}, None), + timeout=12.0, + ) + return "\n".join(p.get("text", "") for p in parts if p.get("type") == "text") + + +async def fetch_raw_links(url: str) -> list[tuple[str, str]]: + """Raw-HTML link harvest for the hop menu; best-effort, empty on any miss.""" + try: + from backend.apps.agents.tools.ssrf_guard import safe_fetch + + resp = await asyncio.wait_for( + safe_fetch(url, method="GET", + headers={"User-Agent": "Mozilla/5.0 (Macintosh) AppleWebKit/537.36"}, + timeout=8.0), + timeout=10.0, + ) + return extract_links(resp.text, url) + except Exception: + return [] + + +async def ask_aux(client, aux_model: str, system: str, content: str) -> str: + from backend.apps.agents.core.aux_llm import safe_resp_text + + resp = await asyncio.wait_for( + client.messages.create( + model=aux_model, max_tokens=500, temperature=0, system=system, + messages=[{"role": "user", "content": content}], + ), + timeout=15.0, + ) + return safe_resp_text(resp).strip() + + async def try_fast_read(prompt: str, brief: str, settings, primary_api: str | None) -> str | None: """Answer text on success; None means fall back to the browser leg.""" entry = extract_entry_url(brief) @@ -47,45 +135,52 @@ async def try_fast_read(prompt: str, brief: str, settings, primary_api: str | No logger.info("[browser-fast-read] no ENTRY url in brief; browser fallback") return None try: - from backend.apps.agents.tools.web import WebFetchTool - + hop = hop_enabled() t0 = time.monotonic() - parts = await asyncio.wait_for( - WebFetchTool().execute({"url": entry, "prompt": prompt}, None), - timeout=12.0, - ) - text = "\n".join(p.get("text", "") for p in parts if p.get("type") == "text") + if hop: + text, links = await asyncio.gather( + fetch_page_text(entry, prompt), fetch_raw_links(entry), + ) + else: + text, links = await fetch_page_text(entry, prompt), [] fetch_ms = int((time.monotonic() - t0) * 1000) if page_is_thin(text): logger.info(f"[browser-fast-read] thin/errored read of {entry} ({len(text)}ch in {fetch_ms}ms); browser fallback") return None - logger.info(f"[browser-fast-read] fetched {entry}: {len(text)}ch in {fetch_ms}ms") + logger.info(f"[browser-fast-read] fetched {entry}: {len(text)}ch in {fetch_ms}ms (links={len(links)})") from backend.apps.settings.credentials import get_anthropic_client_for_model from backend.apps.agents.providers.registry import resolve_aux_model - from backend.apps.agents.core.aux_llm import safe_resp_text aux_model, _ = await resolve_aux_model( settings, preferred_tier="haiku", primary_api=primary_api, ) client = get_anthropic_client_for_model(settings, aux_model) t1 = time.monotonic() - resp = await asyncio.wait_for( - client.messages.create( - model=aux_model, - max_tokens=500, - temperature=0, - system=P_ANSWER_SYSTEM, - messages=[{ - "role": "user", - "content": f"Request: {prompt}\n\nPage text from {entry}:\n{text[:P_MAX_PAGE_CHARS]}", - }], - ), - timeout=15.0, - ) - answer = safe_resp_text(resp).strip() + content = f"Request: {prompt}\n\nPage text from {entry}:\n{text[:P_MAX_PAGE_CHARS]}" + if hop and links: + content += f"\n\nNumbered links found on the page:\n{format_link_menu(links)}" + answer = await ask_aux(client, aux_model, P_HOP_SYSTEM if links else P_ANSWER_SYSTEM, content) answer_ms = int((time.monotonic() - t1) * 1000) - if not answer or answer.upper().startswith("INSUFFICIENT"): + + hop_url = parse_follow(answer, links) if hop and links else "" + if hop_url: + t2 = time.monotonic() + hop_text = await fetch_page_text(hop_url, prompt) + if page_is_thin(hop_text): + logger.info(f"[browser-fast-read] hop to {hop_url} was thin; browser fallback") + return None + answer = await ask_aux( + client, aux_model, P_ANSWER_SYSTEM, + f"Request: {prompt}\n\nPage text from {hop_url}:\n{hop_text[:P_MAX_PAGE_CHARS]}", + ) + logger.info( + f"[browser-fast-read] followed link {hop_url} " + f"(+{int((time.monotonic() - t2) * 1000)}ms hop)" + ) + entry = hop_url + + if not answer or answer.upper().startswith("INSUFFICIENT") or P_FOLLOW_RE.match(answer): logger.info(f"[browser-fast-read] aux found page insufficient ({answer_ms}ms); browser fallback") return None logger.info(f"[browser-fast-read] answered in {answer_ms}ms ({len(answer)}ch, model={aux_model})") diff --git a/backend/tests/test_browser_agent_loop.py b/backend/tests/test_browser_agent_loop.py index eb6fcefe..100102ce 100644 --- a/backend/tests/test_browser_agent_loop.py +++ b/backend/tests/test_browser_agent_loop.py @@ -83,8 +83,8 @@ def p_run_settled(**kw): no longer blocks the reply path, so tests asserting its effects must settle it.""" async def p_go(): r = await BA.run_browser_agent(**kw) - if BA.p_learn_tasks: - await asyncio.gather(*list(BA.p_learn_tasks), return_exceptions=True) + if BA.learn_tasks: + await asyncio.gather(*list(BA.learn_tasks), return_exceptions=True) return r return asyncio.run(p_go())