diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index b4a7d6e9..496de96e 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -525,6 +525,10 @@ async def p_request_browser_approval( return decision +# 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() + + async def run_browser_agent( task: str, browser_id: str, @@ -587,8 +591,16 @@ async def run_browser_agent( whole point of front-loading). Best-effort; never raises.""" recs = [] try: - li = await execute_browser_tool("BrowserListInteractives", {}, browser_id, tab_id) - gt = await execute_browser_tool("BrowserGetText", {}, browser_id, tab_id) + # The two front-load reads are independent, so fire them together: the AX-tree list (slow, occlusion-filtered) and the text read overlap instead of adding up. return_exceptions keeps it best-effort, one read failing no longer discards the other. + li, gt = await asyncio.gather( + execute_browser_tool("BrowserListInteractives", {}, browser_id, tab_id), + execute_browser_tool("BrowserGetText", {}, browser_id, tab_id), + return_exceptions=True, + ) + if not isinstance(li, dict): + li = {} + if not isinstance(gt, dict): + gt = {} url = li.get("url") or gt.get("url") or label_url or "" parts = [] if li.get("text") and "error" not in li: @@ -630,6 +642,7 @@ async def run_browser_agent( from backend.apps.settings.credentials import get_anthropic_client_for_model from backend.apps.agents.providers.registry import ( find_builtin_model, + get_api_type, resolve_model_id_for_sdk, resolve_aux_model, ) @@ -759,7 +772,10 @@ async def run_browser_agent( if not p_aux_state["resolved"]: p_aux_state["resolved"] = True try: - aux_model, _ = await resolve_aux_model(browser_settings, preferred_tier="haiku") + # primary_api unlocks the registry's family-match + API-key branches; without it an OpenAI/Google key-only user gets a raise here and auto-scan + playbook learning silently die. + aux_model, _ = await resolve_aux_model( + browser_settings, preferred_tier="haiku", primary_api=get_api_type(model), + ) p_aux_state["model"] = aux_model p_aux_state["client"] = get_anthropic_client_for_model(browser_settings, aux_model) except Exception as e: @@ -2132,10 +2148,12 @@ async def run_browser_agent( # Tier-2 memory: on a substantive verified success, distill this run into the DURABLE strategy playbook (one cheap aux call, mem0-style distill+ reconcile). Fires for BOTH mechanical and judgment tasks, it's how the judgment ones (which can't be skills) still get faster/wiser next time. if browser_playbook.should_learn(honest, turn + 1): - try: - # App mode keys by the stable app id (the run had no URL host); web keys by the final host, which navigation may have changed. - rec_pb_host = browser_id if app_mode else browser_skills.host_of(last_seen_url) - if rec_pb_host: + async def p_distill_learning() -> None: + try: + # App mode keys by the stable app id (the run had no URL host); web keys by the final host, which navigation may have changed. + rec_pb_host = browser_id if app_mode else browser_skills.host_of(last_seen_url) + if not rec_pb_host: + return aux_client, aux_model = await p_get_aux_client() changed = await browser_playbook.distill_and_store( rec_pb_host, skill_key_task, latest_working_mem, summary, @@ -2151,8 +2169,12 @@ async def run_browser_agent( await ws_manager.send_to_session(session_id, "agent:message", { "session_id": session_id, "message": p_learn_msg.model_dump(mode="json"), }) - except Exception as e: - logger.debug(f"[browser-playbook] distill skipped: {e}") + except Exception as e: + 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) # 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: diff --git a/backend/tests/test_browser_agent_loop.py b/backend/tests/test_browser_agent_loop.py index 7de09eb2..47bada77 100644 --- a/backend/tests/test_browser_agent_loop.py +++ b/backend/tests/test_browser_agent_loop.py @@ -63,6 +63,17 @@ def p_rp(goal, mem="Share dialog is a cross-origin iframe; use the index list.") DOC_URL = "https://docs.google.com/document/d/abc/edit" +def p_run_settled(**kw): + """run_browser_agent then drain the backgrounded learning task; the distill + 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) + return r + return asyncio.run(p_go()) + + def p_install(monkeypatch, primary, aux): # local imports inside run_browser_agent resolve from these source modules import backend.apps.settings.settings as settings_mod @@ -71,10 +82,11 @@ def p_install(monkeypatch, primary, aux): import backend.apps.agents.agent_manager as am_mod monkeypatch.setattr(settings_mod, "load_settings", lambda: {"fake": True}, raising=True) - monkeypatch.setattr(reg_mod, "find_builtin_model", lambda m: object(), raising=True) + # a dict (not object()) so get_api_type's (entry or {}).get("api") works like the real registry rows + monkeypatch.setattr(reg_mod, "find_builtin_model", lambda m: {"api": "anthropic"}, raising=True) monkeypatch.setattr(reg_mod, "resolve_model_id_for_sdk", lambda m, s: "primary-x", raising=True) - async def p_aux_resolve(s, preferred_tier="haiku"): + async def p_aux_resolve(s, preferred_tier="haiku", primary_api=None): return ("aux-x", None) monkeypatch.setattr(reg_mod, "resolve_aux_model", p_aux_resolve, raising=True) @@ -938,9 +950,9 @@ def test_playbook_distills_on_success_survives_restart_and_seeds_next_run(monkey ]) pbaux = PBAux() p_install(monkeypatch, primary1, pbaux) - asyncio.run(BA.run_browser_agent( + p_run_settled( task="find design engineers", browser_id="b1", model="sonnet", initial_url=DOC_URL, - )) + ) assert pbaux.calls >= 1, "a substantive success must trigger the distill aux call" assert PB.get_playbook("docs.google.com"), "playbook recorded for the host" @@ -995,7 +1007,7 @@ def test_ambient_memory_signals_fire_calmly(monkeypatch): # Run 1: nothing learned yet -> NO recall line, but it learns -> closing line. p_install(monkeypatch, p_run(), PBAux()) monkeypatch.setattr(BA.ws_manager, "send_to_session", p_cap, raising=False) - asyncio.run(BA.run_browser_agent(task="find engineers", browser_id="b1", model="sonnet", initial_url=DOC_URL)) + p_run_settled(task="find engineers", browser_id="b1", model="sonnet", initial_url=DOC_URL) joined1 = " ".join(msgs) assert "Picking up what I learned" not in joined1, "no recall on the first-ever visit" assert "so I'm faster here next time" in joined1, "closing 'learned' line after first success"