From a01503638393f33b2cae651a8e1fb3dd8c803a4b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 2 Jun 2026 14:01:00 -0700 Subject: [PATCH] [eric] browser: prompt-cache the fixed prefix and push batching to cut first-run turns --- backend/apps/agents/browser/browser_agent.py | 39 +++++++++++++++++-- backend/apps/agents/browser/browser_schema.py | 8 ++++ backend/tests/test_browser_agent_loop.py | 27 ++++++++++++- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 34c7b159..7060d1f0 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -322,6 +322,17 @@ async def run_browser_agent( + prior_note ) + # Prompt-caching shapes built once: system as a single cached text block, + # and the last tool carrying the cache_control marker (Anthropic keys on the + # trailing marker, so one marker covers the whole tool array + system). + _cached_system = [{ + "type": "text", "text": run_system_prompt, + "cache_control": {"type": "ephemeral"}, + }] + _cached_tools = [dict(t) for t in BROWSER_TOOLS_SCHEMA] + if _cached_tools: + _cached_tools[-1] = {**_cached_tools[-1], "cache_control": {"type": "ephemeral"}} + user_msg = Message(role="user", content=task) session.messages.append(user_msg) await ws_manager.send_to_session(session_id, "agent:message", { @@ -429,8 +440,13 @@ async def run_browser_agent( response = await _cancellable(client.messages.create( model=api_model, max_tokens=4096, - system=run_system_prompt, - tools=BROWSER_TOOLS_SCHEMA, + # Cache the ~4k-token fixed prefix (system + tool schema) so it's + # reprocessed once, not on every turn: big TTFT + cost win on the + # first run, which is dominated by turns x per-turn prefill. The + # trailing cache_control marker is what Anthropic keys on; on + # non-Anthropic routes (9router) the marker is harmlessly ignored. + system=_cached_system, + tools=_cached_tools, messages=messages, )) if response is None: @@ -445,6 +461,13 @@ async def run_browser_agent( if hasattr(response, 'usage') and response.usage: session.tokens["input"] = session.tokens.get("input", 0) + (response.usage.input_tokens or 0) session.tokens["output"] = session.tokens.get("output", 0) + (response.usage.output_tokens or 0) + # Cache-read tokens prove the prompt cache is working (climbs + # after turn 1). Logged so the speed win is verifiable, not assumed. + _cr = getattr(response.usage, "cache_read_input_tokens", 0) or 0 + _cw = getattr(response.usage, "cache_creation_input_tokens", 0) or 0 + if _cr or _cw: + session.tokens["cache_read"] = session.tokens.get("cache_read", 0) + _cr + logger.info(f"[browser-perf] turn {turn}: cache_read={_cr} cache_write={_cw} input={response.usage.input_tokens}") assistant_content = [] text_parts = [] @@ -881,10 +904,18 @@ async def run_browser_agent( # the next identical task on this host runs via the no-LLM fast path. try: rec_host = browser_skills.host_of(last_seen_url) + _distilled = browser_skills.distill_steps(action_log) + logger.info( + f"[browser-skills] record attempt: host={rec_host!r} " + f"last_url={last_seen_url!r} action_tools={[a.get('tool') for a in action_log]} " + f"distilled={[s['tool'] for s in _distilled]}" + ) if browser_skills.record_skill(rec_host, task, action_log): logger.info(f"[browser-skills] learned skill for {rec_host} (future runs replay fast)") - except Exception: - pass + else: + logger.info(f"[browser-skills] NOT recorded (host empty or no robust steps)") + except Exception as e: + logger.warning(f"[browser-skills] record raised: {e}") agent_manager._sync_session_close(session) await ws_manager.send_to_session(session_id, "agent:status", { "session_id": session_id, diff --git a/backend/apps/agents/browser/browser_schema.py b/backend/apps/agents/browser/browser_schema.py index db159b41..46552911 100644 --- a/backend/apps/agents/browser/browser_schema.py +++ b/backend/apps/agents/browser/browser_schema.py @@ -438,6 +438,14 @@ SYSTEM_PROMPT = ( "5. **Coordinate-based fallback**; last resort: take a screenshot, identify the " "button visually, then click by approximate coords.\n\n" + "## Speed: minimize round-trips (this is the #1 driver of how fast you are)\n" + "Every turn is a slow model round-trip; tools themselves are fast. So the way " + "to be fast is FEWER TURNS, not faster tools. Once you can see the page, plan " + "the whole remaining sequence and emit it in ONE BrowserBatch instead of one " + "action per turn. A 3-step form (type, type, click Send) should be a single " + "batch turn, not three. Only break the batch when a later step genuinely " + "depends on reading what an earlier step produced.\n\n" + "## Batch known sequences with BrowserBatch\n" "When you have a known sequence of actions; typing then pressing Enter, " "swiping multiple times, clicking through pagination; emit them all in a " diff --git a/backend/tests/test_browser_agent_loop.py b/backend/tests/test_browser_agent_loop.py index 0251f332..d40ee713 100644 --- a/backend/tests/test_browser_agent_loop.py +++ b/backend/tests/test_browser_agent_loop.py @@ -268,6 +268,24 @@ def test_replay_falls_back_to_full_agent_when_a_step_fails(monkeypatch): assert len(primary.calls) > 0, "fell back to the full LLM agent" +def test_prompt_caching_markers_present(monkeypatch): + # The fixed system+tools prefix must carry cache_control so it's cached + # across turns (the first-run speed/cost win). Without the marker the + # ~4k-token prefix is reprocessed every turn. + BH._browser_history.clear(); BH._domain_notes.clear() + primary = FakeLLM([Resp([Blk("text", "done")], stop_reason="end_turn")]) + aux = FakeAux() + _install(monkeypatch, primary, aux) + asyncio.run(BA.run_browser_agent(task="hi", browser_id="bz", model="sonnet")) + call = primary.calls[0] + sys = call["system"] + assert isinstance(sys, list) and sys[-1]["cache_control"]["type"] == "ephemeral" + tools = call["tools"] + assert tools[-1].get("cache_control", {}).get("type") == "ephemeral" + # exactly one cache marker on the tools array (Anthropic allows <=4; we use 1) + assert sum(1 for t in tools if t.get("cache_control")) == 1 + + def test_prior_domain_hint_is_seeded_into_system_prompt(monkeypatch): BH._browser_history.clear(); BH._domain_notes.clear() BH.set_domain_note("google.com", "REMEMBERED: Share button is index 43; Tab into the dialog.") @@ -279,7 +297,12 @@ def test_prior_domain_hint_is_seeded_into_system_prompt(monkeypatch): task="open the doc", browser_id="b2", model="sonnet", initial_url=DOC_URL, )) assert primary.calls, "LLM should have been called" + # system is a cached content-block list (prompt caching); flatten its text system = primary.calls[0]["system"] - assert "Notes from a previous visit" in system - assert "REMEMBERED: Share button is index 43" in system + system_text = system if isinstance(system, str) else " ".join(b.get("text", "") for b in system) + assert "Notes from a previous visit" in system_text + assert "REMEMBERED: Share button is index 43" in system_text + # the cached system block carries the cache_control marker + if isinstance(system, list): + assert system[-1].get("cache_control", {}).get("type") == "ephemeral" assert len(aux.calls) == 0 # no exhaustion, no adjudication on a clean run